From 4aca3b2fb92ec4c03d49847fa4f724bf2319f97e Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Sat, 18 Apr 2026 14:46:49 -0400 Subject: [PATCH] 04/18 Enhance app, make sidebar collapsible --- app/static/css/app.css | 1339 ++++++++++++++---- app/static/js/vault.js | 2374 +++++++++++++++++++++----------- app/templates/vault/index.html | 690 ++++++++-- 3 files changed, 3228 insertions(+), 1175 deletions(-) diff --git a/app/static/css/app.css b/app/static/css/app.css index e3f90ab..d0300bb 100644 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -1,5 +1,11 @@ /* ── Reset & base ──────────────────────────────────────────────────── */ -*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} :root { --color-bg: #f4f5f7; @@ -14,24 +20,59 @@ --color-sidebar-hover: #2d3250; --color-sidebar-active: #d0021b; --radius: 8px; - --shadow: 0 1px 4px rgba(0,0,0,.08), 0 2px 12px rgba(0,0,0,.05); + --shadow: 0 1px 4px rgba(0, 0, 0, 0.08), 0 2px 12px rgba(0, 0, 0, 0.05); --transition: 150ms ease; } -html, body { height: 100%; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; color: var(--color-text); background: var(--color-bg); } -a { color: var(--color-primary); text-decoration: none; } -a:hover { text-decoration: underline; } -button { cursor: pointer; font: inherit; border: none; background: none; } -input, select, textarea { font: inherit; } -ul { list-style: none; } +html, +body { + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: 14px; + color: var(--color-text); + background: var(--color-bg); +} +a { + color: var(--color-primary); + text-decoration: none; +} +a:hover { + text-decoration: underline; +} +button { + cursor: pointer; + font: inherit; + border: none; + background: none; +} +input, +select, +textarea { + font: inherit; +} +ul { + list-style: none; +} /* ── Utilities ───────────────────────────────────────────────────── */ -.hidden { display: none !important; } +.hidden { + display: none !important; +} /* ── Auth pages ──────────────────────────────────────────────────── */ -.auth-page { display: flex; align-items: center; justify-content: center; min-height: 100vh; background: var(--color-bg); } +.auth-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: var(--color-bg); +} -.auth-container { width: 100%; max-width: 420px; padding: 16px; } +.auth-container { + width: 100%; + max-width: 420px; + padding: 16px; +} .auth-card { background: var(--color-surface); @@ -40,18 +81,48 @@ ul { list-style: none; } padding: 40px 36px; } -.auth-logo { display: flex; align-items: center; gap: 8px; margin-bottom: 28px; } -.auth-logo .logo-icon { font-size: 28px; } -.auth-logo .logo-text { font-size: 20px; font-weight: 700; color: var(--color-text); } +.auth-logo { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 28px; +} +.auth-logo .logo-icon { + font-size: 28px; +} +.auth-logo .logo-text { + font-size: 20px; + font-weight: 700; + color: var(--color-text); +} -.auth-title { font-size: 22px; font-weight: 600; margin-bottom: 24px; } +.auth-title { + font-size: 22px; + font-weight: 600; + margin-bottom: 24px; +} -.auth-footer { margin-top: 20px; text-align: center; color: var(--color-text-muted); } +.auth-footer { + margin-top: 20px; + text-align: center; + color: var(--color-text-muted); +} /* ── Form elements ──────────────────────────────────────────────── */ -.form-group { margin-bottom: 16px; } -.form-group label { display: block; margin-bottom: 6px; font-weight: 500; color: var(--color-text); } -.form-group label .hint { font-weight: 400; color: var(--color-text-muted); font-size: 12px; } +.form-group { + margin-bottom: 16px; +} +.form-group label { + display: block; + margin-bottom: 6px; + font-weight: 500; + color: var(--color-text); +} +.form-group label .hint { + font-weight: 400; + color: var(--color-text-muted); + font-size: 12px; +} .form-group input, .form-group select, @@ -69,66 +140,166 @@ ul { list-style: none; } .form-group textarea:focus { outline: none; border-color: var(--color-primary); - box-shadow: 0 0 0 3px rgba(208,2,27,.12); + box-shadow: 0 0 0 3px rgba(208, 2, 27, 0.12); } -.input-with-toggle { position: relative; } -.input-with-toggle input { padding-right: 40px; } +.input-with-toggle { + position: relative; +} +.input-with-toggle input { + padding-right: 40px; +} .btn-show-pass { - position: absolute; right: 10px; top: 50%; transform: translateY(-50%); - background: none; border: none; cursor: pointer; font-size: 16px; opacity: .6; + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + cursor: pointer; + font-size: 16px; + opacity: 0.6; transition: opacity var(--transition); } -.btn-show-pass:hover { opacity: 1; } +.btn-show-pass:hover { + opacity: 1; +} -.form-error { color: #c00; background: #fff0f0; border: 1px solid #fcc; border-radius: var(--radius); padding: 8px 12px; margin-bottom: 12px; font-size: 13px; } +.form-error { + color: #c00; + background: #fff0f0; + border: 1px solid #fcc; + border-radius: var(--radius); + padding: 8px 12px; + margin-bottom: 12px; + font-size: 13px; +} /* Buttons */ .btn-primary { - display: inline-flex; align-items: center; justify-content: center; - background: var(--color-primary); color: #fff; - padding: 10px 20px; border-radius: var(--radius); font-weight: 600; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--color-primary); + color: #fff; + padding: 10px 20px; + border-radius: var(--radius); + font-weight: 600; transition: background var(--transition); } -.btn-primary:hover { background: var(--color-primary-dark); } -.btn-primary:disabled { opacity: .6; cursor: not-allowed; } -.btn-full { width: 100%; } +.btn-primary:hover { + background: var(--color-primary-dark); +} +.btn-primary:disabled { + opacity: 0.6; + cursor: not-allowed; +} +.btn-full { + width: 100%; +} .btn-secondary { - display: inline-flex; align-items: center; justify-content: center; - background: var(--color-border); color: var(--color-text); - padding: 10px 20px; border-radius: var(--radius); font-weight: 500; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--color-border); + color: var(--color-text); + padding: 10px 20px; + border-radius: var(--radius); + font-weight: 500; transition: background var(--transition); } -.btn-secondary:hover { background: #d1d5dc; } +.btn-secondary:hover { + background: #d1d5dc; +} -.btn-text { background: none; border: none; color: inherit; padding: 0; } +.btn-text { + background: none; + border: none; + color: inherit; + padding: 0; +} /* Notices */ -.notice-success { background: #e8f5e9; border: 1px solid #a5d6a7; color: #2e7d32; border-radius: var(--radius); padding: 10px 14px; margin-bottom: 16px; font-size: 13px; } -.notice-info { background: #e3f2fd; border: 1px solid #90caf9; color: #1565c0; border-radius: var(--radius); padding: 10px 14px; margin-bottom: 16px; font-size: 13px; } +.notice-success { + background: #e8f5e9; + border: 1px solid #a5d6a7; + color: #2e7d32; + border-radius: var(--radius); + padding: 10px 14px; + margin-bottom: 16px; + font-size: 13px; +} +.notice-info { + background: #e3f2fd; + border: 1px solid #90caf9; + color: #1565c0; + border-radius: var(--radius); + padding: 10px 14px; + margin-bottom: 16px; + font-size: 13px; +} /* ── Password Generator modal ────────────────────────────────────────── */ -.gen-opt { display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 13px; } -.gen-opt input[type="checkbox"] { width: 15px; height: 15px; accent-color: var(--color-primary); cursor: pointer; } -#gen-output { letter-spacing: .5px; font-family: 'Courier New', monospace; } +.gen-opt { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + font-size: 13px; +} +.gen-opt input[type="checkbox"] { + width: 15px; + height: 15px; + accent-color: var(--color-primary); + cursor: pointer; +} +#gen-output { + letter-spacing: 0.5px; + font-family: "Courier New", monospace; +} /* Launch (open URL) button — distinct colour so it reads as navigation */ -.btn-icon[data-action="launch"] { color: #1a73e8; } -.btn-icon[data-action="launch"]:hover { background: #e8f0fe; color: #1558b0; } +.btn-icon[data-action="launch"] { + color: #1a73e8; +} +.btn-icon[data-action="launch"]:hover { + background: #e8f0fe; + color: #1558b0; +} /* Password strength */ -.password-strength { margin-top: 6px; font-size: 12px; font-weight: 500; } -.strength-1 { color: #c62828; } -.strength-2 { color: #e65100; } -.strength-3 { color: #f57f17; } -.strength-4 { color: #2e7d32; } -.strength-5 { color: #1b5e20; } +.password-strength { + margin-top: 6px; + font-size: 12px; + font-weight: 500; +} +.strength-1 { + color: #c62828; +} +.strength-2 { + color: #e65100; +} +.strength-3 { + color: #f57f17; +} +.strength-4 { + color: #2e7d32; +} +.strength-5 { + color: #1b5e20; +} /* ── App layout (vault) ──────────────────────────────────────────── */ -.vault-page { height: 100vh; overflow: hidden; } +.vault-page { + height: 100vh; + overflow: hidden; +} -.app-layout { display: flex; height: 100vh; } +.app-layout { + display: flex; + height: 100vh; +} /* ── Sidebar ─────────────────────────────────────────────────────── */ .sidebar { @@ -138,7 +309,14 @@ ul { list-style: none; } color: var(--color-sidebar-text); display: flex; flex-direction: column; + overflow: hidden; overflow-y: auto; + transition: width 0.22s ease; +} + +.sidebar.collapsed { + width: 56px; + overflow-y: visible; } .sidebar-header { @@ -146,19 +324,71 @@ ul { list-style: none; } align-items: center; gap: 8px; padding: 18px 16px; - border-bottom: 1px solid rgba(255,255,255,.08); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + flex-shrink: 0; + min-height: 60px; +} +.sidebar-header .logo-icon { + font-size: 22px; + flex-shrink: 0; +} +.sidebar-header .logo-text { + font-size: 16px; + font-weight: 700; + color: #fff; + flex: 1; + white-space: nowrap; + overflow: hidden; + transition: + opacity 0.15s ease, + width 0.15s ease; +} +.sidebar.collapsed .sidebar-header .logo-text { + opacity: 0; + width: 0; } -.sidebar-header .logo-icon { font-size: 22px; } -.sidebar-header .logo-text { font-size: 16px; font-weight: 700; color: #fff; } -.sidebar-nav { flex: 1; padding: 8px 0; } +/* Toggle button */ +.btn-sidebar-toggle { + background: none; + border: none; + color: rgba(255, 255, 255, 0.5); + font-size: 13px; + cursor: pointer; + padding: 4px 6px; + border-radius: 4px; + line-height: 1; + flex-shrink: 0; + transition: + color var(--transition), + transform 0.22s ease; +} +.btn-sidebar-toggle:hover { + color: #fff; + background: rgba(255, 255, 255, 0.1); +} +.sidebar.collapsed .btn-sidebar-toggle { + transform: rotate(180deg); +} + +.sidebar-nav { + flex: 1; + padding: 8px 0; +} .sidebar-section-header { padding: 12px 16px 4px; font-size: 11px; text-transform: uppercase; - letter-spacing: .08em; - color: rgba(255,255,255,.35); + letter-spacing: 0.08em; + 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; } .sidebar-item { @@ -173,42 +403,144 @@ ul { list-style: none; } font-size: 14px; width: 100%; text-align: left; + position: relative; + white-space: nowrap; +} +.sidebar-item:hover { + background: var(--color-sidebar-hover); +} +.sidebar-item.active { + background: var(--color-sidebar-active); + color: #fff; +} +.sidebar-icon { + font-size: 16px; + flex-shrink: 0; } -.sidebar-item:hover { background: var(--color-sidebar-hover); } -.sidebar-item.active { background: var(--color-sidebar-active); color: #fff; } -.sidebar-icon { font-size: 16px; flex-shrink: 0; } -.sidebar-footer { padding: 12px 0; border-top: 1px solid rgba(255,255,255,.08); } +/* Hide labels and show tooltip when collapsed */ +.sidebar-label { + overflow: hidden; + transition: + opacity 0.15s ease, + width 0.15s ease; +} +.sidebar.collapsed .sidebar-label { + opacity: 0; + width: 0; +} +.sidebar.collapsed .sidebar-item { + justify-content: center; + padding: 9px 0; +} -.folder-item { justify-content: space-between; } -.folder-label { display: flex; align-items: center; gap: 10px; flex: 1; cursor: pointer; } +/* Tooltip — shown on hover in collapsed state */ +.sidebar.collapsed .sidebar-item::after { + content: attr(data-tooltip); + position: absolute; + left: calc(100% + 8px); + top: 50%; + transform: translateY(-50%); + background: #111827; + color: #fff; + font-size: 12px; + padding: 4px 10px; + border-radius: 4px; + white-space: nowrap; + pointer-events: none; + opacity: 0; + transition: opacity 0.12s ease; + z-index: 200; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35); +} +.sidebar.collapsed .sidebar-item:hover::after { + opacity: 1; +} + +.sidebar-footer { + padding: 12px 0; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +.folder-item { + justify-content: space-between; +} +.folder-label { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + cursor: pointer; +} .btn-delete-folder { - background: none; border: none; cursor: pointer; - font-size: 13px; opacity: 0; color: rgba(255,255,255,.6); - transition: opacity var(--transition), color var(--transition); - padding: 2px 4px; border-radius: 4px; flex-shrink: 0; + background: none; + border: none; + cursor: pointer; + font-size: 13px; + opacity: 0; + color: rgba(255, 255, 255, 0.6); + transition: + opacity var(--transition), + color var(--transition); + padding: 2px 4px; + border-radius: 4px; + flex-shrink: 0; +} +.folder-item:hover .btn-delete-folder { + opacity: 1; +} +.btn-delete-folder:hover { + color: #ff6b6b; } -.folder-item:hover .btn-delete-folder { opacity: 1; } -.btn-delete-folder:hover { color: #ff6b6b; } .btn-new-folder { - float: right; margin-right: 4px; - background: none; border: none; color: rgba(255,255,255,.5); - font-size: 16px; line-height: 1; cursor: pointer; padding: 0 4px; + float: right; + margin-right: 4px; + background: none; + border: none; + color: rgba(255, 255, 255, 0.5); + font-size: 16px; + line-height: 1; + cursor: pointer; + padding: 0 4px; transition: color var(--transition); } -.btn-new-folder:hover { color: #fff; } - -.new-folder-row { padding: 4px 10px 4px 16px; } -.new-folder-row form { display: flex; align-items: center; gap: 4px; } -.new-folder-row input { - flex: 1; padding: 5px 8px; border-radius: 4px; border: 1px solid rgba(255,255,255,.2); - background: rgba(255,255,255,.1); color: #fff; font-size: 13px; +.btn-new-folder:hover { + color: #fff; +} + +.new-folder-row { + padding: 4px 10px 4px 16px; +} +.new-folder-row form { + display: flex; + align-items: center; + gap: 4px; +} +.new-folder-row input { + flex: 1; + padding: 5px 8px; + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.2); + background: rgba(255, 255, 255, 0.1); + color: #fff; + font-size: 13px; +} +.new-folder-row input::placeholder { + color: rgba(255, 255, 255, 0.4); +} +.new-folder-row input:focus { + outline: none; + border-color: rgba(255, 255, 255, 0.5); +} +.new-folder-row .btn-icon { + color: rgba(255, 255, 255, 0.7); + font-size: 14px; +} +.new-folder-row .btn-icon:hover { + color: #fff; + background: rgba(255, 255, 255, 0.1); } -.new-folder-row input::placeholder { color: rgba(255,255,255,.4); } -.new-folder-row input:focus { outline: none; border-color: rgba(255,255,255,.5); } -.new-folder-row .btn-icon { color: rgba(255,255,255,.7); font-size: 14px; } -.new-folder-row .btn-icon:hover { color: #fff; background: rgba(255,255,255,.1); } /* ── Vault main ──────────────────────────────────────────────────── */ .vault-main { @@ -219,7 +551,8 @@ ul { list-style: none; } background: var(--color-bg); } -#view-vault, #view-security { +#view-vault, +#view-security { display: flex; flex-direction: column; flex: 1; @@ -227,7 +560,10 @@ ul { list-style: none; } min-height: 0; position: relative; } -#view-vault.hidden, #view-security.hidden { display: none; } +#view-vault.hidden, +#view-security.hidden { + display: none; +} .vault-header { display: flex; @@ -239,187 +575,441 @@ ul { list-style: none; } gap: 16px; } -.vault-title { font-size: 18px; font-weight: 600; white-space: nowrap; } +.vault-title { + font-size: 18px; + font-weight: 600; + white-space: nowrap; +} -.vault-toolbar { display: flex; align-items: center; gap: 12px; flex: 1; justify-content: flex-end; } +.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; + 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; } -.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,.35); - transition: background var(--transition), box-shadow var(--transition); + 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,.45); } +.btn-fab:hover { + background: var(--color-primary-dark); + box-shadow: 0 4px 14px rgba(208, 2, 27, 0.45); +} /* ── Vault list ──────────────────────────────────────────────────── */ -.vault-list { flex: 1; overflow-y: auto; padding: 12px 24px 24px; } +.vault-list { + flex: 1; + overflow-y: auto; + padding: 12px 24px 24px; +} .vault-group-header { - font-size: 12px; font-weight: 600; text-transform: uppercase; - letter-spacing: .06em; color: var(--color-text-muted); - padding: 16px 0 6px; border-bottom: 1px solid var(--color-border); + 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; + text-align: center; + color: var(--color-text-muted); + padding: 60px 0; + font-size: 15px; } .vault-item { - display: flex; align-items: center; gap: 12px; + 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); + transition: + box-shadow var(--transition), + border-color var(--transition); +} +.vault-item:hover { + box-shadow: var(--shadow); + border-color: #c9ccd4; } -.vault-item:hover { box-shadow: var(--shadow); border-color: #c9ccd4; } -.item-icon { font-size: 22px; flex-shrink: 0; } +.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; } +.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; +} -.item-actions { display: flex; gap: 4px; opacity: 0; transition: opacity var(--transition); } -.vault-item:hover .item-actions { opacity: 1; } +.item-actions { + display: flex; + gap: 4px; + opacity: 0; + transition: opacity var(--transition); +} +.vault-item:hover .item-actions { + opacity: 1; +} .btn-icon { - width: 30px; height: 30px; 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); + width: 30px; + height: 30px; + 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); +} +.btn-icon:hover { + background: var(--color-bg); + color: var(--color-text); } -.btn-icon:hover { background: var(--color-bg); color: var(--color-text); } /* ── Spinner ─────────────────────────────────────────────────────── */ .spinner-overlay { - position: absolute; inset: 0; - display: flex; align-items: center; justify-content: center; - background: rgba(255,255,255,.7); + 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-overlay.hidden { + display: none; +} .spinner { - width: 36px; height: 36px; + width: 36px; + height: 36px; border: 3px solid var(--color-border); border-top-color: var(--color-primary); border-radius: 50%; - animation: spin .7s linear infinite; + animation: spin 0.7s linear infinite; +} +@keyframes spin { + to { + transform: rotate(360deg); + } } -@keyframes spin { to { transform: rotate(360deg); } } /* ── Modal ───────────────────────────────────────────────────────── */ .modal-overlay { - position: fixed; inset: 0; - background: rgba(0,0,0,.45); - display: none; align-items: center; justify-content: center; + 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-overlay.open { + display: flex; +} .modal { background: var(--color-surface); border-radius: var(--radius); - box-shadow: 0 8px 40px rgba(0,0,0,.2); - width: 100%; max-width: 480px; + box-shadow: 0 8px 40px rgba(0, 0, 0, 0.2); + width: 100%; + max-width: 480px; max-height: 90vh; overflow-y: auto; padding: 28px; } .modal-header { - display: flex; align-items: center; justify-content: space-between; + display: flex; + align-items: center; + justify-content: space-between; margin-bottom: 20px; } -.modal-header h3 { font-size: 18px; font-weight: 600; } +.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); } +.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; } +.modal-actions { + display: flex; + gap: 10px; + justify-content: flex-end; + margin-top: 20px; +} -.required { color: var(--color-primary); } +.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 .25s, transform .25s; - pointer-events: none; z-index: 200; white-space: nowrap; + 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; } -.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; } +.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: .2em; text-align: center; } +.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; } +.panel-body { + flex: 1; + overflow-y: auto; + padding: 20px 24px; +} /* ── 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); +.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); } -.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-list { + display: flex; + flex-direction: column; + gap: 8px; +} .share-item { - display: flex; align-items: center; gap: 12px; + display: flex; + align-items: center; + gap: 12px; background: var(--color-surface); border: 1px solid var(--color-border); - border-radius: var(--radius); padding: 12px 14px; + 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; } -.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; } +.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; } +.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 { @@ -432,21 +1022,35 @@ ul { list-style: none; } cursor: pointer; outline: none; } -.sort-wrapper select:focus { border-color: var(--color-primary); } +.sort-wrapper select:focus { + border-color: var(--color-primary); +} /* ── Modal item (wider for address/card forms) ───────────────────── */ -.modal-item { max-width: 540px; } +.modal-item { + max-width: 540px; +} /* ── Multi-column form rows ──────────────────────────────────────── */ -.form-row { display: flex; gap: 12px; } -.form-row .form-group { flex: 1; } +.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; } +.btn-sm { + padding: 5px 12px; + font-size: 12px; + border-radius: 6px; +} /* ── Security dashboard ──────────────────────────────────────────── */ .security-dashboard { - flex: 1; overflow-y: auto; + flex: 1; + overflow-y: auto; padding: 24px; display: flex; flex-direction: column; @@ -454,8 +1058,10 @@ ul { list-style: none; } } .sec-notice { - text-align: center; color: var(--color-text-muted); - padding: 40px 0; font-size: 15px; + text-align: center; + color: var(--color-text-muted); + padding: 40px 0; + font-size: 15px; } .security-summary { @@ -469,17 +1075,40 @@ ul { list-style: none; } 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-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-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-stats { + display: flex; + gap: 16px; + flex-wrap: wrap; +} .sec-stat { text-align: center; background: var(--color-bg); @@ -488,11 +1117,29 @@ ul { list-style: none; } 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-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); @@ -501,74 +1148,260 @@ ul { list-style: none; } overflow: hidden; } .sec-section-header { - display: flex; align-items: flex-start; gap: 12px; + 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-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-list { + padding: 0; +} .sec-item { - display: flex; align-items: center; gap: 12px; + 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; } +.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); } +.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; } +.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 ──────────────────────────────────────────────────── */ @media (max-width: 640px) { - .sidebar { width: 60px; } + .sidebar { + width: 56px; + overflow-y: visible; + } .sidebar-header .logo-text, .sidebar-label, - .sidebar-section-header { display: none; } - .sidebar-item { justify-content: center; padding: 12px 0; } - .vault-header { padding: 12px 16px; } - .vault-list { padding: 8px 16px 16px; } - .sort-wrapper { display: none; } - .form-row { flex-direction: column; gap: 0; } + .sidebar-section-header { + opacity: 0; + width: 0; + } + .sidebar-item { + justify-content: center; + padding: 12px 0; + } + .vault-header { + padding: 12px 16px; + } + .vault-list { + padding: 8px 16px 16px; + } + .sort-wrapper { + display: none; + } + .form-row { + flex-direction: column; + gap: 0; + } } /* ── Settings — new sections (Phase 6) ──────────────────────────── */ -.settings-section-danger { border-top: 2px solid var(--color-danger, #e53e3e); margin-top: 8px; padding-top: 16px; } -.settings-section-danger .settings-section-title { color: var(--color-danger, #e53e3e); } -.btn-danger { background: var(--color-danger, #e53e3e); color: #fff; border: none; border-radius: var(--radius); padding: 8px 18px; font-size: 14px; font-weight: 500; cursor: pointer; transition: opacity .15s; } -.btn-danger:hover { opacity: .85; } -.btn-danger:disabled { opacity: .5; cursor: not-allowed; } -.recovery-code-box { display: flex; align-items: center; gap: 8px; background: var(--color-bg-secondary, #f7f7f7); border: 1px solid var(--color-border); border-radius: var(--radius); padding: 12px 14px; margin: 8px 0; } -.recovery-code-box code { font-family: monospace; font-size: 15px; letter-spacing: .08em; word-break: break-all; flex: 1; } -.warning-text { color: var(--color-danger, #e53e3e); font-size: 13px; font-weight: 500; } +.settings-section-danger { + border-top: 2px solid var(--color-danger, #e53e3e); + margin-top: 8px; + padding-top: 16px; +} +.settings-section-danger .settings-section-title { + color: var(--color-danger, #e53e3e); +} +.btn-danger { + background: var(--color-danger, #e53e3e); + color: #fff; + border: none; + border-radius: var(--radius); + padding: 8px 18px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: opacity 0.15s; +} +.btn-danger:hover { + opacity: 0.85; +} +.btn-danger:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.recovery-code-box { + display: flex; + align-items: center; + gap: 8px; + background: var(--color-bg-secondary, #f7f7f7); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 12px 14px; + margin: 8px 0; +} +.recovery-code-box code { + font-family: monospace; + font-size: 15px; + letter-spacing: 0.08em; + word-break: break-all; + flex: 1; +} +.warning-text { + color: var(--color-danger, #e53e3e); + font-size: 13px; + font-weight: 500; +} diff --git a/app/static/js/vault.js b/app/static/js/vault.js index 03d57d9..3fc5a5d 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,75 +249,124 @@ 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; li.innerHTML = `
    ${icon}
    @@ -264,394 +375,535 @@ const Vault = (() => { ${escHtml(subText)}
    - ${showLaunch ? `` : ''} - ${showCopyUser ? `` : ''} - ${showCopyPass ? `` : ''} + ${showLaunch ? `` : ""} + ${showCopyUser ? `` : ""} + ${showCopyPass ? `` : ""}
    `; 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)); + 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(''); + ${ + !s.accepted + ? `` + : `` + } + `, + ) + .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 +1108,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 +1249,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 +1263,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 = `