From 5e0348de1d7bf3b14650b3b172968346099c1998 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Tue, 23 Jun 2026 20:01:16 -0700 Subject: [PATCH] feat(account): add password strength and match validators --- .../validators/password.validators.spec.ts | 54 ++++++++++++++++++ .../account/validators/password.validators.ts | 57 +++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 APP/src/app/features/account/validators/password.validators.spec.ts create mode 100644 APP/src/app/features/account/validators/password.validators.ts 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; + }; +}