From 0aa24329422c833bfa724567c363694088abbfd0 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Mon, 20 Apr 2026 18:05:41 -0400 Subject: [PATCH] 04/20/2026 enhance the extension totp --- app/static/css/app.css | 1674 +++++-------------- app/static/js/vault.js | 2736 +++++++++++++++++++++----------- app/templates/vault/index.html | 435 ++--- extension/popup/popup.css | 30 + extension/popup/popup.js | 106 +- 5 files changed, 2469 insertions(+), 2512 deletions(-) diff --git a/app/static/css/app.css b/app/static/css/app.css index 4fa1786..5207732 100644 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -314,6 +314,92 @@ ul { color: #1558b0; } +/* ── Vault list items ────────────────────────────────────────────── */ +.vault-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + border-bottom: 1px solid var(--color-border); + cursor: default; + transition: background var(--transition); +} + +.vault-item:last-child { + border-bottom: none; +} + +.vault-item:hover { + background: #f9fafb; +} + +.item-icon { + font-size: 20px; + flex-shrink: 0; +} + +.item-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.item-name { + font-size: 14px; + font-weight: 600; + color: var(--color-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.item-sub { + font-size: 12px; + color: var(--color-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.item-actions { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; +} + +/* ── TOTP live display in vault item ─────────────────────────────── */ +.item-totp { + display: inline-flex; + align-items: center; + gap: 0; + font-size: 12px; + font-weight: 700; + font-family: "Courier New", Courier, monospace; + color: #1a73e8; + margin-top: 1px; + letter-spacing: 0.5px; +} + +.totp-timer { + font-size: 11px; + font-weight: 400; + color: #9ca3af; + font-family: inherit; +} + +/* Copy TOTP button — distinct teal so it reads as 2FA, not password */ +.btn-icon[data-action="copy-totp"] { + color: #0891b2; +} + +.btn-icon[data-action="copy-totp"]:hover { + background: #e0f2fe; + color: #0369a1; +} + /* Password strength */ .password-strength { margin-top: 6px; @@ -355,34 +441,13 @@ ul { } /* ── Sidebar ─────────────────────────────────────────────────────── */ + +/* ── Base sidebar styles (shared by mobile and desktop) ─────────── */ .sidebar { - width: 240px; - flex-shrink: 0; background: var(--color-sidebar-bg); color: var(--color-sidebar-text); display: flex; flex-direction: column; - overflow: hidden; - /* No transition here — added back in the >768px media query so it - never interferes with display:none on mobile in Chrome */ -} - -.sidebar.collapsed { - width: 72px; -} - -/* Sidebar width transition only on non-mobile — prevents Chrome from - deferring display:none while a transition is active */ -@media (min-width: 769px) { - .sidebar { - transition: width 0.22s ease; - } -} - -/* Sidebar must not clip its children when collapsed — tooltips need to escape */ -.sidebar.collapsed .sidebar-nav, -.sidebar.collapsed .sidebar-footer { - overflow: visible; } .sidebar-header { @@ -393,20 +458,11 @@ ul { border-bottom: 1px solid rgba(255, 255, 255, 0.08); flex-shrink: 0; min-height: 56px; - overflow: hidden; -} - -/* When collapsed: centre the toggle button, hide everything else */ -.sidebar.collapsed .sidebar-header { - justify-content: center; - padding: 14px 0; - overflow: visible; } .sidebar-header .logo-icon { font-size: 22px; flex-shrink: 0; - transition: opacity 0.15s ease; } .sidebar-header .logo-text { @@ -416,42 +472,6 @@ ul { flex: 1; white-space: nowrap; overflow: hidden; - transition: opacity 0.15s ease; -} - -.sidebar.collapsed .sidebar-header .logo-icon, -.sidebar.collapsed .sidebar-header .logo-text { - opacity: 0; - width: 0; - pointer-events: none; -} - -/* Toggle button — always visible, always clickable */ -.btn-sidebar-toggle { - background: none; - border: none; - color: rgba(255, 255, 255, 0.5); - font-size: 14px; - cursor: pointer; - padding: 6px 8px; - border-radius: 4px; - line-height: 1; - flex-shrink: 0; - transition: - color var(--transition), - transform 0.22s ease; - margin-left: auto; - z-index: 10; -} - -.btn-sidebar-toggle:hover { - color: #fff; - background: rgba(255, 255, 255, 0.12); -} - -.sidebar.collapsed .btn-sidebar-toggle { - transform: rotate(180deg); - margin-left: 0; } .sidebar-nav { @@ -469,14 +489,6 @@ ul { color: rgba(255, 255, 255, 0.35); white-space: nowrap; overflow: hidden; - transition: opacity 0.15s ease; -} - -.sidebar.collapsed .sidebar-section-header { - opacity: 0; - pointer-events: none; - height: 0; - padding: 0; } .sidebar-item { @@ -484,7 +496,6 @@ ul { align-items: center; gap: 10px; padding: 9px 16px; - border-radius: 0; color: var(--color-sidebar-text); cursor: pointer; transition: background var(--transition); @@ -512,28 +523,112 @@ ul { text-align: center; } -/* Labels — fade out when collapsed; use max-width so icon stays visible */ .sidebar-label { + white-space: nowrap; + overflow: hidden; +} + +.sidebar-footer { + padding: 12px 0; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +/* ── DESKTOP sidebar (body.is-desktop) ───────────────────────────── */ +body.is-desktop .sidebar { + width: 240px; + flex-shrink: 0; + overflow: hidden; + /* Transition only on desktop so display:none on mobile is instant */ + transition: width 0.22s ease; +} + +body.is-desktop .sidebar.collapsed { + width: 72px; +} + +body.is-desktop .sidebar.collapsed .sidebar-nav, +body.is-desktop .sidebar.collapsed .sidebar-footer { + overflow: visible; +} + +body.is-desktop .sidebar.collapsed .sidebar-header { + justify-content: center; + padding: 14px 0; + overflow: visible; +} + +body.is-desktop .sidebar-header .logo-icon { + transition: opacity 0.15s ease; +} + +body.is-desktop .sidebar-header .logo-text { + flex: 1; + transition: opacity 0.15s ease; +} + +body.is-desktop .sidebar.collapsed .sidebar-header .logo-icon, +body.is-desktop .sidebar.collapsed .sidebar-header .logo-text { + opacity: 0; + width: 0; + pointer-events: none; +} + +body.is-desktop .btn-sidebar-toggle { + background: none; + border: none; + color: rgba(255, 255, 255, 0.5); + font-size: 14px; + cursor: pointer; + padding: 6px 8px; + border-radius: 4px; + line-height: 1; + flex-shrink: 0; + transition: + color var(--transition), + transform 0.22s ease; + margin-left: auto; +} + +body.is-desktop .btn-sidebar-toggle:hover { + color: #fff; + background: rgba(255, 255, 255, 0.12); +} + +body.is-desktop .sidebar.collapsed .btn-sidebar-toggle { + transform: rotate(180deg); + margin-left: 0; +} + +body.is-desktop .sidebar-section-header { + transition: opacity 0.15s ease; +} + +body.is-desktop .sidebar.collapsed .sidebar-section-header { + opacity: 0; + pointer-events: none; + height: 0; + padding: 0; +} + +body.is-desktop .sidebar-label { transition: opacity 0.15s ease, max-width 0.22s ease; max-width: 200px; - overflow: hidden; } -.sidebar.collapsed .sidebar-label { +body.is-desktop .sidebar.collapsed .sidebar-label { opacity: 0; max-width: 0; } -.sidebar.collapsed .sidebar-item { +body.is-desktop .sidebar.collapsed .sidebar-item { justify-content: center; padding: 10px 0; overflow: visible; } -/* Tooltip — appears to the right of the 72px rail */ -.sidebar.collapsed .sidebar-item::after { +body.is-desktop .sidebar.collapsed .sidebar-item::after { content: attr(data-tooltip); position: fixed; left: 80px; @@ -550,15 +645,145 @@ ul { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); } -.sidebar.collapsed .sidebar-item:hover::after { +body.is-desktop .sidebar.collapsed .sidebar-item:hover::after { opacity: 1; } -.sidebar-footer { - padding: 12px 0; - border-top: 1px solid rgba(255, 255, 255, 0.08); +/* ── MOBILE sidebar (body.is-mobile) ─────────────────────────────── */ +body.is-mobile .sidebar { + /* Hidden by default — no layout space, no transitions to fight */ + display: none; + position: fixed; + top: 0; + left: 0; + width: 280px; + height: 100%; + z-index: 90; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + /* No width/opacity transitions on mobile */ } +body.is-mobile .sidebar.mobile-open { + display: flex; + flex-direction: column; +} + +/* Mobile sidebar always shows full labels regardless of collapsed state */ +body.is-mobile .sidebar .sidebar-label { + opacity: 1; + max-width: none; +} + +body.is-mobile .sidebar .sidebar-section-header { + opacity: 1; + height: auto; + padding: 12px 16px 4px; + pointer-events: auto; +} + +body.is-mobile .sidebar .sidebar-item { + justify-content: flex-start; + padding: 9px 16px; +} + +body.is-mobile .sidebar .sidebar-header { + justify-content: flex-start; + padding: 14px 12px; +} + +body.is-mobile .sidebar .sidebar-header .logo-icon, +body.is-mobile .sidebar .sidebar-header .logo-text { + opacity: 1; + width: auto; + pointer-events: auto; +} + +body.is-mobile .sidebar .sidebar-item::after { + display: none; +} + +body.is-mobile .btn-sidebar-toggle { + display: none; +} + +/* ── Mobile top bar — only on body.is-mobile ─────────────────────── */ +.mobile-topbar { + display: none; + align-items: center; + gap: 12px; + padding: 0 16px; + height: 54px; + background: var(--color-sidebar-bg); + color: #fff; + flex-shrink: 0; + position: sticky; + top: 0; + z-index: 50; +} + +body.is-mobile .mobile-topbar { + display: flex; +} + +.btn-mobile-menu { + font-size: 22px; + color: rgba(255, 255, 255, 0.8); + background: none; + border: none; + padding: 4px 6px; + cursor: pointer; + flex-shrink: 0; +} + +.btn-mobile-menu:hover { + color: #fff; +} + +.mobile-logo { + flex: 1; + font-size: 16px; + font-weight: 700; + color: #fff; +} + +.btn-fab-mobile { + width: 36px; + height: 36px; + font-size: 20px; + flex-shrink: 0; +} + +/* Sidebar backdrop */ +.sidebar-backdrop { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 89; +} + +.sidebar-backdrop.open { + display: block; +} + +/* ── App layout ──────────────────────────────────────────────────── */ +body.is-desktop .app-layout { + display: flex; + height: 100vh; + height: calc(var(--vh, 1vh) * 100); +} + +body.is-mobile .app-layout { + display: flex; + flex-direction: column; + height: 100vh; + height: -webkit-fill-available; + height: calc(var(--vh, 1vh) * 100); + overflow: hidden; +} + +/* ── Folder items ────────────────────────────────────────────────── */ .folder-item { justify-content: space-between; } @@ -650,1152 +875,10 @@ ul { background: rgba(255, 255, 255, 0.1); } -/* ── Vault main ──────────────────────────────────────────────────── */ -.vault-main { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; - background: var(--color-bg); -} - -#view-vault, -#view-security { - display: flex; - flex-direction: column; - flex: 1; - overflow: hidden; - min-height: 0; - position: relative; -} - -#view-vault.hidden, -#view-security.hidden { - display: none; -} - -.vault-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 24px; - background: var(--color-surface); - border-bottom: 1px solid var(--color-border); - gap: 16px; -} - -/* Inner wrapper constrains content width on very large screens */ -.vault-header-inner { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - max-width: 900px; - margin: 0 auto; - gap: 16px; -} - -.vault-title { - font-size: 18px; - font-weight: 600; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.vault-toolbar { - display: flex; - align-items: center; - gap: 12px; - flex: 1; - justify-content: flex-end; -} - -.search-wrapper { - display: flex; - align-items: center; - background: var(--color-bg); - border: 1px solid var(--color-border); - border-radius: 20px; - padding: 6px 14px; - gap: 8px; - flex: 1; - max-width: 360px; -} - -.search-icon { - color: var(--color-text-muted); -} - -#search-input { - border: none; - background: none; - outline: none; - flex: 1; - font-size: 14px; -} - -.btn-fab { - width: 40px; - height: 40px; - border-radius: 50%; - background: var(--color-primary); - color: #fff; - font-size: 24px; - font-weight: 300; - display: flex; - align-items: center; - justify-content: center; - box-shadow: 0 2px 8px rgba(208, 2, 27, 0.35); - transition: - background var(--transition), - box-shadow var(--transition); - flex-shrink: 0; -} - -.btn-fab:hover { - background: var(--color-primary-dark); - box-shadow: 0 4px 14px rgba(208, 2, 27, 0.45); -} - -/* ── Vault list ──────────────────────────────────────────────────── */ -/* Outer scroll container */ -.vault-list { - flex: 1; - overflow-y: auto; - padding: 12px 24px 24px; - -webkit-overflow-scrolling: touch; -} - -/* Content max-width — keeps items readable on very wide screens */ -.vault-list-inner { - max-width: 900px; - margin: 0 auto; -} - -.vault-group-header { - font-size: 12px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--color-text-muted); - padding: 16px 0 6px; - border-bottom: 1px solid var(--color-border); - margin-bottom: 4px; -} - -.vault-empty { - text-align: center; - color: var(--color-text-muted); - padding: 60px 0; - font-size: 15px; -} - -.vault-item { - display: flex; - align-items: center; - gap: 12px; - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius); - padding: 12px 14px; - margin-bottom: 6px; - cursor: pointer; - transition: - box-shadow var(--transition), - border-color var(--transition); -} - -.vault-item:hover { - box-shadow: var(--shadow); - border-color: #c9ccd4; -} - -.item-icon { - font-size: 22px; - flex-shrink: 0; -} - -.item-info { - flex: 1; - min-width: 0; -} - -.item-name { - display: block; - font-weight: 500; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.item-sub { - display: block; - font-size: 12px; - color: var(--color-text-muted); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -/* Actions always visible — no hover-only opacity trick on desktop */ -.item-actions { - display: flex; - gap: 4px; -} - -.vault-item:hover .item-actions { - opacity: 1; -} - -.btn-icon { - width: 36px; - height: 36px; - border-radius: 6px; - display: flex; - align-items: center; - justify-content: center; - font-size: 15px; - color: var(--color-text-muted); - transition: - background var(--transition), - color var(--transition); - /* Expand touch target without changing visual size */ - touch-action: manipulation; -} - -.btn-icon:hover { - background: var(--color-bg); - color: var(--color-text); -} - -@media (max-width: 768px) { - .btn-icon { - min-width: 44px; - min-height: 44px; - } -} - -/* ── Spinner ─────────────────────────────────────────────────────── */ -.spinner-overlay { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - background: rgba(255, 255, 255, 0.7); - z-index: 10; -} - -.spinner-overlay.hidden { - display: none; -} - -.spinner { - width: 36px; - height: 36px; - border: 3px solid var(--color-border); - border-top-color: var(--color-primary); - border-radius: 50%; - animation: spin 0.7s linear infinite; -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -/* ── Modal ───────────────────────────────────────────────────────── */ -.modal-overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.45); - display: none; - align-items: center; - justify-content: center; - z-index: 100; - padding: 16px; -} - -.modal-overlay.open { - display: flex; -} - -.modal { - background: var(--color-surface); - border-radius: var(--radius); - box-shadow: 0 8px 40px rgba(0, 0, 0, 0.2); - width: 100%; - max-width: 480px; - max-height: 90vh; - max-height: calc(var(--vh, 1vh) * 90); - overflow-y: auto; - -webkit-overflow-scrolling: touch; - padding: 28px; -} - -.modal-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 20px; -} - -.modal-header h3 { - font-size: 18px; - font-weight: 600; -} - -.btn-close { - font-size: 18px; - color: var(--color-text-muted); - padding: 4px; - border-radius: 4px; -} - -.btn-close:hover { - background: var(--color-bg); - color: var(--color-text); -} - -.modal-actions { - display: flex; - gap: 10px; - justify-content: flex-end; - margin-top: 20px; -} - -.required { - color: var(--color-primary); -} - -/* ── Toast ───────────────────────────────────────────────────────── */ -.toast { - position: fixed; - bottom: 24px; - left: 50%; - transform: translateX(-50%) translateY(20px); - background: #1a1a2e; - color: #fff; - padding: 10px 20px; - border-radius: 20px; - font-size: 14px; - font-weight: 500; - opacity: 0; - transition: - opacity 0.25s, - transform 0.25s; - pointer-events: none; - z-index: 200; - white-space: nowrap; -} - -.toast.show { - opacity: 1; - transform: translateX(-50%) translateY(0); -} - -.toast.toast-error { - background: #c00; -} - -/* ── Unlock overlay ──────────────────────────────────────────────── */ -.modal-unlock { - max-width: 380px; -} - -.unlock-logo { - margin-bottom: 16px; -} - -.unlock-title { - margin-bottom: 4px; -} - -.unlock-subtitle { - color: var(--color-text-muted); - font-size: 13px; - margin-bottom: 20px; -} - -.btn-link { - padding: 10px 20px; - border-radius: var(--radius); - text-decoration: none; -} - -/* ── Auth step hint ─────────────────────────────────────────────── */ -.auth-step-hint { - color: var(--color-text-muted); - font-size: 13px; - margin-bottom: 16px; -} - -#mfa-code { - font-size: 22px; - letter-spacing: 0.2em; - text-align: center; -} - -/* ── Panel body (sharing / emergency) ───────────────────────────── */ -.panel-body { - flex: 1; - overflow-y: auto; - padding: 20px 24px; - -webkit-overflow-scrolling: touch; -} - -/* ── Tabs ────────────────────────────────────────────────────────── */ -.panel-tabs { - display: flex; - gap: 4px; - margin-bottom: 16px; - border-bottom: 2px solid var(--color-border); - padding-bottom: 0; -} - -.tab-btn { - padding: 8px 16px; - border-radius: 6px 6px 0 0; - font-size: 13px; - font-weight: 500; - color: var(--color-text-muted); - border-bottom: 2px solid transparent; - margin-bottom: -2px; - transition: - color var(--transition), - border-color var(--transition); -} - -.tab-btn:hover { - color: var(--color-text); -} - -.tab-btn.active { - color: var(--color-primary); - border-bottom-color: var(--color-primary); -} - -/* ── Share / Emergency lists ────────────────────────────────────── */ -.share-list { - display: flex; - flex-direction: column; - gap: 8px; -} - -.share-item { - display: flex; - align-items: center; - gap: 12px; - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius); - padding: 12px 14px; -} - -.share-icon { - font-size: 22px; - flex-shrink: 0; -} - -.share-info { - flex: 1; - min-width: 0; -} - -.share-name { - display: block; - font-weight: 500; -} - -.share-meta { - display: block; - font-size: 12px; - color: var(--color-text-muted); - margin-top: 2px; -} - -.share-actions { - display: flex; - gap: 6px; - align-items: center; - flex-shrink: 0; -} - -/* ── Badges ──────────────────────────────────────────────────────── */ -.badge { - display: inline-block; - padding: 3px 8px; - border-radius: 12px; - font-size: 12px; - font-weight: 500; -} - -.badge-warn { - background: #fff8e1; - color: #e65100; - border: 1px solid #ffb74d; -} - -.badge-info { - background: #e3f2fd; - color: #1565c0; - border: 1px solid #90caf9; -} - -/* ── Settings modal ──────────────────────────────────────────────── */ -.modal-settings { - max-width: 520px; -} - -.settings-section { - padding: 16px 0; - border-bottom: 1px solid var(--color-border); -} - -.settings-section:last-child { - border-bottom: none; -} - -.settings-section-title { - font-size: 15px; - font-weight: 600; - margin-bottom: 8px; -} - -.settings-desc { - font-size: 13px; - color: var(--color-text-muted); - margin-bottom: 10px; - line-height: 1.5; -} - -.mfa-qr { - display: block; - margin: 12px auto; - max-width: 180px; - border: 1px solid var(--color-border); - border-radius: 4px; -} - -.mfa-secret-text { - font-family: monospace; - font-size: 12px; -} - -.mfa-secret-text code { - background: var(--color-bg); - padding: 2px 6px; - border-radius: 4px; - user-select: all; -} - -/* ── Sort control ────────────────────────────────────────────────── */ -.sort-wrapper select { - padding: 6px 10px; - border: 1px solid var(--color-border); - border-radius: 20px; - background: var(--color-bg); - color: var(--color-text); - font-size: 13px; - cursor: pointer; - outline: none; -} - -.sort-wrapper select:focus { - border-color: var(--color-primary); -} - -/* ── Modal item (wider for address/card forms) ───────────────────── */ -.modal-item { - max-width: 540px; -} - -/* ── Multi-column form rows ──────────────────────────────────────── */ -.form-row { - display: flex; - gap: 12px; -} - -.form-row .form-group { - flex: 1; -} - -/* ── Btn small ───────────────────────────────────────────────────── */ -.btn-sm { - padding: 5px 12px; - font-size: 12px; - border-radius: 6px; -} - -/* ── Security dashboard ──────────────────────────────────────────── */ -.security-dashboard { - flex: 1; - overflow-y: auto; - padding: 24px; - display: flex; - flex-direction: column; - gap: 24px; - -webkit-overflow-scrolling: touch; -} - -.sec-notice { - text-align: center; - color: var(--color-text-muted); - padding: 40px 0; - font-size: 15px; -} - -.security-summary { - display: flex; - gap: 20px; - align-items: center; - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius); - padding: 24px; - flex-wrap: wrap; -} - -.sec-score-card { - text-align: center; - flex-shrink: 0; -} - -.sec-score { - font-size: 56px; - font-weight: 700; - line-height: 1; -} - -.sec-score.score-good { - color: #2e7d32; -} - -.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; - font-weight: 700; -} - -.sec-stat-label { - display: block; - font-size: 12px; - color: var(--color-text-muted); - margin-top: 2px; -} - -.sec-section { - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius); - overflow: hidden; -} - -.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; - flex-shrink: 0; -} - -.sec-section-title { - font-weight: 600; - font-size: 15px; -} - -.sec-section-desc { - font-size: 12px; - color: var(--color-text-muted); - margin-top: 2px; -} - -.sec-item-list { - padding: 0; -} - -.sec-item { - display: flex; - align-items: center; - gap: 12px; - padding: 10px 20px; - border-bottom: 1px solid var(--color-border); -} - -.sec-item:last-child { - border-bottom: none; -} - -.sec-item-name { - font-weight: 500; - flex: 1; -} - -.sec-item-sub { - font-size: 12px; - color: var(--color-text-muted); - flex: 1; -} - -/* ── Item Detail Modal ───────────────────────────────────────────── */ -.modal-detail { - max-height: 80vh; - display: flex; - flex-direction: column; -} - -.detail-body { - flex: 1; - overflow-y: auto; - padding: 4px 0; -} - -.detail-field { - display: flex; - align-items: flex-start; - gap: 12px; - padding: 10px 0; - border-bottom: 1px solid var(--color-border); -} - -.detail-field:last-child { - border-bottom: none; -} - -.detail-label { - font-size: 12px; - color: var(--color-text-muted); - width: 140px; - flex-shrink: 0; - padding-top: 4px; -} - -.detail-val-row { - display: flex; - align-items: center; - gap: 6px; - flex: 1; - flex-wrap: wrap; -} - -.detail-val { - font-size: 14px; - word-break: break-all; -} - -.detail-sensitive { - display: inline-flex; - align-items: center; - font-family: monospace; - font-size: 14px; -} - -.detail-masked { - letter-spacing: 2px; -} - -.detail-actual { - word-break: break-all; -} - -.btn-reveal, -.btn-copy-field { - background: none; - border: none; - cursor: pointer; - padding: 2px 4px; - font-size: 14px; - color: var(--color-text-muted); - flex-shrink: 0; -} - -.btn-reveal:hover, -.btn-copy-field:hover { - color: var(--color-primary); -} - -/* ── Emergency Vault Panel ───────────────────────────────────────── */ -.em-vault-panel { - padding: 8px 0; -} - -.em-vault-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 4px 0 16px; - border-bottom: 2px solid var(--color-border); - margin-bottom: 8px; -} - -.em-vault-header h4 { - margin: 0; - font-size: 15px; - font-weight: 600; -} - -.em-vault-list { - list-style: none; - padding: 0; - margin: 0; -} - -.em-vault-item { - border-bottom: 1px solid var(--color-border); -} - -.em-vault-item:last-child { - border-bottom: none; -} - -.em-vault-item-header { - display: flex; - align-items: center; - gap: 10px; - width: 100%; - background: none; - border: none; - padding: 12px 4px; - cursor: pointer; - text-align: left; -} - -.em-vault-item-header:hover { - background: var(--color-sidebar-hover); - border-radius: var(--radius); -} - -.em-vault-icon { - font-size: 18px; - flex-shrink: 0; -} - -.em-vault-name { - font-weight: 500; - flex: 1; -} - -.em-vault-type { - font-size: 12px; - color: var(--color-text-muted); -} - -.em-vault-chevron { - font-size: 12px; - color: var(--color-text-muted); - flex-shrink: 0; -} - -.em-vault-item-body { - padding: 0 8px 12px 36px; -} - -.em-detail-body .detail-field { - padding: 8px 0; -} - -/* ── Responsive ──────────────────────────────────────────────────── */ - -/* Mobile top bar — hidden on tablet+ */ -.mobile-topbar { - display: none; - align-items: center; - gap: 12px; - padding: 0 16px; - height: 54px; - background: var(--color-sidebar-bg); - color: #fff; - flex-shrink: 0; - position: sticky; - top: 0; - z-index: 50; -} - -.btn-mobile-menu { - font-size: 22px; - color: rgba(255, 255, 255, 0.8); - background: none; - border: none; - padding: 4px 6px; - cursor: pointer; - flex-shrink: 0; -} - -.btn-mobile-menu:hover { - color: #fff; -} - -.mobile-logo { - flex: 1; - font-size: 16px; - font-weight: 700; - color: #fff; -} - -.btn-fab-mobile { - width: 36px; - height: 36px; - font-size: 20px; - flex-shrink: 0; -} - -/* Sidebar backdrop */ -.sidebar-backdrop { - display: none; - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.5); - z-index: 89; -} - -.sidebar-backdrop.open { - display: block; -} - -/* ── Phone + iPad portrait (≤ 768px) ───────────────────────────── */ -@media (max-width: 768px) { - .app-layout { - flex-direction: column; - height: 100vh; - height: calc(var(--vh, 1vh) * 100); - overflow: hidden; - } - - .mobile-topbar { - display: flex; - } - - /* Sidebar: completely out of layout flow — fixed overlay only */ - .sidebar { - display: none !important; - position: fixed !important; - top: 0; - left: 0; - bottom: 0; - width: 280px !important; - height: 100% !important; - /* Chrome needs explicit height on position:fixed */ - z-index: 90; - overflow-y: auto; - -webkit-overflow-scrolling: touch; - transition: none; - } - - .sidebar.mobile-open { - display: flex !important; - flex-direction: column; - } - - /* Cancel all collapsed-state overrides inside mobile sidebar */ - .sidebar.collapsed { - display: none !important; - } - - .sidebar.collapsed.mobile-open { - display: flex !important; - } - - /* Mobile sidebar: always show full expanded state */ - .sidebar .sidebar-label { - opacity: 1 !important; - max-width: none !important; - overflow: visible !important; - } - - .sidebar .sidebar-section-header { - opacity: 1 !important; - height: auto !important; - padding: 12px 16px 4px !important; - pointer-events: auto !important; - } - - .sidebar .sidebar-item { - justify-content: flex-start !important; - padding: 9px 16px !important; - overflow: hidden !important; - } - - .sidebar .sidebar-item::after { - display: none !important; - } - - .sidebar .sidebar-header { - justify-content: flex-start !important; - padding: 14px 12px !important; - overflow: hidden !important; - } - - .sidebar .sidebar-header .logo-icon, - .sidebar .sidebar-header .logo-text { - opacity: 1 !important; - width: auto !important; - pointer-events: auto !important; - } - - .btn-sidebar-toggle { - display: none !important; - } - - .vault-main { - flex: 1; - overflow: hidden; - min-height: 0; - } - - .vault-header { - padding: 12px 16px; - gap: 8px; - } - - .vault-title { - font-size: 16px; - } - - .vault-list { - padding: 8px 12px 80px; - } - - .panel-body { - padding: 16px; - } - - .security-dashboard { - padding: 12px; - gap: 16px; - } - - .vault-toolbar { - gap: 8px; - } - - .search-wrapper { - max-width: none; - } - - .sort-wrapper { - display: none; - } - - .vault-header .btn-fab { - display: none; - } - - .form-row { - flex-direction: column; - gap: 0; - } - - .modal-actions { - flex-wrap: wrap; - } - - .modal-overlay { - padding: 0; - align-items: flex-end; - } - - .modal { - max-width: 100% !important; - max-height: calc(var(--vh, 1vh) * 92); - border-radius: 16px 16px 0 0; - padding: 20px 16px; - } - - .security-summary { - padding: 16px; - gap: 12px; - flex-wrap: wrap; - } - - .sec-score { - font-size: 40px; - } - - .sec-stats { - gap: 8px; - flex-wrap: wrap; - } - - .sec-stat { - padding: 8px 12px; - min-width: 68px; - } - - .sec-stat-num { - font-size: 22px; - } - - .detail-field { - flex-direction: column; - gap: 4px; - } - - .detail-label { - width: auto; - } -} - -/* ── iPad landscape / small laptop (769px – 1024px) ─────────────── */ +/* ── Breakpoint-driven padding adjustments ───────────────────────── */ +/* Tablet (769–1024px) */ @media (min-width: 769px) and (max-width: 1024px) { - .sidebar:not(.collapsed) { + body.is-desktop .sidebar:not(.collapsed) { width: 200px; } @@ -1835,24 +918,9 @@ ul { } } -/* ── Laptop (1025px – 1440px) ───────────────────────────────────── */ -@media (min-width: 1025px) and (max-width: 1440px) { - .vault-header { - padding: 16px 24px; - } - - .vault-list { - padding: 12px 24px 24px; - } - - .security-dashboard { - padding: 20px 24px; - } -} - -/* ── Large PC (> 1440px) ────────────────────────────────────────── */ +/* Large PC (> 1440px) */ @media (min-width: 1441px) { - .sidebar:not(.collapsed) { + body.is-desktop .sidebar:not(.collapsed) { width: 260px; } @@ -1894,7 +962,106 @@ ul { } } -/* ── Auth pages — phone ──────────────────────────────────────────── */ +/* Mobile padding and layout overrides */ +body.is-mobile .vault-main { + flex: 1; + overflow: hidden; + min-height: 0; +} + +body.is-mobile .vault-header { + padding: 12px 16px; + gap: 8px; +} + +body.is-mobile .vault-title { + font-size: 16px; +} + +body.is-mobile .vault-list { + padding: 8px 12px 80px; +} + +body.is-mobile .panel-body { + padding: 16px; +} + +body.is-mobile .security-dashboard { + padding: 12px; + gap: 16px; +} + +body.is-mobile .vault-toolbar { + gap: 8px; +} + +body.is-mobile .search-wrapper { + max-width: none; +} + +body.is-mobile .sort-wrapper { + display: none; +} + +body.is-mobile .vault-header .btn-fab { + display: none; +} + +body.is-mobile .form-row { + flex-direction: column; + gap: 0; +} + +body.is-mobile .modal-actions { + flex-wrap: wrap; +} + +body.is-mobile .modal-overlay { + padding: 0; + align-items: flex-end; +} + +body.is-mobile .modal { + max-width: 100%; + max-height: calc(var(--vh, 1vh) * 92); + border-radius: 16px 16px 0 0; + padding: 20px 16px; +} + +body.is-mobile .security-summary { + padding: 16px; + gap: 12px; + flex-wrap: wrap; +} + +body.is-mobile .sec-score { + font-size: 40px; +} + +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 { + font-size: 22px; +} + +body.is-mobile .detail-field { + flex-direction: column; + gap: 4px; +} + +body.is-mobile .detail-label { + width: auto; +} + +/* Auth page — phone */ @media (max-width: 480px) { .auth-container { padding: 12px; @@ -1910,6 +1077,13 @@ ul { } } +/* iOS input font-size — prevent auto-zoom on focus */ +body.is-mobile .form-group input, +body.is-mobile .form-group select, +body.is-mobile .form-group textarea { + font-size: 16px; +} + /* ── Settings — new sections (Phase 6) ──────────────────────────── */ .settings-section-danger { border-top: 2px solid var(--color-danger, #e53e3e); diff --git a/app/static/js/vault.js b/app/static/js/vault.js index dafb05c..563bc55 100644 --- a/app/static/js/vault.js +++ b/app/static/js/vault.js @@ -8,33 +8,60 @@ const Vault = (() => { let _items = []; let _folders = []; - let _currentView = 'vault'; + let _currentView = "vault"; let _activeFilter = null; - let _sortOrder = 'name-asc'; + let _sortOrder = "name-asc"; const FIELD_LABELS = { - url: 'Website URL', username: 'Username', password: 'Password', notes: 'Notes', - note_body: 'Note', cardholder_name: 'Cardholder Name', card_number: 'Card Number', - expiry_month: 'Expiry Month', expiry_year: 'Expiry Year', cvv: 'CVV', - bank_name: 'Bank Name', account_type: 'Account Type', routing_number: 'Routing Number', - account_number: 'Account Number', first_name: 'First Name', last_name: 'Last Name', - company: 'Company', address_line: 'Street Address', city: 'City', state: 'State', - zip: 'ZIP', country: 'Country', phone: 'Phone', email: 'Email', - ssn_number: 'Social Security Number', + url: "Website URL", + username: "Username", + password: "Password", + notes: "Notes", + note_body: "Note", + cardholder_name: "Cardholder Name", + card_number: "Card Number", + expiry_month: "Expiry Month", + expiry_year: "Expiry Year", + cvv: "CVV", + bank_name: "Bank Name", + account_type: "Account Type", + routing_number: "Routing Number", + account_number: "Account Number", + first_name: "First Name", + last_name: "Last Name", + company: "Company", + address_line: "Street Address", + city: "City", + state: "State", + zip: "ZIP", + country: "Country", + phone: "Phone", + email: "Email", + ssn_number: "Social Security Number", }; - const SENSITIVE_FIELDS = new Set(['password', 'cvv', 'card_number', 'account_number', 'routing_number', 'ssn_number']); + const SENSITIVE_FIELDS = new Set([ + "password", + "cvv", + "card_number", + "account_number", + "routing_number", + "ssn_number", + ]); // ── API helpers ─────────────────────────────────────────────────────────── async function apiFetch(path, options = {}) { - let token = sessionStorage.getItem('access_token'); - if (!token) { redirectToLogin(); return null; } + let token = sessionStorage.getItem("access_token"); + if (!token) { + redirectToLogin(); + return null; + } const defaults = { headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - 'X-CSRFToken': csrfToken(), + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + "X-CSRFToken": csrfToken(), }, }; const merged = { ...defaults, ...options }; @@ -44,8 +71,12 @@ const Vault = (() => { if (res.status === 401) { const refreshed = await tryRefreshToken(); - if (!refreshed) { redirectToLogin(); return null; } - merged.headers['Authorization'] = `Bearer ${sessionStorage.getItem('access_token')}`; + if (!refreshed) { + redirectToLogin(); + return null; + } + merged.headers["Authorization"] = + `Bearer ${sessionStorage.getItem("access_token")}`; res = await fetch(path, merged); } @@ -62,64 +93,85 @@ const Vault = (() => { // don't each fire a separate rotation (which would blacklist each other's tokens). if (_refreshPromise) return _refreshPromise; _refreshPromise = (async () => { - const refreshToken = localStorage.getItem('refresh_token'); + const refreshToken = localStorage.getItem("refresh_token"); if (!refreshToken) return false; try { - const res = await fetch('/api/auth/refresh', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken() }, + const res = await fetch("/api/auth/refresh", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": csrfToken(), + }, body: JSON.stringify({ refresh_token: refreshToken }), }); if (!res.ok) return false; const data = await res.json(); - sessionStorage.setItem('access_token', data.access_token); - if (data.refresh_token) localStorage.setItem('refresh_token', data.refresh_token); + sessionStorage.setItem("access_token", data.access_token); + if (data.refresh_token) + localStorage.setItem("refresh_token", data.refresh_token); return true; - } catch { return false; } - })().finally(() => { _refreshPromise = null; }); + } catch { + return false; + } + })().finally(() => { + _refreshPromise = null; + }); return _refreshPromise; } function csrfToken() { - return document.querySelector('meta[name="csrf-token"]')?.content || ''; + return document.querySelector('meta[name="csrf-token"]')?.content || ""; } function redirectToLogin() { VaultSession.clear(); SharingSession.clear(); - sessionStorage.removeItem('access_token'); - window.dispatchEvent(new CustomEvent('passkeeper:logout')); - window.location.href = '/login'; + sessionStorage.removeItem("access_token"); + window.dispatchEvent(new CustomEvent("passkeeper:logout")); + window.location.href = "/login"; } // ── Load ────────────────────────────────────────────────────────────────── async function loadVault() { const vaultKey = VaultSession.getKey(); - if (!vaultKey) { showUnlockOverlay(); return; } + if (!vaultKey) { + showUnlockOverlay(); + return; + } showLoadingState(true); try { const [itemsRes, foldersRes] = await Promise.all([ - apiFetch('/api/vault'), apiFetch('/api/folders'), + apiFetch("/api/vault"), + apiFetch("/api/folders"), ]); - if (!itemsRes || !foldersRes) { showUnlockOverlay(); return; } + if (!itemsRes || !foldersRes) { + showUnlockOverlay(); + return; + } const rawItems = await itemsRes.json(); _folders = await foldersRes.json(); - _items = await Promise.all(rawItems.map(async item => { - try { - const plain = await Crypto.decryptItem(vaultKey, item.enc_data, item.iv); - return { ...item, plain }; - } catch { - return { ...item, plain: null, decryptError: true }; - } - })); + _items = await Promise.all( + rawItems.map(async (item) => { + try { + const plain = await Crypto.decryptItem( + vaultKey, + item.enc_data, + item.iv, + ); + return { ...item, plain }; + } catch { + return { ...item, plain: null, decryptError: true }; + } + }), + ); renderFolderList(); applyCurrentFilter(); } catch (err) { - showToast('Failed to load vault: ' + err.message, 'error'); + showToast("Failed to load vault: " + err.message, "error"); } finally { showLoadingState(false); } @@ -127,12 +179,12 @@ const Vault = (() => { async function loadFolders() { try { - const res = await apiFetch('/api/folders'); + const res = await apiFetch("/api/folders"); if (!res) return; _folders = await res.json(); renderFolderList(); } catch (err) { - showToast('Could not refresh folders: ' + err.message, 'error'); + showToast("Could not refresh folders: " + err.message, "error"); } } @@ -140,43 +192,53 @@ const Vault = (() => { function switchView(view) { _currentView = view; - ['vault', 'security', 'sharing', 'emergency'].forEach(v => { - document.getElementById(`view-${v}`)?.classList.toggle('hidden', v !== view); + ["vault", "security", "sharing", "emergency"].forEach((v) => { + document + .getElementById(`view-${v}`) + ?.classList.toggle("hidden", v !== view); }); - document.querySelectorAll('.sidebar-item[data-view]').forEach(el => { - el.classList.toggle('active', el.dataset.view === view); + document.querySelectorAll(".sidebar-item[data-view]").forEach((el) => { + el.classList.toggle("active", el.dataset.view === view); }); - if (view !== 'vault') { - document.querySelectorAll('.sidebar-item[data-type-filter], .sidebar-item[data-folder-id]') - .forEach(el => el.classList.remove('active')); + if (view !== "vault") { + document + .querySelectorAll( + ".sidebar-item[data-type-filter], .sidebar-item[data-folder-id]", + ) + .forEach((el) => el.classList.remove("active")); } - if (view === 'security') renderSecurityDashboard(); - if (view === 'sharing') loadSharingView(); - if (view === 'emergency') loadEmergencyView(); + if (view === "security") renderSecurityDashboard(); + if (view === "sharing") loadSharingView(); + if (view === "emergency") loadEmergencyView(); } // ── Vault render ────────────────────────────────────────────────────────── function renderFolderList() { - const ul = document.getElementById('sidebar-folders'); + const ul = document.getElementById("sidebar-folders"); if (!ul) return; - ul.innerHTML = ''; - _folders.forEach(folder => { - const li = document.createElement('li'); - li.className = 'sidebar-item folder-item'; + ul.innerHTML = ""; + _folders.forEach((folder) => { + const li = document.createElement("li"); + li.className = "sidebar-item folder-item"; li.dataset.folderId = folder.id; - const label = document.createElement('span'); - label.className = 'folder-label'; + const label = document.createElement("span"); + label.className = "folder-label"; label.innerHTML = `📁 ${escHtml(folder.name)}`; - label.addEventListener('click', () => filterByFolder(folder.id, folder.name)); + label.addEventListener("click", () => + filterByFolder(folder.id, folder.name), + ); - const delBtn = document.createElement('button'); - delBtn.className = 'btn-delete-folder'; - delBtn.title = 'Delete folder'; - delBtn.textContent = '🗑'; - delBtn.addEventListener('click', e => { e.stopPropagation(); confirmDeleteFolder(folder); }); + const delBtn = document.createElement("button"); + delBtn.className = "btn-delete-folder"; + delBtn.title = "Delete folder"; + delBtn.textContent = "🗑"; + delBtn.addEventListener("click", (e) => { + e.stopPropagation(); + confirmDeleteFolder(folder); + }); li.appendChild(label); li.appendChild(delBtn); @@ -187,293 +249,463 @@ const Vault = (() => { function getSortedItems(items) { const sorted = [...items]; switch (_sortOrder) { - case 'name-asc': sorted.sort((a, b) => a.name.localeCompare(b.name)); break; - case 'name-desc': sorted.sort((a, b) => b.name.localeCompare(a.name)); break; - case 'date-desc': sorted.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); break; - case 'date-asc': sorted.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); break; - case 'folder': sorted.sort((a, b) => folderName(a.folder_id).localeCompare(folderName(b.folder_id)) || a.name.localeCompare(b.name)); break; + case "name-asc": + sorted.sort((a, b) => a.name.localeCompare(b.name)); + break; + case "name-desc": + sorted.sort((a, b) => b.name.localeCompare(a.name)); + break; + case "date-desc": + sorted.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); + break; + case "date-asc": + sorted.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); + break; + case "folder": + sorted.sort( + (a, b) => + folderName(a.folder_id).localeCompare(folderName(b.folder_id)) || + a.name.localeCompare(b.name), + ); + break; } return sorted; } function folderName(id) { - if (!id) return '(No folder)'; - return _folders.find(f => f.id === id)?.name || 'Unknown'; + if (!id) return "(No folder)"; + return _folders.find((f) => f.id === id)?.name || "Unknown"; } function renderItemList(items) { - const list = document.getElementById('vault-list'); + const list = document.getElementById("vault-list"); if (!list) return; - list.innerHTML = ''; + list.innerHTML = ""; if (items.length === 0) { - list.innerHTML = '
  • No items found. Click + to add one.
  • '; + list.innerHTML = + '
  • No items found. Click + to add one.
  • '; return; } const sorted = getSortedItems(items); const groups = {}; - sorted.forEach(item => { + sorted.forEach((item) => { const key = folderName(item.folder_id); if (!groups[key]) groups[key] = []; groups[key].push(item); }); - const keys = _sortOrder === 'folder' - ? Object.keys(groups).sort() - : ['(No folder)', ..._folders.map(f => f.name)].filter(k => groups[k]); - Object.keys(groups).forEach(k => { if (!keys.includes(k)) keys.push(k); }); + const keys = + _sortOrder === "folder" + ? Object.keys(groups).sort() + : ["(No folder)", ..._folders.map((f) => f.name)].filter( + (k) => groups[k], + ); + Object.keys(groups).forEach((k) => { + if (!keys.includes(k)) keys.push(k); + }); - keys.forEach(groupName => { + keys.forEach((groupName) => { if (!groups[groupName]) return; - const header = document.createElement('li'); - header.className = 'vault-group-header'; + const header = document.createElement("li"); + header.className = "vault-group-header"; header.textContent = groupName; list.appendChild(header); - groups[groupName].forEach(item => list.appendChild(createItemElement(item))); + groups[groupName].forEach((item) => + list.appendChild(createItemElement(item)), + ); }); } function createItemElement(item) { - const li = document.createElement('li'); - li.className = 'vault-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 iconMap = { + password: "🔑", + note: "📝", + card: "💳", + bank: "🏦", + address: "🏠", + ssn: "🪪", + passkey: "🔐", + }; + const icon = iconMap[item.item_type] || "🔑"; - let subText = ''; + let subText = ""; if (item.plain) { switch (item.item_type) { - case 'password': subText = item.plain.username || item.plain.url || ''; break; - case 'note': subText = (item.plain.note_body || '').slice(0, 60); break; - case 'card': subText = item.plain.card_number ? '•••• ' + String(item.plain.card_number).replace(/\s/g, '').slice(-4) : ''; break; - case 'bank': subText = item.plain.bank_name || ''; break; - case 'address': subText = [item.plain.first_name, item.plain.last_name].filter(Boolean).join(' '); break; - case 'ssn': subText = '•••-••-••••'; break; + case "password": + subText = item.plain.username || item.plain.url || ""; + break; + case "note": + subText = (item.plain.note_body || "").slice(0, 60); + break; + case "card": + subText = item.plain.card_number + ? "•••• " + + String(item.plain.card_number).replace(/\s/g, "").slice(-4) + : ""; + break; + case "bank": + subText = item.plain.bank_name || ""; + break; + case "address": + subText = [item.plain.first_name, item.plain.last_name] + .filter(Boolean) + .join(" "); + break; + case "ssn": + subText = "•••-••-••••"; + break; } } - const showCopyUser = item.item_type === 'password'; - const showCopyPass = ['password', 'card', 'bank', 'ssn'].includes(item.item_type); - const showLaunch = item.item_type === 'password' && item.plain?.url; + const showCopyUser = item.item_type === "password"; + const showCopyPass = ["password", "card", "bank", "ssn"].includes( + item.item_type, + ); + const showLaunch = item.item_type === "password" && item.plain?.url; + const showTotp = item.item_type === "password" && !!extractTotpSecret(item.plain?.totp_uri); li.innerHTML = `
    ${icon}
    ${escHtml(item.name)} ${escHtml(subText)} + ${showTotp ? `······` : ""}
    - ${showLaunch ? `` : ''} - ${showCopyUser ? `` : ''} - ${showCopyPass ? `` : ''} + ${showLaunch ? `` : ""} + ${showCopyUser ? `` : ""} + ${showCopyPass ? `` : ""} + ${showTotp ? `` : ""}
    `; + if (showTotp) { + // Start a live TOTP ticker for this item. + const secret = extractTotpSecret(item.plain.totp_uri); + const displayEl = li.querySelector(`#totp-display-${item.id}`); + const codeEl = displayEl.querySelector(".totp-code"); + const timerEl = displayEl.querySelector(".totp-timer"); + + async function refreshTotp() { + const code = await getTotpCode(secret).catch(() => null); + if (!code) return; + // Insert a space in the middle for readability: 123 456 + codeEl.textContent = code.slice(0, 3) + " " + code.slice(3); + const secs = totpSecondsLeft(); + timerEl.textContent = " (" + secs + "s)"; + // Colour the timer red when it's about to expire. + timerEl.style.color = secs <= 5 ? "#dc2626" : "#9ca3af"; + } + refreshTotp(); + // Refresh every second. Store interval id on the element for cleanup. + const intervalId = setInterval(refreshTotp, 1000); + li.dataset.totpInterval = intervalId; + } + if (showLaunch) { - li.querySelector('[data-action="launch"]').addEventListener('click', e => { - e.stopPropagation(); - let url = item.plain?.url || ''; - if (url && !/^https?:\/\//i.test(url)) url = 'https://' + url; - window.open(url, '_blank', 'noopener,noreferrer'); - }); + li.querySelector('[data-action="launch"]').addEventListener( + "click", + (e) => { + e.stopPropagation(); + let url = item.plain?.url || ""; + if (url && !/^https?:\/\//i.test(url)) url = "https://" + url; + window.open(url, "_blank", "noopener,noreferrer"); + }, + ); } if (showCopyUser) { - li.querySelector('[data-action="copy-user"]').addEventListener('click', e => { - e.stopPropagation(); copyToClipboard(item.plain?.username || '', 'Username copied'); - }); + li.querySelector('[data-action="copy-user"]').addEventListener( + "click", + (e) => { + e.stopPropagation(); + copyToClipboard(item.plain?.username || "", "Username copied"); + }, + ); } if (showCopyPass) { - li.querySelector('[data-action="copy-pass"]').addEventListener('click', e => { - e.stopPropagation(); - const s = item.item_type === 'password' ? item.plain?.password - : item.item_type === 'card' ? item.plain?.cvv - : item.item_type === 'bank' ? item.plain?.account_number - : item.plain?.ssn_number; - copyToClipboard(s || '', 'Copied to clipboard'); - }); + li.querySelector('[data-action="copy-pass"]').addEventListener( + "click", + (e) => { + e.stopPropagation(); + const s = + item.item_type === "password" + ? item.plain?.password + : item.item_type === "card" + ? item.plain?.cvv + : item.item_type === "bank" + ? item.plain?.account_number + : item.plain?.ssn_number; + copyToClipboard(s || "", "Copied to clipboard"); + }, + ); } - li.querySelector('[data-action="edit"]').addEventListener('click', e => { e.stopPropagation(); openModal('edit', item); }); - li.querySelector('[data-action="delete"]').addEventListener('click', e => { e.stopPropagation(); confirmDeleteItem(item); }); - li.addEventListener('click', () => openModal('edit', item)); + if (showTotp) { + li.querySelector('[data-action="copy-totp"]').addEventListener( + "click", + async (e) => { + e.stopPropagation(); + const secret = extractTotpSecret(item.plain?.totp_uri); + const code = await getTotpCode(secret).catch(() => null); + if (code) copyToClipboard(code, "2FA code copied"); + }, + ); + } + li.querySelector('[data-action="edit"]').addEventListener("click", (e) => { + e.stopPropagation(); + openModal("edit", item); + }); + li.querySelector('[data-action="delete"]').addEventListener( + "click", + (e) => { + e.stopPropagation(); + confirmDeleteItem(item); + }, + ); + li.addEventListener("click", () => openModal("edit", item)); return li; } // ── Security Dashboard ──────────────────────────────────────────────────── function renderSecurityDashboard() { - const summaryEl = document.getElementById('security-summary'); - const sectionsEl = document.getElementById('security-sections'); + const summaryEl = document.getElementById("security-summary"); + const sectionsEl = document.getElementById("security-sections"); if (!summaryEl || !sectionsEl) return; // If vault hasn't loaded yet, wait for it then re-render if (!_items.length && VaultSession.getKey()) { summaryEl.innerHTML = '
    Loading…
    '; - sectionsEl.innerHTML = ''; + sectionsEl.innerHTML = ""; loadVault().then(() => renderSecurityDashboard()); return; } - const pwItems = _items.filter(i => i.item_type === 'password' && i.plain?.password); + const pwItems = _items.filter( + (i) => i.item_type === "password" && i.plain?.password, + ); if (!pwItems.length) { - summaryEl.innerHTML = '
    No passwords to analyze.
    '; - sectionsEl.innerHTML = ''; + summaryEl.innerHTML = + '
    No passwords to analyze.
    '; + sectionsEl.innerHTML = ""; return; } - const weak = pwItems.filter(i => { + const weak = pwItems.filter((i) => { const p = i.plain.password; if (p.length < 10) return true; - return [/[A-Z]/, /[a-z]/, /[0-9]/, /[^A-Za-z0-9]/].filter(r => r.test(p)).length < 2; + return ( + [/[A-Z]/, /[a-z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((r) => r.test(p)) + .length < 2 + ); }); const passCounts = {}; - pwItems.forEach(i => { const p = i.plain.password; (passCounts[p] = passCounts[p] || []).push(i); }); - const reused = Object.values(passCounts).filter(a => a.length > 1).flat(); + pwItems.forEach((i) => { + const p = i.plain.password; + (passCounts[p] = passCounts[p] || []).push(i); + }); + const reused = Object.values(passCounts) + .filter((a) => a.length > 1) + .flat(); const cutoff = Date.now() - 180 * 86400000; - const old = pwItems.filter(i => new Date(i.created_at).getTime() < cutoff); + const old = pwItems.filter( + (i) => new Date(i.created_at).getTime() < cutoff, + ); const total = pwItems.length; - const score = Math.max(0, Math.round(100 - (weak.length / total) * 40 - (reused.length / total) * 30 - (old.length / total) * 15)); - const cls = score >= 80 ? 'score-good' : score >= 50 ? 'score-fair' : 'score-poor'; - const label = score >= 80 ? 'Good' : score >= 50 ? 'Fair' : 'Needs attention'; + const score = Math.max( + 0, + Math.round( + 100 - + (weak.length / total) * 40 - + (reused.length / total) * 30 - + (old.length / total) * 15, + ), + ); + const cls = + score >= 80 ? "score-good" : score >= 50 ? "score-fair" : "score-poor"; + const label = + score >= 80 ? "Good" : score >= 50 ? "Fair" : "Needs attention"; summaryEl.innerHTML = `
    ${score}
    ${label}
    -
    Based on ${total} password${total !== 1 ? 's' : ''}
    +
    Based on ${total} password${total !== 1 ? "s" : ""}
    -
    ${weak.length}Weak
    -
    ${reused.length}Reused
    -
    ${old.length}Old (>180d)
    +
    ${weak.length}Weak
    +
    ${reused.length}Reused
    +
    ${old.length}Old (>180d)
    `; - sectionsEl.innerHTML = ''; + sectionsEl.innerHTML = ""; const makeSection = (title, icon, items, desc) => { if (!items.length) return; - const sec = document.createElement('div'); - sec.className = 'sec-section'; + const sec = document.createElement("div"); + sec.className = "sec-section"; sec.innerHTML = `
    ${icon}
    ${title} (${items.length})
    ${desc}
    `; - sec.querySelectorAll('[data-sec-edit]').forEach(btn => { - btn.addEventListener('click', () => { - const item = _items.find(it => it.id === parseInt(btn.dataset.secEdit)); - if (item) { switchView('vault'); openModal('edit', item); } + sec.querySelectorAll("[data-sec-edit]").forEach((btn) => { + btn.addEventListener("click", () => { + const item = _items.find( + (it) => it.id === parseInt(btn.dataset.secEdit), + ); + if (item) { + switchView("vault"); + openModal("edit", item); + } }); }); sectionsEl.appendChild(sec); }; - makeSection('Weak Passwords', '⚠️', weak, 'Short or low-complexity passwords.'); - 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, 'Not changed in over 180 days.'); + makeSection( + "Weak Passwords", + "⚠️", + weak, + "Short or low-complexity passwords.", + ); + 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, "Not changed in over 180 days."); } // ── Sharing View ────────────────────────────────────────────────────────── async function loadSharingView() { try { - const keysRes = await apiFetch('/api/sharing/keys'); + const keysRes = await apiFetch("/api/sharing/keys"); if (!keysRes) return; const keysData = await keysRes.json(); - const notice = document.getElementById('sharing-keys-notice'); + const notice = document.getElementById("sharing-keys-notice"); if (!keysData.keys_setup) { - notice.classList.remove('hidden'); + notice.classList.remove("hidden"); } else { - notice.classList.add('hidden'); + notice.classList.add("hidden"); // Load sharing private key into SharingSession if vault key available const vaultKey = VaultSession.getKey(); if (vaultKey && !SharingSession.isReady()) { try { const privKey = await SharingCrypto.decryptPrivateKey( - vaultKey, keysData.private_key_enc, keysData.private_key_iv + vaultKey, + keysData.private_key_enc, + keysData.private_key_iv, ); SharingSession.setKey(privKey); } catch (err) { - console.warn('[PassKeeper] Could not load sharing private key:', err); + console.warn( + "[PassKeeper] Could not load sharing private key:", + err, + ); } } } await Promise.all([loadSharedOut(), loadSharedIn()]); } catch (err) { - showToast('Could not load sharing data: ' + err.message, 'error'); + showToast("Could not load sharing data: " + err.message, "error"); } } async function loadSharedOut() { try { - const res = await apiFetch('/api/sharing'); + const res = await apiFetch("/api/sharing"); if (!res) return; const shares = await res.json(); renderSharedOut(shares); } catch (err) { - console.error('loadSharedOut:', err); + console.error("loadSharedOut:", err); } } async function loadSharedIn() { try { - const res = await apiFetch('/api/sharing/inbox'); + const res = await apiFetch("/api/sharing/inbox"); if (!res) return; const shares = await res.json(); renderSharedIn(shares); } catch (err) { - console.error('loadSharedIn:', err); + console.error("loadSharedIn:", err); } } function renderSharedOut(shares) { - const ul = document.getElementById('shared-out-list'); + const ul = document.getElementById("shared-out-list"); if (!ul) return; if (!shares.length) { - ul.innerHTML = '
  • You haven\'t shared any items yet.
  • '; + ul.innerHTML = + '
  • You haven\'t shared any items yet.
  • '; return; } - ul.innerHTML = shares.map(s => ` + ul.innerHTML = shares + .map( + (s) => `
  • -
  • `).join(''); + `, + ) + .join(""); - ul.querySelectorAll('[data-revoke]').forEach(btn => { - btn.addEventListener('click', async () => { - if (!confirm('Remove this share?')) return; + ul.querySelectorAll("[data-revoke]").forEach((btn) => { + btn.addEventListener("click", async () => { + if (!confirm("Remove this share?")) return; try { - await apiFetch(`/api/sharing/${btn.dataset.revoke}`, { method: 'DELETE' }); - showToast('Share removed'); + await apiFetch(`/api/sharing/${btn.dataset.revoke}`, { + method: "DELETE", + }); + showToast("Share removed"); loadSharedOut(); - } catch (err) { showToast(err.message, 'error'); } + } catch (err) { + showToast(err.message, "error"); + } }); }); } function renderSharedIn(shares) { - const ul = document.getElementById('shared-in-list'); + const ul = document.getElementById("shared-in-list"); if (!ul) return; if (!shares.length) { - ul.innerHTML = '
  • No items have been shared with you.
  • '; + ul.innerHTML = + '
  • No items have been shared with you.
  • '; return; } - ul.innerHTML = shares.map(s => ` + ul.innerHTML = shares + .map( + (s) => `
  • ${!s.accepted - ? `` - : `` - } -
  • `).join(''); + ? `` + : `` + } + `, + ) + .join(""); - ul.querySelectorAll('[data-accept]').forEach(btn => { - btn.addEventListener('click', async () => { + ul.querySelectorAll("[data-accept]").forEach((btn) => { + btn.addEventListener("click", async () => { try { - await apiFetch(`/api/sharing/inbox/${btn.dataset.accept}/accept`, { method: 'POST' }); - showToast('Share accepted'); + await apiFetch(`/api/sharing/inbox/${btn.dataset.accept}/accept`, { + method: "POST", + }); + showToast("Share accepted"); loadSharedIn(); - } catch (err) { showToast(err.message, 'error'); } + } catch (err) { + showToast(err.message, "error"); + } }); }); - ul.querySelectorAll('[data-view-share]').forEach(btn => { - btn.addEventListener('click', async () => { - if (!SharingSession.isReady()) { showToast('Sharing keys not loaded. Check Settings.', 'error'); return; } + ul.querySelectorAll("[data-view-share]").forEach((btn) => { + btn.addEventListener("click", async () => { + if (!SharingSession.isReady()) { + showToast("Sharing keys not loaded. Check Settings.", "error"); + return; + } try { - const ownerPubKey = await SharingCrypto.importPublicKey(btn.dataset.ownerKey); - const sharedKey = await SharingCrypto.deriveSharedKey(SharingSession.getKey(), ownerPubKey); - const plain = await SharingCrypto.decryptShare(sharedKey, btn.dataset.enc, btn.dataset.iv); + const ownerPubKey = await SharingCrypto.importPublicKey( + btn.dataset.ownerKey, + ); + const sharedKey = await SharingCrypto.deriveSharedKey( + SharingSession.getKey(), + ownerPubKey, + ); + const plain = await SharingCrypto.decryptShare( + sharedKey, + btn.dataset.enc, + btn.dataset.iv, + ); showSharedItemDetails(btn.dataset.name, btn.dataset.type, plain); - } catch (err) { showToast('Could not decrypt: ' + err.message, 'error'); } + } catch (err) { + showToast("Could not decrypt: " + err.message, "error"); + } }); }); } function showSharedItemDetails(name, itemType, plain) { - document.getElementById('detail-modal-title').textContent = `${itemIcon(itemType || 'password')} ${name}`; - renderDetailFields(document.getElementById('detail-modal-body'), plain); - document.getElementById('detail-modal').classList.add('open'); + document.getElementById("detail-modal-title").textContent = + `${itemIcon(itemType || "password")} ${name}`; + renderDetailFields(document.getElementById("detail-modal-body"), plain); + document.getElementById("detail-modal").classList.add("open"); } function renderDetailFields(container, plain) { - container.innerHTML = Object.entries(plain) - .filter(([, v]) => v) - .map(([k, v]) => { - const label = FIELD_LABELS[k] || k.replace(/_/g, ' '); - const val = String(v); - const sensitive = SENSITIVE_FIELDS.has(k); - const valHtml = sensitive - ? ` + container.innerHTML = + Object.entries(plain) + .filter(([, v]) => v) + .map(([k, v]) => { + const label = FIELD_LABELS[k] || k.replace(/_/g, " "); + const val = String(v); + const sensitive = SENSITIVE_FIELDS.has(k); + const valHtml = sensitive + ? ` •••••••• ` - : `${escHtml(val)}`; - return `
    + : `${escHtml(val)}`; + return `
    ${escHtml(label)}
    ${valHtml}
    `; - }).join('') || '

    No fields to display.

    '; + }) + .join("") || '

    No fields to display.

    '; - container.querySelectorAll('.btn-reveal').forEach(btn => { - btn.addEventListener('click', () => { + container.querySelectorAll(".btn-reveal").forEach((btn) => { + btn.addEventListener("click", () => { const span = btn.previousElementSibling; - const revealed = span.dataset.revealed === 'true'; + const revealed = span.dataset.revealed === "true"; span.dataset.revealed = String(!revealed); - span.querySelector('.detail-masked').classList.toggle('hidden', !revealed); - span.querySelector('.detail-actual').classList.toggle('hidden', revealed); + span + .querySelector(".detail-masked") + .classList.toggle("hidden", !revealed); + span + .querySelector(".detail-actual") + .classList.toggle("hidden", revealed); }); }); - container.querySelectorAll('.btn-copy-field').forEach(btn => { - btn.addEventListener('click', () => { - navigator.clipboard.writeText(btn.dataset.copy).then(() => showToast('Copied!')); + container.querySelectorAll(".btn-copy-field").forEach((btn) => { + btn.addEventListener("click", () => { + navigator.clipboard + .writeText(btn.dataset.copy) + .then(() => showToast("Copied!")); }); }); } async function handleShareFormSubmit(e) { e.preventDefault(); - const errEl = document.getElementById('share-error'); - errEl.classList.add('hidden'); + const errEl = document.getElementById("share-error"); + errEl.classList.add("hidden"); if (!SharingSession.isReady()) { - showToast('Set up sharing keys first (Settings)', 'error'); + showToast("Set up sharing keys first (Settings)", "error"); return; } - const itemId = parseInt(document.getElementById('share-item-select').value); - const recipientEmail = document.getElementById('share-recipient-email').value.trim().toLowerCase(); + const itemId = parseInt(document.getElementById("share-item-select").value); + const recipientEmail = document + .getElementById("share-recipient-email") + .value.trim() + .toLowerCase(); if (!itemId || !recipientEmail) { - errEl.textContent = 'Please select an item and enter a recipient email.'; - errEl.classList.remove('hidden'); + errEl.textContent = "Please select an item and enter a recipient email."; + errEl.classList.remove("hidden"); return; } try { // Fetch recipient's public key - const pkRes = await apiFetch(`/api/sharing/public-key?email=${encodeURIComponent(recipientEmail)}`); + const pkRes = await apiFetch( + `/api/sharing/public-key?email=${encodeURIComponent(recipientEmail)}`, + ); if (!pkRes) return; const pkData = await pkRes.json(); - const recipientPubKey = await SharingCrypto.importPublicKey(pkData.public_key); - const sharedKey = await SharingCrypto.deriveSharedKey(SharingSession.getKey(), recipientPubKey); + const recipientPubKey = await SharingCrypto.importPublicKey( + pkData.public_key, + ); + const sharedKey = await SharingCrypto.deriveSharedKey( + SharingSession.getKey(), + recipientPubKey, + ); // Find item plaintext - const item = _items.find(i => i.id === itemId); - if (!item?.plain) { showToast('Item not found or not decrypted', 'error'); return; } + const item = _items.find((i) => i.id === itemId); + if (!item?.plain) { + showToast("Item not found or not decrypted", "error"); + return; + } - const { enc_data, iv } = await SharingCrypto.encryptForShare(sharedKey, item.plain); + const { enc_data, iv } = await SharingCrypto.encryptForShare( + sharedKey, + item.plain, + ); - const res = await apiFetch('/api/sharing', { - method: 'POST', + const res = await apiFetch("/api/sharing", { + method: "POST", body: JSON.stringify({ item_id: itemId, recipient_email: recipientEmail, - enc_data, iv, + enc_data, + iv, item_name: item.name, item_type: item.item_type, }), }); if (!res) return; - closeModal('share-modal'); - showToast('Item shared successfully'); + closeModal("share-modal"); + showToast("Item shared successfully"); loadSharedOut(); } catch (err) { errEl.textContent = err.message; - errEl.classList.remove('hidden'); + errEl.classList.remove("hidden"); } } function openShareModal() { - const sel = document.getElementById('share-item-select'); - sel.innerHTML = _items.map(i => - `` - ).join(''); - document.getElementById('share-recipient-email').value = ''; - document.getElementById('share-error').classList.add('hidden'); - document.getElementById('share-modal').classList.add('open'); + const sel = document.getElementById("share-item-select"); + sel.innerHTML = _items + .map( + (i) => + ``, + ) + .join(""); + document.getElementById("share-recipient-email").value = ""; + document.getElementById("share-error").classList.add("hidden"); + document.getElementById("share-modal").classList.add("open"); } // ── Emergency Access View ───────────────────────────────────────────────── async function loadEmergencyView() { try { - const res = await apiFetch('/api/emergency'); + const res = await apiFetch("/api/emergency"); if (!res) return; const data = await res.json(); renderEmergencyGrants(data.grants); renderEmergencyAccess(data.access); } catch (err) { - showToast('Could not load emergency access data: ' + err.message, 'error'); + showToast( + "Could not load emergency access data: " + err.message, + "error", + ); } } function renderEmergencyGrants(grants) { - const ul = document.getElementById('em-grants-list'); + const ul = document.getElementById("em-grants-list"); if (!ul) return; if (!grants.length) { - ul.innerHTML = '
  • No trusted contacts set up yet.
  • '; + ul.innerHTML = + '
  • No trusted contacts set up yet.
  • '; return; } - ul.innerHTML = grants.map(g => { - let actions = ''; - if (g.status === 'accepted') { - actions = ``; - } - if (g.status === 'pending') { - const waitInfo = g.wait_elapsed ? 'Wait period elapsed' : 'Access requested'; - actions = `${waitInfo} + ul.innerHTML = grants + .map((g) => { + let actions = ""; + if (g.status === "accepted") { + actions = ``; + } + if (g.status === "pending") { + const waitInfo = g.wait_elapsed + ? "Wait period elapsed" + : "Access requested"; + actions = `${waitInfo} `; - } - return ``; - }).join(''); + }) + .join(""); - ul.querySelectorAll('[data-provide]').forEach(btn => { - btn.addEventListener('click', () => handleProvideEmergencyData(parseInt(btn.dataset.provide))); + ul.querySelectorAll("[data-provide]").forEach((btn) => { + btn.addEventListener("click", () => + handleProvideEmergencyData(parseInt(btn.dataset.provide)), + ); }); - ul.querySelectorAll('[data-deny]').forEach(btn => { - btn.addEventListener('click', async () => { - if (!confirm('Deny this access request?')) return; + ul.querySelectorAll("[data-deny]").forEach((btn) => { + btn.addEventListener("click", async () => { + if (!confirm("Deny this access request?")) return; try { - await apiFetch(`/api/emergency/${btn.dataset.deny}/deny`, { method: 'POST' }); - showToast('Access request denied'); + await apiFetch(`/api/emergency/${btn.dataset.deny}/deny`, { + method: "POST", + }); + showToast("Access request denied"); loadEmergencyView(); - } catch (err) { showToast(err.message, 'error'); } + } catch (err) { + showToast(err.message, "error"); + } }); }); - ul.querySelectorAll('[data-remove-ea]').forEach(btn => { - btn.addEventListener('click', async () => { - if (!confirm('Remove this emergency access grant?')) return; + ul.querySelectorAll("[data-remove-ea]").forEach((btn) => { + btn.addEventListener("click", async () => { + if (!confirm("Remove this emergency access grant?")) return; try { - await apiFetch(`/api/emergency/${btn.dataset.removeEa}`, { method: 'DELETE' }); - showToast('Emergency access removed'); + await apiFetch(`/api/emergency/${btn.dataset.removeEa}`, { + method: "DELETE", + }); + showToast("Emergency access removed"); loadEmergencyView(); - } catch (err) { showToast(err.message, 'error'); } + } catch (err) { + showToast(err.message, "error"); + } }); }); } async function handleProvideEmergencyData(eaId) { if (!SharingSession.isReady()) { - showToast('Set up sharing keys first (Settings → Sharing Keys)', 'error'); + showToast("Set up sharing keys first (Settings → Sharing Keys)", "error"); return; } - if (!confirm('This will create an encrypted snapshot of all your vault items for your trusted contact. Continue?')) return; + if ( + !confirm( + "This will create an encrypted snapshot of all your vault items for your trusted contact. Continue?", + ) + ) + return; try { // Get grantee's public key from the emergency record - const listRes = await apiFetch('/api/emergency'); + const listRes = await apiFetch("/api/emergency"); const listData = await listRes.json(); - const ea = listData.grants.find(g => g.id === eaId); - if (!ea) { showToast('Emergency access record not found', 'error'); return; } + const ea = listData.grants.find((g) => g.id === eaId); + if (!ea) { + showToast("Emergency access record not found", "error"); + return; + } // Fetch grantee's public key - const pkRes = await apiFetch(`/api/sharing/public-key?email=${encodeURIComponent(ea.grantee_email)}`); + const pkRes = await apiFetch( + `/api/sharing/public-key?email=${encodeURIComponent(ea.grantee_email)}`, + ); if (!pkRes) return; const pkData = await pkRes.json(); - const granteePubKey = await SharingCrypto.importPublicKey(pkData.public_key); - const sharedKey = await SharingCrypto.deriveSharedKey(SharingSession.getKey(), granteePubKey); + const granteePubKey = await SharingCrypto.importPublicKey( + pkData.public_key, + ); + const sharedKey = await SharingCrypto.deriveSharedKey( + SharingSession.getKey(), + granteePubKey, + ); // Re-encrypt all decrypted vault items const encItems = await Promise.all( - _items.filter(i => i.plain).map(async i => { - const { enc_data, iv } = await SharingCrypto.encryptForShare(sharedKey, i.plain); - return { id: i.id, name: i.name, item_type: i.item_type, enc_data, iv }; - }) + _items + .filter((i) => i.plain) + .map(async (i) => { + const { enc_data, iv } = await SharingCrypto.encryptForShare( + sharedKey, + i.plain, + ); + return { + id: i.id, + name: i.name, + item_type: i.item_type, + enc_data, + iv, + }; + }), ); await apiFetch(`/api/emergency/${eaId}/provide`, { - method: 'POST', + method: "POST", body: JSON.stringify({ enc_vault: JSON.stringify(encItems) }), }); - showToast('Recovery data provided'); + showToast("Recovery data provided"); loadEmergencyView(); } catch (err) { - showToast('Failed to provide recovery data: ' + err.message, 'error'); + showToast("Failed to provide recovery data: " + err.message, "error"); } } function renderEmergencyAccess(access) { - const ul = document.getElementById('em-access-list'); + const ul = document.getElementById("em-access-list"); if (!ul) return; if (!access.length) { - ul.innerHTML = '
  • No one has designated you as an emergency contact.
  • '; + ul.innerHTML = + '
  • No one has designated you as an emergency contact.
  • '; return; } - ul.innerHTML = access.map(a => { - let actions = ''; - if (a.status === 'invited') { - actions = ``; - } else if (a.status === 'ready') { - actions = ``; - } else if (a.status === 'pending') { - let daysLeft = a.wait_days; - if (a.request_initiated_at) { - const elapsed = (Date.now() - new Date(a.request_initiated_at).getTime()) / 86400000; - daysLeft = Math.max(0, Math.ceil(a.wait_days - elapsed)); + ul.innerHTML = access + .map((a) => { + let actions = ""; + if (a.status === "invited") { + actions = ``; + } else if (a.status === "ready") { + actions = ``; + } else if (a.status === "pending") { + let daysLeft = a.wait_days; + if (a.request_initiated_at) { + const elapsed = + (Date.now() - new Date(a.request_initiated_at).getTime()) / + 86400000; + daysLeft = Math.max(0, Math.ceil(a.wait_days - elapsed)); + } + actions = a.wait_elapsed + ? `` + : `Waiting (~${daysLeft} day${daysLeft !== 1 ? "s" : ""} left)`; } - actions = a.wait_elapsed - ? `` - : `Waiting (~${daysLeft} day${daysLeft !== 1 ? 's' : ''} left)`; - } - return ``; - }).join(''); + }) + .join(""); - ul.querySelectorAll('[data-accept-ea]').forEach(btn => { - btn.addEventListener('click', async () => { + ul.querySelectorAll("[data-accept-ea]").forEach((btn) => { + btn.addEventListener("click", async () => { try { - await apiFetch(`/api/emergency/${btn.dataset.acceptEa}/accept`, { method: 'POST' }); - showToast('Emergency access accepted'); + await apiFetch(`/api/emergency/${btn.dataset.acceptEa}/accept`, { + method: "POST", + }); + showToast("Emergency access accepted"); loadEmergencyView(); - } catch (err) { showToast(err.message, 'error'); } + } catch (err) { + showToast(err.message, "error"); + } }); }); - ul.querySelectorAll('[data-request-ea]').forEach(btn => { - btn.addEventListener('click', async () => { - if (!confirm('Request emergency access? This will start the waiting period. The vault owner will be notified.')) return; + ul.querySelectorAll("[data-request-ea]").forEach((btn) => { + btn.addEventListener("click", async () => { + if ( + !confirm( + "Request emergency access? This will start the waiting period. The vault owner will be notified.", + ) + ) + return; try { - await apiFetch(`/api/emergency/${btn.dataset.requestEa}/request`, { method: 'POST' }); - showToast('Access request sent — wait period has started'); + await apiFetch(`/api/emergency/${btn.dataset.requestEa}/request`, { + method: "POST", + }); + showToast("Access request sent — wait period has started"); loadEmergencyView(); - } catch (err) { showToast(err.message, 'error'); } + } catch (err) { + showToast(err.message, "error"); + } }); }); - ul.querySelectorAll('[data-get-vault]').forEach(btn => { - btn.addEventListener('click', () => handleGetEmergencyVault(parseInt(btn.dataset.getVault))); + ul.querySelectorAll("[data-get-vault]").forEach((btn) => { + btn.addEventListener("click", () => + handleGetEmergencyVault(parseInt(btn.dataset.getVault)), + ); }); } async function handleGetEmergencyVault(eaId) { if (!SharingSession.isReady()) { - showToast('Set up sharing keys first (Settings → Sharing Keys)', 'error'); + showToast("Set up sharing keys first (Settings → Sharing Keys)", "error"); return; } try { @@ -799,43 +1144,57 @@ const Vault = (() => { if (!res) return; const data = await res.json(); - const grantorPubKey = await SharingCrypto.importPublicKey(data.grantor_public_key); - const sharedKey = await SharingCrypto.deriveSharedKey(SharingSession.getKey(), grantorPubKey); + const grantorPubKey = await SharingCrypto.importPublicKey( + data.grantor_public_key, + ); + const sharedKey = await SharingCrypto.deriveSharedKey( + SharingSession.getKey(), + grantorPubKey, + ); const encItems = JSON.parse(data.enc_vault); - const decItems = await Promise.all(encItems.map(async i => { - try { - const plain = await SharingCrypto.decryptShare(sharedKey, i.enc_data, i.iv); - return { ...i, plain }; - } catch { - return { ...i, plain: null }; - } - })); + const decItems = await Promise.all( + encItems.map(async (i) => { + try { + const plain = await SharingCrypto.decryptShare( + sharedKey, + i.enc_data, + i.iv, + ); + return { ...i, plain }; + } catch { + return { ...i, plain: null }; + } + }), + ); renderEmergencyVaultPanel(decItems); } catch (err) { - showToast('Could not retrieve vault: ' + err.message, 'error'); + showToast("Could not retrieve vault: " + err.message, "error"); } } function renderEmergencyVaultPanel(items) { - const list = document.getElementById('em-access-list'); - const panel = document.getElementById('em-vault-panel'); - const vaultList = document.getElementById('em-vault-list'); + const list = document.getElementById("em-access-list"); + const panel = document.getElementById("em-vault-panel"); + const vaultList = document.getElementById("em-vault-list"); if (!panel || !vaultList) return; - list.classList.add('hidden'); - panel.classList.remove('hidden'); + list.classList.add("hidden"); + panel.classList.remove("hidden"); if (!items.length) { - vaultList.innerHTML = '
  • No items in emergency vault.
  • '; + vaultList.innerHTML = + '
  • No items in emergency vault.
  • '; return; } - vaultList.innerHTML = items.map((item, idx) => { - const icon = itemIcon(item.item_type); - const hasFields = item.plain && Object.values(item.plain).some(v => v); - return `
  • + vaultList.innerHTML = items + .map((item, idx) => { + const icon = itemIcon(item.item_type); + const hasFields = + item.plain && Object.values(item.plain).some((v) => v); + return `
  • `; - }).join(''); + }) + .join(""); // Populate detail fields and wire expand/collapse items.forEach((item, idx) => { const header = vaultList.querySelector(`[data-em-idx="${idx}"]`); const body = document.getElementById(`em-vault-body-${idx}`); - const detailBody = body?.querySelector('.em-detail-body'); + const detailBody = body?.querySelector(".em-detail-body"); if (detailBody && item.plain) renderDetailFields(detailBody, item.plain); - header?.addEventListener('click', () => { - const expanded = header.getAttribute('aria-expanded') === 'true'; - header.setAttribute('aria-expanded', String(!expanded)); - header.querySelector('.em-vault-chevron').textContent = expanded ? '▸' : '▾'; - body?.classList.toggle('hidden', expanded); + header?.addEventListener("click", () => { + const expanded = header.getAttribute("aria-expanded") === "true"; + header.setAttribute("aria-expanded", String(!expanded)); + header.querySelector(".em-vault-chevron").textContent = expanded + ? "▸" + : "▾"; + body?.classList.toggle("hidden", expanded); }); }); - document.getElementById('btn-em-vault-back')?.addEventListener('click', () => { - panel.classList.add('hidden'); - list.classList.remove('hidden'); - }); + document + .getElementById("btn-em-vault-back") + ?.addEventListener("click", () => { + panel.classList.add("hidden"); + list.classList.remove("hidden"); + }); } // ── Password Generator ──────────────────────────────────────────────── const GEN_CHARSETS = { - upper: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', - lower: 'abcdefghijklmnopqrstuvwxyz', - digits: '0123456789', - symbols: '!@#$%^&*()-_=+[]{}|;:,.<>?', + upper: "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + lower: "abcdefghijklmnopqrstuvwxyz", + digits: "0123456789", + symbols: "!@#$%^&*()-_=+[]{}|;:,.<>?", }; - function generatePassword(length = 20, opts = { upper: true, lower: true, digits: true, symbols: true }) { - let pool = ''; + function generatePassword( + length = 20, + opts = { upper: true, lower: true, digits: true, symbols: true }, + ) { + let pool = ""; const required = []; - if (opts.upper) { pool += GEN_CHARSETS.upper; required.push(GEN_CHARSETS.upper); } - if (opts.lower) { pool += GEN_CHARSETS.lower; required.push(GEN_CHARSETS.lower); } - if (opts.digits) { pool += GEN_CHARSETS.digits; required.push(GEN_CHARSETS.digits); } - if (opts.symbols) { pool += GEN_CHARSETS.symbols; required.push(GEN_CHARSETS.symbols); } + if (opts.upper) { + pool += GEN_CHARSETS.upper; + required.push(GEN_CHARSETS.upper); + } + if (opts.lower) { + pool += GEN_CHARSETS.lower; + required.push(GEN_CHARSETS.lower); + } + if (opts.digits) { + pool += GEN_CHARSETS.digits; + required.push(GEN_CHARSETS.digits); + } + if (opts.symbols) { + pool += GEN_CHARSETS.symbols; + required.push(GEN_CHARSETS.symbols); + } if (!pool) pool = GEN_CHARSETS.lower; const arr = new Uint32Array(length + required.length); @@ -904,11 +1284,11 @@ const Vault = (() => { const j = shuffle[i] % (i + 1); [chars[i], chars[j]] = [chars[j], chars[i]]; } - return chars.slice(0, length).join(''); + return chars.slice(0, length).join(""); } function scorePassword(pw) { - if (!pw) return { score: 0, label: '', cls: '' }; + if (!pw) return { score: 0, label: "", cls: "" }; let score = 0; if (pw.length >= 8) score++; if (pw.length >= 12) score++; @@ -918,60 +1298,69 @@ const Vault = (() => { if (/[0-9]/.test(pw)) score++; if (/[^A-Za-z0-9]/.test(pw)) score++; // clamp to 5 bands - const band = Math.min(5, Math.ceil(score / 7 * 5)); - const labels = ['', 'Very Weak', 'Weak', 'Fair', 'Strong', 'Very Strong']; - const clses = ['', 'strength-1', 'strength-2', 'strength-3', 'strength-4', 'strength-5']; + const band = Math.min(5, Math.ceil((score / 7) * 5)); + const labels = ["", "Very Weak", "Weak", "Fair", "Strong", "Very Strong"]; + const clses = [ + "", + "strength-1", + "strength-2", + "strength-3", + "strength-4", + "strength-5", + ]; return { score: band, label: labels[band], cls: clses[band] }; } // Attach strength meter + generate button to the password field in the item modal function initPasswordFieldEnhancements() { - const pwInput = document.getElementById('field-password'); + const pwInput = document.getElementById("field-password"); if (!pwInput) return; // Strength bar below the password field - if (!document.getElementById('field-password-strength')) { - const bar = document.createElement('div'); - bar.id = 'field-password-strength'; - bar.className = 'password-strength'; - pwInput.closest('.input-with-toggle').insertAdjacentElement('afterend', bar); + if (!document.getElementById("field-password-strength")) { + const bar = document.createElement("div"); + bar.id = "field-password-strength"; + bar.className = "password-strength"; + pwInput + .closest(".input-with-toggle") + .insertAdjacentElement("afterend", bar); } - pwInput.addEventListener('input', () => { + pwInput.addEventListener("input", () => { const { label, cls } = scorePassword(pwInput.value); - const bar = document.getElementById('field-password-strength'); - bar.textContent = pwInput.value ? label : ''; - bar.className = 'password-strength ' + (pwInput.value ? cls : ''); + const bar = document.getElementById("field-password-strength"); + bar.textContent = pwInput.value ? label : ""; + bar.className = "password-strength " + (pwInput.value ? cls : ""); }); // Generate button — add only once - if (!document.getElementById('btn-generate-pass')) { - const btn = document.createElement('button'); - btn.type = 'button'; - btn.id = 'btn-generate-pass'; - btn.className = 'btn-secondary btn-sm'; - btn.textContent = '⚡ Generate'; - btn.style.marginTop = '6px'; - pwInput.closest('.form-group').appendChild(btn); + if (!document.getElementById("btn-generate-pass")) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.id = "btn-generate-pass"; + btn.className = "btn-secondary btn-sm"; + btn.textContent = "⚡ Generate"; + btn.style.marginTop = "6px"; + pwInput.closest(".form-group").appendChild(btn); } - document.getElementById('btn-generate-pass').onclick = () => { + document.getElementById("btn-generate-pass").onclick = () => { const pw = generatePassword(20); pwInput.value = pw; - pwInput.type = 'text'; // reveal so user can see what was generated - pwInput.dispatchEvent(new Event('input')); // trigger strength bar + pwInput.type = "text"; // reveal so user can see what was generated + pwInput.dispatchEvent(new Event("input")); // trigger strength bar }; } function openPasswordGeneratorModal() { // Build modal content dynamically — keeps the HTML lean - let modal = document.getElementById('gen-modal'); + let modal = document.getElementById("gen-modal"); if (!modal) { - modal = document.createElement('div'); - modal.id = 'gen-modal'; - modal.className = 'modal-overlay'; - modal.setAttribute('role', 'dialog'); - modal.setAttribute('aria-modal', 'true'); + modal = document.createElement("div"); + modal.id = "gen-modal"; + modal.className = "modal-overlay"; + modal.setAttribute("role", "dialog"); + modal.setAttribute("aria-modal", "true"); modal.innerHTML = ` @@ -24,109 +20,56 @@