// ============================================================ // app.health.js — VAULT HEALTH dashboard (extracted from app.js, §3.1) // ============================================================ // // Weak/reused/old/pwned scoring + the health view render. Pure declarations // (state + consts), no top-level side effects → loads BEFORE app.js. Uses // computeStrength, decryptPwd, state, api, render via shared global scope at // call time. NOTE: auditCache/auditFilter live here (they sit in this var // block) but drive the separate Audit-log viewer in app.js — resolved // cross-file via shared scope, same as other cross-module refs. // // ============================================================ // VAULT HEALTH dashboard // ============================================================ // // One-shot computation per session — decrypting every entry is the // expensive part, so we cache the result and clear it on lock / entry // edit / view re-entry (Tools → Vault health). let healthCache = null; // Audit log: array of {id, action, ip, created_at}. Refreshed on demand // from the server. Cleared on lockVault. let auditCache = null; let auditFilter = ''; // Per-category expand state. Survives full re-renders of the dashboard // (renderGrid runs from openSlideOver → would otherwise reset every // "Show all" toggle back to collapsed). const healthExpanded = { weak: false, reused: false, old: false, pwned: false }; const HEALTH_WEAK_THRESHOLD = 50; // computeStrength score < 50 → weak const HEALTH_OLD_DAYS = 365; // entries not updated in > 1 year function entryAgeDays(e) { const ts = e.updated_at || e.created_at; if (!ts) return 0; // ISO 'yyyy-mm-dd hh:nn:ss' → assume UTC-ish, close enough for ranking. const d = new Date(ts.replace(' ', 'T')); if (isNaN(d)) return 0; return Math.floor((Date.now() - d.getTime()) / 86400000); } async function computeHealthCache() { const weak = [], old = [], pwned = []; const byPwd = new Map(); // plaintext → [entries] // Notes have no password to weigh — their encrypted_password is just // the free-text body. Skipping them avoids polluting the "weak / reused" // categories with note content. for (const e of state.entries) { if ((e.kind || 'login') !== 'login') continue; const ageD = entryAgeDays(e); if (ageD > HEALTH_OLD_DAYS) old.push({ entry: e, ageDays: ageD }); const pwn = state.hibpResults.get(e.id); if (typeof pwn === 'number' && pwn > 0) pwned.push({ entry: e, count: pwn }); // Decrypt for strength + reuse detection. '[ERROR]' bubbles up // from decryptPwd for corrupted ciphertext — skip those silently. const plain = await decryptPwd(e.encrypted_password, e.iv); if (plain === '[ERROR]') continue; const score = computeStrength(plain); if (score < HEALTH_WEAK_THRESHOLD) weak.push({ entry: e, score }); if (!byPwd.has(plain)) byPwd.set(plain, []); byPwd.get(plain).push(e); } // Reuse: groups of ≥2 entries sharing the same plaintext password. const reused = []; for (const [, entries] of byPwd) { if (entries.length >= 2) reused.push(entries); } // Score: start at 100, subtract per issue (capped at 0). Weights // chosen so a single pwned password dominates over a single old one. let score = 100; score -= Math.min(40, weak.length * 5); score -= Math.min(30, reused.length * 10); score -= Math.min(20, old.length * 2); score -= Math.min(50, pwned.length * 15); if (score < 0) score = 0; return { weak, reused, old, pwned, score }; } function healthScoreBand(score) { if (score >= 80) return { label: 'Good', cls: 'is-ok' }; if (score >= 50) return { label: 'Fair', cls: 'is-fair' }; if (score >= 25) return { label: 'At risk', cls: 'is-warn' }; return { label: 'Critical', cls: 'is-danger' }; } // Open the entry slideover, unmask the password, focus it, and pulse the // generator button. The user keeps full context (which entry they're // fixing) and decides whether to type a new password, click the dice, or // dismiss. Auto-opening the generator modal hid the entry context and // forced an extra Save click — worse UX than this lighter nudge. async function openEntryForFix(entryId) { await openSlideOver(entryId); const pwd = document.getElementById('soPassword'); if (pwd) { pwd.type = 'text'; // unmask so the user sees what they're replacing pwd.focus(); pwd.select(); } const genBtn = document.querySelector('.so-pw-row button[title="Generate"]'); if (genBtn) { genBtn.classList.add('is-pulse'); setTimeout(() => genBtn.classList.remove('is-pulse'), 2000); } } async function renderHealthDashboard(grid) { // Recompute on demand. The "Recompute" button below also triggers it. if (!healthCache) { grid.appendChild(el('div', { class: 'health-loading' }, 'Analysing ' + state.entries.length + ' entries…')); healthCache = await computeHealthCache(); grid.innerHTML = ''; } const h = healthCache; const band = healthScoreBand(h.score); // Header: big score + recompute action const header = el('div', { class: 'health-header' }); const scoreEl = el('div', { class: 'health-score ' + band.cls }); scoreEl.appendChild(el('div', { class: 'health-score-num' }, String(h.score))); scoreEl.appendChild(el('div', { class: 'health-score-lbl' }, band.label)); header.appendChild(scoreEl); const intro = el('div', { class: 'health-intro' }); intro.appendChild(el('h3', null, 'How healthy is your vault?')); intro.appendChild(el('p', null, 'A summary of weak, reused, old and breached passwords. ' + 'Click any item to open it and rotate the password.')); const recompute = el('button', { class: 'btn btn-ghost btn-sm', type: 'button' }); recompute.appendChild(icon('i-rotate-ccw')); recompute.appendChild(document.createTextNode(' Recompute')); recompute.addEventListener('click', () => { healthCache = null; render(); }); intro.appendChild(recompute); header.appendChild(intro); grid.appendChild(header); // Four category cards grid.appendChild(renderHealthSection({ key: 'weak', title: 'Weak passwords', hint: 'Strength score below ' + HEALTH_WEAK_THRESHOLD + '/100 (short / few character classes).', items: h.weak, empty: 'All passwords pass the strength check. 👍', formatItem: it => entryDisplayName(it.entry) + ' — ' + it.score + '/100', })); grid.appendChild(renderHealthSection({ key: 'reused', title: 'Reused passwords', hint: 'Same password used on multiple entries — a single breach affects them all.', items: h.reused, empty: 'Every password is unique. 👍', formatItem: group => group.map(e => entryDisplayName(e)).join(' · ') + ' (' + group.length + ' entries)', // Click on a reused group: open the first entry. Could be smarter. idOfItem: group => group[0].id, })); grid.appendChild(renderHealthSection({ key: 'old', title: 'Old passwords', hint: 'Not updated for more than ' + Math.round(HEALTH_OLD_DAYS / 30) + ' months. Consider rotating periodically for high-value accounts.', items: h.old, empty: 'No stale passwords.', formatItem: it => entryDisplayName(it.entry) + ' — ' + Math.floor(it.ageDays / 30) + ' months old', })); grid.appendChild(renderHealthSection({ key: 'pwned', title: 'Breached passwords (HIBP)', hint: state.hibpEnabled ? 'Found in the Have I Been Pwned database. Change them now.' : 'Enable “Check passwords against breach database” in Settings to populate this list.', items: h.pwned, empty: state.hibpEnabled ? 'No password matches a known breach. 👍' : '— breach check is OFF —', formatItem: it => entryDisplayName(it.entry) + ' — seen ' + it.count.toLocaleString() + 'x', })); } // Build one collapsible category card. opts: // title, hint, items[], empty, // formatItem(item) → text for the row, // idOfItem(item) → entry id used by the Fix click. Default: item.entry.id function renderHealthSection(opts) { const card = el('section', { class: 'health-card' }); const head = el('header', { class: 'health-card-head' }); head.appendChild(el('h4', null, opts.title)); const badge = el('span', { class: 'health-badge' }, String(opts.items.length)); if (opts.items.length === 0) badge.classList.add('is-empty'); head.appendChild(badge); card.appendChild(head); card.appendChild(el('p', { class: 'health-hint' }, opts.hint)); if (opts.items.length === 0) { card.appendChild(el('p', { class: 'health-empty' }, opts.empty)); return card; } const list = el('ul', { class: 'health-list' }); const getId = opts.idOfItem || (it => it.entry.id); const INITIAL_LIMIT = 20; // Read persisted expand state so a renderGrid() triggered by // openSlideOver (after clicking "Fix") doesn't snap the list back // to the collapsed view. let expanded = !!(opts.key && healthExpanded[opts.key]); let shown = expanded ? opts.items.length : Math.min(INITIAL_LIMIT, opts.items.length); function renderRows() { list.innerHTML = ''; opts.items.slice(0, shown).forEach(it => { const li = el('li', { class: 'health-item' }); li.appendChild(el('span', { class: 'health-item-label' }, opts.formatItem(it))); const fix = el('button', { class: 'btn btn-ghost btn-xs', type: 'button' }, 'Fix'); fix.addEventListener('click', ev => { // Stop bubbling — the document-level "click outside slideover" // handler would otherwise close the slideover we're about to // open within the same click event. ev.stopPropagation(); openEntryForFix(getId(it)); }); li.appendChild(fix); list.appendChild(li); }); } renderRows(); card.appendChild(list); if (opts.items.length > INITIAL_LIMIT) { const more = el('button', { class: 'btn btn-ghost btn-xs health-more-btn', type: 'button', }, expanded ? 'Show less' : ('Show all ' + opts.items.length)); more.addEventListener('click', ev => { ev.stopPropagation(); expanded = !expanded; shown = expanded ? opts.items.length : INITIAL_LIMIT; more.textContent = expanded ? 'Show less' : 'Show all ' + opts.items.length; if (opts.key) healthExpanded[opts.key] = expanded; renderRows(); }); card.appendChild(more); } return card; }