Files
Password-Manager/index.html
T
Zaki 01c56edf25 feat(recovery): single-use recovery code for forgotten master password
In a zero-knowledge vault, forgetting the master password normally
means losing the data — the AES key is derived from the master pw
and the server can't help. This commit adds the standard escape
hatch: a one-time recovery code that key-wraps the AES key so the
user can get back in.

Threat model
============
The plaintext recovery code is shown to the user exactly once, at
generation time. Server only ever stores SHA-256(code) + an AES-GCM
wrap of the vault key under a KEK = PBKDF2(code, kdf_salt, 600k).
Without the plaintext code the server cannot unwrap. The code is
high-entropy (96 bits from a 32-char ambiguity-free alphabet, in 4
groups of 4) — printed form is misreading-resistant.

Single use: redeeming deletes the row inside the same DB.Lock the
lookup happened in, so concurrent redeem attempts are race-free.
Failed redemptions feed both the per-IP rate limit AND the per-
username lockout, so brute-forcing the code is infeasible.

Schema
======
recovery_keys (
  user_id      INTEGER PRIMARY KEY (1:1 with users, FK cascade),
  code_hash    TEXT NOT NULL          (SHA-256 hex of plaintext code),
  kdf_salt     TEXT NOT NULL          (PBKDF2 salt for KEK derivation),
  wrapped_key  TEXT NOT NULL          (base64 AES-GCM ciphertext of vault key),
  wrapped_iv   TEXT NOT NULL          (base64 12B IV for the wrap),
  created_at   DATETIME DEFAULT CURRENT_TIMESTAMP
)

Backend: new unit PM.Handler.Recovery
=====================================
  GET    /recovery-key/status   (auth)         -> { configured, created_at? }
  POST   /recovery-key/setup    (auth + CSRF)  body {masterPassword, codeHash,
                                                     kdfSalt, wrappedKey, wrappedIv}
  DELETE /recovery-key          (auth + CSRF)  -> remove config
  POST   /recovery-key/redeem   (NO auth)      body {username, code}
                                               -> session + wrappedKey + wrappedIv + kdfSalt
                                                  + user's current salt + kdfIterations

VerifyMasterPassword() helper handles both legacy 'pbkdf2' and
current 'pbkdf2-sha256' schemes consistently with PM.Handler.Auth.

Setup flow
==========
1. Settings → "Generate recovery code" button (asks master pw via reauth).
2. Client generates: 16-char code + fresh kdf_salt + exports the current
   AES key via crypto.subtle.exportKey('raw').
3. Client wraps the raw key under KEK=PBKDF2(code, kdf_salt, 600k)
   with a random 12B IV → base64.
