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