feat(auth): add interactive password requirements validation to registration

- Add password requirements checklist with visual indicators (✓/○)
- Implement real-time validation for length, case, numbers, special chars
- Add confirm password field with matching validation
- Disable submit button until all requirements are met
- Add TypeScript client-side validation with password manager compatibility
This commit is contained in:
2026-02-22 18:40:22 -05:00
parent d28aa5af4b
commit d4d93bc0e3
2 changed files with 216 additions and 4 deletions
+39 -4
View File
@@ -19,11 +19,11 @@ templ Register() {
<form hx-post="/api/auth/register" hx-target="#result" hx-swap="innerHTML" class="card p-6 rounded-lg shadow-md border">
<div class="mb-4">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Email</label>
<input type="email" name="email" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
<input type="email" id="email" name="email" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Username</label>
<input type="text" name="username" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
<input type="text" id="username" name="username" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">First Name</label>
@@ -33,11 +33,38 @@ templ Register() {
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Last Name</label>
<input type="text" name="last_name" placeholder="Optional" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">
</div>
<div id="password-requirements" class="mb-4 p-4 rounded-lg border text-sm" style="background-color: var(--bg-secondary); border-color: var(--border)">
<p class="font-semibold mb-2" style="color: var(--text-primary)">Password Requirements:</p>
<ul class="space-y-1" style="color: var(--text-secondary)">
<li id="req-length" class="flex items-center gap-2">
<span class="requirement-icon"></span> At least 8 characters
</li>
<li id="req-upper" class="flex items-center gap-2">
<span class="requirement-icon"></span> One uppercase letter
</li>
<li id="req-lower" class="flex items-center gap-2">
<span class="requirement-icon"></span> One lowercase letter
</li>
<li id="req-number" class="flex items-center gap-2">
<span class="requirement-icon"></span> One number
</li>
<li id="req-special" class="flex items-center gap-2">
<span class="requirement-icon"></span> One special character
</li>
<li id="req-match" class="flex items-center gap-2">
<span class="requirement-icon"></span> Passwords match
</li>
</ul>
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Password</label>
<input type="password" name="password" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
<input type="password" id="password" name="password" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
</div>
<button type="submit" class="btn-primary w-full py-2 rounded">
<div class="mb-4">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Confirm Password</label>
<input type="password" id="confirm-password" name="confirm_password" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
</div>
<button type="submit" id="register-btn" class="btn-primary w-full py-2 rounded opacity-50 cursor-not-allowed" disabled>
Register
</button>
</form>
@@ -48,6 +75,14 @@ templ Register() {
<a href="/login" class="text-blue-500 hover:underline">Already have an account? Login</a>
</p>
</div>
<script src="/static/password_validation.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
if (window.initPasswordValidation) {
window.initPasswordValidation();
}
});
</script>
</body>
</html>
}
+177
View File
@@ -0,0 +1,177 @@
// Password validation for registration form
// Procedural style - no OOP, passive event listeners for password manager compatibility
// Validation check functions
function hasMinimumLength(password: string): boolean {
return password.length >= 8;
}
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;
}
function hasUsername(username: string): boolean {
return username.trim().length > 0;
}
function hasEmail(email: string): boolean {
return email.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): void {
const button = document.getElementById('register-btn') as HTMLButtonElement;
if (!button) {
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');
}
}
// 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;
if (!passwordField || !confirmField || !usernameField || !emailField) {
return;
}
// 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 });
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 });
// Initial validation
validateAll();
}
// Export for use in template
(window as any).initPasswordValidation = initPasswordValidation;