b44b05118e
Seventh slice of the app.js split. Moves the vault-health dashboard (computeHealthCache, healthScoreBand, renderHealthDashboard/Section, openEntryForFix, entryAgeDays + scoring consts) to js/app.health.js. Pure declarations, no top-level side effects → loads before app.js. Uses computeStrength/decryptPwd/state/api via shared global scope at call time. - Byte-for-byte identical extraction; syntax OK on all eight app parts. - auditCache/auditFilter sit in this var block but drive the separate Audit-log viewer in app.js — they ride along and resolve cross-file via shared scope (documented). - index.html + BuildAssets whitelist + harness APP_PARTS updated. - 62/62 tests green. app.js: 11936 → 9545 lines (7 modules extracted). NOTE: quick search is NOT contiguous (interleaved with cheatsheet + history-modal code, lines 886-1063), so a clean byte-identical extraction isn't trivial — deferred. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
262 lines
11 KiB
JavaScript
262 lines
11 KiB
JavaScript
// ============================================================
|
|
// 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;
|
|
}
|
|
|