# 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<AppUser>`, 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<void>` 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.
