docs: update documentation to OKF v0.1 format
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
---
|
||||
type: guide
|
||||
title: Change Password
|
||||
description: How an authenticated user changes their own password from the shell, and what each error branch looks like.
|
||||
tags: [guide, change-password, auth, tester]
|
||||
timestamp: 2026-07-21T22:19:08Z
|
||||
---
|
||||
|
||||
# When this view is available
|
||||
|
||||
The change-password modal is part of the [authenticated shell](/guides/authenticated-shell.md).
|
||||
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)
|
||||
|
||||
1. Sign in via `/login`.
|
||||
2. Click the username dropdown in the shell header (`data-testid="user-menu-trigger"`).
|
||||
3. Click **Change password** (`data-testid="user-menu-change-password"`).
|
||||
4. 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
|
||||
|
||||
1. Sign in. Open the user menu → **Change password**.
|
||||
2. Enter the current password, a new password meeting the policy, and
|
||||
the same new password in confirmation.
|
||||
3. Click **OK**. The modal closes; the user remains signed in.
|
||||
4. Sign-in from another browser session using the same account — the
|
||||
new password works; the previous password no longer works.
|
||||
|
||||
## Wrong old password
|
||||
|
||||
1. Sign in, open the modal.
|
||||
2. Enter an incorrect "Old password", a valid "New password", and the
|
||||
same new password in confirmation.
|
||||
3. Click **OK**. The modal stays open and the inline error reads
|
||||
"Old password is incorrect". No DB write happens.
|
||||
|
||||
## Mismatched confirmation
|
||||
|
||||
1. Sign in, open the modal.
|
||||
2. Enter the current password, a valid new password, and a different
|
||||
confirmation. The `cp-form-error` element appears immediately with
|
||||
"Passwords do not match" (the OK button stays disabled until the
|
||||
values match because the reactive form is invalid).
|
||||
3. Fix the confirmation. The error disappears.
|
||||
|
||||
## Weak new password
|
||||
|
||||
1. Sign in, open the modal.
|
||||
2. Enter the current password and a too-short new password (e.g.
|
||||
`"abc"`). The modal submits; the server returns 400 with
|
||||
`code=PASSWORD_POLICY`; the inline error reads the server-supplied
|
||||
policy message.
|
||||
|
||||
## Unauthenticated
|
||||
|
||||
1. 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. The `mode='admin-reset'` input (where the `oldPassword` field
|
||||
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 `sessionStorage`
|
||||
session on this tab is preserved because the response is 204 (no new
|
||||
tokens are issued).
|
||||
|
||||
# See also
|
||||
|
||||
- [Authenticated Shell](/guides/authenticated-shell.md)
|
||||
- [Auth Endpoints](/api/auth.md) (`GET /api/v1/auth/me` + `POST /api/v1/auth/change-password`)
|
||||
- [Auth and Settings Tables](/database/auth-settings.md) (`refresh_token` revocation)
|
||||
- [REST API Overview](/api/rest-overview.md) (error envelope + CSRF)
|
||||
Reference in New Issue
Block a user