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:
@@ -37,6 +37,7 @@ js/app.totp.js (TOTP RFC 6238 + TOTP/custom-field crypto — extrait §3.1)
|
||||
js/app.favicon.js (favicon fetch/cache + faviconHost — extrait §3.1)
|
||||
js/app.import.js (export container + import CSV/JSON — extrait §3.1)
|
||||
js/app.backup.js (auto-backup planifié — extrait §3.1)
|
||||
js/app.health.js (vault health dashboard — extrait §3.1)
|
||||
js/app.js (le reste : state, Bridge, api, UI…)
|
||||
js/app.sync.js (WebDAV + merge — extrait §3.1, APRÈS app.js car
|
||||
effet de bord top-level `Bridge.onWebdavResult = …`)
|
||||
@@ -107,6 +108,7 @@ seule `api()` est stubbée).
|
||||
| Favicons (fetch/cache, `faviconHost`) — extrait §3.1 | `js/app.favicon.js` |
|
||||
| Import/export frontend (CSV/JSON parse, export container) — extrait §3.1 | `js/app.import.js` |
|
||||
| Auto-backup frontend (planifié, chiffré) — extrait §3.1 | `js/app.backup.js` |
|
||||
| Vault health dashboard — extrait §3.1 | `js/app.health.js` |
|
||||
| Sync frontend (WebDAV, snapshot, merge) — extrait §3.1 | `js/app.sync.js` |
|
||||
| Argon2id vendé (bundle `@noble/hashes`, IIFE) | `js/argon2.js` |
|
||||
| HTML racine | `index.html` |
|
||||
|
||||
+7
-3
@@ -242,10 +242,14 @@ réécriture des call-sites, risque quasi nul vs conversion en modules ES).
|
||||
**+7 tests `faviconHost`** (extraction hostname → décide quel domaine part
|
||||
vers DDG). A révélé un commentaire faux (IPs « valides » alors qu'elles
|
||||
sont rejetées par la règle TLD `[a-z]{2,}`) — corrigé.
|
||||
- `app.js` : 11 936 → **9 790 lignes** (6 modules sortis).
|
||||
- ✅ `js/app.health.js` extrait (vault health dashboard) — byte-for-byte
|
||||
identique, chargé AVANT app.js (`auditCache`/`auditFilter` viennent avec,
|
||||
résolus cross-fichier).
|
||||
- `app.js` : 11 936 → **9 545 lignes** (7 modules sortis).
|
||||
- Suite de tests : 42 → **62 tests**.
|
||||
- Reste à extraire (grosses sections cohésives) : slideover, settings,
|
||||
quicksearch, autofill, vault-health…
|
||||
- Reste : slideover, settings, autofill… Note : **quicksearch n'est PAS
|
||||
contigu** (entrelacé avec cheatsheet + history-modal 886-1063) → extraction
|
||||
propre pas triviale, reportée.
|
||||
- ✅ `node --check` en pré-étape de `BuildAssets.ps1` : **déjà fait** (cf. §3.2).
|
||||
|
||||
### 3.2 🟡 Aucun test automatisé — **partiellement adressé (2026-07-04)**
|
||||
|
||||
@@ -57,6 +57,7 @@ $patterns = @(
|
||||
'js\app.favicon.js',
|
||||
'js\app.import.js',
|
||||
'js\app.backup.js',
|
||||
'js\app.health.js',
|
||||
'js\app.js',
|
||||
'js\app.sync.js',
|
||||
'css\style.css'
|
||||
|
||||
@@ -1197,6 +1197,7 @@
|
||||
<script src="js/app.favicon.js"></script>
|
||||
<script src="js/app.import.js"></script>
|
||||
<script src="js/app.backup.js"></script>
|
||||
<script src="js/app.health.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
<script src="js/app.sync.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user