/** * Shared password-strength UI: live rule checklist, strength meter, * confirm-password match indicator, and show/hide toggle. * * The RULES below mirror validate_password() in * app/services/validation_service.py exactly. This is the single * client-side copy of that policy — every password form (register, * profile, admin create/edit user, password reset) includes this file * instead of re-implementing the checks, so the two can never drift * out of sync with each other. The server remains the source of truth; * this file only gives the user live feedback before they submit. */ (function (global) { 'use strict'; const RULES = { len : pw => pw.length >= 8 && pw.length <= 128, upper : pw => /[A-Z]/.test(pw), digit : pw => /\d/.test(pw), special: pw => /[!@#$%^&*()\-_=+\[\]{};:'",.<>?/\\|`~]/.test(pw), }; const LEVELS = [ { label: 'Very Weak', color: '#ef4444', pct: 12 }, { label: 'Weak', color: '#f97316', pct: 30 }, { label: 'Fair', color: '#eab308', pct: 52 }, { label: 'Good', color: '#22c55e', pct: 76 }, { label: 'Strong', color: '#059669', pct: 100 }, ]; function score(pw) { let s = 0; if (RULES.len(pw)) s++; if (RULES.upper(pw)) s++; if (RULES.digit(pw)) s++; if (RULES.special(pw)) s++; if (pw.length >= 16) s++; // bonus for extra length return s; } function allRulesMet(pw) { return RULES.len(pw) && RULES.upper(pw) && RULES.digit(pw) && RULES.special(pw); } function byId(idOrEl) { return typeof idOrEl === 'string' ? document.getElementById(idOrEl) : idOrEl; } function setRuleEl(el, met) { if (!el) return; el.classList.toggle('met', met); el.classList.toggle('unmet', !met); const icon = el.querySelector('.ri'); if (icon) { icon.innerHTML = met ? '' : ''; } } function toggleVisibility(inputEl, btnEl) { if (!inputEl || !btnEl) return; const showing = inputEl.type === 'password'; inputEl.type = showing ? 'text' : 'password'; const icon = btnEl.querySelector('i'); if (icon) icon.className = showing ? 'bi bi-eye-slash' : 'bi bi-eye'; btnEl.title = showing ? 'Hide password' : 'Show password'; } /** * Wire up a password field with a live strength bar + rule checklist, * and optionally a confirm-password field with a match indicator. * * opts: * password - id/element of the password (required) * confirm - id/element of the confirm-password (optional) * strengthBar - id/element of the strength bar fill * strengthLabel - id/element of the strength label text * strengthWrap - id/element to show/hide with the strength bar * rulesWrap - id/element of the checklist container to show/hide * rules - { len, upper, digit, special } -> ids/elements of checklist rows * matchMsg - id/element for the confirm-match message * toggles - [{ input, button }, ...] show/hide password buttons * onChange() - called after every recompute (e.g. to gate a submit button) */ function init(opts) { const pwEl = byId(opts.password); if (!pwEl) return null; const confirmEl = byId(opts.confirm); const barEl = byId(opts.strengthBar); const labelEl = byId(opts.strengthLabel); const strengthWrapEl = byId(opts.strengthWrap); const rulesWrapEl = byId(opts.rulesWrap); const matchMsgEl = byId(opts.matchMsg); const ruleEls = {}; if (opts.rules) { Object.keys(opts.rules).forEach(k => { ruleEls[k] = byId(opts.rules[k]); }); } function renderStrength() { const pw = pwEl.value; if (pw.length === 0) { if (barEl) barEl.style.width = '0%'; if (labelEl) { labelEl.textContent = ''; labelEl.style.color = ''; } if (strengthWrapEl) strengthWrapEl.style.display = 'none'; if (rulesWrapEl) rulesWrapEl.style.display = 'none'; pwEl.classList.remove('valid', 'invalid'); } else { if (strengthWrapEl) strengthWrapEl.style.display = 'block'; if (rulesWrapEl) rulesWrapEl.style.display = 'grid'; Object.keys(ruleEls).forEach(k => setRuleEl(ruleEls[k], RULES[k](pw))); const s = score(pw); const lvl = LEVELS[Math.max(0, s - 1)] || LEVELS[0]; if (barEl) { barEl.style.width = lvl.pct + '%'; barEl.style.background = lvl.color; } if (labelEl) { const shieldIcon = s >= 4 ? 'shield-fill' : s >= 2 ? 'shield-half' : 'shield'; labelEl.innerHTML = ` ${lvl.label}`; labelEl.style.color = lvl.color; } const met = allRulesMet(pw); pwEl.classList.toggle('valid', met); pwEl.classList.toggle('invalid', !met); } renderMatch(); if (opts.onChange) opts.onChange(); } function renderMatch() { if (!confirmEl) return; const pw = pwEl.value; const pw2 = confirmEl.value; if (pw2.length === 0) { if (matchMsgEl) { matchMsgEl.innerHTML = ''; matchMsgEl.classList.remove('ok', 'fail'); } confirmEl.classList.remove('valid', 'invalid'); return; } const ok = pw === pw2; if (matchMsgEl) { matchMsgEl.innerHTML = ok ? ' Passwords match' : ' Passwords do not match'; matchMsgEl.classList.toggle('ok', ok); matchMsgEl.classList.toggle('fail', !ok); } confirmEl.classList.toggle('valid', ok); confirmEl.classList.toggle('invalid', !ok); } pwEl.addEventListener('input', renderStrength); if (confirmEl) { confirmEl.addEventListener('input', function () { renderMatch(); if (opts.onChange) opts.onChange(); }); } if (opts.toggles) { opts.toggles.forEach(({ input, button }) => { const inputEl = byId(input); const buttonEl = byId(button); if (buttonEl) buttonEl.addEventListener('click', () => toggleVisibility(inputEl, buttonEl)); }); } return { allRulesMet, score, renderStrength, renderMatch }; } global.PasswordStrength = { init, RULES, allRulesMet, score }; })(window);