diff --git a/client/e2e/register-user.function.ts b/client/e2e/register-user.function.ts index 10cffaf..c14b08d 100644 --- a/client/e2e/register-user.function.ts +++ b/client/e2e/register-user.function.ts @@ -25,6 +25,8 @@ export const registerUser = async ( await usernameInput.fill(username); await passwordInput.fill(password); + // Wait for asynchronous validation to complete + await expect(passwordInput).toHaveClass(/ng-valid/); await page.keyboard.press("Enter"); await responsePromise.then((response) => { diff --git a/client/src/app/components/password-field/password-field.component.ts b/client/src/app/components/password-field/password-field.component.ts index 90280c0..c881a5c 100644 --- a/client/src/app/components/password-field/password-field.component.ts +++ b/client/src/app/components/password-field/password-field.component.ts @@ -2,9 +2,13 @@ import { ChangeDetectionStrategy, Component, computed, + DestroyRef, + inject, input, + OnInit, signal, } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormControl, ReactiveFormsModule } from "@angular/forms"; import { MatButtonModule } from "@angular/material/button"; import { MatFormFieldModule } from "@angular/material/form-field"; @@ -12,7 +16,9 @@ import { MatIconModule } from "@angular/material/icon"; import { MatInputModule } from "@angular/material/input"; import { MatTooltipModule } from "@angular/material/tooltip"; import { PASSWORD_MAX_LENGTH } from "_server/constants/password"; +import { zxcvbnDefaultResult } from "_server/constants/zxcvbn-default-result"; import { PasswordErrorPipe } from "../../pipes/password-error.pipe"; +import { PasswordStrengthService } from "../../services/password-strength.service"; @Component({ selector: "app-password-field", @@ -33,7 +39,7 @@ import { PasswordErrorPipe } from "../../pipes/password-error.pipe"; // https://stackoverflow.com/questions/56584244/can-the-angular-material-errorstatematcher-detect-when-a-parent-form-is-submitte changeDetection: ChangeDetectionStrategy.Default, }) -export class PasswordFieldComponent { +export class PasswordFieldComponent implements OnInit { readonly PASSWORD_MAX_LENGTH = PASSWORD_MAX_LENGTH; autocomplete = input.required<"new-password" | "current-password">(); isPasswordVisible = signal(false); @@ -42,6 +48,24 @@ export class PasswordFieldComponent { () => `${this.isPasswordVisible() ? "Hide" : "Show"} password`, ); + #destroyRef = inject(DestroyRef); + #strength = inject(PasswordStrengthService); + + /** + * Reset the password strength meter when the password input is empty. + * + * This is necessary because the "required" validator prevents the password + * strength async validator from running when the input is empty. + * @see https://angular.dev/guide/forms/form-validation#creating-asynchronous-validators + */ + ngOnInit(): void { + this.passwordControl() + .valueChanges.pipe(takeUntilDestroyed(this.#destroyRef)) + .subscribe((value) => { + if (value === "") this.#strength.result$.next(zxcvbnDefaultResult); + }); + } + togglePasswordVisibility(event: MouseEvent): void { this.isPasswordVisible.update((value) => !value); diff --git a/client/src/app/components/password-strength-meter/password-strength-meter.component.ts b/client/src/app/components/password-strength-meter/password-strength-meter.component.ts index 162681f..c583e93 100644 --- a/client/src/app/components/password-strength-meter/password-strength-meter.component.ts +++ b/client/src/app/components/password-strength-meter/password-strength-meter.component.ts @@ -4,8 +4,10 @@ import { computed, inject, } from "@angular/core"; +import { toSignal } from "@angular/core/rxjs-interop"; import { MatIconModule } from "@angular/material/icon"; import { MatTooltipModule } from "@angular/material/tooltip"; +import { zxcvbnDefaultResult } from "_server/constants/zxcvbn-default-result"; import { PasswordStrengthService } from "../../services/password-strength.service"; @Component({ @@ -17,7 +19,10 @@ import { PasswordStrengthService } from "../../services/password-strength.servic changeDetection: ChangeDetectionStrategy.OnPush, }) export class PasswordStrengthMeterComponent { - result = inject(PasswordStrengthService).result; + result = toSignal(inject(PasswordStrengthService).result$, { + initialValue: zxcvbnDefaultResult, + }); + score = computed(() => this.result().score); ariaValueText = computed(() => this.#scoreLabel); isPasswordValid = computed(() => this.score() >= 3); diff --git a/client/src/app/components/register-form/register-form.component.html b/client/src/app/components/register-form/register-form.component.html index c6f6a14..196631a 100644 --- a/client/src/app/components/register-form/register-form.component.html +++ b/client/src/app/components/register-form/register-form.component.html @@ -10,7 +10,6 @@

Create your account

Username Create your account spellcheck="false" type="text" /> - {{ form.controls.username.errors | usernameError }} - + {{ usernameControl.errors | usernameError }} diff --git a/client/src/app/components/register-form/register-form.component.ts b/client/src/app/components/register-form/register-form.component.ts index fa6392f..73c3461 100644 --- a/client/src/app/components/register-form/register-form.component.ts +++ b/client/src/app/components/register-form/register-form.component.ts @@ -3,14 +3,13 @@ import { ChangeDetectionStrategy, Component, DestroyRef, - effect, inject, signal, } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { - FormBuilder, FormControl, + NonNullableFormBuilder, ReactiveFormsModule, Validators, } from "@angular/forms"; @@ -21,22 +20,20 @@ import { MatIconModule } from "@angular/material/icon"; import { MatInputModule } from "@angular/material/input"; import { Router, RouterLink } from "@angular/router"; import { CONFLICT } from "_server/constants/http-status-code"; -import { - PASSWORD_MAX_LENGTH, - ZXCVBN_MIN_SCORE, -} from "_server/constants/password"; +import { PASSWORD_MAX_LENGTH } from "_server/constants/password"; import { ClientSession } from "_server/types/client-session"; import { USERNAME_MAX_LENGTH, USERNAME_MIN_LENGTH, } from "_server/validation/username"; import { finalize } from "rxjs"; -import { UsernameValidatorDirective } from "../../directives/username-validator.directive"; import { UsernameErrorPipe } from "../../pipes/username-error.pipe"; import { NotificationService } from "../../services/notification.service"; import { PasswordStrengthService } from "../../services/password-strength.service"; import { SessionService } from "../../services/session.service"; import { UserMessage } from "../../types/user-message.enum"; +import { passwordValidatorFactory } from "../../validators/password-validator-factory"; +import { usernamePatternValidator } from "../../validators/username-pattern-validator"; import { PasswordFieldComponent } from "../password-field/password-field.component"; import { PasswordStrengthMeterComponent } from "../password-strength-meter/password-strength-meter.component"; @@ -54,7 +51,6 @@ import { PasswordStrengthMeterComponent } from "../password-strength-meter/passw ReactiveFormsModule, RouterLink, UsernameErrorPipe, - UsernameValidatorDirective, ], templateUrl: "./register-form.component.html", styleUrl: "./register-form.component.scss", @@ -62,85 +58,52 @@ import { PasswordStrengthMeterComponent } from "../password-strength-meter/passw }) export class RegisterFormComponent { readonly USERNAME_MAX_LENGTH = USERNAME_MAX_LENGTH; - form = inject(FormBuilder).group({ - username: [ - "", - [ - Validators.required, - Validators.minLength(USERNAME_MIN_LENGTH), - Validators.maxLength(USERNAME_MAX_LENGTH), - ], + isLoading = signal(false); + + usernameControl = new FormControl("", { + nonNullable: true, + validators: [ + Validators.required, + Validators.minLength(USERNAME_MIN_LENGTH), + Validators.maxLength(USERNAME_MAX_LENGTH), + usernamePatternValidator, ], - password: [ - "", - [Validators.required, Validators.maxLength(PASSWORD_MAX_LENGTH)], + }); + + passwordControl = new FormControl("", { + nonNullable: true, + validators: [ + Validators.required, + Validators.maxLength(PASSWORD_MAX_LENGTH), ], + asyncValidators: passwordValidatorFactory( + inject(PasswordStrengthService), + this.usernameControl, + ), + }); + + form = inject(NonNullableFormBuilder).group({ + username: this.usernameControl, + password: this.passwordControl, }); - isLoading = signal(false); #destroyRef = inject(DestroyRef); #http = inject(HttpClient); #notifier = inject(NotificationService); #router = inject(Router); #session = inject(SessionService); - #passwordStrength = inject(PasswordStrengthService); - #shouldResubmit = false; constructor() { - // Send form inputs to password strength validation worker service - this.form.valueChanges - .pipe( - takeUntilDestroyed(), - finalize(() => { - // Reset strength meter when the component is destroyed - this.#passwordStrength.validate("", []); - }), - ) - .subscribe((next) => { - const { username, password } = next; - - if (typeof password !== "string") { - return; - } - const userInputs = username ? [username] : []; - this.#passwordStrength.validate(password, userInputs); + // Re-evaluate the password strength whenever the username changes + this.usernameControl.valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(() => { + this.passwordControl.updateValueAndValidity(); }); - - // Listen for password strength validation results from the worker service - // and set validation errors on the password control - effect( - (): void => { - try { - const result = this.#passwordStrength.result(); - const control = this.form.controls.password; - - if (result.score >= ZXCVBN_MIN_SCORE) { - this.#removeStrengthError(control); - return; - } - - // Add validation error - control.setErrors({ - ...control.errors, - strength: result.feedback.warning || "Vulnerable password", - }); - } finally { - if (this.#shouldResubmit) { - this.#shouldResubmit = false; - this.onSubmit(); - } - } - }, - { allowSignalWrites: true }, - ); } onSubmit(): void { - if (this.form.invalid || this.isLoading()) return; - if (this.#passwordStrength.isWorkerBusy()) { - this.#shouldResubmit = true; - return; - } + if (!this.form.valid || this.isLoading()) return; this.isLoading.set(true); this.#http @@ -167,13 +130,4 @@ export class RegisterFormComponent { }, }); } - - #removeStrengthError(control: FormControl): void { - if (control.errors === null) return; - - delete control.errors["strength"]; - control.setErrors( - Object.keys(control.errors).length ? control.errors : null, - ); - } } diff --git a/client/src/app/components/sign-in-form/sign-in-form.component.html b/client/src/app/components/sign-in-form/sign-in-form.component.html index bfa6ae9..f506085 100644 --- a/client/src/app/components/sign-in-form/sign-in-form.component.html +++ b/client/src/app/components/sign-in-form/sign-in-form.component.html @@ -6,7 +6,6 @@

Sign in to your account

Username (); #isWorkerInitialized = false; + #notifier = inject(NotificationService); + #worker = this.#createWorker(); + + #queue = new Queue<{ + input: ZxcvbnInput; + result$: Subject; + }>(); + + validate( + password: string, + userInputs: string[] = [], + ): Observable { + const input: ZxcvbnInput = { password, userInputs }; + const result$ = new Subject(); + + this.#queue.enqueue({ input, result$ }); + + if (this.#queue.size === 1 && this.#isWorkerInitialized) + this.#worker.postMessage(input); + + return result$.asObservable(); + } + + #createWorker(): Worker { + const worker = new Worker( + new URL("../workers/password-strength.worker.js", import.meta.url), + { type: "module" }, + ); - constructor() { const mainListener = (event: MessageEvent): void => { - this.result.set(event.data); - this.#checkWorkerInput(); + this.result$.next(event.data); + + if (this.#queue.size === 1) { + const { result$ } = this.#queue.dequeue(); + + result$.next(event.data); + result$.complete(); + return; + } + + this.#handleWork(); }; - // Set up initial listener - this.#worker.onmessage = (event: MessageEvent): void => { + worker.onmessage = (event: MessageEvent): void => { console.log(event.data); + this.#worker.onmessage = mainListener; this.#isWorkerInitialized = true; - this.#worker.onmessage = mainListener; // Overwrite current listener - this.#checkWorkerInput(); + + if (this.#queue.isEmpty) return; + this.#handleWork(); }; - } - validate(password: string, userInputs: string[]): void { - if (this.isWorkerBusy() || !this.#isWorkerInitialized) { - this.#workerInput = { password, userInputs }; - return; - } + worker.onerror = (): void => { + this.#notifier.send(UserMessage.UNEXPECTED_WORKER_ERROR); + }; - this.isWorkerBusy.set(true); - this.#worker.postMessage({ password, userInputs }); + return worker; } - #checkWorkerInput(): void { - if (this.#workerInput) { - this.#worker.postMessage(this.#workerInput); - this.#workerInput = null; - return; - } + /** + * Handle work items that were enqueued while the worker was busy. + */ + #handleWork(): void { + // Abort all previous requests + while (this.#queue.size > 1) this.#queue.dequeue().result$.complete(); - this.isWorkerBusy.set(false); + // Send the last user input to the worker + this.#worker.postMessage(this.#queue.peek().input); } } diff --git a/client/src/app/types/queue.class.ts b/client/src/app/types/queue.class.ts new file mode 100644 index 0000000..0d4935a --- /dev/null +++ b/client/src/app/types/queue.class.ts @@ -0,0 +1,37 @@ +export class Queue { + readonly #items: T[]; + + constructor(...items: T[]) { + this.#items = items; + } + + get isEmpty(): boolean { + return this.#items.length === 0; + } + + get size(): number { + return this.#items.length; + } + + enqueue(item: T): void { + try { + this.#items.push(item); + } catch (e) { + throw new Error("Failed to enqueue item", { cause: e }); + } + } + + dequeue(): T { + if (this.isEmpty) throw new Error("Cannot dequeue from an empty queue"); + return this.#items.shift() as T; + } + + peek(): T { + if (this.isEmpty) throw new Error("Cannot peek at an empty queue"); + return this.#items[0]; + } + + toString(): string { + return `Queue(${this.#items.join(", ")})`; + } +} diff --git a/client/src/app/types/user-message.enum.ts b/client/src/app/types/user-message.enum.ts index b8e1da7..f2d3169 100644 --- a/client/src/app/types/user-message.enum.ts +++ b/client/src/app/types/user-message.enum.ts @@ -16,5 +16,6 @@ export const enum UserMessage { SERVICE_UNAVAILABLE = "Sorry, the server is currently unavailable. Please try again later.", SESSIONS_PAGE_LOAD_FAILED = "Failed to load session data. Please try reloading the page.", UNEXPECTED_ERROR = "An unknown error has occurred. Please try again later.", + UNEXPECTED_WORKER_ERROR = "An unknown error has occurred. Please try reloading the page.", USERNAME_TAKEN = "Sorry, this username is not available.", } diff --git a/client/src/app/validators/password-validator-factory.ts b/client/src/app/validators/password-validator-factory.ts new file mode 100644 index 0000000..0e6ac84 --- /dev/null +++ b/client/src/app/validators/password-validator-factory.ts @@ -0,0 +1,29 @@ +import { AbstractControl, ValidationErrors } from "@angular/forms"; +import { ZXCVBN_MIN_SCORE } from "_server/constants/password"; +import { ZxcvbnResult } from "_server/types/zxcvbn-result"; +import { map, Observable, of } from "rxjs"; +import { PasswordStrengthService } from "../services/password-strength.service"; + +const getValidationErrors = (result: ZxcvbnResult): ValidationErrors | null => + result.score >= ZXCVBN_MIN_SCORE + ? null + : { strength: result.feedback.warning || "Vulnerable password" }; + +export const passwordValidatorFactory = ( + passwordStrength: PasswordStrengthService, + ...otherControls: AbstractControl[] +) => { + return (control: AbstractControl): Observable => { + const password = control.value; + + if (typeof password !== "string") return of(null); + + const userInputs = otherControls + .map((control) => control.value) + .filter((value) => value && typeof value === "string") as string[]; + + return passwordStrength + .validate(password, userInputs) + .pipe(map((result) => getValidationErrors(result))); + }; +}; diff --git a/client/src/app/validators/username-pattern-validator.ts b/client/src/app/validators/username-pattern-validator.ts new file mode 100644 index 0000000..f72461d --- /dev/null +++ b/client/src/app/validators/username-pattern-validator.ts @@ -0,0 +1,12 @@ +import { AbstractControl, ValidationErrors, ValidatorFn } from "@angular/forms"; +import { hasValidUsernameCharacters } from "_server/validation/username"; + +export const usernamePatternValidator: ValidatorFn = ( + control: AbstractControl, +): ValidationErrors | null => { + const username = control.value; + + return typeof username === "string" && !hasValidUsernameCharacters(username) + ? { pattern: "Invalid characters" } + : null; +}; diff --git a/client/src/app/workers/password-strength.worker.ts b/client/src/app/workers/password-strength.worker.ts index 2b0b26c..56307d9 100644 --- a/client/src/app/workers/password-strength.worker.ts +++ b/client/src/app/workers/password-strength.worker.ts @@ -1,7 +1,7 @@ /// import "https://cdn.jsdelivr.net/npm/zxcvbn@4.4.2/dist/zxcvbn.js"; import { APP_NAME } from "_server/constants/app"; -import { ZxcvbnInput } from "_server/types/zxcvbn-input"; +import type { ZxcvbnInput } from "_server/types/zxcvbn-input"; import type zxcvbn from "zxcvbn"; declare global { @@ -10,7 +10,7 @@ declare global { } } -const fetchDictionary = async (): Promise => { +const getDictionary = async (): Promise => { const response = await fetch("app-dictionary.txt"); const text = await response.text(); const dictionary = text.split("\n"); @@ -25,7 +25,7 @@ const fetchDictionary = async (): Promise => { * experimental. * @see https://angular.dev/guide/experimental/zoneless */ -fetchDictionary().then((dictionary) => { +getDictionary().then((dictionary) => { self.onmessage = (event: MessageEvent): void => { const { password, userInputs } = event.data; const result = self.zxcvbn(password, userInputs.concat(dictionary));