feat: website favicons + vault health dashboard
Favicons
- PM.Favicon (new): THTTPClient/WinHTTP proxy to icons.duckduckgo.com.
Native Windows TLS — no OpenSSL DLLs to ship (Indy would fail
silently without them). 5 s timeout, max 3 redirects, 64 KB cap,
magic-byte MIME sniffing.
- DB: vault_entries.icon_b64 TEXT (idempotent migration).
- Endpoints: POST /entries/{id}/icon stores a cached data URI without
forcing a full PUT (which would re-encrypt the password). DELETE
/entries/icons/all purges the cache.
- Bridge cmd://favicon/fetch?host=X&reqId=Y runs in an anonymous thread
so the up-to-5 s HTTP GET doesn't block the main thread; result
shipped back via Bridge.onFaviconResult(reqId, host, dataUri).
- Hostname validated on both sides (JS faviconHost + Delphi
NormalizeHost) so brand labels like "Gitea" never leak upstream.
- Settings: opt-in "Fetch website icons" toggle (synced), three explicit
actions (Fetch missing / Re-fetch all / Clear cache) that bypass the
toggle — manual user actions always work.
- Entry card avatar shows <img> when cached, falls back to initials.
onerror handler recovers silently from a corrupt data URI.
Vault health
- New sidebar Tools → "Vault health" view. Four category cards:
Weak (strength < 50), Reused (same plaintext on ≥ 2 entries), Old
(updated_at > 365d), Pwned (HIBP cache).
- Score 0-100 with colour band (Good/Fair/At risk/Critical).
- One-shot computation cached per session (healthCache), invalidated
on lockVault, entry save, and the explicit "Recompute" button.
- "Fix" button on each item opens the slideover for the affected
entry, unmasks the password, focuses it, and pulses the dice button
— full context preserved, user decides how to fix.
- Click handler stopPropagation prevents the document-level
"click outside slideover" listener from closing the panel that
we just opened in the same click event.
Fixes
- openSlideover typo (lowercase O) → openSlideOver across all call
sites. Was silently breaking the Authenticator card click and the
Vault health Fix button.
- W1050 WideChar warning in PM.Favicon — replaced set-membership
with explicit Ord-style range comparisons.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,8 @@ const API = (location.pathname.indexOf('/password-manager/') === 0)
|
||||
// Falls back to navigator.clipboard for the standalone PHP frontend.
|
||||
const prefResolvers = {};
|
||||
let autoStartResolver = null;
|
||||
const faviconResolvers = {};
|
||||
let _faviconReqSeq = 0;
|
||||
const Bridge = (() => {
|
||||
const active = (API === '');
|
||||
|
||||
@@ -218,6 +220,33 @@ const Bridge = (() => {
|
||||
const cb = document.getElementById('settingAutoStart');
|
||||
if (cb) cb.checked = !!enabled;
|
||||
},
|
||||
|
||||
// ---- Favicon fetch (via Delphi proxy → DuckDuckGo icons) ----------
|
||||
// Returns a Promise<dataUri|''>. Multiple in-flight requests for
|
||||
// distinct hosts are tracked per reqId so they can't collide.
|
||||
fetchFavicon(host) {
|
||||
if (!active) return Promise.resolve('');
|
||||
if (!host) return Promise.resolve('');
|
||||
const reqId = 'fav_' + (++_faviconReqSeq);
|
||||
return new Promise(resolve => {
|
||||
faviconResolvers[reqId] = resolve;
|
||||
cmd('cmd://favicon/fetch?host=' + encodeURIComponent(host) +
|
||||
'&reqId=' + encodeURIComponent(reqId));
|
||||
setTimeout(() => {
|
||||
if (faviconResolvers[reqId]) {
|
||||
delete faviconResolvers[reqId];
|
||||
resolve('');
|
||||
}
|
||||
}, 8000);
|
||||
});
|
||||
},
|
||||
onFaviconResult(reqId, host, dataUri) {
|
||||
const r = faviconResolvers[reqId];
|
||||
if (r) {
|
||||
delete faviconResolvers[reqId];
|
||||
r(dataUri || '');
|
||||
}
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -280,6 +309,10 @@ const state = {
|
||||
'{"ctrl":true,"shift":true,"alt":false,"win":false,"key":"P"}'),
|
||||
sidebarCollapsed: JSON.parse(localStorage.getItem('sidebarCollapsed') ||
|
||||
'{"folders":false,"tags":false,"tools":false}'),
|
||||
// Fetch website favicons via the Delphi DuckDuckGo proxy. OFF by
|
||||
// default — opt-in because it sends each entry's domain to a third
|
||||
// party (DuckDuckGo). Synced because it's a portable preference.
|
||||
faviconsEnabled: localStorage.getItem('faviconsEnabled') === '1',
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
@@ -506,6 +539,111 @@ async function decryptTotpSecret(encB64, ivB64) {
|
||||
return await decryptPwd(encB64, ivB64);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// FAVICONS (opt-in, cached server-side as base64 data URI)
|
||||
// ============================================================
|
||||
|
||||
// Extract a usable host from entry.site (we accept anything user-typed).
|
||||
// Returns '' for values that don't look like real hostnames — common case
|
||||
// is users storing a brand label ("Gitea", "Work GitHub") to help the
|
||||
// autofill matcher. Sending those to DDG would leak meaningless tokens
|
||||
// without ever producing an icon.
|
||||
function faviconHost(siteRaw) {
|
||||
if (!siteRaw) return '';
|
||||
let s = String(siteRaw).trim().toLowerCase();
|
||||
s = s.replace(/^https?:\/\//, '').replace(/^www\./, '');
|
||||
s = s.split('/')[0].split(':')[0];
|
||||
// Validate: dot-separated labels, only hostname-safe chars, TLD ≥ 2
|
||||
// letters. Rejects "Gitea", "my work pwd", IP-like "1.2.3.4" stays
|
||||
// valid (DDG handles IPs gracefully). 253-char overall cap mirrors
|
||||
// the DNS spec.
|
||||
if (!s || s.length > 253) return '';
|
||||
if (!/^[a-z0-9.-]+$/.test(s)) return '';
|
||||
if (s.indexOf('.') < 1) return '';
|
||||
if (!/\.[a-z]{2,}$/.test(s)) return '';
|
||||
if (s.startsWith('.') || s.endsWith('.')) return '';
|
||||
if (s.indexOf('..') >= 0) return '';
|
||||
return s;
|
||||
}
|
||||
|
||||
// Save the icon for one entry via the dedicated endpoint (no full PUT,
|
||||
// no re-encryption). Fire-and-forget: failures are silent so a flaky
|
||||
// network doesn't break the user's flow.
|
||||
async function saveEntryIcon(entryId, dataUri) {
|
||||
try {
|
||||
await fetch(API + '/entries/' + entryId + '/icon', {
|
||||
method: 'POST',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ icon_b64: dataUri || '' }),
|
||||
});
|
||||
} catch (e) { /* silent */ }
|
||||
}
|
||||
|
||||
// Fetch + save the favicon for one entry. Updates state.entries in-place
|
||||
// so the next render() picks it up. No-op if the entry already has one.
|
||||
// opts: { force: bypass "already has icon" skip, manual: bypass the global
|
||||
// faviconsEnabled toggle (for explicit user actions like the Refresh button) }
|
||||
async function ensureEntryFavicon(entry, opts) {
|
||||
opts = opts || {};
|
||||
if (!Bridge.active) return;
|
||||
if (!opts.manual && !state.faviconsEnabled) return;
|
||||
if (!opts.force && entry.icon_b64) return;
|
||||
const host = faviconHost(entry.site);
|
||||
if (!host) return;
|
||||
const dataUri = await Bridge.fetchFavicon(host);
|
||||
if (!dataUri) return;
|
||||
entry.icon_b64 = dataUri;
|
||||
await saveEntryIcon(entry.id, dataUri);
|
||||
// Full render() — patching the avatar in place is fragile because
|
||||
// the avatar also contains the checkbox overlay.
|
||||
render();
|
||||
}
|
||||
|
||||
// Backfill: walk state.entries, fetch missing icons one at a time so we
|
||||
// don't hammer the upstream. Used by the "Refresh icons" button.
|
||||
async function backfillFavicons(force) {
|
||||
if (!Bridge.active) return;
|
||||
const all = state.entries;
|
||||
const eligible = all.filter(e => faviconHost(e.site));
|
||||
const skipped = all.length - eligible.length;
|
||||
const targets = eligible.filter(e => force || !e.icon_b64);
|
||||
if (targets.length === 0) {
|
||||
if (skipped > 0) {
|
||||
toast('No icons to fetch — ' + skipped +
|
||||
' entries have a non-domain site (e.g. "Gitea")', 'warning');
|
||||
} else {
|
||||
toast('No icons to fetch');
|
||||
}
|
||||
return;
|
||||
}
|
||||
toast('Fetching ' + targets.length + ' icon' + (targets.length === 1 ? '' : 's') + '…');
|
||||
let ok = 0;
|
||||
for (const e of targets) {
|
||||
// Explicit user action — bypass the global toggle so the buttons
|
||||
// work even when "Fetch website icons" is OFF (the toggle only
|
||||
// gates auto-fetch on save).
|
||||
await ensureEntryFavicon(e, { force: !!force, manual: true });
|
||||
if (e.icon_b64) ok++;
|
||||
}
|
||||
toast('Fetched ' + ok + ' / ' + targets.length + ' icons');
|
||||
render();
|
||||
}
|
||||
|
||||
async function clearAllFavicons() {
|
||||
try {
|
||||
await fetch(API + '/entries/icons/all', {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders(),
|
||||
});
|
||||
} catch (e) {
|
||||
toast('Failed to clear icons', 'error');
|
||||
return;
|
||||
}
|
||||
state.entries.forEach(e => { e.icon_b64 = null; });
|
||||
render();
|
||||
toast('Cached icons cleared');
|
||||
}
|
||||
|
||||
// Generate a cryptographically random RFC 4648 base32 secret. 20 bytes =
|
||||
// 160 bits → 32 base32 chars, RFC 6238 §5.1 recommended TOTP key size.
|
||||
function randomBase32Secret(numBytes) {
|
||||
@@ -1046,6 +1184,7 @@ function lockVault() {
|
||||
if (typeof totpToolTimer !== 'undefined' && totpToolTimer) {
|
||||
clearInterval(totpToolTimer); totpToolTimer = null;
|
||||
}
|
||||
if (typeof healthCache !== 'undefined') healthCache = null;
|
||||
showAuth();
|
||||
|
||||
// Two UI variants for the auth screen:
|
||||
@@ -1212,6 +1351,7 @@ function viewTitle() {
|
||||
if (state.view === 'favorites') return 'Favorites';
|
||||
if (state.view === 'trash') return 'Trash';
|
||||
if (state.view === 'authenticator') return 'Authenticator';
|
||||
if (state.view === 'health') return 'Vault health';
|
||||
if (state.view.startsWith('folder:')) return state.view.slice(7);
|
||||
if (state.view.startsWith('tag:')) return '# ' + state.view.slice(4);
|
||||
return 'Items';
|
||||
@@ -1386,6 +1526,21 @@ async function addTagToEntry(id, tag) {
|
||||
function renderGrid() {
|
||||
$('#contentTitle').textContent = viewTitle();
|
||||
|
||||
// Vault health dashboard: bypass the standard list rendering entirely.
|
||||
if (state.view === 'health') {
|
||||
if (authTickTimer) { clearInterval(authTickTimer); authTickTimer = null; }
|
||||
const oldBtn = $('#emptyTrashBtn'); if (oldBtn) oldBtn.remove();
|
||||
renderBatchBar();
|
||||
$('#contentMeta').textContent = state.entries.length +
|
||||
(state.entries.length === 1 ? ' entry analysed' : ' entries analysed');
|
||||
const grid = $('#entryGrid');
|
||||
grid.className = 'entry-grid is-health';
|
||||
grid.innerHTML = '';
|
||||
$('#emptyState').classList.add('is-hidden');
|
||||
renderHealthDashboard(grid);
|
||||
return;
|
||||
}
|
||||
|
||||
// Authenticator view: bypass the standard pipeline — render a dedicated
|
||||
// grid of TOTP cards (only entries that have a TOTP secret configured).
|
||||
if (state.view === 'authenticator') {
|
||||
@@ -1496,7 +1651,7 @@ function renderAuthenticatorGrid(grid, entries) {
|
||||
wrap.appendChild(barWrap);
|
||||
|
||||
// Click anywhere on card (outside copy) opens the entry detail.
|
||||
wrap.addEventListener('click', () => openSlideover(e.id));
|
||||
wrap.addEventListener('click', () => openSlideOver(e.id));
|
||||
|
||||
return { entry: e, wrap, codeEl, bar, secret: null };
|
||||
});
|
||||
@@ -1546,6 +1701,214 @@ function renderAuthenticatorGrid(grid, entries) {
|
||||
})();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 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;
|
||||
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]
|
||||
for (const e of state.entries) {
|
||||
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({
|
||||
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({
|
||||
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({
|
||||
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({
|
||||
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);
|
||||
opts.items.slice(0, 20).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);
|
||||
});
|
||||
card.appendChild(list);
|
||||
if (opts.items.length > 20) {
|
||||
card.appendChild(el('p', { class: 'health-more' },
|
||||
'+ ' + (opts.items.length - 20) + ' more not shown'));
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
function showEmptyState() {
|
||||
const illustration = $('#emptyIllustration use');
|
||||
const title = $('#emptyTitle');
|
||||
@@ -1714,9 +2077,19 @@ function renderCard(e) {
|
||||
// checked. Card click anywhere not on the checkbox opens slideover.
|
||||
const head = el('div', { class: 'entry-head' });
|
||||
const displayName = entryDisplayName(e);
|
||||
const avatar = el('div', {
|
||||
class: 'entry-avatar',
|
||||
}, initials(displayName));
|
||||
const avatar = el('div', { class: 'entry-avatar' });
|
||||
if (e.icon_b64) {
|
||||
const img = el('img', { src: e.icon_b64, alt: '', class: 'entry-avatar-img' });
|
||||
// If the cached data URI fails to decode (corrupt blob), fall
|
||||
// back to the initials so the card never shows a broken-image icon.
|
||||
img.addEventListener('error', () => {
|
||||
avatar.innerHTML = '';
|
||||
avatar.textContent = initials(displayName);
|
||||
});
|
||||
avatar.appendChild(img);
|
||||
} else {
|
||||
avatar.textContent = initials(displayName);
|
||||
}
|
||||
const checkbox = el('button', {
|
||||
class: 'entry-check' + (checked ? ' is-checked' : ''),
|
||||
type: 'button',
|
||||
@@ -2988,8 +3361,16 @@ async function saveEntry(e) {
|
||||
}
|
||||
closeEntryModal();
|
||||
await loadEntries();
|
||||
if (typeof healthCache !== 'undefined') healthCache = null;
|
||||
render();
|
||||
if (savedId) flashEntry(savedId);
|
||||
// Fire-and-forget favicon fetch for the saved entry. Updates the
|
||||
// card in place when it arrives. No-op when feature is off or
|
||||
// the entry already has a cached icon.
|
||||
if (savedId && state.faviconsEnabled) {
|
||||
const saved = state.entries.find(e => e.id === savedId);
|
||||
if (saved) ensureEntryFavicon(saved); // honours the toggle
|
||||
}
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
}
|
||||
@@ -4875,6 +5256,11 @@ function openSettings() {
|
||||
$('#settingMaskUser').checked = state.maskUsernames;
|
||||
$('#settingHIBP').checked = state.hibpEnabled;
|
||||
$('#settingShowSite').checked = state.showSiteOnCards;
|
||||
$('#settingFavicons').checked = state.faviconsEnabled;
|
||||
// Action buttons + toggle row only meaningful when the Delphi bridge
|
||||
// is available (the PHP frontend has no outbound proxy).
|
||||
$('#settingFaviconsRow').style.display = Bridge.active ? '' : 'none';
|
||||
$('#settingFaviconActionsRow').style.display = Bridge.active ? 'flex' : 'none';
|
||||
$('#settingAutofill').checked = state.autofillEnabled;
|
||||
$('#settingAutofillRow').style.display = Bridge.active ? '' : 'none';
|
||||
// Hotkey capture buttons — labels reflect current combos.
|
||||
@@ -5034,6 +5420,7 @@ const SYNCED_SETTING_KEYS = [
|
||||
// Sidebar section collapsed state. Object of { folders, tags, tools }
|
||||
// booleans. Synced so the user gets the same fold state across devices.
|
||||
'sidebarCollapsed',
|
||||
'faviconsEnabled',
|
||||
];
|
||||
|
||||
function applySidebarCollapsed() {
|
||||
@@ -5073,6 +5460,9 @@ async function loadServerSettings() {
|
||||
// Object; persist as JSON so the next cold start picks it up.
|
||||
localStorage.setItem(k, JSON.stringify(v));
|
||||
break;
|
||||
case 'faviconsEnabled':
|
||||
localStorage.setItem('faviconsEnabled', v ? '1' : '0');
|
||||
break;
|
||||
}
|
||||
});
|
||||
// Apply visual settings immediately.
|
||||
@@ -5394,6 +5784,14 @@ async function init() {
|
||||
render();
|
||||
});
|
||||
$('#sidebarTotpToolBtn').addEventListener('click', openTotpTool);
|
||||
$('#sidebarHealthBtn').addEventListener('click', () => {
|
||||
state.view = 'health';
|
||||
state.currentPage = 1;
|
||||
$$('.nav-item').forEach(b => b.classList.remove('is-active'));
|
||||
// Invalidate any stale cache so we recompute fresh each open.
|
||||
healthCache = null;
|
||||
render();
|
||||
});
|
||||
|
||||
// Sidebar section collapse toggles
|
||||
document.querySelectorAll('[data-section-toggle]').forEach(btn => {
|
||||
@@ -5481,6 +5879,29 @@ async function init() {
|
||||
? 'Will start with Windows (in tray)'
|
||||
: 'Won’t start with Windows');
|
||||
});
|
||||
$('#settingFavicons').addEventListener('change', e => {
|
||||
state.faviconsEnabled = e.target.checked;
|
||||
localStorage.setItem('faviconsEnabled', state.faviconsEnabled ? '1' : '0');
|
||||
saveServerSettings();
|
||||
if (state.faviconsEnabled) {
|
||||
// Auto-backfill on first opt-in so the user sees the effect
|
||||
// immediately instead of having to click the refresh button.
|
||||
backfillFavicons(false);
|
||||
} else {
|
||||
toast('Website icons disabled (cached icons kept)');
|
||||
}
|
||||
});
|
||||
$('#settingFaviconsRefresh').addEventListener('click', () => backfillFavicons(false));
|
||||
$('#settingFaviconsRefreshAll').addEventListener('click', () => backfillFavicons(true));
|
||||
$('#settingFaviconsClear').addEventListener('click', async () => {
|
||||
const ok = await confirmDialog({
|
||||
title: 'Clear cached icons?',
|
||||
message: 'All website icons cached in your vault will be removed. They will be re-fetched on demand if the toggle stays on.',
|
||||
okText: 'Clear',
|
||||
danger: true,
|
||||
});
|
||||
if (ok) clearAllFavicons();
|
||||
});
|
||||
$('#settingAutofill').addEventListener('change', e => {
|
||||
state.autofillEnabled = e.target.checked;
|
||||
localStorage.setItem('autofillEnabled', state.autofillEnabled ? '1' : '0');
|
||||
|
||||
Reference in New Issue
Block a user