fix(web): password requirement indicators on registration and admin create-user

The /register checklist was dead markup: initPasswordValidation() was
only ever called on the setup page, so the requirement rows never
updated and the Register button stayed disabled forever — web
self-registration was effectively broken. The register Alpine component
now initializes it.

The validator is parameterized (field/submit ids, confirm + username/
email checks optional) so the admin Create User form reuses it: confirm
field, live requirement rows, and the Create button gated on all checks
passing. Requirements mirror the server's passwordcomplex rules
(8+ chars, upper/lower/number/special — the hardcoded defaults; server-
tuned rule values are a future nicety, the server remains the enforcer
either way).
This commit is contained in:
John O'Keefe committed 2026-10-13 12:38:26 -04:00
1 parent f3c9d4b4e8
commit bc6ce41762
7 files changed
+153 -124

No files matched your search

+99 -103
View File
@@ -62,8 +62,8 @@ function updateRequirementStatus(elementId: string, passed: boolean): void {
}
}
function updateSubmitButton(allPassed: boolean): void {
const button = document.getElementById("register-btn") as HTMLButtonElement;
function updateSubmitButton(allPassed: boolean, submitId: string): void {
const button = document.getElementById(submitId) as HTMLButtonElement;
if (!button) {
return;
}
@@ -77,118 +77,114 @@ function updateSubmitButton(allPassed: boolean): void {
}
}
// Main validation orchestrator
function validateAll(): void {
const passwordField = document.getElementById("password") as HTMLInputElement;
const confirmField = document.getElementById(
"confirm-password",
) as HTMLInputElement;
const usernameField = document.getElementById("username") as HTMLInputElement;
const emailField = document.getElementById("email") as HTMLInputElement;
if (!passwordField || !confirmField || !usernameField || !emailField) {
return;
}
const password = passwordField.value;
const confirm = confirmField.value;
const username = usernameField.value;
const email = emailField.value;
// 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 = passwordsMatch(password, confirm);
const hasUser = hasUsername(username);
const hasEmailAddr = hasEmail(email);
// 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;
updateSubmitButton(allPassed);
}
// Debounce function to avoid excessive validation calls
let debounceTimer: number | null = null;
function debouncedValidation(): void {
if (debounceTimer !== null) {
clearTimeout(debounceTimer);
}
debounceTimer = window.setTimeout(() => {
validateAll();
debounceTimer = null;
}, 100);
}
// Event handlers (PASSIVE - no preventDefault, doesn't block password managers)
function onPasswordInput(): void {
debouncedValidation();
}
function onConfirmInput(): void {
debouncedValidation();
}
function onUsernameInput(): void {
debouncedValidation();
}
function onEmailInput(): void {
debouncedValidation();
}
// Initialization
function initPasswordValidation(): void {
const passwordField = document.getElementById("password") as HTMLInputElement;
const confirmField = document.getElementById(
"confirm-password",
) as HTMLInputElement;
const usernameField = document.getElementById("username") as HTMLInputElement;
const emailField = document.getElementById("email") as HTMLInputElement;
export interface PasswordValidationOptions {
// Element ids. Defaults match the /register page; other forms (admin
// create-user, setup) override what differs.
passwordId?: string;
confirmId?: string;
usernameId?: string;
emailId?: string;
submitId?: string;
requireUsernameEmail?: boolean;
}
if (!passwordField || !confirmField || !usernameField || !emailField) {
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 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;
if (!passwordField) {
return;
}
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);
// 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;
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(() => {
validate();
debounceTimer = null;
}, 100);
};
const onInput = (): void => {
debouncedValidation();
};
// Add passive event listeners - don't prevent default, don't block password managers
passwordField.addEventListener("input", onPasswordInput, { passive: true });
passwordField.addEventListener("paste", onPasswordInput, { passive: true });
passwordField.addEventListener("input", onInput, { passive: true });
passwordField.addEventListener("paste", onInput, { passive: true });
confirmField.addEventListener("input", onConfirmInput, { passive: true });
confirmField.addEventListener("paste", onConfirmInput, { passive: true });
usernameField.addEventListener("input", onUsernameInput, { passive: true });
usernameField.addEventListener("paste", onUsernameInput, { passive: true });
emailField.addEventListener("input", onEmailInput, { passive: true });
emailField.addEventListener("paste", onEmailInput, { 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
validateAll();
validate();
}
// Export for use in template
export { initPasswordValidation };
Alpine.data("validation", () => ({
initPasswordValidation,
}));
// Admin create-user form: one password field (no confirm), custom submit id.
Alpine.data("adminCreateUser", () => ({
init() {
initPasswordValidation({
confirmId: "cu-confirm-password",
submitId: "create-user-submit",
});
},
}));
+7 -1
View File
@@ -1,5 +1,6 @@
import { Alpine } from "./alpine";
import { applyTheme } from "./theme";
import { initPasswordValidation } from "./password_validation";
function initRegisterTheme(): void {
// Progressive enhancement: check localStorage immediately
@@ -12,5 +13,10 @@ function initRegisterTheme(): void {
export { initRegisterTheme };
Alpine.data("register", () => ({
initRegisterTheme,
init() {
initRegisterTheme();
// The requirements checklist + submit gating: without this the
// checklist stayed dead markup and the Register button never enabled.
initPasswordValidation();
},
}));