05/20 Modify Vault, Security Dashboard UI

This commit is contained in:
2026-05-20 14:48:45 -04:00
parent 2049f06b05
commit 04f7368a95
2 changed files with 392 additions and 189 deletions
+186 -92
View File
@@ -520,22 +520,29 @@ const Vault = (() => {
});
}
// ── Helper: password strength label & CSS class ──────────────────────────
function _passwordStrength(password) {
if (!password) return null;
const charTypes = [/[A-Z]/, /[a-z]/, /[0-9]/, /[^A-Za-z0-9]/].filter(
(r) => r.test(password),
).length;
if (password.length < 8 || charTypes < 2) return { label: "Weak", cls: "strength-weak" };
if (password.length < 12 || charTypes < 3) return { label: "Medium", cls: "strength-medium" };
return { label: "Strong", cls: "strength-strong" };
}
// ── Helper: initials avatar for a vault item name ──────────────────────────
function _itemInitials(name) {
const words = name.trim().split(/\s+/);
if (words.length >= 2) return (words[0][0] + words[1][0]).toUpperCase();
return name.slice(0, 2).toUpperCase();
}
function createItemElement(item) {
const li = document.createElement("li");
li.className = "vault-item";
li.dataset.id = item.id;
const iconMap = {
password: "🔑",
note: "📝",
card: "💳",
bank: "🏦",
address: "🏠",
ssn: "🪪",
passkey: "🔐",
};
const icon = iconMap[item.item_type] || "🔑";
const itemTags = item.plain?.tags || [];
let subText = "";
if (item.plain) {
@@ -589,14 +596,27 @@ const Vault = (() => {
.map((t) => `<span class="item-tag">${escHtml(t)}</span>`)
.join("");
// Strength pill — only for password type
const strength =
item.item_type === "password"
? _passwordStrength(item.plain?.password)
: null;
const strengthPill = strength
? `<span class="item-strength-pill ${strength.cls}">${strength.label}</span>`
: "";
// Initials avatar
const initials = _itemInitials(item.name);
li.innerHTML = `
<input type="checkbox" class="item-checkbox" data-id="${item.id}" ${_selectedIds.has(item.id) ? "checked" : ""} aria-label="Select ${escHtml(item.name)}"/>
<div class="item-icon">${icon}</div>
<div class="item-avatar" aria-hidden="true">${escHtml(initials)}</div>
<div class="item-info">
<span class="item-name">${escHtml(item.name)}</span>
<span class="item-sub">${escHtml(subText)}</span>
${tagBadges ? `<div class="item-tags">${tagBadges}</div>` : ""}
${showTotp ? `<span class="item-totp" id="totp-display-${item.id}"><span class="totp-code">······</span><span class="totp-timer"></span></span>` : ""}
${strengthPill}
</div>
<div class="item-actions">
${showLaunch ? `<button class="btn-icon" title="Open URL" data-action="launch">🌐</button>` : ""}
@@ -1140,32 +1160,84 @@ const Vault = (() => {
const label =
score >= 80 ? "Good" : score >= 50 ? "Fair" : "Needs attention";
// ── Build new summary: circular ring + title + progress bars ────────────
const strongPct = Math.round(((total - weak.length) / total) * 100);
const uniqueIds = new Set(reused.map((i) => i.id));
const reusedCount = uniqueIds.size;
const uniquePct = Math.round(((total - reusedCount) / total) * 100);
// 2FA pct: items with totp stored vs all password items
const totpCount = pwItems.filter((i) =>
!!extractTotpSecret(i.plain?.totp_uri),
).length;
const twoFAPct = Math.round((totpCount / total) * 100);
// SVG ring math: r=42, circumference = 2*π*42 ≈ 264
const CIRC = 264;
const ringFill = Math.round((score / 100) * CIRC);
const ringColor =
score >= 80 ? "#2e7d32" : score >= 50 ? "#e65100" : "#c62828";
const scoreLabel =
score >= 80
? "Good — keep improving"
: score >= 50
? "Fair — some issues to fix"
: "Needs attention";
const scoreDesc =
score >= 80
? `Your vault is mostly secure. ${weak.length + reusedCount} issue${weak.length + reusedCount !== 1 ? "s" : ""} need attention.`
: score >= 50
? "Several passwords need your attention."
: "Multiple security issues detected.";
const makeBar = (label, pct, color) => `
<div class="sec-bar-row">
<span class="sec-bar-label">${label}</span>
<div class="sec-bar-track"><div class="sec-bar-fill" style="width:${pct}%;background:${color};"></div></div>
<span class="sec-bar-pct">${pct}%</span>
</div>`;
summaryEl.innerHTML = `
<div class="sec-score-card">
<div class="sec-score ${cls}">${score}</div>
<div class="sec-score-label">${label}</div>
<div class="sec-score-desc">Based on ${total} password${total !== 1 ? "s" : ""}</div>
<div class="sec-ring-wrap">
<svg viewBox="0 0 100 100" class="sec-ring-svg" aria-hidden="true">
<circle cx="50" cy="50" r="42" fill="none" stroke="#e5e7eb" stroke-width="8"/>
<circle cx="50" cy="50" r="42" fill="none" stroke="${ringColor}" stroke-width="8"
stroke-dasharray="${ringFill} ${CIRC}" stroke-dashoffset="66"
stroke-linecap="round" transform="rotate(-90 50 50)"/>
</svg>
<div class="sec-ring-score" style="color:${ringColor};">${score}</div>
</div>
<div class="sec-stats">
<div class="sec-stat ${weak.length ? "sec-stat-warn" : "sec-stat-ok"}"><span class="sec-stat-num">${weak.length}</span><span class="sec-stat-label">Weak</span></div>
<div class="sec-stat ${reused.length ? "sec-stat-warn" : "sec-stat-ok"}"><span class="sec-stat-num">${reused.length}</span><span class="sec-stat-label">Reused</span></div>
<div class="sec-stat ${old.length ? "sec-stat-info" : "sec-stat-ok"}"><span class="sec-stat-num">${old.length}</span><span class="sec-stat-label">Old &amp; Weak</span></div>
<div class="sec-summary-right">
<div class="sec-summary-title">${escHtml(scoreLabel)}</div>
<div class="sec-summary-desc">${escHtml(scoreDesc)}</div>
<div class="sec-bars">
${makeBar("Strong passwords", strongPct, "#2e7d32")}
${makeBar("Unique passwords", uniquePct, "#1a73e8")}
${makeBar("2FA enabled", twoFAPct, "#e65100")}
</div>
</div>`;
sectionsEl.innerHTML = "";
const makeSection = (title, icon, items, desc, renderItem) => {
if (!items.length) return;
// ── Section card builder ──────────────────────────────────────────────────
// Returns a div card element. renderItem(i) returns an HTML string for each li.
const makeSection = (title, icon, iconBg, items, desc, renderItem) => {
if (!items.length) return null;
const sec = document.createElement("div");
sec.className = "sec-section";
const defaultRender = (i) => `<li class="sec-item">
<span class="sec-item-name">${escHtml(i.name)}</span>
<span class="sec-item-sub">${escHtml(i.plain?.username || "")}</span>
<button class="btn-secondary btn-sm" data-sec-edit="${i.id}">Edit</button>
</li>`;
const defaultRender = (i) => `
<li class="sec-item">
<span class="sec-item-icon">${icon}</span>
<span class="sec-item-name">${escHtml(i.name)}</span>
<button class="btn-secondary btn-sm" data-sec-edit="${i.id}">Edit</button>
</li>`;
const renderFn = renderItem || defaultRender;
sec.innerHTML = `
<div class="sec-section-header"><span class="sec-section-icon">${icon}</span>
<div><div class="sec-section-title">${title} (${items.length})</div><div class="sec-section-desc">${desc}</div></div>
<div class="sec-section-header">
<div class="sec-section-icon-wrap" style="background:${iconBg};">${icon}</div>
<div>
<div class="sec-section-title">${escHtml(title)}</div>
<div class="sec-section-desc sec-section-issue">${escHtml(items.length)} ${escHtml(desc)}</div>
</div>
</div>
<ul class="sec-item-list">
${items.map(renderFn).join("")}
@@ -1181,78 +1253,98 @@ const Vault = (() => {
}
});
});
sectionsEl.appendChild(sec);
return sec;
};
makeSection(
"Weak Passwords",
"⚠️",
weak,
"Short or low-complexity passwords.",
// ── Build section cards ───────────────────────────────────────────────────
const noTotp = pwItems.filter(
(i) => i.plain?.url && !extractTotpSecret(i.plain?.totp_uri),
);
makeSection(
"Reused Passwords",
"♻️",
[...new Set(reused.map((i) => i.id))]
.map((id) => _items.find((i) => i.id === id))
.filter(Boolean),
"Same password used on multiple sites.",
);
makeSection(
"Old Passwords",
"🕐",
old,
"Weak or reused passwords not changed in over 180 days.",
(i) => {
const ref = i.plain?.password_changed_at || i.created_at;
const daysAgo = ref
? Math.floor((Date.now() - new Date(ref).getTime()) / 86400000)
: null;
const ageLabel = daysAgo !== null
? `Last changed ${daysAgo} day${daysAgo !== 1 ? "s" : ""} ago`
: "Age unknown";
return `<li class="sec-item">
// Sections rendered in pairs into a 2-col grid
const sectionDefs = [
// HIBP section will be inserted asynchronously — placeholder first
null,
makeSection(
"Weak Passwords",
"⚠️",
"#fff3cd",
weak,
"password(s) need strengthening",
(i) => {
const s = _passwordStrength(i.plain?.password);
return `<li class="sec-item">
<span class="sec-item-icon">🔑</span>
<span class="sec-item-name">${escHtml(i.name)}</span>
<span class="sec-item-badge ${s ? s.cls : ""}">${s ? s.label : "Weak"}</span>
<button class="btn-secondary btn-sm" data-sec-edit="${i.id}">Edit</button>
</li>`;
},
),
makeSection(
"Reused Passwords",
"♻️",
"#e3f2fd",
[...new Set(reused.map((i) => i.id))]
.map((id) => _items.find((i) => i.id === id))
.filter(Boolean),
"password(s) reused across sites",
),
makeSection(
"Old Passwords",
"🕐",
"#f3e8ff",
old,
"password(s) not changed in 180+ days",
(i) => {
const ref = i.plain?.password_changed_at || i.created_at;
const daysAgo = ref
? Math.floor((Date.now() - new Date(ref).getTime()) / 86400000)
: null;
const ageLabel =
daysAgo !== null
? `Last changed ${daysAgo} day${daysAgo !== 1 ? "s" : ""} ago`
: "Age unknown";
return `<li class="sec-item">
<span class="sec-item-icon">🕐</span>
<span class="sec-item-name">${escHtml(i.name)}</span>
<span class="sec-item-sub">${escHtml(ageLabel)}</span>
<button class="btn-secondary btn-sm" data-sec-edit="${i.id}">Edit</button>
</li>`;
},
);
},
),
makeSection(
"No 2FA Saved",
"🔓",
"#e8f5e9",
noTotp,
"account(s) may support 2FA but none stored",
),
].filter((s) => s !== null);
// ── Missing 2FA warning ──────────────────────────────────────────────────
// Flag password items that have a URL but no TOTP URI saved.
// These accounts likely support 2FA but the user hasn't stored it.
const noTotp = pwItems.filter(
(i) => i.plain?.url && !extractTotpSecret(i.plain?.totp_uri),
);
makeSection(
"No 2FA Saved",
"🔓",
noTotp,
"These accounts may support two-factor authentication but have no TOTP code stored.",
);
// Render all non-HIBP sections into grid
const grid = document.createElement("div");
grid.className = "sec-sections-grid";
sectionDefs.forEach((s) => grid.appendChild(s));
sectionsEl.appendChild(grid);
// ── HaveIBeenPwned breach check ──────────────────────────────────────────
// Use cached results from the background check when available — avoids
// re-querying HIBP every time the user opens the Security tab.
const hibpSection = document.createElement("div");
hibpSection.className = "sec-section";
hibpSection.innerHTML = `
const hibpCard = document.createElement("div");
hibpCard.className = "sec-section sec-section-hibp-loading";
hibpCard.innerHTML = `
<div class="sec-section-header">
<span class="sec-section-icon">🔓</span>
<div class="sec-section-icon-wrap" style="background:#fde8e8;">🚨</div>
<div>
<div class="sec-section-title">Checking for known breaches…</div>
<div class="sec-section-desc">Querying HaveIBeenPwned (k-anonymity — your passwords are never sent).</div>
</div>
</div>`;
sectionsEl.appendChild(hibpSection);
sectionsEl.insertBefore(hibpCard, grid);
// Use cached results if available, otherwise run fresh checks.
let hibpResults;
if (_healthCache?.hibpResults) {
hibpResults = _healthCache.hibpResults;
} else {
// Run all HIBP checks in parallel — k-anonymity: only 5-char SHA-1 prefix sent.
hibpResults = await Promise.all(
pwItems.map(async (item) => ({
item,
@@ -1263,21 +1355,21 @@ const Vault = (() => {
const breached = hibpResults.filter((r) => r.count > 0).map((r) => r.item);
if (!breached.length) {
hibpSection.innerHTML = `
hibpCard.innerHTML = `
<div class="sec-section-header">
<span class="sec-section-icon">✅</span>
<div class="sec-section-icon-wrap" style="background:#e8f5e9;">✅</div>
<div>
<div class="sec-section-title">No known breaches</div>
<div class="sec-section-desc">None of your passwords appeared in known data breaches (via HaveIBeenPwned).</div>
</div>
</div>`;
} else {
hibpSection.innerHTML = `
hibpCard.innerHTML = `
<div class="sec-section-header">
<span class="sec-section-icon">🔓</span>
<div class="sec-section-icon-wrap" style="background:#fde8e8;">🚨</div>
<div>
<div class="sec-section-title">Breached Passwords (${breached.length})</div>
<div class="sec-section-desc">These passwords appeared in known data breaches. Change them immediately.</div>
<div class="sec-section-title">Breached Passwords</div>
<div class="sec-section-desc sec-section-issue sec-section-issue--danger">${breached.length} password${breached.length !== 1 ? "s" : ""} found in data breaches</div>
</div>
</div>
<ul class="sec-item-list">
@@ -1286,14 +1378,15 @@ const Vault = (() => {
const count =
hibpResults.find((r) => r.item.id === i.id)?.count || 0;
return `<li class="sec-item">
<span class="sec-item-name">${escHtml(i.name)}</span>
<span class="sec-item-sub">${escHtml(i.plain?.username || "")} — seen ${count.toLocaleString()} time${count !== 1 ? "s" : ""} in breaches</span>
<button class="btn-secondary btn-sm" data-sec-edit="${i.id}">Change</button>
</li>`;
<span class="sec-item-icon">🛒</span>
<span class="sec-item-name">${escHtml(i.name)}</span>
<span class="sec-item-badge strength-weak">Breached</span>
<button class="btn-secondary btn-sm" data-sec-edit="${i.id}">Change</button>
</li>`;
})
.join("")}
</ul>`;
hibpSection.querySelectorAll("[data-sec-edit]").forEach((btn) => {
hibpCard.querySelectorAll("[data-sec-edit]").forEach((btn) => {
btn.addEventListener("click", () => {
const item = _items.find(
(it) => it.id === parseInt(btn.dataset.secEdit),
@@ -1305,6 +1398,7 @@ const Vault = (() => {
});
});
}
hibpCard.classList.remove("sec-section-hibp-loading");
}
// ── Import / Export View ─────────────────────────────────────────────────
@@ -4744,4 +4838,4 @@ const Vault = (() => {
}
})();
document.addEventListener("DOMContentLoaded", Vault.init);
document.addEventListener("DOMContentLoaded", Vault.init);