feat(account): add password strength and match validators

This commit is contained in:
Chris Chen
2026-06-23 20:01:16 -07:00
parent 8f18166dbf
commit 5e0348de1d
2 changed files with 111 additions and 0 deletions
@@ -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;
};
}