/* ============================================================ 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 = {}; const fileSaveResolvers = {}; const fileChunkResolvers = {}; // Stream a base64 string to Delphi in URL-sized pieces via cmd://file/chunk, // each acked (onFileChunkAck) before the next is sent. Returns true when all // pieces are buffered server-side, false on a stalled chunk. The ack CLEARS // the pending timeout — without that, a resolved chunk's stale 30s timeout // would later delete the CURRENT chunk's resolver and hang the transfer // forever (only the last location.href navigation "wins" per event loop, so // resolvers must be strictly 1-at-a-time and their timers torn down). async function _streamChunks(reqId, b64, onProgress) { const CHUNK = 1000000; const total = Math.ceil(b64.length / CHUNK); let done = 0; for (let off = 0; off < b64.length; off += CHUNK) { const piece = b64.slice(off, off + CHUNK); const ok = await new Promise(res => { const timer = setTimeout(() => { if (fileChunkResolvers[reqId]) { delete fileChunkResolvers[reqId]; res(false); } }, 30000); fileChunkResolvers[reqId] = { resolve: res, timer }; window.location.href = 'cmd://file/chunk?reqId=' + encodeURIComponent(reqId) + '&data=' + encodeURIComponent(piece); }); if (!ok) return false; done++; if (typeof onProgress === 'function') onProgress(Math.round(done / total * 100)); } return true; } let versionResolver = null; let launchModeResolver = null; const folderPickResolvers = {}; const fileWriteResolvers = {}; const fileListResolvers = {}; const fileDeleteResolvers = {}; let autoStartResolver = null; const faviconResolvers = {}; let _faviconReqSeq = 0; 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); openSlideOver(null, { presetTitle: cleaned }); }, // Tell Delphi to simulate keystrokes. Empty username = password only // (no Tab is sent). hideAfter=true asks Delphi to hide our window // back to the tray AFTER SendInput completes — necessary for the // Ctrl+Shift+Q-from-tray flow (we cannot hide before SendInput or // Win10/11 anti-focus-stealing rules block the target). executeAutofill(username, password, hideAfter, field) { if (!active) return; // field='user' → type only the username (no Tab / password). cmd('cmd://autofill/execute?username=' + encodeURIComponent(username) + '&password=' + encodeURIComponent(password) + (hideAfter ? '&hide_after=1' : '') + (field === 'user' ? '&field=user' : '')); }, // 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); let qs = ''; if (combos.quickSearch) { const q = autofillComboToWin32(combos.quickSearch); qs = '&qs_mods=' + q.mods + '&qs_vk=' + q.vk; } cmd('cmd://autofill/hotkeys?enabled=' + (enabled ? '1' : '0') + '&full_mods=' + f.mods + '&full_vk=' + f.vk + '&pwd_mods=' + p.mods + '&pwd_vk=' + p.vk + qs); }, // 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 || ''); } }, // Native Save As dialog — bypasses WebView2's browser download UI // (which Edge wraps with "Téléchargements" popup + Open File prompt). // The caller passes raw bytes as Uint8Array OR a string; we base64 // it and ship to Delphi which writes the file after the user picks. saveFile(name, content, onProgress) { if (!active) return Promise.resolve({ ok: false, error: 'bridge offline' }); let bytes; if (typeof content === 'string') bytes = new TextEncoder().encode(content); else bytes = content; const b64 = bytesToBase64(bytes); const reqId = 'fs_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8); // cmd:// goes through window.location.href, which WebView2 caps at // roughly a couple MB of URL. A big attachment base64'd blows past // that → the navigation blanks the document (black screen). So for // anything large we stream the data in chunks small enough to fit // a URL, each acknowledged before the next is sent (sequential — // otherwise repeated location.href assignments coalesce and only // the last lands). Small payloads keep the fast single-shot path. // ~1 MB base64 per chunk — comfortably under WebView2's ~2 MB // URL cap even after percent-encoding, while keeping the number // of round-trips (and total time) low on big exports. const CHUNK = 1000000; if (b64.length <= CHUNK) { return new Promise(resolve => { fileSaveResolvers[reqId] = resolve; cmd('cmd://file/save?name=' + encodeURIComponent(name) + '&data=' + encodeURIComponent(b64) + '&reqId=' + encodeURIComponent(reqId)); setTimeout(() => { if (fileSaveResolvers[reqId]) { delete fileSaveResolvers[reqId]; resolve({ ok: false, error: 'timeout' }); } }, 120000); }); } return (async () => { const ok = await _streamChunks(reqId, b64, onProgress); if (!ok) return { ok: false, error: 'chunk transfer failed' }; return await new Promise(resolve => { fileSaveResolvers[reqId] = resolve; cmd('cmd://file/save-commit?reqId=' + encodeURIComponent(reqId) + '&name=' + encodeURIComponent(name)); setTimeout(() => { if (fileSaveResolvers[reqId]) { delete fileSaveResolvers[reqId]; resolve({ ok: false, error: 'timeout' }); } }, 120000); }); })(); }, onFileChunkAck(reqId) { const r = fileChunkResolvers[reqId]; if (r) { delete fileChunkResolvers[reqId]; if (r.timer) clearTimeout(r.timer); r.resolve(true); } }, onFileSaveResult(reqId, ok, path, err) { const r = fileSaveResolvers[reqId]; if (r) { delete fileSaveResolvers[reqId]; r({ ok: !!ok, path: path || '', error: err || '' }); } }, // ---- Auto-backup helpers (folder pick + silent write + list + delete) pickFolder() { if (!active) return Promise.resolve(''); const reqId = 'fp_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6); return new Promise(resolve => { folderPickResolvers[reqId] = resolve; cmd('cmd://folder/pick?reqId=' + encodeURIComponent(reqId)); setTimeout(() => { if (folderPickResolvers[reqId]) { delete folderPickResolvers[reqId]; resolve(''); } }, 120000); }); }, onFolderPickResult(reqId, path) { const r = folderPickResolvers[reqId]; if (r) { delete folderPickResolvers[reqId]; r(path || ''); } }, writeFile(path, content, onProgress) { if (!active) return Promise.resolve({ ok: false, error: 'bridge offline' }); let bytes; if (typeof content === 'string') bytes = new TextEncoder().encode(content); else bytes = content; const b64 = bytesToBase64(bytes); const reqId = 'fw_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6); // Same URL-length trap as saveFile: a big auto-backup base64'd // into a single cmd:// URL blows past WebView2's cap and the // write fails (silently, or blanks the page). Chunk it. const CHUNK = 1000000; if (b64.length <= CHUNK) { return new Promise(resolve => { fileWriteResolvers[reqId] = resolve; cmd('cmd://file/write?path=' + encodeURIComponent(path) + '&data=' + encodeURIComponent(b64) + '&reqId=' + encodeURIComponent(reqId)); setTimeout(() => { if (fileWriteResolvers[reqId]) { delete fileWriteResolvers[reqId]; resolve({ ok: false, error: 'timeout' }); } }, 30000); }); } return (async () => { const ok = await _streamChunks(reqId, b64, onProgress); if (!ok) return { ok: false, error: 'chunk transfer failed' }; return await new Promise(resolve => { fileWriteResolvers[reqId] = resolve; cmd('cmd://file/write-commit?reqId=' + encodeURIComponent(reqId) + '&path=' + encodeURIComponent(path)); setTimeout(() => { if (fileWriteResolvers[reqId]) { delete fileWriteResolvers[reqId]; resolve({ ok: false, error: 'timeout' }); } }, 30000); }); })(); }, onFileWriteResult(reqId, ok, err) { const r = fileWriteResolvers[reqId]; if (r) { delete fileWriteResolvers[reqId]; r({ ok: !!ok, error: err || '' }); } }, listFiles(dir, prefix) { if (!active) return Promise.resolve([]); const reqId = 'fl_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6); return new Promise(resolve => { fileListResolvers[reqId] = resolve; cmd('cmd://file/listMatch?dir=' + encodeURIComponent(dir) + '&prefix=' + encodeURIComponent(prefix || '') + '&reqId=' + encodeURIComponent(reqId)); setTimeout(() => { if (fileListResolvers[reqId]) { delete fileListResolvers[reqId]; resolve([]); } }, 10000); }); }, onFileListResult(reqId, json) { const r = fileListResolvers[reqId]; if (!r) return; delete fileListResolvers[reqId]; try { r(JSON.parse(json || '[]')); } catch { r([]); } }, deleteFile(path) { if (!active) return Promise.resolve(false); const reqId = 'fd_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6); return new Promise(resolve => { fileDeleteResolvers[reqId] = resolve; cmd('cmd://file/delete?path=' + encodeURIComponent(path) + '&reqId=' + encodeURIComponent(reqId)); setTimeout(() => { if (fileDeleteResolvers[reqId]) { delete fileDeleteResolvers[reqId]; resolve(false); } }, 10000); }); }, onFileDeleteResult(reqId, ok) { const r = fileDeleteResolvers[reqId]; if (r) { delete fileDeleteResolvers[reqId]; r(!!ok); } }, // ---- 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; }, // ---- Favicon fetch (via Delphi proxy → DuckDuckGo icons) ---------- // Returns a Promise. Multiple in-flight requests for // distinct hosts are tracked per reqId so they can't collide. fetchFavicon(host) { if (!active) return Promise.resolve(''); if (!host) return Promise.resolve(''); const reqId = 'fav_' + (++_faviconReqSeq); return new Promise(resolve => { faviconResolvers[reqId] = resolve; cmd('cmd://favicon/fetch?host=' + encodeURIComponent(host) + '&reqId=' + encodeURIComponent(reqId)); setTimeout(() => { if (faviconResolvers[reqId]) { delete faviconResolvers[reqId]; resolve(''); } }, 8000); }); }, onFaviconResult(reqId, host, dataUri) { const r = faviconResolvers[reqId]; if (r) { delete faviconResolvers[reqId]; r(dataUri || ''); } }, // Tray menu "Quick search…" → open a compact modal. wasHidden // (passed by Delphi) tells us whether the window was in the tray // before — if so, after the user picks an entry we ask Delphi to // hide the window again so the paste workflow is one keystroke // (Ctrl+V in the target app). // Locked vault → fall through to the master-password screen. openQuickSearch(wasHidden, forFill) { if (state.locked || !state.cryptoKey || !state.token) { const pwd = document.getElementById('loginPassword'); if (pwd && !document.getElementById('authScreen').classList.contains('is-hidden')) { setTimeout(() => pwd.focus(), 60); } if (typeof toast === 'function') toast('Vault is locked — unlock to search', 'warning'); // Tell Delphi we cancelled so the captured HWND doesn't // linger waiting for a never-coming /execute. if (forFill && Bridge.cancelAutofill) Bridge.cancelAutofill(); return; } if (typeof openQuickSearchModal === 'function') openQuickSearchModal(!!wasHidden, !!forFill); }, // Hide the window back to the tray icon. Used by Quick search to // restore "was in tray" state after a password copy. minimizeToTray(keepClipboard) { if (!active) return; // keepClipboard=true → the just-copied password survives the // minimise (quick-search copy-then-hide). Default clears it. cmd('cmd://app/minimize' + (keepClipboard ? '?keepclip=1' : '')); }, // Push the "show tray notifications" preference to Delphi so the // bridge gates the Shell_NotifyIcon NIF_INFO balloons accordingly. setTrayNotifications(enabled) { if (!active) return; cmd('cmd://tray/notifications?enabled=' + (enabled ? '1' : '0')); }, // Hardcoded build version surfaced from the Delphi host. Promise- // based with a 2s safety timeout — if the host is unreachable the // resolver fires with '' rather than hanging the UI. getAppVersion() { if (!active) return Promise.resolve(''); return new Promise(resolve => { versionResolver = resolve; cmd('cmd://app/version'); setTimeout(() => { if (versionResolver === resolve) { versionResolver = null; resolve(''); } }, 2000); }); }, onVersionResult(v) { if (versionResolver) { const r = versionResolver; versionResolver = null; r(v || ''); } }, // Was the app launched at Windows boot (via the HKCU Run entry's // -tray flag), or did the user double-click the exe? // Returns 'auto' | 'manual' | '' (bridge offline). getLaunchMode() { if (!active) return Promise.resolve(''); return new Promise(resolve => { launchModeResolver = resolve; cmd('cmd://app/launch-mode'); setTimeout(() => { if (launchModeResolver === resolve) { launchModeResolver = null; resolve(''); } }, 2000); }); }, onLaunchModeResult(mode) { if (launchModeResolver) { const r = launchModeResolver; launchModeResolver = null; r(mode || ''); } }, // Open an http(s) URL in the user's default browser via ShellExecute. // Delphi validates the scheme so a malformed entry can't smuggle a // file:// or custom handler. openUrl(url) { if (!active) return; cmd('cmd://app/open-url?url=' + encodeURIComponent(url)); }, }; })(); // 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') || '', // Profile picture as a data URI. Loaded from the server at enterApp // (users.avatar_b64). Empty → the initials avatar is shown instead. avatarDataUri: '', // 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, // Auth-hash scheme of the current account. Drives which verifier formula // the client sends: 'pbkdf2-sha256-v2' → SHA256(keyHex + domain) so the // transmitted verifier is NOT the raw AES key; anything else → keyHex // (legacy / pre-decoupling accounts, byte-identical to before). Set from // the /login/challenge response, from the cold-start blob, or hardcoded // to v2 on register / master-pw change. hashAlgo: sessionStorage.getItem('hashAlgo') || '', cryptoKey: null, entries: [], trashed: [], trashedCount: 0, // server-side count, updated separately from state.trashed folders: [{ name: 'All', color: '', icon: '' }], // Index in filteredEntries() of the keyboard-focused card (j/k nav). // -1 = no cursor. Cleared on view/search change so it never points // outside the current list. cursorIdx: -1, 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 // Advanced filters — runtime only (cleared on lockVault). Set of string // keys; see FILTER_DEFS for the available predicates. activeFilters: new Set(), 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"}'), quickSearchHotkey: JSON.parse(localStorage.getItem('quickSearchHotkey') || '{"ctrl":true,"shift":true,"alt":false,"win":false,"key":"Q"}'), sidebarCollapsed: JSON.parse(localStorage.getItem('sidebarCollapsed') || '{"folders":false,"tags":false,"tools":false}'), // Fetch website favicons via the Delphi DuckDuckGo proxy. OFF by // default — opt-in because it sends each entry's domain to a third // party (DuckDuckGo). Synced because it's a portable preference. faviconsEnabled: localStorage.getItem('faviconsEnabled') === '1', // Show the "running in tray" balloon (and any future tray balloon). // Default ON — gates Shell_NotifyIcon NIF_INFO calls in PM.Bridge. trayNotificationsEnabled: localStorage.getItem('trayNotificationsEnabled') !== '0', // Days after which trashed entries are permanently purged. 0 = never. // Synced across devices because it's a user-level preference. trashAutoPurgeDays: parseInt(localStorage.getItem('trashAutoPurgeDays') || '0') || 0, passwordExpiryDays: parseInt(localStorage.getItem('passwordExpiryDays') || '0') || 0, // Table-view hidden columns. Array of keys that the user opted to // hide (e.g. ['folder', 'updated']). Synced across devices. tableColsHidden: (() => { try { return JSON.parse(localStorage.getItem('tableColsHidden') || '[]'); } catch { return []; } })(), editorPosition: localStorage.getItem('editorPosition') || 'right', confirmOnUnsaved: localStorage.getItem('confirmOnUnsaved') !== '0', // 'pw' (master only) | 'pin' (PIN only) | 'both' (master + PIN). 'pw' // is the safe default — any device without a configured PIN behaves // identically to the legacy unlock flow. unlockMode: localStorage.getItem('unlockMode') || 'pw', // Runtime mirror of the DPAPI blob existence — populated on init by // bridgePinStatus(). Editing this directly doesn't touch storage. pinConfigured: 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; } // Decoupled-verifier scheme marker + domain separator. When the account's // hash_algo is HASH_ALGO_V2, the verifier sent to the server is a one-way // SHA-256 of the key hex (domain-separated), NOT the key hex itself — so // intercepting the /login body no longer hands over the AES vault key. // The AES key (cryptoKey) is ALWAYS the raw PBKDF2 output regardless, so // entries stay decryptable and legacy accounts are unaffected. const HASH_ALGO_V2 = 'pbkdf2-sha256-v2'; const AUTH_VERIFIER_DOMAIN = 'pmserver/auth-verifier/v2'; async function sha256Hex(str) { const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str)); return bytesToHex(new Uint8Array(buf)); } // Map the raw PBKDF2 key hex → the verifier to transmit, per account algo. // v2 → domain-separated SHA-256 (decoupled from the key). Anything else → // the key hex verbatim (legacy behaviour, unchanged for existing accounts). async function verifierFromKeyHex(keyHex, algo) { if (algo === HASH_ALGO_V2) return await sha256Hex(keyHex + AUTH_VERIFIER_DOMAIN); return keyHex; } async function deriveKeyAndVerifier(pwd, saltHex, iterations, algo) { 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']); const verifier = await verifierFromKeyHex(bytesToHex(keyBytes), algo); return { cryptoKey, verifier }; } async function computeVerifier(pwd, saltHex, iterations, algo) { const r = await deriveKeyAndVerifier(pwd, saltHex, iterations, algo); 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); } // ---- Custom fields (per-entry encrypted JSON array) ---------------- // // Stored as: // vault_entries.custom_fields = base64 AES-GCM ciphertext of JSON // vault_entries.custom_fields_iv = base64 12-byte IV // Plaintext shape: // [{ "label": "PIN", "value": "1234", "is_secret": true }, ...] // // Same crypto pipeline as encrypted_password (reuses encryptPwd / // decryptPwd over the JSON string) so the master-pw rotation logic // works without any special-casing — it just sees one more ciphertext // blob per entry to re-encrypt. async function encryptCustomFields(fieldsArray) { if (!Array.isArray(fieldsArray) || fieldsArray.length === 0) return { encrypted: '', iv: '' }; return await encryptPwd(JSON.stringify(fieldsArray)); } async function decryptCustomFields(encB64, ivB64) { if (!encB64 || !ivB64) return []; const plain = await decryptPwd(encB64, ivB64); if (plain === '[ERROR]' || !plain) return []; try { const arr = JSON.parse(plain); return Array.isArray(arr) ? arr : []; } catch (e) { return []; } } // ============================================================ // FAVICONS (opt-in, cached server-side as base64 data URI) // ============================================================ // Extract a usable host from entry.site (we accept anything user-typed). // Returns '' for values that don't look like real hostnames — common case // is users storing a brand label ("Gitea", "Work GitHub") to help the // autofill matcher. Sending those to DDG would leak meaningless tokens // without ever producing an icon. function faviconHost(siteRaw) { if (!siteRaw) return ''; let s = String(siteRaw).trim().toLowerCase(); s = s.replace(/^https?:\/\//, '').replace(/^www\./, ''); s = s.split('/')[0].split(':')[0]; // Validate: dot-separated labels, only hostname-safe chars, TLD ≥ 2 // letters. Rejects "Gitea", "my work pwd", IP-like "1.2.3.4" stays // valid (DDG handles IPs gracefully). 253-char overall cap mirrors // the DNS spec. if (!s || s.length > 253) return ''; if (!/^[a-z0-9.-]+$/.test(s)) return ''; if (s.indexOf('.') < 1) return ''; if (!/\.[a-z]{2,}$/.test(s)) return ''; if (s.startsWith('.') || s.endsWith('.')) return ''; if (s.indexOf('..') >= 0) return ''; return s; } // Save the icon for one entry via the dedicated endpoint (no full PUT, // no re-encryption). Fire-and-forget: failures are silent so a flaky // network doesn't break the user's flow. async function saveEntryIcon(entryId, dataUri) { try { const r = await fetch(API + '/entries/' + entryId + '/icon', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ icon_b64: dataUri || '' }), }); if (!r.ok) { // Surface the server's reason so silent persistence failures // (size cap, auth) stop being invisible bugs. let msg = 'HTTP ' + r.status; try { const b = await r.json(); if (b && b.error) msg = b.error; } catch (_) {} toast('Icon NOT saved: ' + msg, 'error'); } } catch (e) { toast('Icon save failed: ' + (e && e.message || e), 'error'); } } // Fetch + save the favicon for one entry. Updates state.entries in-place // so the next render() picks it up. No-op if the entry already has one. // opts: { force: bypass "already has icon" skip, manual: bypass the global // faviconsEnabled toggle (for explicit user actions like the Refresh button) } async function ensureEntryFavicon(entry, opts) { opts = opts || {}; if (!Bridge.active) return; if (!opts.manual && !state.faviconsEnabled) return; if (!opts.force && entry.icon_b64) return; const host = faviconHost(entry.site); if (!host) return; const dataUri = await Bridge.fetchFavicon(host); if (!dataUri) return; entry.icon_b64 = dataUri; await saveEntryIcon(entry.id, dataUri); // Full render() — patching the avatar in place is fragile because // the avatar also contains the checkbox overlay. render(); } // Backfill: walk state.entries, fetch missing icons one at a time so we // don't hammer the upstream. Used by the "Refresh icons" button. async function backfillFavicons(force) { if (!Bridge.active) return; const all = state.entries; const eligible = all.filter(e => faviconHost(e.site)); const skipped = all.length - eligible.length; const targets = eligible.filter(e => force || !e.icon_b64); if (targets.length === 0) { if (skipped > 0) { toast('No icons to fetch — ' + skipped + ' entries have a non-domain site (e.g. "Gitea")', 'warning'); } else { toast('No icons to fetch'); } return; } toast('Fetching ' + targets.length + ' icon' + (targets.length === 1 ? '' : 's') + '…'); let ok = 0; for (const e of targets) { // Explicit user action — bypass the global toggle so the buttons // work even when "Fetch website icons" is OFF (the toggle only // gates auto-fetch on save). await ensureEntryFavicon(e, { force: !!force, manual: true }); if (e.icon_b64) ok++; } toast('Fetched ' + ok + ' / ' + targets.length + ' icons'); render(); } async function clearAllFavicons() { try { await fetch(API + '/entries/icons/all', { method: 'DELETE', headers: authHeaders(), }); } catch (e) { toast('Failed to clear icons', 'error'); return; } state.entries.forEach(e => { e.icon_b64 = null; }); render(); toast('Cached icons cleared'); } // ============================================================ // QUICK SEARCH MODAL (tray menu → fast password copy) // ============================================================ // // Trades on the autofill workflow when SendInput can't reach the target // (UIPI-elevated app, native non-text-input UI, etc.): right-click tray → // Quick search → type → Enter → password is on the clipboard, ready to // paste with Ctrl+V. App returns to whatever state it was in afterwards. let quickSearchSelected = 0; // When opened from the tray menu, we hide back to tray after the user // picks an entry — so the previously-foreground app comes back and // Ctrl+V drops the password in. let quickSearchHideAfter = false; // When opened by Ctrl+Shift+Q hotkey, Delphi has saved the foreground // HWND and is waiting for cmd://autofill/execute. On pick we SendInput // the password instead of copying to the clipboard. let quickSearchFillMode = false; function quickSearchScoreEntry(e, q) { if (!q) return 1; // empty query → all entries pass, ordering preserved const ql = q.toLowerCase(); const fields = [ (e.title || ''), (e.site || ''), (e.username || ''), ].map(x => x.toLowerCase()); let score = 0; fields.forEach((f, i) => { if (!f) return; if (f.startsWith(ql)) score += 100 - i; // strong prefix match else if (f.includes(ql)) score += 50 - i; // substring fallback }); return score; } function quickSearchRender() { const q = document.getElementById('quickSearchInput').value.trim(); const list = state.entries .map(e => ({ e, s: quickSearchScoreEntry(e, q) })) .filter(x => x.s > 0) .sort((a, b) => b.s - a.s) .slice(0, 8) .map(x => x.e); const box = document.getElementById('quickSearchResults'); box.innerHTML = ''; if (list.length === 0) { box.appendChild(el('div', { class: 'quick-search-empty' }, q ? 'No match for "' + q + '"' : 'No entries')); quickSearchSelected = 0; return; } if (quickSearchSelected >= list.length) quickSearchSelected = 0; if (quickSearchSelected < 0) quickSearchSelected = list.length - 1; list.forEach((e, i) => { const row = el('div', { class: 'quick-search-row' + (i === quickSearchSelected ? ' is-selected' : ''), 'data-id': String(e.id), }); // Avatar — favicon if cached, else initials. const avatar = el('div', { class: 'quick-search-avatar' }); if (e.icon_b64) { const img = el('img', { src: e.icon_b64, alt: '' }); img.addEventListener('error', () => { avatar.innerHTML = ''; avatar.textContent = initials(entryDisplayName(e)); }); avatar.appendChild(img); } else { avatar.textContent = initials(entryDisplayName(e)); } const main = el('div', { class: 'quick-search-main' }); main.appendChild(el('div', { class: 'quick-search-name' }, entryDisplayName(e))); if (e.username) main.appendChild(el('div', { class: 'quick-search-sub' }, e.username)); row.appendChild(avatar); row.appendChild(main); // Left click → full (user + Tab + password); Ctrl+click → password // only (step-2 forms / unlock screens). row.addEventListener('click', ev => quickSearchPickEntry(e, (ev.ctrlKey || ev.metaKey) ? 'pwd' : 'full')); // Right click → username only. preventDefault + stopPropagation so // the custom context menu (installCustomContextMenu) doesn't pop. row.addEventListener('contextmenu', ev => { ev.preventDefault(); ev.stopPropagation(); quickSearchPickEntry(e, 'user'); }); box.appendChild(row); }); } // mode: 'full' (user + Tab + password), 'user' (username only) or 'pwd' // (password only). In fill mode each maps to a SendInput variant; in copy // mode 'full' has no meaning so it falls back to copying the password. async function quickSearchPickEntry(entry, mode) { mode = mode || 'full'; // Fill mode (Ctrl+Shift+Q hotkey): SendInput directly into the HWND // Delphi saved when the hotkey fired. No clipboard touch. if (quickSearchFillMode) { if (mode === 'user') { const u = entry.username || ''; if (!u) { toast('No username on this entry', 'warning'); return; } if (Bridge.active) Bridge.executeAutofill(u, '', quickSearchHideAfter, 'user'); toast(entryDisplayName(entry) + ' · username sent'); } else { const pwd = await decryptPwd(entry.encrypted_password, entry.iv); if (pwd === '[ERROR]') { toast('Decryption error', 'error'); if (Bridge.active) Bridge.cancelAutofill(); return; } // 'full' → user + Tab + password (needs a username to make sense); // 'pwd' (or 'full' on an entry without a username) → password only. const u = (mode === 'full') ? (entry.username || '') : ''; // Single command — Delphi defers the SendInput by 60 ms then, // if hide_after=1, MinimizeToTray's AFTER the keystrokes land. // Hiding before SendInput would tip the Win10/11 anti-focus- // stealing rules into refusing to hand focus to the target. if (Bridge.active) Bridge.executeAutofill(u, pwd, quickSearchHideAfter); toast(entryDisplayName(entry) + (u ? ' · username + password sent' : ' · password sent')); } // Flags consumed — closeQuickSearchModal must not re-trigger. quickSearchFillMode = false; quickSearchHideAfter = false; closeQuickSearchModal(); return; } // Copy mode (tray / palette): no target window, so we can only place a // single value on the clipboard. 'user' copies the username, everything // else copies the password. if (mode === 'user') { const u = entry.username || ''; if (!u) { toast('No username on this entry', 'warning'); return; } if (Bridge.active) Bridge.copySecure(u, 30000); else { try { await navigator.clipboard.writeText(u); } catch (_) {} } toast('Username copied · clears in 30s'); } else { const pwd = await decryptPwd(entry.encrypted_password, entry.iv); if (pwd === '[ERROR]') { toast('Decryption error', 'error'); return; } if (Bridge.active) Bridge.copySecure(pwd, 30000); else { try { await navigator.clipboard.writeText(pwd); } catch (_) {} } toast(entryDisplayName(entry) + ' · password copied'); } // keepClipboard=true — we just copied, so minimising back to the tray // must NOT clear the clipboard (the 30s auto-clear still applies). closeQuickSearchModal(true); } // ============================================================ // CHEATSHEET — press '?' anywhere to see all hotkeys // ============================================================ // // Discovery aid. Built dynamically so adding a new hotkey only requires // extending CHEATSHEET_GROUPS — the overlay picks it up automatically. const CHEATSHEET_GROUPS = [ { title: 'Inside the app', items: [ { keys: ['Ctrl', 'K'], desc: 'Command palette / quick search' }, { keys: ['?'], desc: 'Show this cheatsheet' }, { keys: ['Esc'], desc: 'Close modal / panel / cheatsheet' }, { keys: ['Enter'], desc: 'Open / confirm / submit' }, ], }, { title: 'Global (Windows-only, works even when minimised)', items: [ { keys: ['Ctrl', 'Shift', 'L'], desc: 'Autofill username + password into the active window' }, { keys: ['Ctrl', 'Shift', 'P'], desc: 'Autofill password only (step-2 forms, unlock screens)' }, { keys: ['Ctrl', 'Shift', 'Q'], desc: 'Quick search → SendInput password into the active window' }, { keys: ['Ctrl', 'Shift', 'A'], desc: 'Quick-add a new entry pre-filled with the foreground window title' }, ], }, { title: 'Tray', items: [ { keys: ['Right-click tray'], desc: 'Open / Quick search… / Lock vault / Quit' }, { keys: ['Click tray'], desc: 'Restore window' }, ], }, { title: 'On each card', items: [ { keys: [{ icon: 'i-globe' }], desc: 'Open the site in your default browser' }, { keys: [{ icon: 'i-copy' }], desc: 'Copy password to the secure clipboard (auto-clears in 30s)' }, { keys: ['Click card'], desc: 'Open the entry details / edit panel' }, ], }, ]; function renderCheatsheet() { const body = document.getElementById('cheatsheetBody'); body.innerHTML = ''; CHEATSHEET_GROUPS.forEach(group => { const section = el('section', { class: 'cheatsheet-group' }); section.appendChild(el('h4', null, group.title)); const list = el('div', { class: 'cheatsheet-list' }); group.items.forEach(item => { const row = el('div', { class: 'cheatsheet-row' }); const kc = el('div', { class: 'cheatsheet-keys' }); item.keys.forEach((k, i) => { if (i > 0) kc.appendChild(el('span', { class: 'cheatsheet-plus' }, '+')); if (k && typeof k === 'object' && k.icon) { // SVG icon — wrap in kbd-shaped chip for visual consistency // with the text key chips next to it. const chip = el('span', { class: 'cheatsheet-icon-chip' }); chip.appendChild(icon(k.icon)); kc.appendChild(chip); } else { kc.appendChild(el('kbd', null, String(k))); } }); row.appendChild(kc); row.appendChild(el('div', { class: 'cheatsheet-desc' }, item.desc)); list.appendChild(row); }); section.appendChild(list); body.appendChild(section); }); } // ============================================================ // PASSWORD HISTORY — open the modal, decrypt previous versions, // optionally revert one into the current field. // ============================================================ async function openHistoryModal(entryId) { const modal = document.getElementById('historyModal'); const body = document.getElementById('historyBody'); body.innerHTML = ''; body.appendChild(el('div', { class: 'history-loading' }, 'Loading…')); modal.classList.remove('is-hidden'); let rows; try { rows = await fetch(API + '/entries/' + entryId + '/history', { headers: authHeaders(), }).then(r => r.ok ? r.json() : []); } catch (e) { rows = []; } body.innerHTML = ''; if (!rows.length) { body.appendChild(el('p', { class: 'history-empty' }, 'No previous versions yet — they accumulate on each save.')); return; } // Decrypt each row's stored ciphertext with the CURRENT vault key // (master-pw change wipes the history, so the key always works). const list = el('ul', { class: 'history-list' }); for (const row of rows) { const li = el('li', { class: 'history-row' }); const meta = el('div', { class: 'history-meta' }); meta.appendChild(el('span', { class: 'history-date' }, formatDateShort(row.changed_at) + ' · ' + row.changed_at.slice(11, 16))); let plain = ''; try { plain = await decryptPwd(row.encrypted_password, row.iv); } catch (_) { plain = '[ERROR]'; } if (plain === '[ERROR]') plain = ''; const preview = el('div', { class: 'history-preview' }); const isNote = (row.kind === 'note'); const snippet = isNote ? (plain.replace(/\s+/g, ' ').slice(0, 80) + (plain.length > 80 ? '…' : '')) : '•'.repeat(Math.max(plain.length, 8)); const valueSpan = el('span', { class: 'history-value' }, snippet); preview.appendChild(valueSpan); let revealed = false; if (!isNote) { const eye = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Show / hide' }); eye.appendChild(icon('i-eye')); eye.addEventListener('click', () => { revealed = !revealed; valueSpan.textContent = revealed ? plain : '•'.repeat(Math.max(plain.length, 8)); }); preview.appendChild(eye); } const copy = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Copy' }); copy.appendChild(icon('i-copy')); copy.addEventListener('click', () => { if (Bridge.active) Bridge.copySecure(plain, 30000); else { try { navigator.clipboard.writeText(plain); } catch (_) {} } toast('Copied · clears in 30s'); }); preview.appendChild(copy); const revert = el('button', { class: 'btn btn-ghost btn-xs', type: 'button' }); revert.appendChild(icon('i-rotate-ccw')); revert.appendChild(document.createTextNode(' Revert')); revert.addEventListener('click', () => { const target = isNote ? document.getElementById('soNoteBody') : document.getElementById('soPassword'); if (target) { target.value = plain; target.dispatchEvent(new Event('input', { bubbles: true })); soDirtyCheck(); toast('Restored — click Save to commit', 'warning'); } closeHistoryModal(); }); const actions = el('div', { class: 'history-actions' }); actions.appendChild(revert); li.appendChild(meta); li.appendChild(preview); li.appendChild(actions); list.appendChild(li); } body.appendChild(list); } function closeHistoryModal() { document.getElementById('historyModal').classList.add('is-hidden'); } function openCheatsheet() { renderCheatsheet(); document.getElementById('cheatsheetModal').classList.remove('is-hidden'); } function closeCheatsheet() { document.getElementById('cheatsheetModal').classList.add('is-hidden'); } function openQuickSearchModal(hideAfter, forFill) { const modal = document.getElementById('quickSearchModal'); const input = document.getElementById('quickSearchInput'); modal.classList.remove('is-hidden'); input.value = ''; quickSearchSelected = 0; quickSearchHideAfter = !!hideAfter; quickSearchFillMode = !!forFill; // Subtle hint to the user about what Enter will do. const hintEl = modal.querySelector('.quick-search-hint'); if (hintEl) { hintEl.textContent = forFill ? 'Enter = fill user+password · Shift+Enter = username · Ctrl+Enter = password · Esc = cancel' : 'Enter / click = copy password · Shift+Enter / right-click = copy username · Esc = close'; } quickSearchRender(); setTimeout(() => input.focus(), 50); } function closeQuickSearchModal(keepClipboard) { document.getElementById('quickSearchModal').classList.add('is-hidden'); // Fill-mode cancel: tell Delphi to drop the saved HWND so the next // /execute (e.g. an unrelated Ctrl+Shift+L) doesn't accidentally // target the stale window. if (quickSearchFillMode) { if (Bridge.active && typeof Bridge.cancelAutofill === 'function') Bridge.cancelAutofill(); quickSearchFillMode = false; } // If the modal was opened from the tray (window was hidden), restore // the previous "in tray" state so the user can paste straight into // the target app. Cancel (Esc / close X) also triggers this — they // came from the tray, they should go back to the tray. keepClipboard // is set by the copy path so minimising doesn't wipe the password we // just placed on the clipboard. if (quickSearchHideAfter) { quickSearchHideAfter = false; if (Bridge.active && typeof Bridge.minimizeToTray === 'function') Bridge.minimizeToTray(!!keepClipboard); } } // 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(); }); // Esc dismisses. Capture phase so it beats other listeners (e.g. // the keyboard cursor handler that also intercepts Escape). const escHandler = (ev) => { if (ev.key !== 'Escape') return; if (modal.classList.contains('is-hidden')) { document.removeEventListener('keydown', escHandler, true); return; } ev.stopPropagation(); closeTotpTool(); document.removeEventListener('keydown', escHandler, true); }; document.addEventListener('keydown', escHandler, true); } 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. // Only non-v2 accounts ever reach the KDF migration (v2 accounts are // 600k + decoupled → never signalled). Under a non-v2 algo the // verifier is the key hex, so both derivations round-trip exactly as // before; passing state.hashAlgo keeps it explicit. const oldVerifier = await computeVerifier(masterPwd, state.salt, fromIters, state.hashAlgo); const newVerifier = await computeVerifier(masterPwd, state.salt, toIters, state.hashAlgo); 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; const pinInput = ($('#loginPin') || {}).value || ''; const havePin = Bridge.active && state.pinConfigured; const mode = havePin ? (state.unlockMode || 'pw') : 'pw'; // ---- PIN-only mode ------------------------------------------------- if (mode === 'pin') { if (!pinInput) return; $('#loginBtn').disabled = true; const ok = await loginViaPin(pinInput); $('#loginBtn').disabled = false; $('#loginPin').value = ''; if (ok) { await enterApp(); return; } const fresh = await bridgePinStatus(); state.pinConfigured = fresh; applyAuthScreenMode(); $('#authHint').textContent = fresh ? 'Wrong PIN. Try again or "Use master password".' : 'Too many wrong PIN attempts. Sign in with your master password.'; return; } // ---- 'both' mode: master pw first, then PIN verification ---------- if (mode === 'both') { if (!u || !p || !pinInput) return; // Capture the PIN BEFORE the master-pw path clears the form, so // a verification error doesn't lose what the user just typed. const pendingPin = pinInput; // Fall through to the master-pw login below (return after we // tag a post-login PIN check). The hook is in enterApp via // window._pinAfterMaster. window._pinAfterMaster = pendingPin; } 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 }), }); // The challenge tells us the account's auth scheme; compute the // verifier accordingly (v2 → decoupled, else → key hex). state.hashAlgo = ch.hashAlgo || ''; const derived = await deriveKeyAndVerifier(p, ch.salt, ch.kdfIterations, state.hashAlgo); 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)); sessionStorage.setItem('hashAlgo', state.hashAlgo); // 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; // New accounts use the decoupled-verifier scheme (v2). state.hashAlgo = HASH_ALGO_V2; const derived = await deriveKeyAndVerifier(p, newSalt, newIters, HASH_ALGO_V2); const r = await api('/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: u, salt: newSalt, kdfIterations: newIters, verifier: derived.verifier, hashAlgo: HASH_ALGO_V2, }), }); 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)); sessionStorage.setItem('hashAlgo', state.hashAlgo); 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 = [{ name: 'All', color: '', icon: '' }]; 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. // Confirms with the user when there are unsaved edits BEFORE clearing // the session. Returns true if the lock/logout should proceed, false // if the user cancelled. async function confirmDiscardForSessionExit(action) { if (!$('#slideover').classList.contains('is-open')) return true; if (!state.confirmOnUnsaved) return true; if (!isSoDirty()) return true; return await confirmDialog({ title: action === 'logout' ? 'Log out without saving?' : 'Lock without saving?', message: 'You have edits in the open entry that haven\'t been saved. ' + (action === 'logout' ? 'Logging out' : 'Locking the vault') + ' will discard them.' + '

' + 'Tip: turn this prompt off in Settings → Appearance → Confirm before closing unsaved edits.' + '

', okText: action === 'logout' ? 'Log out' : 'Lock', danger: true, }); } function lockVault() { sessionStorage.removeItem('cryptoKey'); state.cryptoKey = null; state.entries = []; state.trashed = []; state.locked = true; state.justRecovered = false; state.avatarDataUri = ''; // reloaded from server on next unlock if (typeof authTickTimer !== 'undefined' && authTickTimer) { clearInterval(authTickTimer); authTickTimer = null; } if (typeof totpToolTimer !== 'undefined' && totpToolTimer) { clearInterval(totpToolTimer); totpToolTimer = null; } if (typeof healthCache !== 'undefined') healthCache = null; if (typeof auditCache !== 'undefined') auditCache = null; auditFilter = ''; state.activeFilters.clear(); // Force-close any open editor / Settings panel BEFORE switching to // the auth screen. Otherwise their .is-open class survives the lock // and (a) the centered-modal backdrop keeps blurring the auth screen // (b) the click-outside handler later prompts "discard changes?" for // the slideover the user can no longer interact with. soState = null; const so = $('#slideover'); if (so) so.classList.remove('is-open'); const sp = $('#settingsPanel'); if (sp) sp.classList.remove('is-open'); 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, state.hashAlgo); 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. The server returns object rows; // tolerate legacy string-only responses too (older deployments). const seen = (r || []).map(x => typeof x === 'string' ? { name: x, color: '', icon: '' } : { name: x.name, color: x.color || '', icon: x.icon || '' }); state.folders = [{ name: 'All', color: '', icon: '' }] .concat(seen.filter(f => f.name !== 'All')); } catch (e) { /* ignore */ } } // Lookup helpers — UI code refers to folders by name everywhere, so we // keep the name-keyed map for O(1) meta access without changing call sites. function folderByName(name) { return state.folders.find(f => f.name === name); } function folderMetaFor(name) { const f = folderByName(name); return { color: (f && f.color) || '', icon: (f && f.icon) || '' }; } 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 // ============================================================ // Advanced filter definitions. Each filter has a label (shown in dropdown // and chip), an icon, and a predicate. Predicates only inspect metadata // the server already returned — no decryption — so they run cheaply on // every render. "weak" / "pwned" / "old" read from already-computed caches // (HIBP / vault-health) so they're consistent with those views. const FILTER_DEFS = { 'kind-note': { label: 'Notes only', icon: 'i-edit', predicate: e => e.kind === 'note', }, 'kind-login': { label: 'Logins only', icon: 'i-key', predicate: e => (e.kind || 'login') === 'login', }, 'has-totp': { label: 'Has 2FA / TOTP', icon: 'i-shield', predicate: e => !!(e.totp_secret && e.totp_iv), }, 'no-totp-login': { label: 'Logins without 2FA', icon: 'i-alert', predicate: e => (e.kind || 'login') === 'login' && !(e.totp_secret && e.totp_iv), }, 'has-icon': { label: 'Has favicon', icon: 'i-globe', predicate: e => !!e.icon_b64, }, 'has-custom-fields': { label: 'Has custom fields', icon: 'i-list', predicate: e => !!(e.custom_fields && e.custom_fields_iv), }, 'pwned': { label: 'Pwned (HIBP)', icon: 'i-alert', predicate: e => { const n = state.hibpResults.get(e.id); return typeof n === 'number' && n > 0; }, }, 'favorite': { label: 'Favorites', icon: 'i-star', predicate: e => !!e.favorite, }, 'aged': { label: 'Aged password', icon: 'i-alert', predicate: e => isPasswordAged(e), }, }; function renderFiltersMenu() { const menu = document.getElementById('filtersMenu'); if (!menu) return; menu.innerHTML = ''; menu.appendChild(el('div', { class: 'filters-menu-title' }, 'Filters')); Object.entries(FILTER_DEFS).forEach(([key, def]) => { const row = el('label', { class: 'filters-menu-row' }); const cb = el('input', { type: 'checkbox', class: 'filters-menu-cb' }); cb.checked = state.activeFilters.has(key); cb.addEventListener('change', ev => { ev.stopPropagation(); if (cb.checked) state.activeFilters.add(key); else state.activeFilters.delete(key); state.currentPage = 1; renderFiltersBadge(); renderFilterChips(); render(); }); row.appendChild(cb); row.appendChild(icon(def.icon)); row.appendChild(el('span', null, def.label)); menu.appendChild(row); }); if (state.activeFilters.size > 0) { const clear = el('button', { class: 'filters-menu-clear', type: 'button', }, 'Clear all filters'); clear.addEventListener('click', ev => { ev.stopPropagation(); state.activeFilters.clear(); state.currentPage = 1; renderFiltersBadge(); renderFilterChips(); renderFiltersMenu(); render(); }); menu.appendChild(clear); } } function renderFiltersBadge() { const badge = document.getElementById('filtersCount'); if (!badge) return; const n = state.activeFilters.size; badge.textContent = String(n); badge.style.display = n > 0 ? '' : 'none'; } function renderFilterChips() { const bar = document.getElementById('filterChips'); if (!bar) return; bar.innerHTML = ''; if (state.activeFilters.size === 0) { bar.classList.add('is-hidden'); return; } bar.classList.remove('is-hidden'); state.activeFilters.forEach(key => { const def = FILTER_DEFS[key]; if (!def) return; const chip = el('span', { class: 'filter-chip' }); chip.appendChild(icon(def.icon)); chip.appendChild(el('span', null, def.label)); const x = el('button', { type: 'button', class: 'filter-chip-x', title: 'Remove' }); x.appendChild(icon('i-x')); x.addEventListener('click', ev => { ev.stopPropagation(); state.activeFilters.delete(key); state.currentPage = 1; renderFiltersBadge(); renderFilterChips(); renderFiltersMenu(); render(); }); chip.appendChild(x); bar.appendChild(chip); }); const clear = el('button', { type: 'button', class: 'filter-chips-clear' }, 'Clear all'); clear.addEventListener('click', ev => { ev.stopPropagation(); state.activeFilters.clear(); state.currentPage = 1; renderFiltersBadge(); renderFilterChips(); renderFiltersMenu(); render(); }); bar.appendChild(clear); } 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 === 'notes') list = list.filter(e => e.kind === 'note'); else if (state.view === 'recent') { // Top 10 actually-used entries, freshest first. accessed_at NULL // (never touched) sinks to the bottom and is filtered out so the // view only ever shows things the user reached for. list = list.filter(e => !!e.accessed_at) .sort((a, b) => (b.accessed_at || '').localeCompare(a.accessed_at || '')) .slice(0, 10); } 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) ); } // Advanced filters (AND). Each active filter key maps to a predicate; // the entry must satisfy ALL of them. if (state.activeFilters && state.activeFilters.size > 0) { for (const key of state.activeFilters) { const def = FILTER_DEFS[key]; if (def && typeof def.predicate === 'function') list = list.filter(def.predicate); } } // Trash view keeps the deletion order (newest first) — re-sorting feels // wrong for a recoverable archive. Recent view already sorted by // accessed_at; re-applying the user sort would defeat the purpose. if (state.view !== 'trash' && state.view !== 'recent') { list = sortEntries(list, state.sortBy, state.sortDir); } return list; } // Deterministic avatar colour: hash the username to a hue so the same // account always gets the same background (no flicker across renders). // Uses a fixed palette of pleasant saturated colours rather than raw // HSL so every avatar reads well on the dark chrome. const AVATAR_COLORS = [ '#e05a5a', '#e0895a', '#e0b45a', '#8bc34a', '#4caf82', '#4aa3c3', '#5a7be0', '#7b5ae0', '#b45ae0', '#e05a9e', ]; function avatarColorFor(name) { const s = String(name || '?'); let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; return AVATAR_COLORS[Math.abs(h) % AVATAR_COLORS.length]; } // Paint the top-right user avatar: custom picture if one is set (data // URI in state.avatarDataUri), otherwise the username's first letter on // a deterministic colour. function renderUserAvatar() { const el = $('#userAvatar'); if (!el) return; const pic = state.avatarDataUri || ''; if (pic) { el.style.backgroundImage = 'url("' + pic + '")'; el.style.backgroundColor = 'transparent'; el.textContent = ''; } else { el.style.backgroundImage = 'none'; el.style.backgroundColor = avatarColorFor(state.username); el.textContent = (state.username || '?').trim().charAt(0) || '?'; } // Keep the Settings preview (if the panel is open) in sync too. const prev = $('#settingAvatarPreview'); if (prev) { const pic2 = state.avatarDataUri || ''; if (pic2) { prev.style.backgroundImage = 'url("' + pic2 + '")'; prev.style.backgroundColor = 'transparent'; prev.textContent = ''; } else { prev.style.backgroundImage = 'none'; prev.style.backgroundColor = avatarColorFor(state.username); prev.textContent = (state.username || '?').trim().charAt(0) || '?'; } const rm = $('#settingAvatarRemove'); if (rm) rm.style.display = pic2 ? '' : 'none'; } } // Fetch the stored profile picture from the server and repaint. async function loadUserAvatar() { try { const r = await api('/avatar', { headers: authHeaders() }); state.avatarDataUri = (r && r.avatar_b64) || ''; } catch (_) { state.avatarDataUri = ''; } renderUserAvatar(); } // Downscale + re-encode a picked image file to a small square JPEG data // URI so we never store a multi-MB original. Returns a Promise. function processAvatarFile(file) { return new Promise((resolve, reject) => { // Read the file as a data: URI (not a blob: URL) — the app's CSP // allows `img-src 'self' data:` but NOT blob:, so an pointed // at an object URL would fail to load. const reader = new FileReader(); reader.onerror = () => reject(new Error('read failed')); reader.onload = () => { const img = new Image(); img.onload = () => { const size = 128; // final square px const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); // Center-crop to a square, then draw scaled into 128×128. const side = Math.min(img.width, img.height); const sx = (img.width - side) / 2; const sy = (img.height - side) / 2; ctx.drawImage(img, sx, sy, side, side, 0, 0, size, size); resolve(canvas.toDataURL('image/jpeg', 0.85)); }; img.onerror = () => reject(new Error('bad image')); img.src = reader.result; // data:image/...;base64,... }; reader.readAsDataURL(file); }); } async function uploadUserAvatar(file) { if (!file || !/^image\//.test(file.type)) return toast('Pick an image file', 'error'); let dataUri; try { dataUri = await processAvatarFile(file); } catch (_) { return toast('Could not read that image', 'error'); } try { await api('/avatar', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ avatar_b64: dataUri }), }); state.avatarDataUri = dataUri; renderUserAvatar(); toast('Profile picture updated'); } catch (e) { toast(e.message || 'Upload failed', 'error'); } } async function removeUserAvatar() { try { await api('/avatar', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ avatar_b64: '' }), }); state.avatarDataUri = ''; renderUserAvatar(); toast('Profile picture removed'); } catch (e) { toast(e.message || 'Failed', 'error'); } } function parseTags(s) { if (!s) return []; return s.split(',').map(t => t.trim()).filter(Boolean); } // ---- Search history (last 5 unique queries, localStorage-backed) ---- // Survives across sessions but not across port-changes (localStorage is // origin-keyed). Acceptable: stale terms don't outlive a server restart. const SEARCH_HISTORY_KEY = 'searchHistory'; const SEARCH_HISTORY_MAX = 5; function getSearchHistory() { try { const raw = localStorage.getItem(SEARCH_HISTORY_KEY); if (!raw) return []; const arr = JSON.parse(raw); return Array.isArray(arr) ? arr.filter(s => typeof s === 'string') : []; } catch { return []; } } function pushSearchHistory(term) { if (!term) return; const t = term.trim(); if (!t) return; const list = getSearchHistory().filter(s => s.toLowerCase() !== t.toLowerCase()); list.unshift(t); while (list.length > SEARCH_HISTORY_MAX) list.pop(); try { localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(list)); } catch {} } function removeSearchHistory(term) { const list = getSearchHistory().filter(s => s.toLowerCase() !== term.toLowerCase()); try { localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(list)); } catch {} renderSearchHistoryMenu(); } function clearSearchHistory() { try { localStorage.removeItem(SEARCH_HISTORY_KEY); } catch {} renderSearchHistoryMenu(); } function renderSearchHistoryMenu() { const menu = document.getElementById('searchHistoryMenu'); if (!menu) return; const list = getSearchHistory(); if (list.length === 0) { menu.classList.add('is-hidden'); return; } menu.innerHTML = ''; const header = el('div', { class: 'search-history-header' }); header.appendChild(el('span', null, 'Recent searches')); const clearBtn = el('button', { class: 'search-history-clear', type: 'button' }, 'Clear'); clearBtn.addEventListener('mousedown', ev => { // mousedown not click — blur on the input would hide the menu before // a regular click reaches the button. ev.preventDefault(); clearSearchHistory(); }); header.appendChild(clearBtn); menu.appendChild(header); list.forEach(term => { const row = el('div', { class: 'search-history-row' }); const item = el('button', { class: 'search-history-item', type: 'button' }); item.appendChild(el('span', null, term)); item.addEventListener('mousedown', ev => { ev.preventDefault(); $('#searchInput').value = term; state.search = term; state.currentPage = 1; renderGrid(); pushSearchHistory(term); // bumps to top menu.classList.add('is-hidden'); }); row.appendChild(item); const del = el('button', { class: 'search-history-del', type: 'button', title: 'Remove', }, '×'); del.addEventListener('mousedown', ev => { // mousedown to beat the input blur that would hide the menu first. ev.preventDefault(); ev.stopPropagation(); removeSearchHistory(term); }); row.appendChild(del); menu.appendChild(row); }); menu.classList.remove('is-hidden'); } 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 === 'notes') return 'Notes'; if (state.view === 'recent') return 'Recently used'; if (state.view === 'trash') return 'Trash'; if (state.view === 'authenticator') return 'Authenticator'; if (state.view === 'health') return 'Vault health'; if (state.view === 'audit') return 'Audit log'; if (state.view === 'folder:All') return '(no folder)'; 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(); renderFilterChips(); renderFiltersBadge(); renderGrid(); } function renderSidebar() { // counts $('#countAll').textContent = state.entries.length; $('#countFav').textContent = state.entries.filter(e => e.favorite).length; const noteCount = state.entries.filter(e => e.kind === 'note').length; const countNotes = document.getElementById('countNotes'); if (countNotes) countNotes.textContent = noteCount || ''; // 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(f => f.name !== '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(f => f.name !== 'All').forEach(folder => { const name = folder.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, draggable: 'true', on: { click: () => setView(key) }, }); // Custom icon if set, else default. Custom color tints the icon. const iconEl = icon(folder.icon || 'i-folder'); if (folder.color) iconEl.style.color = folder.color; item.appendChild(iconEl); item.appendChild(el('span', null, name)); item.appendChild(el('span', { class: 'nav-count' }, String(count))); const editBtn = el('button', { class: 'folder-edit', type: 'button', title: 'Customize folder', on: { click: ev => { ev.stopPropagation(); openFolderModal(folder); } }, }); editBtn.appendChild(icon('i-edit')); item.appendChild(editBtn); // Outgoing drag: reorder. Carries a custom MIME so the drop // handler can distinguish folder-on-folder (reorder) from // entry-on-folder (move entry). item.addEventListener('dragstart', e => { e.dataTransfer.setData('application/x-pm-folder', name); e.dataTransfer.effectAllowed = 'move'; item.classList.add('is-dragging'); }); item.addEventListener('dragend', () => item.classList.remove('is-dragging')); // Incoming drag: either an entry being moved here, OR another // folder being reordered above/below this one. item.addEventListener('dragover', e => { e.preventDefault(); // dataTransfer.types is DOMStringList in some engines (no // .includes) and frozen string[] in others — normalize first. const types = Array.from(e.dataTransfer.types || []); if (types.indexOf('application/x-pm-folder') !== -1) { // Compute insert position based on mouse Y vs item midline. const r = item.getBoundingClientRect(); const before = (e.clientY - r.top) < r.height / 2; item.classList.toggle('drop-before', before); item.classList.toggle('drop-after', !before); item.classList.remove('drag-over'); } else { item.classList.add('drag-over'); item.classList.remove('drop-before', 'drop-after'); } }); item.addEventListener('dragleave', () => item.classList.remove('drag-over', 'drop-before', 'drop-after')); item.addEventListener('drop', async e => { e.preventDefault(); const draggedFolder = e.dataTransfer.getData('application/x-pm-folder'); const before = item.classList.contains('drop-before'); item.classList.remove('drag-over', 'drop-before', 'drop-after'); if (draggedFolder && draggedFolder !== name) { await reorderFolderTo(draggedFolder, name, before); return; } const raw = e.dataTransfer.getData('text/plain') || ''; const ids = raw.split(',').map(s => parseInt(s)).filter(n => n > 0); if (ids.length) { await moveEntriesToFolder(ids, name); state.checked.clear(); } }); 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; // Show the pseudo-entry whenever the user has real folders in the // sidebar — it doubles as a drag target to uncategorise entries. // Also stays visible in the currently-viewed folder:All so a mid- // action move-out doesn't strand the user. const hasRealFolders = state.folders.some(f => f && f.name && f.name !== 'All'); if (uncatCount > 0 || state.view === 'folder:All' || hasRealFolders) { 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 raw = e.dataTransfer.getData('text/plain') || ''; const ids = raw.split(',').map(s => parseInt(s)).filter(n => n > 0); if (ids.length) { await moveEntriesToFolder(ids, 'All'); state.checked.clear(); } }); 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(','), kind: e.kind || 'login', totp_secret: e.totp_secret || '', totp_iv: e.totp_iv || '', custom_fields: e.custom_fields || '', custom_fields_iv: e.custom_fields_iv || '', }), }); e.tags = tags.join(','); render(); toast('Tagged "' + tag + '"'); } catch (err) { toast(err.message, 'error'); } } function renderGrid() { $('#contentTitle').textContent = viewTitle(); // Re-apply the keyboard cursor after the grid re-renders. Defer one // tick so the new cards are in the DOM before we query for them. setTimeout(applyCursorHighlight, 0); // Audit log: read-only chronological feed, custom render. if (state.view === 'audit') { if (authTickTimer) { clearInterval(authTickTimer); authTickTimer = null; } const oldBtn = $('#emptyTrashBtn'); if (oldBtn) oldBtn.remove(); renderBatchBar(); const grid = $('#entryGrid'); grid.className = 'entry-grid is-audit'; grid.innerHTML = ''; $('#emptyState').classList.add('is-hidden'); renderAuditLog(grid); return; } // Vault health dashboard: bypass the standard list rendering entirely. if (state.view === 'health') { if (authTickTimer) { clearInterval(authTickTimer); authTickTimer = null; } const oldBtn = $('#emptyTrashBtn'); if (oldBtn) oldBtn.remove(); renderBatchBar(); $('#contentMeta').textContent = state.entries.length + (state.entries.length === 1 ? ' entry analysed' : ' entries analysed'); const grid = $('#entryGrid'); grid.className = 'entry-grid is-health'; grid.innerHTML = ''; $('#emptyState').classList.add('is-hidden'); renderHealthDashboard(grid); return; } // 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); })(); } // ============================================================ // VAULT HEALTH dashboard // ============================================================ // // One-shot computation per session — decrypting every entry is the // expensive part, so we cache the result and clear it on lock / entry // edit / view re-entry (Tools → Vault health). let healthCache = null; // Audit log: array of {id, action, ip, created_at}. Refreshed on demand // from the server. Cleared on lockVault. let auditCache = null; let auditFilter = ''; // Per-category expand state. Survives full re-renders of the dashboard // (renderGrid runs from openSlideOver → would otherwise reset every // "Show all" toggle back to collapsed). const healthExpanded = { weak: false, reused: false, old: false, pwned: false }; const HEALTH_WEAK_THRESHOLD = 50; // computeStrength score < 50 → weak const HEALTH_OLD_DAYS = 365; // entries not updated in > 1 year function entryAgeDays(e) { const ts = e.updated_at || e.created_at; if (!ts) return 0; // ISO 'yyyy-mm-dd hh:nn:ss' → assume UTC-ish, close enough for ranking. const d = new Date(ts.replace(' ', 'T')); if (isNaN(d)) return 0; return Math.floor((Date.now() - d.getTime()) / 86400000); } async function computeHealthCache() { const weak = [], old = [], pwned = []; const byPwd = new Map(); // plaintext → [entries] // Notes have no password to weigh — their encrypted_password is just // the free-text body. Skipping them avoids polluting the "weak / reused" // categories with note content. for (const e of state.entries) { if ((e.kind || 'login') !== 'login') continue; const ageD = entryAgeDays(e); if (ageD > HEALTH_OLD_DAYS) old.push({ entry: e, ageDays: ageD }); const pwn = state.hibpResults.get(e.id); if (typeof pwn === 'number' && pwn > 0) pwned.push({ entry: e, count: pwn }); // Decrypt for strength + reuse detection. '[ERROR]' bubbles up // from decryptPwd for corrupted ciphertext — skip those silently. const plain = await decryptPwd(e.encrypted_password, e.iv); if (plain === '[ERROR]') continue; const score = computeStrength(plain); if (score < HEALTH_WEAK_THRESHOLD) weak.push({ entry: e, score }); if (!byPwd.has(plain)) byPwd.set(plain, []); byPwd.get(plain).push(e); } // Reuse: groups of ≥2 entries sharing the same plaintext password. const reused = []; for (const [, entries] of byPwd) { if (entries.length >= 2) reused.push(entries); } // Score: start at 100, subtract per issue (capped at 0). Weights // chosen so a single pwned password dominates over a single old one. let score = 100; score -= Math.min(40, weak.length * 5); score -= Math.min(30, reused.length * 10); score -= Math.min(20, old.length * 2); score -= Math.min(50, pwned.length * 15); if (score < 0) score = 0; return { weak, reused, old, pwned, score }; } function healthScoreBand(score) { if (score >= 80) return { label: 'Good', cls: 'is-ok' }; if (score >= 50) return { label: 'Fair', cls: 'is-fair' }; if (score >= 25) return { label: 'At risk', cls: 'is-warn' }; return { label: 'Critical', cls: 'is-danger' }; } // Open the entry slideover, unmask the password, focus it, and pulse the // generator button. The user keeps full context (which entry they're // fixing) and decides whether to type a new password, click the dice, or // dismiss. Auto-opening the generator modal hid the entry context and // forced an extra Save click — worse UX than this lighter nudge. async function openEntryForFix(entryId) { await openSlideOver(entryId); const pwd = document.getElementById('soPassword'); if (pwd) { pwd.type = 'text'; // unmask so the user sees what they're replacing pwd.focus(); pwd.select(); } const genBtn = document.querySelector('.so-pw-row button[title="Generate"]'); if (genBtn) { genBtn.classList.add('is-pulse'); setTimeout(() => genBtn.classList.remove('is-pulse'), 2000); } } async function renderHealthDashboard(grid) { // Recompute on demand. The "Recompute" button below also triggers it. if (!healthCache) { grid.appendChild(el('div', { class: 'health-loading' }, 'Analysing ' + state.entries.length + ' entries…')); healthCache = await computeHealthCache(); grid.innerHTML = ''; } const h = healthCache; const band = healthScoreBand(h.score); // Header: big score + recompute action const header = el('div', { class: 'health-header' }); const scoreEl = el('div', { class: 'health-score ' + band.cls }); scoreEl.appendChild(el('div', { class: 'health-score-num' }, String(h.score))); scoreEl.appendChild(el('div', { class: 'health-score-lbl' }, band.label)); header.appendChild(scoreEl); const intro = el('div', { class: 'health-intro' }); intro.appendChild(el('h3', null, 'How healthy is your vault?')); intro.appendChild(el('p', null, 'A summary of weak, reused, old and breached passwords. ' + 'Click any item to open it and rotate the password.')); const recompute = el('button', { class: 'btn btn-ghost btn-sm', type: 'button' }); recompute.appendChild(icon('i-rotate-ccw')); recompute.appendChild(document.createTextNode(' Recompute')); recompute.addEventListener('click', () => { healthCache = null; render(); }); intro.appendChild(recompute); header.appendChild(intro); grid.appendChild(header); // Four category cards grid.appendChild(renderHealthSection({ key: 'weak', title: 'Weak passwords', hint: 'Strength score below ' + HEALTH_WEAK_THRESHOLD + '/100 (short / few character classes).', items: h.weak, empty: 'All passwords pass the strength check. 👍', formatItem: it => entryDisplayName(it.entry) + ' — ' + it.score + '/100', })); grid.appendChild(renderHealthSection({ key: 'reused', title: 'Reused passwords', hint: 'Same password used on multiple entries — a single breach affects them all.', items: h.reused, empty: 'Every password is unique. 👍', formatItem: group => group.map(e => entryDisplayName(e)).join(' · ') + ' (' + group.length + ' entries)', // Click on a reused group: open the first entry. Could be smarter. idOfItem: group => group[0].id, })); grid.appendChild(renderHealthSection({ key: 'old', title: 'Old passwords', hint: 'Not updated for more than ' + Math.round(HEALTH_OLD_DAYS / 30) + ' months. Consider rotating periodically for high-value accounts.', items: h.old, empty: 'No stale passwords.', formatItem: it => entryDisplayName(it.entry) + ' — ' + Math.floor(it.ageDays / 30) + ' months old', })); grid.appendChild(renderHealthSection({ key: 'pwned', title: 'Breached passwords (HIBP)', hint: state.hibpEnabled ? 'Found in the Have I Been Pwned database. Change them now.' : 'Enable “Check passwords against breach database” in Settings to populate this list.', items: h.pwned, empty: state.hibpEnabled ? 'No password matches a known breach. 👍' : '— breach check is OFF —', formatItem: it => entryDisplayName(it.entry) + ' — seen ' + it.count.toLocaleString() + 'x', })); } // Build one collapsible category card. opts: // title, hint, items[], empty, // formatItem(item) → text for the row, // idOfItem(item) → entry id used by the Fix click. Default: item.entry.id function renderHealthSection(opts) { const card = el('section', { class: 'health-card' }); const head = el('header', { class: 'health-card-head' }); head.appendChild(el('h4', null, opts.title)); const badge = el('span', { class: 'health-badge' }, String(opts.items.length)); if (opts.items.length === 0) badge.classList.add('is-empty'); head.appendChild(badge); card.appendChild(head); card.appendChild(el('p', { class: 'health-hint' }, opts.hint)); if (opts.items.length === 0) { card.appendChild(el('p', { class: 'health-empty' }, opts.empty)); return card; } const list = el('ul', { class: 'health-list' }); const getId = opts.idOfItem || (it => it.entry.id); const INITIAL_LIMIT = 20; // Read persisted expand state so a renderGrid() triggered by // openSlideOver (after clicking "Fix") doesn't snap the list back // to the collapsed view. let expanded = !!(opts.key && healthExpanded[opts.key]); let shown = expanded ? opts.items.length : Math.min(INITIAL_LIMIT, opts.items.length); function renderRows() { list.innerHTML = ''; opts.items.slice(0, shown).forEach(it => { const li = el('li', { class: 'health-item' }); li.appendChild(el('span', { class: 'health-item-label' }, opts.formatItem(it))); const fix = el('button', { class: 'btn btn-ghost btn-xs', type: 'button' }, 'Fix'); fix.addEventListener('click', ev => { // Stop bubbling — the document-level "click outside slideover" // handler would otherwise close the slideover we're about to // open within the same click event. ev.stopPropagation(); openEntryForFix(getId(it)); }); li.appendChild(fix); list.appendChild(li); }); } renderRows(); card.appendChild(list); if (opts.items.length > INITIAL_LIMIT) { const more = el('button', { class: 'btn btn-ghost btn-xs health-more-btn', type: 'button', }, expanded ? 'Show less' : ('Show all ' + opts.items.length)); more.addEventListener('click', ev => { ev.stopPropagation(); expanded = !expanded; shown = expanded ? opts.items.length : INITIAL_LIMIT; more.textContent = expanded ? 'Show less' : 'Show all ' + opts.items.length; if (opts.key) healthExpanded[opts.key] = expanded; renderRows(); }); card.appendChild(more); } return card; } // ============================================================ // AUDIT LOG VIEWER // ============================================================ // // audit_log is filled by every sensitive action server-side // (add/edit/delete entry, autofill, lock, master-pw change, etc.). // 30-day auto-purge runs at server start. Frontend is read-only. async function renderAuditLog(grid) { if (!auditCache) { grid.appendChild(el('div', { class: 'audit-loading' }, 'Loading…')); try { const r = await fetch(API + '/audit?limit=500', { headers: authHeaders() }); auditCache = r.ok ? await r.json() : []; } catch (e) { auditCache = []; } grid.innerHTML = ''; } const total = auditCache.length; $('#contentMeta').textContent = total + (total === 1 ? ' event' : ' events'); // Header: search + refresh + clear notice const head = el('div', { class: 'audit-header' }); const search = el('input', { type: 'text', class: 'audit-search', placeholder: 'Filter actions (e.g. autofill, edit, delete)…', }); search.value = auditFilter; search.addEventListener('input', () => { auditFilter = search.value; renderAuditRows(); }); head.appendChild(search); const refresh = el('button', { class: 'btn btn-ghost btn-sm', type: 'button' }); refresh.appendChild(icon('i-rotate-ccw')); refresh.appendChild(document.createTextNode(' Refresh')); refresh.addEventListener('click', () => { auditCache = null; render(); }); head.appendChild(refresh); grid.appendChild(head); const note = el('p', { class: 'audit-note' }, 'Entries older than 30 days are auto-purged. Server-side.'); grid.appendChild(note); const list = el('div', { class: 'audit-list' }); grid.appendChild(list); function renderAuditRows() { list.innerHTML = ''; const q = auditFilter.trim().toLowerCase(); const filtered = q ? auditCache.filter(r => (r.action || '').toLowerCase().includes(q)) : auditCache; if (filtered.length === 0) { list.appendChild(el('p', { class: 'audit-empty' }, q ? 'No events match "' + q + '"' : 'No events recorded yet.')); return; } filtered.forEach(row => { const li = el('div', { class: 'audit-row' }); li.appendChild(el('span', { class: 'audit-date' }, row.created_at || '')); li.appendChild(el('span', { class: 'audit-action' }, row.action || '')); li.appendChild(el('span', { class: 'audit-ip' }, row.ip || '')); list.appendChild(li); }); } renderAuditRows(); } 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 === 'folder:All') { illustration.setAttribute('href', '#i-empty-vault'); title.textContent = 'No uncategorised entries'; msg.innerHTML = 'Every entry currently belongs to a folder. Drag one here to remove it from its folder.'; } 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 || ''; } // Decide if entry.site can be opened in a browser. Accepts: // "https://github.com/login" → kept as-is // "github.com" → prefixed with https:// // "Gitea" / "my note" → returns '' (no dot or not a hostname) // Returns the canonical URL to pass to ShellExecute, or '' if not openable. function entryOpenUrl(site) { if (!site) return ''; let s = String(site).trim(); // Already-scheme'd: only allow http(s). if (/^https?:\/\//i.test(s)) return s; if (/^[a-z][a-z0-9+.-]*:/i.test(s)) return ''; // ftp://, file://, mailto:… // Plain hostname or hostname/path. Require at least one dot and a // letter TLD ≥ 2 chars to avoid opening "Gitea" or "Brand name". const host = s.split('/')[0].split(':')[0].toLowerCase(); if (!host.includes('.')) return ''; if (!/\.[a-z]{2,}$/i.test(host)) return ''; return 'https://' + s; } function entryOpenInBrowser(url) { if (!url) return; if (Bridge.active && typeof Bridge.openUrl === 'function') { Bridge.openUrl(url); } else { // PHP frontend / fallback: regular window.open. try { window.open(url, '_blank', 'noopener'); } catch (e) {} } } // 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"). // Returns the age (in days) of an entry's password, falling back to // updated_at then created_at for legacy rows with no password_changed_at. function passwordAgeDays(e) { const stamp = e.password_changed_at || e.updated_at || e.created_at; if (!stamp) return 0; const ms = Date.now() - Date.parse(stamp); return Math.floor(ms / 86400000); } function isPasswordAged(e) { const limit = parseInt(state.passwordExpiryDays, 10) || 0; if (limit <= 0) return false; return passwordAgeDays(e) >= limit; } // ---- Keyboard cursor over cards (j/k + arrows) ---- // Stays inert when any input/textarea has focus, when a modal is open, // when an open slideover would steal Enter, or the user is mid-edit. function handleCardCursorKey(e) { if (e.ctrlKey || e.altKey || e.metaKey) return; const tag = (e.target && e.target.tagName || '').toLowerCase(); if (tag === 'input' || tag === 'textarea' || tag === 'select') return; if (e.target && e.target.isContentEditable) return; if (!$('#appShell') || $('#appShell').classList.contains('is-hidden')) return; // Don't fight focus when a modal is up. if (document.querySelector('.modal:not(.is-hidden)')) return; // Slideover open: Enter would commit Save, leave it alone. if ($('#slideover') && $('#slideover').classList.contains('is-open')) { if (e.key === 'Escape') return; // handled elsewhere } const list = filteredEntries(); if (list.length === 0) return; const goNext = (e.key === 'j' || e.key === 'ArrowDown'); const goPrev = (e.key === 'k' || e.key === 'ArrowUp'); if (goNext || goPrev) { e.preventDefault(); let i = state.cursorIdx; if (i < 0) { // Bootstrap to the first visible card on the current page, // not the very first filtered entry — otherwise pressing j // on page 3 would send focus to page 1. const start = (state.currentPage - 1) * state.pageSize; i = goNext ? start : Math.min(list.length - 1, start + state.pageSize - 1); } else { i = Math.max(0, Math.min(list.length - 1, i + (goNext ? 1 : -1))); } state.cursorIdx = i; // If the new cursor lies on a different page, flip to it. The // re-render reapplies the highlight via the deferred call in // renderGrid. const targetPage = Math.floor(i / state.pageSize) + 1; if (targetPage !== state.currentPage) { state.currentPage = targetPage; renderGrid(); } else { applyCursorHighlight(); } return; } if (e.key === 'Enter' && state.cursorIdx >= 0 && state.cursorIdx < list.length) { e.preventDefault(); openSlideOver(list[state.cursorIdx].id); return; } if (e.key === 'Escape' && state.cursorIdx >= 0) { state.cursorIdx = -1; applyCursorHighlight(); } } function applyCursorHighlight() { document.querySelectorAll('.entry-card.is-cursor, .entry-row.is-cursor') .forEach(el => el.classList.remove('is-cursor')); if (state.cursorIdx < 0) return; const list = filteredEntries(); const entry = list[state.cursorIdx]; if (!entry) return; const node = document.querySelector( '.entry-card[data-id="' + entry.id + '"], ' + '.entry-row[data-id="' + entry.id + '"]'); if (node) { node.classList.add('is-cursor'); node.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } } // Human-readable label for an entry template id. Returns '' for unknown // / unset templates so callers can fall back to the kind-based defaults. function templateLabel(tpl) { switch ((tpl || '').toLowerCase()) { case 'credit-card': return 'Credit card'; case 'ssh-key': return 'SSH key'; case 'server': return 'Server'; case 'recovery-codes': return 'Recovery codes'; default: return ''; } } 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) => { // Pinned entries always float to the top regardless of sort. Inside // each group (pinned / unpinned) the user's chosen sort applies. const pa = a.pinned ? 1 : 0, pb = b.pinned ? 1 : 0; if (pa !== pb) return pb - pa; 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 isNote = entry.kind === 'note'; const items = [ { lbl: entry.pinned ? 'Unpin' : 'Pin to top', ic: 'i-pin', fn: () => togglePin(entry.id) }, { lbl: entry.favorite ? 'Unfavorite' : 'Favorite', ic: 'i-star', fn: () => toggleFavorite(entry.id) }, { lbl: isNote ? 'Copy note content' : 'Copy password', ic: 'i-copy', fn: () => copyPassword(entry) }, ]; // Username is login-only — hide the menu item for notes (no username field). if (!isNote) items.push({ lbl: 'Copy username', ic: 'i-user', fn: () => copyUsername(entry) }); items.push( { 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 => { // If the dragged card is part of an active selection, carry // ALL checked ids so a drop on a folder / trash moves the // whole batch in one gesture. Otherwise carry just this one. let ids; if (state.checked.size > 1 && state.checked.has(e.id)) { ids = Array.from(state.checked).join(','); } else { ids = String(e.id); } ev.dataTransfer.setData('text/plain', ids); 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' }); if (e.icon_b64) { const img = el('img', { src: e.icon_b64, alt: '', class: 'entry-avatar-img' }); // If the cached data URI fails to decode (corrupt blob), fall // back to the initials so the card never shows a broken-image icon. img.addEventListener('error', () => { avatar.innerHTML = ''; avatar.textContent = initials(displayName); }); avatar.appendChild(img); } else { avatar.textContent = 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 pin = el('button', { class: 'entry-pin' + (e.pinned ? ' is-on' : ''), title: e.pinned ? 'Unpin' : 'Pin to top', on: { click: ev => { ev.stopPropagation(); togglePin(e.id); } }, }); pin.appendChild(icon('i-pin')); head.appendChild(pin); 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); // Body row: password placeholder for logins, content snippet for notes. const isNoteCard = (e.kind === 'note'); if (isNoteCard) { // Single placeholder line — the body is encrypted client-side, // we don't decrypt it eagerly for every card. Template-typed // entries surface their specific label so the card reads as // "Credit card" / "SSH key" / etc. rather than the generic note // copy. const noteRow = el('div', { class: 'entry-note-row' }); const tplLbl = templateLabel(e.template); noteRow.appendChild(el('span', { class: 'entry-note-placeholder' }, tplLbl || 'Encrypted note · click to read')); // Same crypto pipeline as a password — copyPassword decrypts the // body and drops it in the secure clipboard. const copyBtn = el('button', { class: 'icon-btn icon-btn-sm', title: 'Copy note content', on: { click: ev => { ev.stopPropagation(); copyPassword(e); } }, }); copyBtn.appendChild(icon('i-copy')); noteRow.appendChild(copyBtn); card.appendChild(noteRow); } else { 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); // Open URL — only when the site looks like a real http(s) target. const openUrl = entryOpenUrl(e.site); if (openUrl) { const openBtn = el('button', { class: 'icon-btn icon-btn-sm', title: 'Open ' + openUrl + ' in browser', on: { click: ev => { ev.stopPropagation(); entryOpenInBrowser(openUrl); } }, }); openBtn.appendChild(icon('i-globe')); pwRow.appendChild(openBtn); } 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 fm = folderMetaFor(e.folder); const chip = el('span', { class: 'entry-chip is-folder' }); const ic = icon(fm.icon || 'i-folder'); if (fm.color) ic.style.color = fm.color; chip.appendChild(ic); 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); } // Aged password — flagged when the user-configured expiry window // has elapsed since the password was last changed. Falls back to // updated_at / created_at for legacy rows. if (isPasswordAged(e)) { const days = passwordAgeDays(e); const chip = el('span', { class: 'entry-chip is-aged', title: 'Password unchanged for ' + days + ' days — time to rotate.', }); chip.appendChild(icon('i-alert')); chip.appendChild(el('span', null, 'Aged')); 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). // Keys that the column-picker can't toggle off — the table breaks // visually or behaviourally without them. const TABLE_PINNED_COLS = new Set(['check', 'name', 'actions']); // Optional columns offered in the picker. Order = picker order. const TABLE_TOGGLEABLE_COLS = [ { key: 'site', label: 'Site' }, { key: 'user', label: 'Username' }, { key: 'folder', label: 'Folder' }, { key: 'updated', label: 'Updated' }, ]; function getTableColumns() { const hidden = new Set(state.tableColsHidden || []); // Default behaviour: Site stays hidden unless the user opts in via // the column picker — matches the long-standing "Show site under // display name" behaviour while still letting the picker reveal it. if (!state.showSiteOnCards && !hidden.has('site')) hidden.add('site'); const cols = [ { key: 'check', label: '', sortKey: null }, { key: 'name', label: 'Name', sortKey: 'name' }, { key: 'site', label: 'Site', sortKey: 'site' }, { 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.filter(c => !hidden.has(c.key) || TABLE_PINNED_COLS.has(c.key)); } function renderTableColsMenu() { const menu = document.getElementById('colsMenu'); if (!menu) return; // Visible columns = those actually rendered by getTableColumns(). // Using that set keeps the picker in sync with the legacy // showSiteOnCards setting (which forces Site off by default). const visible = new Set(getTableColumns().map(c => c.key)); menu.innerHTML = ''; TABLE_TOGGLEABLE_COLS.forEach(c => { const item = el('label', { class: 'cols-menu-item' }); const cb = el('input', { type: 'checkbox' }); cb.checked = visible.has(c.key); cb.addEventListener('change', () => { // Site has a legacy second source of truth (showSiteOnCards in // Settings). The picker now owns it: a check enables the // setting + clears the hidden flag, an uncheck adds it to hidden. if (c.key === 'site') { state.showSiteOnCards = cb.checked; localStorage.setItem('showSiteOnCards', cb.checked ? '1' : '0'); } const next = new Set(state.tableColsHidden || []); if (cb.checked) next.delete(c.key); else next.add(c.key); state.tableColsHidden = Array.from(next); localStorage.setItem('tableColsHidden', JSON.stringify(state.tableColsHidden)); saveServerSettings(); renderGrid(); renderTableColsMenu(); }); item.appendChild(cb); item.appendChild(el('span', null, c.label)); menu.appendChild(item); }); } function applyColsWrapVisibility() { const wrap = document.getElementById('colsWrap'); if (!wrap) return; wrap.style.display = (state.viewMode === 'table') ? '' : 'none'; } 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); // Inline sort dropdown — same options as Settings → Appearance "Sort // entries by" but accessible without opening the settings panel. const sortSel = el('select', { class: 'pagination-size pagination-sort', on: { change: ev => { const [by, dir] = ev.target.value.split(':'); state.sortBy = by; state.sortDir = dir; state.currentPage = 1; localStorage.setItem('sortBy', state.sortBy); localStorage.setItem('sortDir', state.sortDir); saveServerSettings(); render(); } }, }); const SORT_OPTIONS = [ ['name:asc', 'Name A → Z'], ['name:desc', 'Name Z → A'], ['updated:desc', 'Recently updated'], ['updated:asc', 'Oldest updated'], ['created:desc', 'Recently created'], ['created:asc', 'Oldest created'], ]; const cur = state.sortBy + ':' + state.sortDir; SORT_OPTIONS.forEach(([v, label]) => { const opt = el('option', { value: v }, label); if (v === cur) opt.selected = true; sortSel.appendChild(opt); }); wrap.appendChild(sortSel); 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), draggable: !inTrash ? 'true' : 'false', on: { click: ev => handleCardClick(ev, e, inTrash) }, }); if (!inTrash) { tr.addEventListener('dragstart', ev => { let ids; if (state.checked.size > 1 && state.checked.has(e.id)) { ids = Array.from(state.checked).join(','); } else { ids = String(e.id); } ev.dataTransfer.setData('text/plain', ids); ev.dataTransfer.effectAllowed = 'move'; }); } // 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 displayName = entryDisplayName(e); const avatar = el('span', { class: 'entry-avatar entry-avatar-sm' }); if (e.icon_b64) { const img = el('img', { src: e.icon_b64, alt: '', class: 'entry-avatar-img' }); img.addEventListener('error', () => { avatar.innerHTML = ''; avatar.textContent = initials(displayName); }); avatar.appendChild(img); } else { avatar.textContent = initials(displayName); } td.appendChild(avatar); const nameWrap = el('span', { class: 'cell-name-wrap' }); nameWrap.appendChild(el('b', null, entryDisplayName(e))); if (e.kind === 'note') { const tplLbl = templateLabel(e.template); nameWrap.appendChild(el('span', { class: 'kind-badge', title: tplLbl || 'Secure note' }, (tplLbl || 'note').toLowerCase())); } 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.kind === 'note' ? '' : (e.site || '')); break; case 'user': { td = el('td', { class: 'col-user' }); if (e.kind === 'note') { // The badge in the Name column already advertises the // type (NOTE / SERVER / SSH KEY / …), so the username // column stays empty here — repeating "Server" twice // adds noise without information. td.appendChild(el('span', { class: 'col-user-note' }, '')); } else { 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: e.kind === 'note' ? 'Copy note content' : 'Copy password', on: { click: ev => { ev.stopPropagation(); copyPassword(e); } }, }); pwBtn.appendChild(icon('i-copy')); td.appendChild(pwBtn); // Open URL — only for login entries whose site looks like // a real http(s) target. Same gate as the card view. const openUrl = entryOpenUrl(e.site); if (e.kind !== 'note' && openUrl) { const openBtn = el('button', { class: 'icon-btn icon-btn-sm', title: 'Open in browser', on: { click: ev => { ev.stopPropagation(); entryOpenInBrowser(openUrl); } }, }); openBtn.appendChild(icon('i-globe')); td.appendChild(openBtn); } 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({ // Re-ship the full payload — partial PUT would wipe // TOTP / custom_fields / kind / template (see also // moveEntryToFolder and the "places à toucher" list // in CLAUDE.md). site: e.site, title: e.title || '', username: e.username, encrypted_password: e.encrypted_password, iv: e.iv, folder, tags: e.tags || '', kind: e.kind || 'login', totp_secret: e.totp_secret || '', totp_iv: e.totp_iv || '', custom_fields: e.custom_fields || '', custom_fields_iv: e.custom_fields_iv || '', }), }); 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(','), kind: e.kind || 'login', totp_secret: e.totp_secret || '', totp_iv: e.totp_iv || '', custom_fields: e.custom_fields || '', custom_fields_iv: e.custom_fields_iv || '', }), }); 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(); state.checked.clear(); render(); // full render so sidebar counts (Trash, folders, tags) refresh } 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(); state.checked.clear(); render(); } 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(); state.checked.clear(); render(); } 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.name }, folderLabel(f.name)))); 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; // Pre-fill bundles for common entry shapes. Picked from the new-entry // chevron menu. Each template seeds title + customFields + (for notes) // the body — the user fills in the actual values. Crypto pipeline is // unchanged: the fields go through the regular AES-GCM custom_fields // blob like any user-added field. // Helper lists for template dropdowns. Built once so each template // reuses the same array. const CARD_BRANDS = ['Visa', 'MasterCard', 'American Express', 'Discover', 'JCB', 'Diners Club', 'UnionPay', 'Maestro', 'Other']; const MONTHS_01_12 = ['01','02','03','04','05','06','07','08','09','10','11','12']; const NEXT_15_YEARS = (() => { const out = [], y = new Date().getFullYear(); for (let i = 0; i < 15; i++) out.push(String(y + i)); return out; })(); const SSH_PROTOCOLS = ['SSH', 'SFTP', 'SCP', 'rsync over SSH']; const SERVER_PROTOS = ['SSH', 'HTTP', 'HTTPS', 'FTP', 'SFTP', 'RDP', 'VNC', 'Telnet', 'PostgreSQL', 'MySQL', 'MongoDB', 'Redis', 'Other']; const ENTRY_TEMPLATES = { 'credit-card': { // Note-kind: site/password aren't required for cards. Card // number goes in a labelled field, not the password slot. kind: 'note', title: 'Credit card', body: '', customFields: [ { label: 'Cardholder name', value: '', is_secret: false }, { label: 'Card number', value: '', is_secret: false }, { label: 'Brand', value: '', is_secret: false, options: CARD_BRANDS }, { label: 'Expiration month',value: '', is_secret: false, options: MONTHS_01_12 }, { label: 'Expiration year', value: '', is_secret: false, options: NEXT_15_YEARS }, { label: 'Security code (CVV)', value: '', is_secret: true }, { label: 'PIN', value: '', is_secret: true }, ], }, 'ssh-key': { kind: 'note', title: 'SSH key', body: '', customFields: [ { label: 'Key name', value: '', is_secret: false }, { label: 'Fingerprint', value: '', is_secret: false }, { label: 'Hosts', value: '', is_secret: false }, { label: 'Protocol', value: '', is_secret: false, options: SSH_PROTOCOLS }, { label: 'Passphrase', value: '', is_secret: true }, { label: 'Private key', value: '', is_secret: true }, ], }, 'server': { kind: 'note', title: 'Server', body: '', customFields: [ { label: 'Hostname / IP', value: '', is_secret: false }, { label: 'Port', value: '', is_secret: false }, { label: 'Protocol', value: '', is_secret: false, options: SERVER_PROTOS }, { label: 'Root password', value: '', is_secret: true }, ], }, 'recovery-codes': { kind: 'note', title: 'Recovery codes', body: 'Paste your one-time recovery codes here, one per line.\n\n', customFields: [ { label: 'Service', value: '', is_secret: false }, { label: 'Account', value: '', is_secret: false }, ], }, }; // Open the slideover for either an existing entry (id = number) or a // brand-new one (id = null). opts: { presetTitle, presetSite, template } // pre-fill the corresponding fields for the new-entry path. async function openSlideOver(id, opts) { opts = opts || {}; const isNew = (id == null); const e = isNew ? null : state.entries.find(x => x.id === id); // Switching to another entry while the current edit has unsaved // changes would silently drop them — gate on the same confirm dialog // used by close paths. Only fires when the panel is already open AND // we're actually moving to a different target. const switching = $('#slideover').classList.contains('is-open') && soState && (soState.id !== id) && !(isNew && soState.id == null && soState.id === id); if (switching && state.confirmOnUnsaved && isSoDirty()) { const ok = await confirmDialog({ title: 'Discard unsaved changes?', message: 'You have edits in the open entry that haven\'t been saved. ' + 'Switch to the other entry anyway?' + '

