10 KiB
10 KiB
type, title, description, tags, timestamp
| type | title | description | tags | timestamp | ||||
|---|---|---|---|---|---|---|---|---|
| guide | Change Password | How an authenticated user changes their own password from the shell, and what each error branch looks like. |
|
2026-07-21T22:19:08Z |
When this view is available
The change-password modal is part of the authenticated shell.
It is opened from the user menu (trigger data-testid="user-menu-trigger"
→ entry data-testid="user-menu-change-password").
| Layer | File | Check |
|---|---|---|
| Client route | frontend/src/app/app.routes.ts |
The shell (/) uses authGuard. |
| Client modal | frontend/src/app/features/shell/change-password/change-password-modal.component.ts |
Rendered inside the shell by HomeComponent. |
| Server route | backend/src/modules/auth/auth.controller.ts |
POST /api/v1/auth/change-password — JWT-protected, CSRF-enforced. |
How to access (tester steps)
- Sign in via
/login. - Click the username dropdown in the shell header (
data-testid="user-menu-trigger"). - Click Change password (
data-testid="user-menu-change-password"). - The modal overlay appears (
data-testid="change-password-overlay") with the modal card (data-testid="change-password-modal").
Expected behavior
Inputs (mode = self)
| Field | Selector | Constraints |
|---|---|---|
| Old password | [data-testid="cp-old"] |
Required when mode === 'self'. Hidden in mode === 'admin-reset' (future Job). |
| New password | [data-testid="cp-new"] |
Required. Must satisfy the server-side validatePassword (length + mixed-case per PASSWORD_MIN_LENGTH / PASSWORD_REQUIRE_MIXED). |
| Confirm new password | [data-testid="cp-confirm"] |
Required. Must equal newPassword (group-level passwordMatchValidator). |
| Policy hint | [data-testid="cp-policy"] |
Renders passwordPolicy.description from the bootstrap payload (e.g. "At least 12 characters with upper, lower, digit and symbol"). |
| Cancel button | [data-testid="cp-cancel"] |
Disabled while submitting. Emits cancel. |
| OK button | [data-testid="cp-ok"] |
Disabled while submitting. Submits the form. |
The modal closes on Escape (@HostListener('document:keydown.escape'))
and on overlay click (the card stops propagation). Cancel is blocked
while submitting() is true.
Inline validation (client-side)
| State | Selector | Message |
|---|---|---|
newPassword and confirmNewPassword differ |
[data-testid="cp-form-error"] |
Passwords do not match |
| API error returned | [data-testid="cp-error"] |
Mapped via formatChangePasswordError() (see below). |
Successful response
| Outcome | Visible behaviour |
|---|---|
| HTTP 204 | Modal closes (changePasswordOpen.set(false)), user stays signed in on this tab. All other refresh tokens for the user are revoked server-side — other tabs will be forced to /login on their next guard tick. |
Error envelope
The frontend maps each error code to user-facing copy via
formatChangePasswordError({code, message}) in
frontend/src/app/features/shell/change-password/password-feedback.ts:
| HTTP | code |
Modal message |
|---|---|---|
| 400 | PASSWORDS_DO_NOT_MATCH |
"New password and confirmation do not match" |
| 400 | PASSWORD_POLICY |
The server's policy text (or "New password does not meet the policy requirements" if empty). |
| 400 | VALIDATION_FAILED |
The server's policy text (fallback "Failed to change password"). |
| 401 | INVALID_OLD_PASSWORD |
"Old password is incorrect" |
| 401 | UNAUTHORIZED |
"You must be signed in to change your password" |
| other | (anything else) | Server message (or fallback "Failed to change password"). |
Visual elements
| Element | Selector | Purpose |
|---|---|---|
| Modal overlay | [data-testid="change-password-overlay"] |
Backdrop; clicking it cancels. |
| Modal card | [data-testid="change-password-modal"] |
Stops propagation so card clicks don't cancel. |
| Form error (match) | [data-testid="cp-form-error"] |
Inline error from passwordMatchValidator. |
| Server error | [data-testid="cp-error"] |
Inline error from formatChangePasswordError(). |
Architecture map
| Step | Where | What happens |
|---|---|---|
| 1 | frontend/src/app/features/shell/change-password/change-password-modal.component.ts |
Dumb modal with reactive form, passwordMatchValidator, policy hint, errorMessage/submitting inputs. |
| 2 | frontend/src/app/features/shell/change-password/change-password-modal.validators.ts |
passwordMatchValidator group validator + PasswordPolicy zod schema. |
| 3 | frontend/src/app/features/shell/change-password/password-feedback.ts |
formatChangePasswordError({code, message}) — pure helper unit-tested in tests/frontend/change-password-modal.spec.ts. |
| 4 | frontend/src/app/features/home/home.component.ts |
Smart shell: owns changePasswordOpen/changePasswordSubmitting/changePasswordError signals; on submit(payload) calls AuthService.changePassword(). |
| 5 | frontend/src/app/core/services/auth.service.ts |
changePassword(dto) runs ensureCsrf() then POST /api/v1/auth/change-password with withCredentials: true; returns ChangePasswordResult. |
| 6 | backend/src/modules/auth/dto/auth.dto.ts |
ChangePasswordDtoSchema (zod) — confirmNewPassword refines to === newPassword. |
| 7 | backend/src/modules/auth/auth.service.ts |
changePassword(userId, dto) (transaction): mismatch check → argon2.verify(old) → validatePassword(new) → re-hash + UPDATE refresh_token SET revokedAt = now WHERE userId = ? AND revokedAt IS NULL. |
| 8 | backend/src/common/utils/password-policy.ts |
validatePassword(password, config) enforces PASSWORD_MIN_LENGTH and (when enabled) the mixed-case requirement. |
Tester flows
Happy path
- Sign in. Open the user menu → Change password.
- Enter the current password, a new password meeting the policy, and the same new password in confirmation.
- Click OK. The modal closes; the user remains signed in.
- Sign-in from another browser session using the same account — the new password works; the previous password no longer works.
Wrong old password
- Sign in, open the modal.
- Enter an incorrect "Old password", a valid "New password", and the same new password in confirmation.
- Click OK. The modal stays open and the inline error reads "Old password is incorrect". No DB write happens.
Mismatched confirmation
- Sign in, open the modal.
- Enter the current password, a valid new password, and a different
confirmation. The
cp-form-errorelement appears immediately with "Passwords do not match" (the OK button stays disabled until the values match because the reactive form is invalid). - Fix the confirmation. The error disappears.
Weak new password
- Sign in, open the modal.
- Enter the current password and a too-short new password (e.g.
"abc"). The modal submits; the server returns 400 withcode=PASSWORD_POLICY; the inline error reads the server-supplied policy message.
Unauthenticated
- Manually clear the session token in DevTools and submit the modal.
The server returns 401
UNAUTHORIZED; the inline error reads "You must be signed in to change your password". The frontend then has no usable session, so the next guard tick (e.g. clicking a tab) sends the user to/login.
Notes
- The change-password modal always runs in
mode='self'from the shell. Themode='admin-reset'input (where theoldPasswordfield is hidden) is supported by the modal component and will be wired by the admin-reset Job. - The server revokes all refresh tokens for the user on success,
so other devices must re-authenticate. The local
sessionStoragesession on this tab is preserved because the response is 204 (no new tokens are issued).
See also
- Authenticated Shell
- Auth Endpoints (
GET /api/v1/auth/me+POST /api/v1/auth/change-password) - Auth and Settings Tables (
refresh_tokenrevocation) - REST API Overview (error envelope + CSRF)