05/20 Modify Vault, Security Dashboard UI
This commit is contained in:
+205
-96
@@ -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;
|
||||
|
||||
+154
-60
@@ -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-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 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 & Weak</span></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">
|
||||
const defaultRender = (i) => `
|
||||
<li class="sec-item">
|
||||
<span class="sec-item-icon">${icon}</span>
|
||||
<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 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;
|
||||
};
|
||||
|
||||
// ── Build section cards ───────────────────────────────────────────────────
|
||||
const noTotp = pwItems.filter(
|
||||
(i) => i.plain?.url && !extractTotpSecret(i.plain?.totp_uri),
|
||||
);
|
||||
|
||||
// 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,
|
||||
"Short or low-complexity passwords.",
|
||||
);
|
||||
"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),
|
||||
"Same password used on multiple sites.",
|
||||
);
|
||||
"password(s) reused across sites",
|
||||
),
|
||||
makeSection(
|
||||
"Old Passwords",
|
||||
"🕐",
|
||||
"#f3e8ff",
|
||||
old,
|
||||
"Weak or reused passwords not changed in over 180 days.",
|
||||
"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
|
||||
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>`;
|
||||
},
|
||||
);
|
||||
|
||||
// ── 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",
|
||||
"🔓",
|
||||
"#e8f5e9",
|
||||
noTotp,
|
||||
"These accounts may support two-factor authentication but have no TOTP code stored.",
|
||||
);
|
||||
"account(s) may support 2FA but none stored",
|
||||
),
|
||||
].filter((s) => s !== null);
|
||||
|
||||
// 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-icon">🛒</span>
|
||||
<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>
|
||||
<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 ─────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user