4. POSTs to /recovery-key/setup. Server verifies master pw, INSERT-or-
   replaces the row (DELETE+INSERT, no UPSERT — same pattern as the
   lockout table since FireDAC's UPSERT support is patchy).
5. Confirm modal shows the plaintext code in a monospace, user-select-all
   panel. The modal is forcing: "I saved it" button is the only way out.
   Modal is the only place the code ever appears — server never sees it.

Redeem flow (forgot master pw)
==============================
1. Auth screen → "Forgot master password? Use a recovery code" link.
2. promptDialog: username, then code (masked input).
3. POST /recovery-key/redeem. Server hashes the typed code, joins with
   users by username, ConstantTimeEquals against stored hash. On match:
     - deletes the recovery_keys row (single-use)
     - issues a fresh session token + CSRF
     - returns: { token, csrfToken, salt, kdfIterations, kdfSalt,
                  wrappedKey, wrappedIv, userId }
4. Client unwraps the AES key with PBKDF2(code, kdfSalt, 600k) → raw bytes
   → importKey('raw') back into a CryptoKey.
5. State is reconstituted from the new session, persistCryptoKey, enterApp.
6. Client immediately opens the Change-master-password modal — the
   recovery code is consumed and the account needs a fresh master pw
   AND a fresh recovery code (the user generates a new one from Settings).

Backward compat
===============
Recovery is opt-in. Existing users see "No recovery key set" in Settings
until they generate one. No migration needed — the table is created via
CREATE TABLE IF NOT EXISTS at server startup, FK cascade on user delete.

Minor UI additions
==================
 - .btn-link CSS class for the auth-screen "Forgot master password?" link.
 - Recovery-status label in Settings refreshed on every openSettings()
   via GET /recovery-key/status.
2026-05-23 11:18:34 +01:00

596 lines
34 KiB
HTML

<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vault</title>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<!-- ============================================================ -->
<!-- ICON SPRITE (Lucide subset) -->
<!-- ============================================================ -->
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
<symbol id="i-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></symbol>
<symbol id="i-unlock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/></symbol>
<symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></symbol>
<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5v14"/></symbol>
<symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></symbol>
<symbol id="i-star" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></symbol>
<symbol id="i-trash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></symbol>
<symbol id="i-eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></symbol>
<symbol id="i-eye-off" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.88 9.88a3 3 0 1 0 4.24 4.24M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61M2 2l20 20"/></symbol>
<symbol id="i-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></symbol>
<symbol id="i-edit" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></symbol>
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></symbol>
<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z"/></symbol>
<symbol id="i-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/></symbol>
<symbol id="i-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79Z"/></symbol>
<symbol id="i-globe" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10Z"/></symbol>
<symbol id="i-dice" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8" cy="8" r="1.2" fill="currentColor"/><circle cx="16" cy="8" r="1.2" fill="currentColor"/><circle cx="12" cy="12" r="1.2" fill="currentColor"/><circle cx="8" cy="16" r="1.2" fill="currentColor"/><circle cx="16" cy="16" r="1.2" fill="currentColor"/></symbol>
<symbol id="i-tag" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41 13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82Z"/><line x1="7" y1="7" x2="7.01" y2="7"/></symbol>
<symbol id="i-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></symbol>
<symbol id="i-log-in" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></symbol>
<symbol id="i-rotate-ccw" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></symbol>
<symbol id="i-command" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 3a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0-3-3H6a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3V6a3 3 0 0 0-3-3 3 3 0 0 0-3 3 3 3 0 0 0 3 3h12a3 3 0 0 0 3-3 3 3 0 0 0-3-3Z"/></symbol>
<symbol id="i-log-out" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></symbol>
<symbol id="i-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></symbol>
<symbol id="i-list" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></symbol>
<symbol id="i-more" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="1.4" fill="currentColor"/><circle cx="12" cy="12" r="1.4" fill="currentColor"/><circle cx="12" cy="19" r="1.4" fill="currentColor"/></symbol>
<symbol id="i-user" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></symbol>
<symbol id="i-empty-vault" viewBox="0 0 120 120" fill="none">
<circle cx="60" cy="60" r="50" fill="var(--accent-soft)"/>
<rect x="40" y="50" width="40" height="35" rx="4" fill="var(--bg-elev)" stroke="var(--accent)" stroke-width="2"/>
<path d="M48 50V40a12 12 0 0 1 24 0v10" fill="none" stroke="var(--accent)" stroke-width="2" stroke-linecap="round"/>
<circle cx="60" cy="65" r="3" fill="var(--accent)"/>
<line x1="60" y1="68" x2="60" y2="74" stroke="var(--accent)" stroke-width="2" stroke-linecap="round"/>
</symbol>
<symbol id="i-empty-trash" viewBox="0 0 120 120" fill="none">
<circle cx="60" cy="60" r="50" fill="var(--accent-soft)"/>
<path d="M40 45h40M48 45v-5a3 3 0 0 1 3-3h18a3 3 0 0 1 3 3v5M44 45v35a4 4 0 0 0 4 4h24a4 4 0 0 0 4-4V45" stroke="var(--accent)" stroke-width="2" stroke-linecap="round" fill="none"/>
<path d="M54 55v22M66 55v22" stroke="var(--accent)" stroke-width="2" stroke-linecap="round"/>
</symbol>
<symbol id="i-empty-search" viewBox="0 0 120 120" fill="none">
<circle cx="60" cy="60" r="50" fill="var(--accent-soft)"/>
<circle cx="55" cy="55" r="14" stroke="var(--accent)" stroke-width="2.5" fill="none"/>
<path d="m66 66 11 11" stroke="var(--accent)" stroke-width="2.5" stroke-linecap="round"/>
</symbol>
</svg>
<!-- ============================================================ -->
<!-- TOAST CONTAINER -->
<!-- ============================================================ -->
<div id="toastContainer" class="toast-container"></div>
<!-- ============================================================ -->
<!-- IDLE WARNING (auto-lock countdown) -->
<!-- ============================================================ -->
<div id="idleWarning" class="idle-warning is-hidden">
<svg class="idle-icon"><use href="#i-lock"/></svg>
<h3>Auto-lock</h3>
<p>Vault locks in <b id="idleCountdown">30</b>s due to inactivity</p>
<button class="btn btn-primary btn-block" id="idleStayBtn">Stay unlocked</button>
</div>
<!-- ============================================================ -->
<!-- AUTH SCREEN (login + register) -->
<!-- ============================================================ -->
<section id="authScreen" class="auth-screen">
<div class="auth-card">
<div class="auth-brand">
<svg class="auth-brand-icon"><use href="#i-lock"/></svg>
<h1>Vault</h1>
<p>Your passwords, encrypted locally.</p>
</div>
<div class="auth-tabs">
<button class="auth-tab is-active" data-tab="login">Login</button>
<button class="auth-tab" data-tab="register">Register</button>
</div>
<div id="authHint" class="auth-hint"></div>
<form id="loginForm" class="auth-form" autocomplete="off">
<label class="field">
<span>Username</span>
<input id="loginUsername" type="text" required>
</label>
<label class="field">
<span>Master password</span>
<input id="loginPassword" type="password" required>
</label>
<button id="loginBtn" type="submit" class="btn btn-primary btn-block">
<svg><use href="#i-unlock"/></svg>
Unlock
</button>
<button id="passkeyBtn" type="button" class="btn btn-ghost btn-block">
<svg><use href="#i-lock"/></svg>
Use a passkey
</button>
<button id="recoveryBtn" type="button" class="btn btn-link btn-block" style="font-size:12px;margin-top:4px">
Forgot master password? Use a recovery code
</button>
</form>
<form id="registerForm" class="auth-form is-hidden" autocomplete="off">
<label class="field">
<span>Username <span class="hint">min 3 chars</span></span>
<input id="regUsername" type="text" required minlength="3">
</label>
<label class="field">
<span>Master password <span class="hint">min 8 chars</span></span>
<input id="regPassword" type="password" required minlength="8">
<div class="strength-bar" id="regStrengthBar"></div>
</label>
<button id="registerBtn" type="submit" class="btn btn-primary btn-block">
<svg><use href="#i-plus"/></svg>
Create vault
</button>
</form>
</div>
</section>
<!-- ============================================================ -->
<!-- APP SHELL (sidebar + main + slide-over) -->
<!-- ============================================================ -->
<div id="appShell" class="app-shell is-hidden">
<!-- ---- SIDEBAR ----------------------------------------- -->
<aside class="sidebar">
<div class="sidebar-brand">
<svg><use href="#i-lock"/></svg>
<span>Vault</span>
</div>
<nav class="sidebar-nav">
<button class="nav-item is-active" data-view="all">
<svg><use href="#i-globe"/></svg>
<span>All items</span>
<span class="nav-count" id="countAll">0</span>
</button>
<button class="nav-item" data-view="favorites">
<svg><use href="#i-star"/></svg>
<span>Favorites</span>
<span class="nav-count" id="countFav">0</span>
</button>
</nav>
<div class="sidebar-section">
<div class="sidebar-section-header">
<span>Folders</span>
<button class="icon-btn icon-btn-sm" id="addFolderBtn" title="New folder">
<svg><use href="#i-plus"/></svg>
</button>
</div>
<nav class="sidebar-nav" id="foldersList"></nav>
</div>
<div class="sidebar-section">
<div class="sidebar-section-header"><span>Tags</span></div>
<nav class="sidebar-nav" id="tagsList"></nav>
</div>
<div class="sidebar-section">
<div class="sidebar-section-header"><span>Tools</span></div>
<nav class="sidebar-nav">
<button class="nav-item" id="sidebarGenBtn">
<svg><use href="#i-dice"/></svg>
<span>Generator</span>
</button>
<button class="nav-item" id="sidebarImportBtn">
<svg><use href="#i-log-in"/></svg>
<span>Import vault</span>
</button>
<button class="nav-item" id="sidebarExportBtn">
<svg><use href="#i-log-out"/></svg>
<span>Export vault</span>
</button>
</nav>
</div>
<div class="sidebar-bottom">
<button class="nav-item" data-view="trash">
<svg><use href="#i-trash"/></svg>
<span>Trash</span>
<span class="nav-count" id="countTrash">0</span>
</button>
<button class="nav-item" id="settingsBtn">
<svg><use href="#i-settings"/></svg>
<span>Settings</span>
</button>
</div>
</aside>
<!-- ---- MAIN -------------------------------------------- -->
<main class="main">
<header class="topbar">
<div class="search">
<svg><use href="#i-search"/></svg>
<input id="searchInput" type="search" placeholder="Search…" autocomplete="off">
<kbd>Ctrl+K</kbd>
</div>
<div class="topbar-actions">
<div class="view-toggle">
<button class="view-btn is-active" id="viewCardsBtn" data-view="cards" title="Cards view">
<svg><use href="#i-grid"/></svg>
</button>
<button class="view-btn" id="viewListBtn" data-view="list" title="List view">
<svg><use href="#i-list"/></svg>
</button>
</div>
<button class="icon-btn" id="themeBtn" title="Toggle theme">
<svg class="theme-icon theme-icon-dark"><use href="#i-sun"/></svg>
<svg class="theme-icon theme-icon-light"><use href="#i-moon"/></svg>
</button>
<button class="btn btn-primary btn-sm" id="newEntryBtn">
<svg><use href="#i-plus"/></svg>
New
</button>
<div class="user-menu">
<button class="user-chip" id="userChip">
<span id="userName">user</span>
</button>
<div class="user-dropdown is-hidden" id="userDropdown">
<button class="dropdown-item" id="lockBtn">
<svg><use href="#i-lock"/></svg> Lock vault
</button>
<button class="dropdown-item" id="logoutBtn">
<svg><use href="#i-log-out"/></svg> Sign out
</button>
</div>
</div>
</div>
</header>
<div class="content">
<div class="content-header">
<h2 id="contentTitle">All items</h2>
<span class="content-meta" id="contentMeta">0 items</span>
</div>
<!-- Entry grid -->
<div id="entryGrid" class="entry-grid"></div>
<!-- Empty state — illustration + message are filled dynamically -->
<div id="emptyState" class="empty-state is-hidden">
<svg class="empty-illustration" id="emptyIllustration"><use href="#i-empty-vault"/></svg>
<h3 id="emptyTitle">Nothing here yet</h3>
<p id="emptyMessage">Click <b>+ New</b> to add your first password.</p>
</div>
</div>
</main>
<!-- ---- SLIDE-OVER DETAIL ------------------------------- -->
<aside class="slideover" id="slideover">
<header class="slideover-header">
<h3 id="slideoverTitle">Entry</h3>
<button class="icon-btn" id="slideoverClose"><svg><use href="#i-x"/></svg></button>
</header>
<div class="slideover-body" id="slideoverBody"></div>
</aside>
<!-- ---- SETTINGS SLIDE-OVER ----------------------------- -->
<aside class="slideover" id="settingsPanel">
<header class="slideover-header">
<h3>Settings</h3>
<button class="icon-btn" id="settingsClose"><svg><use href="#i-x"/></svg></button>
</header>
<div class="slideover-body">
<div class="slideover-field">
<div class="slideover-field-label">Appearance</div>
<div class="setting-row">
<span>Theme</span>
<select id="settingTheme">
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
</div>
<div class="setting-row">
<span>Compact action menu (⋯)</span>
<label class="toggle">
<input type="checkbox" id="settingCompact">
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-row">
<span>Mask usernames (ab***)</span>
<label class="toggle">
<input type="checkbox" id="settingMaskUser">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="slideover-field">
<div class="slideover-field-label">Security</div>
<div class="setting-row">
<span>Auto-lock after (minutes)</span>
<select id="settingAutoLock">
<option value="0">Never</option>
<option value="1">1</option>
<option value="5">5</option>
<option value="15">15</option>
<option value="30">30</option>
<option value="60">60</option>
</select>
</div>
<div class="setting-row">
<span>Ask before moving to trash</span>
<label class="toggle">
<input type="checkbox" id="settingAskDelete">
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-row">
<span>
Check passwords against breach database (HIBP)
<small class="setting-hint">Only the first 5 chars of the SHA-1 are sent — your passwords never leave the machine.</small>
</span>
<label class="toggle">
<input type="checkbox" id="settingHIBP">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="slideover-field">
<div class="slideover-field-label">Clipboard privacy</div>
<p style="font-size:12px;color:var(--text-dim);margin:0 0 8px;line-height:1.5">
Windows keeps copied passwords in <b>clipboard history (Win+V)</b>.
To prevent that, disable history in <i>Settings → System → Clipboard</i>.
</p>
<button class="btn btn-ghost btn-sm" id="openClipboardSettings">Why?</button>
</div>
<div class="slideover-field">
<div class="slideover-field-label">Export</div>
<p style="font-size:12px;color:var(--text-dim);margin:0 0 8px;line-height:1.5">
Download an <b>encrypted</b> backup of your vault. You'll
choose a password independent of your master password —
save it carefully, you need it to restore.
</p>
<button class="btn btn-ghost btn-sm" id="exportBtn">
<svg><use href="#i-log-out"/></svg> Export vault
</button>
</div>
<div class="slideover-field">
<div class="slideover-field-label">Import</div>
<p style="font-size:12px;color:var(--text-dim);margin:0 0 8px;line-height:1.5">
Import a vault from a JSON export (this app) or a CSV file
(Bitwarden, KeePass, Chrome, 1Password…). Entries are added
to your current vault — duplicates are NOT removed.
</p>
<button class="btn btn-ghost btn-sm" id="importBtn">
<svg><use href="#i-log-in"/></svg> Import vault
</button>
</div>
<div class="slideover-field">
<div class="slideover-field-label">Account</div>
<p style="font-size:12px;color:var(--text-dim);margin:0 0 8px">
Signed in as <b id="settingUser"></b>
</p>
<button class="btn btn-ghost btn-sm" id="changeMasterBtn">
<svg><use href="#i-lock"/></svg> Change master password
</button>
</div>
<div class="slideover-field">
<div class="slideover-field-label">Recovery key</div>
<p id="recoveryStatus" style="font-size:12px;color:var(--text-dim);margin:0 0 8px;line-height:1.5">
No recovery key set.
</p>
<p style="font-size:11px;color:var(--text-faint);margin:0 0 8px;line-height:1.4">
A single-use code that lets you recover access if you
forget your master password. Generate it once, save the
code somewhere offline — the server never sees it again.
</p>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<button class="btn btn-ghost btn-sm" id="recoverySetupBtn">
Generate recovery code
</button>
<button class="btn btn-ghost btn-sm is-danger" id="recoveryRemoveBtn" style="display:none">
Remove
</button>
</div>
</div>
</div>
</aside>
<!-- ============================================================ -->
<!-- MODAL: Re-auth (for export and other sensitive ops) -->
<!-- ============================================================ -->
<div id="reauthModal" class="modal is-hidden" role="dialog" aria-modal="true">
<div class="modal-backdrop" data-close></div>
<div class="modal-panel modal-panel-sm">
<header class="modal-header">
<h3 id="reauthTitle">Confirm master password</h3>
<button class="icon-btn" data-close><svg><use href="#i-x"/></svg></button>
</header>
<form id="reauthForm" class="modal-body" autocomplete="off">
<p style="margin:0 0 12px;color:var(--text-dim);font-size:13px" id="reauthMessage">
This action requires your master password.
</p>
<label class="field">
<span>Master password</span>
<input id="reauthPassword" type="password" required autofocus>
</label>
</form>
<footer class="modal-footer">
<button type="button" class="btn btn-ghost" data-close>Cancel</button>
<button type="submit" form="reauthForm" class="btn btn-primary" id="reauthConfirm">
<svg><use href="#i-check"/></svg> Confirm
</button>
</footer>
</div>
</div>
<!-- ============================================================ -->
<!-- MODAL: Change Master Password -->
<!-- ============================================================ -->
<div id="changeMasterModal" class="modal is-hidden" role="dialog" aria-modal="true">
<div class="modal-backdrop" data-close></div>
<div class="modal-panel modal-panel-sm">
<header class="modal-header">
<h3>Change master password</h3>
<button class="icon-btn" data-close><svg><use href="#i-x"/></svg></button>
</header>
<form id="changeMasterForm" class="modal-body" autocomplete="off">
<p style="margin:0 0 12px;color:var(--text-dim);font-size:13px;line-height:1.5">
Your vault will be re-encrypted with the new key.
All other sessions will be signed out.
</p>
<label class="field">
<span>Current master password</span>
<input id="cmCurrentPwd" type="password" required>
</label>
<label class="field">
<span>New master password (min 8 chars)</span>
<input id="cmNewPwd" type="password" required minlength="8">
</label>
<label class="field">
<span>Confirm new master password</span>
<input id="cmConfirmPwd" type="password" required>
</label>
<p id="cmError" style="margin:8px 0 0;color:#dc2626;font-size:12px;display:none"></p>
</form>
<footer class="modal-footer">
<button type="button" class="btn btn-ghost" data-close>Cancel</button>
<button type="submit" form="changeMasterForm" class="btn btn-primary" id="cmConfirmBtn">
<svg><use href="#i-check"/></svg> Change password
</button>
</footer>
</div>
</div>
</div>
<!-- ============================================================ -->
<!-- MODAL: New / Edit Entry -->
<!-- ============================================================ -->
<div id="entryModal" class="modal is-hidden" role="dialog" aria-modal="true">
<div class="modal-backdrop" data-close></div>
<div class="modal-panel">
<header class="modal-header">
<h3 id="entryModalTitle">New entry</h3>
<button class="icon-btn" data-close><svg><use href="#i-x"/></svg></button>
</header>
<form id="entryForm" class="modal-body" autocomplete="off">
<input type="hidden" id="entryId">
<label class="field">
<span>Website / App</span>
<input id="entrySite" type="text" required placeholder="example.com">
</label>
<label class="field">
<span>Username / Email</span>
<input id="entryUsername" type="text" placeholder="me@example.com">
</label>
<label class="field">
<span>Password</span>
<div class="input-with-action">
<input id="entryPassword" type="password" required placeholder="••••••••">
<button type="button" class="icon-btn" id="entryPwToggle"><svg><use href="#i-eye"/></svg></button>
<button type="button" class="icon-btn" id="entryPwGen" title="Generate"><svg><use href="#i-dice"/></svg></button>
</div>
<div class="strength-bar" id="entryStrengthBar"></div>
</label>
<label class="field">
<span>Folder</span>
<select id="entryFolder"></select>
</label>
<label class="field">
<span>Tags <span class="hint">enter to add</span></span>
<div class="chip-input" id="entryTagsInput">
<input id="entryTagsField" type="text" placeholder="add a tag…" autocomplete="off">
</div>
<input type="hidden" id="entryTags">
</label>
</form>
<footer class="modal-footer">
<button class="btn btn-ghost" data-close>Cancel</button>
<button class="btn btn-primary" id="entrySaveBtn">
<svg><use href="#i-check"/></svg> Save
</button>
</footer>
</div>
</div>
<!-- ============================================================ -->
<!-- MODAL: Password Generator -->
<!-- ============================================================ -->
<div id="genModal" class="modal is-hidden" role="dialog" aria-modal="true">
<div class="modal-backdrop" data-close></div>
<div class="modal-panel modal-panel-sm">
<header class="modal-header">
<h3>Password generator</h3>
<button class="icon-btn" data-close><svg><use href="#i-x"/></svg></button>
</header>
<div class="modal-body">
<div class="gen-preview" id="genPreview">click regenerate</div>
<label class="field">
<span>Length <b id="genLenLabel">16</b></span>
<input id="genLen" type="range" min="6" max="128" value="16">
</label>
<div class="gen-options">
<label class="check"><input type="checkbox" id="genUpper" checked><span>A-Z</span></label>
<label class="check"><input type="checkbox" id="genLower" checked><span>a-z</span></label>
<label class="check"><input type="checkbox" id="genNum" checked><span>0-9</span></label>
<label class="check"><input type="checkbox" id="genSym" checked><span>!@#</span></label>
</div>
</div>
<footer class="modal-footer">
<button class="btn btn-ghost" id="genRegen"><svg><use href="#i-rotate-ccw"/></svg> Regenerate</button>
<button class="btn btn-ghost" id="genCopy"><svg><use href="#i-copy"/></svg> Copy</button>
<button class="btn btn-primary" id="genUse"><svg><use href="#i-check"/></svg> Use</button>
</footer>
</div>
</div>
<!-- ============================================================ -->
<!-- CONFIRM / PROMPT MODAL (replaces native confirm/prompt) -->
<!-- ============================================================ -->
<div id="confirmModal" class="modal is-hidden" role="dialog" aria-modal="true">
<div class="modal-backdrop" data-confirm-cancel></div>
<div class="modal-panel modal-panel-sm">
<header class="modal-header">
<h3 id="confirmTitle">Confirm</h3>
<button class="icon-btn" data-confirm-cancel><svg><use href="#i-x"/></svg></button>
</header>
<form id="confirmForm" class="modal-body">
<p id="confirmMessage" style="margin:0 0 12px;color:var(--text-dim);font-size:13px;line-height:1.5">
Are you sure?
</p>
<label class="field is-hidden" id="confirmInputField">
<input id="confirmInput" type="text" autocomplete="off">
</label>
</form>
<footer class="modal-footer">
<button type="button" class="btn btn-ghost" data-confirm-cancel id="confirmCancelBtn">Cancel</button>
<button type="submit" form="confirmForm" class="btn btn-primary" id="confirmOkBtn">
<svg><use href="#i-check"/></svg> Confirm
</button>
</footer>
</div>
</div>
<!-- ============================================================ -->
<!-- COMMAND PALETTE (Cmd+K) — phase 3, shell only for now -->
<!-- ============================================================ -->
<div id="cmdPalette" class="cmd-palette is-hidden" role="dialog" aria-modal="true">
<div class="modal-backdrop" data-close></div>
<div class="cmd-panel">
<div class="cmd-input">
<svg><use href="#i-command"/></svg>
<input id="cmdInput" type="text" placeholder="Type a command or search…">
</div>
<div class="cmd-results" id="cmdResults"></div>
</div>
</div>
<script src="js/app.js"></script>
</body>
</html>