Files
ROLAC/docs/superpowers/plans/2026-06-23-change-password.md
Chris Chen 3544b6ee78 Add change-password implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:04:09 -07:00

37 KiB

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):

    public const string PasswordChanged    = "PasswordChanged";

Then update the All collection to include it — change the existing block to:

    public static readonly IReadOnlyList<string> 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:

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
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:

    private static Mock<UserManager<AppUser>> BuildUserManager(
        AppUser?         findResult           = null,
        bool             passwordOk           = true,
        IList<string>?   roles                = null,
        IdentityResult?  changePasswordResult = null)
    {

Add this setup just before return mgr; (after the UpdateAsync setup at line 54-55):

        mgr.Setup(m => m.ChangePasswordAsync(
                It.IsAny<AppUser>(), It.IsAny<string>(), It.IsAny<string>()))
           .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:

    // -----------------------------------------------------------------------
    // 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<AppUser>(), It.IsAny<string>(), It.IsAny<string>()), 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.

    /// <summary>
    /// Changes the password for an already-authenticated user. Verifies the current
    /// password and enforces the configured Identity password policy via
    /// <c>UserManager.ChangePasswordAsync</c>. On success, revokes the user's other
    /// active refresh tokens (keeping the one matching <paramref name="currentRawRefreshToken"/>)
    /// and writes a security audit entry. Returns the <see cref="IdentityResult"/> so the
    /// caller can surface failures; never throws on a bad password.
    /// </summary>
    Task<IdentityResult> 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).

    // -------------------------------------------------------------------------
    // Change password
    // -------------------------------------------------------------------------

    public async Task<IdentityResult> 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
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).

    // -------------------------------------------------------------------------
    // POST /api/auth/change-password
    // -------------------------------------------------------------------------

    /// <summary>
    /// 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.
    /// </summary>
    [HttpPost("change-password")]
    [Authorize]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    public async Task<IActionResult> 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
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:

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:

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

  // ── 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):

    /**
     * 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<void> {
        return this.http.post<void>(
            `${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
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:

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<ChangePasswordFormComponent>;
  let component: ChangePasswordFormComponent;
  let authSpy: jasmine.SpyObj<AuthService>;
  let toastSpy: jasmine.SpyObj<ToastService>;

  beforeEach(async () => {
    authSpy = jasmine.createSpyObj<AuthService>('AuthService', ['changePassword']);
    toastSpy = jasmine.createSpyObj<ToastService>('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:

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:

<form [formGroup]="form" class="k-form k-form-vertical" (ngSubmit)="onSubmit()">
  <div class="grid grid-cols-1 gap-y-3 max-w-md">

    <kendo-formfield>
      <kendo-label text="Current Password *"></kendo-label>
      <kendo-textbox formControlName="currentPassword" type="password"
        [clearButton]="false"></kendo-textbox>
      <kendo-formerror *ngIf="form.get('currentPassword')?.errors?.['required']">
        Required.
      </kendo-formerror>
    </kendo-formfield>

    <kendo-formfield>
      <kendo-label text="New Password *"></kendo-label>
      <kendo-textbox formControlName="newPassword" type="password"
        [clearButton]="false"></kendo-textbox>
      <kendo-formerror *ngIf="form.get('newPassword')?.errors?.['required']">
        Required.
      </kendo-formerror>
      <kendo-formerror *ngIf="form.get('newPassword')?.errors?.['passwordStrength']">
        Must be at least 8 characters with an uppercase letter, a lowercase letter,
        a digit, and a special character.
      </kendo-formerror>
    </kendo-formfield>

    <kendo-formfield>
      <kendo-label text="Confirm New Password *"></kendo-label>
      <kendo-textbox formControlName="confirmPassword" type="password"
        [clearButton]="false"></kendo-textbox>
      <kendo-formerror *ngIf="form.get('confirmPassword')?.errors?.['required']">
        Required.
      </kendo-formerror>
      <kendo-formerror *ngIf="form.errors?.['mismatch'] && form.get('confirmPassword')?.touched">
        Passwords do not match.
      </kendo-formerror>
      <kendo-formerror *ngIf="form.errors?.['sameAsCurrent'] && form.get('newPassword')?.touched">
        New password must be different from the current password.
      </kendo-formerror>
    </kendo-formfield>

    <div class="mt-2">
      <button kendoButton themeColor="primary" type="submit"
        [disabled]="form.invalid || submitting">
        Change Password
      </button>
    </div>

  </div>
</form>
  • 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
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:

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:

<div class="p-4 md:p-6">
  <section class="bg-white rounded-lg shadow-sm border border-gray-200 p-4 md:p-6 max-w-xl">
    <h2 class="text-lg font-semibold mb-1">Change Password</h2>
    <p class="text-sm text-gray-500 mb-4">
      Changing your password signs you out on your other devices.
    </p>
    <app-change-password-form></app-change-password-form>
  </section>
</div>
  • Step 3: Register the route

In APP/src/app/app.routes.ts, add an import near the other page-component imports (after line 25):

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:

            {
                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:

        {
          text: 'Settings',
          icon: 'settings',
          disabled: true
        },

with:

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