Files
bookhoard/web/src/password_validation.ts
T
John O'Keefe df556d8208
Release / build-and-push (push) Successful in 3m4s
feat(auth): data-driven password requirement checkers
GET /api/auth/password-rules (public, pre-auth) exposes the ACTIVE
complexity config from the settings registry; password_validation.ts
now fetches it and generates the checklist rows per active rule —
inactive requirements no longer linger as fake tasks, and the min
length mirrors the admin's setting instead of a hardcoded 8.

Surfaces: register, setup, admin create-user (Users page), profile
password change (self + admin variants, now gated on checklist pass;
misleading minlength=6 dropped — the server was always the enforcer).

Verified live: flip min_length=12 / number+special off via the
settings API → endpoint + register/profile/admin checklists re-render
row-for-row; enforcement follows (short rejected, rule-valid accepted
without digits/specials); restore defaults → checklists return.
2026-10-14 11:08:12 -04:00

244 lines
8.7 KiB
TypeScript

// Password validation for password-set forms (register, setup, admin
// create-user, profile password change). The requirement checklist is
// generated from the server's ACTIVE complexity configuration
// (GET /api/auth/password-rules) so the UI mirrors what the server will
// actually enforce — the server remains the enforcer; this is display
// only. Procedural style, passive listeners (password-manager
// compatibility).
import { Alpine } from "./alpine";
interface PasswordRules {
min_length: number;
require_upper: boolean;
require_lower: boolean;
require_number: boolean;
require_special: boolean;
}
const DEFAULT_RULES: PasswordRules = {
min_length: 8,
require_upper: true,
require_lower: true,
require_number: true,
require_special: true,
};
let activeRules: PasswordRules = { ...DEFAULT_RULES };
// Fetches the ACTIVE rules once per page load; falls back to the
// historical defaults if the endpoint is unreachable.
export async function loadPasswordRules(): Promise<PasswordRules> {
try {
const res = await fetch("/api/auth/password-rules");
if (!res.ok) throw new Error(String(res.status));
const rules = (await res.json()) as Partial<PasswordRules>;
activeRules = {
min_length:
typeof rules.min_length === "number" && rules.min_length > 0
? rules.min_length
: DEFAULT_RULES.min_length,
require_upper: rules.require_upper ?? DEFAULT_RULES.require_upper,
require_lower: rules.require_lower ?? DEFAULT_RULES.require_lower,
require_number: rules.require_number ?? DEFAULT_RULES.require_number,
require_special: rules.require_special ?? DEFAULT_RULES.require_special,
};
} catch {
activeRules = { ...DEFAULT_RULES };
}
return activeRules;
}
// Check functions (rules-driven)
function checkLength(password: string, min: number): boolean {
return password.length >= min;
}
function hasUppercase(password: string): boolean {
return /[A-Z]/.test(password);
}
function hasLowercase(password: string): boolean {
return /[a-z]/.test(password);
}
function hasNumber(password: string): boolean {
return /[0-9]/.test(password);
}
function hasSpecialChar(password: string): boolean {
return /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password);
}
function passwordsMatch(password: string, confirm: string): boolean {
return password.length > 0 && password === confirm;
}
function nonEmpty(value: string): boolean {
return value.trim().length > 0;
}
// UI update functions
function updateRequirementStatus(elementId: string, passed: boolean): void {
const element = document.getElementById(elementId);
if (!element) return;
const icon = element.querySelector(".requirement-icon");
if (!icon) return;
if (passed) {
icon.textContent = "✓";
icon.className = "requirement-icon text-green-500";
element.style.color = "var(--text-primary)";
} else {
icon.textContent = "○";
icon.className = "requirement-icon";
element.style.color = "var(--text-secondary)";
}
}
function updateSubmitButton(allPassed: boolean, submitId: string): void {
const button = document.getElementById(submitId) as HTMLButtonElement | null;
if (!button) return;
button.disabled = !allPassed;
button.classList.toggle("opacity-50", !allPassed);
button.classList.toggle("cursor-not-allowed", !allPassed);
}
function requirementRow(id: string, label: string): string {
return `<li id="${id}" class="flex items-center gap-2"><span class="requirement-icon">○</span> ${label}</li>`;
}
// (Re)generates the checklist rows from the ACTIVE rules inside the
// container's <ul> (creating the list if the container is empty). Static
// markup rows are replaced — rules are the source of truth.
function ensureChecklist(containerId: string, rules: PasswordRules, hasConfirm: boolean): void {
const container = document.getElementById(containerId);
if (!container) return;
let list = container.querySelector("ul");
if (container.tagName === "UL") list = container; // container IS the list
if (!list) {
container.insertAdjacentHTML(
"beforeend",
'<ul class="space-y-1" style="color: var(--text-secondary)"></ul>',
);
list = container.querySelector("ul");
}
if (!list) return;
const rows: string[] = [requirementRow("req-length", `At least ${rules.min_length} characters`)];
if (rules.require_upper) rows.push(requirementRow("req-upper", "One uppercase letter"));
if (rules.require_lower) rows.push(requirementRow("req-lower", "One lowercase letter"));
if (rules.require_number) rows.push(requirementRow("req-number", "One number"));
if (rules.require_special) rows.push(requirementRow("req-special", "One special character"));
if (hasConfirm) rows.push(requirementRow("req-match", "Passwords match"));
list.innerHTML = rows.join("");
}
export interface PasswordValidationOptions {
passwordId?: string;
confirmId?: string; // omit/null → no match check
usernameId?: string;
emailId?: string;
submitId?: string;
checklistContainerId?: string; // holds the generated <ul>
requireUsernameEmail?: boolean;
}
export function initPasswordValidation(options: PasswordValidationOptions = {}): void {
const passwordId = options.passwordId ?? "password";
const confirmId = options.confirmId ?? "confirm-password";
const usernameId = options.usernameId ?? "username";
const emailId = options.emailId ?? "email";
const submitId = options.submitId ?? "register-btn";
const checklistId = options.checklistContainerId ?? "password-requirements";
const requireUsernameEmail = options.requireUsernameEmail ?? true;
const passwordField = document.getElementById(passwordId) as HTMLInputElement | null;
if (!passwordField) return;
const confirmField = document.getElementById(confirmId) as HTMLInputElement | null;
const usernameField = document.getElementById(usernameId) as HTMLInputElement | null;
const emailField = document.getElementById(emailId) as HTMLInputElement | null;
// (Re)generate the checklist rows from the ACTIVE rules — replaces any
// static markup so inactive requirements never linger as fake tasks.
ensureChecklist(checklistId, activeRules, !!confirmField);
const validate = (): void => {
const password = passwordField.value;
const confirm = confirmField ? confirmField.value : password;
const checks: Array<[string, boolean]> = [
["req-length", checkLength(password, activeRules.min_length)],
];
if (activeRules.require_upper) checks.push(["req-upper", hasUppercase(password)]);
if (activeRules.require_lower) checks.push(["req-lower", hasLowercase(password)]);
if (activeRules.require_number) checks.push(["req-number", hasNumber(password)]);
if (activeRules.require_special) checks.push(["req-special", hasSpecialChar(password)]);
if (confirmField) checks.push(["req-match", passwordsMatch(password, confirm)]);
let allPassed = true;
for (const [id, passed] of checks) {
updateRequirementStatus(id, passed);
allPassed = allPassed && passed;
}
if (requireUsernameEmail) {
allPassed =
allPassed &&
(usernameField ? nonEmpty(usernameField.value) : true) &&
(emailField ? nonEmpty(emailField.value) : true);
}
updateSubmitButton(allPassed, submitId);
};
let timer: number | null = null;
const debounced = (): void => {
if (timer !== null) clearTimeout(timer);
timer = window.setTimeout(() => {
validate();
timer = null;
}, 100);
};
const onInput = (): void => debounced();
const wire = (el: HTMLElement | null): void => {
if (!el) return;
el.addEventListener("input", onInput, { passive: true });
el.addEventListener("paste", onInput, { passive: true });
};
wire(passwordField);
wire(confirmField);
wire(usernameField);
wire(emailField);
validate();
}
Alpine.data("validation", () => ({}));
// Admin create-user form (Users page): rules container is
// #password-requirements-admin.
Alpine.data("adminCreateUser", () => ({
async init() {
await loadPasswordRules();
ensureChecklist(
"password-requirements-admin",
activeRules,
!!document.getElementById("cu-confirm-password"),
);
initPasswordValidation({
passwordId: "password",
confirmId: "cu-confirm-password",
submitId: "create-user-submit",
checklistContainerId: "password-requirements-admin",
});
},
}));
// Profile password change (self-edit + admin reset variants share the
// field ids). Informational checklist + gated password buttons.
Alpine.data("profilePasswordForm", () => ({
async init() {
await loadPasswordRules();
initPasswordValidation({
passwordId: "new-password",
confirmId: "confirm-password",
submitId: "password-submit-btn",
checklistContainerId: "password-requirements",
requireUsernameEmail: false,
});
},
}));