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.favicon.js (favicon fetch/cache + faviconHost — extrait §3.1)
|
||||||
js/app.import.js (export container + import CSV/JSON — 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.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.js (le reste : state, Bridge, api, UI…)
|
||||||
js/app.sync.js (WebDAV + merge — extrait §3.1, APRÈS app.js car
|
js/app.sync.js (WebDAV + merge — extrait §3.1, APRÈS app.js car
|
||||||
effet de bord top-level `Bridge.onWebdavResult = …`)
|
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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| Sync frontend (WebDAV, snapshot, merge) — extrait §3.1 | `js/app.sync.js` |
|
||||||
| Argon2id vendé (bundle `@noble/hashes`, IIFE) | `js/argon2.js` |
|
| Argon2id vendé (bundle `@noble/hashes`, IIFE) | `js/argon2.js` |
|
||||||
| HTML racine | `index.html` |
|
| 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
|
**+7 tests `faviconHost`** (extraction hostname → décide quel domaine part
|
||||||
vers DDG). A révélé un commentaire faux (IPs « valides » alors qu'elles
|
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é.
|
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**.
|
- Suite de tests : 42 → **62 tests**.
|
||||||
- Reste à extraire (grosses sections cohésives) : slideover, settings,
|
- Reste : slideover, settings, autofill… Note : **quicksearch n'est PAS
|
||||||
quicksearch, autofill, vault-health…
|
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).
|
- ✅ `node --check` en pré-étape de `BuildAssets.ps1` : **déjà fait** (cf. §3.2).
|
||||||
|
|
||||||
### 3.2 🟡 Aucun test automatisé — **partiellement adressé (2026-07-04)**
|
### 3.2 🟡 Aucun test automatisé — **partiellement adressé (2026-07-04)**
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ $patterns = @(
|
|||||||
'js\app.favicon.js',
|
'js\app.favicon.js',
|
||||||
'js\app.import.js',
|
'js\app.import.js',
|
||||||
'js\app.backup.js',
|
'js\app.backup.js',
|
||||||
|
'js\app.health.js',
|
||||||
'js\app.js',
|
'js\app.js',
|
||||||
'js\app.sync.js',
|
'js\app.sync.js',
|
||||||
'css\style.css'
|
'css\style.css'
|
||||||
|
|||||||
@@ -1197,6 +1197,7 @@
|
|||||||
<script src="js/app.favicon.js"></script>
|
<script src="js/app.favicon.js"></script>
|
||||||
<script src="js/app.import.js"></script>
|
<script src="js/app.import.js"></script>
|
||||||
<script src="js/app.backup.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.js"></script>
|
||||||
<script src="js/app.sync.js"></script>
|
<script src="js/app.sync.js"></script>
|
||||||
</body>
|
</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
|
// 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
|
// 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
|
// the app.* parts (in <script> load order) into one script. argon2.js is a
|
||||||
// self-contained IIFE and loads separately (see below).
|
// 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).
|
// In-memory Storage stub (Web Storage API surface used by app.js).
|
||||||
function makeStorage() {
|
function makeStorage() {
|
||||||
|
|||||||
Reference in New Issue
Block a user