From 3544b6ee781f436107650082fe044c9086ae8ba2 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Tue, 23 Jun 2026 19:04:09 -0700 Subject: [PATCH] Add change-password implementation plan Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-06-23-change-password.md | 983 ++++++++++++++++++ 1 file changed, 983 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-23-change-password.md diff --git a/docs/superpowers/plans/2026-06-23-change-password.md b/docs/superpowers/plans/2026-06-23-change-password.md new file mode 100644 index 0000000..8884891 --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-change-password.md @@ -0,0 +1,983 @@ +# Change Password (Self-Service) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let an authenticated user change their own password from a new Account Settings page, verifying the current password, enforcing the existing Identity policy, and revoking the user's other sessions on success. + +**Architecture:** New `POST /api/auth/change-password` endpoint → `AuthService.ChangePasswordAsync` uses `UserManager.ChangePasswordAsync` (verifies current password + applies policy + bumps SecurityStamp), then revokes the user's other refresh tokens (keeps the current cookie's token) and writes a security audit entry. Frontend adds a `/user-portal/account` page hosting a focused `ChangePasswordFormComponent`, an `authService.changePassword()` call, and wires the previously-disabled user-menu "Settings" item to the page. + +**Tech Stack:** C# / ASP.NET Core Identity / EF Core (in-memory for tests) / xUnit + Moq (backend); Angular standalone components / Reactive Forms / Kendo UI v20 / Karma + Jasmine (frontend). No DB migration — uses inherited `IdentityUser.PasswordHash`/`SecurityStamp` and the existing `RefreshToken` table. + +**Reference spec:** `docs/superpowers/specs/2026-06-23-change-password-design.md` + +**No schema change / no migration is required.** + +--- + +## File Structure + +**Backend (create):** +- `API/ROLAC.API/DTOs/Auth/ChangePasswordRequest.cs` — request DTO. + +**Backend (modify):** +- `API/ROLAC.API/Entities/Logging/AuditLog.cs` — add `PasswordChanged` audit action constant. +- `API/ROLAC.API/Services/IAuthService.cs` — add `ChangePasswordAsync` to the interface. +- `API/ROLAC.API/Services/AuthService.cs` — implement `ChangePasswordAsync`. +- `API/ROLAC.API/Controllers/AuthController.cs` — add `POST change-password` action. + +**Backend (test):** +- `API/ROLAC.API.Tests/Services/AuthServiceTests.cs` — add change-password tests. + +**Frontend (create):** +- `APP/src/app/features/account/validators/password.validators.ts` — strength + match validators. +- `APP/src/app/features/account/validators/password.validators.spec.ts` — validator tests. +- `APP/src/app/features/account/components/change-password-form/change-password-form.component.ts` +- `APP/src/app/features/account/components/change-password-form/change-password-form.component.html` +- `APP/src/app/features/account/components/change-password-form/change-password-form.component.spec.ts` +- `APP/src/app/features/account/pages/account-settings-page/account-settings-page.component.ts` +- `APP/src/app/features/account/pages/account-settings-page/account-settings-page.component.html` + +**Frontend (modify):** +- `APP/src/app/shared/services/auth.service.ts` — add `changePassword()`. +- `APP/src/app/shared/services/auth.service.spec.ts` — add `changePassword()` test. +- `APP/src/app/app.routes.ts` — register `account` route. +- `APP/src/app/portals/user-portal/components/user-header/user-header.component.ts` — wire "Settings" menu item. + +--- + +## Commands reference + +- **Backend tests (all):** `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release` +- **Backend tests (filtered):** `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~ChangePassword"` +- **Frontend tests (single run):** run from `APP/`: `npx ng test --watch=false --browsers=ChromeHeadless` +- Always build/test with `-c Release` (Visual Studio holds a lock on `bin/Debug`). + +--- + +## Task 1: Add `PasswordChanged` audit action + `ChangePasswordRequest` DTO + +**Files:** +- Modify: `API/ROLAC.API/Entities/Logging/AuditLog.cs:39-61` +- Create: `API/ROLAC.API/DTOs/Auth/ChangePasswordRequest.cs` + +- [ ] **Step 1: Add the `PasswordChanged` constant** + +In `API/ROLAC.API/Entities/Logging/AuditLog.cs`, inside `public static class AuditActions`, add the constant after `RoleChanged` (line 47) and include it in the `All` list. + +Add the field (after the `RoleChanged` line): + +```csharp + public const string PasswordChanged = "PasswordChanged"; +``` + +Then update the `All` collection to include it — change the existing block to: + +```csharp + public static readonly IReadOnlyList All = + [ + Create, Update, Delete, Login, Logout, LoginFailed, RoleChanged, + PasswordChanged, UserDeactivated, PermissionChanged, CheckIssued, + CheckVoided, ExpenseApproved, StatementFinalized, + ]; +``` + +- [ ] **Step 2: Create the request DTO** + +Create `API/ROLAC.API/DTOs/Auth/ChangePasswordRequest.cs`: + +```csharp +using System.ComponentModel.DataAnnotations; + +namespace ROLAC.API.DTOs.Auth; + +public class ChangePasswordRequest +{ + [Required] + [MaxLength(128)] + public string CurrentPassword { get; set; } = null!; + + [Required] + [MinLength(8)] + [MaxLength(128)] + public string NewPassword { get; set; } = null!; +} +``` + +- [ ] **Step 3: Build to verify it compiles** + +Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release` +Expected: Build succeeded (0 errors). + +- [ ] **Step 4: Commit** + +```bash +git add API/ROLAC.API/Entities/Logging/AuditLog.cs API/ROLAC.API/DTOs/Auth/ChangePasswordRequest.cs +git commit -m "feat(auth): add PasswordChanged audit action and ChangePasswordRequest DTO" +``` + +--- + +## Task 2: Add `ChangePasswordAsync` to the auth service (TDD) + +**Files:** +- Modify: `API/ROLAC.API/Services/IAuthService.cs` +- Modify: `API/ROLAC.API/Services/AuthService.cs` +- Test: `API/ROLAC.API.Tests/Services/AuthServiceTests.cs` + +The existing test helper `BuildUserManager` (lines 34-58) does **not** set up `ChangePasswordAsync`. We add a setup so the mock returns a configurable result. + +- [ ] **Step 1: Extend the `BuildUserManager` helper to support `ChangePasswordAsync`** + +In `API/ROLAC.API.Tests/Services/AuthServiceTests.cs`, change the `BuildUserManager` signature and add one setup. Replace the method signature line and add the setup before `return mgr;`. + +Change the signature (line 34-37) to add a `changePasswordResult` parameter: + +```csharp + private static Mock> BuildUserManager( + AppUser? findResult = null, + bool passwordOk = true, + IList? roles = null, + IdentityResult? changePasswordResult = null) + { +``` + +Add this setup just before `return mgr;` (after the `UpdateAsync` setup at line 54-55): + +```csharp + mgr.Setup(m => m.ChangePasswordAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(changePasswordResult ?? IdentityResult.Success); +``` + +- [ ] **Step 2: Write the failing tests** + +Append these tests inside the `AuthServiceTests` class in `API/ROLAC.API.Tests/Services/AuthServiceTests.cs` (before the closing brace), adding a section header: + +```csharp + // ----------------------------------------------------------------------- + // Change password tests + // ----------------------------------------------------------------------- + + [Fact] + public async Task ChangePassword_ValidRequest_Succeeds() + { + var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true }; + var um = BuildUserManager(findResult: user); + var ts = BuildTokenService(); + var sut = BuildSut(um, ts, BuildDb()); + + var result = await sut.ChangePasswordAsync("u1", "Old1234!", "New1234!", null); + + Assert.True(result.Succeeded); + um.Verify(m => m.ChangePasswordAsync(user, "Old1234!", "New1234!"), Times.Once); + } + + [Fact] + public async Task ChangePassword_UnknownUser_Fails() + { + var um = BuildUserManager(findResult: null); + var ts = BuildTokenService(); + var sut = BuildSut(um, ts, BuildDb()); + + var result = await sut.ChangePasswordAsync("missing", "Old1234!", "New1234!", null); + + Assert.False(result.Succeeded); + um.Verify(m => m.ChangePasswordAsync( + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ChangePassword_WrongCurrentPassword_ReturnsFailure() + { + var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true }; + var failed = IdentityResult.Failed(new IdentityError { Description = "Incorrect password." }); + var um = BuildUserManager(findResult: user, changePasswordResult: failed); + var ts = BuildTokenService(); + var sut = BuildSut(um, ts, BuildDb()); + + var result = await sut.ChangePasswordAsync("u1", "WrongOld!", "New1234!", null); + + Assert.False(result.Succeeded); + } + + [Fact] + public async Task ChangePassword_Success_RevokesOtherSessionsButKeepsCurrent() + { + var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true }; + var um = BuildUserManager(findResult: user); + var ts = BuildTokenService(); // HashToken(x) => "hash:{x}" + var db = BuildDb(); + + // Current session token (raw "current-raw" => "hash:current-raw") + db.RefreshTokens.Add(new RefreshToken + { + UserId = "u1", + TokenHash = "hash:current-raw", + ExpiresAt = DateTime.UtcNow.AddDays(30), + CreatedAt = DateTime.UtcNow.AddHours(-1), + }); + // Another active session on a different device + db.RefreshTokens.Add(new RefreshToken + { + UserId = "u1", + TokenHash = "hash:other-device", + ExpiresAt = DateTime.UtcNow.AddDays(30), + CreatedAt = DateTime.UtcNow.AddHours(-2), + }); + await db.SaveChangesAsync(); + + var sut = BuildSut(um, ts, db); + await sut.ChangePasswordAsync("u1", "Old1234!", "New1234!", "current-raw"); + + var current = db.RefreshTokens.Single(rt => rt.TokenHash == "hash:current-raw"); + var other = db.RefreshTokens.Single(rt => rt.TokenHash == "hash:other-device"); + Assert.Null(current.RevokedAt); // current session preserved + Assert.NotNull(other.RevokedAt); // other session revoked + } +``` + +- [ ] **Step 3: Add the interface method** + +In `API/ROLAC.API/Services/IAuthService.cs`, add this method to the `IAuthService` interface (after `LogoutAsync`, before `BuildUserInfoAsync`). Add `using Microsoft.AspNetCore.Identity;` at the top of the file. + +```csharp + /// + /// Changes the password for an already-authenticated user. Verifies the current + /// password and enforces the configured Identity password policy via + /// UserManager.ChangePasswordAsync. On success, revokes the user's other + /// active refresh tokens (keeping the one matching ) + /// and writes a security audit entry. Returns the so the + /// caller can surface failures; never throws on a bad password. + /// + Task ChangePasswordAsync( + string userId, + string currentPassword, + string newPassword, + string? currentRawRefreshToken); +``` + +- [ ] **Step 4: Implement the service method** + +In `API/ROLAC.API/Services/AuthService.cs`, add this method after `LogoutAsync` (after line 160), before the "Private helpers" region. `IdentityResult` is available via the existing `using Microsoft.AspNetCore.Identity;` (line 1). + +```csharp + // ------------------------------------------------------------------------- + // Change password + // ------------------------------------------------------------------------- + + public async Task ChangePasswordAsync( + string userId, string currentPassword, string newPassword, string? currentRawRefreshToken) + { + var user = await _userManager.FindByIdAsync(userId); + if (user is null) + return IdentityResult.Failed(new IdentityError + { + Code = "UserNotFound", + Description = "User not found.", + }); + + var result = await _userManager.ChangePasswordAsync(user, currentPassword, newPassword); + if (!result.Succeeded) + return result; + + // Revoke the user's other active sessions; keep the current one alive. + var currentHash = currentRawRefreshToken is null + ? null + : _tokenService.HashToken(currentRawRefreshToken); + + var otherTokens = await _db.RefreshTokens + .Where(rt => rt.UserId == userId + && rt.RevokedAt == null + && rt.TokenHash != currentHash) + .ToListAsync(); + + foreach (var token in otherTokens) + token.RevokedAt = DateTime.UtcNow; + + await _db.SaveChangesAsync(); + + _audit.Write( + AuditActions.PasswordChanged, AuditCategories.Security, LogLevelEnum.Information, + entityName: nameof(AppUser), entityId: user.Id, + summary: $"Password changed: {user.Email}", + userId: user.Id, userEmail: user.Email); + + return result; + } +``` + +- [ ] **Step 5: Run the new tests to verify they pass** + +Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~ChangePassword"` +Expected: 4 tests pass (`ChangePassword_ValidRequest_Succeeds`, `ChangePassword_UnknownUser_Fails`, `ChangePassword_WrongCurrentPassword_ReturnsFailure`, `ChangePassword_Success_RevokesOtherSessionsButKeepsCurrent`). + +- [ ] **Step 6: Run the full backend suite to confirm nothing regressed** + +Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release` +Expected: all tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add API/ROLAC.API/Services/IAuthService.cs API/ROLAC.API/Services/AuthService.cs API/ROLAC.API.Tests/Services/AuthServiceTests.cs +git commit -m "feat(auth): add ChangePasswordAsync with other-session revocation and audit" +``` + +--- + +## Task 3: Add the `POST /api/auth/change-password` controller endpoint + +**Files:** +- Modify: `API/ROLAC.API/Controllers/AuthController.cs` + +This codebase unit-tests services, not controllers, so this thin pass-through has no unit test; it is covered by Task 2's service tests and verified by build + the manual smoke test in Task 9. + +- [ ] **Step 1: Add the endpoint** + +In `API/ROLAC.API/Controllers/AuthController.cs`, add this action after the `Logout` action (after line 155), before the "Private helpers" region. The needed usings already exist: `System.Security.Claims` (line 1), `Microsoft.AspNetCore.Authorization` (line 2), `ROLAC.API.DTOs.Auth` (line 5). + +```csharp + // ------------------------------------------------------------------------- + // POST /api/auth/change-password + // ------------------------------------------------------------------------- + + /// + /// Changes the current user's password. Requires the correct current password and a + /// new password meeting the configured policy. On success the user's *other* sessions + /// are revoked while the current session stays active. + /// + [HttpPost("change-password")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task ChangePassword([FromBody] ChangePasswordRequest request) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"); + if (string.IsNullOrEmpty(userId)) + return Unauthorized(); + + var currentRefresh = Request.Cookies[CookieName]; + var result = await _authService.ChangePasswordAsync( + userId, request.CurrentPassword, request.NewPassword, currentRefresh); + + if (!result.Succeeded) + return BadRequest(new + { + message = string.Join(" ", result.Errors.Select(error => error.Description)), + }); + + return NoContent(); + } +``` + +- [ ] **Step 2: Build to verify it compiles** + +Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release` +Expected: Build succeeded (0 errors). If `Select` is unresolved, add `using System.Linq;` at the top (it is usually implicit via `ImplicitUsings`). + +- [ ] **Step 3: Commit** + +```bash +git add API/ROLAC.API/Controllers/AuthController.cs +git commit -m "feat(auth): add POST /api/auth/change-password endpoint" +``` + +--- + +## Task 4: Add password validators (TDD) + +**Files:** +- Create: `APP/src/app/features/account/validators/password.validators.ts` +- Test: `APP/src/app/features/account/validators/password.validators.spec.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `APP/src/app/features/account/validators/password.validators.spec.ts`: + +```typescript +import { FormControl, FormGroup } from '@angular/forms'; +import { passwordStrengthValidator, passwordMatchValidator } from './password.validators'; + +describe('passwordStrengthValidator', () => { + const validate = (value: string) => + passwordStrengthValidator()(new FormControl(value)); + + it('returns null for an empty value (required handles emptiness)', () => { + expect(validate('')).toBeNull(); + }); + + it('returns null for a strong password', () => { + expect(validate('Str0ng!Pass')).toBeNull(); + }); + + it('flags a password that is too short', () => { + const errors = validate('Ab1!'); + expect(errors?.['passwordStrength']?.['minlength']).toBeTrue(); + }); + + it('flags a missing uppercase letter', () => { + const errors = validate('weak1234!'); + expect(errors?.['passwordStrength']?.['uppercase']).toBeTrue(); + }); + + it('flags a missing special character', () => { + const errors = validate('Weak1234'); + expect(errors?.['passwordStrength']?.['special']).toBeTrue(); + }); +}); + +describe('passwordMatchValidator', () => { + const buildGroup = (current: string, next: string, confirm: string) => + new FormGroup({ + currentPassword: new FormControl(current), + newPassword: new FormControl(next), + confirmPassword: new FormControl(confirm), + }); + + it('returns null when new matches confirm and differs from current', () => { + const group = buildGroup('Old1234!', 'New1234!', 'New1234!'); + expect(passwordMatchValidator()(group)).toBeNull(); + }); + + it('flags a confirm mismatch', () => { + const group = buildGroup('Old1234!', 'New1234!', 'Different1!'); + expect(passwordMatchValidator()(group)?.['mismatch']).toBeTrue(); + }); + + it('flags a new password equal to the current password', () => { + const group = buildGroup('Same1234!', 'Same1234!', 'Same1234!'); + expect(passwordMatchValidator()(group)?.['sameAsCurrent']).toBeTrue(); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run from `APP/`: `npx ng test --watch=false --browsers=ChromeHeadless` +Expected: FAIL — `password.validators` module not found / functions undefined. + +- [ ] **Step 3: Implement the validators** + +Create `APP/src/app/features/account/validators/password.validators.ts`: + +```typescript +import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; + +/** + * Mirrors the ASP.NET Identity password policy enforced on the server: + * at least 8 characters with an uppercase, a lowercase, a digit, and a + * non-alphanumeric character. Client-side only — the server stays authoritative. + * Returns null for an empty value so the `required` validator owns emptiness. + */ +export function passwordStrengthValidator(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const value = control.value as string; + if (!value) { + return null; + } + + const errors: ValidationErrors = {}; + if (value.length < 8) { + errors['minlength'] = true; + } + if (!/[A-Z]/.test(value)) { + errors['uppercase'] = true; + } + if (!/[a-z]/.test(value)) { + errors['lowercase'] = true; + } + if (!/[0-9]/.test(value)) { + errors['digit'] = true; + } + if (!/[^a-zA-Z0-9]/.test(value)) { + errors['special'] = true; + } + + return Object.keys(errors).length ? { passwordStrength: errors } : null; + }; +} + +/** + * Group-level validator: the confirm field must match the new password, and the + * new password must differ from the current one. + */ +export function passwordMatchValidator(): ValidatorFn { + return (group: AbstractControl): ValidationErrors | null => { + const current = group.get('currentPassword')?.value; + const next = group.get('newPassword')?.value; + const confirm = group.get('confirmPassword')?.value; + + const errors: ValidationErrors = {}; + if (next && confirm && next !== confirm) { + errors['mismatch'] = true; + } + if (next && current && next === current) { + errors['sameAsCurrent'] = true; + } + + return Object.keys(errors).length ? errors : null; + }; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run from `APP/`: `npx ng test --watch=false --browsers=ChromeHeadless` +Expected: PASS — the `passwordStrengthValidator` and `passwordMatchValidator` describe blocks are green. + +- [ ] **Step 5: Commit** + +```bash +git add APP/src/app/features/account/validators/password.validators.ts APP/src/app/features/account/validators/password.validators.spec.ts +git commit -m "feat(account): add password strength and match validators" +``` + +--- + +## Task 5: Add `changePassword()` to the frontend AuthService (TDD) + +**Files:** +- Modify: `APP/src/app/shared/services/auth.service.ts` +- Test: `APP/src/app/shared/services/auth.service.spec.ts` + +- [ ] **Step 1: Write the failing test** + +In `APP/src/app/shared/services/auth.service.spec.ts`, add this describe block inside the top-level `describe('AuthService', ...)` (e.g. after the `login()` block). The `service`, `httpMock`, and `apiConfig` variables are already set up in the file's `beforeEach`. + +```typescript + // ── changePassword() ───────────────────────────────────────────────────── + describe('changePassword()', () => { + it('POSTs current+new password to /api/auth/change-password with credentials', () => { + service.changePassword('Old1234!', 'New1234!').subscribe(); + + const req = httpMock.expectOne(`${apiConfig.authUrl}/change-password`); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ + currentPassword: 'Old1234!', + newPassword: 'New1234!', + }); + expect(req.request.withCredentials).toBeTrue(); + req.flush(null, { status: 204, statusText: 'No Content' }); + }); + }); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run from `APP/`: `npx ng test --watch=false --browsers=ChromeHeadless` +Expected: FAIL — `service.changePassword` is not a function. + +- [ ] **Step 3: Implement the method** + +In `APP/src/app/shared/services/auth.service.ts`, add this method inside the `AuthService` class in the "Auth API calls" region (e.g. after `logout()`, around line 164): + +```typescript + /** + * Changes the current user's password. Sends the cookie so the server can + * keep the current session alive while revoking the user's other sessions. + * Emits void on success (204); errors propagate so the caller can show the + * server message. + */ + changePassword(currentPassword: string, newPassword: string): Observable { + return this.http.post( + `${this.apiConfig.authUrl}/change-password`, + { currentPassword, newPassword }, + { withCredentials: true } + ); + } +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run from `APP/`: `npx ng test --watch=false --browsers=ChromeHeadless` +Expected: PASS — the `changePassword()` block is green. + +- [ ] **Step 5: Commit** + +```bash +git add APP/src/app/shared/services/auth.service.ts APP/src/app/shared/services/auth.service.spec.ts +git commit -m "feat(auth): add changePassword() to frontend AuthService" +``` + +--- + +## Task 6: Build the `ChangePasswordFormComponent` (TDD) + +**Files:** +- Create: `APP/src/app/features/account/components/change-password-form/change-password-form.component.ts` +- Create: `APP/src/app/features/account/components/change-password-form/change-password-form.component.html` +- Test: `APP/src/app/features/account/components/change-password-form/change-password-form.component.spec.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `APP/src/app/features/account/components/change-password-form/change-password-form.component.spec.ts`: + +```typescript +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { ChangePasswordFormComponent } from './change-password-form.component'; +import { AuthService } from '../../../../shared/services/auth.service'; +import { ToastService } from '../../../../core/services/toast.service'; + +describe('ChangePasswordFormComponent', () => { + let fixture: ComponentFixture; + let component: ChangePasswordFormComponent; + let authSpy: jasmine.SpyObj; + let toastSpy: jasmine.SpyObj; + + beforeEach(async () => { + authSpy = jasmine.createSpyObj('AuthService', ['changePassword']); + toastSpy = jasmine.createSpyObj('ToastService', ['success', 'error']); + + await TestBed.configureTestingModule({ + imports: [ChangePasswordFormComponent], + providers: [ + { provide: AuthService, useValue: authSpy }, + { provide: ToastService, useValue: toastSpy }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ChangePasswordFormComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + const fill = (current: string, next: string, confirm: string) => { + component.form.setValue({ + currentPassword: current, + newPassword: next, + confirmPassword: confirm, + }); + }; + + it('is invalid when the new password is weak', () => { + fill('Old1234!', 'weak', 'weak'); + expect(component.form.invalid).toBeTrue(); + }); + + it('is invalid when confirm does not match', () => { + fill('Old1234!', 'New1234!', 'Other1234!'); + expect(component.form.invalid).toBeTrue(); + }); + + it('is invalid when the new password equals the current password', () => { + fill('Same1234!', 'Same1234!', 'Same1234!'); + expect(component.form.invalid).toBeTrue(); + }); + + it('is valid for a strong, matching, different new password', () => { + fill('Old1234!', 'New1234!', 'New1234!'); + expect(component.form.valid).toBeTrue(); + }); + + it('does not call the service when submitting an invalid form', () => { + fill('Old1234!', 'weak', 'weak'); + component.onSubmit(); + expect(authSpy.changePassword).not.toHaveBeenCalled(); + }); + + it('calls the service with current+new and shows success + resets on 204', () => { + authSpy.changePassword.and.returnValue(of(void 0)); + fill('Old1234!', 'New1234!', 'New1234!'); + + component.onSubmit(); + + expect(authSpy.changePassword).toHaveBeenCalledWith('Old1234!', 'New1234!'); + expect(toastSpy.success).toHaveBeenCalled(); + expect(component.form.get('newPassword')?.value).toBeNull(); + }); + + it('shows the server error message on failure', () => { + authSpy.changePassword.and.returnValue( + throwError(() => ({ error: { message: 'Incorrect password.' } })) + ); + fill('Wrong1234!', 'New1234!', 'New1234!'); + + component.onSubmit(); + + expect(toastSpy.error).toHaveBeenCalledWith('Incorrect password.'); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run from `APP/`: `npx ng test --watch=false --browsers=ChromeHeadless` +Expected: FAIL — `ChangePasswordFormComponent` module not found. + +- [ ] **Step 3: Implement the component** + +Create `APP/src/app/features/account/components/change-password-form/change-password-form.component.ts`: + +```typescript +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; +import { InputsModule } from '@progress/kendo-angular-inputs'; +import { LabelModule } from '@progress/kendo-angular-label'; +import { ButtonsModule } from '@progress/kendo-angular-buttons'; +import { AuthService } from '../../../../shared/services/auth.service'; +import { ToastService } from '../../../../core/services/toast.service'; +import { + passwordStrengthValidator, + passwordMatchValidator, +} from '../../validators/password.validators'; + +@Component({ + selector: 'app-change-password-form', + standalone: true, + imports: [ + CommonModule, ReactiveFormsModule, + InputsModule, LabelModule, ButtonsModule, + ], + templateUrl: './change-password-form.component.html', +}) +export class ChangePasswordFormComponent { + form: FormGroup; + submitting = false; + + constructor( + private fb: FormBuilder, + private authService: AuthService, + private toast: ToastService, + ) { + this.form = this.fb.group( + { + currentPassword: ['', [Validators.required]], + newPassword: ['', [Validators.required, passwordStrengthValidator()]], + confirmPassword: ['', [Validators.required]], + }, + { validators: passwordMatchValidator() }, + ); + } + + onSubmit(): void { + if (this.form.invalid) { + this.form.markAllAsTouched(); + return; + } + + this.submitting = true; + const { currentPassword, newPassword } = this.form.value; + + this.authService.changePassword(currentPassword, newPassword).subscribe({ + next: () => { + this.toast.success('Password changed successfully.'); + this.form.reset(); + this.submitting = false; + }, + error: (err) => { + this.toast.error(err?.error?.message || 'Failed to change password.'); + this.submitting = false; + }, + }); + } +} +``` + +- [ ] **Step 4: Create the template** + +Create `APP/src/app/features/account/components/change-password-form/change-password-form.component.html`: + +```html +
+
+ + + + + + Required. + + + + + + + + Required. + + + Must be at least 8 characters with an uppercase letter, a lowercase letter, + a digit, and a special character. + + + + + + + + Required. + + + Passwords do not match. + + + New password must be different from the current password. + + + +
+ +
+ +
+
+``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run from `APP/`: `npx ng test --watch=false --browsers=ChromeHeadless` +Expected: PASS — all `ChangePasswordFormComponent` specs green. + +- [ ] **Step 6: Commit** + +```bash +git add APP/src/app/features/account/components/change-password-form/ +git commit -m "feat(account): add ChangePasswordFormComponent" +``` + +--- + +## Task 7: Build the Account Settings page, route, and menu wiring + +**Files:** +- Create: `APP/src/app/features/account/pages/account-settings-page/account-settings-page.component.ts` +- Create: `APP/src/app/features/account/pages/account-settings-page/account-settings-page.component.html` +- Modify: `APP/src/app/app.routes.ts` +- Modify: `APP/src/app/portals/user-portal/components/user-header/user-header.component.ts` + +- [ ] **Step 1: Create the page component** + +Create `APP/src/app/features/account/pages/account-settings-page/account-settings-page.component.ts`: + +```typescript +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ChangePasswordFormComponent } from '../../components/change-password-form/change-password-form.component'; + +@Component({ + selector: 'app-account-settings-page', + standalone: true, + imports: [CommonModule, ChangePasswordFormComponent], + templateUrl: './account-settings-page.component.html', +}) +export class AccountSettingsPageComponent {} +``` + +- [ ] **Step 2: Create the page template** + +Create `APP/src/app/features/account/pages/account-settings-page/account-settings-page.component.html`: + +```html +
+
+