' + 'Tip: turn this prompt off in Settings → Appearance → Confirm before closing unsaved edits.' + '

', okText: 'Discard', danger: true, }); if (!ok) return; } if (!isNew && !e) return; state.selectedId = isNew ? null : id; if (!isNew) touchEntry(id); // Templates: resolve a preset bundle of {kind, title, customFields, // body} and merge it into opts so the rest of this function sees a // pre-seeded new-entry payload. Only applies to fresh entries. const tplId = isNew ? opts.template : null; const tpl = tplId ? ENTRY_TEMPLATES[tplId] : null; if (tpl) { opts = Object.assign({}, opts, { kind: tpl.kind || opts.kind || 'login', presetTitle: tpl.title || opts.presetTitle, presetBody: tpl.body || '', presetFields: tpl.customFields || [], presetTemplate: tplId, }); } // Resolve kind early: opts.kind for new entries (login default), entry's // own kind for existing. Drives the field layout below. const kind = isNew ? (opts.kind || 'login') : (e.kind || 'login'); const isNote = (kind === 'note'); // Title prefix reflects mode + kind (note vs login). const titleEl = $('#slideoverTitle'); const kindLabel = isNote ? 'note' : 'entry'; titleEl.textContent = isNew ? ('+ New ' + kindLabel) : ('Edit · ' + entryDisplayName(e)); titleEl.classList.toggle('is-new-mode', isNew); titleEl.classList.toggle('is-edit-mode', !isNew); const body = $('#slideoverBody'); body.innerHTML = ''; let plain = isNew ? (opts.presetBody || '') : ''; let plainTotp = ''; let plainCustom = isNew ? (opts.presetFields || []).map(f => { const out = { label: f.label || '', value: f.value || '', is_secret: !!f.is_secret }; if (Array.isArray(f.options) && f.options.length > 0) out.options = f.options.slice(); return out; }) : []; if (!isNew) { 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.). if (e.totp_secret && e.totp_iv) { plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv); if (plainTotp === '[ERROR]') plainTotp = ''; } // Custom fields: same crypto pipeline, but the plaintext is a JSON // array of {label, value, is_secret}. if (e.custom_fields && e.custom_fields_iv) { plainCustom = await decryptCustomFields(e.custom_fields, e.custom_fields_iv); } } // Track original values so we can detect "dirty". For new entries the // originals are empty strings — typing anything triggers the Save button. const defaultFolder = state.view.startsWith('folder:') ? state.view.slice(7) : 'All'; soState = { id: isNew ? null : e.id, kind: kind, original: { site: isNew ? (opts.presetSite || '') : (e.site || ''), title: isNew ? (opts.presetTitle || '') : (e.title || ''), username: isNew ? '' : (e.username || ''), password: plain, // for notes this holds the note body folder: isNew ? defaultFolder : (e.folder || 'All'), tags: isNew ? '' : parseTags(e.tags).join(','), totp: plainTotp, }, tags: isNew ? [] : parseTags(e.tags), // Working copy of the custom-fields array — mutated in place by // buildCustomFieldRow handlers. The serialized JSON of this array // at Save time is what gets encrypted into custom_fields/iv. // customFields + originalCustomJson are assigned just after soState // is constructed (see below) so both sides of the dirty check use // the SAME normalized shape — comparing raw plainCustom against the // mapped working copy would falsely fire dirty on entries whose // stored blob carries extra/legacy keys. customFields: [], originalCustomJson: '[]', originalEncrypted: isNew ? null : e.encrypted_password, originalIV: isNew ? null : e.iv, originalTotpEncrypted: isNew ? null : e.totp_secret, originalTotpIV: isNew ? null : e.totp_iv, originalCustomEncrypted: isNew ? null : (e.custom_fields || null), originalCustomIV: isNew ? null : (e.custom_fields_iv || null), // Template identifier — propagated from the entry on edit, or // injected by the template picker on new. Sent back to the server // on Save so the card/table can label it correctly. template: isNew ? (opts.presetTemplate || '') : (e.template || ''), // Attachments staged BEFORE the entry exists server-side. Empty on // edit (existing attachments are fetched live via the dedicated // /entries/:id/attachments endpoint). Flushed by soSave after the // POST returns the new entry id. pendingAttachments: [], }; // Normalize the custom-fields array ONCE — the working copy and the // dirty-check baseline must share the same shape, otherwise stripped // legacy keys (empty options[], stray metadata) make the JSON diverge // on open and the entry looks dirty without any user input. soState.customFields = plainCustom.map(f => { const out = { label: f.label || '', value: f.value || '', is_secret: !!f.is_secret, }; if (Array.isArray(f.options) && f.options.length > 0) out.options = f.options.slice(); return out; }); soState.originalCustomJson = JSON.stringify(soState.customFields); if (isNote) { // Notes: minimal layout — name + multiline body + folder + tags. // No icon (covered by sidebar icon), no site/user/totp. body.appendChild(soEditableField('Title', 'soTitle', soState.original.title)); body.appendChild(soNoteBodyField(plain)); const hist = soHistoryButton(); if (hist) body.appendChild(hist); body.appendChild(soCustomFieldsField()); // For new entries the attachments are staged in soState.pendingAttachments // and uploaded after the entry has been created (we need its id). body.appendChild(soAttachmentsField(isNew ? null : id)); body.appendChild(soFolderField(soState.original.folder)); body.appendChild(soTagsField()); } else { // Login (existing layout). const eForIcon = isNew ? { id: null, icon_b64: null, site: soState.original.site, title: soState.original.title } : e; body.appendChild(soIconField(eForIcon)); body.appendChild(soEditableField('Display name', 'soTitle', soState.original.title)); body.appendChild(soEditableField('Site', 'soSite', soState.original.site)); body.appendChild(soEditableField('Username', 'soUsername', soState.original.username)); body.appendChild(soPasswordField(plain)); const histLogin = soHistoryButton(); if (histLogin) body.appendChild(histLogin); body.appendChild(soTotpField(plainTotp)); body.appendChild(soCustomFieldsField()); body.appendChild(soAttachmentsField(isNew ? null : id)); body.appendChild(soFolderField(soState.original.folder)); 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 (selectors absent for notes are ignored). ['#soTitle', '#soSite', '#soUsername', '#soPassword', '#soFolder', '#soNoteBody'].forEach(sel => { const el = $(sel); if (el) el.addEventListener('input', soDirtyCheck); if (el) el.addEventListener('change', soDirtyCheck); }); $('#slideover').classList.add('is-open'); // New entries: Save visible from the start so the action is obvious, // and focus the most relevant pivot field (Title for notes, Site for logins). if (isNew) { const save = document.getElementById('soSaveBtn'); if (save) save.style.display = ''; setTimeout(() => { const f = isNote ? document.getElementById('soTitle') : document.getElementById('soSite'); if (f) f.focus(); }, 50); } else { // Edit mode: focus the Title input so the user can start editing // immediately (especially when opened via Enter from j/k nav). // Defer so the slideover transition finishes first — otherwise // the browser may steal focus back during the animation. setTimeout(() => { const f = document.getElementById('soTitle'); if (f) { f.focus(); f.select(); } }, 80); } renderGrid(); } // "Show history" launcher — placed below the password field for logins, // below the note body for notes. Reads soState.id so it works in edit // mode only (new entries have no history yet). function soHistoryButton() { if (!soState || soState.id == null) return null; const wrap = el('div', { class: 'slideover-field so-history-wrap' }); const btn = el('button', { class: 'btn btn-ghost btn-xs', type: 'button', }); btn.appendChild(icon('i-rotate-ccw')); btn.appendChild(document.createTextNode(' Show previous versions')); btn.addEventListener('click', () => openHistoryModal(soState.id)); wrap.appendChild(btn); return wrap; } // Custom fields editor — dynamic list of {label, value, is_secret} rows. // Mutates soState.customFields in place; on change calls soDirtyCheck so // the Save button surfaces. The whole array is re-encrypted on Save (no // per-row IVs to keep simple). function soCustomFieldsField() { const wrap = el('div', { class: 'slideover-field so-custom-wrap' }); wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Custom fields')); const list = el('div', { class: 'so-custom-list', id: 'soCustomList' }); wrap.appendChild(list); function renderRows() { list.innerHTML = ''; (soState.customFields || []).forEach((f, idx) => { list.appendChild(buildCustomFieldRow(f, idx, renderRows)); }); } renderRows(); const addWrap = el('div', { class: 'so-custom-add' }); const addBtn = el('button', { class: 'btn btn-ghost btn-sm', type: 'button' }); addBtn.appendChild(icon('i-plus')); addBtn.appendChild(document.createTextNode(' Add field')); addBtn.addEventListener('click', ev => { // stopPropagation — the document-level "click outside slideover" // listener would otherwise see this click as outside (the button // isn't in any of the allow-listed containers) and close the panel. // Same pattern as #newEntryBtn, health-dashboard "Fix", etc. ev.stopPropagation(); soState.customFields = soState.customFields || []; soState.customFields.push({ label: '', value: '', is_secret: false }); renderRows(); soDirtyCheck(); setTimeout(() => { const inputs = list.querySelectorAll('.so-custom-label'); const last = inputs[inputs.length - 1]; if (last) last.focus(); }, 0); }); addWrap.appendChild(addBtn); wrap.appendChild(addWrap); return wrap; } function buildCustomFieldRow(field, idx, rerender) { const row = el('div', { class: 'so-custom-row' }); const labelInput = el('input', { type: 'text', class: 'so-input so-custom-label', placeholder: 'Label (e.g. PIN, Account #)', autocomplete: 'off', spellcheck: 'false', }); labelInput.value = field.label || ''; labelInput.addEventListener('input', () => { field.label = labelInput.value; soDirtyCheck(); }); // Value field. When the field declares an `options` array (entry // templates: card brand, expiry year/month, etc.) we wrap the input in // a CUSTOM editable combobox: an arrow button that drops a menu of ALL // options (unlike a native , which filters to what's typed), // while the input stays freely typeable for a value not in the list. // Storage shape unchanged — `field.value` holds the string either way. let valueInput; let valueSlot; // what actually goes into the row (input or combo wrap) const hasOptions = Array.isArray(field.options) && field.options.length > 0; valueInput = el('input', { type: field.is_secret ? 'password' : 'text', class: 'so-input so-custom-value', placeholder: hasOptions ? 'Pick or type…' : 'Value', autocomplete: field.is_secret ? 'new-password' : 'off', spellcheck: 'false', }); valueInput.value = field.value || ''; valueInput.addEventListener('input', () => { field.value = valueInput.value; soDirtyCheck(); }); if (hasOptions && !field.is_secret) { const combo = el('div', { class: 'so-combo' }); valueInput.classList.add('so-combo-input'); const arrow = el('button', { class: 'so-combo-arrow', type: 'button', tabindex: '-1', title: 'Show options', }); arrow.appendChild(icon('i-chevron-down')); const menu = el('div', { class: 'so-combo-menu is-hidden' }); field.options.forEach(opt => { const item = el('div', { class: 'so-combo-item' }, opt); // mousedown (not click) + preventDefault so the input doesn't // blur-close the menu before we read the choice. item.addEventListener('mousedown', ev => { ev.preventDefault(); ev.stopPropagation(); valueInput.value = opt; field.value = opt; soDirtyCheck(); menu.classList.add('is-hidden'); }); menu.appendChild(item); }); arrow.addEventListener('click', ev => { ev.stopPropagation(); // Close any other open combo first, then toggle this one. document.querySelectorAll('.so-combo-menu:not(.is-hidden)') .forEach(m => { if (m !== menu) m.classList.add('is-hidden'); }); menu.classList.toggle('is-hidden'); }); combo.appendChild(valueInput); combo.appendChild(arrow); combo.appendChild(menu); valueSlot = combo; } else { valueSlot = valueInput; } // Reveal eye — only meaningful for secret fields. const eye = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Show / hide' }); eye.appendChild(icon('i-eye')); // All button handlers below stopPropagation — see CLAUDE.md // "Click-outside-slideover bug" for why. eye.addEventListener('click', ev => { ev.stopPropagation(); if (!field.is_secret) return; // No-op on on next open (otherwise it would silently degrade // to a free-text input after the first save). if (Array.isArray(f.options) && f.options.length > 0) { out.options = f.options.slice(); } return out; }); let cfEnc = '', cfIv = ''; const curCustomJson = JSON.stringify(cleanCustom); if (cleanCustom.length === 0) { // Empty array → send empty strings → server stores NULL. cfEnc = ''; cfIv = ''; } else if (!isNew && curCustomJson === soState.originalCustomJson && soState.originalCustomEncrypted) { cfEnc = soState.originalCustomEncrypted; cfIv = soState.originalCustomIV; } else { const e = await encryptCustomFields(cleanCustom); cfEnc = e.encrypted; cfIv = e.iv; } const body = JSON.stringify({ site, title: title.trim(), username: user, encrypted_password: enc.encrypted, iv: enc.iv, totp_secret: totpEnc, totp_iv: totpIv, custom_fields: cfEnc, custom_fields_iv: cfIv, folder: fold, tags: soState.tags.join(','), kind, template: soState.template || '', }); try { let targetId = soState.id; if (isNew) { const r = await api('/entries', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body, }); if (r && typeof r.id === 'number') targetId = r.id; // Flush staged attachments now that we have the entry id. // Best-effort: a single failure doesn't break the save, but // surfaces a warning so the user knows to retry from the // re-opened slideover. const staged = soState.pendingAttachments || []; if (staged.length > 0 && typeof targetId === 'number') { let attachOk = 0, attachFail = 0; for (const att of staged) { try { const { encrypted, iv } = await encryptBlobBytes(att.bytes); await api('/entries/' + targetId + '/attachments', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ filename: att.filename, mime: att.mime, encrypted_blob: encrypted, iv, size_bytes: att.size_bytes, }), }); attachOk++; } catch (_) { attachFail++; } } soState.pendingAttachments = []; if (attachFail > 0) toast(attachOk + ' attachment(s) saved · ' + attachFail + ' failed', 'warning'); else toast(attachOk + ' attachment(s) saved'); } else { toast('Saved'); } } else { await api('/entries/' + soState.id, { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), body, }); toast('Saved'); } await loadEntries(); // Invalidate the vault-health cache so the new/updated entry // is reflected the next time the dashboard renders. if (typeof healthCache !== 'undefined') healthCache = null; const updated = state.entries.find(x => x.id === targetId); // Clear soState BEFORE re-opening so openSlideOver doesn't mistake // the re-open for a "switch entry while dirty" — the form still // holds the just-saved values but soState.original is stale, which // would falsely trigger the discard-confirm modal and (on Cancel) // leave soState at id=null, causing a second Save to POST again // and create a duplicate. soState = null; if (updated) openSlideOver(updated.id); else closeSlideOver(); render(); if (isNew && targetId) { flashEntry(targetId); // Auto-fetch favicon only for logins (notes don't have a site). if (state.faviconsEnabled && updated && (updated.kind || 'login') === 'login') ensureEntryFavicon(updated); } } 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(); // Blur any input inside the slideover BEFORE we hide it — otherwise // focus lingers on an invisible field and Edge's saved-form-data // popup ("Informations enregistrées") can still pop on arrow-down / // backspace, leaking past values to the user-visible UI. const ae = document.activeElement; if (ae && $('#slideover').contains(ae) && typeof ae.blur === 'function') ae.blur(); $('#slideover').classList.remove('is-open'); state.selectedId = null; renderGrid(); } // Gate every dismissal path through this so unsaved changes aren't lost // silently when the user clicks outside, presses Esc, switches entry, // or clicks the X. Returns true if the close went through, false if the // user cancelled. Async because the confirm dialog awaits user input. async function requestCloseSlideOver() { if (!$('#slideover').classList.contains('is-open')) return true; if (state.confirmOnUnsaved && isSoDirty()) { const ok = await confirmDialog({ title: 'Discard unsaved changes?', message: 'You have edits that haven\'t been saved. ' + 'Close anyway?' + '

