Add change-password implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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<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`:
|
||||||
|
|
||||||
|
```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<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):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
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:
|
||||||
|
|
||||||
|
```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<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.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <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).
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 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**
|
||||||
|
|
||||||
|
```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
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <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**
|
||||||
|
|
||||||
|
```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<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**
|
||||||
|
|
||||||
|
```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<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`:
|
||||||
|
|
||||||
|
```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
|
||||||
|
<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**
|
||||||
|
|
||||||
|
```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
|
||||||
|
<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):
|
||||||
|
|
||||||
|
```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.
|
||||||
Reference in New Issue
Block a user