Files
2026-07-02 14:36:06 -04:00

177 lines
6.3 KiB
JavaScript

/**
* 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
? '<i class="bi bi-check-circle-fill"></i>'
: '<i class="bi bi-circle"></i>';
}
}
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 <input> (required)
* confirm - id/element of the confirm-password <input> (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 = `<i class="bi bi-${shieldIcon}"></i> ${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
? '<i class="bi bi-check-circle-fill"></i> Passwords match'
: '<i class="bi bi-x-circle-fill"></i> 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);