Merge branch 'feature/change-password'

This commit is contained in:
Chris Chen
2026-06-23 20:36:26 -07:00
21 changed files with 579 additions and 13 deletions
+6
View File
@@ -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,
@@ -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<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.');
});
});
@@ -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: `
<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-formerror *ngIf="form.errors?.['sameAsCurrent'] && form.get('newPassword')?.touched">
New password must be different from the current password.
</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-formfield>
<div class="mt-2">
<button kendoButton themeColor="primary" type="submit"
[disabled]="form.invalid || submitting">
Change Password
</button>
</div>
</div>
</form>
`,
})
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;
},
});
}
}
@@ -0,0 +1,9 @@
<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>
@@ -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 {}
@@ -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();
});
});
@@ -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;
};
}
@@ -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;
@@ -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()', () => {
@@ -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<void> {
return this.http.post<void>(
`${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.