' + 'Tip: turn this prompt off in Settings → Appearance → Confirm before closing unsaved edits.' + '

', okText: 'Discard', danger: true, }); if (!ok) return false; } closeSlideOver(); return true; } // ============================================================ // 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.name }, folderLabel(f.name)))); } 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(); if (typeof healthCache !== 'undefined') healthCache = null; render(); if (savedId) flashEntry(savedId); // Fire-and-forget favicon fetch for the saved entry. Updates the // card in place when it arrives. No-op when feature is off or // the entry already has a cached icon. if (savedId && state.faviconsEnabled) { const saved = state.entries.find(e => e.id === savedId); if (saved) ensureEntryFavicon(saved); // honours the toggle } } catch (err) { toast(err.message, 'error'); } } async function restoreEntry(id) { try { await api('/entries/' + id + '/restore', { method: 'POST', headers: authHeaders() }); toast('Restored'); state.checked.delete(id); 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'); state.checked.delete(id); 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'); state.checked.clear(); 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 || '', // Preserve the source kind — without this notes were // sent without 'kind', the server defaulted to 'login', // then rejected the empty site as "Site required". kind: entry.kind || 'login', // Carry the encrypted custom-fields blob across as-is; it's // already encrypted with the current vault key so the copy // decrypts the same way as the source. custom_fields: entry.custom_fields || '', custom_fields_iv: entry.custom_fields_iv || '', // Carry the cached favicon / custom icon over too. Plain // text data URI, server-side cap applies. icon_b64: entry.icon_b64 || '', // Carry the template identifier so the copy keeps the // same card / table label as the source. template: entry.template || '', }), }); // Copy attachments. The source's encrypted blobs are keyed to the // current vault key, so we just download → re-upload (no re-encrypt // needed; same key on both sides). if (r && typeof r.id === 'number') { try { const metas = await api('/entries/' + entry.id + '/attachments', { headers: authHeaders() }); for (const m of (metas || [])) { const full = await api('/attachments/' + m.id, { headers: authHeaders() }); await api('/entries/' + r.id + '/attachments', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ filename: m.filename, mime: m.mime || 'application/octet-stream', encrypted_blob: full.encrypted_blob, iv: full.iv, size_bytes: m.size_bytes, }), }); } } catch (_) { /* best-effort — copy may end up partial */ } } 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(); state.checked.delete(id); 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 togglePin(id) { try { await api('/entries/' + id + '/pin', { method: 'POST', headers: authHeaders() }); const entry = state.entries.find(e => e.id === id); if (entry) entry.pinned = entry.pinned ? 0 : 1; render(); toast(entry && entry.pinned ? 'Pinned to top' : 'Unpinned'); } catch (e) { toast(e.message, 'error'); } } async function moveEntriesToFolder(ids, folder) { let ok = 0, skipped = 0; for (const id of ids) { const e = state.entries.find(x => x.id === id); if (!e) continue; if (e.folder === folder) { skipped++; 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 || '', kind: e.kind || 'login', totp_secret: e.totp_secret || '', totp_iv: e.totp_iv || '', custom_fields: e.custom_fields || '', custom_fields_iv: e.custom_fields_iv || '', }), }); e.folder = folder; ok++; } catch (err) { /* try the rest */ } } state.checked.clear(); render(); if (ok === 0 && skipped > 0) return; if (ok === 1) toast('Moved to ' + folder); else if (ok > 1) toast('Moved ' + ok + ' entries to ' + folder); } async function moveEntryToFolder(id, folder) { const e = state.entries.find(x => x.id === id); if (!e || e.folder === folder) return; try { // PUT requires the full payload — missing fields default server-side // (kind='login' wipes notes through the "Site required" check; // totp_*/custom_fields_* default to empty → cleared via .Clear). // Re-ship every blob bit-for-bit; only `folder` actually changes. 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 || '', kind: e.kind || 'login', totp_secret: e.totp_secret || '', totp_iv: e.totp_iv || '', custom_fields: e.custom_fields || '', custom_fields_iv: e.custom_fields_iv || '', }), }); 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); } touchEntry(entry.id); } 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')); } touchEntry(entry.id); } // Bump accessed_at on the server (fire-and-forget) AND in local state so // the "Recent" view updates without a full reload. Coalesces rapid calls // for the same id within 2s to avoid hammering the server on bulk copies. const _touchTimers = {}; function touchEntry(id) { if (!id) return; const now = new Date().toISOString(); const e = state.entries.find(x => x.id === id); if (e) e.accessed_at = now; if (_touchTimers[id]) return; _touchTimers[id] = setTimeout(() => { delete _touchTimers[id]; fetch(API + '/entries/' + id + '/touch', { method: 'POST', headers: authHeaders(), }).catch(() => {}); // silent — local state already reflects it }, 2000); } // ============================================================ // ENCRYPTED ATTACHMENTS // ============================================================ // Per-entry files (PDFs, backup-code images, recovery sheets, …) // encrypted client-side with the vault key, base64-shipped to the server // for opaque storage. Listing returns metadata only — the ciphertext is // fetched on demand when the user clicks Download. const ATTACHMENT_MAX_BYTES = 5 * 1024 * 1024; // raw file size cap (5 MB) async function encryptBlobBytes(bytes) { const iv = crypto.getRandomValues(new Uint8Array(12)); const ct = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, state.cryptoKey, bytes); return { encrypted: bytesToBase64(ct), iv: bytesToBase64(iv) }; } async function decryptBlobBytes(encB64, ivB64) { const ct = base64ToBytes(encB64); const iv = base64ToBytes(ivB64); const dec = await crypto.subtle.decrypt( { name: 'AES-GCM', iv }, state.cryptoKey, ct); return new Uint8Array(dec); } function humanFileSize(n) { if (n < 1024) return n + ' B'; if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; return (n / 1024 / 1024).toFixed(2) + ' MB'; } async function uploadAttachment(entryId, file) { if (!file) return null; if (file.size > ATTACHMENT_MAX_BYTES) { toast('File too large (max 5 MB raw)', 'error'); return null; } const bytes = new Uint8Array(await file.arrayBuffer()); const { encrypted, iv } = await encryptBlobBytes(bytes); const meta = await api('/entries/' + entryId + '/attachments', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ filename: file.name, mime: file.type || 'application/octet-stream', encrypted_blob: encrypted, iv, size_bytes: file.size, }), }); return meta; } async function downloadAttachment(att) { // Spinner for large attachments — fetch + decrypt + chunked transfer // of a multi-MB file takes a moment before the Save dialog appears. const big = (att.size_bytes || 0) > 512 * 1024; try { if (big) showBusy('Preparing download…'); const full = await api('/attachments/' + att.id, { headers: authHeaders() }); const bytes = await decryptBlobBytes(full.encrypted_blob, full.iv); if (Bridge.active && typeof Bridge.saveFile === 'function') { const res = await Bridge.saveFile(att.filename, bytes, pct => { if (big) updateBusy('Preparing download… ' + pct + '%'); }); if (big) hideBusy(); if (res.ok) toast('Saved to ' + res.path); else if (res.error) toast('Save failed: ' + res.error, 'error'); } else { if (big) hideBusy(); // Web fallback: trigger a browser download. const blob = new Blob([bytes], { type: att.mime || 'application/octet-stream' }); const url = URL.createObjectURL(blob); const a = el('a', { href: url, download: att.filename }); document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100); } } catch (e) { if (big) hideBusy(); toast('Download failed: ' + (e && e.message ? e.message : e), 'error'); } } // Slideover field: header + upload button + list of attachments. // Re-renders the list in place when the contents change (upload/delete). function soAttachmentsField(entryId) { const isStaging = (entryId === null || entryId === undefined); const wrap = el('div', { class: 'slideover-field' }); wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Attachments')); const addBtn = el('button', { class: 'btn btn-ghost btn-sm', type: 'button', title: 'Attach a file (encrypted before upload, max 5 MB)', }); addBtn.appendChild(icon('i-paperclip')); addBtn.appendChild(document.createTextNode(' Attach file')); wrap.appendChild(addBtn); const fileInput = el('input', { type: 'file', style: 'display:none' }); wrap.appendChild(fileInput); const list = el('div', { class: 'so-attach-list', id: 'soAttachList' }); wrap.appendChild(list); addBtn.addEventListener('click', ev => { ev.stopPropagation(); fileInput.click(); }); fileInput.addEventListener('change', async ev => { ev.stopPropagation(); const file = fileInput.files && fileInput.files[0]; if (!file) return; fileInput.value = ''; // allow re-uploading the same name later if (file.size > ATTACHMENT_MAX_BYTES) { toast('File too large (max 5 MB raw)', 'error'); return; } addBtn.disabled = true; try { if (isStaging) { // Stage in memory — uploaded by soSave after the new // entry's id is known. const bytes = new Uint8Array(await file.arrayBuffer()); soState.pendingAttachments.push({ filename: file.name, mime: file.type || 'application/octet-stream', size_bytes: file.size, bytes, }); soDirtyCheck(); renderStagedAttachments(list); } else { await uploadAttachment(entryId, file); toast('Attachment uploaded'); await refreshAttachments(entryId, list); } } catch (e) { toast('Upload failed: ' + e.message, 'error'); } finally { addBtn.disabled = false; } }); if (isStaging) renderStagedAttachments(list); else refreshAttachments(entryId, list); return wrap; } // Renders the pendingAttachments array (new-entry mode). Mirrors the // existing-entry layout so users can't tell the difference until they // hit Save. function renderStagedAttachments(container) { container.innerHTML = ''; const list = soState.pendingAttachments || []; if (list.length === 0) { container.appendChild(el('div', { class: 'so-attach-empty' }, 'No attachments yet. Files will upload after you save.')); return; } list.forEach((att, idx) => { const row = el('div', { class: 'so-attach-row' }); const info = el('div', { class: 'so-attach-info' }); info.appendChild(el('div', { class: 'so-attach-name', title: att.filename }, att.filename)); info.appendChild(el('div', { class: 'so-attach-meta' }, humanFileSize(att.size_bytes) + ' · ' + (att.mime || '?') + ' · pending')); row.appendChild(info); const del = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Remove' }); del.appendChild(icon('i-trash')); del.addEventListener('click', ev => { ev.stopPropagation(); soState.pendingAttachments.splice(idx, 1); soDirtyCheck(); renderStagedAttachments(container); }); row.appendChild(del); container.appendChild(row); }); } async function refreshAttachments(entryId, container) { container.innerHTML = ''; let items = []; try { items = await api('/entries/' + entryId + '/attachments', { headers: authHeaders() }); } catch (e) { container.appendChild(el('div', { class: 'so-attach-empty' }, 'Failed to load attachments')); return; } if (!items || items.length === 0) { container.appendChild(el('div', { class: 'so-attach-empty' }, 'No attachments yet.')); return; } items.forEach(att => container.appendChild(buildAttachmentRow(att, entryId, container))); } function buildAttachmentRow(att, entryId, listContainer) { const row = el('div', { class: 'so-attach-row' }); const name = el('div', { class: 'so-attach-name', title: att.filename }, att.filename); const meta = el('div', { class: 'so-attach-meta' }, humanFileSize(att.size_bytes) + ' · ' + (att.mime || '?')); const info = el('div', { class: 'so-attach-info' }); info.appendChild(name); info.appendChild(meta); row.appendChild(info); const dl = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Download' }); dl.appendChild(icon('i-download')); dl.addEventListener('click', ev => { ev.stopPropagation(); downloadAttachment(att); }); row.appendChild(dl); const del = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Delete' }); del.appendChild(icon('i-trash')); del.addEventListener('click', async ev => { ev.stopPropagation(); const ok = await confirmDialog({ title: 'Delete attachment?', message: 'This will permanently remove ' + att.filename + '. There is no trash for attachments.', okText: 'Delete', danger: true, }); if (!ok) return; try { await api('/attachments/' + att.id, { method: 'DELETE', headers: authHeaders(), }); toast('Attachment deleted'); await refreshAttachments(entryId, listContainer); } catch (e) { toast('Delete failed: ' + e.message, 'error'); } }); row.appendChild(del); return row; } // 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'); } } // Curated swatches + icon choices. Kept small so the picker stays a // single visual row each — paradox of choice tames itself. const FOLDER_PALETTE = [ '', // 'no color' = default accent '#ef4444', '#f97316', '#eab308', '#22c55e', '#06b6d4', '#3b82f6', '#a855f7', '#ec4899', ]; const FOLDER_ICONS = [ 'i-folder', 'i-shield', 'i-key', 'i-star', 'i-user', 'i-tag', 'i-globe', 'i-lock', ]; function addFolder() { openFolderModal(null); } // Reorder: move `draggedName` directly before or after `targetName`. // Builds the new names list and persists via POST /folders/reorder. async function reorderFolderTo(draggedName, targetName, insertBefore) { // Work on the user-visible (non-"All") slice — "All" is synthetic and // never reordered, so it must stay at index 0 in state.folders. const real = state.folders.filter(f => f.name !== 'All'); const dragged = real.find(f => f.name === draggedName); if (!dragged) return; const filtered = real.filter(f => f.name !== draggedName); const targetIdx = filtered.findIndex(f => f.name === targetName); if (targetIdx === -1) return; const insertIdx = insertBefore ? targetIdx : targetIdx + 1; filtered.splice(insertIdx, 0, dragged); state.folders = [{ name: 'All', color: '', icon: '' }].concat(filtered); render(); // optimistic — feels instant try { await api('/folders/reorder', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ names: filtered.map(f => f.name) }), }); } catch (e) { // Roll back by reloading from the server on failure. toast('Reorder failed — reverting', 'error'); await loadFolders(); render(); } } function openFolderModal(existing) { const isEdit = !!existing; const initName = isEdit ? existing.name : ''; const initColor = isEdit ? (existing.color || '') : ''; const initIcon = isEdit ? (existing.icon || 'i-folder') : 'i-folder'; // Build modal node fresh each call so swatches/icons don't leak listeners. const old = document.getElementById('folderModal'); if (old) old.remove(); const modal = el('div', { id: 'folderModal', class: 'modal' }); const backdrop = el('div', { class: 'modal-backdrop' }); backdrop.addEventListener('click', () => modal.remove()); modal.appendChild(backdrop); const panel = el('div', { class: 'modal-panel modal-panel-sm' }); const header = el('div', { class: 'modal-header' }); header.appendChild(el('h3', null, isEdit ? 'Customize folder' : 'New folder')); panel.appendChild(header); const body = el('div', { class: 'modal-body' }); // Name input (read-only when editing — rename out of scope for v1) const nameField = el('div', { class: 'form-field' }); nameField.appendChild(el('label', null, 'Name')); const nameInput = el('input', { type: 'text', class: 'so-input', id: 'folderModalName', placeholder: 'e.g. Work', value: initName, }); if (isEdit) nameInput.disabled = true; nameField.appendChild(nameInput); body.appendChild(nameField); // Selected state held by closure. let pickedColor = initColor; let pickedIcon = initIcon; // Color row const colorField = el('div', { class: 'form-field' }); colorField.appendChild(el('label', null, 'Color')); const colorRow = el('div', { class: 'folder-swatch-row' }); FOLDER_PALETTE.forEach(c => { const sw = el('button', { type: 'button', class: 'folder-swatch' + (c === pickedColor ? ' is-active' : ''), 'data-color': c, title: c || 'Default', }); if (c) sw.style.background = c; else sw.classList.add('is-default'); sw.addEventListener('click', () => { pickedColor = c; colorRow.querySelectorAll('.folder-swatch').forEach(x => x.classList.toggle('is-active', x.getAttribute('data-color') === c)); refreshPreview(); }); colorRow.appendChild(sw); }); colorField.appendChild(colorRow); body.appendChild(colorField); // Icon row const iconField = el('div', { class: 'form-field' }); iconField.appendChild(el('label', null, 'Icon')); const iconRow = el('div', { class: 'folder-icon-row' }); FOLDER_ICONS.forEach(name => { const btn = el('button', { type: 'button', class: 'folder-icon-btn' + (name === pickedIcon ? ' is-active' : ''), 'data-icon': name, }); btn.appendChild(icon(name)); btn.addEventListener('click', () => { pickedIcon = name; iconRow.querySelectorAll('.folder-icon-btn').forEach(x => x.classList.toggle('is-active', x.getAttribute('data-icon') === name)); refreshPreview(); }); iconRow.appendChild(btn); }); iconField.appendChild(iconRow); body.appendChild(iconField); // Live preview const previewWrap = el('div', { class: 'folder-preview' }); const previewIcon = icon(pickedIcon); previewWrap.appendChild(previewIcon); const previewLabel = el('span', null, initName || 'Folder'); previewWrap.appendChild(previewLabel); body.appendChild(previewWrap); panel.appendChild(body); function refreshPreview() { previewIcon.innerHTML = ''; const u = document.createElementNS('http://www.w3.org/2000/svg', 'use'); u.setAttribute('href', '#' + pickedIcon); previewIcon.appendChild(u); previewIcon.style.color = pickedColor || ''; previewLabel.textContent = (nameInput.value || 'Folder'); } nameInput.addEventListener('input', refreshPreview); // Footer buttons const footer = el('div', { class: 'modal-footer' }); const cancelBtn = el('button', { class: 'btn btn-ghost', type: 'button' }, 'Cancel'); cancelBtn.addEventListener('click', () => modal.remove()); footer.appendChild(cancelBtn); const saveBtn = el('button', { class: 'btn btn-primary', type: 'button' }, isEdit ? 'Save' : 'Create'); saveBtn.addEventListener('click', () => submitFolder()); footer.appendChild(saveBtn); panel.appendChild(footer); modal.appendChild(panel); document.body.appendChild(modal); setTimeout(() => nameInput.focus(), 30); async function submitFolder() { const clean = (nameInput.value || '').trim(); if (!isEdit && !clean) return toast('Name required', 'warning'); if (clean.toLowerCase() === 'all') return toast('"All" is reserved', 'warning'); try { if (isEdit) { await api('/folders/' + encodeURIComponent(initName), { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ color: pickedColor, icon: pickedIcon }), }); toast('Folder updated'); } else { await api('/folders', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ name: clean, color: pickedColor, icon: pickedIcon }), }); toast('Folder created'); } modal.remove(); await loadFolders(); render(); } 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(); openSlideOver(null); } }, { id: 'new-note', label: 'New note', icon: 'i-edit', run: () => { closePalette(); openSlideOver(null, { kind: 'note' }); } }, { id: 'shortcuts', label: 'Show keyboard shortcuts (?)', icon: 'i-command', run: () => { closePalette(); openCheatsheet(); } }, { id: 'lock', label: 'Lock vault', icon: 'i-lock', run: async () => { closePalette(); if (await confirmDiscardForSessionExit('lock')) lockVault(); } }, { id: 'logout', label: 'Sign out', icon: 'i-log-out', run: async () => { closePalette(); if (await confirmDiscardForSessionExit('logout')) 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: 'notes', label: 'Show notes', icon: 'i-edit', run: () => { closePalette(); setView('notes'); } }, { 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, error } opts = opts || {}; $('#confirmTitle').textContent = opts.title || 'Enter value'; // Append a transient red error line under the message so the modal // can recycle for retries (wrong password, too-short, etc.) without // a toast that swallows the context. const msgHtml = opts.message || ''; const errHtml = opts.error ? '

