From 562cb85de9fb67a7a7d1857031c527f604e6423e Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Sun, 19 Apr 2026 09:40:48 -0400 Subject: [PATCH] 04/19 Continue fixing the UI issues with Chrome --- app/__init__.py | 6 +- app/config.py | 7 +- app/static/css/app.css | 1588 ++++++---------------- app/static/js/layout-init.js | 26 + app/static/js/vault.js | 2485 ++++++++++++---------------------- app/templates/base.html | 47 +- 6 files changed, 1351 insertions(+), 2808 deletions(-) create mode 100644 app/static/js/layout-init.js diff --git a/app/__init__.py b/app/__init__.py index d35d399..8b02e1f 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -31,6 +31,10 @@ def create_app(config_name: str = 'development') -> Flask: cors_origins = app.config.get('CORS_ORIGINS', '*') CORS(app, resources={r'/api/*': {'origins': cors_origins}}) + # Inject static asset version into every template for cache-busting. + # Usage in templates: {{ url_for('static', filename='css/app.css') }}?v={{ sv }} + app.jinja_env.globals['sv'] = app.config.get('STATIC_VERSION', '1') + # Attach security headers to every response @app.after_request def set_security_headers(response): @@ -111,4 +115,4 @@ def create_app(config_name: str = 'development') -> Flask: def recover_page(): return render_template('auth/recover.html') - return app + return app \ No newline at end of file diff --git a/app/config.py b/app/config.py index 4d08fc1..01369a2 100644 --- a/app/config.py +++ b/app/config.py @@ -41,6 +41,11 @@ class BaseConfig: # CORS — restrict to the production origin in production config. CORS_ORIGINS = os.environ.get('CORS_ORIGINS', '*') + # Cache-busting version string appended as ?v=... to all static assets. + # Bump this value on every deploy to force browsers to reload CSS/JS. + # Set via .env: STATIC_VERSION=20260418 + STATIC_VERSION = os.environ.get('STATIC_VERSION', '1') + class DevelopmentConfig(BaseConfig): DEBUG = True @@ -59,4 +64,4 @@ class ProductionConfig(BaseConfig): config = { 'development': DevelopmentConfig, 'production': ProductionConfig, -} +} \ No newline at end of file diff --git a/app/static/css/app.css b/app/static/css/app.css index 42563b0..b8b6046 100644 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -20,36 +20,41 @@ --color-sidebar-hover: #2d3250; --color-sidebar-active: #d0021b; --radius: 8px; - --shadow: 0 1px 4px rgba(0, 0, 0, 0.08), 0 2px 12px rgba(0, 0, 0, 0.05); + --shadow: 0 1px 4px rgba(0, 0, 0, .08), 0 2px 12px rgba(0, 0, 0, .05); --transition: 150ms ease; } html, body { height: 100%; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + 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; } @@ -80,14 +85,17 @@ ul { box-shadow: var(--shadow); padding: 40px 36px; } + @media (max-width: 480px) { .auth-container { padding: 12px; } + .auth-card { padding: 28px 20px; border-radius: 12px; } + .auth-title { font-size: 20px; } @@ -99,9 +107,11 @@ ul { gap: 8px; margin-bottom: 28px; } + .auth-logo .logo-icon { font-size: 28px; } + .auth-logo .logo-text { font-size: 20px; font-weight: 700; @@ -124,12 +134,14 @@ ul { .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); @@ -147,15 +159,18 @@ ul { color: var(--color-text); transition: border-color var(--transition); } + .form-group input:focus, .form-group select:focus, .form-group textarea:focus { outline: none; border-color: var(--color-primary); - box-shadow: 0 0 0 3px rgba(208, 2, 27, 0.12); + box-shadow: 0 0 0 3px rgba(208, 2, 27, .12); } + /* Prevent iOS viewport auto-zoom on input focus (requires font-size >= 16px) */ @media (max-width: 768px) { + .form-group input, .form-group select, .form-group textarea { @@ -166,9 +181,11 @@ ul { .input-with-toggle { position: relative; } + .input-with-toggle input { padding-right: 40px; } + .btn-show-pass { position: absolute; right: 10px; @@ -178,9 +195,10 @@ ul { border: none; cursor: pointer; font-size: 16px; - opacity: 0.6; + opacity: .6; transition: opacity var(--transition); } + .btn-show-pass:hover { opacity: 1; } @@ -207,13 +225,16 @@ ul { font-weight: 600; transition: background var(--transition); } + .btn-primary:hover { background: var(--color-primary-dark); } + .btn-primary:disabled { - opacity: 0.6; + opacity: .6; cursor: not-allowed; } + .btn-full { width: 100%; } @@ -229,6 +250,7 @@ ul { font-weight: 500; transition: background var(--transition); } + .btn-secondary:hover { background: #d1d5dc; } @@ -250,6 +272,7 @@ ul { margin-bottom: 16px; font-size: 13px; } + .notice-info { background: #e3f2fd; border: 1px solid #90caf9; @@ -268,21 +291,24 @@ ul { 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; + letter-spacing: .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; @@ -294,18 +320,23 @@ ul { 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; } @@ -324,25 +355,17 @@ ul { } /* ── Sidebar ─────────────────────────────────────────────────────── */ + +/* ── Base sidebar styles (shared by mobile and desktop) ─────────── */ .sidebar { - width: 240px; - flex-shrink: 0; background: var(--color-sidebar-bg); color: var(--color-sidebar-text); display: flex; flex-direction: column; + /* Default = desktop. html.is-mobile overrides display:none below. */ + width: 240px; + flex-shrink: 0; overflow: hidden; - transition: width 0.22s ease; -} - -.sidebar.collapsed { - width: 72px; -} - -/* Sidebar must not clip its children when collapsed — tooltips need to escape */ -.sidebar.collapsed .sidebar-nav, -.sidebar.collapsed .sidebar-footer { - overflow: visible; } .sidebar-header { @@ -350,22 +373,16 @@ ul { align-items: center; gap: 8px; padding: 14px 12px; - border-bottom: 1px solid rgba(255, 255, 255, 0.08); + border-bottom: 1px solid rgba(255, 255, 255, .08); flex-shrink: 0; min-height: 56px; - overflow: hidden; -} -/* When collapsed: centre the toggle button, hide everything else */ -.sidebar.collapsed .sidebar-header { - justify-content: center; - padding: 14px 0; - overflow: visible; } + .sidebar-header .logo-icon { font-size: 22px; flex-shrink: 0; - transition: opacity 0.15s ease; } + .sidebar-header .logo-text { font-size: 16px; font-weight: 700; @@ -373,39 +390,6 @@ ul { flex: 1; white-space: nowrap; overflow: hidden; - transition: opacity 0.15s ease; -} -.sidebar.collapsed .sidebar-header .logo-icon, -.sidebar.collapsed .sidebar-header .logo-text { - opacity: 0; - width: 0; - pointer-events: none; -} - -/* Toggle button — always visible, always clickable */ -.btn-sidebar-toggle { - background: none; - border: none; - color: rgba(255, 255, 255, 0.5); - font-size: 14px; - cursor: pointer; - padding: 6px 8px; - border-radius: 4px; - line-height: 1; - flex-shrink: 0; - transition: - color var(--transition), - transform 0.22s ease; - margin-left: auto; - z-index: 10; -} -.btn-sidebar-toggle:hover { - color: #fff; - background: rgba(255, 255, 255, 0.12); -} -.sidebar.collapsed .btn-sidebar-toggle { - transform: rotate(180deg); - margin-left: 0; } .sidebar-nav { @@ -419,17 +403,10 @@ ul { padding: 12px 16px 4px; font-size: 11px; text-transform: uppercase; - letter-spacing: 0.08em; - color: rgba(255, 255, 255, 0.35); + letter-spacing: .08em; + color: rgba(255, 255, 255, .35); white-space: nowrap; overflow: hidden; - transition: opacity 0.15s ease; -} -.sidebar.collapsed .sidebar-section-header { - opacity: 0; - pointer-events: none; - height: 0; - padding: 0; } .sidebar-item { @@ -437,7 +414,6 @@ ul { align-items: center; gap: 10px; padding: 9px 16px; - border-radius: 0; color: var(--color-sidebar-text); cursor: pointer; transition: background var(--transition); @@ -448,13 +424,16 @@ ul { white-space: nowrap; overflow: hidden; } + .sidebar-item:hover { background: var(--color-sidebar-hover); } + .sidebar-item.active { background: var(--color-sidebar-active); color: #fff; } + .sidebar-icon { font-size: 20px; flex-shrink: 0; @@ -462,26 +441,110 @@ ul { text-align: center; } -/* Labels — fade out when collapsed; use max-width so icon stays visible */ .sidebar-label { - transition: - opacity 0.15s ease, - max-width 0.22s ease; - max-width: 200px; + white-space: nowrap; overflow: hidden; } -.sidebar.collapsed .sidebar-label { + +.sidebar-footer { + padding: 12px 0; + border-top: 1px solid rgba(255, 255, 255, .08); +} + +/* ── DESKTOP sidebar (html.is-desktop) ───────────────────────────── */ +html.is-desktop .sidebar { + width: 240px; + flex-shrink: 0; + overflow: hidden; + /* Transition only on desktop so display:none on mobile is instant */ + transition: width .22s ease; +} + +/* Set by layout-init.js before paint so collapsed sidebar renders at right width immediately */ +html.is-desktop.sidebar-will-collapse .sidebar, +html.is-desktop .sidebar.collapsed { + width: 72px; +} + +html.is-desktop .sidebar.collapsed .sidebar-nav, +html.is-desktop .sidebar.collapsed .sidebar-footer { + overflow: visible; +} + +html.is-desktop .sidebar.collapsed .sidebar-header { + justify-content: center; + padding: 14px 0; + overflow: visible; +} + +html.is-desktop .sidebar-header .logo-icon { + transition: opacity .15s ease; +} + +html.is-desktop .sidebar-header .logo-text { + flex: 1; + transition: opacity .15s ease; +} + +html.is-desktop .sidebar.collapsed .sidebar-header .logo-icon, +html.is-desktop .sidebar.collapsed .sidebar-header .logo-text { + opacity: 0; + width: 0; + pointer-events: none; +} + +html.is-desktop .btn-sidebar-toggle { + background: none; + border: none; + color: rgba(255, 255, 255, .5); + font-size: 14px; + cursor: pointer; + padding: 6px 8px; + border-radius: 4px; + line-height: 1; + flex-shrink: 0; + transition: color var(--transition), transform .22s ease; + margin-left: auto; +} + +html.is-desktop .btn-sidebar-toggle:hover { + color: #fff; + background: rgba(255, 255, 255, .12); +} + +html.is-desktop .sidebar.collapsed .btn-sidebar-toggle { + transform: rotate(180deg); + margin-left: 0; +} + +html.is-desktop .sidebar-section-header { + transition: opacity .15s ease; +} + +html.is-desktop .sidebar.collapsed .sidebar-section-header { + opacity: 0; + pointer-events: none; + height: 0; + padding: 0; +} + +html.is-desktop .sidebar-label { + transition: opacity .15s ease, max-width .22s ease; + max-width: 200px; +} + +html.is-desktop .sidebar.collapsed .sidebar-label { opacity: 0; max-width: 0; } -.sidebar.collapsed .sidebar-item { + +html.is-desktop .sidebar.collapsed .sidebar-item { justify-content: center; padding: 10px 0; overflow: visible; } -/* Tooltip — appears to the right of the 72px rail */ -.sidebar.collapsed .sidebar-item::after { +html.is-desktop .sidebar.collapsed .sidebar-item::after { content: attr(data-tooltip); position: fixed; left: 80px; @@ -493,917 +556,74 @@ ul { white-space: nowrap; pointer-events: none; opacity: 0; - transition: opacity 0.12s ease; + transition: opacity .12s ease; z-index: 1000; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); + box-shadow: 0 2px 8px rgba(0, 0, 0, .4); } -.sidebar.collapsed .sidebar-item:hover::after { + +html.is-desktop .sidebar.collapsed .sidebar-item:hover::after { opacity: 1; } -.sidebar-footer { - padding: 12px 0; - border-top: 1px solid rgba(255, 255, 255, 0.08); -} - -.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, 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; -} - -.btn-new-folder { - 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, 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); -} - -/* ── Vault main ──────────────────────────────────────────────────── */ -.vault-main { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; - background: var(--color-bg); -} - -#view-vault, -#view-security { - display: flex; - flex-direction: column; - flex: 1; - overflow: hidden; - min-height: 0; - position: relative; -} -#view-vault.hidden, -#view-security.hidden { +/* ── MOBILE sidebar (html.is-mobile) ─────────────────────────────── */ +html.is-mobile .sidebar { + /* Hidden by default — no layout space, no transitions to fight */ display: none; -} - -.vault-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 24px; - background: var(--color-surface); - border-bottom: 1px solid var(--color-border); - gap: 16px; -} - -/* Inner wrapper constrains content width on very large screens */ -.vault-header-inner { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - max-width: 900px; - margin: 0 auto; - gap: 16px; -} - -.vault-title { - font-size: 18px; - font-weight: 600; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.vault-toolbar { - display: flex; - align-items: center; - gap: 12px; - flex: 1; - justify-content: flex-end; -} - -.search-wrapper { - display: flex; - align-items: center; - background: var(--color-bg); - border: 1px solid var(--color-border); - border-radius: 20px; - padding: 6px 14px; - gap: 8px; - flex: 1; - max-width: 360px; -} -.search-icon { - color: var(--color-text-muted); -} -#search-input { - border: none; - background: none; - outline: none; - flex: 1; - font-size: 14px; -} - -.btn-fab { - width: 40px; - height: 40px; - border-radius: 50%; - background: var(--color-primary); - color: #fff; - font-size: 24px; - font-weight: 300; - display: flex; - align-items: center; - justify-content: center; - box-shadow: 0 2px 8px rgba(208, 2, 27, 0.35); - transition: - background var(--transition), - box-shadow var(--transition); - flex-shrink: 0; -} -.btn-fab:hover { - background: var(--color-primary-dark); - box-shadow: 0 4px 14px rgba(208, 2, 27, 0.45); -} - -/* ── Vault list ──────────────────────────────────────────────────── */ -/* Outer scroll container */ -.vault-list { - flex: 1; - overflow-y: auto; - padding: 12px 24px 24px; - -webkit-overflow-scrolling: touch; -} - -/* Content max-width — keeps items readable on very wide screens */ -.vault-list-inner { - max-width: 900px; - margin: 0 auto; -} - -.vault-group-header { - font-size: 12px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--color-text-muted); - padding: 16px 0 6px; - border-bottom: 1px solid var(--color-border); - margin-bottom: 4px; -} - -.vault-empty { - text-align: center; - color: var(--color-text-muted); - padding: 60px 0; - font-size: 15px; -} - -.vault-item { - display: flex; - align-items: center; - gap: 12px; - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius); - padding: 12px 14px; - margin-bottom: 6px; - cursor: pointer; - transition: - box-shadow var(--transition), - border-color var(--transition); -} -.vault-item:hover { - box-shadow: var(--shadow); - border-color: #c9ccd4; -} - -.item-icon { - font-size: 22px; - flex-shrink: 0; -} - -.item-info { - flex: 1; - min-width: 0; -} -.item-name { - display: block; - font-weight: 500; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.item-sub { - display: block; - font-size: 12px; - color: var(--color-text-muted); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -/* Actions always visible — no hover-only opacity trick on desktop */ -.item-actions { - display: flex; - gap: 4px; -} -.vault-item:hover .item-actions { - opacity: 1; -} - -.btn-icon { - width: 36px; - height: 36px; - border-radius: 6px; - display: flex; - align-items: center; - justify-content: center; - font-size: 15px; - color: var(--color-text-muted); - transition: - background var(--transition), - color var(--transition); - /* Expand touch target without changing visual size */ - touch-action: manipulation; -} -.btn-icon:hover { - background: var(--color-bg); - color: var(--color-text); -} -@media (max-width: 768px) { - .btn-icon { - min-width: 44px; - min-height: 44px; - } -} - -/* ── Spinner ─────────────────────────────────────────────────────── */ -.spinner-overlay { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - background: rgba(255, 255, 255, 0.7); - z-index: 10; -} -.spinner-overlay.hidden { - display: none; -} -.spinner { - width: 36px; - height: 36px; - border: 3px solid var(--color-border); - border-top-color: var(--color-primary); - border-radius: 50%; - animation: spin 0.7s linear infinite; -} -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -/* ── Modal ───────────────────────────────────────────────────────── */ -.modal-overlay { position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.45); - display: none; - align-items: center; - justify-content: center; - z-index: 100; - padding: 16px; -} -.modal-overlay.open { - display: flex; -} - -.modal { - background: var(--color-surface); - border-radius: var(--radius); - box-shadow: 0 8px 40px rgba(0, 0, 0, 0.2); - width: 100%; - max-width: 480px; - max-height: 90vh; - max-height: calc(var(--vh, 1vh) * 90); + top: 0; + left: 0; + width: 280px; + height: 100%; + z-index: 90; overflow-y: auto; -webkit-overflow-scrolling: touch; - padding: 28px; + /* No width/opacity transitions on mobile */ } -.modal-header { +html.is-mobile .sidebar.mobile-open { display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 20px; -} -.modal-header h3 { - font-size: 18px; - font-weight: 600; + flex-direction: column; } -.btn-close { - font-size: 18px; - color: var(--color-text-muted); - padding: 4px; - border-radius: 4px; -} -.btn-close:hover { - background: var(--color-bg); - color: var(--color-text); -} - -.modal-actions { - display: flex; - gap: 10px; - justify-content: flex-end; - margin-top: 20px; -} - -.required { - color: var(--color-primary); -} - -/* ── Toast ───────────────────────────────────────────────────────── */ -.toast { - position: fixed; - bottom: 24px; - left: 50%; - transform: translateX(-50%) translateY(20px); - background: #1a1a2e; - color: #fff; - padding: 10px 20px; - border-radius: 20px; - font-size: 14px; - font-weight: 500; - opacity: 0; - transition: - opacity 0.25s, - transform 0.25s; - pointer-events: none; - z-index: 200; - white-space: nowrap; -} -.toast.show { +/* Mobile sidebar always shows full labels regardless of collapsed state */ +html.is-mobile .sidebar .sidebar-label { opacity: 1; - transform: translateX(-50%) translateY(0); -} -.toast.toast-error { - background: #c00; + max-width: none; } -/* ── 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; +html.is-mobile .sidebar .sidebar-section-header { + opacity: 1; + height: auto; + padding: 12px 16px 4px; + pointer-events: auto; } -/* ── Auth step hint ─────────────────────────────────────────────── */ -.auth-step-hint { - color: var(--color-text-muted); - font-size: 13px; - margin-bottom: 16px; -} -#mfa-code { - font-size: 22px; - letter-spacing: 0.2em; - text-align: center; +html.is-mobile .sidebar .sidebar-item { + justify-content: flex-start; + padding: 9px 16px; } -/* ── Panel body (sharing / emergency) ───────────────────────────── */ -.panel-body { - flex: 1; - overflow-y: auto; - padding: 20px 24px; - -webkit-overflow-scrolling: touch; +html.is-mobile .sidebar .sidebar-header { + justify-content: flex-start; + padding: 14px 12px; } -/* ── Tabs ────────────────────────────────────────────────────────── */ -.panel-tabs { - display: flex; - gap: 4px; - margin-bottom: 16px; - border-bottom: 2px solid var(--color-border); - padding-bottom: 0; -} -.tab-btn { - padding: 8px 16px; - border-radius: 6px 6px 0 0; - font-size: 13px; - font-weight: 500; - color: var(--color-text-muted); - border-bottom: 2px solid transparent; - margin-bottom: -2px; - transition: - color var(--transition), - border-color var(--transition); -} -.tab-btn:hover { - color: var(--color-text); -} -.tab-btn.active { - color: var(--color-primary); - border-bottom-color: var(--color-primary); +html.is-mobile .sidebar .sidebar-header .logo-icon, +html.is-mobile .sidebar .sidebar-header .logo-text { + opacity: 1; + width: auto; + pointer-events: auto; } -/* ── Share / Emergency lists ────────────────────────────────────── */ -.share-list { - display: flex; - flex-direction: column; - gap: 8px; -} -.share-item { - display: flex; - align-items: center; - gap: 12px; - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius); - padding: 12px 14px; -} -.share-icon { - font-size: 22px; - flex-shrink: 0; -} -.share-info { - flex: 1; - min-width: 0; -} -.share-name { - display: block; - font-weight: 500; -} -.share-meta { - display: block; - font-size: 12px; - color: var(--color-text-muted); - margin-top: 2px; -} -.share-actions { - display: flex; - gap: 6px; - align-items: center; - flex-shrink: 0; +html.is-mobile .sidebar .sidebar-item::after { + display: none; } -/* ── 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; +html.is-mobile .btn-sidebar-toggle { + display: none; } -/* ── Settings modal ──────────────────────────────────────────────── */ -.modal-settings { - max-width: 520px; -} -.settings-section { - padding: 16px 0; - border-bottom: 1px solid var(--color-border); -} -.settings-section:last-child { - border-bottom: none; -} -.settings-section-title { - font-size: 15px; - font-weight: 600; - margin-bottom: 8px; -} -.settings-desc { - font-size: 13px; - color: var(--color-text-muted); - margin-bottom: 10px; - line-height: 1.5; -} -.mfa-qr { - display: block; - margin: 12px auto; - max-width: 180px; - border: 1px solid var(--color-border); - border-radius: 4px; -} -.mfa-secret-text { - font-family: monospace; - font-size: 12px; -} -.mfa-secret-text code { - background: var(--color-bg); - padding: 2px 6px; - border-radius: 4px; - user-select: all; -} - -/* ── Sort control ────────────────────────────────────────────────── */ -.sort-wrapper select { - padding: 6px 10px; - border: 1px solid var(--color-border); - border-radius: 20px; - background: var(--color-bg); - color: var(--color-text); - font-size: 13px; - cursor: pointer; - outline: none; -} -.sort-wrapper select:focus { - border-color: var(--color-primary); -} - -/* ── Modal item (wider for address/card forms) ───────────────────── */ -.modal-item { - max-width: 540px; -} - -/* ── Multi-column form rows ──────────────────────────────────────── */ -.form-row { - display: flex; - gap: 12px; -} -.form-row .form-group { - flex: 1; -} - -/* ── Btn small ───────────────────────────────────────────────────── */ -.btn-sm { - padding: 5px 12px; - font-size: 12px; - border-radius: 6px; -} - -/* ── Security dashboard ──────────────────────────────────────────── */ -.security-dashboard { - flex: 1; - overflow-y: auto; - padding: 24px; - display: flex; - flex-direction: column; - gap: 24px; - -webkit-overflow-scrolling: touch; -} - -.sec-notice { - text-align: center; - color: var(--color-text-muted); - padding: 40px 0; - font-size: 15px; -} - -.security-summary { - display: flex; - gap: 20px; - align-items: center; - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius); - padding: 24px; - flex-wrap: wrap; -} - -.sec-score-card { - text-align: center; - flex-shrink: 0; -} -.sec-score { - font-size: 56px; - font-weight: 700; - line-height: 1; -} -.sec-score.score-good { - color: #2e7d32; -} -.sec-score.score-fair { - color: #e65100; -} -.sec-score.score-poor { - color: #c62828; -} -.sec-score-label { - font-size: 16px; - font-weight: 600; - margin-top: 4px; -} -.sec-score-desc { - font-size: 12px; - color: var(--color-text-muted); - margin-top: 2px; -} - -.sec-stats { - display: flex; - gap: 16px; - flex-wrap: wrap; -} -.sec-stat { - text-align: center; - background: var(--color-bg); - border: 1px solid var(--color-border); - border-radius: var(--radius); - padding: 12px 20px; - min-width: 80px; -} -.sec-stat-warn { - border-color: #ffb74d; - background: #fff8e1; -} -.sec-stat-info { - border-color: #90caf9; - background: #e3f2fd; -} -.sec-stat-ok { - border-color: #a5d6a7; - background: #e8f5e9; -} -.sec-stat-num { - display: block; - font-size: 28px; - font-weight: 700; -} -.sec-stat-label { - display: block; - font-size: 12px; - color: var(--color-text-muted); - margin-top: 2px; -} - -.sec-section { - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius); - overflow: hidden; -} -.sec-section-header { - display: flex; - align-items: flex-start; - gap: 12px; - padding: 16px 20px; - border-bottom: 1px solid var(--color-border); - background: var(--color-bg); -} -.sec-section-icon { - font-size: 20px; - flex-shrink: 0; -} -.sec-section-title { - font-weight: 600; - font-size: 15px; -} -.sec-section-desc { - font-size: 12px; - color: var(--color-text-muted); - margin-top: 2px; -} - -.sec-item-list { - padding: 0; -} -.sec-item { - display: flex; - align-items: center; - gap: 12px; - padding: 10px 20px; - border-bottom: 1px solid var(--color-border); -} -.sec-item:last-child { - border-bottom: none; -} -.sec-item-name { - font-weight: 500; - flex: 1; -} -.sec-item-sub { - font-size: 12px; - color: var(--color-text-muted); - flex: 1; -} - -/* ── Item Detail Modal ───────────────────────────────────────────── */ -.modal-detail { - max-height: 80vh; - display: flex; - flex-direction: column; -} -.detail-body { - flex: 1; - overflow-y: auto; - padding: 4px 0; -} -.detail-field { - display: flex; - align-items: flex-start; - gap: 12px; - padding: 10px 0; - border-bottom: 1px solid var(--color-border); -} -.detail-field:last-child { - border-bottom: none; -} -.detail-label { - font-size: 12px; - color: var(--color-text-muted); - width: 140px; - flex-shrink: 0; - padding-top: 4px; -} -.detail-val-row { - display: flex; - align-items: center; - gap: 6px; - flex: 1; - flex-wrap: wrap; -} -.detail-val { - font-size: 14px; - word-break: break-all; -} -.detail-sensitive { - display: inline-flex; - align-items: center; - font-family: monospace; - font-size: 14px; -} -.detail-masked { - letter-spacing: 2px; -} -.detail-actual { - word-break: break-all; -} -.btn-reveal, -.btn-copy-field { - background: none; - border: none; - cursor: pointer; - padding: 2px 4px; - font-size: 14px; - color: var(--color-text-muted); - flex-shrink: 0; -} -.btn-reveal:hover, -.btn-copy-field:hover { - color: var(--color-primary); -} - -/* ── Emergency Vault Panel ───────────────────────────────────────── */ -.em-vault-panel { - padding: 8px 0; -} -.em-vault-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 4px 0 16px; - border-bottom: 2px solid var(--color-border); - margin-bottom: 8px; -} -.em-vault-header h4 { - margin: 0; - font-size: 15px; - font-weight: 600; -} -.em-vault-list { - list-style: none; - padding: 0; - margin: 0; -} -.em-vault-item { - border-bottom: 1px solid var(--color-border); -} -.em-vault-item:last-child { - border-bottom: none; -} -.em-vault-item-header { - display: flex; - align-items: center; - gap: 10px; - width: 100%; - background: none; - border: none; - padding: 12px 4px; - cursor: pointer; - text-align: left; -} -.em-vault-item-header:hover { - background: var(--color-sidebar-hover); - border-radius: var(--radius); -} -.em-vault-icon { - font-size: 18px; - flex-shrink: 0; -} -.em-vault-name { - font-weight: 500; - flex: 1; -} -.em-vault-type { - font-size: 12px; - color: var(--color-text-muted); -} -.em-vault-chevron { - font-size: 12px; - color: var(--color-text-muted); - flex-shrink: 0; -} -.em-vault-item-body { - padding: 0 8px 12px 36px; -} -.em-detail-body .detail-field { - padding: 8px 0; -} - -/* ── Responsive ──────────────────────────────────────────────────── */ - -/* Mobile top bar — hidden on tablet+ */ +/* ── Mobile top bar — only on html.is-mobile ─────────────────────── */ .mobile-topbar { display: none; align-items: center; @@ -1417,24 +637,32 @@ ul { top: 0; z-index: 50; } + +html.is-mobile .mobile-topbar { + display: flex; +} + .btn-mobile-menu { font-size: 22px; - color: rgba(255, 255, 255, 0.8); + color: rgba(255, 255, 255, .8); background: none; border: none; padding: 4px 6px; cursor: pointer; flex-shrink: 0; } + .btn-mobile-menu:hover { color: #fff; } + .mobile-logo { flex: 1; font-size: 16px; font-weight: 700; color: #fff; } + .btn-fab-mobile { width: 36px; height: 36px; @@ -1447,279 +675,340 @@ ul { display: none; position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.5); + background: rgba(0, 0, 0, .5); z-index: 89; } + .sidebar-backdrop.open { display: block; } -/* ── Phone + iPad portrait (≤ 768px) ───────────────────────────── */ -@media (max-width: 768px) { - .app-layout { - flex-direction: column; - height: calc(var(--vh, 1vh) * 100); - min-height: calc(var(--vh, 1vh) * 100); - overflow: hidden; - } - - .mobile-topbar { - display: flex; - } - - /* Sidebar: completely out of layout flow — fixed overlay only */ - .sidebar { - display: none !important; - position: fixed !important; - top: 0; - left: 0; - bottom: 0; - width: 280px !important; - height: 100% !important; /* Chrome needs explicit height on position:fixed */ - z-index: 90; - overflow-y: auto; - -webkit-overflow-scrolling: touch; - transition: none; - } - .sidebar.mobile-open { - display: flex !important; - flex-direction: column; - } - - /* Cancel all collapsed-state overrides inside mobile sidebar */ - .sidebar.collapsed { - display: none !important; - } - .sidebar.collapsed.mobile-open { - display: flex !important; - } - - /* Mobile sidebar: always show full expanded state */ - .sidebar .sidebar-label { - opacity: 1 !important; - max-width: none !important; - overflow: visible !important; - } - .sidebar .sidebar-section-header { - opacity: 1 !important; - height: auto !important; - padding: 12px 16px 4px !important; - pointer-events: auto !important; - } - .sidebar .sidebar-item { - justify-content: flex-start !important; - padding: 9px 16px !important; - overflow: hidden !important; - } - .sidebar .sidebar-item::after { - display: none !important; - } - .sidebar .sidebar-header { - justify-content: flex-start !important; - padding: 14px 12px !important; - overflow: hidden !important; - } - .sidebar .sidebar-header .logo-icon, - .sidebar .sidebar-header .logo-text { - opacity: 1 !important; - width: auto !important; - pointer-events: auto !important; - } - .btn-sidebar-toggle { - display: none !important; - } - - .vault-main { - flex: 1; - overflow: hidden; - min-height: 0; - } - - .vault-header { - padding: 12px 16px; - gap: 8px; - } - .vault-title { - font-size: 16px; - } - .vault-list { - padding: 8px 12px 80px; - } - .panel-body { - padding: 16px; - } - .security-dashboard { - padding: 12px; - gap: 16px; - } - - .vault-toolbar { - gap: 8px; - } - .search-wrapper { - max-width: none; - } - .sort-wrapper { - display: none; - } - - .vault-header .btn-fab { - display: none; - } - - .form-row { - flex-direction: column; - gap: 0; - } - .modal-actions { - flex-wrap: wrap; - } - - .modal-overlay { - padding: 0; - align-items: flex-end; - } - .modal { - max-width: 100% !important; - max-height: calc(var(--vh, 1vh) * 92); - border-radius: 16px 16px 0 0; - padding: 20px 16px; - } - - .security-summary { - padding: 16px; - gap: 12px; - flex-wrap: wrap; - } - .sec-score { - font-size: 40px; - } - .sec-stats { - gap: 8px; - flex-wrap: wrap; - } - .sec-stat { - padding: 8px 12px; - min-width: 68px; - } - .sec-stat-num { - font-size: 22px; - } - - .detail-field { - flex-direction: column; - gap: 4px; - } - .detail-label { - width: auto; - } +/* ── App layout ──────────────────────────────────────────────────── */ +html.is-desktop .app-layout { + display: flex; + height: 100vh; + height: calc(var(--vh, 1vh) * 100); } -/* ── iPad landscape / small laptop (769px – 1024px) ─────────────── */ +html.is-mobile .app-layout { + display: flex; + flex-direction: column; + height: 100vh; + height: -webkit-fill-available; + height: calc(var(--vh, 1vh) * 100); + overflow: hidden; +} + +/* ── Folder items ────────────────────────────────────────────────── */ +.folder-item { + justify-content: space-between; +} + +.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; +} + +.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; + 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; +} + +.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); +} + +/* ── Breakpoint-driven padding adjustments ───────────────────────── */ +/* Tablet (769–1024px) */ @media (min-width: 769px) and (max-width: 1024px) { - .sidebar:not(.collapsed) { + html.is-desktop .sidebar:not(.collapsed) { width: 200px; } + .vault-header { padding: 14px 18px; } + .vault-list { padding: 10px 18px 18px; } + .panel-body { padding: 16px 18px; } + .security-dashboard { padding: 16px; gap: 16px; } + .search-wrapper { max-width: 280px; } + .security-summary { flex-wrap: wrap; gap: 12px; } + .sec-stats { flex-wrap: wrap; gap: 10px; } + .modal { max-width: 460px; } } -/* ── Laptop (1025px – 1440px) ───────────────────────────────────── */ -@media (min-width: 1025px) and (max-width: 1440px) { - .vault-header { - padding: 16px 24px; - } - .vault-list { - padding: 12px 24px 24px; - } - .security-dashboard { - padding: 20px 24px; - } -} - -/* ── Large PC (> 1440px) ────────────────────────────────────────── */ +/* Large PC (> 1440px) */ @media (min-width: 1441px) { - .sidebar:not(.collapsed) { + html.is-desktop .sidebar:not(.collapsed) { width: 260px; } + .vault-header { padding: 18px 40px; } + .vault-list { padding: 16px 40px 40px; } + .vault-list-inner { max-width: 1100px; } + .vault-header-inner { max-width: 1100px; } + .panel-body { padding: 24px 40px; } + .security-dashboard { padding: 24px 40px; gap: 28px; } + .search-wrapper { max-width: 480px; } + .modal { max-width: 520px; } + .modal-settings { max-width: 580px; } } -/* ── Auth pages — phone ──────────────────────────────────────────── */ +/* Mobile padding and layout overrides */ +html.is-mobile .vault-main { + flex: 1; + overflow: hidden; + min-height: 0; +} + +html.is-mobile .vault-header { + padding: 12px 16px; + gap: 8px; +} + +html.is-mobile .vault-title { + font-size: 16px; +} + +html.is-mobile .vault-list { + padding: 8px 12px 80px; +} + +html.is-mobile .panel-body { + padding: 16px; +} + +html.is-mobile .security-dashboard { + padding: 12px; + gap: 16px; +} + +html.is-mobile .vault-toolbar { + gap: 8px; +} + +html.is-mobile .search-wrapper { + max-width: none; +} + +html.is-mobile .sort-wrapper { + display: none; +} + +html.is-mobile .vault-header .btn-fab { + display: none; +} + +html.is-mobile .form-row { + flex-direction: column; + gap: 0; +} + +html.is-mobile .modal-actions { + flex-wrap: wrap; +} + +html.is-mobile .modal-overlay { + padding: 0; + align-items: flex-end; +} + +html.is-mobile .modal { + max-width: 100%; + max-height: calc(var(--vh, 1vh) * 92); + border-radius: 16px 16px 0 0; + padding: 20px 16px; +} + +html.is-mobile .security-summary { + padding: 16px; + gap: 12px; + flex-wrap: wrap; +} + +html.is-mobile .sec-score { + font-size: 40px; +} + +html.is-mobile .sec-stats { + gap: 8px; + flex-wrap: wrap; +} + +html.is-mobile .sec-stat { + padding: 8px 12px; + min-width: 68px; +} + +html.is-mobile .sec-stat-num { + font-size: 22px; +} + +html.is-mobile .detail-field { + flex-direction: column; + gap: 4px; +} + +html.is-mobile .detail-label { + width: auto; +} + +/* Auth page — phone */ @media (max-width: 480px) { .auth-container { padding: 12px; } + .auth-card { padding: 28px 20px; border-radius: 12px; } + .auth-title { font-size: 20px; } } +/* iOS input font-size — prevent auto-zoom on focus */ +html.is-mobile .form-group input, +html.is-mobile .form-group select, +html.is-mobile .form-group textarea { + font-size: 16px; +} + /* ── 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; @@ -1729,15 +1018,18 @@ ul { font-size: 14px; font-weight: 500; cursor: pointer; - transition: opacity 0.15s; + transition: opacity .15s; } + .btn-danger:hover { - opacity: 0.85; + opacity: .85; } + .btn-danger:disabled { - opacity: 0.5; + opacity: .5; cursor: not-allowed; } + .recovery-code-box { display: flex; align-items: center; @@ -1748,15 +1040,17 @@ ul { padding: 12px 14px; margin: 8px 0; } + .recovery-code-box code { font-family: monospace; font-size: 15px; - letter-spacing: 0.08em; + letter-spacing: .08em; word-break: break-all; flex: 1; } + .warning-text { color: var(--color-danger, #e53e3e); font-size: 13px; font-weight: 500; -} +} \ No newline at end of file diff --git a/app/static/js/layout-init.js b/app/static/js/layout-init.js new file mode 100644 index 0000000..00ff487 --- /dev/null +++ b/app/static/js/layout-init.js @@ -0,0 +1,26 @@ +/** + * layout-init.js — runs synchronously in before body renders. + * + * Sets html.is-mobile or html.is-desktop immediately so CSS scoped to + * those classes takes effect on the very first paint. No flash, no + * layout shift, works on Chrome and Safari equally. + * + * Also restores the sidebar collapsed state on desktop so the sidebar + * width is correct from the very first frame. + * + * Must be loaded with NO defer/async attribute so it blocks parsing + * until it runs — that's intentional and necessary. + */ +(function () { + var isMobile = window.matchMedia('(max-width: 768px)').matches; + document.documentElement.classList.add(isMobile ? 'is-mobile' : 'is-desktop'); + + // On desktop, restore collapsed state from localStorage so the sidebar + // renders at the correct width without any visual jump. + if (!isMobile && localStorage.getItem('sidebar_collapsed') === '1') { + // We can't set it on #sidebar yet (body not parsed), so we use a + // temporary html class that CSS can target, then vault.js adds + // .collapsed to the sidebar element on DOMContentLoaded. + document.documentElement.classList.add('sidebar-will-collapse'); + } +})(); \ No newline at end of file diff --git a/app/static/js/vault.js b/app/static/js/vault.js index 084c9da..a32c657 100644 --- a/app/static/js/vault.js +++ b/app/static/js/vault.js @@ -8,60 +8,33 @@ 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 }; @@ -71,12 +44,8 @@ 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); } @@ -93,85 +62,64 @@ 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); } @@ -179,12 +127,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'); } } @@ -192,53 +140,43 @@ 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); @@ -249,124 +187,75 @@ 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}
    @@ -375,535 +264,394 @@ 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="edit"]').addEventListener("click", (e) => { - e.stopPropagation(); - openModal("edit", item); - }); - li.querySelector('[data-action="delete"]').addEventListener( - "click", - (e) => { + li.querySelector('[data-action="copy-pass"]').addEventListener('click', e => { e.stopPropagation(); - confirmDeleteItem(item); - }, - ); - li.addEventListener("click", () => openModal("edit", item)); + 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)); 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)); - } - actions = a.wait_elapsed - ? `` - : `Waiting (~${daysLeft} day${daysLeft !== 1 ? "s" : ""} left)`; + 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)); } - 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 { @@ -1108,57 +799,43 @@ 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); @@ -1249,11 +904,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++; @@ -1263,69 +918,60 @@ 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 = `