diff --git a/API/ROLAC.API.Tests/Services/AuthServiceTests.cs b/API/ROLAC.API.Tests/Services/AuthServiceTests.cs index c39747b..8aa0746 100644 --- a/API/ROLAC.API.Tests/Services/AuthServiceTests.cs +++ b/API/ROLAC.API.Tests/Services/AuthServiceTests.cs @@ -32,9 +32,10 @@ public class AuthServiceTests /// Creates a mock with sensible defaults. private static Mock> BuildUserManager( - AppUser? findResult = null, - bool passwordOk = true, - IList? roles = null) + AppUser? findResult = null, + bool passwordOk = true, + IList? roles = null, + IdentityResult? changePasswordResult = null) { var store = new Mock>(); // Remaining ctor params are all optional; Moq passes them via reflection. @@ -53,6 +54,9 @@ public class AuthServiceTests .ReturnsAsync(roles ?? new List { "member" }); mgr.Setup(m => m.UpdateAsync(It.IsAny())) .ReturnsAsync(IdentityResult.Success); + mgr.Setup(m => m.ChangePasswordAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(changePasswordResult ?? IdentityResult.Success); return mgr; } @@ -308,4 +312,85 @@ public class AuthServiceTests var token = db.RefreshTokens.Single(); Assert.NotNull(token.RevokedAt); } + + // ----------------------------------------------------------------------- + // 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 + } } diff --git a/API/ROLAC.API/Controllers/AuthController.cs b/API/ROLAC.API/Controllers/AuthController.cs index e718c20..d0ef6d5 100644 --- a/API/ROLAC.API/Controllers/AuthController.cs +++ b/API/ROLAC.API/Controllers/AuthController.cs @@ -154,6 +154,38 @@ public class AuthController : ControllerBase return NoContent(); } + // ------------------------------------------------------------------------- + // 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(); + } + // ------------------------------------------------------------------------- // Private helpers // ------------------------------------------------------------------------- diff --git a/API/ROLAC.API/Controllers/MealAttendanceController.cs b/API/ROLAC.API/Controllers/MealAttendanceController.cs index a89f689..4282885 100644 --- a/API/ROLAC.API/Controllers/MealAttendanceController.cs +++ b/API/ROLAC.API/Controllers/MealAttendanceController.cs @@ -16,7 +16,7 @@ public class MealAttendanceController : ControllerBase [HttpGet("today")] [AllowAnonymous] public async Task GetToday() - => Ok(await _svc.GetOrCreateAsync(_svc.Today)); + => Ok(await _svc.GetOrCreateAsync(_svc.ServiceDay)); /// Daily counts within a date range, for the back-office dashboard chart. [HttpGet] diff --git a/API/ROLAC.API/DTOs/Auth/ChangePasswordRequest.cs b/API/ROLAC.API/DTOs/Auth/ChangePasswordRequest.cs new file mode 100644 index 0000000..f806dc1 --- /dev/null +++ b/API/ROLAC.API/DTOs/Auth/ChangePasswordRequest.cs @@ -0,0 +1,15 @@ +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!; +} diff --git a/API/ROLAC.API/Entities/Logging/AuditLog.cs b/API/ROLAC.API/Entities/Logging/AuditLog.cs index 150374f..f4bcafa 100644 --- a/API/ROLAC.API/Entities/Logging/AuditLog.cs +++ b/API/ROLAC.API/Entities/Logging/AuditLog.cs @@ -45,6 +45,7 @@ public static class AuditActions public const string Logout = "Logout"; public const string LoginFailed = "LoginFailed"; public const string RoleChanged = "RoleChanged"; + public const string PasswordChanged = "PasswordChanged"; public const string UserDeactivated = "UserDeactivated"; public const string PermissionChanged = "PermissionChanged"; public const string CheckIssued = "CheckIssued"; @@ -55,8 +56,8 @@ public static class AuditActions public static readonly IReadOnlyList All = [ Create, Update, Delete, Login, Logout, LoginFailed, RoleChanged, - UserDeactivated, PermissionChanged, CheckIssued, CheckVoided, - ExpenseApproved, StatementFinalized, + PasswordChanged, UserDeactivated, PermissionChanged, CheckIssued, + CheckVoided, ExpenseApproved, StatementFinalized, ]; } diff --git a/API/ROLAC.API/Hubs/AttendanceHub.cs b/API/ROLAC.API/Hubs/AttendanceHub.cs index 25f5f77..8714939 100644 --- a/API/ROLAC.API/Hubs/AttendanceHub.cs +++ b/API/ROLAC.API/Hubs/AttendanceHub.cs @@ -18,7 +18,7 @@ public class AttendanceHub : Hub // Push the current counts to a client the moment it connects. public override async Task OnConnectedAsync() { - var counts = await _svc.GetOrCreateAsync(_svc.Today); + var counts = await _svc.GetOrCreateAsync(_svc.ServiceDay); await Clients.Caller.SendAsync("ReceiveCounts", counts); await base.OnConnectedAsync(); } @@ -26,14 +26,14 @@ public class AttendanceHub : Hub // Apply a batched delta for one age group, then broadcast the new totals to everyone. public async Task Increment(string category, int delta) { - var counts = await _svc.IncrementAsync(_svc.Today, category, delta); + var counts = await _svc.IncrementAsync(_svc.ServiceDay, category, delta); await Clients.All.SendAsync("ReceiveCounts", counts); } // Overwrite one age group with an absolute value, then broadcast the new totals to everyone. public async Task SetCount(string category, int value) { - var counts = await _svc.SetAsync(_svc.Today, category, value); + var counts = await _svc.SetAsync(_svc.ServiceDay, category, value); await Clients.All.SendAsync("ReceiveCounts", counts); } } diff --git a/API/ROLAC.API/Services/AuthService.cs b/API/ROLAC.API/Services/AuthService.cs index 4848705..db0abad 100644 --- a/API/ROLAC.API/Services/AuthService.cs +++ b/API/ROLAC.API/Services/AuthService.cs @@ -159,6 +159,50 @@ public class AuthService : IAuthService } } + // ------------------------------------------------------------------------- + // 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 + && (currentHash == 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; + } + // ------------------------------------------------------------------------- // Private helpers // ------------------------------------------------------------------------- diff --git a/API/ROLAC.API/Services/IAuthService.cs b/API/ROLAC.API/Services/IAuthService.cs index 29b8e28..39f77ad 100644 --- a/API/ROLAC.API/Services/IAuthService.cs +++ b/API/ROLAC.API/Services/IAuthService.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Identity; using ROLAC.API.DTOs.Auth; using ROLAC.API.Entities; @@ -30,6 +31,20 @@ public interface IAuthService /// Task LogoutAsync(string rawRefreshToken); + /// + /// 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); + /// /// Builds the UserInfo payload (identity, roles, and effective permissions) for an /// already-authenticated user. Used by GET /api/auth/me to refresh permissions diff --git a/API/ROLAC.API/Services/IMealAttendanceService.cs b/API/ROLAC.API/Services/IMealAttendanceService.cs index b0284e8..05c122f 100644 --- a/API/ROLAC.API/Services/IMealAttendanceService.cs +++ b/API/ROLAC.API/Services/IMealAttendanceService.cs @@ -5,7 +5,7 @@ namespace ROLAC.API.Services; public interface IMealAttendanceService { /// Today's date in the server's local time zone (the church's "current Sunday"). - DateOnly Today { get; } + DateOnly ServiceDay { get; } /// Returns the counts for , creating a zeroed row if none exists. Task GetOrCreateAsync(DateOnly date); diff --git a/API/ROLAC.API/Services/MealAttendanceService.cs b/API/ROLAC.API/Services/MealAttendanceService.cs index 343210d..3f82717 100644 --- a/API/ROLAC.API/Services/MealAttendanceService.cs +++ b/API/ROLAC.API/Services/MealAttendanceService.cs @@ -12,7 +12,14 @@ public class MealAttendanceService : IMealAttendanceService public MealAttendanceService(AppDbContext db) => _db = db; // Server local time is assumed to match the church's local day. - public DateOnly Today => DateOnly.FromDateTime(DateTime.Now); + public DateOnly ServiceDay + { + get + { + var today = DateOnly.FromDateTime(DateTime.Now); + return today.AddDays(-(int)today.DayOfWeek); + } + } public async Task GetOrCreateAsync(DateOnly date) { diff --git a/APP/package.json b/APP/package.json index 37f955a..4c6e017 100644 --- a/APP/package.json +++ b/APP/package.json @@ -1,5 +1,5 @@ { - "name": "RBJ.Identity.App", + "name": "ROLAC.App", "version": "0.0.0", "scripts": { "ng": "ng", @@ -92,4 +92,4 @@ "tailwindcss": "^4.3.0", "typescript": "~5.8.2" } -} +} \ No newline at end of file diff --git a/APP/src/app/app.routes.ts b/APP/src/app/app.routes.ts index 1d0029a..b11a04f 100644 --- a/APP/src/app/app.routes.ts +++ b/APP/src/app/app.routes.ts @@ -23,6 +23,7 @@ import { AttendanceCounterPageComponent } from './features/meal-attendance/pages import { OfferingEntryMobilePageComponent } from './features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component'; import { SystemLogsPageComponent } from './features/logging/pages/system-logs-page/system-logs-page.component'; import { AuditLogsPageComponent } from './features/logging/pages/audit-logs-page/audit-logs-page.component'; +import { AccountSettingsPageComponent } from './features/account/pages/account-settings-page/account-settings-page.component'; export const routes: Routes = [ // Public routes @@ -46,6 +47,11 @@ export const routes: Routes = [ component: DashboardComponent, data: { title: 'Dashboard', titleZh: '首頁', section: 'Home' }, }, + { + path: 'account', + component: AccountSettingsPageComponent, + data: { title: 'Account Settings', titleZh: '帳戶設定', section: 'Account' }, + }, { path: 'admin/members', component: MembersPageComponent, diff --git a/APP/src/app/features/account/components/change-password-form/change-password-form.component.spec.ts b/APP/src/app/features/account/components/change-password-form/change-password-form.component.spec.ts new file mode 100644 index 0000000..0c55619 --- /dev/null +++ b/APP/src/app/features/account/components/change-password-form/change-password-form.component.spec.ts @@ -0,0 +1,85 @@ +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.'); + }); +}); diff --git a/APP/src/app/features/account/components/change-password-form/change-password-form.component.ts b/APP/src/app/features/account/components/change-password-form/change-password-form.component.ts new file mode 100644 index 0000000..d0b8cde --- /dev/null +++ b/APP/src/app/features/account/components/change-password-form/change-password-form.component.ts @@ -0,0 +1,113 @@ +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, + ], + template: ` +
+
+ + + + + + Required. + + + + + + + + Required. + + + Must be at least 8 characters with an uppercase letter, a lowercase letter, + a digit, and a special character. + + + New password must be different from the current password. + + + + + + + + Required. + + + Passwords do not match. + + + +
+ +
+ +
+
+ `, +}) +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; + }, + }); + } +} diff --git a/APP/src/app/features/account/pages/account-settings-page/account-settings-page.component.html b/APP/src/app/features/account/pages/account-settings-page/account-settings-page.component.html new file mode 100644 index 0000000..cb05966 --- /dev/null +++ b/APP/src/app/features/account/pages/account-settings-page/account-settings-page.component.html @@ -0,0 +1,9 @@ +
+
+

Change Password

+

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

+ +
+
diff --git a/APP/src/app/features/account/pages/account-settings-page/account-settings-page.component.ts b/APP/src/app/features/account/pages/account-settings-page/account-settings-page.component.ts new file mode 100644 index 0000000..062ae15 --- /dev/null +++ b/APP/src/app/features/account/pages/account-settings-page/account-settings-page.component.ts @@ -0,0 +1,11 @@ +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 {} diff --git a/APP/src/app/features/account/validators/password.validators.spec.ts b/APP/src/app/features/account/validators/password.validators.spec.ts new file mode 100644 index 0000000..0333fd2 --- /dev/null +++ b/APP/src/app/features/account/validators/password.validators.spec.ts @@ -0,0 +1,54 @@ +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(); + }); +}); diff --git a/APP/src/app/features/account/validators/password.validators.ts b/APP/src/app/features/account/validators/password.validators.ts new file mode 100644 index 0000000..3e63e7b --- /dev/null +++ b/APP/src/app/features/account/validators/password.validators.ts @@ -0,0 +1,57 @@ +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; + }; +} diff --git a/APP/src/app/portals/user-portal/user-portal.component.ts b/APP/src/app/portals/user-portal/user-portal.component.ts index ba9626c..bdac1b3 100644 --- a/APP/src/app/portals/user-portal/user-portal.component.ts +++ b/APP/src/app/portals/user-portal/user-portal.component.ts @@ -21,6 +21,7 @@ import { xIcon, chevronDownIcon, lockIcon, + gearIcon, } from '@progress/kendo-svg-icons'; import { AuthService, UserInfo } from '../../shared/services/auth.service'; import { PageHeaderService } from '../../shared/services/page-header.service'; @@ -145,6 +146,7 @@ export class UserPortalComponent implements OnInit, OnDestroy { public personalNavItems: NavItem[] = [ { text: 'My Reimbursements', icon: walletOutlineIcon, path: '/user-portal/reimbursements' }, + { text: 'Account Settings', icon: gearIcon, path: '/user-portal/account' }, ]; public showMemberAdminSection = false; diff --git a/APP/src/app/shared/services/auth.service.spec.ts b/APP/src/app/shared/services/auth.service.spec.ts index c9d4a10..9dfa406 100644 --- a/APP/src/app/shared/services/auth.service.spec.ts +++ b/APP/src/app/shared/services/auth.service.spec.ts @@ -179,6 +179,22 @@ describe('AuthService', () => { }); }); + // ── 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' }); + }); + }); + // ── initializeFromRefreshToken() ─────────────────────────────────────────── describe('initializeFromRefreshToken()', () => { diff --git a/APP/src/app/shared/services/auth.service.ts b/APP/src/app/shared/services/auth.service.ts index 2c40b8f..b14c5a2 100644 --- a/APP/src/app/shared/services/auth.service.ts +++ b/APP/src/app/shared/services/auth.service.ts @@ -163,6 +163,20 @@ export class AuthService { return this.refreshInFlight$; } + /** + * 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 } + ); + } + /** * Clears in-memory auth state immediately, then fires a fire-and-forget * POST to revoke the server-side refresh token cookie.