' + String(opts.error).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])) + '

' : ''; $('#confirmMessage').innerHTML = msgHtml + errHtml; $('#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). const inp = $('#confirmInput'); const eye = $('#confirmInputEye'); inp.type = opts.password ? 'password' : 'text'; if (eye) { // Eye toggle only for password prompts. Reset to masked + leave // room on the right so text doesn't run under the button. eye.style.display = opts.password ? '' : 'none'; inp.style.paddingRight = opts.password ? '34px' : ''; eye.onclick = () => { inp.type = inp.type === 'password' ? 'text' : 'password'; inp.focus(); }; } $('#confirmModal').classList.remove('is-hidden'); setTimeout(() => $('#confirmInput').focus(), 50); return new Promise(res => { confirmResolver = res; }); } // Global busy overlay for long operations (export, big file saves). function showBusy(text) { const t = $('#busyText'); if (t) t.textContent = text || 'Working…'; const o = $('#busyOverlay'); if (o) o.classList.remove('is-hidden'); } function updateBusy(text) { const t = $('#busyText'); if (t) t.textContent = text || ''; } function hideBusy() { const o = $('#busyOverlay'); if (o) o.classList.add('is-hidden'); } 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, opts) { opts = opts || {}; return new Promise(resolve => { reauthResolve = resolve; $('#reauthMessage').textContent = message || 'This action requires your master password.'; $('#reauthPassword').value = ''; const errEl = $('#reauthError'); if (errEl) { if (opts.error) { errEl.textContent = opts.error; errEl.classList.remove('is-hidden'); } else { errEl.textContent = ''; errEl.classList.add('is-hidden'); } } $('#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); } }; // ============================================================ // PIN UNLOCK (DPAPI blob, PIN-derived wrap of the vault key) // ============================================================ // Three modes governed by state.unlockMode (synced setting): // 'pw' — current behaviour, master pw only (PIN ignored even if set) // 'pin' — PIN unlocks the vault on this device // 'both' — master pw unlocks; PIN is then verified before access // // Anti-brute-force: each failed PIN attempt rewrites the blob with an // incremented counter. Past PIN_MAX_ATTEMPTS the blob self-destructs and // the user has to fall back to master pw (and re-set the PIN if desired). // // Sensitive actions (export, change master pw, recovery code…) still go // through askReauth which always asks for the master pw — the PIN never // substitutes there. const PIN_KDF_ITERS = 100000; // lower than master pw — PIN entropy // is low (4–6 digits) so high iters // mostly slow down honest users. const PIN_MAX_ATTEMPTS = 5; let pinResolver = null; let pinStatusResolver = null; function bridgePinGet() { if (!Bridge.active) return Promise.resolve(null); return new Promise(resolve => { pinResolver = resolve; setTimeout(() => { if (pinResolver === resolve) { pinResolver = null; resolve(null); } }, 3000); window.location.href = 'cmd://pin/get'; }); } function bridgePinStatus() { if (!Bridge.active) return Promise.resolve(false); return new Promise(resolve => { pinStatusResolver = resolve; setTimeout(() => { if (pinStatusResolver === resolve) { pinStatusResolver = null; resolve(false); } }, 2000); window.location.href = 'cmd://pin/status'; }); } function bridgePinStore(payloadB64) { if (!Bridge.active) return; window.location.href = 'cmd://pin/store?data=' + encodeURIComponent(payloadB64); } function bridgePinClear() { if (!Bridge.active) return; window.location.href = 'cmd://pin/clear'; } Bridge.onPinResult = function(b64) { if (pinResolver) { const cb = pinResolver; pinResolver = null; cb(b64); } }; Bridge.onPinStatus = function(configured) { if (pinStatusResolver) { const cb = pinStatusResolver; pinStatusResolver = null; cb(!!configured); } }; async function derivePinWrapKey(pin, saltBytes, iters) { const km = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(pin), 'PBKDF2', false, ['deriveKey']); return crypto.subtle.deriveKey( { name: 'PBKDF2', salt: saltBytes, iterations: iters, hash: 'SHA-256' }, km, { name: 'AES-GCM', length: 256 }, true, // extractable: false would be safer but we need to re-wrap on attempt increment ['encrypt', 'decrypt']); } async function pinBuildBlob(pin) { // Snapshot of everything the cold-start unlock needs to log back in // without the master pw. Mirrors the Quick Unlock payload shape. const salt = crypto.getRandomValues(new Uint8Array(16)); const iv = crypto.getRandomValues(new Uint8Array(12)); const wrap = await derivePinWrapKey(pin, salt, PIN_KDF_ITERS); const raw = await crypto.subtle.exportKey('raw', state.cryptoKey); const wrapped = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, wrap, raw); return { v: 1, username: state.username, loginSalt: state.salt, loginIters: state.kdfIterations || 600000, // Auth scheme so cold-start sends the right verifier (v2 accounts // need the decoupled transform, not the raw key hex). Absent on // pre-decoupling blobs → cold-start defaults to the key hex, which // is correct for those (legacy) accounts. hashAlgo: state.hashAlgo || '', salt: bytesToBase64(salt), iters: PIN_KDF_ITERS, iv: bytesToBase64(iv), wrapped: bytesToBase64(wrapped), attempts: 0, }; } function pinBlobToB64(obj) { return bytesToBase64(new TextEncoder().encode(JSON.stringify(obj))); } function pinB64ToBlob(b64) { return JSON.parse(new TextDecoder().decode(base64ToBytes(b64))); } async function pinFetchBlob() { const b64 = await bridgePinGet(); if (!b64) return null; try { return pinB64ToBlob(b64); } catch { return null; } } // Validates a PIN against the stored blob. Returns the unwrapped vault // key bytes on success, null on failure (and rewrites the blob with the // incremented attempts counter or wipes it past the cap). async function pinTryUnwrap(pin) { const blob = await pinFetchBlob(); if (!blob) return null; try { const wrap = await derivePinWrapKey(pin, base64ToBytes(blob.salt), blob.iters || PIN_KDF_ITERS); const raw = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: base64ToBytes(blob.iv) }, wrap, base64ToBytes(blob.wrapped)); // Successful unlock — reset the attempts counter so a future // bad-then-good streak doesn't accidentally wipe the blob. if ((blob.attempts || 0) !== 0) { blob.attempts = 0; bridgePinStore(pinBlobToB64(blob)); } return { rawKey: new Uint8Array(raw), blob }; } catch (_) { const next = (blob.attempts || 0) + 1; if (next >= PIN_MAX_ATTEMPTS) { bridgePinClear(); } else { blob.attempts = next; bridgePinStore(pinBlobToB64(blob)); } return null; } } async function setupPin(pin) { if (!Bridge.active) return toast('PIN requires the Delphi app', 'warning'); if (!state.cryptoKey) return toast('Unlock the vault first', 'warning'); const blob = await pinBuildBlob(pin); bridgePinStore(pinBlobToB64(blob)); state.pinConfigured = true; toast('PIN set'); } async function removePin() { bridgePinClear(); state.pinConfigured = false; // If we were in pin-only mode, fall back to pw — leaving the user // unable to log in next time would be a self-foot-gun. if (state.unlockMode === 'pin' || state.unlockMode === 'both') { state.unlockMode = 'pw'; localStorage.setItem('unlockMode', 'pw'); saveServerSettings(); } toast('PIN removed'); } // Refresh the Settings panel PIN row: dropdown value, status text, // button labels. Idempotent — safe to call from listeners or after the // async pinStatus probe completes. function refreshPinUnlockUI() { const sel = document.getElementById('settingUnlockMode'); const status = document.getElementById('pinStatus'); const setBtn = document.getElementById('pinSetBtn'); const delBtn = document.getElementById('pinRemoveBtn'); if (sel) sel.value = state.unlockMode || 'pw'; if (status) { status.textContent = state.pinConfigured ? 'PIN is set on this device.' : 'No PIN set.'; } if (setBtn) setBtn.textContent = state.pinConfigured ? 'Change PIN' : 'Set PIN'; if (delBtn) delBtn.style.display = state.pinConfigured ? '' : 'none'; } // Prompt + setup flow used by the Set/Change PIN button. Validates the // PIN client-side (4–12 digits) then writes the DPAPI blob. async function pinSetupFlow() { if (!Bridge.active) return toast('PIN requires the Delphi app', 'warning'); if (!state.cryptoKey) return toast('Unlock the vault first', 'warning'); // Setting/changing a PIN creates a new unlock path → treat as a // sensitive operation. An unattended unlocked vault must not be // possible for a passerby to pin-backdoor. const masterPwd = await askReauth( 'Confirm your master password to set or change the PIN.'); if (!masterPwd) return; try { const verifier = await computeVerifier( masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo); await api('/reauth', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ verifier: verifier }), }); } catch (err) { return toast('Wrong master password', 'error'); } let lastError = ''; let attempts = 0; for (;;) { const pin = await promptDialog({ title: state.pinConfigured ? 'Change PIN' : 'Set a PIN', message: '4–12 digits. PIN unlock is device-local and never leaves this machine.', placeholder: 'PIN', password: true, okText: 'Save', error: lastError, }); // Cancel / Esc / X resolves with `false` (not null) — treat any // falsy value as "user backed out", not as a wrong attempt. if (pin === false || pin === null || pin === undefined || pin === '') return; if (!/^\d{4,12}$/.test(pin)) { attempts++; if (attempts >= 5) return toast('Too many invalid attempts', 'error'); lastError = 'PIN must be 4–12 digits (attempt ' + attempts + ' / 5).'; continue; } await setupPin(pin); refreshPinUnlockUI(); return; } } // ----- Auth screen mode switching --------------------------------- // Picks which inputs are visible on the lock screen based on the // user's unlockMode + whether a PIN blob actually exists on this // device. Always falls back to the master-pw layout if PIN unlock can't // realistically work (no Delphi bridge, no DPAPI blob). function applyAuthScreenMode() { const pwField = document.getElementById('loginPasswordField'); const pinField = document.getElementById('loginPinField'); const useMaster = document.getElementById('loginUseMasterBtn'); if (!pwField || !pinField) return; const havePin = Bridge.active && state.pinConfigured; // Drift fix: if the user previously chose pin/both but the blob is // no longer on disk (auto-wiped after 5 wrong attempts, or removed // from another session), demote the mode locally so the Settings // dropdown reflects reality. Server sync happens next time the user // logs in and openSettings saves changes. if (!havePin && (state.unlockMode === 'pin' || state.unlockMode === 'both')) { state.unlockMode = 'pw'; localStorage.setItem('unlockMode', 'pw'); } const mode = havePin ? (state.unlockMode || 'pw') : 'pw'; pwField.style.display = (mode === 'pin') ? 'none' : ''; pinField.style.display = (mode === 'pin' || mode === 'both') ? '' : 'none'; // PIN-only mode: offer a one-shot escape hatch so the user can fall // back to master pw if the PIN blob got corrupted or they forgot it. if (useMaster) useMaster.style.display = (mode === 'pin') ? '' : 'none'; // Required attribute on hidden inputs blocks form submission — keep // it in sync with visibility. const pwInput = document.getElementById('loginPassword'); const pinInput = document.getElementById('loginPin'); if (pwInput) pwInput.required = (mode !== 'pin'); if (pinInput) pinInput.required = (mode === 'pin' || mode === 'both'); } // Try the PIN-unlock cold-start. Mirrors tryQuickUnlock but gated by a // user-typed PIN. Returns true on success (vault unlocked), false on // any failure (wrong PIN, missing blob, server refused). The caller is // responsible for showing the master-pw fallback UI on false. async function loginViaPin(pin) { if (!Bridge.active) return false; if (!pin) return false; const ok = await pinTryUnwrap(pin); if (!ok) return false; const { rawKey, blob } = ok; // Restore the identity bits we need to call /login with a verifier. state.username = blob.username || state.username; state.salt = blob.loginSalt || state.salt; state.kdfIterations = blob.loginIters || state.kdfIterations || 600000; state.hashAlgo = blob.hashAlgo || ''; try { state.cryptoKey = await crypto.subtle.importKey( 'raw', rawKey, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']); } catch (_) { return false; } try { // v2 accounts need the decoupled verifier; legacy → key hex. const verifier = await verifierFromKeyHex(bytesToHex(rawKey), state.hashAlgo); 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 (_) { // Server credentials drifted (master pw rotation since PIN // setup). Force user back to master pw + ask them to redo PIN. return false; } sessionStorage.setItem('username', state.username); sessionStorage.setItem('salt', state.salt); sessionStorage.setItem('kdfIterations', String(state.kdfIterations)); sessionStorage.setItem('hashAlgo', state.hashAlgo); sessionStorage.setItem('authToken', state.token); sessionStorage.setItem('csrfToken', state.csrf); await persistCryptoKey(); state.locked = false; state.justRecovered = false; return true; } // 'both' mode helper: master pw has already populated state.cryptoKey // via the regular login flow. Now check the PIN matches the stored // blob (validation only — we don't use the unwrapped key from here). async function verifyPinAfterMasterUnlock(pin) { const ok = await pinTryUnwrap(pin); return !!ok; } 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, state.hashAlgo); 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, // Auth scheme for cold-start verifier selection (see pinBuildBlob). hashAlgo: state.hashAlgo || '', 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; state.hashAlgo = parsed.hashAlgo || ''; 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 { // v2 accounts need the decoupled verifier; legacy → key hex. const verifier = await verifierFromKeyHex(bytesToHex(rawKey), state.hashAlgo); 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('hashAlgo', state.hashAlgo); 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. // Renders a printable A4 sheet (CSS-only, no external libs) with the // recovery code in large monospace + a fold-and-stash instruction. // Removes the print container after the print dialog closes. function printRecoveryCode(code, username) { const existing = document.getElementById('printRecoveryArea'); if (existing) existing.remove(); const wrap = document.createElement('div'); wrap.id = 'printRecoveryArea'; const today = new Date().toISOString().slice(0, 10); wrap.innerHTML = '
' + '

