refactor(js): extract vault-health module from app.js monofile (§3.1)

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>
This commit is contained in:
r-zakarya
2026-07-08 15:07:42 +01:00
parent 8e0e7fd330
commit b44b05118e
7 changed files with 275 additions and 251 deletions
+261
View File
@@ -0,0 +1,261 @@
// ============================================================
// 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;
}
+2 -247
View File
@@ -2740,254 +2740,9 @@ function renderAuthenticatorGrid(grid, entries) {
}
// ============================================================
// VAULT HEALTH dashboard
// VAULT HEALTH — extracted to js/app.health.js (§3.1), loaded as a
// separate <script> before this file (pure declarations).
// ============================================================
//
// 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;
}
// ============================================================
// AUDIT LOG VIEWER
+1 -1
View File
@@ -30,7 +30,7 @@ const { webcrypto } = require('node:crypto');
// top-level const/let across separate runInContext calls, so we CONCATENATE
// the app.* parts (in <script> load order) into one script. argon2.js is a
// self-contained IIFE and loads separately (see below).
const APP_PARTS = ['app.crypto.js', 'app.totp.js', 'app.favicon.js', 'app.import.js', 'app.backup.js', 'app.js', 'app.sync.js'].map(f => path.join(__dirname, '..', f));
const APP_PARTS = ['app.crypto.js', 'app.totp.js', 'app.favicon.js', 'app.import.js', 'app.backup.js', 'app.health.js', 'app.js', 'app.sync.js'].map(f => path.join(__dirname, '..', f));
// In-memory Storage stub (Web Storage API surface used by app.js).
function makeStorage() {