/* ============================================================ Vault — UI V2 app.js Clean state + render layer. Crypto helpers preserved verbatim from legacy. Backend (PHP api.php / Delphi loopback) is detected from URL path. ============================================================ */ // ---- Backend detection ------------------------------------- const API = (location.pathname.indexOf('/password-manager/') === 0) ? '/password-manager/api.php' : ''; // ---- Delphi native bridge ---------------------------------- // Active only when running inside the Delphi-hosted WebView2 (API === ''). // Falls back to navigator.clipboard for the standalone PHP frontend. const prefResolvers = {}; let autoStartResolver = null; const Bridge = (() => { const active = (API === ''); // Navigate to a cmd:// URL — intercepted synchronously by // TTMSFNCWebBrowser OnBeforeNavigate before any actual navigation occurs. function cmd(path) { window.location.href = path; } return { active, // Copy text to clipboard, excluded from Win+V history. // clearAfterMs: Delphi auto-clears after this many ms (0 = never). // Returns true when the bridge handled the copy, false as fallback signal. copySecure(text, clearAfterMs = 30000) { if (!active) return false; cmd('cmd://clipboard/copy?text=' + encodeURIComponent(text) + '&clear=' + clearAfterMs); return true; }, // Called by Delphi (ExecuteJavaScript) on WTS_SESSION_LOCK and // PBT_APMSUSPEND. When quick-unlock is enabled on this device the // DPAPI blob already gates access via the Windows user account, so // re-locking is redundant — we just stay unlocked and the user is // back where they left off when they return. onSystemLock() { if (state.quickUnlockEnabled) { if (typeof toast === 'function') toast('System lock — vault kept unlocked (quick unlock active)'); return; } if (typeof lockVault === 'function') lockVault(); }, // Called by Delphi (ExecuteJavaScript) when the user restores the // window from the tray icon. Useful for resetting auto-lock state // and giving a subtle visual cue. onTrayRestore() { // If the user has been away long enough that the auto-lock // should fire, lockVault was already called by either WTS lock // or the local idle timer — so we only reset here when still // unlocked. if (state.cryptoKey && !state.locked) { if (typeof resetAutoLock === 'function') resetAutoLock(); if (typeof toast === 'function') toast('Welcome back'); } }, // ---- Autofill (Ctrl+Shift+L / Ctrl+Shift+P global hotkeys) ---------- // Called by Delphi when a hotkey fires. windowTitle = foreground // window title at hotkey time. kind = "full" (user+Tab+pwd) or // "password" (password only — for step-2 forms, unlock screens). onAutofillRequest(windowTitle, kind) { autofillHandleRequest(windowTitle, kind || 'full'); }, // Called by Delphi on Ctrl+Shift+A. Opens the new-entry modal with // the foreground window title pre-filled (browser suffix stripped). onNewEntryFromTitle(windowTitle) { if (!state.cryptoKey || state.locked || !state.token) { toast('Unlock the vault first', 'warning'); return; } const cleaned = autofillStripBrowserSuffix(windowTitle); openEntryModal(); setTimeout(() => { const titleField = $('#entryTitle'); if (titleField) { titleField.value = cleaned; $('#entrySite').focus(); } }, 0); }, // Tell Delphi to simulate keystrokes. Empty username = password only // (no Tab is sent). executeAutofill(username, password) { if (!active) return; cmd('cmd://autofill/execute?username=' + encodeURIComponent(username) + '&password=' + encodeURIComponent(password)); }, // Ask Delphi to bring the main window to front (used when the // multi-match picker opens, so the user definitely sees it even // if the app was minimised to tray or hidden behind other apps). focusApp() { if (!active) return; cmd('cmd://app/focus'); }, // Tell Delphi the page is rendered and waiting for input — Delphi // calls WebBrowser.SetFocus (the FMX control needs OS-level focus // before any input.focus() inside the DOM can work) then injects // a focus script targeting the visible auth field. appReady() { if (!active) return; cmd('cmd://app/ready'); }, // Sync the Windows title bar with the app theme (dark vs light). // Calls DwmSetWindowAttribute DWMWA_USE_IMMERSIVE_DARK_MODE on the // form's HWND. No-op on Windows < 10 build 19044. syncTitleBarTheme(mode) { if (!active) return; cmd('cmd://app/theme?mode=' + (mode === 'light' ? 'light' : 'dark')); }, // Tell Delphi we couldn't find a match / user cancelled. cancelAutofill() { if (!active) return; cmd('cmd://autofill/cancel'); }, // Sync the hotkey registration state with this device's preference. configureAutofill(enabled) { if (!active) return; cmd('cmd://autofill/configure?enabled=' + (enabled ? '1' : '0')); }, // Send the full hotkey configuration to Delphi (enabled + combos). // combos = { full: {ctrl,shift,alt,win,key}, password: {…} }. setAutofillHotkeys(enabled, combos) { if (!active) return; const f = autofillComboToWin32(combos.full); const p = autofillComboToWin32(combos.password); cmd('cmd://autofill/hotkeys?enabled=' + (enabled ? '1' : '0') + '&full_mods=' + f.mods + '&full_vk=' + f.vk + '&pwd_mods=' + p.mods + '&pwd_vk=' + p.vk); }, // Called by Delphi after a setAutofillHotkeys request, with true if // BOTH combos registered successfully, false otherwise (clash with // another app holding a global hotkey). Surfaces a toast. onAutofillHotkeysResult(allOk) { if (allOk) { toast('Autofill hotkeys updated'); } else { toast('Autofill: one or both hotkeys are already used by another app', 'warning'); } }, // ---- Device-bound prefs (DPAPI-backed) ---------------------------- // localStorage is keyed by origin, and our HTTP port is random on // every launch — so anything we put there is wiped at reboot. For // prefs that need to survive a reboot (remembered username, etc.), // round-trip through Delphi which persists via DPAPI. getPref(key) { if (!active) return Promise.resolve(''); return new Promise(resolve => { prefResolvers[key] = resolve; cmd('cmd://prefs/get?key=' + encodeURIComponent(key)); setTimeout(() => { if (prefResolvers[key] === resolve) { delete prefResolvers[key]; resolve(''); } }, 2000); }); }, setPref(key, value) { if (!active) return; cmd('cmd://prefs/set?key=' + encodeURIComponent(key) + '&value=' + encodeURIComponent(value || '')); }, onPrefResult(key, value) { const r = prefResolvers[key]; if (r) { delete prefResolvers[key]; r(value || ''); } }, // ---- Start with Windows (HKCU Run registry) ----------------------- getAutoStart() { if (!active) return Promise.resolve(false); return new Promise(resolve => { autoStartResolver = resolve; cmd('cmd://autostart/get'); setTimeout(() => { if (autoStartResolver === resolve) { autoStartResolver = null; resolve(false); } }, 2000); }); }, setAutoStart(enabled) { if (!active) return; cmd('cmd://autostart/set?enabled=' + (enabled ? '1' : '0')); }, onAutoStartStatus(enabled) { if (autoStartResolver) { const r = autoStartResolver; autoStartResolver = null; r(!!enabled); } state.autoStartEnabled = !!enabled; const cb = document.getElementById('settingAutoStart'); if (cb) cb.checked = !!enabled; }, }; })(); // Expose Bridge on window so Delphi's ExecuteJavaScript can reach it. window.Bridge = Bridge; // ---- Global state ------------------------------------------ const state = { token: sessionStorage.getItem('authToken') || '', csrf: sessionStorage.getItem('csrfToken') || '', salt: sessionStorage.getItem('salt') || '', username: sessionStorage.getItem('username') || '', // KDF iteration count of the currently-logged-in user. Cached so reauth // and on-the-fly verifier computations don't need a /login/challenge // round trip every time. Refreshed from every auth response. kdfIterations: parseInt(sessionStorage.getItem('kdfIterations') || '0') || 0, cryptoKey: null, entries: [], trashed: [], trashedCount: 0, // server-side count, updated separately from state.trashed folders: ['All'], view: 'all', // 'all' | 'favorites' | 'folder:' | 'tag:' | 'trash' search: '', selectedId: null, theme: localStorage.getItem('theme') || 'dark', locked: false, // true after user clicks Lock (token still valid server-side) autoLock: parseInt(localStorage.getItem('autoLockMin') || '5'), askBeforeDelete: localStorage.getItem('askBeforeDelete') !== '0', // default true maskUsernames: localStorage.getItem('maskUsernames') === '1', // default false // Show the raw site/URL under the display name on cards. Default OFF // because the display name is meant to be the user-friendly label; // most users don't want the hostname cluttering the card layout. showSiteOnCards: localStorage.getItem('showSiteOnCards') === '1', // default false compactActions: localStorage.getItem('compactActions') === '1', // default false viewMode: localStorage.getItem('viewMode') || 'cards', // 'cards' | 'list' | 'table' // Sort criterion + direction. Defaults: alphabetical by display name — // the most common pattern for a password manager (predictable lookup). // Other values: 'updated' (last modified), 'created' (creation order), // 'site' (raw site/URL, distinct from name when user set a title). sortBy: localStorage.getItem('sortBy') || 'name', sortDir: localStorage.getItem('sortDir') || 'asc', pageSize: parseInt(localStorage.getItem('pageSize') || '25') || 25, currentPage: 1, checked: new Set(), // entry IDs checked for batch operations hibpEnabled: localStorage.getItem('hibpEnabled') === '1', // default OFF // entry.id → count from HIBP (0 = clean, >0 = pwned, undefined = unchecked) hibpResults: new Map(), quickUnlockEnabled: localStorage.getItem('quickUnlockEnabled') === '1', recoveryConfigured: false, // refreshed by refreshRecoveryStatus on Settings open autofillEnabled: localStorage.getItem('autofillEnabled') !== '0', // default ON // Hotkey combos. Each combo = { ctrl, shift, alt, win, key }. // key is the uppercase character or VK label ('A'..'Z', '0'..'9', // 'F1'..'F12'). Default: Ctrl+Shift+L / Ctrl+Shift+P. Combos are // local-by-default but ALSO synced via settings_json so they travel // with the user (still Windows-only at runtime — non-Windows clients // ignore the value). autofillHotkeyFull: JSON.parse(localStorage.getItem('autofillHotkeyFull') || '{"ctrl":true,"shift":true,"alt":false,"win":false,"key":"L"}'), autofillHotkeyPwd: JSON.parse(localStorage.getItem('autofillHotkeyPwd') || '{"ctrl":true,"shift":true,"alt":false,"win":false,"key":"P"}'), sidebarCollapsed: JSON.parse(localStorage.getItem('sidebarCollapsed') || '{"folders":false,"tags":false,"tools":false}'), }; // ============================================================ // CRYPTO (preserved from legacy app.js — DO NOT TOUCH) // ============================================================ async function deriveKey(pwd, saltHex, iterations) { // Iterations parameter is the per-user value returned by the server in // the /login response (legacy users = 100000, modern = 600000). Falling // back to 100000 keeps backwards compatibility with old code paths that // didn't pass the value, but every new caller should pass it explicitly. iterations = iterations || 100000; const enc = new TextEncoder(); const km = await crypto.subtle.importKey('raw', enc.encode(pwd), 'PBKDF2', false, ['deriveKey']); // saltHex is the same string that PHP/Delphi passed to PBKDF2 — use its bytes. const sb = enc.encode(saltHex); return crypto.subtle.deriveKey( { name: 'PBKDF2', salt: sb, iterations: iterations, hash: 'SHA-256' }, km, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'] ); } // ---- Zero-knowledge auth helpers -------------------------------- // // Single PBKDF2 → both outputs at once: // - cryptoKey: the AES-GCM key used to encrypt entries (= raw PBKDF2 bytes) // - verifier: the same 32 bytes in hex form, sent to the server in place // of the plaintext master password. Server then SHA-256-wraps // it (HASH_ALGO_CURRENT) or compares directly (LEGACY) without // ever seeing the plaintext. // // Doing it together avoids running PBKDF2 twice. computeVerifier() is for // places that only need the hex (re-auth, current-pw verification on change, // etc.) and skips the AES-GCM importKey work. function bytesToHex(arr) { if (arr instanceof ArrayBuffer) arr = new Uint8Array(arr); let hex = ''; for (let i = 0; i < arr.length; i++) hex += arr[i].toString(16).padStart(2, '0'); return hex; } async function deriveKeyAndVerifier(pwd, saltHex, iterations) { iterations = iterations || 100000; const enc = new TextEncoder(); const km = await crypto.subtle.importKey( 'raw', enc.encode(pwd), 'PBKDF2', false, ['deriveBits']); const bits = await crypto.subtle.deriveBits( { name: 'PBKDF2', salt: enc.encode(saltHex), iterations: iterations, hash: 'SHA-256' }, km, 256); // 256 bits = 32 bytes — matches PBKDF2_SHA256_Hex output const keyBytes = new Uint8Array(bits); const cryptoKey = await crypto.subtle.importKey( 'raw', keyBytes, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']); return { cryptoKey, verifier: bytesToHex(keyBytes) }; } async function computeVerifier(pwd, saltHex, iterations) { const r = await deriveKeyAndVerifier(pwd, saltHex, iterations); return r.verifier; } async function encryptPwd(plain) { const iv = crypto.getRandomValues(new Uint8Array(12)); const enc = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, state.cryptoKey, new TextEncoder().encode(plain) ); return { encrypted: btoa(String.fromCharCode(...new Uint8Array(enc))), iv: btoa(String.fromCharCode(...iv)), }; } async function decryptPwd(encB64, ivB64) { try { const enc = Uint8Array.from(atob(encB64), c => c.charCodeAt(0)); const iv = Uint8Array.from(atob(ivB64), c => c.charCodeAt(0)); const dec = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, state.cryptoKey, enc); return new TextDecoder().decode(dec); } catch (e) { return '[ERROR]'; } } async function persistCryptoKey() { const raw = await crypto.subtle.exportKey('raw', state.cryptoKey); sessionStorage.setItem('cryptoKey', btoa(String.fromCharCode(...new Uint8Array(raw)))); } async function restoreCryptoKey() { const saved = sessionStorage.getItem('cryptoKey'); if (!saved) return false; try { const raw = Uint8Array.from(atob(saved), c => c.charCodeAt(0)); state.cryptoKey = await crypto.subtle.importKey( 'raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt'] ); return true; } catch (e) { return false; } } // ============================================================ // HTTP HELPERS // ============================================================ function authHeaders(extra) { const h = Object.assign({ 'Authorization': 'Bearer ' + state.token }, extra || {}); if (state.csrf) h['X-CSRF-Token'] = state.csrf; return h; } async function api(path, opts) { opts = opts || {}; const r = await fetch(API + path, opts); let body = null; try { body = await r.json(); } catch (e) { body = {}; } if (!r.ok) { // Preserve status + body on the Error so callers can distinguish // 429-with-retry_after (account lockout) from a generic auth error. const err = new Error(body.error || ('HTTP ' + r.status)); err.status = r.status; err.body = body || {}; throw err; } return body; } // ============================================================ // TOTP (RFC 6238) — 6-digit time-based codes // ============================================================ // // Implementation is pure crypto.subtle (HMAC-SHA1) + a small base32 // decoder. No external library. The secret is stored encrypted with the // vault's AES-GCM key (same flow as passwords), so the server never sees // the plaintext base32 secret. // Decode an RFC 4648 base32 string (Google Authenticator format) to bytes. // Tolerates lowercase, spaces, and padding. Throws on invalid characters. function base32Decode(s) { const ALPH = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; const clean = String(s).toUpperCase().replace(/[\s=]/g, ''); let bits = 0, buffer = 0; const out = []; for (const ch of clean) { const v = ALPH.indexOf(ch); if (v < 0) throw new Error('Invalid base32 character: ' + ch); buffer = (buffer << 5) | v; bits += 5; if (bits >= 8) { bits -= 8; out.push((buffer >> bits) & 0xFF); } } return new Uint8Array(out); } // Generate a TOTP code per RFC 6238. Returns the 6-digit code as a string // (zero-padded) along with how many seconds remain in the current 30s window. // Throws if the secret can't be decoded. async function generateTOTP(secretBase32, period, digits) { period = period || 30; digits = digits || 6; const keyBytes = base32Decode(secretBase32); // Counter = floor(unix_time / period), encoded as 8-byte big-endian. const nowSec = Math.floor(Date.now() / 1000); let counter = Math.floor(nowSec / period); const counterBytes = new Uint8Array(8); for (let i = 7; i >= 0; i--) { counterBytes[i] = counter & 0xFF; counter = Math.floor(counter / 256); } const cryptoKey = await crypto.subtle.importKey( 'raw', keyBytes, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign'] ); const sigBuf = await crypto.subtle.sign('HMAC', cryptoKey, counterBytes); const sig = new Uint8Array(sigBuf); // Dynamic truncation: low nibble of last byte = offset into HMAC output. const offset = sig[sig.length - 1] & 0x0F; const truncated = ((sig[offset] & 0x7F) << 24) | ((sig[offset + 1] & 0xFF) << 16) | ((sig[offset + 2] & 0xFF) << 8) | ( sig[offset + 3] & 0xFF); const mod = Math.pow(10, digits); const code = String(truncated % mod).padStart(digits, '0'); return { code: code, period: period, secondsLeft: period - (nowSec % period), }; } // Parse a Google Authenticator-style otpauth:// URI and extract the secret. // Example: otpauth://totp/Example:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example // Returns the secret alone (we don't yet honor issuer/algorithm/digits/period // overrides — assume SHA-1 / 6 digits / 30s, which covers ~all real services). function parseOtpAuthUri(raw) { raw = String(raw || '').trim(); if (!raw.toLowerCase().startsWith('otpauth://')) return null; try { const u = new URL(raw); const sec = u.searchParams.get('secret'); return sec ? sec.trim() : null; } catch (e) { return null; } } // Encrypt a TOTP secret with the vault key. Returns { encrypted, iv } in // the same base64 shape as encryptPwd, ready to send to the server. async function encryptTotpSecret(secretBase32) { return await encryptPwd(secretBase32); // same crypto, just different field } async function decryptTotpSecret(encB64, ivB64) { return await decryptPwd(encB64, ivB64); } // Generate a cryptographically random RFC 4648 base32 secret. 20 bytes = // 160 bits → 32 base32 chars, RFC 6238 §5.1 recommended TOTP key size. function randomBase32Secret(numBytes) { numBytes = numBytes || 20; const ALPH = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; const bytes = crypto.getRandomValues(new Uint8Array(numBytes)); let bits = 0, buffer = 0, out = ''; for (let i = 0; i < bytes.length; i++) { buffer = (buffer << 8) | bytes[i]; bits += 8; while (bits >= 5) { bits -= 5; out += ALPH[(buffer >> bits) & 0x1F]; } } if (bits > 0) out += ALPH[(buffer << (5 - bits)) & 0x1F]; return out; } // ---- Standalone TOTP generator modal (paste secret → live code) ----- let totpToolTimer = null; function openTotpTool() { const modal = document.getElementById('totpToolModal'); const input = document.getElementById('totpToolSecret'); const codeEl = document.getElementById('totpToolCode'); const barEl = document.getElementById('totpToolBar'); const errEl = document.getElementById('totpToolError'); modal.classList.remove('is-hidden'); input.value = ''; codeEl.textContent = '— — — — — —'; barEl.style.width = '100%'; errEl.style.display = 'none'; setTimeout(() => input.focus(), 0); async function tick() { let secret = input.value.trim(); if (!secret) { codeEl.textContent = '— — — — — —'; barEl.style.width = '100%'; errEl.style.display = 'none'; return; } if (secret.toLowerCase().startsWith('otpauth://')) { const fromUri = parseOtpAuthUri(secret); if (fromUri) { secret = fromUri; input.value = fromUri; } } try { const t = await generateTOTP(secret); codeEl.textContent = t.code.replace(/(\d{3})(\d{3})/, '$1 $2'); const newPct = (t.secondsLeft / 30) * 100; if (newPct > (tick._lastPct || 0) + 5) { barEl.style.transition = 'none'; barEl.style.width = newPct.toFixed(1) + '%'; void barEl.offsetWidth; barEl.style.transition = ''; } else { barEl.style.width = newPct.toFixed(1) + '%'; } tick._lastPct = newPct; errEl.style.display = 'none'; } catch (e) { codeEl.textContent = '— — — — — —'; barEl.style.width = '0%'; errEl.textContent = 'Invalid base32 secret'; errEl.style.display = ''; } } input.addEventListener('input', tick); if (totpToolTimer) clearInterval(totpToolTimer); totpToolTimer = setInterval(tick, 1000); document.getElementById('totpToolCopy').onclick = async () => { const code = codeEl.textContent.replace(/\s/g, ''); if (!/^\d{6}$/.test(code)) return; if (Bridge.active) Bridge.copySecure(code, 30000); else { try { await navigator.clipboard.writeText(code); } catch (e) {} } toast('TOTP code copied'); }; document.getElementById('totpToolGen').onclick = () => { input.value = randomBase32Secret(20); tick(); toast('Random secret generated'); }; document.getElementById('totpToolCopySecret').onclick = async () => { const s = input.value.trim(); if (!s) return; if (Bridge.active) Bridge.copySecure(s, 30000); else { try { await navigator.clipboard.writeText(s); } catch (e) {} } toast('Secret copied'); }; modal.querySelectorAll('[data-close]').forEach(b => { b.onclick = () => closeTotpTool(); }); } function closeTotpTool() { document.getElementById('totpToolModal').classList.add('is-hidden'); if (totpToolTimer) { clearInterval(totpToolTimer); totpToolTimer = null; } } // ============================================================ // HIBP — Have I Been Pwned breach check (k-anonymity) // ============================================================ // // HIBP's range API exposes pwned password counts without ever seeing the // password (or even its full hash): // 1. Client computes SHA-1 of the password. // 2. Client sends ONLY the first 5 hex chars to api.pwnedpasswords.com/range/XXXXX // 3. Server returns up to ~500 suffixes (35 chars each) with counts. // 4. Client searches the response for its own suffix locally. // // This means the network observer (and HIBP itself) sees only the 5-char // prefix — which matches ~3,000 of the ~half-billion known pwned passwords. // Information leakage is bounded by design. // // Toggle is OFF by default. When enabled, all entries are checked once // after vault load, then individual entries are re-checked when the user // edits the password. Results cached in state.hibpResults keyed by entry id. async function sha1Hex(text) { const buf = new TextEncoder().encode(text); const hashBuf = await crypto.subtle.digest('SHA-1', buf); const bytes = new Uint8Array(hashBuf); let hex = ''; for (const b of bytes) hex += b.toString(16).padStart(2, '0'); return hex.toUpperCase(); } // Returns the breach count (0 if not found, >0 if pwned). Throws on // network failure — caller decides whether to silently skip or alert. async function hibpCheckPassword(plaintext) { if (!plaintext) return 0; const hash = await sha1Hex(plaintext); const prefix = hash.substring(0, 5); const suffix = hash.substring(5); const resp = await fetch('https://api.pwnedpasswords.com/range/' + prefix, { // Padding mitigates side-channel attacks where an observer counts // bytes in the response to narrow down the prefix queried. headers: { 'Add-Padding': 'true' }, }); if (!resp.ok) throw new Error('HIBP HTTP ' + resp.status); const body = await resp.text(); // Body lines: "SUFFIX:COUNT\r\n" — search for our suffix. for (const line of body.split('\n')) { const colonAt = line.indexOf(':'); if (colonAt <= 0) continue; if (line.substring(0, colonAt).trim() === suffix) { return parseInt(line.substring(colonAt + 1).trim(), 10) || 0; } } return 0; } // Batch-check every entry currently in state.entries. Awaits all in // parallel but with a small concurrency cap so we don't hammer HIBP // or trip browser connection limits. Mutates state.hibpResults and // re-renders to show the new badges. async function hibpCheckAllEntries() { if (!state.hibpEnabled || !state.entries.length) return; const CONCURRENCY = 6; const queue = state.entries.slice(); const workers = []; for (let w = 0; w < CONCURRENCY; w++) { workers.push((async () => { while (queue.length) { const entry = queue.shift(); try { const pwd = await decryptPwd(entry.encrypted_password, entry.iv); if (pwd === '[ERROR]') continue; const count = await hibpCheckPassword(pwd); state.hibpResults.set(entry.id, count); } catch (e) { // Network or decrypt failure: skip silently. Will retry // next time the user opens the vault. } } })()); } await Promise.all(workers); render(); } // ============================================================ // KDF MIGRATION (PBKDF2 100k → 600k re-encryption) // ============================================================ // // When the server signals kdfMigration in /login or /reauth, we transparently // re-encrypt every entry with a stronger key (600k PBKDF2 iterations) and // commit the new ciphertext + the new server-side hash in one atomic // /migrate-kdf request. If anything fails, the user stays on the legacy // config and the migration retries at next login. The entries currently // loaded in state.entries are encrypted with the OLD key (state.cryptoKey). // // Threading: runs in the background after enterApp completes. Locking the // vault during migration is safe — we just lose the in-flight transition // and the server's atomic rollback means nothing persisted. let kdfMigrationInProgress = false; async function runKdfMigration(masterPwd, fromIters, toIters) { if (kdfMigrationInProgress) return; // dedupe concurrent calls if (!state.entries || !state.cryptoKey) return; kdfMigrationInProgress = true; try { // Two distinct migration scenarios: // A. fromIters !== toIters: KDF iteration count is changing, so // the AES key is changing. We re-encrypt every entry with the // new key + fresh IVs, swap state.cryptoKey at the end. // B. fromIters === toIters: same KDF, only the server-side hash // format is being upgraded (legacy "pbkdf2" raw → "pbkdf2-sha256" // wrapped). No entry re-encryption needed — just trigger the // endpoint so the server rewrites the user row. const kdfChange = fromIters !== toIters; let newKey, newCiphertexts; if (kdfChange) { newKey = await deriveKey(masterPwd, state.salt, toIters); // Re-encrypt every entry. Each entry gets a fresh random IV // under the new key — never reuse the old IV with the new key // (would be pointless but also a small information leak via IV // reuse patterns). newCiphertexts = []; for (const entry of state.entries) { const plain = await decryptPwd(entry.encrypted_password, entry.iv); if (plain === '[ERROR]') { // One decrypt failure aborts the whole migration — // better to stay on the legacy config than commit // partial state. throw new Error('Could not decrypt entry id=' + entry.id); } const tmpKey = state.cryptoKey; try { state.cryptoKey = newKey; const re = await encryptPwd(plain); newCiphertexts.push({ id: entry.id, encrypted_password: re.encrypted, iv: re.iv, }); } finally { state.cryptoKey = tmpKey; // restore for any concurrent read } } } else { // Hash-format-only upgrade — server still wants an entries // array (it's an idempotent transactional update), just empty. newCiphertexts = []; } // Zero-knowledge: compute BOTH verifiers locally. oldVerifier proves // the user knows the master pw under the current (legacy) iters; // newVerifier is what the server will SHA-256-wrap to be the new // stored hash after migration. Master pw never leaves the browser. const oldVerifier = await computeVerifier(masterPwd, state.salt, fromIters); const newVerifier = await computeVerifier(masterPwd, state.salt, toIters); await api('/migrate-kdf', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ oldVerifier: oldVerifier, newVerifier: newVerifier, entries: newCiphertexts, }), }); if (kdfChange) { // Swap to the new AES key + update cached ciphertexts. state.cryptoKey = newKey; state.kdfIterations = toIters; sessionStorage.setItem('kdfIterations', String(toIters)); await persistCryptoKey(); for (let i = 0; i < state.entries.length; i++) { const nc = newCiphertexts[i]; state.entries[i].encrypted_password = nc.encrypted_password; state.entries[i].iv = nc.iv; } toast('Vault security upgraded (' + fromIters.toLocaleString() + ' → ' + toIters.toLocaleString() + ' KDF iterations)'); } else { // Format-only upgrade is silent — the user didn't perceive a // weakness change, and nothing visible in the UI changed. // (A subtle "Auth format upgraded" toast felt noisy.) } } catch (err) { // Silent retry on next login — the migration is idempotent and // safe to abandon (server rolled back). console.warn('KDF migration aborted:', err); } finally { kdfMigrationInProgress = false; } } // ============================================================ // ACCOUNT LOCKOUT UI // ============================================================ let lockoutTimer = null; // Called when the backend responds with 429 + retry_after on /login or // /reauth. Disables the auth form and displays a live countdown in // #authHint. When the countdown reaches 0, the form is re-enabled. function showLockoutCountdown(seconds) { if (lockoutTimer) { clearInterval(lockoutTimer); lockoutTimer = null; } const hint = $('#authHint'); const btn = $('#loginBtn'); const fmt = (s) => { if (s >= 3600) return Math.ceil(s / 3600) + ' h'; if (s >= 60) return Math.ceil(s / 60) + ' min'; return s + ' s'; }; const tick = () => { if (seconds <= 0) { clearInterval(lockoutTimer); lockoutTimer = null; if (hint) hint.textContent = 'You can try again now.'; if (btn) btn.disabled = false; return; } if (hint) hint.textContent = 'Account locked — try again in ' + fmt(seconds); seconds--; }; if (btn) btn.disabled = true; tick(); // show first frame immediately lockoutTimer = setInterval(tick, 1000); } // ============================================================ // TOAST // ============================================================ function toast(msg, type) { type = type || 'success'; const container = $('#toastContainer'); const t = document.createElement('div'); t.className = 'toast is-' + type; t.textContent = msg; container.appendChild(t); setTimeout(() => t.remove(), 2800); } // ============================================================ // DOM HELPERS // ============================================================ function $(sel, root) { return (root || document).querySelector(sel); } function $$(sel, root) { return Array.from((root || document).querySelectorAll(sel)); } function el(tag, props, ...kids) { const e = document.createElement(tag); if (props) for (const k in props) { if (k === 'class') e.className = props[k]; else if (k === 'on') for (const ev in props.on) e.addEventListener(ev, props.on[ev]); else if (k === 'html') e.innerHTML = props[k]; else if (k in e) e[k] = props[k]; else e.setAttribute(k, props[k]); } for (const k of kids) { if (k == null) continue; e.appendChild(typeof k === 'string' ? document.createTextNode(k) : k); } return e; } function icon(id) { const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); const u = document.createElementNS('http://www.w3.org/2000/svg', 'use'); u.setAttribute('href', '#' + id); s.appendChild(u); return s; } // ============================================================ // AUTH // ============================================================ async function doLogin(e) { e && e.preventDefault(); const u = $('#loginUsername').value.trim(); const p = $('#loginPassword').value; if (!u || !p) return; // If we are in locked mode (token still valid), try fast unlock first. if (state.locked && state.token && state.salt && u === state.username) { $('#loginBtn').disabled = true; const ok = await doUnlock(p); $('#loginBtn').disabled = false; if (ok) return; // unlock failed — fall through to a full login } $('#loginBtn').disabled = true; try { // Zero-knowledge: ask the server for the user's salt + iter count, // compute the verifier locally, send only the verifier. Master pw // never leaves the browser. const ch = await api('/login/challenge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: u }), }); const derived = await deriveKeyAndVerifier(p, ch.salt, ch.kdfIterations); const r = await api('/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: u, verifier: derived.verifier }), }); state.token = r.token; state.csrf = r.csrfToken; state.salt = r.salt; state.username = u; state.kdfIterations = r.kdfIterations || ch.kdfIterations; sessionStorage.setItem('authToken', state.token); sessionStorage.setItem('csrfToken', state.csrf); sessionStorage.setItem('salt', state.salt); sessionStorage.setItem('username', state.username); sessionStorage.setItem('kdfIterations', String(state.kdfIterations)); // Persist via DPAPI when running inside the Delphi host (localStorage // is wiped on each restart because the HTTP port — and therefore the // origin — changes every launch). Fall back to localStorage for the // web/PHP frontend where the origin is stable. const remember = $('#loginRememberUser').checked; if (Bridge.active) { Bridge.setPref('rememberedUsername', remember ? u : ''); } else { if (remember) localStorage.setItem('rememberedUsername', u); else localStorage.removeItem('rememberedUsername'); } // cryptoKey is already derived — no second PBKDF2 pass. state.cryptoKey = derived.cryptoKey; state.justRecovered = false; await persistCryptoKey(); toast('Welcome back, ' + u); await enterApp(); // Trigger KDF migration AFTER entries are loaded into state. if (r.kdfMigration && r.kdfMigration.target) { runKdfMigration(p, r.kdfIterations, r.kdfMigration.target); } } catch (err) { // 429 with retry_after = account lockout. Show countdown in the // auth hint instead of a generic error toast, and keep the login // button disabled until the lockout expires. if (err.status === 429 && err.body && err.body.retry_after) { showLockoutCountdown(err.body.retry_after); return; // do NOT re-enable the button in finally } // Mask "Unknown user" from /login/challenge as a generic credentials // failure — keeps user-existence enumeration consistent with the // existing /login behavior. if (err.status === 404) { toast('Invalid credentials', 'error'); } else { toast(err.message, 'error'); } } finally { // Only re-enable when not in lockout (showLockoutCountdown manages // the button itself for the lockout case). if (!lockoutTimer) $('#loginBtn').disabled = false; } } async function doRegister(e) { e && e.preventDefault(); const u = $('#regUsername').value.trim(); const p = $('#regPassword').value; if (u.length < 3 || p.length < 8) return toast('Min 3 / 8 chars', 'error'); $('#registerBtn').disabled = true; try { // Zero-knowledge register: client generates salt + iters, computes // the verifier locally, sends only the verifier. Master pw never // leaves the browser. const newSalt = randomHexSalt(); const newIters = 600000; const derived = await deriveKeyAndVerifier(p, newSalt, newIters); const r = await api('/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: u, salt: newSalt, kdfIterations: newIters, verifier: derived.verifier, }), }); state.token = r.token; state.csrf = r.csrfToken; state.salt = r.salt || newSalt; state.username = u; state.kdfIterations = r.kdfIterations || newIters; sessionStorage.setItem('authToken', state.token); sessionStorage.setItem('csrfToken', state.csrf); sessionStorage.setItem('salt', state.salt); sessionStorage.setItem('username', state.username); sessionStorage.setItem('kdfIterations', String(state.kdfIterations)); state.cryptoKey = derived.cryptoKey; await persistCryptoKey(); toast('Vault created'); await enterApp(); } catch (err) { toast(err.message, 'error'); } finally { $('#registerBtn').disabled = false; } } async function doLogout() { try { await api('/logout', { method: 'POST', headers: authHeaders() }); } catch (e) {} // Explicit logout → wipe the DPAPI quick-unlock blob too. Logout is the // user saying "I'm done", quite different from a passive lock. if (Bridge.active && localStorage.getItem('quickUnlockEnabled') === '1') { window.location.href = 'cmd://quickunlock/clear'; localStorage.removeItem('quickUnlockEnabled'); state.quickUnlockEnabled = false; } sessionStorage.clear(); state.token = ''; state.csrf = ''; state.salt = ''; state.username = ''; state.kdfIterations = 0; state.cryptoKey = null; state.entries = []; state.trashed = []; state.folders = ['All']; state.locked = false; showAuth(); $('#loginUsername').value = ''; $('#loginPassword').value = ''; $('#loginUsername').readOnly = false; $('#authHint').textContent = ''; } // Lock: do NOT hit /logout — keep server session alive, just drop the in-memory // crypto key. On unlock, /reauth validates the master password and we re-derive. function lockVault() { sessionStorage.removeItem('cryptoKey'); state.cryptoKey = null; state.entries = []; state.trashed = []; state.locked = true; state.justRecovered = false; if (typeof authTickTimer !== 'undefined' && authTickTimer) { clearInterval(authTickTimer); authTickTimer = null; } if (typeof totpToolTimer !== 'undefined' && totpToolTimer) { clearInterval(totpToolTimer); totpToolTimer = null; } showAuth(); // Two UI variants for the auth screen: // - We know the username (user was logged in before lock) → // pre-fill it as readonly so the user only types the master pw. // - We don't know the username (lock fired before any login — e.g. // tray "Lock vault" clicked on a fresh session, or Win+L right // after launch) → show a normal fresh login (editable username). if (state.username) { $('#loginUsername').value = state.username; $('#loginUsername').readOnly = true; $('#authHint').textContent = 'Vault locked — enter master password to unlock'; $('#loginPassword').focus(); } else { $('#loginUsername').value = ''; $('#loginUsername').readOnly = false; $('#authHint').textContent = ''; $('#loginUsername').focus(); } $('#loginPassword').value = ''; } // Unlock flow: validate master pw via /reauth (which uses current session), // then re-derive the crypto key locally without rotating session/csrf. async function doUnlock(p) { try { // Compute the verifier locally with the salt+iters cached at login. // Server compares verifier → never sees the plaintext master pw. const iters = state.kdfIterations || 100000; const derived = await deriveKeyAndVerifier(p, state.salt, iters); const r = await api('/reauth', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ verifier: derived.verifier }), }); // Refresh cached iter count in case the server has migrated us. state.kdfIterations = r.kdfIterations || iters; sessionStorage.setItem('kdfIterations', String(state.kdfIterations)); state.cryptoKey = derived.cryptoKey; await persistCryptoKey(); state.locked = false; $('#loginUsername').readOnly = false; $('#authHint').textContent = ''; toast('Unlocked'); await enterApp(); if (r.kdfMigration && r.kdfMigration.target) { runKdfMigration(p, r.kdfIterations, r.kdfMigration.target); } return true; } catch (err) { // Account lockout (too many wrong master pw attempts): show // countdown in the auth hint, keep the form disabled. if (err.status === 429 && err.body && err.body.retry_after) { showLockoutCountdown(err.body.retry_after); return false; } if (err.message === 'Invalid password') { toast('Wrong master password', 'error'); } else { // session expired — fall back to full login sessionStorage.clear(); state.token = ''; state.csrf = ''; state.salt = ''; state.locked = false; $('#loginUsername').readOnly = false; $('#authHint').textContent = 'Session expired, please sign in again'; toast('Session expired', 'warning'); } return false; } } // ============================================================ // DATA LOADING // ============================================================ async function loadFolders() { try { const r = await api('/folders', { headers: authHeaders() }); // 'All' is always implicit first state.folders = ['All'].concat(r.filter(n => n !== 'All')); } catch (e) { /* ignore */ } } async function loadEntries() { try { const r = await api('/entries', { headers: authHeaders() }); state.entries = Array.isArray(r) ? r : []; } catch (e) { if (e.message === 'Invalid session' || e.message === 'Session expired') { return doLogout(); } toast(e.message, 'error'); } } async function loadTrash() { try { const r = await api('/entries?deleted=1', { headers: authHeaders() }); state.trashed = Array.isArray(r) ? r : []; state.trashedCount = state.trashed.length; } catch (e) { state.trashed = []; } } async function loadEntryCounts() { try { const r = await api('/entries/count', { headers: authHeaders() }); if (r && typeof r.trashed === 'number') state.trashedCount = r.trashed; } catch (e) { /* silent — sidebar count just stays 0 */ } } // ============================================================ // FILTERS / DERIVED // ============================================================ function filteredEntries() { // Trash view shows its own list (loaded separately) let list; if (state.view === 'trash') { list = state.trashed; } else { list = state.entries; if (state.view === 'favorites') list = list.filter(e => e.favorite); else if (state.view.startsWith('folder:')) { const f = state.view.slice(7); // 'folder:All' is now the "(no folder)" pseudo-entry → filter // to entries that are uncategorized (folder is 'All', empty, // or absent). All other folder names are exact-matched. list = list.filter(e => (e.folder || 'All') === f); } else if (state.view.startsWith('tag:')) { const t = state.view.slice(4); list = list.filter(e => parseTags(e.tags).includes(t)); } } if (state.search) { const q = state.search.toLowerCase(); list = list.filter(e => (e.site || '').toLowerCase().includes(q) || (e.title || '').toLowerCase().includes(q) || (e.username || '').toLowerCase().includes(q) || (e.tags || '').toLowerCase().includes(q) ); } // Trash view keeps the deletion order (newest first) — re-sorting feels // wrong for a recoverable archive. All other views honour user choice. if (state.view !== 'trash') { list = sortEntries(list, state.sortBy, state.sortDir); } return list; } function parseTags(s) { if (!s) return []; return s.split(',').map(t => t.trim()).filter(Boolean); } function allTags() { const set = new Set(); state.entries.forEach(e => parseTags(e.tags).forEach(t => set.add(t))); return Array.from(set).sort(); } function viewTitle() { if (state.view === 'all') return 'All items'; if (state.view === 'favorites') return 'Favorites'; if (state.view === 'trash') return 'Trash'; if (state.view === 'authenticator') return 'Authenticator'; if (state.view.startsWith('folder:')) return state.view.slice(7); if (state.view.startsWith('tag:')) return '# ' + state.view.slice(4); return 'Items'; } // ============================================================ // RENDER // ============================================================ function render() { renderSidebar(); renderGrid(); } function renderSidebar() { // counts $('#countAll').textContent = state.entries.length; $('#countFav').textContent = state.entries.filter(e => e.favorite).length; // Prefer the server-side count (always up-to-date even if user never // navigated to Trash this session) ; fall back to local array length. const trashN = state.trashedCount || state.trashed.length || 0; $('#countTrash').textContent = trashN || ''; // Section totals — shown next to the section header so the user still // sees the count when the section is collapsed. const folderCount = state.folders.filter(n => n !== 'All').length; const tagCount = allTags().length; const fc = document.getElementById('countFolders'); const tc = document.getElementById('countTags'); if (fc) fc.textContent = String(folderCount); if (tc) tc.textContent = String(tagCount); // active state for top-level items $$('#appShell .nav-item[data-view]').forEach(n => { n.classList.toggle('is-active', n.dataset.view === state.view); }); // folders — hide the special "All" container (it doubles up with the // "All items" view in the top nav and confuses users with two "All" // entries that count different sets). Entries with folder='All' are // still reachable via "All items". const fList = $('#foldersList'); fList.innerHTML = ''; state.folders.filter(n => n !== 'All').forEach(name => { const count = state.entries.filter(e => e.folder === name).length; const key = 'folder:' + name; const item = el('button', { class: 'nav-item' + (state.view === key ? ' is-active' : ''), 'data-folder': name, on: { click: () => setView(key) }, }); item.appendChild(icon('i-folder')); item.appendChild(el('span', null, name)); item.appendChild(el('span', { class: 'nav-count' }, String(count))); // drag and drop target item.addEventListener('dragover', e => { e.preventDefault(); item.classList.add('drag-over'); }); item.addEventListener('dragleave', () => item.classList.remove('drag-over')); item.addEventListener('drop', async e => { e.preventDefault(); item.classList.remove('drag-over'); const id = e.dataTransfer.getData('text/plain'); if (id) await moveEntryToFolder(parseInt(id), name); }); const delBtn = el('button', { class: 'folder-delete', type: 'button', title: 'Delete folder', on: { click: ev => { ev.stopPropagation(); deleteFolder(name, count); } }, }); delBtn.appendChild(icon('i-x')); item.appendChild(delBtn); fList.appendChild(item); }); // "(no folder)" pseudo-entry: filters to entries with no real folder // (folder is empty or the default "All"). Skipped when there are zero // such entries so the sidebar stays clean for organised users. // No folder icon — visually communicates "this is the absence of a // folder, not a folder". Drag target so users can quickly uncategorise. const uncatCount = state.entries.filter(e => !e.folder || e.folder === 'All').length; if (uncatCount > 0) { const key = 'folder:All'; const item = el('button', { class: 'nav-item is-uncategorized' + (state.view === key ? ' is-active' : ''), 'data-folder': 'All', on: { click: () => setView(key) }, }); // Spacer instead of the folder icon — keeps alignment with real // folders without implying "this is a folder". MUST NOT be a // because .nav-item > span:first-of-type { flex: 1 } would target // the spacer instead of the label and push the count off to the // right. Other nav-items have as their first child, so the // label span naturally wins :first-of-type; we mimic that by // making the spacer a non-span element. item.appendChild(el('i', { class: 'nav-icon-spacer' })); item.appendChild(el('span', null, '(no folder)')); item.appendChild(el('span', { class: 'nav-count' }, String(uncatCount))); item.addEventListener('dragover', e => { e.preventDefault(); item.classList.add('drag-over'); }); item.addEventListener('dragleave', () => item.classList.remove('drag-over')); item.addEventListener('drop', async e => { e.preventDefault(); item.classList.remove('drag-over'); const id = e.dataTransfer.getData('text/plain'); if (id) await moveEntryToFolder(parseInt(id), 'All'); }); fList.appendChild(item); } // tags const tList = $('#tagsList'); tList.innerHTML = ''; const tags = allTags(); if (tags.length === 0) { tList.appendChild(el('div', { class: 'sidebar-section-header', style: 'padding:6px 10px;color:var(--text-faint);font-size:11px;text-transform:none;letter-spacing:0' }, 'No tags yet')); } else { tags.forEach(t => { const key = 'tag:' + t; const count = state.entries.filter(e => parseTags(e.tags).includes(t)).length; const item = el('button', { class: 'nav-item' + (state.view === key ? ' is-active' : ''), on: { click: () => setView(key) }, }); item.appendChild(icon('i-tag')); item.appendChild(el('span', null, t)); item.appendChild(el('span', { class: 'nav-count' }, String(count))); // Drop target: drag a card here to add this tag to that entry item.addEventListener('dragover', e => { e.preventDefault(); item.classList.add('drag-over'); }); item.addEventListener('dragleave', () => item.classList.remove('drag-over')); item.addEventListener('drop', async e => { e.preventDefault(); item.classList.remove('drag-over'); const id = parseInt(e.dataTransfer.getData('text/plain')); if (id) await addTagToEntry(id, t); }); tList.appendChild(item); }); } } async function addTagToEntry(id, tag) { const e = state.entries.find(x => x.id === id); if (!e) return; const tags = parseTags(e.tags); if (tags.includes(tag)) { toast('Already tagged with "' + tag + '"', 'warning'); return; } tags.push(tag); try { await api('/entries/' + id, { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ site: e.site, title: e.title || '', username: e.username, encrypted_password: e.encrypted_password, iv: e.iv, folder: e.folder, tags: tags.join(','), }), }); e.tags = tags.join(','); render(); toast('Tagged "' + tag + '"'); } catch (err) { toast(err.message, 'error'); } } function renderGrid() { $('#contentTitle').textContent = viewTitle(); // Authenticator view: bypass the standard pipeline — render a dedicated // grid of TOTP cards (only entries that have a TOTP secret configured). if (state.view === 'authenticator') { if (authTickTimer) { clearInterval(authTickTimer); authTickTimer = null; } const oldBtn = $('#emptyTrashBtn'); if (oldBtn) oldBtn.remove(); renderBatchBar(); const totpEntries = state.entries.filter(e => e.totp_secret && e.totp_iv); $('#contentMeta').textContent = totpEntries.length + (totpEntries.length === 1 ? ' code' : ' codes'); const grid = $('#entryGrid'); grid.className = 'entry-grid is-auth'; grid.innerHTML = ''; if (totpEntries.length === 0) { $('#emptyState').classList.remove('is-hidden'); const illu = $('#emptyIllustration use'); illu.setAttribute('href', '#i-empty-vault'); $('#emptyTitle').textContent = 'No TOTP codes yet'; $('#emptyMessage').innerHTML = 'Add a TOTP secret to any entry to see its live code here.'; return; } $('#emptyState').classList.add('is-hidden'); renderAuthenticatorGrid(grid, totpEntries); return; } else if (authTickTimer) { clearInterval(authTickTimer); authTickTimer = null; } const list = filteredEntries(); $('#contentMeta').textContent = list.length + (list.length === 1 ? ' item' : ' items'); // Empty trash action button next to title (only in trash view) const oldBtn = $('#emptyTrashBtn'); if (oldBtn) oldBtn.remove(); if (state.view === 'trash' && state.trashed.length > 0) { const btn = el('button', { class: 'btn btn-ghost btn-sm', id: 'emptyTrashBtn', style: 'margin-left:auto', on: { click: emptyTrash }, }, withIcon('i-trash', 'Empty trash')); $('.content-header').appendChild(btn); } // Batch action bar (shown when selection is non-empty) renderBatchBar(); const grid = $('#entryGrid'); grid.className = 'entry-grid' + (state.viewMode === 'list' ? ' is-list' : '') + (state.viewMode === 'table' ? ' is-table' : ''); grid.innerHTML = ''; if (list.length === 0) { showEmptyState(); return; } $('#emptyState').classList.add('is-hidden'); const total = list.length; const totalPages = Math.max(1, Math.ceil(total / state.pageSize)); if (state.currentPage > totalPages) state.currentPage = totalPages; if (state.currentPage < 1) state.currentPage = 1; const start = (state.currentPage - 1) * state.pageSize; const pageList = list.slice(start, start + state.pageSize); if (total > 10) { grid.appendChild(renderPagination(total, totalPages)); } if (state.viewMode === 'table') { grid.appendChild(renderTable(pageList)); } else { pageList.forEach(e => grid.appendChild(renderCard(e))); } } // Authenticator view tick timer — recomputes every code once per second. let authTickTimer = null; function renderAuthenticatorGrid(grid, entries) { // Decrypt all secrets once up-front (slow); render cards immediately // with a placeholder, then patch in the codes as they decrypt. const cards = entries.map(e => { const wrap = el('div', { class: 'auth-card', 'data-id': String(e.id) }); const head = el('div', { class: 'auth-card-head' }); head.appendChild(el('div', { class: 'auth-card-title' }, e.title || e.site || '(no name)')); if (e.username) head.appendChild(el('div', { class: 'auth-card-sub' }, e.username)); wrap.appendChild(head); const codeRow = el('div', { class: 'auth-card-code-row' }); const codeEl = el('div', { class: 'auth-card-code' }, '— — — — — —'); const copyBtn = el('button', { class: 'icon-btn', title: 'Copy code', type: 'button' }); copyBtn.appendChild(icon('i-copy')); copyBtn.addEventListener('click', async ev => { ev.stopPropagation(); const code = codeEl.textContent.replace(/\s/g, ''); if (!/^\d{6}$/.test(code)) return; if (Bridge.active) Bridge.copySecure(code, 30000); else { try { await navigator.clipboard.writeText(code); } catch (_) {} } toast('Code copied'); }); codeRow.appendChild(codeEl); codeRow.appendChild(copyBtn); wrap.appendChild(codeRow); const barWrap = el('div', { class: 'auth-card-bar-wrap' }); const bar = el('div', { class: 'auth-card-bar' }); barWrap.appendChild(bar); wrap.appendChild(barWrap); // Click anywhere on card (outside copy) opens the entry detail. wrap.addEventListener('click', () => openSlideover(e.id)); return { entry: e, wrap, codeEl, bar, secret: null }; }); cards.forEach(c => grid.appendChild(c.wrap)); // Decrypt then start ticking (async () => { for (const c of cards) { try { c.secret = await decryptTotpSecret(c.entry.totp_secret, c.entry.totp_iv); } catch (e) { c.secret = null; } } async function tick() { for (const c of cards) { if (!c.secret) { c.codeEl.textContent = 'error'; continue; } try { const t = await generateTOTP(c.secret); c.codeEl.textContent = t.code.replace(/(\d{3})(\d{3})/, '$1 $2'); const newPct = (t.secondsLeft / 30) * 100; // Detect period reset (countdown wrapped from ~0 back to 30s): // snap the bar instantly to 100% instead of letting the CSS // transition animate the jump backwards, which looks like a // freeze / reverse glide. if (newPct > (c.lastPct || 0) + 5) { c.bar.style.transition = 'none'; c.bar.style.width = newPct.toFixed(1) + '%'; // Force reflow then restore the transition for the smooth // forward countdown. void c.bar.offsetWidth; c.bar.style.transition = ''; } else { c.bar.style.width = newPct.toFixed(1) + '%'; } c.lastPct = newPct; c.bar.classList.toggle('is-warning', t.secondsLeft <= 5); } catch (e) { c.codeEl.textContent = 'error'; } } } await tick(); if (authTickTimer) clearInterval(authTickTimer); authTickTimer = setInterval(tick, 1000); })(); } function showEmptyState() { const illustration = $('#emptyIllustration use'); const title = $('#emptyTitle'); const msg = $('#emptyMessage'); if (state.search) { illustration.setAttribute('href', '#i-empty-search'); title.textContent = 'No matches'; msg.innerHTML = 'Try a different search term, or click + New to add a new entry.'; } else if (state.view === 'trash') { illustration.setAttribute('href', '#i-empty-trash'); title.textContent = 'Trash is empty'; msg.textContent = 'Deleted entries land here. They can be restored at any time.'; } else if (state.view === 'favorites') { illustration.setAttribute('href', '#i-empty-vault'); title.textContent = 'No favorites yet'; msg.innerHTML = 'Click the on any entry to add it to favorites.'; } else if (state.view.startsWith('folder:')) { illustration.setAttribute('href', '#i-empty-vault'); title.textContent = 'Folder is empty'; msg.innerHTML = 'Move entries here by drag & drop, or by setting their folder.'; } else if (state.view.startsWith('tag:')) { illustration.setAttribute('href', '#i-empty-vault'); title.textContent = 'No entries with this tag'; msg.textContent = 'Drop a card on the tag to add this tag to that entry.'; } else { illustration.setAttribute('href', '#i-empty-vault'); title.textContent = 'Your vault is empty'; msg.innerHTML = 'Click + New to add your first password. They\'re encrypted before they leave your machine.'; } $('#emptyState').classList.remove('is-hidden'); } // Skeleton loaders shown during the initial fetch right after login/unlock function showSkeletons(n) { const grid = $('#entryGrid'); grid.innerHTML = ''; $('#emptyState').classList.add('is-hidden'); for (let i = 0; i < n; i++) { const card = el('div', { class: 'skeleton-card' }); const row = el('div', { class: 'skeleton-row' }); row.appendChild(el('div', { class: 'skeleton-circle' })); const col = el('div', { style: 'flex:1' }); col.appendChild(el('div', { class: 'skeleton-line w-60' })); col.appendChild(el('div', { class: 'skeleton-line w-40', style: 'margin-bottom:0' })); row.appendChild(col); card.appendChild(row); card.appendChild(el('div', { class: 'skeleton-line w-80' })); card.appendChild(el('div', { class: 'skeleton-line w-40', style: 'margin-bottom:0' })); grid.appendChild(card); } } function initials(s) { return (s || '?').replace(/[^a-zA-Z0-9]/g, '').slice(0, 2).toUpperCase() || '?'; } // User-facing name for an entry. Falls back to `site` when `title` is empty // (default for legacy entries and any entry the user hasn't customised). // IMPORTANT: do NOT use this for autofill domain matching or search-by-host // — those need the raw site/URL/hostname. function entryDisplayName(e) { if (!e) return ''; const t = (e.title || '').trim(); return t || e.site || ''; } // Display label for a folder value. "All" is the default "uncategorized" // bucket; we relabel it so users don't see two "All" entries in folder // pickers (the top nav "All items" also says "All"). function folderLabel(f) { if (!f || f === 'All') return '(no folder)'; return f; } // Comparator for sorting entries. by ∈ {name, site, updated, created, folder}. // dir ∈ {asc, desc}. Stable sort: ties keep their relative order (Array.sort // is stable per the modern spec). function sortEntries(list, by, dir) { const mul = dir === 'desc' ? -1 : 1; const get = e => { switch (by) { case 'site': return (e.site || '').toLowerCase(); case 'updated': return e.updated_at || ''; case 'created': return e.created_at || ''; case 'folder': return (e.folder || '').toLowerCase(); case 'name': default: return entryDisplayName(e).toLowerCase(); } }; return list.slice().sort((a, b) => { const va = get(a), vb = get(b); if (va < vb) return -1 * mul; if (va > vb) return 1 * mul; return 0; }); } // Compact-action kebab menu shown on each card when state.compactActions is on. function buildKebabMenu(entry) { const wrap = el('div', { class: 'entry-kebab-wrap' }); const btn = el('button', { class: 'entry-kebab', title: 'More actions', on: { click: ev => { ev.stopPropagation(); // Close any other open menu, then toggle this one $$('.entry-kebab-menu.is-open').forEach(m => { if (m !== menu) m.classList.remove('is-open'); }); menu.classList.toggle('is-open'); } }, }); btn.appendChild(icon('i-more')); wrap.appendChild(btn); const menu = el('div', { class: 'entry-kebab-menu' }); const items = [ { lbl: entry.favorite ? 'Unfavorite' : 'Favorite', ic: 'i-star', fn: () => toggleFavorite(entry.id) }, { lbl: 'Copy password', ic: 'i-copy', fn: () => copyPassword(entry) }, { lbl: 'Copy username', ic: 'i-user', fn: () => copyUsername(entry) }, { lbl: 'Edit', ic: 'i-edit', fn: () => openSlideOver(entry.id) }, { lbl: 'Duplicate', ic: 'i-copy', fn: () => duplicateEntry(entry) }, { lbl: 'Move to trash', ic: 'i-trash', fn: () => deleteEntry(entry.id), danger: true }, ]; items.forEach(it => { const mi = el('button', { class: 'kebab-item' + (it.danger ? ' is-danger' : ''), on: { click: ev => { ev.stopPropagation(); menu.classList.remove('is-open'); it.fn(); } }, }); mi.appendChild(icon(it.ic)); mi.appendChild(el('span', null, it.lbl)); menu.appendChild(mi); }); wrap.appendChild(menu); return wrap; } function renderCard(e) { const inTrash = state.view === 'trash'; const checked = state.checked.has(e.id); const card = el('article', { class: 'entry-card' + (state.selectedId === e.id ? ' is-selected' : '') + (checked ? ' is-checked' : ''), 'data-id': e.id, draggable: inTrash ? 'false' : 'true', on: { click: ev => handleCardClick(ev, e, inTrash) }, }); if (!inTrash) { card.addEventListener('dragstart', ev => { ev.dataTransfer.setData('text/plain', String(e.id)); ev.dataTransfer.effectAllowed = 'move'; }); } // head: avatar shows identity (initials). A checkbox overlay LIVES // INSIDE the avatar (absolute inset:0) — it covers the avatar exactly // when visible, no separate footprint that could collide with the // avatar's position. Visible on hover or whenever the entry is // checked. Card click anywhere not on the checkbox opens slideover. const head = el('div', { class: 'entry-head' }); const displayName = entryDisplayName(e); const avatar = el('div', { class: 'entry-avatar', }, initials(displayName)); const checkbox = el('button', { class: 'entry-check' + (checked ? ' is-checked' : ''), type: 'button', title: checked ? 'Deselect' : 'Select', on: { click: ev => { ev.stopPropagation(); toggleChecked(e.id); } }, }); if (checked) checkbox.appendChild(el('span', null, '✓')); avatar.appendChild(checkbox); // NESTED inside avatar — no overlap head.appendChild(avatar); const title = el('div', { class: 'entry-title' }); title.appendChild(el('b', null, displayName)); // If user set a custom title AND it differs from site, optionally show // site as a small subtitle. Hidden by default to keep cards clean — // toggled via Settings > Appearance. if (state.showSiteOnCards && e.title && e.title.trim() && e.title.trim() !== e.site) { title.appendChild(el('span', { class: 'entry-subtitle' }, e.site)); } // Username row with inline copy button (visible on card hover) const userRow = el('small', { class: 'entry-user-row' }); userRow.appendChild(el('span', null, displayUsername(e.username))); if (e.username) { const copyUser = el('button', { class: 'entry-copy-user', title: 'Copy username', on: { click: ev => { ev.stopPropagation(); copyUsername(e); } }, }); copyUser.appendChild(icon('i-copy')); userRow.appendChild(copyUser); } title.appendChild(userRow); head.appendChild(title); if (inTrash) { // In trash: show restore + permanent delete buttons const restore = el('button', { class: 'icon-btn icon-btn-sm', title: 'Restore', on: { click: ev => { ev.stopPropagation(); restoreEntry(e.id); } }, }); restore.appendChild(icon('i-rotate-ccw')); const purge = el('button', { class: 'icon-btn icon-btn-sm', title: 'Delete forever', style: 'color:var(--danger)', on: { click: ev => { ev.stopPropagation(); permanentDelete(e.id); } }, }); purge.appendChild(icon('i-trash')); head.appendChild(restore); head.appendChild(purge); } else if (state.compactActions) { // Compact mode: single kebab menu replaces fav + dup + del head.appendChild(buildKebabMenu(e)); } else { const fav = el('button', { class: 'entry-fav' + (e.favorite ? ' is-on' : ''), title: 'Favorite', on: { click: ev => { ev.stopPropagation(); toggleFavorite(e.id); } }, }); fav.appendChild(icon('i-star')); head.appendChild(fav); const dup = el('button', { class: 'entry-dup', title: 'Duplicate', on: { click: ev => { ev.stopPropagation(); duplicateEntry(e); } }, }); dup.appendChild(icon('i-copy')); head.appendChild(dup); const del = el('button', { class: 'entry-del', title: 'Move to trash', on: { click: ev => { ev.stopPropagation(); deleteEntry(e.id); } }, }); del.appendChild(icon('i-x')); head.appendChild(del); } card.appendChild(head); // password row (placeholder dots, click reveals via slide-over) const pwRow = el('div', { class: 'entry-pw-row' }); pwRow.appendChild(el('span', { class: 'entry-pw', id: 'pw-' + e.id }, '••••••••')); const copyBtn = el('button', { class: 'icon-btn icon-btn-sm', title: 'Copy password', on: { click: ev => { ev.stopPropagation(); copyPassword(e); } }, }); copyBtn.appendChild(icon('i-copy')); pwRow.appendChild(copyBtn); card.appendChild(pwRow); // meta chips: folder + first 2 tags. "All" is the default "uncategorized" // bucket and shouldn't be shown as a chip (visually duplicates "All items"). const meta = el('div', { class: 'entry-meta' }); if (e.folder && e.folder !== 'All') { const chip = el('span', { class: 'entry-chip is-folder' }); chip.appendChild(icon('i-folder')); chip.appendChild(el('span', null, e.folder)); meta.appendChild(chip); } parseTags(e.tags).slice(0, 3).forEach(t => { const chip = el('span', { class: 'entry-chip' }); chip.appendChild(icon('i-tag')); chip.appendChild(el('span', null, t)); meta.appendChild(chip); }); // HIBP pwned badge — only shown when the user enabled HIBP and the // background scan completed with count > 0 for this entry. const pwnedCount = state.hibpResults.get(e.id); if (state.hibpEnabled && pwnedCount && pwnedCount > 0) { const chip = el('span', { class: 'entry-chip is-pwned', title: 'This password appeared in ' + pwnedCount.toLocaleString() + ' known data breaches. Consider changing it.', }); chip.appendChild(icon('i-alert')); chip.appendChild(el('span', null, 'Pwned')); meta.appendChild(chip); } // 2FA indicator — entry has a TOTP secret configured. Server returns // null for both fields when none; truthy = configured (the actual // secret stays encrypted until the user opens the slide-over). if (e.totp_secret && e.totp_iv) { const chip = el('span', { class: 'entry-chip is-2fa', title: 'Two-factor authentication (TOTP) configured', }); chip.appendChild(icon('i-lock')); chip.appendChild(el('span', null, '2FA')); meta.appendChild(chip); } card.appendChild(meta); return card; } // ============================================================ // TABLE VIEW // ============================================================ // Dense, spreadsheet-like layout for users who manage many entries. // Sortable headers — clicking a column header sets state.sortBy/sortDir // (with toggle on the active column) and re-renders. Same source of // truth as the Settings dropdown — both stay in sync. // Columns built dynamically — the Site column tracks the user's // "Show site under display name" preference so card view and table view // stay consistent (default: site hidden, can be re-enabled from Settings). function getTableColumns() { const cols = [ { key: 'check', label: '', sortKey: null }, { key: 'name', label: 'Name', sortKey: 'name' }, ]; if (state.showSiteOnCards) { cols.push({ key: 'site', label: 'Site', sortKey: 'site' }); } cols.push( { key: 'user', label: 'Username', sortKey: null }, // no sort: derived/varies { key: 'folder', label: 'Folder', sortKey: 'folder' }, { key: 'updated', label: 'Updated', sortKey: 'updated' }, { key: 'actions', label: '', sortKey: null }, ); return cols; } function renderPagination(total, totalPages) { const wrap = el('div', { class: 'pagination' }); const start = (state.currentPage - 1) * state.pageSize + 1; const end = Math.min(start + state.pageSize - 1, total); wrap.appendChild(el('span', { class: 'pagination-info' }, start + '–' + end + ' of ' + total)); function pageBtn(label, page, opts) { opts = opts || {}; const b = el('button', { class: 'pagination-btn' + (opts.active ? ' is-active' : '') + (opts.disabled ? ' is-disabled' : ''), type: 'button', on: { click: () => { if (opts.disabled || opts.active) return; state.currentPage = page; render(); } }, }, String(label)); return b; } wrap.appendChild(pageBtn('‹ Prev', state.currentPage - 1, { disabled: state.currentPage <= 1 })); const pages = computePageList(state.currentPage, totalPages); pages.forEach(p => { if (p === '…') wrap.appendChild(el('span', { class: 'pagination-ellipsis' }, '…')); else wrap.appendChild(pageBtn(p, p, { active: p === state.currentPage })); }); wrap.appendChild(pageBtn('Next ›', state.currentPage + 1, { disabled: state.currentPage >= totalPages })); const sizeSel = el('select', { class: 'pagination-size', on: { change: ev => { state.pageSize = parseInt(ev.target.value); state.currentPage = 1; localStorage.setItem('pageSize', String(state.pageSize)); saveServerSettings(); render(); } }, }); [10, 25, 50, 100].forEach(n => { const opt = el('option', { value: String(n) }, String(n) + ' / page'); if (n === state.pageSize) opt.selected = true; sizeSel.appendChild(opt); }); wrap.appendChild(sizeSel); return wrap; } function computePageList(current, total) { if (total <= 7) { const arr = []; for (let i = 1; i <= total; i++) arr.push(i); return arr; } const pages = [1]; if (current > 3) pages.push('…'); const from = Math.max(2, current - 1); const to = Math.min(total - 1, current + 1); for (let i = from; i <= to; i++) pages.push(i); if (current < total - 2) pages.push('…'); pages.push(total); return pages; } function renderTable(list) { const columns = getTableColumns(); const table = el('table', { class: 'entry-table' }); // Header row with click-to-sort const thead = el('thead'); const headerRow = el('tr'); columns.forEach(col => { const th = el('th', { 'data-col': col.key }); if (col.sortKey) { th.classList.add('is-sortable'); const isActive = state.sortBy === col.sortKey; if (isActive) th.classList.add('is-active'); th.appendChild(el('span', null, col.label)); // Arrow indicator only on the active column. if (isActive) { th.appendChild(el('span', { class: 'sort-arrow' }, state.sortDir === 'asc' ? '↑' : '↓')); } th.addEventListener('click', () => { if (state.sortBy === col.sortKey) { // Same column → flip direction state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc'; } else { state.sortBy = col.sortKey; // Sensible default direction per field state.sortDir = (col.sortKey === 'updated' || col.sortKey === 'created') ? 'desc' : 'asc'; } localStorage.setItem('sortBy', state.sortBy); localStorage.setItem('sortDir', state.sortDir); // Keep the Settings dropdown in sync if it's currently displayed. const sel = $('#settingSort'); if (sel) sel.value = state.sortBy + ':' + state.sortDir; render(); saveServerSettings(); }); } else { th.appendChild(el('span', null, col.label)); } headerRow.appendChild(th); }); thead.appendChild(headerRow); table.appendChild(thead); // Body rows const tbody = el('tbody'); list.forEach(e => tbody.appendChild(renderTableRow(e))); table.appendChild(tbody); return table; } function renderTableRow(e) { const checked = state.checked.has(e.id); const inTrash = state.view === 'trash'; const tr = el('tr', { class: 'entry-row' + (state.selectedId === e.id ? ' is-selected' : '') + (checked ? ' is-checked' : ''), 'data-id': String(e.id), on: { click: ev => handleCardClick(ev, e, inTrash) }, }); // Cells are emitted in EXACTLY the same order as getTableColumns() // returns headers, otherwise THs and TDs drift apart and clicks land // on the wrong column. Switch by col.key so add/remove of a column // affects header + body in one place. getTableColumns().forEach(col => { let td; switch (col.key) { case 'check': { td = el('td', { class: 'col-check' }); // Standalone variant — not nested inside an avatar, so it // needs its own explicit dimensions. .entry-check-static // overrides the absolute/inset:0 positioning used in the // card-view (where the box gets its size from its avatar // parent). const checkbox = el('button', { class: 'entry-check entry-check-static' + (checked ? ' is-checked' : ''), type: 'button', title: checked ? 'Deselect' : 'Select', on: { click: ev => { ev.stopPropagation(); toggleChecked(e.id); } }, }); if (checked) checkbox.appendChild(el('span', null, '✓')); td.appendChild(checkbox); break; } case 'name': { td = el('td', { class: 'col-name' }); const avatar = el('span', { class: 'entry-avatar entry-avatar-sm' }, initials(entryDisplayName(e))); td.appendChild(avatar); const nameWrap = el('span', { class: 'cell-name-wrap' }); nameWrap.appendChild(el('b', null, entryDisplayName(e))); if (e.favorite) nameWrap.appendChild(el('span', { class: 'fav-dot', title: 'Favorite' }, '★')); td.appendChild(nameWrap); break; } case 'site': td = el('td', { class: 'col-site' }, e.site || ''); break; case 'user': { td = el('td', { class: 'col-user' }); td.appendChild(el('span', null, displayUsername(e.username))); if (e.username) { const btn = el('button', { class: 'icon-btn icon-btn-sm', title: 'Copy username', on: { click: ev => { ev.stopPropagation(); copyUsername(e); } }, }); btn.appendChild(icon('i-copy')); td.appendChild(btn); } break; } case 'folder': td = el('td', { class: 'col-folder' }, (e.folder && e.folder !== 'All') ? e.folder : ''); break; case 'updated': td = el('td', { class: 'col-updated' }, formatDateShort(e.updated_at)); break; case 'actions': { td = el('td', { class: 'col-actions' }); const pwBtn = el('button', { class: 'icon-btn icon-btn-sm', title: 'Copy password', on: { click: ev => { ev.stopPropagation(); copyPassword(e); } }, }); pwBtn.appendChild(icon('i-copy')); td.appendChild(pwBtn); td.appendChild(buildKebabMenu(e)); break; } } tr.appendChild(td); }); return tr; } // Compact date for the table column. ISO string in → "2026-05-23" out. // Avoids per-locale parsing surprises (server uses ISO already). function formatDateShort(iso) { if (!iso) return ''; return iso.slice(0, 10); } // ============================================================ // MULTI-SELECTION + BATCH ACTIONS // ============================================================ // // Selection patterns (no rubber-band marquee — removed 2026-05-24, value // for a password manager was too low vs the accidental-deselect cost): // - Click the checkbox overlay on a card → toggle just that entry // - Click a card body (when ≥1 is already checked) → toggle (sticky) // - Ctrl+click row/card → toggle (always, no need for sticky mode) // - Shift+click row/card → range select from last anchor // - Ctrl+A (when not typing) → select every visible entry // - Escape (when nothing else to dismiss) → clear selection let selectionAnchor = null; // last single-clicked card, used for shift+click range function toggleChecked(id) { if (state.checked.has(id)) state.checked.delete(id); else state.checked.add(id); renderGrid(); } function handleCardClick(ev, entry, inTrash) { // Ctrl/Cmd+Click: toggle this card in selection if (ev.ctrlKey || ev.metaKey) { toggleChecked(entry.id); selectionAnchor = entry.id; return; } // Shift+Click: select range from anchor to this card if (ev.shiftKey && selectionAnchor !== null) { const list = filteredEntries(); const a = list.findIndex(x => x.id === selectionAnchor); const b = list.findIndex(x => x.id === entry.id); if (a >= 0 && b >= 0) { const lo = Math.min(a, b), hi = Math.max(a, b); for (let i = lo; i <= hi; i++) state.checked.add(list[i].id); renderGrid(); return; } } // If any cards are already checked, a plain click toggles (sticky multi-select) if (state.checked.size > 0) { toggleChecked(entry.id); selectionAnchor = entry.id; return; } // Default: open slide-over (or trash actions) selectionAnchor = entry.id; if (inTrash) openTrashActions(entry.id); else openSlideOver(entry.id); } function clearChecked() { state.checked.clear(); renderGrid(); } async function batchMoveToFolder(folder) { const ids = Array.from(state.checked); if (!ids.length) return; for (const id of ids) { const e = state.entries.find(x => x.id === id); if (!e || e.folder === folder) continue; try { await api('/entries/' + id, { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ site: e.site, title: e.title || '', username: e.username, encrypted_password: e.encrypted_password, iv: e.iv, folder, tags: e.tags || '', }), }); e.folder = folder; } catch (err) { /* ignore individual failures */ } } toast(ids.length + ' moved to ' + folder); clearChecked(); } async function batchAddTag(tag) { tag = (tag || '').trim(); if (!tag) return; const ids = Array.from(state.checked); if (!ids.length) return; for (const id of ids) { const e = state.entries.find(x => x.id === id); if (!e) continue; const tags = parseTags(e.tags); if (tags.includes(tag)) continue; tags.push(tag); try { await api('/entries/' + id, { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ site: e.site, title: e.title || '', username: e.username, encrypted_password: e.encrypted_password, iv: e.iv, folder: e.folder, tags: tags.join(','), }), }); e.tags = tags.join(','); } catch (err) {} } toast('Tagged ' + ids.length + ' as "' + tag + '"'); clearChecked(); } async function batchDelete() { const ids = Array.from(state.checked); if (!ids.length) return; const ok = await confirmDialog({ title: 'Move to trash', message: '' + ids.length + ' entries will be moved to trash.', okText: 'Move to trash', danger: true, }); if (!ok) return; for (const id of ids) { try { await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() }); } catch (err) {} } toast(ids.length + ' moved to trash'); await loadEntries(); await loadTrash(); clearChecked(); } async function batchRestore() { const ids = Array.from(state.checked); if (!ids.length) return; for (const id of ids) { try { await api('/entries/' + id + '/restore', { method: 'POST', headers: authHeaders() }); } catch (err) {} } toast(ids.length + ' restored'); await loadEntries(); await loadTrash(); clearChecked(); } async function batchPermDelete() { const ids = Array.from(state.checked); if (!ids.length) return; const ok = await confirmDialog({ title: 'Delete forever', message: '' + ids.length + ' entries will be permanently deleted. This cannot be undone.', okText: 'Delete forever', danger: true, }); if (!ok) return; for (const id of ids) { try { await api('/entries/' + id + '?permanent=1', { method: 'DELETE', headers: authHeaders() }); } catch (err) {} } toast(ids.length + ' deleted permanently'); await loadTrash(); clearChecked(); } function renderBatchBar() { const existing = $('#batchBar'); if (existing) existing.remove(); if (state.checked.size === 0) return; const inTrash = state.view === 'trash'; const bar = el('div', { class: 'batch-bar', id: 'batchBar' }); bar.appendChild(el('span', { class: 'batch-bar-count' }, state.checked.size + ' selected')); if (inTrash) { // Trash view: Restore | Delete forever bar.appendChild(el('button', { class: 'btn btn-ghost btn-sm', on: { click: batchRestore }, }, withIcon('i-rotate-ccw', 'Restore'))); bar.appendChild(el('button', { class: 'btn btn-ghost btn-sm', style: 'color:var(--danger)', on: { click: batchPermDelete }, }, withIcon('i-trash', 'Delete forever'))); } else { // Normal view: Move to folder | Add tag | Delete (soft) const moveSel = el('select'); moveSel.appendChild(el('option', { value: '' }, 'Move to folder…')); state.folders.forEach(f => moveSel.appendChild(el('option', { value: f }, folderLabel(f)))); moveSel.addEventListener('change', () => { if (moveSel.value) batchMoveToFolder(moveSel.value); }); bar.appendChild(moveSel); bar.appendChild(el('button', { class: 'btn btn-ghost btn-sm', on: { click: async () => { const t = await promptDialog({ title: 'Add tag', message: 'Add a tag to ' + state.checked.size + ' selected entries', placeholder: 'tag name', okText: 'Add', }); if (t) batchAddTag(t); } }, }, withIcon('i-tag', 'Add tag'))); bar.appendChild(el('button', { class: 'btn btn-ghost btn-sm', style: 'color:var(--danger)', on: { click: batchDelete }, }, withIcon('i-trash', 'Delete'))); } bar.appendChild(el('div', { class: 'grow' })); bar.appendChild(el('button', { class: 'btn btn-ghost btn-sm', on: { click: clearChecked }, }, withIcon('i-x', 'Clear'))); const content = $('.content'); content.insertBefore(bar, $('#entryGrid')); } // ============================================================ // SLIDE-OVER // ============================================================ // Edit-in-place state for the slide-over let soState = null; async function openSlideOver(id) { const e = state.entries.find(x => x.id === id); if (!e) return; state.selectedId = id; $('#slideoverTitle').textContent = entryDisplayName(e); const body = $('#slideoverBody'); body.innerHTML = ''; const plain = await decryptPwd(e.encrypted_password, e.iv); // Decrypt TOTP secret if present. Empty string when no TOTP configured // OR when decryption fails (orphan ciphertext, key mismatch, etc.) — the // UI treats both cases as "no 2FA", so the user can re-paste a secret to // recover. let plainTotp = ''; if (e.totp_secret && e.totp_iv) { plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv); if (plainTotp === '[ERROR]') plainTotp = ''; } // Track original values so we can detect "dirty" soState = { id: e.id, original: { site: e.site, title: e.title || '', username: e.username || '', password: plain, folder: e.folder || 'All', tags: parseTags(e.tags).join(','), totp: plainTotp, }, tags: parseTags(e.tags), originalEncrypted: e.encrypted_password, originalIV: e.iv, originalTotpEncrypted: e.totp_secret, originalTotpIV: e.totp_iv, }; body.appendChild(soEditableField('Display name', 'soTitle', e.title || '')); body.appendChild(soEditableField('Site', 'soSite', e.site)); body.appendChild(soEditableField('Username', 'soUsername', e.username || '')); body.appendChild(soPasswordField(plain)); body.appendChild(soTotpField(plainTotp)); body.appendChild(soFolderField(e.folder || 'All')); body.appendChild(soTagsField()); // Action row — Save button is hidden until dirty. No Delete here: // the quick-X on each card handles deletion (avoids duplication). const actions = el('div', { class: 'slideover-actions' }); const saveBtn = el('button', { class: 'btn btn-primary', id: 'soSaveBtn', style: 'display:none', on: { click: soSave }, }, withIcon('i-check', 'Save')); actions.appendChild(saveBtn); body.appendChild(actions); // Wire change detection ['#soTitle', '#soSite', '#soUsername', '#soPassword', '#soFolder'].forEach(sel => { const el = $(sel); if (el) el.addEventListener('input', soDirtyCheck); if (el) el.addEventListener('change', soDirtyCheck); }); $('#slideover').classList.add('is-open'); renderGrid(); } function soEditableField(label, id, value) { const wrap = el('div', { class: 'slideover-field' }); wrap.appendChild(el('div', { class: 'slideover-field-label' }, label)); const input = el('input', { type: 'text', id, value, class: 'so-input' }); input.addEventListener('keydown', soOnEnterSave); wrap.appendChild(input); return wrap; } function soOnEnterSave(ev) { if (ev.key !== 'Enter') return; ev.preventDefault(); soSave(); } function soPasswordField(plain) { const wrap = el('div', { class: 'slideover-field' }); wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Password')); const row = el('div', { class: 'so-pw-row' }); const input = el('input', { type: 'password', id: 'soPassword', value: plain, class: 'so-input', style: 'flex:1;font-family:JetBrains Mono,monospace', }); input.addEventListener('keydown', soOnEnterSave); const toggle = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Show/hide' }); toggle.appendChild(icon('i-eye')); toggle.addEventListener('click', () => { input.type = input.type === 'password' ? 'text' : 'password'; }); const copy = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Copy' }); copy.appendChild(icon('i-copy')); copy.addEventListener('click', () => { if (Bridge.copySecure(input.value, 30000)) { toast('Copied · clears in 30s'); } else { navigator.clipboard.writeText(input.value).then(() => { toast('Copied · clears in 30s'); setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000); }); } }); const gen = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Generate' }); gen.appendChild(icon('i-dice')); gen.addEventListener('click', () => { openGen('slideover'); }); row.appendChild(input); row.appendChild(toggle); row.appendChild(copy); row.appendChild(gen); wrap.appendChild(row); return wrap; } // ---- TOTP field in slide-over (input + live code + countdown) ---- let totpTickTimer = null; function startTotpTick() { if (totpTickTimer) return; // Refresh once per second so the countdown bar moves smoothly and the // code auto-rolls when the 30s window expires. totpTickTimer = setInterval(updateTotpDisplay, 1000); // Defer the first immediate update: callers append the wrap to the DOM // AFTER soTotpField() returns, so a sync $() lookup here would see null // elements and stopTotpTick() would kill the interval we just created. setTimeout(updateTotpDisplay, 0); } function stopTotpTick() { if (totpTickTimer) { clearInterval(totpTickTimer); totpTickTimer = null; } } async function updateTotpDisplay() { const input = $('#soTotpSecret'); const codeEl = $('#soTotpCode'); const barEl = $('#soTotpProgress'); if (!input || !codeEl) { stopTotpTick(); return; } const secret = (input.value || '').trim(); if (!secret) { codeEl.textContent = ''; codeEl.classList.remove('is-invalid'); if (barEl) barEl.style.width = '0%'; return; } try { const t = await generateTOTP(secret); // Format as "123 456" — the standard spacing for authenticator apps codeEl.textContent = t.code.slice(0, 3) + ' ' + t.code.slice(3); codeEl.classList.remove('is-invalid'); if (barEl) { const pct = (t.secondsLeft / t.period) * 100; barEl.style.width = pct + '%'; // Switch to red when < 5s left so the user notices the imminent roll barEl.style.background = t.secondsLeft < 5 ? '#dc2626' : 'var(--accent)'; } } catch (err) { codeEl.textContent = 'invalid secret'; codeEl.classList.add('is-invalid'); if (barEl) barEl.style.width = '0%'; } } function soTotpField(plainSecret) { const wrap = el('div', { class: 'slideover-field' }); wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Two-factor (TOTP)')); const row = el('div', { class: 'so-pw-row' }); const input = el('input', { type: 'password', id: 'soTotpSecret', value: plainSecret || '', class: 'so-input', placeholder: 'Paste base32 secret or otpauth:// URI', on: { keydown: soOnEnterSave }, style: 'flex:1;font-family:JetBrains Mono,monospace', autocomplete: 'off', spellcheck: 'false', }); // If the user pastes a full otpauth:// URI, auto-extract the secret param // so the displayed value is the clean base32 only. Triggers via 'input' // (covers both paste events and manual typing). input.addEventListener('input', () => { const v = input.value.trim(); const fromUri = parseOtpAuthUri(v); if (fromUri) input.value = fromUri; updateTotpDisplay(); soDirtyCheck(); }); const toggle = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Show/hide secret' }); toggle.appendChild(icon('i-eye')); toggle.addEventListener('click', () => { input.type = input.type === 'password' ? 'text' : 'password'; }); const clear = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Remove TOTP' }); clear.appendChild(icon('i-x')); clear.addEventListener('click', () => { input.value = ''; updateTotpDisplay(); soDirtyCheck(); }); row.appendChild(input); row.appendChild(toggle); row.appendChild(clear); wrap.appendChild(row); // Live code panel — shows the current 6-digit code with a copy button // and a progress bar that drains over the 30s window. const panel = el('div', { class: 'totp-panel' }); const codeEl = el('div', { class: 'totp-code', id: 'soTotpCode' }); panel.appendChild(codeEl); const copyBtn = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Copy code' }); copyBtn.appendChild(icon('i-copy')); copyBtn.addEventListener('click', async () => { const secret = (input.value || '').trim(); if (!secret) return; try { const t = await generateTOTP(secret); if (Bridge.copySecure(t.code, 30000)) { toast('Code copied · clears in 30s'); } else { navigator.clipboard.writeText(t.code).then(() => { toast('Code copied · clears in 30s'); setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000); }); } } catch (e) { toast('Invalid TOTP secret', 'error'); } }); panel.appendChild(copyBtn); wrap.appendChild(panel); const barWrap = el('div', { class: 'totp-bar-wrap' }); const bar = el('div', { class: 'totp-bar', id: 'soTotpProgress' }); barWrap.appendChild(bar); wrap.appendChild(barWrap); startTotpTick(); return wrap; } function soFolderField(current) { const wrap = el('div', { class: 'slideover-field' }); wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Folder')); const sel = el('select', { id: 'soFolder', class: 'so-input' }); state.folders.forEach(f => { const opt = el('option', { value: f }, folderLabel(f)); if (f === current) opt.selected = true; sel.appendChild(opt); }); wrap.appendChild(sel); return wrap; } function soTagsField() { const wrap = el('div', { class: 'slideover-field' }); wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Tags')); const cont = el('div', { class: 'chip-input', id: 'soTagsContainer' }); const input = el('input', { type: 'text', id: 'soTagsField', placeholder: 'add a tag…', autocomplete: 'off', }); // Render existing chips inline (renderSoChips() would no-op here because // the container isn't attached to the DOM yet, $ would return null). soState.tags.forEach((t, i) => { const chip = el('span', { class: 'chip' }); chip.appendChild(el('span', null, t)); const x = el('button', { type: 'button', on: { click: ev => { ev.stopPropagation(); soState.tags.splice(i, 1); renderSoChips(); soDirtyCheck(); } }, }); x.appendChild(icon('i-x')); chip.appendChild(x); cont.appendChild(chip); }); cont.appendChild(input); wrap.appendChild(cont); input.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); const v = input.value.trim().replace(/,/g, ''); if (v && !soState.tags.includes(v)) { soState.tags.push(v); renderSoChips(); soDirtyCheck(); } input.value = ''; } else if (e.key === 'Backspace' && !input.value && soState.tags.length) { soState.tags.pop(); renderSoChips(); soDirtyCheck(); } }); return wrap; } function renderSoChips() { const cont = $('#soTagsContainer'); if (!cont) return; const input = $('#soTagsField'); $$('.chip', cont).forEach(c => c.remove()); soState.tags.forEach((t, i) => { const chip = el('span', { class: 'chip' }); chip.appendChild(el('span', null, t)); const x = el('button', { type: 'button', on: { click: ev => { ev.stopPropagation(); soState.tags.splice(i, 1); renderSoChips(); soDirtyCheck(); } }, }); x.appendChild(icon('i-x')); chip.appendChild(x); cont.insertBefore(chip, input); }); } function soDirtyCheck() { if (!soState) return; const cur = { title: ($('#soTitle') || {}).value || '', site: ($('#soSite') || {}).value || '', username: ($('#soUsername') || {}).value || '', password: ($('#soPassword') || {}).value || '', folder: ($('#soFolder') || {}).value || '', totp: ($('#soTotpSecret') || {}).value || '', tags: soState.tags.join(','), }; const dirty = cur.title !== soState.original.title || cur.site !== soState.original.site || cur.username !== soState.original.username || cur.password !== soState.original.password || cur.folder !== soState.original.folder || cur.totp !== soState.original.totp || cur.tags !== soState.original.tags; const btn = $('#soSaveBtn'); if (btn) btn.style.display = dirty ? '' : 'none'; } async function soSave() { if (!soState) return; // Flush any uncommitted tag text — user may have typed in the chip // input without pressing Enter / comma before clicking Save. const pendingTag = (($('#soTagsField') || {}).value || '').trim(); if (pendingTag && !soState.tags.includes(pendingTag)) { soState.tags.push(pendingTag); $('#soTagsField').value = ''; } const title = ($('#soTitle') || {}).value || ''; const site = $('#soSite').value.trim(); const user = $('#soUsername').value.trim(); const pwd = $('#soPassword').value; const fold = $('#soFolder').value; const totp = (($('#soTotpSecret') || {}).value || '').trim(); if (!site || !pwd) return toast('Site and password required', 'error'); // Only re-encrypt if password changed; otherwise reuse stored ciphertext let enc; if (pwd === soState.original.password) { enc = { encrypted: soState.originalEncrypted, iv: soState.originalIV }; } else { enc = await encryptPwd(pwd); } // Same idea for TOTP: re-encrypt only if changed, send empty strings when // cleared so the server stores NULL. let totpEnc = ''; let totpIv = ''; if (totp !== '') { if (totp === soState.original.totp && soState.originalTotpEncrypted) { totpEnc = soState.originalTotpEncrypted; totpIv = soState.originalTotpIV; } else { // Validate the secret can be decoded BEFORE saving — saving a // garbled base32 wouldn't break anything but would surprise the // user when the code panel shows "invalid secret" next time. try { base32Decode(totp); } catch (e) { return toast('Invalid TOTP secret (must be base32)', 'error'); } const tEnc = await encryptTotpSecret(totp); totpEnc = tEnc.encrypted; totpIv = tEnc.iv; } } try { await api('/entries/' + soState.id, { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ site, title: title.trim(), username: user, encrypted_password: enc.encrypted, iv: enc.iv, totp_secret: totpEnc, totp_iv: totpIv, folder: fold, tags: soState.tags.join(','), }), }); toast('Saved'); await loadEntries(); // Re-open with updated data const updated = state.entries.find(x => x.id === soState.id); if (updated) openSlideOver(updated.id); else closeSlideOver(); render(); } catch (err) { toast(err.message, 'error'); } } function withIcon(name, label) { const frag = document.createDocumentFragment(); frag.appendChild(icon(name)); frag.appendChild(document.createTextNode(label)); return frag; } function field(label, value) { const wrap = el('div', { class: 'slideover-field' }); wrap.appendChild(el('div', { class: 'slideover-field-label' }, label)); wrap.appendChild(el('div', { class: 'slideover-field-value' }, value)); return wrap; } function passwordField(plain) { const wrap = el('div', { class: 'slideover-field' }); wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Password')); let revealed = false; const valueRow = el('div', { class: 'slideover-field-value' }); const span = el('span', { style: 'flex:1;font-family:JetBrains Mono,monospace;user-select:none' }, '••••••••'); const toggle = el('button', { class: 'icon-btn icon-btn-sm', title: 'Show/hide' }); toggle.appendChild(icon('i-eye')); toggle.addEventListener('click', () => { revealed = !revealed; span.textContent = revealed ? plain : '••••••••'; span.style.userSelect = revealed ? 'text' : 'none'; }); const copy = el('button', { class: 'icon-btn icon-btn-sm', title: 'Copy' }); copy.appendChild(icon('i-copy')); copy.addEventListener('click', () => { if (Bridge.copySecure(plain, 0)) { toast('Copied'); } else { navigator.clipboard.writeText(plain).then(() => toast('Copied')); } }); valueRow.appendChild(span); valueRow.appendChild(toggle); valueRow.appendChild(copy); wrap.appendChild(valueRow); return wrap; } function closeSlideOver() { stopTotpTick(); $('#slideover').classList.remove('is-open'); state.selectedId = null; renderGrid(); } // ============================================================ // TAG CHIP INPUT // ============================================================ // Local mutable list of tags currently in the entry modal. Synced to the // hidden #entryTags field on every change so saveEntry can read it. let editingTags = []; let chipSuggestEl = null; let chipSuggestActive = -1; function syncTagsHidden() { $('#entryTags').value = editingTags.join(','); } function renderChips() { const container = $('#entryTagsInput'); // Wipe existing chips but keep the input element $$('.chip', container).forEach(c => c.remove()); const input = $('#entryTagsField'); editingTags.forEach((t, i) => { const chip = el('span', { class: 'chip' }); chip.appendChild(el('span', null, t)); const x = el('button', { type: 'button', on: { click: ev => { ev.stopPropagation(); editingTags.splice(i, 1); renderChips(); syncTagsHidden(); } }, }); x.appendChild(icon('i-x')); chip.appendChild(x); container.insertBefore(chip, input); }); syncTagsHidden(); } function setEditingTags(arr) { editingTags = (arr || []).filter(Boolean).map(t => t.trim()).filter(Boolean); renderChips(); } function addTag(raw) { const t = (raw || '').trim().replace(/,/g, ''); if (!t) return; if (editingTags.includes(t)) return; editingTags.push(t); renderChips(); } function closeChipSuggest() { if (chipSuggestEl) { chipSuggestEl.remove(); chipSuggestEl = null; } chipSuggestActive = -1; } function openChipSuggest() { closeChipSuggest(); const field = $('#entryTagsField'); const q = field.value.trim().toLowerCase(); const existing = allTags(); const candidates = existing .filter(t => !editingTags.includes(t)) .filter(t => !q || t.toLowerCase().includes(q)) .slice(0, 8); if (!q && candidates.length === 0) return; chipSuggestEl = el('div', { class: 'chip-suggestions' }); if (candidates.length === 0) { chipSuggestEl.appendChild(el('div', { class: 'chip-suggestion-empty' }, 'Press Enter to create "' + q + '"')); } else { candidates.forEach((t, i) => { const item = el('div', { class: 'chip-suggestion' + (i === 0 ? ' is-active' : ''), on: { mousedown: ev => { ev.preventDefault(); addTag(t); field.value = ''; closeChipSuggest(); } }, }, t); chipSuggestEl.appendChild(item); }); chipSuggestActive = 0; } // Position under the chip input const rect = $('#entryTagsInput').getBoundingClientRect(); chipSuggestEl.style.position = 'fixed'; chipSuggestEl.style.top = (rect.bottom + 4) + 'px'; chipSuggestEl.style.left = rect.left + 'px'; chipSuggestEl.style.width = Math.max(160, rect.width / 2) + 'px'; document.body.appendChild(chipSuggestEl); } function moveChipSuggest(dir) { if (!chipSuggestEl) return; const items = $$('.chip-suggestion', chipSuggestEl); if (items.length === 0) return; items.forEach(it => it.classList.remove('is-active')); chipSuggestActive = (chipSuggestActive + dir + items.length) % items.length; items[chipSuggestActive].classList.add('is-active'); } function selectActiveSuggestion() { if (!chipSuggestEl || chipSuggestActive < 0) return false; const items = $$('.chip-suggestion', chipSuggestEl); if (items[chipSuggestActive]) { addTag(items[chipSuggestActive].textContent); $('#entryTagsField').value = ''; closeChipSuggest(); return true; } return false; } // ============================================================ // ENTRY MODAL (new / edit) // ============================================================ async function openEntryModal(entry) { populateFolderSelect(); if (entry) { $('#entryModalTitle').textContent = 'Edit entry'; $('#entryId').value = entry.id; $('#entryTitle').value = entry.title || ''; $('#entrySite').value = entry.site; $('#entryUsername').value = entry.username || ''; $('#entryFolder').value = entry.folder || 'All'; setEditingTags(parseTags(entry.tags)); $('#entryPassword').value = await decryptPwd(entry.encrypted_password, entry.iv); if ($('#entryPassword').value === '[ERROR]') $('#entryPassword').value = ''; } else { $('#entryModalTitle').textContent = 'New entry'; $('#entryId').value = ''; $('#entryForm').reset(); $('#entryFolder').value = state.view.startsWith('folder:') ? state.view.slice(7) : 'All'; setEditingTags([]); } $('#entryTagsField').value = ''; closeChipSuggest(); updateEntryStrength(); $('#entryModal').classList.remove('is-hidden'); $('#entrySite').focus(); } function closeEntryModal() { $('#entryModal').classList.add('is-hidden'); } function populateFolderSelect() { const sel = $('#entryFolder'); sel.innerHTML = ''; state.folders.forEach(f => sel.appendChild(el('option', { value: f }, folderLabel(f)))); } async function saveEntry(e) { e && e.preventDefault(); // Flush any pending text in the chip input as a final tag const pending = $('#entryTagsField').value.trim(); if (pending) { addTag(pending); $('#entryTagsField').value = ''; } const id = $('#entryId').value; const title = $('#entryTitle').value.trim(); const site = $('#entrySite').value.trim(); const user = $('#entryUsername').value.trim(); const pwd = $('#entryPassword').value; const fold = $('#entryFolder').value; const tags = editingTags.join(','); if (!site || !pwd) return toast('Site and password required', 'error'); const enc = await encryptPwd(pwd); const body = JSON.stringify({ site, title, username: user, encrypted_password: enc.encrypted, iv: enc.iv, folder: fold, tags, }); try { let savedId = id ? parseInt(id) : null; if (id) { await api('/entries/' + id, { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), body, }); toast('Updated'); } else { const r = await api('/entries', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body, }); if (r && typeof r.id === 'number') savedId = r.id; toast('Saved'); } closeEntryModal(); await loadEntries(); render(); if (savedId) flashEntry(savedId); } catch (err) { toast(err.message, 'error'); } } async function restoreEntry(id) { try { await api('/entries/' + id + '/restore', { method: 'POST', headers: authHeaders() }); toast('Restored'); await loadEntries(); await loadTrash(); render(); } catch (err) { toast(err.message, 'error'); } } async function permanentDelete(id) { const ok = await confirmDialog({ title: 'Delete forever', message: 'This entry will be permanently deleted. This cannot be undone.', okText: 'Delete forever', danger: true, }); if (!ok) return; try { await api('/entries/' + id + '?permanent=1', { method: 'DELETE', headers: authHeaders() }); toast('Deleted permanently'); await loadTrash(); render(); } catch (err) { toast(err.message, 'error'); } } async function emptyTrash() { if (!state.trashed.length) return; const ok = await confirmDialog({ title: 'Empty trash', message: '' + state.trashed.length + ' entries will be deleted forever. This cannot be undone.', okText: 'Empty trash', danger: true, }); if (!ok) return; try { await api('/entries/trash/empty', { method: 'DELETE', headers: authHeaders() }); toast('Trash emptied'); await loadTrash(); render(); } catch (err) { toast(err.message, 'error'); } } function openTrashActions(id) { // For trash entries, we don't open the slide-over — actions are inline on the card. // But user can click outside the buttons to no-op. Could open a read-only view later. } async function duplicateEntry(entry) { if (!entry) return; try { const r = await api('/entries', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ site: entry.site, title: entryDisplayName(entry) + ' (copy)', username: entry.username || '', encrypted_password: entry.encrypted_password, iv: entry.iv, folder: entry.folder || 'All', tags: entry.tags || '', totp_secret: entry.totp_secret || '', totp_iv: entry.totp_iv || '', }), }); await loadEntries(); render(); toast('Duplicated: ' + entryDisplayName(entry)); if (r && typeof r.id === 'number') flashEntry(r.id); } catch (err) { toast(err.message || 'Duplicate failed', 'error'); } } function flashEntry(id) { if (!id) return; setTimeout(() => { const el = document.querySelector( '.entry-card[data-id="' + id + '"], ' + '.entry-row[data-id="' + id + '"]' ); if (!el) return; el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.classList.add('is-flash'); setTimeout(() => el.classList.remove('is-flash'), 2600); }, 50); } async function deleteEntry(id) { const e = state.entries.find(x => x.id === id); if (state.askBeforeDelete) { const ok = await confirmDialog({ title: 'Move to trash', message: 'Send ' + (e ? e.site : 'this entry') + ' to trash? You can restore it later.', okText: 'Move to trash', danger: true, }); if (!ok) return; } try { await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() }); toast('Moved to trash'); closeSlideOver(); await loadEntries(); state.trashedCount = (state.trashedCount || 0) + 1; render(); } catch (err) { toast(err.message, 'error'); } } async function toggleFavorite(id) { try { await api('/entries/' + id + '/favorite', { method: 'POST', headers: authHeaders() }); const entry = state.entries.find(e => e.id === id); if (entry) entry.favorite = entry.favorite ? 0 : 1; render(); } catch (e) {} } async function moveEntryToFolder(id, folder) { const e = state.entries.find(x => x.id === id); if (!e || e.folder === folder) return; try { await api('/entries/' + id, { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ site: e.site, title: e.title || '', username: e.username, encrypted_password: e.encrypted_password, iv: e.iv, folder, tags: e.tags || '', }), }); e.folder = folder; render(); toast('Moved to ' + folder); } catch (err) { toast(err.message, 'error'); } } async function copyPassword(entry) { const p = await decryptPwd(entry.encrypted_password, entry.iv); if (p === '[ERROR]') return toast('Cannot decrypt', 'error'); if (Bridge.copySecure(p, 30000)) { toast('Password copied · clears in 30s'); } else { navigator.clipboard.writeText(p).then(() => toast('Password copied · clears in 30s')); setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000); } } function copyUsername(entry) { const u = entry.username || ''; if (!u) return toast('No username to copy', 'warning'); if (Bridge.copySecure(u, 0)) { toast('Username copied'); } else { navigator.clipboard.writeText(u).then(() => toast('Username copied')); } } // Display helper: when `maskUsernames` setting is on, show only the first 2 // chars followed by '***'. Used in cards/list (but slide-over always reveals). function displayUsername(u) { if (!u) return '—'; if (!state.maskUsernames) return u; if (u.length <= 2) return u + '***'; return u.slice(0, 2) + '***'; } // ============================================================ // FOLDERS CRUD // ============================================================ async function deleteFolder(name, entryCount) { const message = entryCount > 0 ? '' + name + ' contains ' + entryCount + ' entries. They will be moved to (no folder). Continue?' : 'Delete folder ' + name + '?'; const ok = await confirmDialog({ title: 'Delete folder', message, okText: 'Delete', danger: true, }); if (!ok) return; try { await api('/folders/' + encodeURIComponent(name), { method: 'DELETE', headers: authHeaders(), }); if (state.view === 'folder:' + name) state.view = 'all'; await loadFolders(); await loadEntries(); render(); toast('Folder deleted'); } catch (e) { toast(e.message || 'Delete failed', 'error'); } } async function addFolder() { const name = await promptDialog({ title: 'New folder', message: 'Folder name', placeholder: 'e.g. Work', okText: 'Create', }); if (!name || !name.trim()) return; const clean = name.trim(); // "All" is reserved as the internal default for "uncategorized" entries // (and would visually duplicate the "All items" top nav). Reject here // rather than letting the user create a confusing duplicate. if (clean.toLowerCase() === 'all') { toast('"All" is reserved — pick another name', 'warning'); return; } try { await api('/folders', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ name: clean }), }); await loadFolders(); render(); toast('Folder created'); } catch (e) { toast(e.message, 'error'); } } // ============================================================ // PASSWORD GENERATOR // ============================================================ let genCurrent = ''; function genPassword() { const len = parseInt($('#genLen').value); $('#genLenLabel').textContent = len; let chars = ''; if ($('#genUpper').checked) chars += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; if ($('#genLower').checked) chars += 'abcdefghijklmnopqrstuvwxyz'; if ($('#genNum').checked) chars += '0123456789'; if ($('#genSym').checked) chars += '!@#$%^&*()_+-=[]{}|;:,.<>?'; if (!chars) { $('#genPreview').textContent = 'Select at least one'; return; } let p = ''; const max = 256 - (256 % chars.length); const buf = new Uint8Array(1); for (let i = 0; i < len; i++) { do { crypto.getRandomValues(buf); } while (buf[0] >= max); p += chars.charAt(buf[0] % chars.length); } genCurrent = p; $('#genPreview').textContent = p; } // genTarget: 'entry' (insert into entry form) or 'standalone' (just copy/dismiss) let genTarget = 'entry'; function openGen(target) { genTarget = target || 'entry'; // Show "Use" when targeting an editable field (entry modal or slide-over) $('#genUse').style.display = (genTarget === 'standalone') ? 'none' : ''; $('#genModal').classList.remove('is-hidden'); genPassword(); } function closeGen() { $('#genModal').classList.add('is-hidden'); } // ============================================================ // PASSWORD STRENGTH // ============================================================ function computeStrength(p) { let s = 0; if (p.length >= 8) s += 25; if (p.length >= 12) s += 15; if (/[A-Z]/.test(p) && /[a-z]/.test(p)) s += 20; if (/\d/.test(p)) s += 15; if (/[^A-Za-z0-9]/.test(p)) s += 25; return Math.min(100, s); } function updateRegStrength() { const p = $('#regPassword').value; $('#regStrengthBar').style.setProperty('--strength', computeStrength(p) + '%'); } function updateEntryStrength() { const p = $('#entryPassword').value; $('#entryStrengthBar').style.setProperty('--strength', computeStrength(p) + '%'); } // ============================================================ // COMMAND PALETTE // ============================================================ function openPalette() { $('#cmdPalette').classList.remove('is-hidden'); $('#cmdInput').value = ''; $('#cmdInput').focus(); renderPaletteResults(''); } function closePalette() { $('#cmdPalette').classList.add('is-hidden'); } function paletteCommands() { return [ { id: 'new', label: 'New entry', icon: 'i-plus', run: () => { closePalette(); openEntryModal(); } }, { id: 'lock', label: 'Lock vault', icon: 'i-lock', run: () => { closePalette(); lockVault(); } }, { id: 'logout', label: 'Sign out', icon: 'i-log-out', run: () => { closePalette(); doLogout(); } }, { id: 'theme', label: 'Toggle theme', icon: 'i-sun', run: () => { closePalette(); toggleTheme(); } }, { id: 'all', label: 'Show all items', icon: 'i-globe', run: () => { closePalette(); setView('all'); } }, { id: 'fav', label: 'Show favorites', icon: 'i-star', run: () => { closePalette(); setView('favorites'); } }, { id: 'trash', label: 'Show trash', icon: 'i-trash', run: () => { closePalette(); setView('trash'); } }, ]; } function renderPaletteResults(q) { const cmds = paletteCommands(); const entries = state.entries.map(e => ({ id: 'entry-' + e.id, label: entryDisplayName(e), // Sub: site (when different from displayName) + username, joined. sub: [ (e.title && e.title.trim() && e.title.trim() !== e.site) ? e.site : '', e.username || '', ].filter(Boolean).join(' · '), icon: 'i-globe', run: () => { closePalette(); openSlideOver(e.id); }, })); const all = cmds.concat(entries); q = (q || '').toLowerCase(); const filtered = q ? all.filter(c => c.label.toLowerCase().includes(q) || (c.sub||'').toLowerCase().includes(q)) : all; const out = $('#cmdResults'); out.innerHTML = ''; filtered.slice(0, 50).forEach((c, i) => { const it = el('div', { class: 'cmd-item' + (i === 0 ? ' is-active' : ''), on: { click: c.run }, }); it.appendChild(icon(c.icon)); it.appendChild(el('span', null, c.label)); if (c.sub) it.appendChild(el('span', { style: 'color:var(--text-faint);font-size:11px;margin-left:auto' }, c.sub)); out.appendChild(it); }); } // ============================================================ // IN-APP CONFIRM / PROMPT (no native alerts) // ============================================================ let confirmResolver = null; function confirmDialog(opts) { // opts: { title, message, okText, cancelText, danger } opts = opts || {}; $('#confirmTitle').textContent = opts.title || 'Confirm'; $('#confirmMessage').innerHTML = opts.message || 'Are you sure?'; $('#confirmOkBtn').lastChild.nodeValue = ' ' + (opts.okText || 'Confirm'); $('#confirmCancelBtn').textContent = opts.cancelText || 'Cancel'; $('#confirmOkBtn').classList.toggle('is-danger', !!opts.danger); $('#confirmInputField').classList.add('is-hidden'); $('#confirmModal').classList.remove('is-hidden'); setTimeout(() => $('#confirmOkBtn').focus(), 50); return new Promise(res => { confirmResolver = res; }); } function promptDialog(opts) { // opts: { title, message, okText, placeholder, value, password } opts = opts || {}; $('#confirmTitle').textContent = opts.title || 'Enter value'; $('#confirmMessage').innerHTML = opts.message || ''; $('#confirmOkBtn').lastChild.nodeValue = ' ' + (opts.okText || 'OK'); $('#confirmCancelBtn').textContent = 'Cancel'; $('#confirmOkBtn').classList.remove('is-danger'); $('#confirmInputField').classList.remove('is-hidden'); $('#confirmInput').value = opts.value || ''; $('#confirmInput').placeholder = opts.placeholder || ''; // Allow password-style masking (used by encrypted import/export). $('#confirmInput').type = opts.password ? 'password' : 'text'; $('#confirmModal').classList.remove('is-hidden'); setTimeout(() => $('#confirmInput').focus(), 50); return new Promise(res => { confirmResolver = res; }); } function closeConfirm(value) { $('#confirmModal').classList.add('is-hidden'); if (confirmResolver) { const cb = confirmResolver; confirmResolver = null; cb(value); } } // ============================================================ // RE-AUTH MODAL + EXPORT // ============================================================ let reauthResolve = null; function askReauth(message) { return new Promise(resolve => { reauthResolve = resolve; $('#reauthMessage').textContent = message || 'This action requires your master password.'; $('#reauthPassword').value = ''; $('#reauthModal').classList.remove('is-hidden'); setTimeout(() => $('#reauthPassword').focus(), 50); }); } function closeReauth(ok) { $('#reauthModal').classList.add('is-hidden'); if (reauthResolve) { const pwd = ok ? $('#reauthPassword').value : null; const cb = reauthResolve; reauthResolve = null; cb(pwd); } } // ============================================================ // QUICK UNLOCK — remember on this device (DPAPI-backed) // ============================================================ // // User-controlled convenience feature. When opted in, the current vault // state (raw AES key + salt + username + session token) is bundled into // a JSON blob and handed to the Delphi side, which DPAPI-encrypts it // (CRYPTPROTECT_CURRENT_USER) and stashes the blob on disk. Subsequent // app starts can recover the entire session without re-entering the // master password. // // Honest trade-off: the encrypted blob is readable by ANY process // running as the same Windows user. The protection is no stronger than // the Windows account itself. Users with Windows Hello / fingerprint / // PIN configured at the OS level get biometric gating transitively // (via login). Without that, this is "remember on trusted device". // // Only available when running inside the Delphi host (Bridge.active); // the PHP standalone has no DPAPI equivalent. // Resolver for the Delphi → JS callback. Delphi side fires // Bridge.onQuickUnlockResult(b64|null) after processing cmd://quickunlock/get. let quickUnlockResolver = null; // Same for the status query. let quickUnlockStatusResolver = null; function bridgeRequestQuickUnlock() { if (!Bridge.active) return Promise.resolve(null); return new Promise(resolve => { quickUnlockResolver = resolve; // Safety: if Delphi never responds, time out after 3 s so the auth // screen doesn't hang. Falls back to master-pw login. setTimeout(() => { if (quickUnlockResolver === resolve) { quickUnlockResolver = null; resolve(null); } }, 3000); window.location.href = 'cmd://quickunlock/get'; }); } let clipboardReadResolver = null; function bridgeReadClipboard() { if (!Bridge.active) return Promise.resolve(''); return new Promise(resolve => { clipboardReadResolver = resolve; setTimeout(() => { if (clipboardReadResolver === resolve) { clipboardReadResolver = null; resolve(''); } }, 1500); window.location.href = 'cmd://clipboard/read'; }); } Bridge.onClipboardRead = function(text) { if (clipboardReadResolver) { clipboardReadResolver(text || ''); clipboardReadResolver = null; } }; function bridgeQuickUnlockStatus() { if (!Bridge.active) return Promise.resolve(false); return new Promise(resolve => { quickUnlockStatusResolver = resolve; setTimeout(() => { if (quickUnlockStatusResolver === resolve) { quickUnlockStatusResolver = null; resolve(false); } }, 2000); window.location.href = 'cmd://quickunlock/status'; }); } // Patch Bridge.onQuickUnlockResult / Status to feed the resolvers above. Bridge.onQuickUnlockResult = function(b64) { if (quickUnlockResolver) { const cb = quickUnlockResolver; quickUnlockResolver = null; cb(b64); } }; Bridge.onQuickUnlockStatus = function(configured) { if (quickUnlockStatusResolver) { const cb = quickUnlockStatusResolver; quickUnlockStatusResolver = null; cb(!!configured); } }; async function enableQuickUnlock() { if (!Bridge.active) return toast('Quick unlock requires the Delphi app', 'warning'); if (!state.cryptoKey) return toast('Unlock the vault first', 'warning'); const masterPwd = await askReauth( 'Confirm your master password to enable Quick unlock on this device.'); if (!masterPwd) return; try { const verifier = await computeVerifier( masterPwd, state.salt, state.kdfIterations || 100000); await api('/reauth', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ verifier: verifier }), }); } catch (err) { return toast('Wrong master password', 'error'); } // Export the raw key + identity. We don't store the session token — // tryQuickUnlock re-logs in with a verifier derived from the key, // which always yields a fresh server session (the stored token would // expire after 24 h and break cold-start restore on a moved exe). const raw = await crypto.subtle.exportKey('raw', state.cryptoKey); const blob = JSON.stringify({ v: 2, username: state.username, salt: state.salt, kdfIterations: state.kdfIterations, key: bytesToBase64(raw), }); const b64 = bytesToBase64(new TextEncoder().encode(blob)); window.location.href = 'cmd://quickunlock/store?data=' + encodeURIComponent(b64); localStorage.setItem('quickUnlockEnabled', '1'); state.quickUnlockEnabled = true; if ($('#quickUnlockStatus')) updateQuickUnlockUI(); toast('Quick unlock enabled'); } async function disableQuickUnlock() { window.location.href = 'cmd://quickunlock/clear'; localStorage.removeItem('quickUnlockEnabled'); state.quickUnlockEnabled = false; if ($('#quickUnlockStatus')) updateQuickUnlockUI(); toast('Quick unlock disabled'); } function updateQuickUnlockUI() { const lbl = $('#quickUnlockStatus'); const btn = $('#quickUnlockToggleBtn'); if (!lbl || !btn) return; if (state.quickUnlockEnabled) { lbl.textContent = 'Enabled on this device.'; btn.textContent = 'Disable'; btn.classList.add('is-danger'); } else { lbl.textContent = 'Disabled.'; btn.textContent = 'Enable on this device'; btn.classList.remove('is-danger'); } } // Try to unlock via DPAPI. Returns true if the vault is now unlocked, // false otherwise (caller falls back to master-pw login). async function tryQuickUnlock() { if (!Bridge.active) return false; const b64 = await bridgeRequestQuickUnlock(); if (!b64) return false; localStorage.setItem('quickUnlockEnabled', '1'); state.quickUnlockEnabled = true; let parsed; try { const jsonStr = new TextDecoder().decode(base64ToBytes(b64)); parsed = JSON.parse(jsonStr); } catch (e) { return false; } if (!parsed || !parsed.key || !parsed.salt || !parsed.username) return false; // Restore identity + crypto key from the blob. state.username = parsed.username; state.salt = parsed.salt; state.kdfIterations = parsed.kdfIterations || 600000; const rawKey = base64ToBytes(parsed.key); try { state.cryptoKey = await crypto.subtle.importKey( 'raw', rawKey, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']); } catch (e) { return false; } // Always request a fresh session token via /login using the key-derived // verifier. The stored token (if any) may have expired or been cleaned // up by the server's session GC, which used to drop the user back to // the login screen on cold start. try { const verifier = bytesToHex(rawKey); const r = await api('/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: state.username, verifier }), }); state.token = r.token; state.csrf = r.csrfToken; if (r.salt) state.salt = r.salt; if (r.kdfIterations) state.kdfIterations = r.kdfIterations; } catch (e) { // Login failed — vault credentials may have changed (master pw // rotation) since Quick Unlock was set up. Force a fresh master-pw // login; the user will need to re-enable Quick Unlock afterwards. return false; } sessionStorage.setItem('username', state.username); sessionStorage.setItem('salt', state.salt); sessionStorage.setItem('kdfIterations', String(state.kdfIterations)); sessionStorage.setItem('authToken', state.token); sessionStorage.setItem('csrfToken', state.csrf); await persistCryptoKey(); state.locked = false; return true; } // ============================================================ // RECOVERY KEY — one-shot emergency access // ============================================================ // // Generated at the user's request from Settings. The plaintext code is // shown exactly once; the server stores only SHA-256(code) for lookup // + an AES-GCM wrap of the current vault key under a KEK derived from // PBKDF2(code, kdfSalt, 600k). // // Recovery flow (master pw forgotten): // 1. Auth screen → "Use recovery code" → enter username + code // 2. Server hashes code, looks up user, verifies match, DELETES the // recovery row (single-use), returns wrapped key + KEK salt + // a fresh session. // 3. Client derives the KEK, unwraps the AES key. // 4. Client immediately forces a master-password change so the // account isn't left with the recovery code's KEK as the only // escape hatch. // Random recovery code: 16 chars in 4 groups of 4. ~96 bits entropy // from a 36-char alphabet (no ambiguous chars: no 0/O/I/l/1) so the // printed form is misreading-resistant. function generateRecoveryCode() { const A = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // 32 chars const bytes = crypto.getRandomValues(new Uint8Array(16)); let s = ''; for (let i = 0; i < 16; i++) { if (i > 0 && i % 4 === 0) s += '-'; s += A[bytes[i] % A.length]; } return s; } async function sha256HexLocal(input) { const buf = new TextEncoder().encode(input); const hashBuf = await crypto.subtle.digest('SHA-256', buf); const bytes = new Uint8Array(hashBuf); let hex = ''; for (const b of bytes) hex += b.toString(16).padStart(2, '0'); return hex; } // Derive a KEK from the recovery code + per-row salt, then wrap the // supplied AES key bytes under it. Returns base64 ciphertext + IV. async function wrapAesKeyForRecovery(aesKeyBytes, recoveryCode, kdfSaltHex) { const saltBytes = new TextEncoder().encode(kdfSaltHex); // match deriveKey's quirk const km = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(recoveryCode), 'PBKDF2', false, ['deriveKey']); const kek = await crypto.subtle.deriveKey( { name: 'PBKDF2', salt: saltBytes, iterations: 600000, hash: 'SHA-256' }, km, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); const iv = crypto.getRandomValues(new Uint8Array(12)); const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, kek, aesKeyBytes); return { wrappedKey: bytesToBase64(ct), wrappedIv: bytesToBase64(iv) }; } async function unwrapAesKeyFromRecovery(wrappedKeyB64, wrappedIvB64, recoveryCode, kdfSaltHex) { const saltBytes = new TextEncoder().encode(kdfSaltHex); const km = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(recoveryCode), 'PBKDF2', false, ['deriveKey']); const kek = await crypto.subtle.deriveKey( { name: 'PBKDF2', salt: saltBytes, iterations: 600000, hash: 'SHA-256' }, km, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); const iv = base64ToBytes(wrappedIvB64); const ct = base64ToBytes(wrappedKeyB64); return await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, kek, ct); // raw bytes } // Generate a new recovery key for the logged-in user. Shows the plaintext // code in a modal that the user must explicitly acknowledge before closing. async function doGenerateRecoveryKey() { if (!state.cryptoKey) { return toast('Vault locked', 'warning'); } const masterPwd = await askReauth( 'Confirm your master password to generate a recovery key.'); if (!masterPwd) return; // Generate the code + a fresh per-row salt for the KEK PBKDF2. Salt is // per-recovery so regenerating doesn't reuse the same KDF parameters. const code = generateRecoveryCode(); const codeHash = await sha256HexLocal(code); const kdfSalt = randomHexSalt(); // Export the current AES key as raw bytes so we can wrap it under // the recovery KEK. The export only works because deriveKey was // called with `extractable=true` — already the case in our code. const rawKey = await crypto.subtle.exportKey('raw', state.cryptoKey); const { wrappedKey, wrappedIv } = await wrapAesKeyForRecovery( rawKey, code, kdfSalt); try { // Send a verifier instead of the master pw — server proves the // user still knows the master pw without ever seeing the plaintext. const verifier = await computeVerifier( masterPwd, state.salt, state.kdfIterations || 100000); await api('/recovery-key/setup', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ verifier: verifier, codeHash: codeHash, kdfSalt: kdfSalt, wrappedKey: wrappedKey, wrappedIv: wrappedIv, }), }); } catch (err) { return toast('Setup failed: ' + (err.message || ''), 'error'); } // Refresh the Settings button label state.recoveryConfigured = true; if ($('#recoveryStatus')) updateRecoveryStatusLabel(); // Show the code ONCE. Use the confirm modal so the user has to // explicitly click "I saved it" before the value vanishes. // // Wire the inline Copy button BEFORE awaiting the dialog: confirmDialog // injects the HTML synchronously, so a 0-ms task fires after the DOM // is in place but before the user can interact. CSP forbids inline // onclick handlers, hence the addEventListener route. setTimeout(() => { const btn = document.getElementById('copyRecoveryCodeBtn'); if (!btn) return; btn.addEventListener('click', () => { // Same path as password copy: secure-clipboard via Delphi // (excluded from Win+V history, auto-cleared after 30s) when // running embedded, navigator.clipboard with manual scrub // otherwise. if (Bridge.copySecure(code, 30000)) { toast('Recovery code copied · clears in 30s'); } else { navigator.clipboard.writeText(code).then(() => { toast('Recovery code copied · clears in 30s'); setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000); }); } }); }, 0); await confirmDialog({ title: 'Your recovery code', message: '