PMServer · Recovery Code

' + '
' + '
Account: ' + (username || '') + '
' + '
Generated: ' + today + '
' + '
' + '
' + code + '
' + '
' + '

Keep this sheet offline and physically secure.

' + '

Use this code if you forget your master password:

' + '
    ' + '
  1. On the unlock screen, click "Use recovery code".
  2. ' + '
  3. Enter your username and the 16-character code above.
  4. ' + '
  5. You will be asked to set a new master password — the code is then consumed.
  6. ' + '
' + '

The code can be used up to 5 times. It is permanently erased the moment you successfully change the master password.

' + '

Anyone with this code can reset your master password — store it like a paper key, not a sticky note.

' + '
' + '
'; document.body.appendChild(wrap); // Restore on print-end and on focus (Edge fires focus when preview closes). const cleanup = () => { const n = document.getElementById('printRecoveryArea'); if (n) n.remove(); window.removeEventListener('afterprint', cleanup); window.removeEventListener('focus', cleanup); }; window.addEventListener('afterprint', cleanup); window.addEventListener('focus', cleanup); setTimeout(() => window.print(), 50); } // 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, state.hashAlgo); 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) { 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); }); } }); } const printBtn = document.getElementById('printRecoveryCodeBtn'); if (printBtn) { printBtn.addEventListener('click', () => printRecoveryCode(code, state.username)); } }, 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; // Account's auth scheme — needed so the recovery-mode master-pw change // proves the current key under the right verifier transform. state.hashAlgo = r.hashAlgo || ''; 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)); sessionStorage.setItem('hashAlgo', state.hashAlgo); // 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; // Rotation re-encrypts every entry (and every attachment) under the new // key — seconds to tens of seconds on a big vault. Show a spinner so it // doesn't look frozen; 0ms yield lets it paint before the loop blocks. showBusy('Re-encrypting vault…'); await new Promise(r => setTimeout(r, 0)); 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(); // Rotate onto the decoupled-verifier scheme (v2) — a master-pw // change re-derives + re-encrypts everything anyway, so it's the // natural migration point for existing accounts. const newDerived = await deriveKeyAndVerifier(newPwd, newSalt, 600000, HASH_ALGO_V2); const newKey = newDerived.cryptoKey; let currentVerifier; if (recoveryMode) { // Current pw is proven via the in-memory recovered key. The // server compares under the account's CURRENT algo, so apply the // same verifier transform (v2 → decoupled, else → key hex). const rawCurrentKey = new Uint8Array(await crypto.subtle.exportKey('raw', state.cryptoKey)); currentVerifier = await verifierFromKeyHex(bytesToHex(rawCurrentKey), state.hashAlgo); } else { currentVerifier = await computeVerifier( curPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo); } // 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. // Hoisted so the attachment re-encryption loop after the server // round-trip can still reach the OLD key. const oldKey = state.cryptoKey; const encrypted = []; let _cmDone = 0; const _cmTotal = state.entries.length; for (const e of state.entries) { _cmDone++; if (_cmTotal > 10 && (_cmDone % 5 === 0 || _cmDone === _cmTotal)) updateBusy('Re-encrypting entries… ' + _cmDone + '/' + _cmTotal); 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. 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; } } // Custom fields — same dance: decrypt with OLD key, encrypt // with NEW key, send fresh ciphertext. let cfEnc = '', cfIv = ''; if (e.custom_fields && e.custom_fields_iv) { state.cryptoKey = oldKey; const plainCf = await decryptCustomFields( e.custom_fields, e.custom_fields_iv); state.cryptoKey = newKey; if (plainCf.length > 0) { const c = await encryptCustomFields(plainCf); cfEnc = c.encrypted; cfIv = c.iv; } } encrypted.push({ id: e.id, encrypted_password: re.encrypted, iv: re.iv, totp_secret: totpEnc, totp_iv: totpIv, custom_fields: cfEnc, custom_fields_iv: cfIv, }); } 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; // The account is now on the decoupled-verifier scheme. state.hashAlgo = HASH_ALGO_V2; state.cryptoKey = newKey; await persistCryptoKey(); sessionStorage.setItem('salt', state.salt); sessionStorage.setItem('kdfIterations', String(state.kdfIterations)); sessionStorage.setItem('hashAlgo', state.hashAlgo); // Server invalidated every session for this user (including ours) // and minted a fresh pair — adopt them so subsequent API calls // don't bounce with "invalid session". if (r.token) { state.token = r.token; sessionStorage.setItem('authToken', r.token); } if (r.csrf) { state.csrf = r.csrf; sessionStorage.setItem('csrfToken', r.csrf); } // Re-encrypt attachments under the new vault key. The server-side // change-master endpoint can't touch these (they're opaque to it), // so we fetch each blob, decrypt with the OLD key, encrypt with // the NEW one, and ship it back. Best-effort — a single failure // doesn't undo the rotation, but the user gets a warning toast. try { const allAttach = await api('/attachments/all', { headers: authHeaders() }); if (allAttach && allAttach.length > 0) { let failed = 0; let _atDone = 0; const _atTotal = allAttach.length; for (const meta of allAttach) { _atDone++; updateBusy('Re-encrypting attachments… ' + _atDone + '/' + _atTotal); try { // state.cryptoKey is already newKey at this point. // Swap to oldKey for decryption, then back for upload. const full = await api('/attachments/' + meta.id, { headers: authHeaders() }); state.cryptoKey = oldKey; const plainBytes = await decryptBlobBytes( full.encrypted_blob, full.iv); state.cryptoKey = newKey; const re = await encryptBlobBytes(plainBytes); await api('/attachments/' + meta.id, { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ encrypted_blob: re.encrypted, iv: re.iv, }), }); } catch (e) { failed++; } } state.cryptoKey = newKey; // make sure we end on the new key if (failed > 0) { toast(failed + ' attachment(s) failed to re-encrypt — they may be unreadable. Re-upload from a backup.', 'error'); } else { toast('Attachments re-encrypted'); } } } catch (e) { toast('Attachment re-encryption skipped: ' + (e && e.message || e), 'warning'); } 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; } // Quick-unlock blob holds the raw AES key bundle. It stores the key // directly (DPAPI-wrapped, no user secret), so instead of forcing // the user to re-enable it after every rotation we transparently // RE-WRAP it with the new key + salt + iters + algo. state.* already // reflects the new values at this point (step 4 above). Cold-start // then re-logs in with a verifier derived from the new key. if (Bridge.active && localStorage.getItem('quickUnlockEnabled') === '1') { try { const raw = await crypto.subtle.exportKey('raw', state.cryptoKey); const blob = JSON.stringify({ v: 2, username: state.username, salt: state.salt, kdfIterations: state.kdfIterations, hashAlgo: state.hashAlgo || '', key: bytesToBase64(new Uint8Array(raw)), }); const b64 = bytesToBase64(new TextEncoder().encode(blob)); window.location.href = 'cmd://quickunlock/store?data=' + encodeURIComponent(b64); // stays enabled — flag + state unchanged } catch (_) { // Re-wrap failed → fall back to clearing so we never leave a // stale (old-key) blob that would decrypt to garbage. window.location.href = 'cmd://quickunlock/clear'; localStorage.removeItem('quickUnlockEnabled'); state.quickUnlockEnabled = false; } } // Yield so the quick-unlock store navigation above is processed // before the PIN clear below — both go through window.location.href // and back-to-back assignments can coalesce (only the last lands), // which would drop the quick-unlock re-wrap and leave a stale blob. await new Promise(r => setTimeout(r, 0)); // Same problem for the PIN blob — wrapped key is from the old // master, server verifier won't match anymore. Wipe so the user // gets a clean fallback to master pw next time. if (Bridge.active && state.pinConfigured) { bridgePinClear(); state.pinConfigured = false; if (state.unlockMode === 'pin' || state.unlockMode === 'both') { state.unlockMode = 'pw'; localStorage.setItem('unlockMode', 'pw'); } // Settings panel is open during the rotation — refresh its // PIN row so the "Change/Remove PIN" buttons + status text // reflect the wipe without needing to close + reopen. if (typeof refreshPinUnlockUI === 'function') refreshPinUnlockUI(); } 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 { hideBusy(); 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. // Prefer URL-shaped columns for `site` and human-readable name for // `title` so KeePass/Bitwarden exports keep both. Fall back: if only // one is present, reuse it for the other. const colTitle = findColumn(headers, ['name', 'title', 'item_name', 'entry_name']); const colSite = findColumn(headers, ['url', 'login_uri', 'login_url', 'site', 'website', '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']); // Our own CSV export carries an explicit `kind` column. When absent we // fall back to a heuristic (empty site + non-empty notes = a note). const colKind = findColumn(headers, ['kind', 'type', 'item_type']); const colTemplate = findColumn(headers, ['template', 'subtype']); const colCustom = findColumn(headers, ['custom_fields', 'custom', 'fields']); // Bitwarden card columns — only used when type=card. Each maps to a // custom field on a credit-card-template note. const colCardHolder = findColumn(headers, ['card_cardholdername', 'card_holder', 'cardholder']); const colCardBrand = findColumn(headers, ['card_brand', 'card_type']); const colCardNumber = findColumn(headers, ['card_number', 'cardnumber']); const colCardExpM = findColumn(headers, ['card_expmonth', 'card_exp_month']); const colCardExpY = findColumn(headers, ['card_expyear', 'card_exp_year']); const colCardCode = findColumn(headers, ['card_code', 'card_cvv', 'card_cvc']); // Bitwarden identity columns — mapped to identity-template note. const colIdFirst = findColumn(headers, ['identity_firstname']); const colIdLast = findColumn(headers, ['identity_lastname']); const colIdEmail = findColumn(headers, ['identity_email']); const colIdPhone = findColumn(headers, ['identity_phone']); if (colSite === null && colTitle === null && colUser === null) throw new Error('No recognizable title/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 titleRaw = (colTitle !== null ? r[colTitle] : '').trim(); const siteRaw = (colSite !== null ? r[colSite] : '').trim(); const pwd = (colPwd !== null ? r[colPwd] : ''); const notesRaw = (colNotes !== null ? r[colNotes] : '').trim(); // Resolve kind: explicit column wins. Heuristic fallback for foreign // CSVs (Bitwarden/KeePass) — when site+user+pwd are all empty but // notes/title is set, that's a secure-note row. let kindRaw = (colKind !== null ? String(r[colKind] || '').toLowerCase().trim() : ''); let kind = (kindRaw === 'note' || kindRaw === 'secure_note') ? 'note' : (kindRaw === 'card' || kindRaw === 'identity') ? 'note' : 'login'; if (kindRaw === '' && !siteRaw && !pwd && notesRaw) kind = 'note'; let templateRaw = (colTemplate !== null ? String(r[colTemplate] || '').trim() : ''); // Bitwarden type=card / type=identity → note kind + appropriate // template. The card/identity columns become custom fields below. if (kindRaw === 'card') templateRaw = templateRaw || 'credit-card'; if (kindRaw === 'identity') templateRaw = templateRaw || 'identity'; // Notes legitimately have no site; their body is in `notes` (or in // `password` when round-tripping our own CSV — we wrote the body // into the password column for the export). // Custom fields cell carries a JSON-stringified array (our own // export shape). Parse defensively — a malformed cell drops to // [] rather than failing the row. let cf = []; if (colCustom !== null) { const raw = String(r[colCustom] || '').trim(); if (raw) { try { // Our own export: JSON array of {label, value, is_secret} const arr = JSON.parse(raw); if (Array.isArray(arr)) cf = arr.filter(f => f && typeof f === 'object' && f.label); } catch { // Bitwarden / Chrome / KeePass CSV: newline-separated // "label: value" lines (sometimes "label=value"). Split, // pick the FIRST separator only so values can contain // ":" or "=" without being mangled. raw.split(/\r?\n/).forEach(line => { line = line.trim(); if (!line) return; const sep = line.search(/[:=]/); if (sep <= 0) return; const label = line.slice(0, sep).trim(); const value = line.slice(sep + 1).trim(); if (label) cf.push({ label, value, is_secret: false }); }); } } } if (kind === 'note') { // Pull Bitwarden card/identity columns into custom_fields so // the type=card / type=identity rows survive the import. const push = (label, val, is_secret) => { if (val) cf.push({ label, value: val, is_secret: !!is_secret }); }; if (kindRaw === 'card') { push('Cardholder', colCardHolder !== null ? String(r[colCardHolder] || '').trim() : ''); push('Brand', colCardBrand !== null ? String(r[colCardBrand] || '').trim() : ''); push('Number', colCardNumber !== null ? String(r[colCardNumber] || '').trim() : '', true); const expM = colCardExpM !== null ? String(r[colCardExpM] || '').trim() : ''; const expY = colCardExpY !== null ? String(r[colCardExpY] || '').trim() : ''; if (expM || expY) push('Expires', (expM && expY) ? (expM + '/' + expY) : (expM || expY)); push('CVV', colCardCode !== null ? String(r[colCardCode] || '').trim() : '', true); } if (kindRaw === 'identity') { const f = colIdFirst !== null ? String(r[colIdFirst] || '').trim() : ''; const l = colIdLast !== null ? String(r[colIdLast] || '').trim() : ''; if (f || l) push('Name', (f && l) ? (f + ' ' + l) : (f || l)); push('Email', colIdEmail !== null ? String(r[colIdEmail] || '').trim() : ''); push('Phone', colIdPhone !== null ? String(r[colIdPhone] || '').trim() : ''); } const body = pwd || notesRaw || ' '; // template carries data via cf if (!body && cf.length === 0) { skipped++; continue; } const tagsArr = []; if (colTags !== null) { String(r[colTags] || '').split(/[,;]/).forEach(t => { t = t.trim(); if (t) tagsArr.push(t); }); } entries.push({ site: '', title: titleRaw, username: '', password: body, folder: (colFolder !== null ? r[colFolder] : '').trim() || 'All', tags: tagsArr.join(','), totp_secret: '', kind: 'note', template: templateRaw, custom_fields: cf, }); continue; } // Fall through chain: site → title → username so we always have // something to display. The unused string becomes the title for // browser-style cards. const site = siteRaw || titleRaw || (colUser !== null ? r[colUser] : '').trim(); const title = titleRaw || ''; if (!site || !pwd) { skipped++; continue; } // Tags: only the explicit tags column. Free-form notes are // surfaced as a custom "Notes" field below — putting prose into // the tag chip strip turned it into noise (and lost line breaks). let tagsArr = []; if (colTags !== null) { String(r[colTags] || '').split(/[,;]/).forEach(t => { t = t.trim(); if (t) tagsArr.push(t); }); } // Bitwarden / KeePass / Chrome login rows carry per-entry notes // in a `notes` column. Preserve them as a non-secret custom field // so the body survives round-trip without polluting tags. if (colNotes !== null) { const n = String(r[colNotes] || '').trim(); if (n) cf.push({ label: 'Notes', value: n, is_secret: false }); } // 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, title: title, username: (colUser !== null ? r[colUser] : '').trim(), password: pwd, folder: (colFolder !== null ? r[colFolder] : '').trim() || 'All', tags: tagsArr.join(','), totp_secret: totp, kind: 'login', template: templateRaw, custom_fields: cf, }); } return { entries, skipped, columns: { title: colTitle, site: colSite, username: colUser, password: colPwd, folder: colFolder, tags: colTags, notes: colNotes, totp: colTotp, kind: colKind, custom_fields: colCustom, } }; } // 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'); // Folders metadata (color, icon) — only present on payloads produced // by our own JSON exporter from 2026-06 onward. Silently absent for // older backups or foreign formats; the per-entry `folder` name is // still respected either way. const folders = Array.isArray(data.folders) ? data.folders.filter(f => f && f.name && f.name !== 'All') .map(f => ({ name: String(f.name).trim(), color: String(f.color || '').trim(), icon: String(f.icon || '').trim(), })) : []; const entries = []; let skipped = 0; for (const e of raw) { if (!e || typeof e !== 'object') { skipped++; continue; } const kind = (e.kind === 'note') ? 'note' : 'login'; const site = String(e.site || e.url || e.name || '').trim(); const pwd = String(e.password || ''); // Notes legitimately have no `site` — their "content" lives in // password (the note body). Logins still need both site + pwd. if (kind === 'login' && (!site || !pwd)) { skipped++; continue; } // Notes with a template (credit-card, ssh-key, etc.) carry data // in custom_fields — an empty body is legitimate as long as at // least one custom field has content. Only skip a note if BOTH // the body AND every custom field are empty. if (kind === 'note' && !pwd) { const cfList = Array.isArray(e.custom_fields) ? e.custom_fields : []; const anyFieldFilled = cfList.some(f => f && (String(f.value || '').trim() !== '')); if (!anyFieldFilled) { skipped++; continue; } } const tagsVal = e.tags; const tagsStr = Array.isArray(tagsVal) ? tagsVal.join(',') : String(tagsVal || ''); // Custom fields: array of {label, value, is_secret}. Tolerate // missing / malformed gracefully — drop the field rather than // failing the entry. let cf = []; if (Array.isArray(e.custom_fields)) { cf = e.custom_fields.filter(f => f && typeof f === 'object' && f.label); } // Attachments: pass through as-is; the import side re-encrypts // the base64 content with the current vault key and POSTs each. let atts = []; if (Array.isArray(e.attachments)) { atts = e.attachments.filter(a => a && typeof a === 'object' && a.filename && a.content_b64); } entries.push({ uuid: String(e.uuid || '').trim(), site: site, title: String(e.title || '').trim(), 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(), kind: kind, template: String(e.template || '').trim(), custom_fields: cf, attachments: atts, icon_b64: String(e.icon_b64 || '').trim(), }); } return { entries, skipped, columns: null, folders, avatar_b64: typeof data.avatar_b64 === 'string' ? data.avatar_b64 : '' }; } // 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. } } // Custom fields: encrypt the same way the slideover does so the row // round-trips through the regular GET path. let cfEnc = '', cfIv = ''; if (Array.isArray(plain.custom_fields) && plain.custom_fields.length > 0) { try { const c = await encryptCustomFields(plain.custom_fields); cfEnc = c.encrypted; cfIv = c.iv; } catch (e) { /* drop silently */ } } // tags may arrive as a comma-separated string (CSV / our own JSON // export) or as a real array (buildSyncSnapshot uses parseTags → []). // The server's HandleCreateEntry does GetValue which throws // "TJSONArray → string non supporté" on an array — normalise here. const tagsStr = Array.isArray(plain.tags) ? plain.tags.filter(Boolean).join(',') : (plain.tags || ''); return { uuid: plain.uuid || '', site: plain.site, title: plain.title || '', username: plain.username || '', encrypted_password: pw.encrypted, iv: pw.iv, folder: plain.folder || 'All', tags: tagsStr, totp_secret: totpEnc, totp_iv: totpIv, kind: plain.kind === 'note' ? 'note' : 'login', custom_fields: cfEnc, custom_fields_iv: cfIv, icon_b64: plain.icon_b64 || '', template: plain.template || '', }; } // 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; // parsedAtt is computed AFTER dedup — see below where // dedupedEntries is defined. let parsedAtt = 0; // Apply folder customisation (color, icon) from the payload — // additive only: existing local folders are left untouched so the // user's current customisation isn't overwritten by an older // backup. Folders referenced by entries but absent from the // folders[] block will still be auto-created with defaults during // the bulk-import step server-side. if (Array.isArray(parsed.folders) && parsed.folders.length > 0) { const existing = new Set((state.folders || []) .filter(f => f && f.name).map(f => f.name)); let createdFolders = 0; for (const f of parsed.folders) { if (!f.name || existing.has(f.name)) continue; try { await api('/folders', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ name: f.name, color: f.color || '', icon: f.icon || '', }), }); createdFolders++; } catch (_) { /* duplicate or invalid — skip silently */ } } if (createdFolders > 0) { await loadFolders(); toast(createdFolders + ' folder(s) added'); } } // Restore the profile picture from the backup — only when the // current account has none, so an import doesn't clobber a // picture the user already set on this device. if (parsed.avatar_b64 && !state.avatarDataUri) { try { await api('/avatar', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ avatar_b64: parsed.avatar_b64 }), }); state.avatarDataUri = parsed.avatar_b64; renderUserAvatar(); } catch (_) { /* non-critical */ } } // CSV imports (Bitwarden / KeePass / Chrome) don't carry a // folders[] block — they just stamp a folder name on each row. // Bulk-import stores the name but never creates the folders // table row, so the sidebar wouldn't show the new folder. // Auto-create any referenced folder that doesn't exist yet. const referenced = new Set(); for (const e of parsed.entries) { const f = (e.folder || '').trim(); if (f && f !== 'All') referenced.add(f); } if (referenced.size > 0) { const localNames = new Set((state.folders || []) .filter(f => f && f.name).map(f => f.name)); let createdMissing = 0; for (const name of referenced) { if (localNames.has(name)) continue; try { await api('/folders', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ name, color: '', icon: '' }), }); createdMissing++; } catch (_) { /* duplicate or invalid — skip silently */ } } if (createdMissing > 0) await loadFolders(); } // Dedupe by uuid: split parsed rows into "fresh" (uuid absent // locally, safe to bulk-insert) and "overlapping" (uuid already // exists — the user is either re-importing a backup or rolling // back to an earlier version). Ask what to do with overlapping // entries so a restore isn't silently blocked by the dedup. const localByUuid = new Map(); for (const e of state.entries) if (e && e.uuid) localByUuid.set(e.uuid, e); const fresh = []; const overlaps = []; for (const e of parsed.entries) { if (e.uuid && localByUuid.has(e.uuid)) overlaps.push(e); else fresh.push(e); } let overwriteOverlaps = false; if (overlaps.length > 0) { overwriteOverlaps = await confirmDialog({ title: overlaps.length + ' entries already in vault', message: '' + overlaps.length + ' entries in this file ' + 'already exist locally (same UUID).

