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
+206 -97
View File
@@ -313,28 +313,50 @@ ul {
color: #1558b0;
}
/* ── Vault list items ────────────────────────────────────────────── */
/* ── Vault list — card grid layout ───────────────────────────────── */
#vault-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 10px;
padding: 4px 0;
}
/* Group header spans all columns */
.vault-group-header {
grid-column: 1 / -1;
}
.vault-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-bottom: 1px solid var(--color-border);
padding: 14px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
cursor: default;
transition: background var(--transition);
}
.vault-item:last-child {
border-bottom: none;
transition: box-shadow var(--transition), border-color var(--transition);
}
.vault-item:hover {
background: #f9fafb;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
border-color: #d1d5db;
}
.item-icon {
font-size: 20px;
/* Initials avatar */
.item-avatar {
width: 40px;
height: 40px;
border-radius: 10px;
background: #fde8ea;
color: var(--color-primary-dark);
font-size: 14px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
user-select: none;
}
.item-info {
@@ -369,6 +391,31 @@ ul {
flex-shrink: 0;
}
/* Strength pill inside card */
.item-strength-pill {
display: inline-block;
font-size: 11px;
font-weight: 600;
padding: 1px 8px;
border-radius: 999px;
margin-top: 4px;
}
.item-strength-pill.strength-strong {
background: #d1fae5;
color: #065f46;
}
.item-strength-pill.strength-medium {
background: #fef3c7;
color: #92400e;
}
.item-strength-pill.strength-weak {
background: #fee2e2;
color: #991b1b;
}
/* ── TOTP live display in vault item ─────────────────────────────── */
.item-totp {
display: inline-flex;
@@ -584,6 +631,7 @@ ul {
color: var(--color-text-muted);
padding: 60px 0;
font-size: 15px;
grid-column: 1 / -1;
}
.btn-icon {
@@ -953,7 +1001,7 @@ ul {
.security-summary {
display: flex;
gap: 20px;
gap: 24px;
align-items: center;
background: var(--color-surface);
border: 1px solid var(--color-border);
@@ -962,82 +1010,95 @@ ul {
flex-wrap: wrap;
}
.sec-score-card {
text-align: center;
/* ── Circular score ring ──────────────────────────────────────────── */
.sec-ring-wrap {
position: relative;
width: 100px;
height: 100px;
flex-shrink: 0;
}
.sec-score {
font-size: 56px;
font-weight: 700;
.sec-ring-svg {
width: 100%;
height: 100%;
}
.sec-ring-score {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
font-weight: 800;
line-height: 1;
}
.sec-score.score-good {
color: #2e7d32;
.sec-summary-right {
flex: 1;
min-width: 200px;
}
.sec-score.score-fair {
color: #e65100;
}
.sec-score.score-poor {
color: #c62828;
}
.sec-score-label {
font-size: 16px;
font-weight: 600;
margin-top: 4px;
}
.sec-score-desc {
font-size: 12px;
color: var(--color-text-muted);
margin-top: 2px;
}
.sec-stats {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.sec-stat {
text-align: center;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 12px 20px;
min-width: 80px;
}
.sec-stat-warn {
border-color: #ffb74d;
background: #fff8e1;
}
.sec-stat-info {
border-color: #90caf9;
background: #e3f2fd;
}
.sec-stat-ok {
border-color: #a5d6a7;
background: #e8f5e9;
}
.sec-stat-num {
display: block;
font-size: 28px;
.sec-summary-title {
font-size: 18px;
font-weight: 700;
color: var(--color-text);
margin-bottom: 4px;
}
.sec-stat-label {
display: block;
.sec-summary-desc {
font-size: 14px;
color: var(--color-text-muted);
margin-bottom: 16px;
}
/* ── Progress bars ────────────────────────────────────────────────── */
.sec-bars {
display: flex;
flex-direction: column;
gap: 8px;
}
.sec-bar-row {
display: flex;
align-items: center;
gap: 10px;
}
.sec-bar-label {
font-size: 13px;
color: var(--color-text);
width: 130px;
flex-shrink: 0;
}
.sec-bar-track {
flex: 1;
height: 6px;
background: #e5e7eb;
border-radius: 3px;
overflow: hidden;
}
.sec-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.4s ease;
}
.sec-bar-pct {
font-size: 12px;
color: var(--color-text-muted);
margin-top: 2px;
width: 36px;
text-align: right;
flex-shrink: 0;
}
/* ── Section cards grid ───────────────────────────────────────────── */
.sec-sections-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.sec-section {
@@ -1047,23 +1108,34 @@ ul {
overflow: hidden;
}
/* HIBP card spans full width above grid */
.sec-section + .sec-sections-grid {
margin-top: 0;
}
.sec-section-header {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg);
}
.sec-section-icon {
font-size: 20px;
.sec-section-icon-wrap {
width: 36px;
height: 36px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
}
.sec-section-title {
font-weight: 600;
font-size: 15px;
color: var(--color-text);
}
.sec-section-desc {
@@ -1072,6 +1144,14 @@ ul {
margin-top: 2px;
}
.sec-section-issue {
color: var(--color-text-muted);
}
.sec-section-issue--danger {
color: #c62828;
}
.sec-item-list {
padding: 0;
}
@@ -1079,7 +1159,7 @@ ul {
.sec-item {
display: flex;
align-items: center;
gap: 12px;
gap: 10px;
padding: 10px 20px;
border-bottom: 1px solid var(--color-border);
}
@@ -1088,15 +1168,46 @@ ul {
border-bottom: none;
}
.sec-item-icon {
font-size: 18px;
flex-shrink: 0;
width: 24px;
text-align: center;
}
.sec-item-name {
font-weight: 500;
flex: 1;
font-size: 14px;
}
.sec-item-sub {
font-size: 12px;
color: var(--color-text-muted);
flex: 1;
}
/* Strength / status badge inside sec-item */
.sec-item-badge {
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 999px;
flex-shrink: 0;
}
.sec-item-badge.strength-strong {
background: #d1fae5;
color: #065f46;
}
.sec-item-badge.strength-medium {
background: #fef3c7;
color: #92400e;
}
.sec-item-badge.strength-weak {
background: #fee2e2;
color: #991b1b;
}
/* ── Item Detail Modal ───────────────────────────────────────────── */
@@ -1724,9 +1835,8 @@ body.is-mobile .app-layout {
gap: 12px;
}
.sec-stats {
flex-wrap: wrap;
gap: 10px;
.sec-sections-grid {
grid-template-columns: 1fr;
}
.modal {
@@ -1850,24 +1960,23 @@ body.is-mobile .security-summary {
flex-wrap: wrap;
}
body.is-mobile .sec-score {
font-size: 40px;
body.is-mobile .sec-ring-wrap {
width: 80px;
height: 80px;
}
body.is-mobile .sec-stats {
gap: 8px;
flex-wrap: wrap;
}
body.is-mobile .sec-stat {
padding: 8px 12px;
min-width: 68px;
}
body.is-mobile .sec-stat-num {
body.is-mobile .sec-ring-score {
font-size: 22px;
}
body.is-mobile .sec-sections-grid {
grid-template-columns: 1fr;
}
body.is-mobile #vault-list {
grid-template-columns: 1fr;
}
body.is-mobile .detail-field {
flex-direction: column;
gap: 4px;
@@ -2692,4 +2801,4 @@ html.sidebar-open {
.pw-history-reveal:hover,
.pw-history-restore:hover {
color: var(--text);
}
}
+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);