Save this code somewhere safe (password manager, ' + 'safe deposit box, printed copy). It will not be ' + 'shown again.

' + '
' + '' + code + '' + '' + '
' + '

' + 'Using it lets you recover access if you forget your master ' + 'password. The code can be used up to 5 times, and ' + 'is permanently erased as soon as you successfully change ' + 'your master password — so set a new one right after ' + 'recovering.

', okText: 'I saved it', }); toast('Recovery code generated'); } async function doRemoveRecoveryKey() { const ok = await confirmDialog({ title: 'Remove recovery key', message: 'You will lose your ability to recover this account if you ' + 'forget the master password. Continue?', okText: 'Remove', danger: true, }); if (!ok) return; try { await api('/recovery-key', { method: 'DELETE', headers: authHeaders(), }); state.recoveryConfigured = false; if ($('#recoveryStatus')) updateRecoveryStatusLabel(); toast('Recovery key removed'); } catch (err) { toast(err.message, 'error'); } } function updateRecoveryStatusLabel() { const lbl = $('#recoveryStatus'); const setupBtn = $('#recoverySetupBtn'); const removeBtn = $('#recoveryRemoveBtn'); if (!lbl) return; if (state.recoveryConfigured) { const left = state.recoveryRemainingUses; const usesNote = (typeof left === 'number' && left < 5) ? ' (' + left + ' use' + (left === 1 ? '' : 's') + ' left)' : ''; lbl.textContent = 'Recovery key is configured.' + usesNote; if (setupBtn) setupBtn.textContent = 'Regenerate code'; if (removeBtn) removeBtn.style.display = ''; } else { lbl.textContent = 'No recovery key set.'; if (setupBtn) setupBtn.textContent = 'Generate recovery code'; if (removeBtn) removeBtn.style.display = 'none'; } } async function refreshRecoveryStatus() { try { const r = await api('/recovery-key/status', { headers: authHeaders() }); state.recoveryConfigured = !!r.configured; state.recoveryRemainingUses = (typeof r.remaining_uses === 'number') ? r.remaining_uses : 5; updateRecoveryStatusLabel(); } catch (e) { /* ignore */ } } // Recovery redeem flow — called from the auth screen when the user clicks // "Use a recovery code". Prompts for username + code, redeems, unwraps the // vault key, immediately forces a master password change. async function doRecoveryRedeem() { const u = await promptDialog({ title: 'Recover access', message: 'Enter your username — we\'ll ask for the recovery code next.', placeholder: 'Username', okText: 'Continue', }); if (!u) return; const code = await promptDialog({ title: 'Enter recovery code', message: 'Recovery codes look like XXXX-XXXX-XXXX-XXXX. They allow ' + 'up to 5 uses, and are erased when you set a new master ' + 'password — remember to generate a fresh code afterwards.', placeholder: 'XXXX-XXXX-XXXX-XXXX', okText: 'Recover', password: true, }); if (!code) return; let r; try { r = await api('/recovery-key/redeem', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: u.trim(), code: code.trim() }), }); } catch (err) { if (err.status === 429 && err.body && err.body.retry_after) { return showLockoutCountdown(err.body.retry_after); } return toast('Recovery failed: ' + (err.message || 'invalid code'), 'error'); } // Unwrap the vault key with the code the user just typed. let rawKey; try { rawKey = await unwrapAesKeyFromRecovery( r.wrappedKey, r.wrappedIv, code.trim(), r.kdfSalt); } catch (e) { return toast('Could not decrypt vault — wrong code?', 'error'); } // Reconstitute state from the new session. state.token = r.token; state.csrf = r.csrfToken; state.salt = r.salt; state.username = u.trim(); state.kdfIterations = r.kdfIterations || 600000; sessionStorage.setItem('authToken', state.token); sessionStorage.setItem('csrfToken', state.csrf); sessionStorage.setItem('salt', state.salt); sessionStorage.setItem('username', state.username); sessionStorage.setItem('kdfIterations', String(state.kdfIterations)); // Import the raw key bytes as a fresh AES-GCM CryptoKey (extractable // so master-pw change can later re-export and re-wrap as needed). state.cryptoKey = await crypto.subtle.importKey( 'raw', rawKey, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']); await persistCryptoKey(); const remaining = (typeof r.remainingUses === 'number') ? r.remainingUses : 0; if (remaining <= 0) { toast('Last recovery use — set a new master password now or the code is gone forever', 'warning'); } else { toast('Recovery code used. ' + remaining + ' use(s) left before it expires. Change your master password now.', 'warning'); } state.justRecovered = true; await enterApp(); setTimeout(openChangeMasterModal, 300); } // ============================================================ // CHANGE MASTER PASSWORD // ============================================================ // // Two-step flow: // 1. Modal collects current pw + new pw (×2) + validates locally. // 2. Client re-encrypts every entry under the new key (derived from // new pw + freshly-generated salt) and sends the whole payload to // /change-master-password. Server verifies current pw, then commits // user row + all entry ciphertexts in one transaction. // On success: state.salt + state.cryptoKey are swapped, the user stays // logged in (current session preserved), all OTHER sessions invalidated. // 64-char hex salt — matches the format the server expects and the shape // produced by Delphi RandomHex(32). Crypto-secure RNG. function randomHexSalt() { const bytes = crypto.getRandomValues(new Uint8Array(32)); let hex = ''; for (const b of bytes) hex += b.toString(16).padStart(2, '0'); return hex; } function openChangeMasterModal() { $('#cmCurrentPwd').value = ''; $('#cmNewPwd').value = ''; $('#cmConfirmPwd').value = ''; const errEl = $('#cmError'); if (errEl) { errEl.textContent = ''; errEl.style.display = 'none'; } const curInput = $('#cmCurrentPwd'); const curWrap = curInput.closest('.field') || curInput.parentElement; const titleEl = $('#changeMasterTitle'); if (state.justRecovered) { if (curWrap) curWrap.classList.add('is-hidden'); if (curInput) curInput.required = false; if (titleEl) titleEl.textContent = 'Set new master password'; } else { if (curWrap) curWrap.classList.remove('is-hidden'); if (curInput) curInput.required = true; if (titleEl) titleEl.textContent = 'Change master password'; } $('#changeMasterModal').classList.remove('is-hidden'); setTimeout(() => { if (state.justRecovered) $('#cmNewPwd').focus(); else $('#cmCurrentPwd').focus(); }, 50); } function closeChangeMasterModal() { $('#changeMasterModal').classList.add('is-hidden'); } function showCmError(msg) { const el = $('#cmError'); if (!el) return; el.textContent = msg; el.style.display = ''; } async function doChangeMasterPassword() { const recoveryMode = !!state.justRecovered; const curPwd = $('#cmCurrentPwd').value; const newPwd = $('#cmNewPwd').value; const confPwd = $('#cmConfirmPwd').value; if (!recoveryMode && !curPwd) return showCmError('All fields are required'); if (!newPwd || !confPwd) return showCmError('All fields are required'); if (newPwd.length < 8) return showCmError('New password must be at least 8 characters'); if (newPwd !== confPwd) return showCmError('New password and confirmation do not match'); if (!recoveryMode && newPwd === curPwd) return showCmError('New password must differ from the current one'); // Disable the confirm button so a double-click doesn't fire two // re-encryption passes in parallel. const btn = $('#cmConfirmBtn'); if (btn) btn.disabled = true; try { // Step 1: generate the new salt and derive the new AES key + verifier. // Also compute the verifier for the CURRENT pw so the server can // authenticate the change without ever seeing the plaintext. const newSalt = randomHexSalt(); const newDerived = await deriveKeyAndVerifier(newPwd, newSalt, 600000); const newKey = newDerived.cryptoKey; let currentVerifier; if (recoveryMode) { const rawCurrentKey = new Uint8Array(await crypto.subtle.exportKey('raw', state.cryptoKey)); currentVerifier = bytesToHex(rawCurrentKey); } else { currentVerifier = await computeVerifier( curPwd, state.salt, state.kdfIterations || 100000); } // Step 2: re-encrypt every entry's password AND every entry's TOTP // secret (if present) under the new key. The current state.cryptoKey // still decrypts the existing ciphertext. const encrypted = []; for (const e of state.entries) { const plain = await decryptPwd(e.encrypted_password, e.iv); if (plain === '[ERROR]') { throw new Error('Could not decrypt entry id=' + e.id); } // Swap the key around encryptPwd so it picks up the new one. const oldKey = state.cryptoKey; state.cryptoKey = newKey; try { const re = await encryptPwd(plain); let totpEnc = '', totpIv = ''; if (e.totp_secret && e.totp_iv) { state.cryptoKey = oldKey; const plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv); state.cryptoKey = newKey; if (plainTotp !== '[ERROR]') { const t = await encryptPwd(plainTotp); totpEnc = t.encrypted; totpIv = t.iv; } } encrypted.push({ id: e.id, encrypted_password: re.encrypted, iv: re.iv, totp_secret: totpEnc, totp_iv: totpIv, }); } finally { state.cryptoKey = oldKey; // restore until server confirms } } // Step 3: send the atomic request. Server verifies the current pw, // updates the user row, swaps every entry's ciphertext, returns // the new salt + iter count. const r = await api('/change-master-password', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ currentVerifier: currentVerifier, newVerifier: newDerived.verifier, newSalt: newSalt, entries: encrypted, }), }); // Step 4: server committed → switch the in-memory key & salt, refresh // the cached ciphertexts, persist for F5 survival. state.salt = r.salt || newSalt; state.kdfIterations = r.kdfIterations || 600000; state.cryptoKey = newKey; await persistCryptoKey(); sessionStorage.setItem('salt', state.salt); sessionStorage.setItem('kdfIterations', String(state.kdfIterations)); for (let i = 0; i < state.entries.length; i++) { const nc = encrypted[i]; state.entries[i].encrypted_password = nc.encrypted_password; state.entries[i].iv = nc.iv; state.entries[i].totp_secret = nc.totp_secret || null; state.entries[i].totp_iv = nc.totp_iv || null; } // The DPAPI quick-unlock blob (if any) still holds the OLD AES key // bundle, which would unlock to entries encrypted with the new key // → unreadable. Clear it; user can re-enable from Settings. if (Bridge.active && localStorage.getItem('quickUnlockEnabled') === '1') { window.location.href = 'cmd://quickunlock/clear'; localStorage.removeItem('quickUnlockEnabled'); state.quickUnlockEnabled = false; } state.justRecovered = false; closeChangeMasterModal(); toast(recoveryMode ? 'New master password set' : 'Master password changed · other sessions signed out'); } catch (err) { if (err.status === 401) { showCmError('Current password is incorrect'); } else if (err.status === 429 && err.body && err.body.retry_after) { showCmError('Account locked, try again in ' + Math.ceil(err.body.retry_after / 60) + ' min'); } else { showCmError('Failed: ' + (err.message || 'unknown error')); } } finally { if (btn) btn.disabled = false; } } // ============================================================ // ENCRYPTED EXPORT CONTAINER // ============================================================ // // File format (JSON): // { // "format": "pm-encrypted-export-v1", // "kdf": "pbkdf2-sha256", // "kdf_iterations": 600000, // "kdf_salt": "", // "iv": "", // "ciphertext":"", // "created_at": "" // } // payload = same shape produced by the plaintext exporter (entries array). // // The encryption password is INDEPENDENT of the master password — the // user picks it at export time and provides it again at import time. // Decoupling means a master-password change doesn't brick old backups, // and the backup can be shared without revealing the master pw. function bytesToBase64(arr) { if (arr instanceof ArrayBuffer) arr = new Uint8Array(arr); let s = ''; for (let i = 0; i < arr.length; i++) s += String.fromCharCode(arr[i]); return btoa(s); } function base64ToBytes(b64) { return Uint8Array.from(atob(b64), c => c.charCodeAt(0)); } // Derive an AES-GCM key from a user-chosen export password + random salt. // Uses the same 600k iteration PBKDF2 as the rest of the app. async function deriveExportKey(password, saltBytes, iterations) { const km = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey']); return crypto.subtle.deriveKey( { name: 'PBKDF2', salt: saltBytes, iterations: iterations, hash: 'SHA-256' }, km, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); } async function encryptExportPayload(payloadObj, exportPwd) { const plaintext = new TextEncoder().encode(JSON.stringify(payloadObj)); const salt = crypto.getRandomValues(new Uint8Array(32)); const iv = crypto.getRandomValues(new Uint8Array(12)); const key = await deriveExportKey(exportPwd, salt, 600000); const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext); return { format: 'pm-encrypted-export-v1', kdf: 'pbkdf2-sha256', kdf_iterations: 600000, kdf_salt: bytesToBase64(salt), iv: bytesToBase64(iv), ciphertext: bytesToBase64(ct), created_at: new Date().toISOString(), }; } async function decryptExportContainer(container, exportPwd) { const salt = base64ToBytes(container.kdf_salt); const iv = base64ToBytes(container.iv); const ct = base64ToBytes(container.ciphertext); const key = await deriveExportKey(exportPwd, salt, container.kdf_iterations || 600000); const plainBuf = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct); return JSON.parse(new TextDecoder().decode(plainBuf)); } // ============================================================ // IMPORT — JSON (native round-trip) + CSV (universal) // ============================================================ // // Two supported input formats: // 1. Native JSON: the same shape produced by doExport() above // { version, exported_at, username, entries: [ // { site, username, password, folder, tags, favorite, ... } // ]} // 2. CSV: with a header row. Column names are mapped heuristically so // exports from Bitwarden / KeePass / Chrome / 1Password generally // "just work" without manual column mapping. // // Each parsed entry is encrypted client-side with the vault key (same // flow as a single-entry add), then sent to /entries/bulk-import as one // transactional batch. // Minimal RFC 4180-ish CSV parser. Handles quoted fields, escaped quotes // (""), commas inside quotes, and CRLF line endings. Returns an array of // arrays (rows × columns). No streaming — fine for the ~MB-scale imports // a password manager realistically deals with. function parseCSV(text) { const rows = []; let row = [], field = '', inQuotes = false; for (let i = 0; i < text.length; i++) { const c = text[i]; if (inQuotes) { if (c === '"') { if (text[i + 1] === '"') { field += '"'; i++; } // escaped "" else inQuotes = false; } else field += c; } else { if (c === '"') inQuotes = true; else if (c === ',') { row.push(field); field = ''; } else if (c === '\n' || c === '\r') { if (c === '\r' && text[i + 1] === '\n') i++; // CRLF row.push(field); field = ''; if (row.length > 1 || (row.length === 1 && row[0] !== '')) rows.push(row); row = []; } else field += c; } } // Flush trailing field/row (file without final newline) if (field !== '' || row.length > 0) { row.push(field); rows.push(row); } return rows; } // Header heuristics: pick the first matching column name (case-insensitive, // underscore/space-tolerant). Returns null if no candidate header matches. function findColumn(headers, candidates) { const norm = s => String(s || '').toLowerCase().replace(/[\s_-]+/g, ''); const cand = candidates.map(norm); for (let i = 0; i < headers.length; i++) { if (cand.indexOf(norm(headers[i])) >= 0) return i; } return null; } // Parse a CSV text into an array of plaintext entries // ({ site, username, password, folder, tags, totp_secret }). Returns // { entries, skipped, columns } so the preview can show what was matched. function parseEntriesFromCSV(text) { const rows = parseCSV(text); if (rows.length < 2) { throw new Error('CSV needs a header row and at least one data row'); } const headers = rows[0]; // Candidate names per format observed in real exports: // Bitwarden CSV : folder, name, login_uri, login_username, login_password, login_totp, notes // KeePass CSV : Title, URL, Username, Password, Group, Notes // Chrome/Edge : name, url, username, password // 1Password CSV : Title, URL, Username, Password, Notes // findColumn normalizes (lowercase, strip _ and -) so 'login_uri' and // 'loginuri' both match the same candidate. const colSite = findColumn(headers, ['name', 'title', 'url', 'site', 'website', 'login_uri', 'login_url', 'web_site']); const colUser = findColumn(headers, ['login_username', 'username', 'user', 'login', 'email', 'user_name']); const colPwd = findColumn(headers, ['login_password', 'password', 'pass', 'pwd']); const colFolder = findColumn(headers, ['folder', 'group', 'category', 'path', 'collection']); const colTags = findColumn(headers, ['tags', 'labels']); const colNotes = findColumn(headers, ['notes', 'note', 'comment', 'comments']); const colTotp = findColumn(headers, ['login_totp', 'totp', 'totp_secret', 'otp', 'otpauth', 'authenticator', 'two_factor', 'twofa']); if (colSite === null && colUser === null) throw new Error('No recognizable site/url or username column in CSV header'); if (colPwd === null) throw new Error('No recognizable password column in CSV header'); const entries = []; let skipped = 0; for (let i = 1; i < rows.length; i++) { const r = rows[i]; const site = (colSite !== null ? r[colSite] : '').trim() || (colUser !== null ? r[colUser] : '').trim(); const pwd = (colPwd !== null ? r[colPwd] : ''); if (!site || !pwd) { skipped++; continue; } // Tags: combine the tags column and any free-form notes into a // comma-separated string. Notes often contain useful metadata we // don't want to drop on the floor. let tagsArr = []; if (colTags !== null) { String(r[colTags] || '').split(/[,;]/).forEach(t => { t = t.trim(); if (t) tagsArr.push(t); }); } if (colNotes !== null) { const n = String(r[colNotes] || '').trim(); if (n && n.length < 80) tagsArr.push(n); // long notes become noise as tags } // TOTP: support raw base32 OR full otpauth:// URI in the cell. let totp = ''; if (colTotp !== null) { const raw = String(r[colTotp] || '').trim(); totp = parseOtpAuthUri(raw) || raw; } entries.push({ site: site, username: (colUser !== null ? r[colUser] : '').trim(), password: pwd, folder: (colFolder !== null ? r[colFolder] : '').trim() || 'All', tags: tagsArr.join(','), totp_secret: totp, }); } return { entries, skipped, columns: { site: colSite, username: colUser, password: colPwd, folder: colFolder, tags: colTags, notes: colNotes, totp: colTotp, } }; } // Parse a native JSON export. Forgiving: accepts both our own format and // a flat array of entry objects. function parseEntriesFromJSON(text) { let data; try { data = JSON.parse(text); } catch (e) { throw new Error('Invalid JSON: ' + e.message); } const raw = Array.isArray(data) ? data : (data.entries || []); if (!Array.isArray(raw) || raw.length === 0) throw new Error('No entries in JSON file'); const entries = []; let skipped = 0; for (const e of raw) { if (!e || typeof e !== 'object') { skipped++; continue; } const site = String(e.site || e.url || e.name || '').trim(); const pwd = String(e.password || ''); if (!site || !pwd) { skipped++; continue; } const tagsVal = e.tags; const tagsStr = Array.isArray(tagsVal) ? tagsVal.join(',') : String(tagsVal || ''); entries.push({ site: site, username: String(e.username || e.user || e.login || '').trim(), password: pwd, folder: String(e.folder || e.group || 'All').trim() || 'All', tags: tagsStr, totp_secret: String(e.totp || e.totp_secret || e.otpauth || '').trim(), }); } return { entries, skipped, columns: null }; // JSON: no column report } // Encrypt one parsed entry (plaintext password + optional TOTP) into the // shape the bulk-import endpoint expects. Reuses encryptPwd which already // generates a fresh IV per call. async function encryptImportEntry(plain) { const pw = await encryptPwd(plain.password); let totpEnc = '', totpIv = ''; if (plain.totp_secret) { try { base32Decode(plain.totp_secret); // validate before encrypting const t = await encryptPwd(plain.totp_secret); totpEnc = t.encrypted; totpIv = t.iv; } catch (e) { // Bad TOTP secret in source file — keep the entry but drop the // 2FA silently. The user can fix it later via the slide-over. } } return { site: plain.site, title: plain.title || '', username: plain.username || '', encrypted_password: pw.encrypted, iv: pw.iv, folder: plain.folder || 'All', tags: plain.tags || '', totp_secret: totpEnc, totp_iv: totpIv, }; } // Open a hidden file picker, route the result through the right parser, // show a preview confirmation, then bulk-encrypt + POST. async function doImport() { const fileInput = el('input', { type: 'file', accept: '.json,.csv,application/json,text/csv', style: 'display:none', }); document.body.appendChild(fileInput); fileInput.addEventListener('change', async () => { const file = fileInput.files && fileInput.files[0]; fileInput.remove(); if (!file) return; let text; try { text = await file.text(); } catch (e) { return toast('Cannot read file: ' + e.message, 'error'); } const isJSON = /\.json$/i.test(file.name) || text.trim().startsWith('{') || text.trim().startsWith('['); // If the JSON is an encrypted-export container, prompt for the // backup password and decrypt before handing the plaintext payload // to the regular JSON parser. if (isJSON) { let raw; try { raw = JSON.parse(text); } catch (e) { raw = null; } if (raw && raw.format === 'pm-encrypted-export-v1') { const pw = await promptDialog({ title: 'Encrypted backup', message: 'This backup is encrypted. Enter the password ' + 'you set when you exported it.', placeholder: 'Backup encryption password', okText: 'Decrypt', password: true, }); if (!pw) return; try { const payload = await decryptExportContainer(raw, pw); // Hand the decrypted payload back to parseEntriesFromJSON // via JSON.stringify — keeps the parser code path single. text = JSON.stringify(payload); } catch (e) { return toast('Decryption failed — wrong password or corrupted file', 'error'); } } } let parsed; try { parsed = isJSON ? parseEntriesFromJSON(text) : parseEntriesFromCSV(text); } catch (e) { return toast('Parse error: ' + e.message, 'error'); } if (parsed.entries.length === 0) { return toast('No valid entries found in file', 'warning'); } // Build the preview message (innerHTML target → escape user data) const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); const parts = []; parts.push('' + parsed.entries.length + ' entries detected in ' + esc(file.name) + ''); if (parsed.skipped > 0) parts.push('' + parsed.skipped + ' rows skipped (missing site or password)'); const sample = parsed.entries.slice(0, 3).map(e => '• ' + esc(e.site || '?') + (e.username ? ' (' + esc(e.username) + ')' : '') ).join('
'); parts.push('
' + sample + (parsed.entries.length > 3 ? '
…' : '') + '
'); parts.push('
Import now? This adds the entries to your existing vault.
'); const confirmed = await confirmDialog({ title: 'Import vault', message: parts.join('
'), okText: 'Import', }); if (!confirmed) return; // Encrypt all entries client-side, then POST as one transaction. toast('Encrypting ' + parsed.entries.length + ' entries…'); const encrypted = []; for (const e of parsed.entries) { encrypted.push(await encryptImportEntry(e)); } try { const r = await api('/entries/bulk-import', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ entries: encrypted }), }); toast('Imported ' + r.imported + ' entries'); await loadEntries(); render(); if (state.hibpEnabled) hibpCheckAllEntries(); // scan the new entries too } catch (err) { toast('Import failed: ' + err.message, 'error'); } }); fileInput.click(); } async function doExport() { // Step 1: reauth — verifies the human in front of the screen is the // vault owner before we hand them every plaintext password. Defense // against a stranger reaching the open laptop and exfiltrating data. const masterPwd = await askReauth( 'Enter your master password to start an encrypted export.'); if (!masterPwd) return; try { const verifier = await computeVerifier( masterPwd, state.salt, state.kdfIterations || 100000); await api('/reauth', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ verifier: verifier }), }); } catch (err) { // 429 (account lockout) is possible here too — propagate as a clear // message rather than the generic "wrong master password" toast. if (err.status === 429 && err.body && err.body.retry_after) { return toast('Account locked. Try again in ' + Math.ceil(err.body.retry_after / 60) + ' min', 'warning'); } return toast('Wrong master password', 'error'); } // Step 2: ask for an INDEPENDENT export password. Decoupled from the // master pw so a master-pw change later doesn't invalidate the backup, // and so the backup can be shared without revealing the master pw. const exportPwd = await promptDialog({ title: 'Encrypted export', message: 'Choose a password to encrypt the backup file.
' + '' + 'You will need this password to restore the file. ' + 'It is independent of your master password.', placeholder: 'Backup encryption password', okText: 'Export', password: true, }); if (!exportPwd) return; if (exportPwd.length < 6) { return toast('Use at least 6 characters', 'warning'); } // Step 3: assemble the plaintext payload (same shape as the legacy // plaintext exporter — round-trips with the existing JSON importer // after decryption). const payload = { version: 1, exported_at: new Date().toISOString(), username: state.username, entries: [], }; for (const e of state.entries) { const plain = await decryptPwd(e.encrypted_password, e.iv); let plainTotp = ''; if (e.totp_secret && e.totp_iv) { plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv); if (plainTotp === '[ERROR]') plainTotp = ''; } payload.entries.push({ site: e.site, title: e.title || '', username: e.username, password: plain, folder: e.folder, tags: parseTags(e.tags), favorite: !!e.favorite, totp_secret: plainTotp, created_at: e.created_at, updated_at: e.updated_at, }); } // Step 4: encrypt + download const container = await encryptExportPayload(payload, exportPwd); const blob = new Blob([JSON.stringify(container, null, 2)], { type: 'application/json', }); const url = URL.createObjectURL(blob); const a = el('a', { href: url, download: 'vault-export-' + new Date().toISOString().slice(0, 10) + '.json', }); document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100); toast(payload.entries.length + ' entries exported (encrypted)'); } // ============================================================ // AUTOFILL (Ctrl+Shift+L global hotkey) // ============================================================ // Win32 modifier flags for RegisterHotKey. const WIN32_MOD = { alt: 0x0001, ctrl: 0x0002, shift: 0x0004, win: 0x0008 }; // Format a combo for human display: "Ctrl+Shift+L". function autofillComboLabel(c) { if (!c || !c.key) return '— not set —'; const parts = []; if (c.ctrl) parts.push('Ctrl'); if (c.alt) parts.push('Alt'); if (c.shift) parts.push('Shift'); if (c.win) parts.push('Win'); parts.push(c.key); return parts.join('+'); } // Convert a combo to the Win32 (mods bitmask, virtual-key code) pair that // Delphi's RegisterHotKey takes. key='A'..'Z'/'0'..'9' → ASCII code; // 'F1'..'F12' → 0x70..0x7B. function autofillComboToWin32(c) { let mods = 0; if (c.ctrl) mods |= WIN32_MOD.ctrl; if (c.alt) mods |= WIN32_MOD.alt; if (c.shift) mods |= WIN32_MOD.shift; if (c.win) mods |= WIN32_MOD.win; let vk = 0; const k = (c.key || '').toUpperCase(); if (/^F([1-9]|1[0-2])$/.test(k)) vk = 0x70 + parseInt(k.slice(1)) - 1; else if (k.length === 1 && k >= 'A' && k <= 'Z') vk = k.charCodeAt(0); else if (k.length === 1 && k >= '0' && k <= '9') vk = k.charCodeAt(0); return { mods, vk }; } // Validate a captured combo. Requires at least one modifier (otherwise a // single key would steal that letter globally) and a valid main key. function autofillComboValid(c) { if (!c) return false; if (!(c.ctrl || c.alt || c.win)) return false; // shift-only is unreliable const w = autofillComboToWin32(c); return w.vk !== 0; } // Capture a key combo from a single keydown event. Returns null if the // event is "incomplete" (only modifiers pressed so far) or Escape. function autofillCaptureFromEvent(e) { const k = e.key; if (k === 'Escape') return 'cancel'; // Ignore pure-modifier keydowns (user is still building the combo). if (k === 'Control' || k === 'Shift' || k === 'Alt' || k === 'Meta' || k === 'OS') return null; // Accept letter, digit, F1-F12. let key = null; if (k.length === 1 && /[a-z0-9]/i.test(k)) { key = k.toUpperCase(); } else if (/^F([1-9]|1[0-2])$/i.test(k)) { key = k.toUpperCase(); } else { return 'invalid'; } return { ctrl: !!e.ctrlKey, shift: !!e.shiftKey, alt: !!e.altKey, win: !!e.metaKey, key, }; } // Push current state to Delphi (toggle + both combos) and persist. // Called after any change so Delphi's RegisterHotKey reflects state. function autofillPushHotkeys() { localStorage.setItem('autofillHotkeyFull', JSON.stringify(state.autofillHotkeyFull)); localStorage.setItem('autofillHotkeyPwd', JSON.stringify(state.autofillHotkeyPwd)); if (Bridge.active) { Bridge.setAutofillHotkeys(state.autofillEnabled, { full: state.autofillHotkeyFull, password: state.autofillHotkeyPwd, }); } } // Extract a bare hostname from a site string for fuzzy matching. // "https://www.github.com/login" → "github.com" // Strip the browser brand suffix that lives at the end of every tab title // ("Some Page - Google Chrome", "Page — Mozilla Firefox", etc.). Without // this, entries whose site is "google" / "mozilla" / "edge" would match // every single page that has Chrome / Firefox / Edge as the browser brand. const BROWSER_SUFFIX_RE = /\s*[-—–|]\s*(google chrome|chromium|mozilla firefox|firefox|microsoft edge|edge|brave|opera|vivaldi|safari|tor browser|tor|arc)\s*$/i; function autofillStripBrowserSuffix(title) { return (title || '').replace(BROWSER_SUFFIX_RE, '').trim(); } function autofillExtractHost(site) { return site.toLowerCase() .replace(/^https?:\/\//i, '') .replace(/^www\./i, '') .split('/')[0] .split(':')[0]; } // Get the second-level domain (brand part) from a hostname. // "github.com" → "github" ; "mail.google.com" → "google" ; "x.com" → "x" function autofillSLD(host) { const parts = host.split('.').filter(p => p.length > 0); if (parts.length <= 1) return host; return parts[parts.length - 2]; } // Escape a string for safe insertion into a RegExp. function autofillEscapeRegex(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } // Score how well a vault entry matches the foreground window title. // Returns 0 (no match) or a positive integer (higher = better). // // Strategy (browser titles rarely contain the full hostname — usually // just the brand name, e.g. "Sign in to GitHub" or "X. C'est… - Google Chrome"): // 1. Full hostname substring → strongest (score 1000 + len) // 2. SLD ≥3 chars as substring → medium (score 500 + len) // 3. SLD <3 chars as word → weak (score 100), requires word // boundaries to avoid matching "x" inside arbitrary words. function autofillScore(entry, titleLower) { // 1. Display name (entry.title) lowercased substring — strongest brand // match. Skips when title is empty or same as site (already tested // via the site path below). const displayTitle = (entry.title || '').trim().toLowerCase(); if (displayTitle.length >= 2 && titleLower.includes(displayTitle)) return 1500 + displayTitle.length; if (!entry.site) return 0; const host = autofillExtractHost(entry.site); if (host.length < 2) return 0; // 2. Full hostname (rare in tab titles, but strongest URL signal) if (titleLower.includes(host)) return 1000 + host.length; // 3/4. Second-level domain const sld = autofillSLD(host); if (sld.length === 0) return 0; if (sld.length >= 3) { if (titleLower.includes(sld)) return 500 + sld.length; return 0; } // Short SLD ("x", "qq", "vk"…) — require word boundaries so we don't // match the letter inside random words. const re = new RegExp('(^|[^a-z0-9])' + autofillEscapeRegex(sld) + '([^a-z0-9]|$)', 'i'); if (re.test(titleLower)) return 100; return 0; } // Called by Bridge.onAutofillRequest when a hotkey fires. // kind: 'full' = Ctrl+Shift+L (user + Tab + pwd) ; 'password' = Ctrl+Shift+P. async function autofillHandleRequest(windowTitle, kind) { if (!state.autofillEnabled) return; if (!state.cryptoKey || state.locked || !state.token) { // Vault is locked — bring the app to the front so the user can // unlock immediately, rather than silently no-op'ing the hotkey. Bridge.cancelAutofill(); Bridge.focusApp(); setTimeout(() => { const pwd = document.getElementById('loginPassword'); const user = document.getElementById('loginUsername'); if (pwd && !document.getElementById('authScreen').classList.contains('is-hidden')) { if (user && !user.value) user.focus(); else pwd.focus(); } }, 80); toast('Vault is locked — unlock to autofill', 'warning'); return; } const titleLower = autofillStripBrowserSuffix(windowTitle).toLowerCase(); const scored = state.entries .map(e => ({ entry: e, score: autofillScore(e, titleLower) })) .filter(x => x.score > 0) .sort((a, b) => b.score - a.score); if (scored.length === 0) { toast('Autofill: no match for "' + windowTitle.slice(0, 40) + '"', 'warning'); Bridge.cancelAutofill(); return; } if (scored.length === 1) { await autofillFillEntry(scored[0].entry, kind); return; } // Multiple candidates — show picker. kind is captured so clicking a // candidate honours password-only mode. openAutofillPicker(scored.map(x => x.entry), windowTitle, kind); } // Decrypt and type an entry. kind = 'full' or 'password'. async function autofillFillEntry(entry, kind) { const password = await decryptPwd(entry.encrypted_password, entry.iv); if (password === '[ERROR]') { toast('Autofill: decryption error', 'error'); Bridge.cancelAutofill(); return; } // password-only kind → empty username → Delphi skips Tab. // full kind with empty entry.username → also no Tab (Delphi handles it). const user = (kind === 'password') ? '' : (entry.username || ''); Bridge.executeAutofill(user, password); toast((kind === 'password' ? 'Password filled: ' : 'Autofilled: ') + entry.site); // Audit (best-effort, ignore failures) fetch('' + '/audit', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ action: kind === 'password' ? 'autofill_pwd' : 'autofill', site: entry.site, }), }).catch(() => {}); } // Picker modal for multi-match case. kind is forwarded to autofillFillEntry // so the user's hotkey intent (full vs password-only) is preserved through // the manual choice. function openAutofillPicker(entries, windowTitle, kind) { // Bring the app to front so the picker is unambiguously visible — // otherwise the modal opens behind / next to the user's original // window (e.g. Notepad) and easy to miss. ExecuteAutofill restores // the original target HWND via ForceForegroundWindow on selection. Bridge.focusApp(); const list = $('#autofillPickerList'); list.innerHTML = ''; entries.forEach(e => { const btn = el('button', { class: 'autofill-pick-btn', on: { click: async () => { closeAutofillPicker(false); await autofillFillEntry(e, kind); }, }, }); btn.appendChild(el('span', { class: 'autofill-pick-site' }, entryDisplayName(e))); if (e.username) { btn.appendChild(el('span', { class: 'autofill-pick-user' }, e.username)); } list.appendChild(btn); }); const head = (kind === 'password' ? 'Pick entry (password only) — ' : 'Pick entry — ') + entries.length + ' match "' + windowTitle.slice(0, 30) + '…"'; $('#autofillPickerTitle').textContent = head; $('#autofillPickerModal').classList.remove('is-hidden'); } function closeAutofillPicker(notifyCancel = true) { $('#autofillPickerModal').classList.add('is-hidden'); if (notifyCancel) Bridge.cancelAutofill(); } // ============================================================ // VIEWS / NAV // ============================================================ async function setView(v) { state.view = v; state.currentPage = 1; if (v === 'trash') { await loadTrash(); } render(); } function toggleTheme() { setTheme(state.theme === 'dark' ? 'light' : 'dark'); } function setTheme(t) { state.theme = t; document.documentElement.setAttribute('data-theme', t); localStorage.setItem('theme', t); const sel = $('#settingTheme'); if (sel) sel.value = t; Bridge.syncTitleBarTheme(t); } function openSettings() { $('#settingTheme').value = state.theme; $('#settingSort').value = state.sortBy + ':' + state.sortDir; $('#settingAutoLock').value = String(state.autoLock); $('#settingAskDelete').checked = state.askBeforeDelete; $('#settingCompact').checked = state.compactActions; $('#settingMaskUser').checked = state.maskUsernames; $('#settingHIBP').checked = state.hibpEnabled; $('#settingShowSite').checked = state.showSiteOnCards; $('#settingAutofill').checked = state.autofillEnabled; $('#settingAutofillRow').style.display = Bridge.active ? '' : 'none'; // Hotkey capture buttons — labels reflect current combos. $('#settingAutofillFullCombo').textContent = autofillComboLabel(state.autofillHotkeyFull); $('#settingAutofillPwdCombo').textContent = autofillComboLabel(state.autofillHotkeyPwd); $('#settingAutofillHotkeysRow').style.display = Bridge.active ? '' : 'none'; // Start-with-Windows toggle: only meaningful inside the Delphi host // (registry access). Hide for the PHP frontend. $('#settingAutoStartRow').style.display = Bridge.active ? '' : 'none'; if (Bridge.active) { // Fire-and-forget: the bridge response updates the checkbox via // Bridge.onAutoStartStatus. Bridge.getAutoStart(); } $('#settingUser').textContent = state.username; // Async: query server for recovery key state and update the label refreshRecoveryStatus(); // Sync the quick-unlock UI from the actual DPAPI file (in case the // user cleared the file outside the app or the file is gone for some // other reason like a Windows profile reset). if (Bridge.active) { bridgeQuickUnlockStatus().then(actual => { if (!actual && state.quickUnlockEnabled) { // Drift: localStorage said enabled but Delphi has no blob. localStorage.removeItem('quickUnlockEnabled'); state.quickUnlockEnabled = false; } updateQuickUnlockUI(); }); } else { updateQuickUnlockUI(); } $('#settingsPanel').classList.add('is-open'); } function closeSettings() { $('#settingsPanel').classList.remove('is-open'); } // ---- Auto-lock with 30s warning countdown ------------------- const WARNING_SECONDS = 30; let autoLockTimer = null; let warningTimer = null; let countdownInterval = null; function hideIdleWarning() { $('#idleWarning').classList.add('is-hidden'); if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; } } function showIdleWarning() { $('#idleCountdown').textContent = WARNING_SECONDS; $('#idleWarning').classList.remove('is-hidden'); let s = WARNING_SECONDS; countdownInterval = setInterval(() => { s -= 1; $('#idleCountdown').textContent = Math.max(0, s); if (s <= 0) { clearInterval(countdownInterval); countdownInterval = null; } }, 1000); } function resetAutoLock() { if (autoLockTimer) clearTimeout(autoLockTimer); if (warningTimer) clearTimeout(warningTimer); hideIdleWarning(); if (!state.autoLock || !state.token || !state.cryptoKey) return; const totalMs = state.autoLock * 60 * 1000; const warningAt = Math.max(0, totalMs - WARNING_SECONDS * 1000); warningTimer = setTimeout(showIdleWarning, warningAt); autoLockTimer = setTimeout(() => { hideIdleWarning(); toast('Auto-locked due to inactivity', 'warning'); lockVault(); }, totalMs); } // Reset idle on user interaction — but ignore events that fire while the // warning popup is visible (otherwise the popup would never auto-dismiss). ['mousemove', 'keydown', 'click', 'touchstart'].forEach(ev => document.addEventListener(ev, e => { // Allow clicks on the "Stay unlocked" button to also reset if ($('#idleWarning').classList.contains('is-hidden')) { resetAutoLock(); } }, { passive: true }) ); async function showAuth() { $('#authScreen').classList.remove('is-hidden'); $('#appShell').classList.add('is-hidden'); if (autoLockTimer) { clearTimeout(autoLockTimer); autoLockTimer = null; } let remembered = ''; if (Bridge.active) { remembered = await Bridge.getPref('rememberedUsername'); } else { remembered = localStorage.getItem('rememberedUsername') || ''; } const userInput = $('#loginUsername'); const remCb = $('#loginRememberUser'); if (remCb) remCb.checked = !!remembered; if (userInput && !userInput.value && remembered) userInput.value = remembered; // Delphi-hosted: ask Delphi to SetFocus the WebBrowser control first // (DOM input.focus() is a no-op while the WebView2 lacks OS-level // focus). Web fallback: direct DOM focus. if (Bridge.active) { Bridge.appReady(); } else { setTimeout(() => { const u = $('#loginUsername'); const p = $('#loginPassword'); if (u && u.value) p && p.focus(); else u && u.focus(); }, 0); } } async function enterApp() { $('#authScreen').classList.add('is-hidden'); $('#appShell').classList.remove('is-hidden'); $('#userName').textContent = state.username; // Server-side prefs override localStorage cache; runs before render so // theme / view mode / mask flags are applied to the first paint. await loadServerSettings(); // Show skeleton cards immediately while the initial fetch runs showSkeletons(6); await loadFolders(); await loadEntries(); await loadEntryCounts(); render(); resetAutoLock(); // Fire-and-forget HIBP scan if the user opted in. Runs in background, // re-renders when done to show badges. if (state.hibpEnabled) hibpCheckAllEntries(); // Push the user-configured hotkeys (combos + enabled state) to Delphi. // Replaces the historical "always Ctrl+Shift+L on startup" path. autofillPushHotkeys(); } // ============================================================ // SERVER-SIDE SETTINGS SYNC // ============================================================ // // Synced keys (user preferences, portable across devices). Device-specific // toggles (quickUnlockEnabled, autofillEnabled) stay in localStorage. const SYNCED_SETTING_KEYS = [ 'theme', 'autoLock', 'askBeforeDelete', 'maskUsernames', 'compactActions', 'viewMode', 'hibpEnabled', 'showSiteOnCards', 'sortBy', 'sortDir', 'pageSize', // Hotkey combos are user preferences — values are portable. The // registration itself is Windows-only, so non-Windows clients just // ignore them. 'autofillHotkeyFull', 'autofillHotkeyPwd', // Sidebar section collapsed state. Object of { folders, tags, tools } // booleans. Synced so the user gets the same fold state across devices. 'sidebarCollapsed', ]; function applySidebarCollapsed() { const s = state.sidebarCollapsed || {}; document.querySelectorAll('.sidebar-section[data-section]').forEach(sec => { const k = sec.getAttribute('data-section'); sec.classList.toggle('is-collapsed', !!s[k]); }); } async function loadServerSettings() { try { const r = await fetch(API + '/settings', { headers: authHeaders() }); if (!r.ok) return; const remote = await r.json(); // Merge remote into state (remote wins). localStorage is updated // too so first-paint on next reload uses the synced value. SYNCED_SETTING_KEYS.forEach(k => { if (!(k in remote)) return; const v = remote[k]; state[k] = v; switch (k) { case 'theme': localStorage.setItem('theme', v); break; case 'autoLock': localStorage.setItem('autoLockMin', String(v)); break; case 'askBeforeDelete': localStorage.setItem('askBeforeDelete', v ? '1' : '0'); break; case 'maskUsernames': localStorage.setItem('maskUsernames', v ? '1' : '0'); break; case 'compactActions': localStorage.setItem('compactActions', v ? '1' : '0'); break; case 'viewMode': localStorage.setItem('viewMode', v); break; case 'hibpEnabled': localStorage.setItem('hibpEnabled', v ? '1' : '0'); break; case 'showSiteOnCards': localStorage.setItem('showSiteOnCards', v ? '1' : '0'); break; case 'sortBy': localStorage.setItem('sortBy', String(v)); break; case 'sortDir': localStorage.setItem('sortDir', String(v)); break; case 'pageSize': localStorage.setItem('pageSize', String(v)); break; case 'autofillHotkeyFull': case 'autofillHotkeyPwd': case 'sidebarCollapsed': // Object; persist as JSON so the next cold start picks it up. localStorage.setItem(k, JSON.stringify(v)); break; } }); // Apply visual settings immediately. if (remote.theme) setTheme(remote.theme); if ('sidebarCollapsed' in remote) applySidebarCollapsed(); } catch (e) { // Network/server hiccup is harmless — localStorage cache still works. } } let _settingsSaveTimer = null; function saveServerSettings() { // Debounce: collapse rapid toggles (e.g. user playing with the theme // dropdown) into a single PUT. if (_settingsSaveTimer) clearTimeout(_settingsSaveTimer); _settingsSaveTimer = setTimeout(async () => { _settingsSaveTimer = null; const payload = {}; SYNCED_SETTING_KEYS.forEach(k => { payload[k] = state[k]; }); try { await fetch(API + '/settings', { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify(payload), }); } catch (e) { // Silent — next change will retry. } }, 400); } // ============================================================ // INIT // ============================================================ function installCustomContextMenu() { const menu = el('div', { class: 'custom-ctxmenu is-hidden' }); document.body.appendChild(menu); function isEditable(node) { if (!node) return false; const tag = node.tagName; if (tag === 'INPUT') return !['button','submit','checkbox','radio','range','color','file'].includes(node.type); if (tag === 'TEXTAREA') return true; if (node.isContentEditable) return true; return false; } function hide() { menu.classList.add('is-hidden'); } function buildItems(target) { menu.innerHTML = ''; const isInput = isEditable(target); const hasSelection = isInput && target.selectionStart !== target.selectionEnd; const items = [ { lbl: 'Cut', on: hasSelection, fn: () => doCut(target) }, { lbl: 'Copy', on: hasSelection, fn: () => doCopy(target) }, { lbl: 'Paste', on: isInput && !target.readOnly, fn: () => doPaste(target) }, { lbl: 'Select all', on: isInput, fn: () => target.select() }, ]; items.forEach(it => { const mi = el('button', { class: 'custom-ctxmenu-item' + (it.on ? '' : ' is-disabled'), type: 'button', on: { click: ev => { ev.preventDefault(); ev.stopPropagation(); if (!it.on) return; hide(); it.fn(); } }, }, it.lbl); menu.appendChild(mi); }); } async function doCopy(target) { const sel = target.value.slice(target.selectionStart, target.selectionEnd); try { await navigator.clipboard.writeText(sel); } catch (e) {} } async function doCut(target) { await doCopy(target); const s = target.selectionStart, e = target.selectionEnd; target.value = target.value.slice(0, s) + target.value.slice(e); target.selectionStart = target.selectionEnd = s; target.dispatchEvent(new Event('input', { bubbles: true })); } async function doPaste(target) { const txt = Bridge.active ? await bridgeReadClipboard() : await navigator.clipboard.readText().catch(() => ''); if (!txt) return; const s = target.selectionStart, e = target.selectionEnd; target.value = target.value.slice(0, s) + txt + target.value.slice(e); target.selectionStart = target.selectionEnd = s + txt.length; target.dispatchEvent(new Event('input', { bubbles: true })); } document.addEventListener('contextmenu', ev => { ev.preventDefault(); if (!isEditable(ev.target)) { hide(); return; } buildItems(ev.target); menu.classList.remove('is-hidden'); const vw = window.innerWidth, vh = window.innerHeight; const mw = menu.offsetWidth || 160, mh = menu.offsetHeight || 140; const x = Math.min(ev.clientX, vw - mw - 4); const y = Math.min(ev.clientY, vh - mh - 4); menu.style.left = x + 'px'; menu.style.top = y + 'px'; }); document.addEventListener('mousedown', ev => { if (!ev.target.closest('.custom-ctxmenu')) hide(); }); document.addEventListener('keydown', ev => { if (ev.key === 'Escape') hide(); }); window.addEventListener('blur', hide); } async function init() { document.documentElement.setAttribute('data-theme', state.theme); if (location.search.indexOf('pmt=') !== -1) { history.replaceState(null, '', location.pathname + location.hash); } installCustomContextMenu(); document.addEventListener('keydown', e => { const mod = e.ctrlKey || e.metaKey; if (e.key === 'F12' || e.key === 'F5') return e.preventDefault(); if (mod && e.shiftKey && /^[ijIJ]$/.test(e.key)) return e.preventDefault(); // DevTools if (mod && /^[uUjJhHsSpPtTnNrR]$/.test(e.key)) return e.preventDefault(); // View source / Downloads / History / Save / Print / New tab+win / Reload if (mod && e.shiftKey && /^[nNwW]$/.test(e.key)) return e.preventDefault(); // New incognito / Close window if (mod && e.shiftKey && e.key === 'Delete') return e.preventDefault(); // Clear browsing data }); // Auth tabs $$('.auth-tab').forEach(t => { t.addEventListener('click', () => { $$('.auth-tab').forEach(x => x.classList.remove('is-active')); t.classList.add('is-active'); const tab = t.dataset.tab; $('#loginForm').classList.toggle('is-hidden', tab !== 'login'); $('#registerForm').classList.toggle('is-hidden', tab !== 'register'); }); }); // Forms $('#loginForm').addEventListener('submit', doLogin); $('#registerForm').addEventListener('submit', doRegister); $('#regPassword').addEventListener('input', updateRegStrength); $('#entryPassword').addEventListener('input', updateEntryStrength); // Top-bar function applyViewMode() { $$('.view-btn').forEach(b => b.classList.toggle('is-active', b.dataset.view === state.viewMode)); renderGrid(); } applyViewMode(); $$('.view-btn').forEach(b => b.addEventListener('click', () => { state.viewMode = b.dataset.view; state.currentPage = 1; localStorage.setItem('viewMode', state.viewMode); applyViewMode(); saveServerSettings(); })); $('#themeBtn').addEventListener('click', () => { toggleTheme(); saveServerSettings(); }); $('#newEntryBtn').addEventListener('click', () => openEntryModal()); $('#userChip').addEventListener('click', () => $('#userDropdown').classList.toggle('is-hidden')); $('#lockBtn').addEventListener('click', lockVault); $('#dropdownSettingsBtn').addEventListener('click', () => { $('#userDropdown').classList.add('is-hidden'); openSettings(); }); $('#logoutBtn').addEventListener('click', doLogout); document.addEventListener('click', e => { if (!e.target.closest('.user-menu')) $('#userDropdown').classList.add('is-hidden'); // Close any open kebab menu when clicking outside it if (!e.target.closest('.entry-kebab-wrap')) { $$('.entry-kebab-menu.is-open').forEach(m => m.classList.remove('is-open')); } }); // Sidebar nav $$('#appShell .nav-item[data-view]').forEach(n => { n.addEventListener('click', () => setView(n.dataset.view)); }); $('#addFolderBtn').addEventListener('click', addFolder); // Drag-to-trash: dropping an entry onto the Trash nav item soft-deletes it const trashItem = $('#appShell .nav-item[data-view="trash"]'); if (trashItem) { trashItem.addEventListener('dragover', ev => { ev.preventDefault(); trashItem.classList.add('drag-over'); }); trashItem.addEventListener('dragleave', () => trashItem.classList.remove('drag-over')); trashItem.addEventListener('drop', async ev => { ev.preventDefault(); trashItem.classList.remove('drag-over'); const id = parseInt(ev.dataTransfer.getData('text/plain')); if (!id) return; try { await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() }); toast('Moved to trash'); await loadEntries(); await loadTrash(); render(); } catch (err) { toast(err.message, 'error'); } }); } // Search $('#searchInput').addEventListener('input', e => { state.search = e.target.value; state.currentPage = 1; renderGrid(); }); // Slide-over $('#slideoverClose').addEventListener('click', closeSlideOver); // Click outside the slide-over closes it. Clicks on cards re-open it for // another entry (so we don't close in that case; the card's own handler // will switch state.selectedId). document.addEventListener('click', e => { if (!$('#slideover').classList.contains('is-open')) return; if (e.target.closest('.slideover')) return; if (e.target.closest('.entry-card')) return; if (e.target.closest('.modal')) return; if (e.target.closest('.cmd-palette')) return; if (e.target.closest('.idle-warning')) return; closeSlideOver(); }); // Click outside the settings panel closes it. Each setting change has // already pushed to server + localStorage, so "close = autosave" is // implicit. Ignore clicks on the triggers and on any open modal (so the // reauth / confirm flows fired from inside settings don't dismiss it). document.addEventListener('click', e => { if (!$('#settingsPanel').classList.contains('is-open')) return; if (e.target.closest('#settingsPanel')) return; if (e.target.closest('#settingsBtn')) return; if (e.target.closest('#dropdownSettingsBtn')) return; if (e.target.closest('.modal')) return; closeSettings(); }); // Entry modal $('#entryForm').addEventListener('submit', saveEntry); $('#entrySaveBtn').addEventListener('click', saveEntry); $$('#entryModal [data-close]').forEach(b => b.addEventListener('click', closeEntryModal)); $('#entryPwToggle').addEventListener('click', () => { const input = $('#entryPassword'); input.type = input.type === 'password' ? 'text' : 'password'; }); $('#loginPwToggle').addEventListener('click', () => { const input = $('#loginPassword'); input.type = input.type === 'password' ? 'text' : 'password'; }); $('#entryPwGen').addEventListener('click', openGen); // Chip input (tags) $('#entryTagsInput').addEventListener('click', () => $('#entryTagsField').focus()); $('#entryTagsField').addEventListener('keydown', e => { const field = e.target; if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); if (!selectActiveSuggestion()) { addTag(field.value); field.value = ''; closeChipSuggest(); } } else if (e.key === 'Backspace' && !field.value && editingTags.length) { editingTags.pop(); renderChips(); } else if (e.key === 'ArrowDown') { e.preventDefault(); moveChipSuggest(+1); } else if (e.key === 'ArrowUp') { e.preventDefault(); moveChipSuggest(-1); } else if (e.key === 'Escape') { closeChipSuggest(); } }); $('#entryTagsField').addEventListener('input', openChipSuggest); $('#entryTagsField').addEventListener('focus', openChipSuggest); $('#entryTagsField').addEventListener('blur', () => setTimeout(closeChipSuggest, 150)); // Generator $('#genLen').addEventListener('input', genPassword); $$('#genModal input[type=checkbox]').forEach(c => c.addEventListener('change', genPassword)); $('#genRegen').addEventListener('click', genPassword); $('#genCopy').addEventListener('click', () => { if (!genCurrent) return; if (Bridge.copySecure(genCurrent, 30000)) { toast('Copied · clears in 30s'); } else { navigator.clipboard.writeText(genCurrent).then(() => { toast('Copied · clears in 30s'); setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000); }); } }); $('#genUse').addEventListener('click', () => { if (genTarget === 'slideover') { const soPw = $('#soPassword'); if (soPw) { soPw.value = genCurrent; soDirtyCheck(); } } else { $('#entryPassword').value = genCurrent; updateEntryStrength(); } closeGen(); }); $$('#genModal [data-close]').forEach(b => b.addEventListener('click', closeGen)); // Sidebar Generator tool $('#sidebarGenBtn').addEventListener('click', () => openGen('standalone')); $('#sidebarExportBtn').addEventListener('click', doExport); $('#sidebarImportBtn').addEventListener('click', doImport); $('#sidebarAuthenticatorBtn').addEventListener('click', () => { state.view = 'authenticator'; state.currentPage = 1; $$('.nav-item').forEach(b => b.classList.remove('is-active')); render(); }); $('#sidebarTotpToolBtn').addEventListener('click', openTotpTool); // Sidebar section collapse toggles document.querySelectorAll('[data-section-toggle]').forEach(btn => { btn.addEventListener('click', () => { const key = btn.getAttribute('data-section-toggle'); state.sidebarCollapsed = state.sidebarCollapsed || {}; state.sidebarCollapsed[key] = !state.sidebarCollapsed[key]; applySidebarCollapsed(); localStorage.setItem('sidebarCollapsed', JSON.stringify(state.sidebarCollapsed)); saveServerSettings(); }); }); applySidebarCollapsed(); // Idle warning "Stay unlocked" $('#idleStayBtn').addEventListener('click', resetAutoLock); // Settings slide-over $('#settingsBtn').addEventListener('click', openSettings); $('#settingsClose').addEventListener('click', closeSettings); // Theme: setTheme already writes localStorage. Capture before/after so // sync only fires if it actually changed. $('#settingTheme').addEventListener('change', e => { setTheme(e.target.value); state.theme = e.target.value; saveServerSettings(); }); $('#settingSort').addEventListener('change', e => { const [by, dir] = e.target.value.split(':'); state.sortBy = by; state.sortDir = dir; state.currentPage = 1; localStorage.setItem('sortBy', by); localStorage.setItem('sortDir', dir); render(); saveServerSettings(); }); $('#settingAutoLock').addEventListener('change', e => { state.autoLock = parseInt(e.target.value); localStorage.setItem('autoLockMin', String(state.autoLock)); resetAutoLock(); saveServerSettings(); toast(state.autoLock ? ('Auto-lock: ' + state.autoLock + ' min') : 'Auto-lock disabled'); }); $('#settingAskDelete').addEventListener('change', e => { state.askBeforeDelete = e.target.checked; localStorage.setItem('askBeforeDelete', state.askBeforeDelete ? '1' : '0'); saveServerSettings(); toast(state.askBeforeDelete ? 'Will ask before deleting' : 'Will delete without asking'); }); $('#settingCompact').addEventListener('change', e => { state.compactActions = e.target.checked; localStorage.setItem('compactActions', state.compactActions ? '1' : '0'); render(); saveServerSettings(); }); $('#settingMaskUser').addEventListener('change', e => { state.maskUsernames = e.target.checked; localStorage.setItem('maskUsernames', state.maskUsernames ? '1' : '0'); render(); saveServerSettings(); }); $('#settingShowSite').addEventListener('change', e => { state.showSiteOnCards = e.target.checked; localStorage.setItem('showSiteOnCards', state.showSiteOnCards ? '1' : '0'); render(); saveServerSettings(); }); $('#settingHIBP').addEventListener('change', e => { state.hibpEnabled = e.target.checked; localStorage.setItem('hibpEnabled', state.hibpEnabled ? '1' : '0'); saveServerSettings(); if (state.hibpEnabled) { toast('Checking passwords against breach database…'); hibpCheckAllEntries(); } else { state.hibpResults.clear(); render(); toast('Breach check disabled'); } }); $('#settingAutoStart').addEventListener('change', e => { if (!Bridge.active) return; Bridge.setAutoStart(e.target.checked); toast(e.target.checked ? 'Will start with Windows (in tray)' : 'Won’t start with Windows'); }); $('#settingAutofill').addEventListener('change', e => { state.autofillEnabled = e.target.checked; localStorage.setItem('autofillEnabled', state.autofillEnabled ? '1' : '0'); // Push the FULL state (toggle + combos) so Delphi register/unregister // uses the user's current combos, not the defaults. autofillPushHotkeys(); const lbl = autofillComboLabel(state.autofillHotkeyFull); toast(state.autofillEnabled ? ('Autofill enabled (' + lbl + ')') : 'Autofill disabled'); }); // ---- Hotkey capture buttons ---- // Click → button label becomes "Press combo…" → next keydown captures. // While capturing, all other keys are swallowed so the user can press // any modifier+letter combo without triggering app shortcuts. function bindHotkeyCapture(btnId, kind) { const btn = $(btnId); if (!btn) return; btn.addEventListener('click', () => { if (btn.dataset.capturing === '1') return; btn.dataset.capturing = '1'; btn.classList.add('is-capturing'); const original = btn.textContent; btn.textContent = 'Press combo… (Esc to cancel)'; function finish(restore) { btn.dataset.capturing = ''; btn.classList.remove('is-capturing'); document.removeEventListener('keydown', onKey, true); if (restore) btn.textContent = original; } function onKey(e) { // Swallow EVERYTHING while capturing so the user's combo // doesn't trigger the cmd-palette etc. e.preventDefault(); e.stopPropagation(); const captured = autofillCaptureFromEvent(e); if (captured === null) return; // still building if (captured === 'cancel') { finish(true); return; } if (captured === 'invalid'){ toast('Unsupported key — use a letter, digit or F-key', 'warning'); finish(true); return; } if (!autofillComboValid(captured)) { toast('Combo needs at least Ctrl, Alt or Win as a modifier', 'warning'); finish(true); return; } // Reject if it collides with the other slot. const other = (kind === 'full') ? state.autofillHotkeyPwd : state.autofillHotkeyFull; if (JSON.stringify(other) === JSON.stringify(captured)) { toast('That combo is already used by the other hotkey', 'warning'); finish(true); return; } // Commit. if (kind === 'full') state.autofillHotkeyFull = captured; else state.autofillHotkeyPwd = captured; btn.textContent = autofillComboLabel(captured); finish(false); autofillPushHotkeys(); // re-register in Delphi saveServerSettings(); // sync to server (debounced) } document.addEventListener('keydown', onKey, true); }); } bindHotkeyCapture('#settingAutofillFullCombo', 'full'); bindHotkeyCapture('#settingAutofillPwdCombo', 'password'); $('#settingAutofillResetHotkeys').addEventListener('click', () => { state.autofillHotkeyFull = { ctrl: true, shift: true, alt: false, win: false, key: 'L' }; state.autofillHotkeyPwd = { ctrl: true, shift: true, alt: false, win: false, key: 'P' }; $('#settingAutofillFullCombo').textContent = autofillComboLabel(state.autofillHotkeyFull); $('#settingAutofillPwdCombo').textContent = autofillComboLabel(state.autofillHotkeyPwd); autofillPushHotkeys(); saveServerSettings(); toast('Hotkeys reset to defaults'); }); // Autofill picker modal close button $$('#autofillPickerModal [data-close]').forEach(b => b.addEventListener('click', closeAutofillPicker)); $('#openClipboardSettings').addEventListener('click', () => { toast('Open Windows Settings → System → Clipboard → turn off "Clipboard history"', 'warning'); }); $('#exportBtn').addEventListener('click', doExport); $('#importBtn').addEventListener('click', doImport); $('#changeMasterBtn').addEventListener('click', openChangeMasterModal); $('#changeMasterForm').addEventListener('submit', e => { e.preventDefault(); doChangeMasterPassword(); }); $$('#changeMasterModal [data-close]').forEach(b => b.addEventListener('click', closeChangeMasterModal)); // Recovery key $('#recoverySetupBtn').addEventListener('click', doGenerateRecoveryKey); $('#recoveryRemoveBtn').addEventListener('click', doRemoveRecoveryKey); $('#recoveryBtn').addEventListener('click', doRecoveryRedeem); // Quick unlock (DPAPI) $('#quickUnlockToggleBtn').addEventListener('click', () => { if (state.quickUnlockEnabled) disableQuickUnlock(); else enableQuickUnlock(); }); // Re-auth modal $('#reauthForm').addEventListener('submit', e => { e.preventDefault(); closeReauth(true); }); $$('#reauthModal [data-close]').forEach(b => b.addEventListener('click', () => closeReauth(false))); // Custom confirm / prompt modal $('#confirmForm').addEventListener('submit', e => { e.preventDefault(); // If input field visible -> resolve with its value, else -> true const hasInput = !$('#confirmInputField').classList.contains('is-hidden'); closeConfirm(hasInput ? $('#confirmInput').value : true); }); $$('#confirmModal [data-confirm-cancel]').forEach(b => b.addEventListener('click', () => closeConfirm(false)) ); // Helper: is the user currently typing into an input/textarea/select // or contenteditable surface? Shortcuts like Ctrl+A must NOT hijack // input focus (browser default = select all text in the field). function isTypingTarget(t) { if (!t) return false; const tag = t.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; if (t.isContentEditable) return true; return false; } // Command palette + bulk shortcuts document.addEventListener('keydown', e => { if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); openPalette(); } else if ((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')) { // Ctrl+A = select every visible entry. Replaces the role of // a marquee "drag across all cards" that we used to have. // Only fire when the user isn't typing in a field — otherwise // we'd steal the universal Select All in inputs. if (isTypingTarget(e.target)) return; // Don't fire if the app isn't actually showing the entry grid // (auth screen, locked, etc.) if ($('#appShell').classList.contains('is-hidden')) return; e.preventDefault(); const visible = filteredEntries(); visible.forEach(en => state.checked.add(en.id)); renderGrid(); } else if (e.key === 'Escape') { // Close in priority order: confirm first (most modal-y) then others if (!$('#confirmModal').classList.contains('is-hidden')) { closeConfirm(false); return; } if (!$('#changeMasterModal').classList.contains('is-hidden')) { closeChangeMasterModal(); return; } closePalette(); closeSlideOver(); closeEntryModal(); closeGen(); // If nothing else needed dismissing and there's an active // selection, clear it. Replaces the "click empty space to // deselect" path that the marquee provided. if (state.checked.size > 0) { state.checked.clear(); renderGrid(); } } }); $('#cmdInput').addEventListener('input', e => renderPaletteResults(e.target.value)); $$('#cmdPalette [data-close]').forEach(b => b.addEventListener('click', closePalette)); // Re-sync quickUnlockEnabled from the DPAPI source of truth. localStorage // is wiped at each launch (random port → new origin), so the cached value // can lie about the actual server-side state. if (Bridge.active) { const has = await bridgeQuickUnlockStatus(); state.quickUnlockEnabled = has; if (has) localStorage.setItem('quickUnlockEnabled', '1'); else localStorage.removeItem('quickUnlockEnabled'); } // Restore session if any if (state.token && state.salt) { const ok = await restoreCryptoKey(); if (ok) { await enterApp(); } else { // sessionStorage still has token+salt but the crypto key was // cleared (locked / tab closed). Try the DPAPI quick-unlock // path before falling back to the master-pw prompt. if (await tryQuickUnlock()) { await enterApp(); } else { showAuth(); $('#loginUsername').value = state.username; } } } else { // Cold start: no sessionStorage at all. Quick unlock can still // restore everything (token + salt + key + username) from the // DPAPI blob. if (await tryQuickUnlock()) { await enterApp(); } else { showAuth(); } } } document.addEventListener('DOMContentLoaded', init);