From 21e98230089b459a0c6657bf27614c1bed5a916a Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Tue, 23 Jun 2026 18:58:03 -0700 Subject: [PATCH] Add self-service change-password design spec Co-Authored-By: Claude Opus 4.8 --- .../2026-06-23-change-password-design.md | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-23-change-password-design.md diff --git a/docs/superpowers/specs/2026-06-23-change-password-design.md b/docs/superpowers/specs/2026-06-23-change-password-design.md new file mode 100644 index 0000000..f9fb798 --- /dev/null +++ b/docs/superpowers/specs/2026-06-23-change-password-design.md @@ -0,0 +1,148 @@ +# Change Password (Self-Service) — Design + +**Date:** 2026-06-23 +**Status:** Approved, pending implementation plan + +## Summary + +Add a self-service "change password" feature so authenticated users can change +their own password. The UI lives on a new **Account Settings** page in the user +portal, reachable from the user-header menu's currently-disabled **Settings** +item. The backend exposes a new authenticated endpoint that verifies the current +password, enforces the existing ASP.NET Identity password policy, and — on +success — revokes the user's *other* sessions while keeping the current one +active. + +Out of scope (explicitly deferred): admin-driven forced password change / +"must change on first login" after an admin reset. The existing admin reset +endpoint (`POST /api/users/{id}/reset-password`) is unchanged. + +## Existing infrastructure (context) + +- **User entity:** `API/ROLAC.API/Entities/AppUser.cs` — `IdentityUser`, so + `PasswordHash` / `SecurityStamp` are inherited. +- **Hashing & policy:** ASP.NET Core Identity (`PasswordHasher`, PBKDF2). + Policy in `API/ROLAC.API/Program.cs`: min length 8, requires digit, uppercase, + lowercase, and non-alphanumeric. +- **Auth service / controller:** `API/ROLAC.API/Services/AuthService.cs`, + `API/ROLAC.API/Controllers/AuthController.cs` (`/api/auth/login`, `/refresh`, + `/me`, `/logout`). Refresh tokens stored in DB; the active refresh token is + delivered in an HttpOnly cookie. +- **Current user id:** read from the `sub` JWT claim + (`ClaimTypes.NameIdentifier ?? "sub"`), because `MapInboundClaims = false` and + `NameClaimType = "sub"`. +- **Audit:** `API/ROLAC.API/Services/Logging/AuditLogger.cs` — security actions + (login success/failure, logout) are logged; password change will be too. +- **Frontend auth:** `APP/src/app/shared/services/auth.service.ts`. +- **User portal:** `APP/src/app/portals/user-portal/` with + `components/user-header/user-header.component.ts` (user menu with disabled + Profile/Settings stubs). Routes in `APP/src/app/app.routes.ts` carry + `title`/`titleZh`/`section` data for the unified system header. +- **Form patterns:** `APP/src/app/features/users/components/edit-user-dialog/` + (Kendo `kendo-textbox`, `kendo-formfield`, `kendo-formerror`, Reactive Forms). + Form layout via Tailwind utilities on a neutral wrapper, not per-component SCSS. + +## Backend + +### Endpoint + +`POST /api/auth/change-password` — requires authentication. + +```csharp +public record ChangePasswordRequest(string CurrentPassword, string NewPassword); +``` + +### Flow (`AuthService.ChangePasswordAsync`) + +1. Resolve the user id from the `sub` claim; `UserManager.FindByIdAsync`. +2. `UserManager.ChangePasswordAsync(user, currentPassword, newPassword)`. This: + - verifies the current password, + - enforces the configured Identity password policy on the new password, + - re-hashes and persists, + - automatically bumps `SecurityStamp`. + No manual `CheckPasswordAsync` call is needed. +3. On failure, return `400 Bad Request` with readable error messages: + - wrong current password → "Incorrect current password", + - policy failures → mapped to readable messages. +4. On success: + - revoke all of the user's refresh tokens **except** the one presented in the + current request's HttpOnly cookie (other devices get logged out; current + session stays alive), + - write a security audit-log entry (same pattern as login logging: + action, category Security, entityId = user id, user email, IP). +5. Return `204 No Content`. + +The controller reads the current refresh token from the cookie to identify which +session to preserve, and passes it to the service. Validation is +server-authoritative; client-side checks are UX-only. + +## Frontend + +### Route & navigation + +- New route `/user-portal/account` → `AccountSettingsPageComponent`, registered + in `app.routes.ts` with `[AuthGuard]` and `data: { title: 'Account Settings', + titleZh: '帳戶設定', section: 'Account' }` so it uses the unified system header. +- Wire the disabled **Settings** item in `user-header.component.ts` to navigate + to this route (remove `disabled`, add navigation). The **Profile** stub is left + as-is. + +### Components + +- `AccountSettingsPageComponent` — page shell hosting a "Change Password" + section/card. Room to grow later (profile, language preference). +- `ChangePasswordFormComponent` — focused child component owning the form + (single responsibility; independently testable). + +### Form + +- Reactive Forms + Kendo, mirroring `edit-user-dialog` patterns. +- Three `kendo-textbox type="password"` fields: Current password, New password, + Confirm new password. +- Layout via Tailwind utilities (`grid grid-cols-1`) on a neutral wrapper div — + no per-component SCSS for layout. +- Validators (UX-only; server stays authoritative): + - new password: min 8, at least one upper, lower, digit, non-alphanumeric, + - cross-field: new ≠ current, confirm === new. +- Show password-rule hints and `kendo-formerror` messages. Submit disabled while + invalid or pending. +- Submit → `authService.changePassword(current, next)`: + - `204` → success notification, reset form, + - `400` → surface server message inline (e.g. incorrect current password). +- Single narrow column → inherently mobile-friendly; no grid/card-list split. + +### Auth service + +Add `changePassword(currentPassword, newPassword): Observable` calling +`POST /api/auth/change-password` with `withCredentials: true` so the +refresh-token cookie is sent. + +## Testing + +Follow TDD — tests first, then implementation — for both layers. + +### Backend (xUnit; build with `-c Release` per build env notes) + +- Success: correct current password + policy-valid new password → succeeds, + stored hash changes, `204`. +- Wrong current password → `400`, password unchanged. +- New password failing policy (too short / missing a required class) → `400` + with the relevant message. +- Other refresh tokens revoked; the current cookie's refresh token preserved. +- Audit entry written. + +### Frontend (Karma/Jasmine) + +- `ChangePasswordFormComponent`: validators (weak password invalid, mismatch + invalid, new === current invalid); submit disabled when invalid; calls service + with correct args; renders server error on `400`; resets on success. +- `AuthService.changePassword`: issues `POST /api/auth/change-password` with + `withCredentials`. + +## Deliverables checklist + +- Backend: `ChangePasswordRequest` DTO, `AuthController` action, `AuthService` + method, refresh-token revocation (preserve current), audit logging. +- Frontend: `/user-portal/account` route, `AccountSettingsPageComponent`, + `ChangePasswordFormComponent`, Settings menu wiring, `authService.changePassword`. +- Tests: backend unit tests + frontend unit tests.