Change Password

+

+ Changing your password signs you out on your other devices. +

+ +
+
+``` + +- [ ] **Step 3: Register the route** + +In `APP/src/app/app.routes.ts`, add an import near the other page-component imports (after line 25): + +```typescript +import { AccountSettingsPageComponent } from './features/account/pages/account-settings-page/account-settings-page.component'; +``` + +Then add this route inside the `user-portal` `children` array (e.g. right after the `dashboard` route block, around line 48). No `PermissionGuard` — any authenticated user may change their own password; the parent `AuthGuard` already protects it: + +```typescript + { + path: 'account', + component: AccountSettingsPageComponent, + data: { title: 'Account Settings', titleZh: '帳戶設定', section: 'Account' }, + }, +``` + +- [ ] **Step 4: Wire the "Settings" menu item to the page** + +In `APP/src/app/portals/user-portal/components/user-header/user-header.component.ts`, in `updateUserMenu()` (lines 100-104), change the disabled Settings entry to navigate to the account page. Replace: + +```typescript + { + text: 'Settings', + icon: 'settings', + disabled: true + }, +``` + +with: + +```typescript + { + text: 'Settings', + icon: 'settings', + click: () => this.router.navigate(['/user-portal/account']) + }, +``` + +(`this.router` is already injected in the constructor at line 50, and `onUserMenuClick` already invokes `item.click`.) + +- [ ] **Step 5: Build the frontend to verify it compiles** + +Run from `APP/`: `npx ng build` +Expected: Build completes with no errors. + +- [ ] **Step 6: Run the full frontend test suite** + +Run from `APP/`: `npx ng test --watch=false --browsers=ChromeHeadless` +Expected: all tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add APP/src/app/features/account/pages/ APP/src/app/app.routes.ts APP/src/app/portals/user-portal/components/user-header/user-header.component.ts +git commit -m "feat(account): add Account Settings page, route, and wire Settings menu item" +``` + +--- + +## Task 8: Final verification — full suites both layers + +- [ ] **Step 1: Run the full backend suite** + +Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release` +Expected: all tests pass. + +- [ ] **Step 2: Run the full frontend suite** + +Run from `APP/`: `npx ng test --watch=false --browsers=ChromeHeadless` +Expected: all tests pass. + +--- + +## Task 9 (optional): Manual smoke test against the dev API + +Only if you want end-to-end confidence beyond unit tests. Requires running the API from CLI (`-c Release` to dodge the VS Debug lock) and pointing the SPA at it (see `project_build_run_env`: dev admin `admin@rolac.org` / `Admin1234!`, CORS allows `http://localhost:4200`). + +- [ ] **Step 1: Log in, change password, verify** + - Log in as the seeded admin. + - Open the user menu → **Settings** → confirm the Account Settings page loads with the Change Password form. + - Submit with a wrong current password → expect an inline/toast error ("Incorrect password."). + - Submit with the correct current password and a policy-valid new password → expect a success toast and the form to reset. + - Log in again with the new password to confirm it took effect. + - (Optional) Restore the original password afterward so the seed login still works. + +--- + +## Self-review notes + +- **Spec coverage:** endpoint + service (Task 2-3), policy enforcement via `UserManager.ChangePasswordAsync` (Task 2), revoke-others-keep-current (Task 2 + test), audit entry (Task 1-2), `/user-portal/account` page + `ChangePasswordFormComponent` + Settings wiring (Task 6-7), `authService.changePassword` (Task 5), backend + frontend tests (throughout). All spec sections map to a task. +- **No DB migration** — confirmed: uses inherited Identity password fields and the existing `RefreshToken` table. +- **Type consistency:** `ChangePasswordAsync(userId, currentPassword, newPassword, currentRawRefreshToken)` signature is identical in interface (Task 2 Step 3), implementation (Step 4), and controller call (Task 3). Validator names `passwordStrengthValidator`/`passwordMatchValidator` and error keys (`passwordStrength`, `mismatch`, `sameAsCurrent`) match across validator (Task 4), component (Task 6), and templates.