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));