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 = ` `; - 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 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 = - '