From 04f7368a95c1e75bf20f85a926839c446bec18ce Mon Sep 17 00:00:00 2001 From: NguyenND Date: Wed, 20 May 2026 14:48:45 -0400 Subject: [PATCH] 05/20 Modify Vault, Security Dashboard UI --- app/static/css/app.css | 303 ++++++++++++++++++++++++++++------------- app/static/js/vault.js | 278 ++++++++++++++++++++++++------------- 2 files changed, 392 insertions(+), 189 deletions(-) diff --git a/app/static/css/app.css b/app/static/css/app.css index 9adc778..fb432dc 100644 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -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); -} +} \ No newline at end of file diff --git a/app/static/js/vault.js b/app/static/js/vault.js index efe2c9f..61f977d 100644 --- a/app/static/js/vault.js +++ b/app/static/js/vault.js @@ -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) => `${escHtml(t)}`) .join(""); + // Strength pill — only for password type + const strength = + item.item_type === "password" + ? _passwordStrength(item.plain?.password) + : null; + const strengthPill = strength + ? `${strength.label}` + : ""; + + // Initials avatar + const initials = _itemInitials(item.name); + li.innerHTML = ` -
${icon}
+
${escHtml(item.name)} ${escHtml(subText)} ${tagBadges ? `
${tagBadges}
` : ""} ${showTotp ? `······` : ""} + ${strengthPill}
${showLaunch ? `` : ""} @@ -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) => ` +
+ ${label} +
+ ${pct}% +
`; + summaryEl.innerHTML = ` -
-
${score}
-
${label}
-
Based on ${total} password${total !== 1 ? "s" : ""}
+
+ +
${score}
-
-
${weak.length}Weak
-
${reused.length}Reused
-
${old.length}Old & Weak
+
+
${escHtml(scoreLabel)}
+
${escHtml(scoreDesc)}
+
+ ${makeBar("Strong passwords", strongPct, "#2e7d32")} + ${makeBar("Unique passwords", uniquePct, "#1a73e8")} + ${makeBar("2FA enabled", twoFAPct, "#e65100")} +
`; 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) => `
  • - ${escHtml(i.name)} - ${escHtml(i.plain?.username || "")} - -
  • `; + const defaultRender = (i) => ` +
  • + ${icon} + ${escHtml(i.name)} + +
  • `; const renderFn = renderItem || defaultRender; sec.innerHTML = ` -
    ${icon} -
    ${title} (${items.length})
    ${desc}
    +
    +
    ${icon}
    +
    +
    ${escHtml(title)}
    +
    ${escHtml(items.length)} ${escHtml(desc)}
    +
      ${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 `
    • + + // 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 `
    • + 🔑 + ${escHtml(i.name)} + ${s ? s.label : "Weak"} + +
    • `; + }, + ), + 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 `
    • + 🕐 ${escHtml(i.name)} ${escHtml(ageLabel)}
    • `; - }, - ); + }, + ), + 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 = `
      - 🔓 +
      🚨
      Checking for known breaches…
      Querying HaveIBeenPwned (k-anonymity — your passwords are never sent).
      `; - 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 = `
      - +
      No known breaches
      None of your passwords appeared in known data breaches (via HaveIBeenPwned).
      `; } else { - hibpSection.innerHTML = ` + hibpCard.innerHTML = `
      - 🔓 +
      🚨
      -
      Breached Passwords (${breached.length})
      -
      These passwords appeared in known data breaches. Change them immediately.
      +
      Breached Passwords
      +
      ${breached.length} password${breached.length !== 1 ? "s" : ""} found in data breaches
        @@ -1286,14 +1378,15 @@ const Vault = (() => { const count = hibpResults.find((r) => r.item.id === i.id)?.count || 0; return `
      • - ${escHtml(i.name)} - ${escHtml(i.plain?.username || "")} — seen ${count.toLocaleString()} time${count !== 1 ? "s" : ""} in breaches - -
      • `; + 🛒 + ${escHtml(i.name)} + Breached + + `; }) .join("")}
      `; - 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); \ No newline at end of file