feat(auth): data-driven password requirement checkers
Release / build-and-push (push) Successful in 3m4s

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.
This commit is contained in:
John O'Keefe committed 2026-10-14 11:08:12 -04:00
1 parent d95814ded3
commit df556d8208
10 files changed
+218 -156

No files matched your search

+160 -107
View File
@@ -1,56 +1,83 @@
// Password validation for registration form
// Procedural style - no OOP, passive event listeners for password manager compatibility
// 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";
// Validation check functions
function hasMinimumLength(password: string): boolean {
return password.length >= 8;
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 {
if (!password && !confirm) {
return false;
}
return password === confirm;
return password.length > 0 && password === confirm;
}
function hasUsername(username: string): boolean {
return username.trim().length > 0;
}
function hasEmail(email: string): boolean {
return email.trim().length > 0;
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;
}
if (!element) return;
const icon = element.querySelector(".requirement-icon");
if (!icon) {
return;
}
if (!icon) return;
if (passed) {
icon.textContent = "✓";
icon.className = "requirement-icon text-green-500";
@@ -63,128 +90,154 @@ function updateRequirementStatus(elementId: string, passed: boolean): void {
}
function updateSubmitButton(allPassed: boolean, submitId: string): void {
const button = document.getElementById(submitId) as HTMLButtonElement;
if (!button) {
return;
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;
if (allPassed) {
button.disabled = false;
button.classList.remove("opacity-50", "cursor-not-allowed");
} else {
button.disabled = true;
button.classList.add("opacity-50", "cursor-not-allowed");
}
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("");
}
// Initialization
export interface PasswordValidationOptions {
// Element ids. Defaults match the /register page; other forms (admin
// create-user, setup) override what differs.
passwordId?: string;
confirmId?: 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 {
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;
const confirmField = document.getElementById(confirmId) as HTMLInputElement;
const usernameField = document.getElementById(usernameId) as HTMLInputElement;
const emailField = document.getElementById(emailId) as HTMLInputElement;
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;
if (!passwordField) {
return;
}
// (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 username = usernameField ? usernameField.value : "x";
const email = emailField ? emailField.value : "x@x";
// Check password requirements
const hasLen = hasMinimumLength(password);
const hasUpper = hasUppercase(password);
const hasLower = hasLowercase(password);
const hasNum = hasNumber(password);
const hasSpecial = hasSpecialChar(password);
const doMatch = confirmField ? passwordsMatch(password, confirm) : true;
const hasUser = !requireUsernameEmail || hasUsername(username);
const hasEmailAddr = !requireUsernameEmail || hasEmail(email);
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)]);
// Update requirement indicators
updateRequirementStatus("req-length", hasLen);
updateRequirementStatus("req-upper", hasUpper);
updateRequirementStatus("req-lower", hasLower);
updateRequirementStatus("req-number", hasNum);
updateRequirementStatus("req-special", hasSpecial);
updateRequirementStatus("req-match", doMatch);
// Enable/disable submit button
const allPassed =
hasLen && hasUpper && hasLower && hasNum && hasSpecial && doMatch && hasUser && hasEmailAddr;
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);
};
// Debounce to avoid excessive validation calls
let debounceTimer: number | null = null;
const debouncedValidation = (): void => {
if (debounceTimer !== null) {
clearTimeout(debounceTimer);
}
debounceTimer = window.setTimeout(() => {
let timer: number | null = null;
const debounced = (): void => {
if (timer !== null) clearTimeout(timer);
timer = window.setTimeout(() => {
validate();
debounceTimer = null;
timer = null;
}, 100);
};
const onInput = (): void => debounced();
const onInput = (): void => {
debouncedValidation();
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);
// Add passive event listeners - don't prevent default, don't block password managers
passwordField.addEventListener("input", onInput, { passive: true });
passwordField.addEventListener("paste", onInput, { passive: true });
if (confirmField) {
confirmField.addEventListener("input", onInput, { passive: true });
confirmField.addEventListener("paste", onInput, { passive: true });
}
if (usernameField) {
usernameField.addEventListener("input", onInput, { passive: true });
usernameField.addEventListener("paste", onInput, { passive: true });
}
if (emailField) {
emailField.addEventListener("input", onInput, { passive: true });
emailField.addEventListener("paste", onInput, { passive: true });
}
// Initial validation
validate();
}
Alpine.data("validation", () => ({
initPasswordValidation,
}));
Alpine.data("validation", () => ({}));
// Admin create-user form: one password field (no confirm), custom submit id.
// Admin create-user form (Users page): rules container is
// #password-requirements-admin.
Alpine.data("adminCreateUser", () => ({
init() {
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,
});
},
}));
+5 -4
View File
@@ -1,6 +1,6 @@
import { Alpine } from "./alpine";
import { applyTheme } from "./theme";
import { initPasswordValidation } from "./password_validation";
import { initPasswordValidation, loadPasswordRules } from "./password_validation";
function initRegisterTheme(): void {
// Progressive enhancement: check localStorage immediately
@@ -15,8 +15,9 @@ export { initRegisterTheme };
Alpine.data("register", () => ({
init() {
initRegisterTheme();
// The requirements checklist + submit gating: without this the
// checklist stayed dead markup and the Register button never enabled.
initPasswordValidation();
// The requirements checklist + submit gating mirror the server's
// ACTIVE complexity rules: without this the checklist stayed dead
// markup and the Register button never enabled.
loadPasswordRules().then(() => initPasswordValidation());
},
}));
+2 -4
View File
@@ -11,7 +11,7 @@ import {
handleError,
} from "./api";
import { showToast } from "./toast";
import { initPasswordValidation } from "./password_validation";
import { initPasswordValidation, loadPasswordRules } from "./password_validation";
interface LibraryEntry {
id: string;
@@ -71,9 +71,7 @@ function initSetup(): void {
init() {
initSetup();
this.admin.baseUrl = window.location.origin;
setTimeout(() => {
initPasswordValidation();
}, 100);
loadPasswordRules().then(() => initPasswordValidation());
const modal = document.getElementById("folder-browser-modal");
if (modal) {