6bee69eb1a
Each category is capped, so past the cap fixing one more password leaves the total unchanged and the work looks wasted. The breakdown lists count and points per category, marks a capped deduction with (max), and explains in a tooltip why the score is standing still.
300 lines
13 KiB
JavaScript
300 lines
13 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) {
|
||
// Bail if the editor didn't actually switch (user cancelled the discard
|
||
// prompt): the steps below would unmask + select the password of whatever
|
||
// entry is still open, and pulse Generate on it — the wrong entry.
|
||
// noFocus: openSlideOver otherwise defers a focus+select on the title
|
||
// (setTimeout 80ms) which lands AFTER the password focus below and steals
|
||
// it back. Fix targets the password — it owns the focus here.
|
||
if (!(await openSlideOver(entryId, { noFocus: true }))) return;
|
||
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));
|
||
// Per-category breakdown. Each category is capped, so once a cap is reached
|
||
// fixing one more password leaves the total untouched — which reads as "my
|
||
// work did nothing". Showing the raw deduction next to the capped one makes
|
||
// the progress visible even when the score sits still.
|
||
const parts = [
|
||
{ lbl: 'Weak', n: h.weak.length, per: 5, cap: 40 },
|
||
{ lbl: 'Reused', n: h.reused.length, per: 10, cap: 30 },
|
||
{ lbl: 'Old', n: h.old.length, per: 2, cap: 20 },
|
||
{ lbl: 'Pwned', n: h.pwned.length, per: 15, cap: 50 },
|
||
];
|
||
const breakdown = el('div', { class: 'health-score-breakdown' });
|
||
parts.forEach(p => {
|
||
const raw = p.n * p.per;
|
||
const applied = Math.min(p.cap, raw);
|
||
const row = el('div', {
|
||
class: 'health-bd-row' + (p.n === 0 ? ' is-clear' : ''),
|
||
title: raw > applied
|
||
? p.lbl + ': ' + p.n + ' × ' + p.per + ' = −' + raw +
|
||
', capped at −' + p.cap + '. Fixing one more won\'t move the ' +
|
||
'score until you drop below the cap.'
|
||
: p.lbl + ': ' + p.n + ' × ' + p.per + ' points',
|
||
});
|
||
row.appendChild(el('span', { class: 'health-bd-lbl' }, p.lbl));
|
||
row.appendChild(el('span', { class: 'health-bd-n' }, String(p.n)));
|
||
row.appendChild(el('span', { class: 'health-bd-pts' },
|
||
applied === 0 ? '0' : '−' + applied + (raw > applied ? ' (max)' : '')));
|
||
breakdown.appendChild(row);
|
||
});
|
||
// Under the dial, not inside it — the circle is a fixed 110px.
|
||
const scoreCol = el('div', { class: 'health-score-col' });
|
||
scoreCol.appendChild(scoreEl);
|
||
scoreCol.appendChild(breakdown);
|
||
header.appendChild(scoreCol);
|
||
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;
|
||
}
|
||
|