' + 'Choose Overwrite to replace the local version with the ' + 'file\'s (rolls back edits made since the backup was taken).

' + 'Choose Skip to keep the current local version and only ' + 'import genuinely new entries.', okText: 'Overwrite', cancelText: 'Skip', danger: true, }); } if (fresh.length === 0 && !overwriteOverlaps) { return toast('Nothing new to import', 'warning'); } parsedAtt = fresh.reduce( (n, e) => n + (Array.isArray(e.attachments) ? e.attachments.length : 0), 0); toast('Encrypting ' + fresh.length + ' entries…'); const encrypted = []; for (const e of fresh) { encrypted.push(await encryptImportEntry(e)); } // Overwriting overlaps: PUT each existing entry with the file's // content. Attachments on the local entry stay in place — the // user typically wants to roll back credentials, not lose // manually-uploaded files. Adjust if that assumption changes. let overwritten = 0; if (overwriteOverlaps) { for (const src of overlaps) { try { const local = localByUuid.get(src.uuid); if (!local) continue; const enc = await encryptImportEntry(src); await api('/entries/' + local.id, { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify(enc), }); overwritten++; } catch (_) { /* skip the single row on failure */ } } } // Use dedupedEntries as an alias for fresh so downstream code // (attachments loop) keeps working without a second rename. const dedupedEntries = fresh; try { let r = { imported: 0, ids: [] }; if (encrypted.length > 0) { r = await api('/entries/bulk-import', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ entries: encrypted }), }); } const tailMsg = overwritten > 0 ? ' · ' + overwritten + ' overwritten' : ''; toast('Imported ' + r.imported + ' entries' + tailMsg); // Restore attachments. ids[] is parallel-indexed with the // input (-1 = server skipped this row), so we can map back // from each parsed entry to its newly-created server id. const ids = Array.isArray(r.ids) ? r.ids : []; if (parsedAtt > 0 && ids.length === 0) { toast(parsedAtt + ' attachment(s) skipped — server missing /ids response', 'warning'); } let attachCount = 0; for (let i = 0; i < dedupedEntries.length; i++) { const newId = ids[i]; const atts = dedupedEntries[i].attachments; if (typeof newId !== 'number' || newId <= 0) continue; if (!Array.isArray(atts) || atts.length === 0) continue; for (const a of atts) { try { const bytes = base64ToBytes(a.content_b64 || ''); // Re-encrypt under the CURRENT vault key — the // export stored plaintext bytes so a cross- // account / post-rotation restore still works. const { encrypted: blob, iv } = await encryptBlobBytes(bytes); await api('/entries/' + newId + '/attachments', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ filename: a.filename, mime: a.mime || 'application/octet-stream', encrypted_blob: blob, iv, size_bytes: a.size_bytes || bytes.length, }), }); attachCount++; } catch (e) { /* skip the single attachment */ } } } if (attachCount > 0) toast(attachCount + ' attachment(s) restored'); 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. // Up to 5 retries with inline error in the modal — beyond that the // server-side rate limiter takes over (429 with retry_after). let lastError = null; let attempts = 0; const MAX_ATTEMPTS = 5; let reauthed = false; while (!reauthed) { const masterPwd = await askReauth( 'Enter your master password to start an encrypted export.', { error: lastError }); if (!masterPwd) return; // user cancelled try { const verifier = await computeVerifier( masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo); await api('/reauth', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ verifier: verifier }), }); reauthed = true; } catch (err) { 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'); } attempts++; if (attempts >= MAX_ATTEMPTS) { return toast('Too many wrong attempts — try again later', 'error'); } lastError = 'Wrong master password. Attempt ' + attempts + ' / ' + MAX_ATTEMPTS + '.'; } } // 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. let exportPwd = null; { let pwdErr = ''; let pwdAttempts = 0; const PWD_MAX = 5; for (;;) { const v = 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, error: pwdErr, }); if (!v) return; if (v.length >= 6) { exportPwd = v; break; } pwdAttempts++; if (pwdAttempts >= PWD_MAX) { return toast('Too many invalid attempts', 'error'); } pwdErr = 'Use at least 6 characters (attempt ' + pwdAttempts + ' / ' + PWD_MAX + ').'; } } // Show the spinner BEFORE the heavy work — the entry-decrypt + // attachment-fetch loop below is the real cost on big vaults, not // just the final encrypt/save. A 0ms yield lets the overlay paint // before we block the thread. showBusy('Reading vault…'); await new Promise(r => setTimeout(r, 0)); try { // 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, // Profile picture (data URI) so a restore brings the avatar // back. Empty string when none set. avatar_b64: state.avatarDataUri || '', // Folder customisation (color, icon) so restoring on a fresh // install brings the sidebar back the way the user had it, // not the default gray + folder-icon. 'All' is synthetic and // never persisted, skip it. folders: (state.folders || []) .filter(f => f && f.name && f.name !== 'All') .map(f => ({ name: f.name, color: f.color || '', icon: f.icon || '', })), entries: [], }; let _expDone = 0; const _expTotal = state.entries.length; for (const e of state.entries) { _expDone++; if (_expTotal > 10 && (_expDone % 5 === 0 || _expDone === _expTotal)) updateBusy('Reading vault… ' + _expDone + '/' + _expTotal); 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 = ''; } let plainCustom = []; if (e.custom_fields && e.custom_fields_iv) { try { plainCustom = await decryptCustomFields( e.custom_fields, e.custom_fields_iv); } catch (_) { plainCustom = []; } } // Attachments are fetched separately (one extra GET per entry // that has them) so this branch stays cheap on vaults without. let attachments = []; try { const metas = await api('/entries/' + e.id + '/attachments', { headers: authHeaders() }); for (const m of (metas || [])) { const full = await api('/attachments/' + m.id, { headers: authHeaders() }); const bytes = await decryptBlobBytes( full.encrypted_blob, full.iv); attachments.push({ filename: m.filename, mime: m.mime, size_bytes: m.size_bytes, content_b64: bytesToBase64(bytes), }); } } catch (_) { /* partial export beats a failed one */ } payload.entries.push({ uuid: e.uuid || '', site: e.site, title: e.title || '', username: e.username, password: plain, folder: e.folder, tags: parseTags(e.tags), favorite: !!e.favorite, totp_secret: plainTotp, kind: e.kind || 'login', template: e.template || '', custom_fields: plainCustom, attachments: attachments, icon_b64: e.icon_b64 || '', created_at: e.created_at, updated_at: e.updated_at, }); } const attachTotal = payload.entries.reduce( (n, e) => n + (Array.isArray(e.attachments) ? e.attachments.length : 0), 0); // Step 4: encrypt + save via native dialog. Big vaults (many // attachments) take a few seconds — show a spinner so the app // doesn't look frozen while the Save dialog is being prepared. showBusy('Encrypting export…'); let res; try { const container = await encryptExportPayload(payload, exportPwd); const json = JSON.stringify(container, null, 2); const fname = 'vault-export-' + new Date().toISOString().slice(0, 10) + '.json'; updateBusy('Preparing file…'); res = await Bridge.saveFile(fname, json, pct => updateBusy('Preparing file… ' + pct + '%')); } finally { hideBusy(); } if (res.ok) { const tail = attachTotal > 0 ? ' + ' + attachTotal + ' attachment(s)' : ''; toast(payload.entries.length + ' entries' + tail + ' exported to ' + res.path); } else if (res.error) toast('Export failed: ' + res.error, 'error'); } catch (err) { hideBusy(); toast('Export failed: ' + (err && err.message ? err.message : err), 'error'); } } // CSV escape: wrap in quotes if the value contains comma / quote / newline. // Inner quotes doubled per RFC 4180. function csvEscape(s) { if (s == null) return ''; s = String(s); if (/[",\n\r]/.test(s)) return '"' + s.replace(/"/g, '""') + '"'; return s; } async function doExportCSV() { const ok = await confirmDialog({ title: 'Export to CSV?', message: 'The CSV file is NOT encrypted. Passwords, note bodies and ' + 'custom field values will be written in plaintext, readable by ' + 'anyone who opens the file.

' + 'Not included in CSV: encrypted attachments and custom ' + 'favicons. Use Encrypted JSON export for a complete ' + 'backup that round-trips everything.

