feat(auth): data-driven password requirement checkers
Release / build-and-push (push) Successful in 3m4s
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:
1 parent
d95814ded3
commit
df556d8208
10 files changed
+218
-156
No files matched your search
+160
-107
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user