# 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.