Password Management
Overview
The CMS offers four password paths, covering both self-service recovery and administrator-assisted resets. All of them live under the CMS API's user module.
| Use case | Entry point | Who uses it |
|---|---|---|
| Forgot password (request a reset link) | /forgot-password page | Anyone, no sign-in required |
| Set or reset a password from an email link | /reset-password page | Users who received the link |
| Change your own password | Modal from the profile menu in the top bar | Signed-in users |
| Reset another user's password | The users table in User Management | Administrators |
One detail worth knowing about the reset link: the token it carries is not a JWT but a base64-encoded JSON payload produced by the server. It contains the user id, a reference code, an expiry date and an isNewAccount flag. The frontend decodes it and checks the expiry itself first, then asks the server whether the reference code is still valid.
The isNewAccount flag switches the wording on screen between "create your password" — for users whose accounts were created by an administrator — and "reset your password" for existing users who forgot theirs.
Password rules
The reset-password page and the change-password modal share the same six criteria:
- Between 8 and 16 characters
- At least one uppercase letter
- At least one lowercase letter
- At least one special character
- At least one digit
- English characters only
Business Flow
1. Forgot password
- The user opens
/forgot-passwordand enters the email they registered with. A help popover next to the label explains the requirements. - The submit button stays disabled until the address is in a valid format.
- On submit, the app calls
POST /user/forgot-passwordto have the server send a reset link. - On success a confirmation modal states that the link has been sent, highlighting the email address in the message. Confirming it returns the user to
/login. - If the address is not found, an inline error appears below the field while the entered value is preserved.
2. Set or reset a password from the link
- The user clicks the link in the email, opening
/reset-passwordwith a token. - The app validates the token in order:
- Not base64-shaped — a modal reports an invalid link.
- Cannot be decoded — the link is treated as unusable.
- Past the expiry date carried in the token — treated as expired without contacting the server.
- Still valid — call
GET /user/reset-password/{code}to confirm with the server that the reference code is still usable.
- A loading screen is shown while validation runs. If the link is unusable, an error screen appears with a button back to login, worded differently depending on whether this is a first-time password creation or a reset.
- Once the token passes, the form appears with a new-password field, a confirmation field, and the six-item checklist that updates as the user types.
- The submit button stays disabled until both fields are filled, no validation errors remain, and every criterion is met.
- On submit, the new password is sent together with the user id and reference code to
POST /user/reset-password, and the user is returned to/login. - On failure the app maps the server's error code to a readable message. The common cases are an expired or invalid token, and reusing one of the last ten passwords.
3. Change your own password
- A signed-in user clicks their avatar in the top bar and selects "Change password".
- The modal presents three fields — current password, new password and confirmation — alongside a card listing the six criteria.
- The checklist updates in real time (grey before typing, green when met, red when not), and a mismatch between the two new-password fields is flagged immediately.
- The submit button becomes available once every condition is satisfied; the app then calls
POST /user/change-password. - On success a confirmation message appears and the modal closes with all fields cleared. On failure the server's error message is displayed.
4. Administrator reset for another user
- In the User Management table, each row has a key icon for triggering a password reset.
- Clicking it opens a confirmation prompt, guarding against accidental clicks.
- On confirmation the app calls
POST /user/{userId}/admin-reset-passwordand reports the result. - The server then emails a reset link to that user, who continues through flow 2 above.
Key Screens & Components
Forgot password screen
/forgot-password presents a single email field. The logic that controls the submit button's disabled state is isolated in its own helper, and the confirmation modal is shared with other screens. Its message supports both emphasis and line breaks.
Reset password screen
/reset-password has three display states — validating, unusable link, and ready to set a password. The container handles decoding the token, checking the expiry, querying the server, and translating error codes.
Password checklist component
PasswordChecklist (src/components/common/password/password-checklist.tsx) bundles the password field, the confirmation field and the six-item checklist, and reports back whether all criteria are met so the calling screen can enable or disable its submit button. It is shared between the reset-password page and the change-password modal.
Change password modal
Invoked from the profile menu in the top bar, this modal is reachable from every screen that has a top bar, and validates all conditions in real time as the user types.
Related API endpoints
All password functions live in the user management service (src/services/user-management.service.ts) under the user prefix.
| Operation | Endpoint |
|---|---|
| Request a reset link | POST /user/forgot-password |
| Validate the reference code in a link | GET /user/reset-password/{code} |
| Save a new password from a link | POST /user/reset-password |
| Change your own password | POST /user/change-password |
| Administrator-triggered reset | POST /user/{userId}/admin-reset-password |
Dependencies
- Login — every path ends by returning the user to
/login. - User Management — creating a user sends them a link carrying the
isNewAccountflag, which switches the screen into "create password" mode. The administrator reset button also lives in that table. - Sign-up — uses a different set of password criteria from the reset page, which is worth keeping in mind when communicating with users.
- Access control — the forgot- and reset-password pages are public, the change-password modal requires an active session, and the administrator reset button sits on a page that requires view permission on user data.
- Technical note — all password functions go through the shared axios instance, including the ones called from public pages, which means an HTTP 401 response will trigger the global sign-out interceptor.
Backend Details (CMS API)
A common source of confusion: all password routes live under /api/user/..., but the code that serves them sits in the auth module, not the user module. When the system was ported from its previous version, the password paths were carried over together with auth. The password-history module is separate and deliberately exposes no routes of its own — it exists purely for other modules (user management and sign-up) to call.
Rate limits actually enforced per endpoint
These are invisible from the frontend and explain why rapid repeat submissions get rejected.
| Endpoint | Limit | Token required |
|---|---|---|
POST /api/user/forgot-password | 3 per 10 seconds | No |
POST /api/user/reset-password | 3 per 10 seconds | No |
GET /api/user/reset-password/:code | 3 per 10 seconds | No |
POST /api/user/change-password | 5 per 60 seconds | Yes, behind the JWT guard |
What the backend does in each path
Forgot password
- Generates a 16-character hex code (from 8 bytes of a cryptographic generator) and inserts a row into the
forgot_passwordtable. - Sets the expiry to one day from the request, truncating sub-second precision to match the behaviour of the previous system.
- The email formats the expiry in the Asia/Bangkok timezone, stating
(UTC+7)explicitly rather than using the recipient's local time. - If no user matches the email, the response carries error code
AUT_101, meaning no user was found for the password reset — this is the source of the inline error under the email field.
Validating the code and setting a new password
GET /api/user/reset-password/:codeanswers whether the code is still unexpired and still unused. That matters because the frontend can check the expiry from the token itself, but cannot tell whether the code has already been consumed.- When saving the new password, the backend performs three things in one request: hashes it with bcrypt and updates the password column in the
usertable, records it inpassword_history, and marks the code as used. - Because the code is marked as used, a reset link works exactly once. Reopening the same link after a successful reset lands on the unusable-link screen.
Changing your password while signed in
- The current password is always verified first, then the new password is checked against the recorded history and rejected if it matches — the same mechanism that produces the "cannot reuse a previous password" message on the reset page.
- On success the new password is immediately appended to the history, so the password just set also becomes off-limits for the next change.
Notes worth knowing
- Changing a password does not clear sessions. The backend updates the password and its history but does not automatically delete the Redis session, so other devices holding the old token keep working until it expires or is revoked.
- Password history is shared across every path — sign-up, administrator-created users, resets and self-service changes all write to the same table, so the reuse rule applies uniformly across all of them.
- Every email goes through the system-wide shared mailer configured from the
MAIL_SMTP_*environment variables. Misconfiguration shows up as the API reporting success while no email ever arrives. - Three requests per ten seconds is a fairly tight window for a screen where users may mistype repeatedly; support staff testing this path back-to-back should allow cool-down time between attempts.