' + 'Use this format only for migration to another password ' + 'manager — delete the file as soon as the import is done.', okText: 'Export plaintext', danger: true, }); if (!ok) return; // Decrypt everything client-side (server never sees plaintext). const rows = [[ 'kind', 'template', 'title', 'site', 'username', 'password', 'totp_secret', 'folder', 'tags', 'custom_fields', 'created_at', 'updated_at', ]]; for (const e of state.entries) { const pwd = await decryptPwd(e.encrypted_password, e.iv); let totp = ''; if (e.totp_secret && e.totp_iv) { totp = await decryptTotpSecret(e.totp_secret, e.totp_iv); if (totp === '[ERROR]') totp = ''; } let cf = ''; if (e.custom_fields && e.custom_fields_iv) { const arr = await decryptCustomFields(e.custom_fields, e.custom_fields_iv); if (arr.length) cf = JSON.stringify(arr); } rows.push([ e.kind || 'login', e.template || '', e.title || '', e.site || '', e.username || '', pwd === '[ERROR]' ? '' : pwd, totp, e.folder || '', e.tags || '', cf, e.created_at || '', e.updated_at || '', ]); } const csv = rows.map(r => r.map(csvEscape).join(',')).join('\r\n'); // Prepend UTF-8 BOM so Excel reads accented chars correctly. const body = '' + csv; const fname = 'vault-export-' + new Date().toISOString().slice(0, 10) + '.csv'; const res = await Bridge.saveFile(fname, body); if (res.ok) toast(state.entries.length + ' entries exported to ' + res.path, 'warning'); else if (res.error) toast('Export failed: ' + res.error, 'error'); } // ============================================================ // 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)); localStorage.setItem('quickSearchHotkey', JSON.stringify(state.quickSearchHotkey)); if (Bridge.active) { Bridge.setAutofillHotkeys(state.autofillEnabled, { full: state.autofillHotkeyFull, password: state.autofillHotkeyPwd, quickSearch: state.quickSearchHotkey, }); } } // 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; $('#settingFavicons').checked = state.faviconsEnabled; // Action buttons + toggle row only meaningful when the Delphi bridge // is available (the PHP frontend has no outbound proxy). $('#settingFaviconsRow').style.display = Bridge.active ? '' : 'none'; $('#settingFaviconActionsRow').style.display = Bridge.active ? 'flex' : 'none'; $('#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); $('#settingQuickSearchCombo').textContent = autofillComboLabel(state.quickSearchHotkey); $('#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(); } $('#settingTrayNotif').checked = state.trayNotificationsEnabled !== false; $('#settingTrayNotifRow').style.display = Bridge.active ? '' : 'none'; $('#settingTrashPurge').value = String(state.trashAutoPurgeDays || 0); $('#settingPasswordExpiry').value = String(state.passwordExpiryDays || 0); $('#settingEditorPosition').value = state.editorPosition || 'right'; $('#settingConfirmUnsaved').checked = state.confirmOnUnsaved !== false; // PIN unlock — only meaningful when DPAPI is available. const pinField = document.getElementById('pinUnlockField'); if (pinField) { pinField.style.display = Bridge.active ? '' : 'none'; if (Bridge.active) bridgePinStatus().then(has => { state.pinConfigured = has; refreshPinUnlockUI(); }); } $('#settingUser').textContent = state.username; renderUserAvatar(); // sync the Account-section preview + Remove button // Hide the version row entirely in the PHP/web frontend (no bridge). if (Bridge.active) { $('#settingVersionRow').style.display = ''; Promise.all([Bridge.getAppVersion(), Bridge.getLaunchMode()]).then(([v, mode]) => { const tag = mode === 'auto' ? ' · started with Windows' : mode === 'manual' ? ' · launched manually' : ''; $('#settingVersion').textContent = (v ? 'v' + v : '(unknown)') + tag; }); } else { $('#settingVersionRow').style.display = 'none'; } // 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(); } // Auto-backup: only meaningful with the Delphi bridge (filesystem). if (Bridge.active) { loadAutoBackupConfig().then(refreshAutoBackupUI); } else { refreshAutoBackupUI(null); } // Sync: same gate (Bridge required for WebDAV HTTP + DPAPI prefs). const syncField = document.getElementById('syncField'); if (syncField) { syncField.style.display = Bridge.active ? '' : 'none'; if (Bridge.active) loadSyncConfig().then(refreshSyncUI); } $('#settingsPanel').classList.add('is-open'); // Reset the search filter every time Settings is re-opened so the // user lands on the full panel, not the last filtered view. const si = $('#settingsSearch'); if (si) { si.value = ''; applySettingsSearch(''); } } function closeSettings() { $('#settingsPanel').classList.remove('is-open'); } // Filter the settings panel by text. Matches against the label + the // section body so e.g. "Ctrl" finds the hotkeys section via its button // labels. Empty query = show everything. Adds a "No matches" hint when // every section is hidden. function applySettingsSearch(rawQuery) { const panel = $('#settingsPanel'); if (!panel) return; const wrap = $('.settings-search-wrap'); const q = (rawQuery || '').trim().toLowerCase(); wrap && wrap.classList.toggle('has-query', q.length > 0); const sections = panel.querySelectorAll('.slideover-body > .slideover-field'); let totalShownRows = 0; sections.forEach(sec => { // No query: reset everything to visible. if (!q) { sec.classList.remove('is-search-hidden'); sec.querySelectorAll('.is-search-hidden').forEach(n => n.classList.remove('is-search-hidden')); return; } // Section label text is part of the section's identity (e.g. // "Sync" or "Security") — a query that hits the label keeps the // whole section visible without per-row filtering. const labelEl = sec.querySelector('.slideover-field-label'); const labelTxt = (labelEl ? labelEl.innerText : '').toLowerCase(); const labelMatch = labelTxt && labelTxt.indexOf(q) >= 0; // Per-row filter: each .setting-row is an individually-matchable // entry. Non-row children (paragraphs, button groups, hints) keep // their default visibility — they're context for whichever row is // shown, not standalone matches. const rows = sec.querySelectorAll(':scope > .setting-row'); let rowMatches = 0; rows.forEach(row => { if (labelMatch) { row.classList.remove('is-search-hidden'); rowMatches++; return; } const txt = (row.innerText || '').toLowerCase(); const hit = txt.indexOf(q) >= 0; row.classList.toggle('is-search-hidden', !hit); if (hit) rowMatches++; }); // Section has no rows at all (button-only section like Import / // Recovery): match against the whole section text. const sectionHasRows = rows.length > 0; const sectionHit = labelMatch || (!sectionHasRows && (sec.innerText || '').toLowerCase().indexOf(q) >= 0) || rowMatches > 0; sec.classList.toggle('is-search-hidden', !sectionHit); if (sectionHit) totalShownRows += sectionHasRows ? rowMatches : 1; }); let banner = panel.querySelector('.settings-no-results'); if (!banner) { banner = el('div', { class: 'settings-no-results' }, 'No settings match your search.'); const body = panel.querySelector('.slideover-body'); if (body) body.appendChild(banner); } banner.classList.toggle('is-visible', q.length > 0 && totalShownRows === 0); } // ---- 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; } // Re-query PIN presence each time we land on the auth screen — the // user may have set/removed it from another instance, and we want // the correct fields to show up without forcing a full reload. state.pinConfigured = await bridgePinStatus(); applyAuthScreenMode(); // Clear residual PIN input from a previous attempt. const pinInput = document.getElementById('loginPin'); if (pinInput) pinInput.value = ''; 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() { // 'both' unlock mode: the master pw just passed — now verify the PIN // the user typed before we open the vault. Bail back to lock screen // if the PIN doesn't match the stored blob. if (window._pinAfterMaster) { const pin = window._pinAfterMaster; window._pinAfterMaster = null; const ok = await verifyPinAfterMasterUnlock(pin); if (!ok) { const fresh = await bridgePinStatus(); state.pinConfigured = fresh; lockVault(); $('#authHint').textContent = fresh ? 'Wrong PIN. Try again.' : 'Too many wrong PIN attempts — PIN has been removed. Re-set it from Settings after unlocking.'; return; } } $('#authScreen').classList.add('is-hidden'); $('#appShell').classList.remove('is-hidden'); $('#userName').textContent = state.username; renderUserAvatar(); loadUserAvatar(); // async — repaints the avatar once the pic arrives // Server-side prefs override localStorage cache; runs before render so // theme / view mode / mask flags are applied to the first paint. await loadServerSettings(); // Drift fix: server may have stale unlockMode='pin'|'both' from a // previous device where the PIN blob has since been auto-wiped (5 // wrong attempts). Re-check real PIN presence post-sync and push // the corrected mode back up so the Settings dropdown matches the // actual unlock options the user has on this device. if (Bridge.active) { state.pinConfigured = await bridgePinStatus(); if (!state.pinConfigured && (state.unlockMode === 'pin' || state.unlockMode === 'both')) { state.unlockMode = 'pw'; localStorage.setItem('unlockMode', 'pw'); saveServerSettings(); } } // 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(); // Sync the tray notifications preference to Delphi (default ON; // settings_json may have flipped it). if (Bridge.active && typeof Bridge.setTrayNotifications === 'function') Bridge.setTrayNotifications(state.trayNotificationsEnabled !== false); // Trash auto-purge (configured via Settings → Security). Fire-and- // forget: failures are silent — the user can run "Empty trash" manually. autoPurgeTrashIfNeeded(); // Fire-and-forget periodic backup. Defer a few seconds so the unlock // path isn't blocked by file I/O + AES-GCM over the full vault. setTimeout(() => { runAutoBackupIfDue(); }, 5000); } async function autoPurgeTrashIfNeeded() { const days = parseInt(state.trashAutoPurgeDays, 10) || 0; if (days <= 0) return; try { const r = await fetch(API + '/entries/trash/old?days=' + days, { method: 'DELETE', headers: authHeaders(), }); if (!r.ok) return; const body = await r.json().catch(() => ({})); const n = parseInt(body.purged, 10) || 0; if (n > 0) { toast(n + ' old entr' + (n === 1 ? 'y' : 'ies') + ' permanently removed from trash'); // Refresh the count so the sidebar reflects the purge. await loadEntryCounts(); render(); } } catch (e) { // Silent — user can manually empty trash if they care. } } // ============================================================ // SYNC (WebDAV, auto-merge with timestamps) // ============================================================ // Multi-device sync via a remote WebDAV server (Nextcloud, ownCloud, // Apache mod_dav, any compatible). The remote file is an encrypted // JSON snapshot (same crypto container as the export, separate // password — the sync password is device-local DPAPI and the user must // configure the same one on each device they want to sync). // // Strategy: auto-merge with last-write-wins on per-entry updated_at, // tombstones for delete propagation. No conflict UI in v1 — silent // resolution because solo personal use rarely produces simultaneous // edits across devices. Toast reports added / updated / deleted counts. // // Sensitive actions (export, change master pw…) keep their own reauth // path. Sync only touches entries + folders + tombstones. const SYNC_PREFS = { enabled: 'syncEnabled', url: 'syncUrl', // e.g. https://cloud.example.com/remote.php/dav/files/USER/PMServer/vault-sync.json user: 'syncUser', pwd: 'syncPwd', // WebDAV password / app token encPwd: 'syncEncPwd', // secret for the encrypted JSON container preBackup: 'syncPreBackup', // 'on' / '' — write a local copy before each sync last: 'syncLast', // ISO timestamp of last successful run }; let _webdavResolvers = {}; Bridge.onWebdavResult = function(reqId, status, payload, etag) { const r = _webdavResolvers[reqId]; if (!r) return; delete _webdavResolvers[reqId]; r({ status: status | 0, payload: payload || '', etag: etag || '' }); }; // method: 'get'|'put'|'test'. opts.ifMatch → sent as If-Match on a put so // the server rejects (412) a write when the remote changed since our pull. function _webdavCall(method, url, user, pwd, dataB64, opts) { opts = opts || {}; const reqId = 'dav_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8); const CHUNK = 1000000; // Large PUT bodies (a big encrypted vault) can't ride in a single // cmd:// URL — WebView2 caps it and the navigation blanks the page. // Stream the base64 through the file/chunk transport, then commit via // webdav/put-commit (which reads the accumulated buffer server-side). if (method === 'put' && dataB64 && dataB64.length > CHUNK) { return (async () => { const ok = await _streamChunks(reqId, dataB64); if (!ok) return { status: 0, payload: 'chunk transfer failed', etag: '' }; return await new Promise(resolve => { _webdavResolvers[reqId] = resolve; let q = 'cmd://webdav/put-commit' + '?reqId=' + encodeURIComponent(reqId) + '&url=' + encodeURIComponent(url) + '&user=' + encodeURIComponent(user || '') + '&pwd=' + encodeURIComponent(pwd || ''); if (opts.ifMatch) q += '&ifmatch=' + encodeURIComponent(opts.ifMatch); window.location.href = q; setTimeout(() => { if (_webdavResolvers[reqId]) { delete _webdavResolvers[reqId]; resolve({ status: 0, payload: 'timeout', etag: '' }); } }, 60000); }); })(); } return new Promise(resolve => { _webdavResolvers[reqId] = resolve; let q = 'cmd://webdav/' + method + '?reqId=' + encodeURIComponent(reqId) + '&url=' + encodeURIComponent(url) + '&user=' + encodeURIComponent(user || '') + '&pwd=' + encodeURIComponent(pwd || ''); if (dataB64) q += '&data=' + encodeURIComponent(dataB64); if (opts.ifMatch) q += '&ifmatch=' + encodeURIComponent(opts.ifMatch); window.location.href = q; setTimeout(() => { if (_webdavResolvers[reqId]) { delete _webdavResolvers[reqId]; resolve({ status: 0, payload: 'timeout', etag: '' }); } }, 60000); }); } function refreshSyncUI(cfg) { if (!cfg) return; const cb = document.getElementById('settingSyncEnabled'); const cfg2 = document.getElementById('syncConfig'); if (cb) cb.checked = !!cfg.enabled; if (cfg2) cfg2.style.display = cfg.enabled ? '' : 'none'; const url = document.getElementById('syncUrl'); const user = document.getElementById('syncUser'); const pwd = document.getElementById('syncPwd'); const pre = document.getElementById('syncPreBackup'); if (url) url.value = cfg.url || ''; if (user) user.value = cfg.user || ''; if (pwd) pwd.value = cfg.pwd || ''; if (pre) pre.checked = !!cfg.preBackup; const pwdStatus = document.getElementById('syncPwdStatus'); if (pwdStatus) pwdStatus.textContent = cfg.encPwd ? 'Set.' : 'Not set.'; const last = document.getElementById('syncLast'); if (last) last.textContent = cfg.last ? ' · Last: ' + cfg.last.replace('T', ' ').slice(0, 16) : ''; } async function syncSetEncPwdFlow() { let err = ''; let n = 0; for (;;) { const v = await promptDialog({ title: 'Set sync password', message: 'Use the SAME password on every device that syncs with this remote. ' + 'Stored DPAPI-protected on this device only — never transmitted.', placeholder: 'At least 8 characters', password: true, okText: 'Save', error: err, }); if (!v) return; if (v.length >= 8) { Bridge.setPref(SYNC_PREFS.encPwd, v); const pwdStatus = document.getElementById('syncPwdStatus'); if (pwdStatus) pwdStatus.textContent = 'Set.'; toast('Sync password saved'); return; } n++; if (n >= 5) return toast('Too many invalid attempts', 'error'); err = 'Use at least 8 characters (attempt ' + n + ' / 5).'; } } async function loadSyncConfig() { if (!Bridge.active) return null; const [enabled, url, user, pwd, encPwd, pre, last] = await Promise.all([ Bridge.getPref(SYNC_PREFS.enabled), Bridge.getPref(SYNC_PREFS.url), Bridge.getPref(SYNC_PREFS.user), Bridge.getPref(SYNC_PREFS.pwd), Bridge.getPref(SYNC_PREFS.encPwd), Bridge.getPref(SYNC_PREFS.preBackup), Bridge.getPref(SYNC_PREFS.last), ]); return { enabled: enabled === '1', url: url || '', user: user || '', pwd: pwd || '', encPwd: encPwd || '', preBackup: pre === '1', last: last || '', }; } async function syncTestConnection() { const cfg = await loadSyncConfig(); if (!cfg || !cfg.url) return toast('Set the WebDAV URL first', 'warning'); toast('Testing connection…'); const r = await _webdavCall('test', cfg.url, cfg.user, cfg.pwd); if (r.status >= 200 && r.status < 400) { toast('Connection OK (' + r.status + ')'); } else if (r.status === 404) { // Server reachable, snapshot file just doesn't exist yet — normal // before the first sync. Treat as success. toast('Connection OK · snapshot not created yet'); } else if (r.status === 401 || r.status === 403) { toast('Auth failed (' + r.status + ') — check user/password', 'error'); } else if (r.status === 0) { toast('Network error: ' + (r.payload || 'unreachable'), 'error'); } else { toast('Server returned ' + r.status, 'error'); } } // Build the snapshot payload that gets encrypted + pushed to the remote. // Includes entries (decrypted plaintext, then re-encrypted under the // sync key), folders metadata, and tombstones. Mirrors doExport's shape // so a sync snapshot is also importable via "Import vault". async function buildSyncSnapshot() { const payload = { version: 1, snapshot_at: new Date().toISOString(), username: state.username, folders: (state.folders || []) .filter(f => f && f.name && f.name !== 'All') .map(f => ({ name: f.name, color: f.color || '', icon: f.icon || '' })), entries: [], tombstones: [], }; // Progress feedback: buildSyncSnapshot dominates runtime (per-entry // decrypt + attachment fetch). Report N/total as we go so the user // doesn't think the app froze on large vaults. const eligible = state.entries.filter(e => e && e.uuid); const total = eligible.length; let done = 0; for (const e of eligible) { done++; if (total > 5 && (done === 1 || done % 5 === 0 || done === total)) { syncStatus('Preparing… ' + done + '/' + total); } 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 = ''; } let plainCustom = []; if (e.custom_fields && e.custom_fields_iv) { try { plainCustom = await decryptCustomFields( e.custom_fields, e.custom_fields_iv); } catch (_) {} } let attachments = []; try { const metas = await api('/entries/' + e.id + '/attachments', { headers: authHeaders() }); for (const m of (metas || [])) { const full = await api('/attachments/' + m.id, { headers: authHeaders() }); const bytes = await decryptBlobBytes(full.encrypted_blob, full.iv); attachments.push({ filename: m.filename, mime: m.mime, size_bytes: m.size_bytes, content_b64: bytesToBase64(bytes), }); } } catch (_) {} payload.entries.push({ uuid: e.uuid, site: e.site || '', title: e.title || '', username: e.username || '', password: plain === '[ERROR]' ? '' : plain, folder: e.folder || 'All', tags: parseTags(e.tags), favorite: !!e.favorite, totp_secret: plainTotp, kind: e.kind || 'login', template: e.template || '', custom_fields: plainCustom, attachments, icon_b64: e.icon_b64 || '', created_at: e.created_at, updated_at: e.updated_at, }); } try { const ts = await api('/entries/tombstones', { headers: authHeaders() }); payload.tombstones = (Array.isArray(ts) ? ts : []).map(t => ({ uuid: t.uuid, deleted_at: t.deleted_at, })); } catch (_) {} return payload; } async function applyRemoteSnapshot(remote) { if (!remote || !Array.isArray(remote.entries)) return { added:0, updated:0, deleted:0, failed:0 }; let added = 0, updated = 0, deleted = 0, failed = 0; // Ensure state.entries reflects the live DB before we read updated_at // for the resurrection arbitration below — a restore-then-sync must // see the restored rows' fresh timestamps. await loadEntries(); // Apply remote tombstones — but arbitrate against local resurrections. // A remote tombstone says "this uuid was deleted at T". If the local // entry with that uuid was updated AFTER T (e.g. restored from a // backup since the deletion), the resurrection wins and the tombstone // is skipped — otherwise a restore would be silently undone on the // next sync. Entries the local side hasn't touched since T are // deleted normally (standard delete propagation). if (Array.isArray(remote.tombstones) && remote.tombstones.length > 0) { // Snapshot local uuid → updated_at BEFORE any deletion. const localTs = new Map(); for (const e of state.entries) if (e.uuid) localTs.set(e.uuid, e.updated_at || ''); // A local entry beats the tombstone only if it exists AND is // provably newer than deleted_at. Unparseable/missing timestamps // favour KEEP (data-loss is worse than a stale entry the user can // re-delete). const isResurrected = (uuid, deletedAt) => { if (!localTs.has(uuid)) return false; // not local → apply const up = Date.parse(String(localTs.get(uuid)).replace(' ', 'T')); const del = Date.parse(String(deletedAt || '').replace(' ', 'T')); if (isNaN(up)) return true; // can't tell → keep if (isNaN(del)) return false; // no delete time → apply return up > del; }; const toApply = remote.tombstones .filter(t => t.uuid && !isResurrected(t.uuid, t.deleted_at)) .map(t => t.uuid); if (toApply.length > 0) { const preLocal = new Set(); for (const e of state.entries) if (e.uuid) preLocal.add(e.uuid); try { await api('/entries/tombstones', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ uuids: toApply }), }); deleted = toApply.filter(u => preLocal.has(u)).length; } catch (_) {} } } // Refresh local view AFTER tombstones so the maps reflect the cull. await loadEntries(); const byUuid = new Map(); for (const e of state.entries) if (e.uuid) byUuid.set(e.uuid, e); // Local tombstones must veto ANY remote entry with a matching uuid — // otherwise a perm-delete on this device gets undone by the next // pull ("48 added" after wiping the vault, because the remote // snapshot still carries the pre-delete state). The tombstones are // pushed back to the remote at the next push, propagating the // delete cleanly. const localTombstones = new Set(); try { const ts = await api('/entries/tombstones', { headers: authHeaders() }); (Array.isArray(ts) ? ts : []).forEach(t => t.uuid && localTombstones.add(t.uuid)); } catch (_) {} // Folders — add missing ones with the remote's color/icon. Existing // folders are left untouched (user's local customisation wins). if (Array.isArray(remote.folders)) { const localNames = new Set((state.folders || []).map(f => f.name)); for (const f of remote.folders) { if (!f.name || localNames.has(f.name)) continue; try { await api('/folders', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ name: f.name, color: f.color || '', icon: f.icon || '', }), }); } catch (_) {} } await loadFolders(); } // Per-entry merge. for (const r of remote.entries) { if (!r.uuid) continue; // Skip entries this device has already tombstoned — never // resurrect a deleted entry. if (localTombstones.has(r.uuid)) continue; const local = byUuid.get(r.uuid); if (!local) { // New entry on remote — encrypt locally + POST keeping the uuid. try { const enc = await encryptImportEntry(Object.assign({}, r, { uuid: r.uuid })); const created = await api('/entries', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify(enc), }); added++; // Restore attachments for this new entry. if (Array.isArray(r.attachments) && created && created.id) { for (const a of r.attachments) { try { const bytes = base64ToBytes(a.content_b64 || ''); const blob = await encryptBlobBytes(bytes); await api('/entries/' + created.id + '/attachments', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ filename: a.filename, mime: a.mime || 'application/octet-stream', encrypted_blob: blob.encrypted, iv: blob.iv, size_bytes: a.size_bytes || bytes.length, }), }); } catch (_) {} } } } catch (_) { failed++; } } else { // Both sides have it — keep the newer one (lexical ISO sort). const remoteWins = (r.updated_at || '') > (local.updated_at || ''); if (!remoteWins) continue; try { const enc = await encryptImportEntry(r); // PUT keeps the existing id but accepts the encrypted blobs. // template + uuid not touched here (server preserves columns // when fields aren't in body — uuid is immutable anyway). await api('/entries/' + local.id, { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify(enc), }); updated++; } catch (_) { failed++; } } } return { added, updated, deleted, failed }; } // Update the inline sync-status label next to the button. Empty string // hides it (end of sync). Called from every phase so the user sees // where the runtime is spent (buildSyncSnapshot is by far the slowest, // hence the per-entry counter). function syncStatus(text) { const el = document.getElementById('syncStatus'); if (el) { if (text) { el.textContent = text; el.style.display = ''; } else { el.textContent = ''; el.style.display = 'none'; } } // Also drive the global busy overlay so a running sync blocks stray // clicks (e.g. the auto-backup "Choose…" picker) and reads the same // as the manual backup. Empty text = clear. if (text) showBusy(text); else hideBusy(); // Lock the Sync/Test buttons while a run is in flight so the user // can't double-click a second concurrent sync. const active = !!text; ['syncNowBtn', 'syncTestBtn'].forEach(id => { const b = document.getElementById(id); if (b) b.disabled = active; }); } async function runSyncNow(_attempt) { _attempt = _attempt || 0; const cfg = await loadSyncConfig(); if (!cfg) return toast('Bridge not available', 'error'); if (!cfg.url) return toast('Sync not configured', 'warning'); if (!cfg.encPwd) return toast('Set the sync password first', 'warning'); if (!state.cryptoKey) return toast('Vault is locked', 'warning'); if (_attempt === 0) toast('Syncing…'); let merged = { added: 0, updated: 0, deleted: 0 }; // Fail-fast connectivity: hit the remote FIRST so a dead server / // wrong URL aborts before any heavy local work (the pre-sync backup // and buildSyncSnapshot are expensive on large vaults — no point // running them if we can't reach the server). syncStatus('Connecting…'); let pullResp; try { pullResp = await _webdavCall('get', cfg.url, cfg.user, cfg.pwd); } catch (e) { syncStatus(''); return toast('Sync pull failed: ' + (e && e.message || e), 'error'); } if (pullResp.status === 0) { syncStatus(''); return toast('Network error: ' + (pullResp.payload || 'unreachable'), 'error'); } if (!(pullResp.status === 404 || (pullResp.status >= 200 && pullResp.status < 300))) { syncStatus(''); return toast('Pull failed: HTTP ' + pullResp.status, 'error'); } // ETag of the version we just pulled — sent back as If-Match on the // push so the server rejects our write if another device changed the // file in between (optimistic concurrency, avoids lost updates). const remoteEtag = pullResp.etag || ''; // Decrypt + cross-account guard BEFORE touching local state, so a // wrong sync password or a foreign account aborts cleanly. let remoteSnap = null; if (pullResp.status !== 404 && pullResp.payload) { try { const jsonText = new TextDecoder().decode(base64ToBytes(pullResp.payload)); const container = JSON.parse(jsonText); remoteSnap = await decryptExportContainer(container, cfg.encPwd); } catch (e) { syncStatus(''); return toast('Remote decrypt failed — wrong sync password?', 'error'); } if (remoteSnap && remoteSnap.username && state.username && remoteSnap.username !== state.username) { // Drop the busy overlay so this confirm (z-index below it) // is visible; a later syncStatus() re-shows it if we continue. hideBusy(); const ok = await confirmDialog({ title: 'Different account on remote', message: 'The remote snapshot belongs to ' + (remoteSnap.username + '').replace(/[<>&]/g, '') + ', but you are signed in as ' + (state.username + '').replace(/[<>&]/g, '') + '. Merging would mix the two vaults. ' + 'Use a distinct sync URL per account.', okText: 'Merge anyway', cancelText: 'Cancel', danger: true, }); if (!ok) { syncStatus(''); return toast('Sync cancelled — account mismatch', 'warning'); } } } // Server reachable + snapshot decrypted → NOW do the optional // pre-sync backup (captures current local state before the merge // mutates it). Best-effort; a failure here doesn't block the sync. if (cfg.preBackup) { try { const ab = await loadAutoBackupConfig(); const dir = (ab && ab.dir) ? ab.dir : null; if (dir) { syncStatus('Local backup…'); const snap = await buildSyncSnapshot(); const container = await encryptExportPayload(snap, cfg.encPwd); const ts = new Date().toISOString() .replace(/[-:]/g, '').replace('T', '-').slice(0, 15); const path = dir.replace(/[\\/]+$/, '') + '\\vault-presync-' + ts + '.json'; await Bridge.writeFile(path, JSON.stringify(container, null, 2)); } } catch (_) { /* best-effort */ } } // Apply the remote snapshot (404 → nothing to merge, first sync). if (remoteSnap) { syncStatus('Merging…'); try { merged = await applyRemoteSnapshot(remoteSnap); } catch (e) { syncStatus(''); return toast('Apply failed: ' + (e && e.message || e), 'error'); } } // Guard against data loss: if we failed to import ONE or more remote // entries locally (server rejected them, encryption failed, etc.), // pushing the current local snapshot would silently overwrite the // remote file with a shrunken dataset. Abort the push and surface a // clear error so the user can investigate + retry. if (merged.failed && merged.failed > 0) { syncStatus(''); return toast('Sync aborted — ' + merged.failed + ' remote entry(ies) failed to import locally. Push skipped to avoid overwriting remote data.', 'error'); } // Push merged state back to the remote. let pushedCount = 0; try { await loadEntries(); // pull latest after applying remote changes const snap = await buildSyncSnapshot(); pushedCount = Array.isArray(snap.entries) ? snap.entries.length : 0; syncStatus('Encrypting…'); const container = await encryptExportPayload(snap, cfg.encPwd); const bodyBytes = new TextEncoder().encode(JSON.stringify(container, null, 2)); syncStatus('Pushing…'); const r = await _webdavCall('put', cfg.url, cfg.user, cfg.pwd, bytesToBase64(bodyBytes), { ifMatch: remoteEtag }); // 412 Precondition Failed = the remote changed since our pull // (another device pushed). Re-run the whole pull→merge→push so we // fold in their changes instead of clobbering them. Bounded to a // few tries to avoid a livelock against a device syncing in a hot // loop. if (r.status === 412) { syncStatus(''); if (_attempt < 3) { toast('Remote changed — re-syncing…'); return await runSyncNow(_attempt + 1); } return toast('Sync gave up after repeated remote changes — try again', 'error'); } if (!(r.status >= 200 && r.status < 300)) { syncStatus(''); return toast('Push failed: HTTP ' + r.status, 'error'); } } catch (e) { syncStatus(''); return toast('Sync push failed: ' + (e && e.message || e), 'error'); } syncStatus(''); const now = new Date().toISOString(); Bridge.setPref(SYNC_PREFS.last, now); render(); // Bidirectional summary: the added/updated/deleted counts are what // was pulled FROM the remote into this device; pushedCount is the // total entries written back to the remote (so "0·0·0 · pushed 12" // makes clear the vault is safely uploaded even when nothing new // came down). const summary = 'pulled ' + merged.added + ' new · ' + merged.updated + ' updated · ' + merged.deleted + ' deleted · ' + 'pushed ' + pushedCount + ' ' + (pushedCount === 1 ? 'entry' : 'entries'); toast('Sync complete — ' + summary); } // ============================================================ // AUTO-BACKUP (silent encrypted exports on a schedule) // ============================================================ // All config is device-local (folder paths and passwords don't sync // meaningfully across machines) and persisted via Delphi DPAPI prefs // so it survives the port-rotation localStorage wipe. const ABK = { enabled: 'autoBackupEnabled', // '1' | '' dir: 'autoBackupDir', // absolute Windows path interval: 'autoBackupInterval', // days, int as string keep: 'autoBackupKeep', // count, int as string last: 'autoBackupLast', // ISO timestamp of last successful run pwd: 'autoBackupPwd', // user-chosen pwd, used silently }; const AUTO_BACKUP_PREFIX = 'vault-autobackup-'; async function loadAutoBackupConfig() { if (!Bridge.active) return null; const [enabled, dir, interval, keep, last, pwd] = await Promise.all([ Bridge.getPref(ABK.enabled), Bridge.getPref(ABK.dir), Bridge.getPref(ABK.interval), Bridge.getPref(ABK.keep), Bridge.getPref(ABK.last), Bridge.getPref(ABK.pwd), ]); return { enabled: enabled === '1', dir: dir || '', interval: Math.max(1, parseInt(interval, 10) || 7), keep: Math.max(1, parseInt(keep, 10) || 10), last: last || '', hasPwd: !!pwd, pwd, }; } function refreshAutoBackupUI(cfg) { if (!cfg) { $('#autoBackupField').style.display = 'none'; return; } $('#autoBackupField').style.display = ''; $('#settingAutoBackupEnabled').checked = cfg.enabled; $('#autoBackupConfig').style.display = cfg.enabled ? '' : 'none'; $('#autoBackupDir').textContent = cfg.dir || '(not set)'; $('#settingAutoBackupInterval').value = cfg.interval; $('#settingAutoBackupKeep').value = cfg.keep; $('#autoBackupLast').textContent = cfg.last ? 'Last run: ' + cfg.last.replace('T', ' ').slice(0, 16) : 'Never run yet'; } async function pickAutoBackupFolder() { const path = await Bridge.pickFolder(); if (!path) return; Bridge.setPref(ABK.dir, path); $('#autoBackupDir').textContent = path; toast('Backup folder set'); } async function promptAndStoreBackupPwd() { let lastError = ''; let attempts = 0; const MAX_ATTEMPTS = 5; for (;;) { const pwd = await promptDialog({ title: 'Choose a backup password', message: 'You will need this to restore the auto-backups. Save it somewhere safe — it is independent of your master password.', placeholder: 'At least 6 characters', password: true, okText: 'Save', error: lastError, }); if (!pwd) return false; // cancelled (false / null / '' / undefined) if (pwd.length >= 6) { Bridge.setPref(ABK.pwd, pwd); return true; } attempts++; if (attempts >= MAX_ATTEMPTS) { toast('Too many invalid attempts', 'error'); return false; } lastError = 'Password must be at least 6 characters (attempt ' + attempts + ' / ' + MAX_ATTEMPTS + ').'; } } async function onToggleAutoBackup(ev) { const enabled = ev.target.checked; if (enabled) { const cfg = await loadAutoBackupConfig(); if (!cfg.hasPwd && !(await promptAndStoreBackupPwd())) { ev.target.checked = false; return; } Bridge.setPref(ABK.enabled, '1'); $('#autoBackupConfig').style.display = ''; toast('Auto-backup enabled'); // Refresh the dir/last display in case we came from cold state. const fresh = await loadAutoBackupConfig(); refreshAutoBackupUI(fresh); } else { Bridge.setPref(ABK.enabled, ''); // Forget the stored backup pwd so re-enabling prompts fresh — // gives the user a way to change it without extra UI. Bridge.setPref(ABK.pwd, ''); $('#autoBackupConfig').style.display = 'none'; toast('Auto-backup disabled'); } } async function runAutoBackupNow(silent) { const cfg = await loadAutoBackupConfig(); if (!cfg) return silent || toast('Bridge not available', 'error'); if (!cfg.dir) return silent || toast('Choose a backup folder first', 'warning'); if (!cfg.hasPwd) return silent || toast('Backup password not set', 'warning'); if (!state.cryptoKey) return silent || toast('Vault is locked', 'warning'); // Manual "Backup now" shows a spinner (big vaults take ~30s). The // scheduled on-unlock run stays silent (no overlay stealing focus). if (!silent) { showBusy('Reading vault…'); await new Promise(r => setTimeout(r, 0)); } try { const payload = { version: 1, exported_at: new Date().toISOString(), username: state.username, folders: (state.folders || []) .filter(f => f && f.name && f.name !== 'All') .map(f => ({ name: f.name, color: f.color || '', icon: f.icon || '', })), entries: [], }; let _bkDone = 0; const _bkTotal = state.entries.length; for (const e of state.entries) { _bkDone++; if (!silent && _bkTotal > 10 && (_bkDone % 5 === 0 || _bkDone === _bkTotal)) updateBusy('Reading vault… ' + _bkDone + '/' + _bkTotal); 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 = ''; } let plainCustom = []; if (e.custom_fields && e.custom_fields_iv) { try { plainCustom = await decryptCustomFields( e.custom_fields, e.custom_fields_iv); } catch (_) { plainCustom = []; } } let attachments = []; try { const metas = await api('/entries/' + e.id + '/attachments', { headers: authHeaders() }); for (const m of (metas || [])) { const full = await api('/attachments/' + m.id, { headers: authHeaders() }); const bytes = await decryptBlobBytes( full.encrypted_blob, full.iv); attachments.push({ filename: m.filename, mime: m.mime, size_bytes: m.size_bytes, content_b64: bytesToBase64(bytes), }); } } catch (_) {} payload.entries.push({ uuid: e.uuid || '', site: e.site, title: e.title || '', username: e.username, password: plain, folder: e.folder, tags: parseTags(e.tags), favorite: !!e.favorite, totp_secret: plainTotp, kind: e.kind || 'login', template: e.template || '', custom_fields: plainCustom, attachments: attachments, icon_b64: e.icon_b64 || '', created_at: e.created_at, updated_at: e.updated_at, }); } if (!silent) updateBusy('Encrypting backup…'); const container = await encryptExportPayload(payload, cfg.pwd); const json = JSON.stringify(container, null, 2); // Filename: yyyymmdd-HHMMSS for filesystem-sort-friendliness. const ts = new Date().toISOString() .replace(/[-:]/g, '').replace('T', '-').slice(0, 15); const fname = AUTO_BACKUP_PREFIX + ts + '.json'; const path = cfg.dir.replace(/[\\/]+$/, '') + '\\' + fname; if (!silent) updateBusy('Writing file…'); const res = await Bridge.writeFile(path, json, pct => { if (!silent) updateBusy('Writing file… ' + pct + '%'); }); if (!res.ok) { if (!silent) toast('Backup failed: ' + (res.error || 'unknown'), 'error'); return; } const now = new Date().toISOString(); Bridge.setPref(ABK.last, now); $('#autoBackupLast').textContent = 'Last run: ' + now.replace('T', ' ').slice(0, 16); if (!silent) toast(payload.entries.length + ' entries backed up'); applyAutoBackupRetention(cfg.dir, cfg.keep); } catch (err) { if (!silent) toast('Backup failed: ' + (err && err.message ? err.message : err), 'error'); } finally { if (!silent) hideBusy(); } } async function applyAutoBackupRetention(dir, keep) { try { const files = await Bridge.listFiles(dir, AUTO_BACKUP_PREFIX); if (files.length <= keep) return; // Sort by name desc (timestamps in filename → lexical = chronological) files.sort((a, b) => (a.name < b.name ? 1 : -1)); const toDelete = files.slice(keep); for (const f of toDelete) { const path = dir.replace(/[\\/]+$/, '') + '\\' + f.name; await Bridge.deleteFile(path); } } catch (e) { // Retention is best-effort; user can clean up manually. } } async function runAutoBackupIfDue() { if (!Bridge.active) return; const cfg = await loadAutoBackupConfig(); if (!cfg || !cfg.enabled || !cfg.dir || !cfg.hasPwd) return; if (!state.cryptoKey) return; const intervalMs = cfg.interval * 24 * 3600 * 1000; const last = cfg.last ? Date.parse(cfg.last) : 0; if (last && (Date.now() - last) < intervalMs) return; await runAutoBackupNow(true); // silent — no spinner on the scheduled run } // ============================================================ // 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', 'quickSearchHotkey', // Sidebar section collapsed state. Object of { folders, tags, tools } // booleans. Synced so the user gets the same fold state across devices. 'sidebarCollapsed', 'faviconsEnabled', 'trayNotificationsEnabled', 'trashAutoPurgeDays', // Password expiry reminder window (days, 0 = disabled). Cards show // an "Aged" badge for entries whose password_changed_at exceeds it. 'passwordExpiryDays', // Table view: columns the user has explicitly hidden. 'tableColsHidden', // Where the entry editor docks: 'right' (default slideover), // 'left' (mirrored slideover), or 'center' (centered modal). 'editorPosition', // When the entry editor has unsaved changes, ask before dismissing. 'confirmOnUnsaved', // Unlock method (pw / pin / both). The PIN blob itself is device-local // DPAPI so this synced setting only carries the user's preferred mode. 'unlockMode', ]; // Sets `data-editor-position` on so CSS can swap the slideover // between right / left / centered. No JS-side render changes — same DOM, // same JS, just different CSS. function applyEditorPosition() { const pos = ['right', 'left', 'center'].includes(state.editorPosition) ? state.editorPosition : 'right'; document.body.setAttribute('data-editor-position', pos); } 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 'quickSearchHotkey': case 'sidebarCollapsed': // Object; persist as JSON so the next cold start picks it up. localStorage.setItem(k, JSON.stringify(v)); break; case 'faviconsEnabled': localStorage.setItem('faviconsEnabled', v ? '1' : '0'); break; case 'trayNotificationsEnabled': localStorage.setItem('trayNotificationsEnabled', v ? '1' : '0'); // Push the synced value to Delphi so the bridge honours // it from this point on (the user may have flipped it // on another device). if (Bridge.active && typeof Bridge.setTrayNotifications === 'function') Bridge.setTrayNotifications(v); break; case 'trashAutoPurgeDays': localStorage.setItem('trashAutoPurgeDays', String(v)); break; case 'passwordExpiryDays': localStorage.setItem('passwordExpiryDays', String(v)); break; case 'tableColsHidden': localStorage.setItem('tableColsHidden', JSON.stringify(Array.isArray(v) ? v : [])); break; case 'editorPosition': localStorage.setItem('editorPosition', String(v || 'right')); applyEditorPosition(); break; case 'confirmOnUnsaved': localStorage.setItem('confirmOnUnsaved', v ? '1' : '0'); break; case 'unlockMode': localStorage.setItem('unlockMode', String(v || 'pw')); 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 }); document.addEventListener('keydown', handleCardCursorKey); // Delete / Backspace on the grid (no input focused, no modal open) // triggers the batch action matching the current view: soft-trash for // normal views, permanent-delete for the trash view. Mirrors what the // batch bar does, just via keyboard. document.addEventListener('keydown', e => { if (e.key !== 'Delete' && e.key !== 'Backspace') return; if (e.ctrlKey || e.altKey || e.metaKey) return; const tag = (e.target && e.target.tagName || '').toLowerCase(); if (tag === 'input' || tag === 'textarea' || tag === 'select') return; if (e.target && e.target.isContentEditable) return; if (!$('#appShell') || $('#appShell').classList.contains('is-hidden')) return; if (document.querySelector('.modal:not(.is-hidden)')) return; if ($('#slideover') && $('#slideover').classList.contains('is-open')) return; if ($('#settingsPanel') && $('#settingsPanel').classList.contains('is-open')) return; if (state.checked.size === 0) return; e.preventDefault(); if (state.view === 'trash') batchPermDelete(); else batchDelete(); }); // 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)); applyColsWrapVisibility(); renderGrid(); } applyViewMode(); $$('.view-btn').forEach(b => b.addEventListener('click', () => { state.viewMode = b.dataset.view; state.currentPage = 1; localStorage.setItem('viewMode', state.viewMode); applyViewMode(); saveServerSettings(); })); // Show/hide table column picker. Built lazily on first open so the // checkbox state always reflects the current `state.tableColsHidden`. const colsBtn = document.getElementById('colsBtn'); const colsMenu = document.getElementById('colsMenu'); if (colsBtn && colsMenu) { colsBtn.addEventListener('click', ev => { ev.stopPropagation(); const isHidden = colsMenu.classList.contains('is-hidden'); if (isHidden) renderTableColsMenu(); colsMenu.classList.toggle('is-hidden'); }); document.addEventListener('click', e => { if (!e.target.closest('#colsWrap')) colsMenu.classList.add('is-hidden'); }); } $('#themeBtn').addEventListener('click', () => { toggleTheme(); saveServerSettings(); }); $('#newEntryBtn').addEventListener('click', ev => { ev.stopPropagation(); // Default click → new login (preserves the muscle memory of the // existing button). The chevron next to it opens the kind picker. $('#newEntryMenu').classList.add('is-hidden'); openSlideOver(null, { kind: 'login' }); }); $('#newEntryCaretBtn').addEventListener('click', ev => { ev.stopPropagation(); $('#newEntryMenu').classList.toggle('is-hidden'); }); $$('#newEntryMenu [data-new-kind]').forEach(b => { b.addEventListener('click', ev => { ev.stopPropagation(); const kind = b.getAttribute('data-new-kind') || 'login'; $('#newEntryMenu').classList.add('is-hidden'); openSlideOver(null, { kind }); }); }); $$('#newEntryMenu [data-new-template]').forEach(b => { b.addEventListener('click', ev => { ev.stopPropagation(); const template = b.getAttribute('data-new-template'); $('#newEntryMenu').classList.add('is-hidden'); openSlideOver(null, { template }); }); }); // Close the dropdown on any other click. document.addEventListener('click', e => { if (!e.target.closest('.new-entry-wrap')) $('#newEntryMenu').classList.add('is-hidden'); }); $('#userChip').addEventListener('click', () => $('#userDropdown').classList.toggle('is-hidden')); $('#lockBtn').addEventListener('click', async () => { if (await confirmDiscardForSessionExit('lock')) lockVault(); }); $('#dropdownSettingsBtn').addEventListener('click', () => { $('#userDropdown').classList.add('is-hidden'); openSettings(); }); $('#logoutBtn').addEventListener('click', async () => { if (await confirmDiscardForSessionExit('logout')) 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 raw = ev.dataTransfer.getData('text/plain') || ''; const ids = raw.split(',').map(s => parseInt(s)).filter(n => n > 0); if (!ids.length) return; let ok = 0; for (const id of ids) { try { await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() }); ok++; } catch (err) { /* keep going for the rest */ } } toast(ok === 1 ? 'Moved to trash' : 'Moved ' + ok + ' entries to trash'); state.checked.clear(); await loadEntries(); await loadTrash(); render(); }); } // Search let searchHistoryDebounce = null; $('#searchInput').addEventListener('input', e => { state.search = e.target.value; state.currentPage = 1; renderGrid(); // Hide history while user is actively typing; it pops back on // focus when the field is empty. if (e.target.value) $('#searchHistoryMenu').classList.add('is-hidden'); else renderSearchHistoryMenu(); // Commit to history after a pause — captures the live-typing case // where the user finds what they wanted without ever pressing // Enter or blurring the field. if (searchHistoryDebounce) clearTimeout(searchHistoryDebounce); const v = e.target.value.trim(); if (v.length >= 2) { searchHistoryDebounce = setTimeout(() => pushSearchHistory(v), 1000); } }); $('#searchInput').addEventListener('focus', () => { if (!$('#searchInput').value) renderSearchHistoryMenu(); }); $('#searchInput').addEventListener('keydown', e => { // Enter commits the current term to history. Blur with a non-empty // value also commits — handled below. if (e.key === 'Enter' && $('#searchInput').value.trim()) { pushSearchHistory($('#searchInput').value.trim()); $('#searchHistoryMenu').classList.add('is-hidden'); } else if (e.key === 'Escape') { $('#searchHistoryMenu').classList.add('is-hidden'); } }); $('#searchInput').addEventListener('blur', () => { const v = $('#searchInput').value.trim(); if (v) pushSearchHistory(v); // Delay-hide so a click on a history item can still register. setTimeout(() => $('#searchHistoryMenu').classList.add('is-hidden'), 120); }); // Click-outside also dismisses (covers the case where focus moves via // keyboard to a non-blurring element). document.addEventListener('click', e => { if (!e.target.closest('.search')) { $('#searchHistoryMenu').classList.add('is-hidden'); } }); // Slide-over $('#slideoverClose').addEventListener('click', () => requestCloseSlideOver()); // 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). // mousedown origin is captured so a drag-selection that starts inside // an input and ends outside doesn't trigger close (the resulting click // event has a target outside the slideover even though the user never // intended to dismiss it). // Esc closes the slideover. Registered with capture=true so it fires // BEFORE any input-level handler that might call stopPropagation, and // before the browser swallows the keystroke for things like clearing // an active form-autocomplete popup on a focused field. document.addEventListener('keydown', e => { if (e.key !== 'Escape') return; if (!$('#slideover').classList.contains('is-open')) return; if (document.querySelector('.modal:not(.is-hidden)')) return; // Settings panel takes priority — when both Settings AND the // editor are open, the first Esc should close Settings (the // thing the user just opened on top), the second Esc handles // the editor's dirty-check. Let the Settings Esc handler run. if ($('#settingsPanel').classList.contains('is-open')) return; // Stop here so the global "Esc closes confirmModal" handler // doesn't fire on the SAME keystroke and immediately dismiss // the discard-confirm dialog that requestCloseSlideOver just // opened — that bug left the user with no visible feedback at // all (modal opened and closed in one tick). e.preventDefault(); e.stopPropagation(); requestCloseSlideOver(); }, true); // Click-outside closes too. mousedown origin is captured so a // drag-selection that starts inside an input and ends outside doesn't // count as an outside click. Cards/rows/modals/etc. are whitelisted // so clicks on them don't dismiss the panel. let _slideoverMouseDownInside = false; document.addEventListener('mousedown', e => { _slideoverMouseDownInside = !!(e.target.closest && e.target.closest('.slideover')); // Close any open custom combobox menu when the click lands outside // a combo (the arrow toggle + item mousedown both stopPropagation, // so this only fires for genuine outside clicks). if (!(e.target.closest && e.target.closest('.so-combo'))) { document.querySelectorAll('.so-combo-menu:not(.is-hidden)') .forEach(m => m.classList.add('is-hidden')); } }, true); document.addEventListener('click', e => { if (!$('#slideover').classList.contains('is-open')) return; if (_slideoverMouseDownInside) { _slideoverMouseDownInside = false; return; } if (e.target.closest('.slideover')) return; if (e.target.closest('.entry-card')) return; if (e.target.closest('.entry-row')) return; if (e.target.closest('.modal')) return; if (e.target.closest('.cmd-palette')) return; if (e.target.closest('.idle-warning')) return; // Sidebar + topbar are UI chrome — clicking the theme toggle, // user menu, filter button, pagination etc. shouldn't dismiss // the editor. Same for the table column picker dropdown. if (e.target.closest('.topbar')) return; if (e.target.closest('.sidebar')) return; if (e.target.closest('.pagination')) return; if (e.target.closest('.cols-menu')) return; if (e.target.closest('.filters-menu')) return; if (e.target.closest('.user-menu')) return; if (e.target.closest('.search-history')) return; requestCloseSlideOver(); }); // Click outside the settings panel closes it. Handled on MOUSEDOWN // (not click) so async handlers that reparent the DOM (e.g. // backfillFavicons → render() during the click→bubble window) can't // make us mistake an inside-click for an outside-click. Ignore the // triggers and any open modal so reauth / confirm flows fired from // inside Settings don't dismiss it. document.addEventListener('mousedown', e => { if (!$('#settingsPanel').classList.contains('is-open')) return; if (!(e.target && e.target.closest)) 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; if (e.target.closest('.cmd-palette')) return; if (e.target.closest('.toast')) 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'; }); // PIN-mode escape hatch: temporarily switch to the master-pw layout // for this unlock attempt (doesn't change the saved unlockMode). const useMasterBtn = document.getElementById('loginUseMasterBtn'); if (useMasterBtn) useMasterBtn.addEventListener('click', () => { state.pinConfigured = false; // local-only flag, restored by next showAuth() applyAuthScreenMode(); const p = document.getElementById('loginPassword'); if (p) p.focus(); }); $('#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); $('#sidebarRecentBtn').addEventListener('click', () => { state.view = 'recent'; state.currentPage = 1; $$('.nav-item').forEach(b => b.classList.remove('is-active')); render(); }); $('#sidebarHealthBtn').addEventListener('click', () => { state.view = 'health'; state.currentPage = 1; $$('.nav-item').forEach(b => b.classList.remove('is-active')); // Invalidate any stale cache so we recompute fresh each open. healthCache = null; render(); }); $('#sidebarAuditBtn').addEventListener('click', () => { state.view = 'audit'; $$('.nav-item').forEach(b => b.classList.remove('is-active')); auditCache = null; render(); }); // 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(); applyEditorPosition(); // Idle warning "Stay unlocked" $('#idleStayBtn').addEventListener('click', resetAutoLock); // Settings slide-over $('#settingsBtn').addEventListener('click', openSettings); $('#settingsClose').addEventListener('click', closeSettings); // Escape closes the Settings panel — unless the search input is // focused with a non-empty query (in that case its own handler // already swallowed the event and cleared the query). Skip when a // confirm/reauth modal is up so its Escape stays the priority. document.addEventListener('keydown', e => { if (e.key !== 'Escape') return; if (!$('#settingsPanel').classList.contains('is-open')) return; if (document.querySelector('.modal:not(.is-hidden)')) return; // When the search box has focus AND a query, the input's own // handler clears the query (and stops propagation) — let it run. // Capture phase fires document handlers BEFORE element handlers, // so without this guard we'd close Settings before the search // input ever saw the Esc. const si = $('#settingsSearch'); if (si && si === document.activeElement && si.value) return; // Don't let the global Esc-fallback handler fire on the same // keystroke — its slideover-close branch would pop the discard // confirm in the background while Settings closes, surprising // the user who expected one Esc = close one panel. e.stopPropagation(); closeSettings(); }, true); // Settings search box: live filter on every input. Escape clears the // query (without closing the panel — the existing Esc handler also // closes settings, but only when focus isn't inside an input). const sInput = $('#settingsSearch'); const sClear = $('#settingsSearchClear'); if (sInput) { sInput.addEventListener('input', e => applySettingsSearch(e.target.value)); sInput.addEventListener('keydown', e => { if (e.key === 'Escape' && sInput.value) { e.stopPropagation(); sInput.value = ''; applySettingsSearch(''); } }); } if (sClear) { sClear.addEventListener('click', () => { if (!sInput) return; sInput.value = ''; applySettingsSearch(''); sInput.focus(); }); } // 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'); }); $('#settingTrayNotif').addEventListener('change', e => { state.trayNotificationsEnabled = e.target.checked; localStorage.setItem('trayNotificationsEnabled', e.target.checked ? '1' : '0'); if (Bridge.active) Bridge.setTrayNotifications(e.target.checked); saveServerSettings(); toast(e.target.checked ? 'Tray notifications enabled' : 'Tray notifications disabled'); }); $('#settingTrashPurge').addEventListener('change', e => { const n = parseInt(e.target.value, 10) || 0; state.trashAutoPurgeDays = n; localStorage.setItem('trashAutoPurgeDays', String(n)); saveServerSettings(); if (n === 0) toast('Trash auto-purge disabled'); else toast('Trash will auto-purge after ' + n + ' days (next unlock)'); }); $('#settingPasswordExpiry').addEventListener('change', e => { const n = parseInt(e.target.value, 10) || 0; state.passwordExpiryDays = n; localStorage.setItem('passwordExpiryDays', String(n)); saveServerSettings(); render(); if (n === 0) toast('Password aging reminders disabled'); else toast('Passwords older than ' + n + ' days will be flagged'); }); $('#settingEditorPosition').addEventListener('change', e => { const v = ['right', 'left', 'center'].includes(e.target.value) ? e.target.value : 'right'; state.editorPosition = v; localStorage.setItem('editorPosition', v); applyEditorPosition(); saveServerSettings(); }); $('#settingConfirmUnsaved').addEventListener('change', e => { state.confirmOnUnsaved = !!e.target.checked; localStorage.setItem('confirmOnUnsaved', state.confirmOnUnsaved ? '1' : '0'); saveServerSettings(); }); // PIN unlock controls. Setting the mode to PIN/both without a PIN // configured first would lock the user out — gate the dropdown so a // missing PIN forces the user through Set PIN first. const pinSet = document.getElementById('pinSetBtn'); const pinDel = document.getElementById('pinRemoveBtn'); const pinSel = document.getElementById('settingUnlockMode'); if (pinSet) pinSet.addEventListener('click', pinSetupFlow); if (pinDel) pinDel.addEventListener('click', async () => { const ok = await confirmDialog({ title: 'Remove PIN?', message: 'You will need your master password to unlock until you set a new PIN.', okText: 'Remove', danger: true, }); if (!ok) return; await removePin(); refreshPinUnlockUI(); }); if (pinSel) pinSel.addEventListener('change', async e => { const next = e.target.value; if ((next === 'pin' || next === 'both') && !state.pinConfigured) { toast('Set a PIN first', 'warning'); e.target.value = state.unlockMode || 'pw'; return; } state.unlockMode = next; localStorage.setItem('unlockMode', next); saveServerSettings(); toast('Unlock method updated'); }); // Sync (WebDAV) listeners const syncCb = document.getElementById('settingSyncEnabled'); const syncCfgBox = document.getElementById('syncConfig'); const syncUrlEl = document.getElementById('syncUrl'); const syncUserEl = document.getElementById('syncUser'); const syncPwdEl = document.getElementById('syncPwd'); const syncPreEl = document.getElementById('syncPreBackup'); const syncSetPwd = document.getElementById('syncSetPwdBtn'); const syncTest = document.getElementById('syncTestBtn'); const syncNow = document.getElementById('syncNowBtn'); if (syncCb) syncCb.addEventListener('change', e => { const on = !!e.target.checked; Bridge.setPref(SYNC_PREFS.enabled, on ? '1' : ''); if (syncCfgBox) syncCfgBox.style.display = on ? '' : 'none'; toast(on ? 'Sync enabled' : 'Sync disabled'); }); if (syncUrlEl) syncUrlEl.addEventListener('change', e => Bridge.setPref(SYNC_PREFS.url, e.target.value.trim())); if (syncUserEl) syncUserEl.addEventListener('change', e => Bridge.setPref(SYNC_PREFS.user, e.target.value.trim())); if (syncPwdEl) syncPwdEl.addEventListener('change', e => Bridge.setPref(SYNC_PREFS.pwd, e.target.value)); if (syncPreEl) syncPreEl.addEventListener('change', e => Bridge.setPref(SYNC_PREFS.preBackup, e.target.checked ? '1' : '')); if (syncSetPwd) syncSetPwd.addEventListener('click', syncSetEncPwdFlow); if (syncTest) syncTest.addEventListener('click', syncTestConnection); if (syncNow) syncNow.addEventListener('click', runSyncNow); $('#settingAutoBackupEnabled').addEventListener('change', onToggleAutoBackup); $('#autoBackupPickDirBtn').addEventListener('click', pickAutoBackupFolder); $('#settingAutoBackupInterval').addEventListener('change', e => { const n = Math.max(1, parseInt(e.target.value, 10) || 7); e.target.value = n; Bridge.setPref(ABK.interval, String(n)); }); $('#settingAutoBackupKeep').addEventListener('change', e => { const n = Math.max(1, parseInt(e.target.value, 10) || 10); e.target.value = n; Bridge.setPref(ABK.keep, String(n)); }); $('#autoBackupNowBtn').addEventListener('click', () => runAutoBackupNow()); $('#settingFavicons').addEventListener('change', e => { state.faviconsEnabled = e.target.checked; localStorage.setItem('faviconsEnabled', state.faviconsEnabled ? '1' : '0'); saveServerSettings(); if (state.faviconsEnabled) { // Auto-backfill on first opt-in so the user sees the effect // immediately instead of having to click the refresh button. backfillFavicons(false); } else { toast('Website icons disabled (cached icons kept)'); } }); // stopPropagation on the favicon buttons — the document-level // "click outside Settings closes it" handler was firing because the // click target lost its #settingsPanel ancestor mid-bubble (the // async backfill chain triggers a render that reparents nodes). $('#settingFaviconsRefresh').addEventListener('click', ev => { ev.stopPropagation(); backfillFavicons(false); }); $('#settingFaviconsRefreshAll').addEventListener('click', ev => { ev.stopPropagation(); backfillFavicons(true); }); $('#settingFaviconsClear').addEventListener('click', async ev => { ev.stopPropagation(); const ok = await confirmDialog({ title: 'Clear cached icons?', message: 'All website icons cached in your vault will be removed. They will be re-fetched on demand if the toggle stays on.', okText: 'Clear', danger: true, }); if (ok) clearAllFavicons(); }); $('#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 any of the other configurable slots. const others = [ kind !== 'full' ? state.autofillHotkeyFull : null, kind !== 'password' ? state.autofillHotkeyPwd : null, kind !== 'quickSearch' ? state.quickSearchHotkey : null, ].filter(Boolean); const capJson = JSON.stringify(captured); if (others.some(o => JSON.stringify(o) === capJson)) { toast('That combo is already used by another hotkey', 'warning'); finish(true); return; } // Commit. if (kind === 'full') state.autofillHotkeyFull = captured; else if (kind === 'password') state.autofillHotkeyPwd = captured; else /* quickSearch */ state.quickSearchHotkey = 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'); bindHotkeyCapture('#settingQuickSearchCombo', 'quickSearch'); $('#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' }; state.quickSearchHotkey = { ctrl: true, shift: true, alt: false, win: false, key: 'Q' }; $('#settingAutofillFullCombo').textContent = autofillComboLabel(state.autofillHotkeyFull); $('#settingAutofillPwdCombo').textContent = autofillComboLabel(state.autofillHotkeyPwd); $('#settingQuickSearchCombo').textContent = autofillComboLabel(state.quickSearchHotkey); 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); $('#exportCsvBtn').addEventListener('click', doExportCSV); $('#importBtn').addEventListener('click', doImport); $('#changeMasterBtn').addEventListener('click', openChangeMasterModal); // Profile picture: "Change picture" opens the hidden file input; // selecting a file downscales + uploads it; "Remove" clears it. const avaUpload = $('#settingAvatarUpload'); const avaInput = $('#settingAvatarInput'); const avaRemove = $('#settingAvatarRemove'); if (avaUpload && avaInput) { avaUpload.addEventListener('click', () => avaInput.click()); avaInput.addEventListener('change', async e => { const f = e.target.files && e.target.files[0]; e.target.value = ''; // allow re-picking the same file later if (f) await uploadUserAvatar(f); }); } if (avaRemove) avaRemove.addEventListener('click', removeUserAvatar); $('#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 === '?' && !/^(INPUT|TEXTAREA|SELECT)$/.test((e.target||{}).tagName) && !e.ctrlKey && !e.metaKey && !e.altKey) { // '?' anywhere outside an input shows the hotkey cheatsheet. // Skipped while the auth screen is up — discovery is for the // unlocked workflow. if ($('#appShell').classList.contains('is-hidden')) return; e.preventDefault(); openCheatsheet(); } 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; } if (!$('#cheatsheetModal').classList.contains('is-hidden')) { closeCheatsheet(); return; } if (!$('#historyModal').classList.contains('is-hidden')) { closeHistoryModal(); return; } closePalette(); requestCloseSlideOver(); 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)); $$('#cheatsheetModal [data-close]').forEach(b => b.addEventListener('click', closeCheatsheet)); $$('#historyModal [data-close]').forEach(b => b.addEventListener('click', closeHistoryModal)); const cheatBtn = document.getElementById('cheatsheetBtn'); if (cheatBtn) cheatBtn.addEventListener('click', openCheatsheet); // Filters dropdown const filtersBtn = document.getElementById('filtersBtn'); const filtersMenu = document.getElementById('filtersMenu'); if (filtersBtn && filtersMenu) { filtersBtn.addEventListener('click', ev => { ev.stopPropagation(); renderFiltersMenu(); filtersMenu.classList.toggle('is-hidden'); }); // Click outside the menu closes it (not on a checkbox / button — those // bubbleStop above so they don't reach this listener). document.addEventListener('click', e => { if (!e.target.closest('.filters-wrap')) filtersMenu.classList.add('is-hidden'); }); } renderFiltersBadge(); renderFilterChips(); // Quick-search modal (tray menu) — keyboard nav + close const qsInput = document.getElementById('quickSearchInput'); if (qsInput) { qsInput.addEventListener('input', () => { quickSearchSelected = 0; quickSearchRender(); }); qsInput.addEventListener('keydown', e => { const rows = document.querySelectorAll('#quickSearchResults .quick-search-row'); if (e.key === 'Escape') { e.preventDefault(); closeQuickSearchModal(); } else if (e.key === 'ArrowDown') { e.preventDefault(); if (rows.length) { quickSearchSelected++; quickSearchRender(); } } else if (e.key === 'ArrowUp') { e.preventDefault(); if (rows.length) { quickSearchSelected--; quickSearchRender(); } } else if (e.key === 'Enter') { e.preventDefault(); const sel = rows[quickSearchSelected]; if (!sel) return; const id = parseInt(sel.dataset.id, 10); const entry = state.entries.find(x => x.id === id); // Shift+Enter → username, Ctrl+Enter → password only, // plain Enter → full (user + Tab + password). const mode = e.shiftKey ? 'user' : (e.ctrlKey || e.metaKey) ? 'pwd' : 'full'; if (entry) quickSearchPickEntry(entry, mode); } }); } $$('#quickSearchModal [data-close]').forEach(b => b.addEventListener('click', closeQuickSearchModal)); // 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);