/* ============================================================ 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 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. // Exposed as window.Bridge.onSystemLock so the Delphi side can call it, // but the actual lock is triggered directly via lockVault() in Delphi. onSystemLock() { 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'); } }, }; })(); // 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') || '', cryptoKey: null, entries: [], trashed: [], folders: ['All'], view: 'all', // 'all' | 'favorites' | 'folder:' | 'tag:' | 'trash' search: '', selectedId: null, theme: localStorage.getItem('theme') || 'dark', locked: false, // true after user clicks Lock (token still valid server-side) autoLock: parseInt(localStorage.getItem('autoLockMin') || '5'), askBeforeDelete: localStorage.getItem('askBeforeDelete') !== '0', // default true maskUsernames: localStorage.getItem('maskUsernames') === '1', // default false compactActions: localStorage.getItem('compactActions') === '1', // default false viewMode: localStorage.getItem('viewMode') || 'cards', // 'cards' | 'list' checked: new Set(), // entry IDs checked for batch operations hibpEnabled: localStorage.getItem('hibpEnabled') === '1', // default OFF // entry.id → count from HIBP (0 = clean, >0 = pwned, undefined = unchecked) hibpResults: new Map(), }; // ============================================================ // 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'] ); } 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); } // ============================================================ // 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 = []; } // Send the atomic migrate request. Server verifies the master pw // against the OLD hash, then updates the user row (hash, iter // count, hash_algo) AND every entry's ciphertext in a single // transaction. await api('/migrate-kdf', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ masterPassword: masterPwd, entries: newCiphertexts, }), }); if (kdfChange) { // Swap to the new AES key + update cached ciphertexts. state.cryptoKey = newKey; await persistCryptoKey(); for (let i = 0; i < state.entries.length; i++) { const nc = newCiphertexts[i]; state.entries[i].encrypted_password = nc.encrypted_password; state.entries[i].iv = nc.iv; } toast('Vault security upgraded (' + fromIters.toLocaleString() + ' → ' + toIters.toLocaleString() + ' KDF iterations)'); } else { // Format-only upgrade is silent — the user didn't perceive a // weakness change, and nothing visible in the UI changed. // (A subtle "Auth format upgraded" toast felt noisy.) } } catch (err) { // Silent retry on next login — the migration is idempotent and // safe to abandon (server rolled back). console.warn('KDF migration aborted:', err); } finally { kdfMigrationInProgress = false; } } // ============================================================ // ACCOUNT LOCKOUT UI // ============================================================ let lockoutTimer = null; // Called when the backend responds with 429 + retry_after on /login or // /reauth. Disables the auth form and displays a live countdown in // #authHint. When the countdown reaches 0, the form is re-enabled. function showLockoutCountdown(seconds) { if (lockoutTimer) { clearInterval(lockoutTimer); lockoutTimer = null; } const hint = $('#authHint'); const btn = $('#loginBtn'); const fmt = (s) => { if (s >= 3600) return Math.ceil(s / 3600) + ' h'; if (s >= 60) return Math.ceil(s / 60) + ' min'; return s + ' s'; }; const tick = () => { if (seconds <= 0) { clearInterval(lockoutTimer); lockoutTimer = null; if (hint) hint.textContent = 'You can try again now.'; if (btn) btn.disabled = false; return; } if (hint) hint.textContent = 'Account locked — try again in ' + fmt(seconds); seconds--; }; if (btn) btn.disabled = true; tick(); // show first frame immediately lockoutTimer = setInterval(tick, 1000); } // ============================================================ // TOAST // ============================================================ function toast(msg, type) { type = type || 'success'; const container = $('#toastContainer'); const t = document.createElement('div'); t.className = 'toast is-' + type; t.textContent = msg; container.appendChild(t); setTimeout(() => t.remove(), 2800); } // ============================================================ // DOM HELPERS // ============================================================ function $(sel, root) { return (root || document).querySelector(sel); } function $$(sel, root) { return Array.from((root || document).querySelectorAll(sel)); } function el(tag, props, ...kids) { const e = document.createElement(tag); if (props) for (const k in props) { if (k === 'class') e.className = props[k]; else if (k === 'on') for (const ev in props.on) e.addEventListener(ev, props.on[ev]); else if (k === 'html') e.innerHTML = props[k]; else if (k in e) e[k] = props[k]; else e.setAttribute(k, props[k]); } for (const k of kids) { if (k == null) continue; e.appendChild(typeof k === 'string' ? document.createTextNode(k) : k); } return e; } function icon(id) { const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); const u = document.createElementNS('http://www.w3.org/2000/svg', 'use'); u.setAttribute('href', '#' + id); s.appendChild(u); return s; } // ============================================================ // AUTH // ============================================================ async function doLogin(e) { e && e.preventDefault(); const u = $('#loginUsername').value.trim(); const p = $('#loginPassword').value; if (!u || !p) return; // If we are in locked mode (token still valid), try fast unlock first. if (state.locked && state.token && state.salt && u === state.username) { $('#loginBtn').disabled = true; const ok = await doUnlock(p); $('#loginBtn').disabled = false; if (ok) return; // unlock failed — fall through to a full login } $('#loginBtn').disabled = true; try { const r = await api('/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: u, masterPassword: p }), }); state.token = r.token; state.csrf = r.csrfToken; state.salt = r.salt; state.username = u; sessionStorage.setItem('authToken', state.token); sessionStorage.setItem('csrfToken', state.csrf); sessionStorage.setItem('salt', state.salt); sessionStorage.setItem('username', state.username); // Derive with the server-specified iteration count — legacy users // receive 100k, modern users 600k. The cryptoKey is what currently // decrypts the entries on this server. state.cryptoKey = await deriveKey(p, state.salt, r.kdfIterations); 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 } 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 { const r = await api('/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: u, masterPassword: p }), }); state.token = r.token; state.csrf = r.csrfToken; state.salt = r.salt; state.username = u; sessionStorage.setItem('authToken', state.token); sessionStorage.setItem('csrfToken', state.csrf); sessionStorage.setItem('salt', state.salt); sessionStorage.setItem('username', state.username); // Fresh account → server returns kdfIterations = current target. // No migration ever needed for a brand-new vault. state.cryptoKey = await deriveKey(p, state.salt, r.kdfIterations); 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) {} sessionStorage.clear(); state.token = ''; state.csrf = ''; state.salt = ''; state.username = ''; state.cryptoKey = null; state.entries = []; state.trashed = []; state.folders = ['All']; state.locked = false; showAuth(); $('#loginUsername').value = ''; $('#loginPassword').value = ''; $('#loginUsername').readOnly = false; $('#authHint').textContent = ''; } // Lock: do NOT hit /logout — keep server session alive, just drop the in-memory // crypto key. On unlock, /reauth validates the master password and we re-derive. function lockVault() { sessionStorage.removeItem('cryptoKey'); state.cryptoKey = null; state.entries = []; state.trashed = []; state.locked = true; 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 { const r = await api('/reauth', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ masterPassword: p }), }); // r now carries kdfIterations + optional kdfMigration, same as /login. state.cryptoKey = await deriveKey(p, state.salt, r.kdfIterations); await persistCryptoKey(); state.locked = false; $('#loginUsername').readOnly = false; $('#authHint').textContent = ''; toast('Unlocked'); await enterApp(); if (r.kdfMigration && r.kdfMigration.target) { runKdfMigration(p, r.kdfIterations, r.kdfMigration.target); } return true; } catch (err) { // Account lockout (too many wrong master pw attempts): show // countdown in the auth hint, keep the form disabled. if (err.status === 429 && err.body && err.body.retry_after) { showLockoutCountdown(err.body.retry_after); return false; } if (err.message === 'Invalid password') { toast('Wrong master password', 'error'); } else { // session expired — fall back to full login sessionStorage.clear(); state.token = ''; state.csrf = ''; state.salt = ''; state.locked = false; $('#loginUsername').readOnly = false; $('#authHint').textContent = 'Session expired, please sign in again'; toast('Session expired', 'warning'); } return false; } } // ============================================================ // DATA LOADING // ============================================================ async function loadFolders() { try { const r = await api('/folders', { headers: authHeaders() }); // 'All' is always implicit first state.folders = ['All'].concat(r.filter(n => n !== 'All')); } catch (e) { /* ignore */ } } async function loadEntries() { try { const r = await api('/entries', { headers: authHeaders() }); state.entries = Array.isArray(r) ? r : []; } catch (e) { if (e.message === 'Invalid session' || e.message === 'Session expired') { return doLogout(); } toast(e.message, 'error'); } } async function loadTrash() { try { const r = await api('/entries?deleted=1', { headers: authHeaders() }); state.trashed = Array.isArray(r) ? r : []; } catch (e) { state.trashed = []; } } // ============================================================ // FILTERS / DERIVED // ============================================================ function filteredEntries() { // Trash view shows its own list (loaded separately) let list; if (state.view === 'trash') { list = state.trashed; } else { list = state.entries; if (state.view === 'favorites') list = list.filter(e => e.favorite); else if (state.view.startsWith('folder:')) { const f = state.view.slice(7); if (f !== 'All') list = list.filter(e => e.folder === 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.username || '').toLowerCase().includes(q) || (e.tags || '').toLowerCase().includes(q) ); } return list; } function parseTags(s) { if (!s) return []; return s.split(',').map(t => t.trim()).filter(Boolean); } function allTags() { const set = new Set(); state.entries.forEach(e => parseTags(e.tags).forEach(t => set.add(t))); return Array.from(set).sort(); } function viewTitle() { if (state.view === 'all') return 'All items'; if (state.view === 'favorites') return 'Favorites'; if (state.view === 'trash') return 'Trash'; if (state.view.startsWith('folder:')) return state.view.slice(7); if (state.view.startsWith('tag:')) return '# ' + state.view.slice(4); return 'Items'; } // ============================================================ // RENDER // ============================================================ function render() { renderSidebar(); renderGrid(); } function renderSidebar() { // counts $('#countAll').textContent = state.entries.length; $('#countFav').textContent = state.entries.filter(e => e.favorite).length; $('#countTrash').textContent = state.trashed.length || ''; // active state for top-level items $$('#appShell .nav-item[data-view]').forEach(n => { n.classList.toggle('is-active', n.dataset.view === state.view); }); // folders const fList = $('#foldersList'); fList.innerHTML = ''; state.folders.forEach(name => { const count = state.entries.filter(e => e.folder === name).length; const key = 'folder:' + name; const item = el('button', { class: 'nav-item' + (state.view === key ? ' is-active' : ''), 'data-folder': name, on: { click: () => setView(key) }, }); item.appendChild(icon('i-folder')); item.appendChild(el('span', null, name)); item.appendChild(el('span', { class: 'nav-count' }, String(count))); // drag and drop target item.addEventListener('dragover', e => { e.preventDefault(); item.classList.add('drag-over'); }); item.addEventListener('dragleave', () => item.classList.remove('drag-over')); item.addEventListener('drop', async e => { e.preventDefault(); item.classList.remove('drag-over'); const id = e.dataTransfer.getData('text/plain'); if (id) await moveEntryToFolder(parseInt(id), name); }); 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, username: e.username, encrypted_password: e.encrypted_password, iv: e.iv, folder: e.folder, tags: tags.join(','), }), }); e.tags = tags.join(','); render(); toast('Tagged "' + tag + '"'); } catch (err) { toast(err.message, 'error'); } } function renderGrid() { $('#contentTitle').textContent = viewTitle(); 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' : ''); grid.innerHTML = ''; if (list.length === 0) { showEmptyState(); return; } $('#emptyState').classList.add('is-hidden'); list.forEach(e => grid.appendChild(renderCard(e))); } function showEmptyState() { const illustration = $('#emptyIllustration use'); const title = $('#emptyTitle'); const msg = $('#emptyMessage'); if (state.search) { illustration.setAttribute('href', '#i-empty-search'); title.textContent = 'No matches'; msg.innerHTML = 'Try a different search term, or click + New to add a new entry.'; } else if (state.view === 'trash') { illustration.setAttribute('href', '#i-empty-trash'); title.textContent = 'Trash is empty'; msg.textContent = 'Deleted entries land here. They can be restored at any time.'; } else if (state.view === 'favorites') { illustration.setAttribute('href', '#i-empty-vault'); title.textContent = 'No favorites yet'; msg.innerHTML = 'Click the on any entry to add it to favorites.'; } else if (state.view.startsWith('folder:')) { illustration.setAttribute('href', '#i-empty-vault'); title.textContent = 'Folder is empty'; msg.innerHTML = 'Move entries here by drag & drop, or by setting their folder.'; } else if (state.view.startsWith('tag:')) { illustration.setAttribute('href', '#i-empty-vault'); title.textContent = 'No entries with this tag'; msg.textContent = 'Drop a card on the tag to add this tag to that entry.'; } else { illustration.setAttribute('href', '#i-empty-vault'); title.textContent = 'Your vault is empty'; msg.innerHTML = 'Click + New to add your first password. They\'re encrypted before they leave your machine.'; } $('#emptyState').classList.remove('is-hidden'); } // Skeleton loaders shown during the initial fetch right after login/unlock function showSkeletons(n) { const grid = $('#entryGrid'); grid.innerHTML = ''; $('#emptyState').classList.add('is-hidden'); for (let i = 0; i < n; i++) { const card = el('div', { class: 'skeleton-card' }); const row = el('div', { class: 'skeleton-row' }); row.appendChild(el('div', { class: 'skeleton-circle' })); const col = el('div', { style: 'flex:1' }); col.appendChild(el('div', { class: 'skeleton-line w-60' })); col.appendChild(el('div', { class: 'skeleton-line w-40', style: 'margin-bottom:0' })); row.appendChild(col); card.appendChild(row); card.appendChild(el('div', { class: 'skeleton-line w-80' })); card.appendChild(el('div', { class: 'skeleton-line w-40', style: 'margin-bottom:0' })); grid.appendChild(card); } } function initials(s) { return (s || '?').replace(/[^a-zA-Z0-9]/g, '').slice(0, 2).toUpperCase() || '?'; } // Compact-action kebab menu shown on each card when state.compactActions is on. function buildKebabMenu(entry) { const wrap = el('div', { class: 'entry-kebab-wrap' }); const btn = el('button', { class: 'entry-kebab', title: 'More actions', on: { click: ev => { ev.stopPropagation(); // Close any other open menu, then toggle this one $$('.entry-kebab-menu.is-open').forEach(m => { if (m !== menu) m.classList.remove('is-open'); }); menu.classList.toggle('is-open'); } }, }); btn.appendChild(icon('i-more')); wrap.appendChild(btn); const menu = el('div', { class: 'entry-kebab-menu' }); const items = [ { lbl: entry.favorite ? 'Unfavorite' : 'Favorite', ic: 'i-star', fn: () => toggleFavorite(entry.id) }, { lbl: 'Copy password', ic: 'i-copy', fn: () => copyPassword(entry) }, { lbl: 'Copy username', ic: 'i-user', fn: () => copyUsername(entry) }, { lbl: 'Edit', ic: 'i-edit', fn: () => openSlideOver(entry.id) }, { lbl: 'Move to trash', ic: 'i-trash', fn: () => deleteEntry(entry.id), danger: true }, ]; items.forEach(it => { const mi = el('button', { class: 'kebab-item' + (it.danger ? ' is-danger' : ''), on: { click: ev => { ev.stopPropagation(); menu.classList.remove('is-open'); it.fn(); } }, }); mi.appendChild(icon(it.ic)); mi.appendChild(el('span', null, it.lbl)); menu.appendChild(mi); }); wrap.appendChild(menu); return wrap; } function renderCard(e) { const inTrash = state.view === 'trash'; const checked = state.checked.has(e.id); const card = el('article', { class: 'entry-card' + (state.selectedId === e.id ? ' is-selected' : '') + (checked ? ' is-checked' : ''), 'data-id': e.id, draggable: inTrash ? 'false' : 'true', on: { click: ev => handleCardClick(ev, e, inTrash) }, }); if (!inTrash) { card.addEventListener('dragstart', ev => { ev.dataTransfer.setData('text/plain', String(e.id)); ev.dataTransfer.effectAllowed = 'move'; }); } // head: avatar acts as a multi-select checkbox (click on avatar -> toggle) const head = el('div', { class: 'entry-head' }); const avatar = el('div', { class: 'entry-avatar is-checkable', title: 'Click to select', on: { click: ev => { ev.stopPropagation(); toggleChecked(e.id); } }, }, checked ? '✓' : initials(e.site)); head.appendChild(avatar); const title = el('div', { class: 'entry-title' }); title.appendChild(el('b', null, 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 + del head.appendChild(buildKebabMenu(e)); } else { const fav = el('button', { class: 'entry-fav' + (e.favorite ? ' is-on' : ''), title: 'Favorite', on: { click: ev => { ev.stopPropagation(); toggleFavorite(e.id); } }, }); fav.appendChild(icon('i-star')); head.appendChild(fav); // Quick-delete: small X visible on card hover. Always available // without opening the slide-over. const del = el('button', { class: 'entry-del', title: 'Move to trash', on: { click: ev => { ev.stopPropagation(); deleteEntry(e.id); } }, }); del.appendChild(icon('i-x')); head.appendChild(del); } card.appendChild(head); // password row (placeholder dots, click reveals via slide-over) const pwRow = el('div', { class: 'entry-pw-row' }); pwRow.appendChild(el('span', { class: 'entry-pw', id: 'pw-' + e.id }, '••••••••')); const copyBtn = el('button', { class: 'icon-btn icon-btn-sm', title: 'Copy password', on: { click: ev => { ev.stopPropagation(); copyPassword(e); } }, }); copyBtn.appendChild(icon('i-copy')); pwRow.appendChild(copyBtn); card.appendChild(pwRow); // meta chips: folder + first 2 tags const meta = el('div', { class: 'entry-meta' }); if (e.folder) { const chip = el('span', { class: 'entry-chip is-folder' }); chip.appendChild(icon('i-folder')); chip.appendChild(el('span', null, e.folder)); meta.appendChild(chip); } parseTags(e.tags).slice(0, 3).forEach(t => { const chip = el('span', { class: 'entry-chip' }); chip.appendChild(icon('i-tag')); chip.appendChild(el('span', null, t)); meta.appendChild(chip); }); // HIBP pwned badge — only shown when the user enabled HIBP and the // background scan completed with count > 0 for this entry. const pwnedCount = state.hibpResults.get(e.id); if (state.hibpEnabled && pwnedCount && pwnedCount > 0) { const chip = el('span', { class: 'entry-chip is-pwned', title: 'This password appeared in ' + pwnedCount.toLocaleString() + ' known data breaches. Consider changing it.', }); chip.appendChild(icon('i-alert')); chip.appendChild(el('span', null, 'Pwned')); meta.appendChild(chip); } // 2FA indicator — entry has a TOTP secret configured. Server returns // null for both fields when none; truthy = configured (the actual // secret stays encrypted until the user opens the slide-over). if (e.totp_secret && e.totp_iv) { const chip = el('span', { class: 'entry-chip is-2fa', title: 'Two-factor authentication (TOTP) configured', }); chip.appendChild(icon('i-lock')); chip.appendChild(el('span', null, '2FA')); meta.appendChild(chip); } card.appendChild(meta); return card; } // ============================================================ // MARQUEE (rubber-band) SELECTION // ============================================================ // Click-drag on empty space in the entry grid draws a rectangle. // Cards whose bounding box intersects the rectangle become selected. // Shift/Ctrl held = add to existing selection (otherwise replace). let marqueeEl = null; let marqueeStart = null; let marqueeAdditive = false; let marqueeInitialSet = null; function startMarquee(ev) { // Only fire on left mouse button, and only when starting on grid background if (ev.button !== 0) return; if (ev.target.closest('.entry-card')) return; // ignore drags from cards if (ev.target.closest('.batch-bar')) return; if (!ev.target.closest('#entryGrid')) return; marqueeAdditive = ev.shiftKey || ev.ctrlKey || ev.metaKey; marqueeInitialSet = new Set(state.checked); if (!marqueeAdditive) state.checked.clear(); marqueeStart = { x: ev.clientX, y: ev.clientY }; marqueeEl = el('div', { class: 'marquee' }); Object.assign(marqueeEl.style, { left: marqueeStart.x + 'px', top: marqueeStart.y + 'px', width: '0px', height: '0px', }); document.body.appendChild(marqueeEl); ev.preventDefault(); document.addEventListener('mousemove', updateMarquee); document.addEventListener('mouseup', endMarquee); } function updateMarquee(ev) { if (!marqueeEl) return; const x1 = Math.min(marqueeStart.x, ev.clientX); const y1 = Math.min(marqueeStart.y, ev.clientY); const x2 = Math.max(marqueeStart.x, ev.clientX); const y2 = Math.max(marqueeStart.y, ev.clientY); Object.assign(marqueeEl.style, { left: x1 + 'px', top: y1 + 'px', width: (x2 - x1) + 'px', height: (y2 - y1) + 'px', }); // Re-check intersections const marqueeRect = { left: x1, top: y1, right: x2, bottom: y2 }; state.checked = new Set(marqueeAdditive ? marqueeInitialSet : []); $$('#entryGrid .entry-card').forEach(card => { const r = card.getBoundingClientRect(); const intersects = !(r.right < marqueeRect.left || r.left > marqueeRect.right || r.bottom < marqueeRect.top || r.top > marqueeRect.bottom); if (intersects) { const id = parseInt(card.dataset.id); state.checked.add(id); card.classList.add('is-checked'); } else if (!marqueeInitialSet.has(parseInt(card.dataset.id))) { card.classList.remove('is-checked'); } }); } function endMarquee() { document.removeEventListener('mousemove', updateMarquee); document.removeEventListener('mouseup', endMarquee); if (marqueeEl) marqueeEl.remove(); marqueeEl = null; marqueeStart = null; marqueeInitialSet = null; // Re-render so the batch bar appears with the new count + avatar states renderGrid(); } // ============================================================ // MULTI-SELECTION + BATCH ACTIONS // ============================================================ let selectionAnchor = null; // last single-clicked card, used for shift+click range function toggleChecked(id) { if (state.checked.has(id)) state.checked.delete(id); else state.checked.add(id); renderGrid(); } function handleCardClick(ev, entry, inTrash) { // Ctrl/Cmd+Click: toggle this card in selection if (ev.ctrlKey || ev.metaKey) { toggleChecked(entry.id); selectionAnchor = entry.id; return; } // Shift+Click: select range from anchor to this card if (ev.shiftKey && selectionAnchor !== null) { const list = filteredEntries(); const a = list.findIndex(x => x.id === selectionAnchor); const b = list.findIndex(x => x.id === entry.id); if (a >= 0 && b >= 0) { const lo = Math.min(a, b), hi = Math.max(a, b); for (let i = lo; i <= hi; i++) state.checked.add(list[i].id); renderGrid(); return; } } // If any cards are already checked, a plain click toggles (sticky multi-select) if (state.checked.size > 0) { toggleChecked(entry.id); selectionAnchor = entry.id; return; } // Default: open slide-over (or trash actions) selectionAnchor = entry.id; if (inTrash) openTrashActions(entry.id); else openSlideOver(entry.id); } function clearChecked() { state.checked.clear(); renderGrid(); } async function batchMoveToFolder(folder) { const ids = Array.from(state.checked); if (!ids.length) return; for (const id of ids) { const e = state.entries.find(x => x.id === id); if (!e || e.folder === folder) continue; try { await api('/entries/' + id, { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ site: e.site, username: e.username, encrypted_password: e.encrypted_password, iv: e.iv, folder, tags: e.tags || '', }), }); e.folder = folder; } catch (err) { /* ignore individual failures */ } } toast(ids.length + ' moved to ' + folder); clearChecked(); } async function batchAddTag(tag) { tag = (tag || '').trim(); if (!tag) return; const ids = Array.from(state.checked); if (!ids.length) return; for (const id of ids) { const e = state.entries.find(x => x.id === id); if (!e) continue; const tags = parseTags(e.tags); if (tags.includes(tag)) continue; tags.push(tag); try { await api('/entries/' + id, { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ site: e.site, username: e.username, encrypted_password: e.encrypted_password, iv: e.iv, folder: e.folder, tags: tags.join(','), }), }); e.tags = tags.join(','); } catch (err) {} } toast('Tagged ' + ids.length + ' as "' + tag + '"'); clearChecked(); } async function batchDelete() { const ids = Array.from(state.checked); if (!ids.length) return; const ok = await confirmDialog({ title: 'Move to trash', message: '' + ids.length + ' entries will be moved to trash.', okText: 'Move to trash', danger: true, }); if (!ok) return; for (const id of ids) { try { await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() }); } catch (err) {} } toast(ids.length + ' moved to trash'); await loadEntries(); await loadTrash(); clearChecked(); } async function batchRestore() { const ids = Array.from(state.checked); if (!ids.length) return; for (const id of ids) { try { await api('/entries/' + id + '/restore', { method: 'POST', headers: authHeaders() }); } catch (err) {} } toast(ids.length + ' restored'); await loadEntries(); await loadTrash(); clearChecked(); } async function batchPermDelete() { const ids = Array.from(state.checked); if (!ids.length) return; const ok = await confirmDialog({ title: 'Delete forever', message: '' + ids.length + ' entries will be permanently deleted. This cannot be undone.', okText: 'Delete forever', danger: true, }); if (!ok) return; for (const id of ids) { try { await api('/entries/' + id + '?permanent=1', { method: 'DELETE', headers: authHeaders() }); } catch (err) {} } toast(ids.length + ' deleted permanently'); await loadTrash(); clearChecked(); } function renderBatchBar() { const existing = $('#batchBar'); if (existing) existing.remove(); if (state.checked.size === 0) return; const inTrash = state.view === 'trash'; const bar = el('div', { class: 'batch-bar', id: 'batchBar' }); bar.appendChild(el('span', { class: 'batch-bar-count' }, state.checked.size + ' selected')); if (inTrash) { // Trash view: Restore | Delete forever bar.appendChild(el('button', { class: 'btn btn-ghost btn-sm', on: { click: batchRestore }, }, withIcon('i-rotate-ccw', 'Restore'))); bar.appendChild(el('button', { class: 'btn btn-ghost btn-sm', style: 'color:var(--danger)', on: { click: batchPermDelete }, }, withIcon('i-trash', 'Delete forever'))); } else { // Normal view: Move to folder | Add tag | Delete (soft) const moveSel = el('select'); moveSel.appendChild(el('option', { value: '' }, 'Move to folder…')); state.folders.forEach(f => moveSel.appendChild(el('option', { value: f }, f))); moveSel.addEventListener('change', () => { if (moveSel.value) batchMoveToFolder(moveSel.value); }); bar.appendChild(moveSel); bar.appendChild(el('button', { class: 'btn btn-ghost btn-sm', on: { click: async () => { const t = await promptDialog({ title: 'Add tag', message: 'Add a tag to ' + state.checked.size + ' selected entries', placeholder: 'tag name', okText: 'Add', }); if (t) batchAddTag(t); } }, }, withIcon('i-tag', 'Add tag'))); bar.appendChild(el('button', { class: 'btn btn-ghost btn-sm', style: 'color:var(--danger)', on: { click: batchDelete }, }, withIcon('i-trash', 'Delete'))); } bar.appendChild(el('div', { class: 'grow' })); bar.appendChild(el('button', { class: 'btn btn-ghost btn-sm', on: { click: clearChecked }, }, withIcon('i-x', 'Clear'))); const content = $('.content'); content.insertBefore(bar, $('#entryGrid')); } // ============================================================ // SLIDE-OVER // ============================================================ // Edit-in-place state for the slide-over let soState = null; async function openSlideOver(id) { const e = state.entries.find(x => x.id === id); if (!e) return; state.selectedId = id; $('#slideoverTitle').textContent = e.site; const body = $('#slideoverBody'); body.innerHTML = ''; const plain = await decryptPwd(e.encrypted_password, e.iv); // Decrypt TOTP secret if present. Empty string when no TOTP configured // OR when decryption fails (orphan ciphertext, key mismatch, etc.) — the // UI treats both cases as "no 2FA", so the user can re-paste a secret to // recover. let plainTotp = ''; if (e.totp_secret && e.totp_iv) { plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv); if (plainTotp === '[ERROR]') plainTotp = ''; } // Track original values so we can detect "dirty" soState = { id: e.id, original: { site: e.site, username: e.username || '', password: plain, folder: e.folder || 'All', tags: parseTags(e.tags).join(','), totp: plainTotp, }, tags: parseTags(e.tags), originalEncrypted: e.encrypted_password, originalIV: e.iv, originalTotpEncrypted: e.totp_secret, originalTotpIV: e.totp_iv, }; body.appendChild(soEditableField('Site', 'soSite', e.site)); body.appendChild(soEditableField('Username', 'soUsername', e.username || '')); body.appendChild(soPasswordField(plain)); body.appendChild(soTotpField(plainTotp)); body.appendChild(soFolderField(e.folder || 'All')); body.appendChild(soTagsField()); // Action row — Save button is hidden until dirty. No Delete here: // the quick-X on each card handles deletion (avoids duplication). const actions = el('div', { class: 'slideover-actions' }); const saveBtn = el('button', { class: 'btn btn-primary', id: 'soSaveBtn', style: 'display:none', on: { click: soSave }, }, withIcon('i-check', 'Save')); actions.appendChild(saveBtn); body.appendChild(actions); // Wire change detection ['#soSite', '#soUsername', '#soPassword', '#soFolder'].forEach(sel => { const el = $(sel); if (el) el.addEventListener('input', soDirtyCheck); if (el) el.addEventListener('change', soDirtyCheck); }); $('#slideover').classList.add('is-open'); renderGrid(); } function soEditableField(label, id, value) { const wrap = el('div', { class: 'slideover-field' }); wrap.appendChild(el('div', { class: 'slideover-field-label' }, label)); const input = el('input', { type: 'text', id, value, class: 'so-input' }); wrap.appendChild(input); return wrap; } function soPasswordField(plain) { const wrap = el('div', { class: 'slideover-field' }); wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Password')); const row = el('div', { class: 'so-pw-row' }); const input = el('input', { type: 'password', id: 'soPassword', value: plain, class: 'so-input', style: 'flex:1;font-family:JetBrains Mono,monospace', }); const toggle = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Show/hide' }); toggle.appendChild(icon('i-eye')); toggle.addEventListener('click', () => { input.type = input.type === 'password' ? 'text' : 'password'; }); const copy = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Copy' }); copy.appendChild(icon('i-copy')); copy.addEventListener('click', () => { if (Bridge.copySecure(input.value, 30000)) { toast('Copied · clears in 30s'); } else { navigator.clipboard.writeText(input.value).then(() => { toast('Copied · clears in 30s'); setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000); }); } }); const gen = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Generate' }); gen.appendChild(icon('i-dice')); gen.addEventListener('click', () => { openGen('slideover'); }); row.appendChild(input); row.appendChild(toggle); row.appendChild(copy); row.appendChild(gen); wrap.appendChild(row); return wrap; } // ---- TOTP field in slide-over (input + live code + countdown) ---- let totpTickTimer = null; function startTotpTick() { if (totpTickTimer) return; // Refresh once per second so the countdown bar moves smoothly and the // code auto-rolls when the 30s window expires. totpTickTimer = setInterval(updateTotpDisplay, 1000); updateTotpDisplay(); } function stopTotpTick() { if (totpTickTimer) { clearInterval(totpTickTimer); totpTickTimer = null; } } async function updateTotpDisplay() { const input = $('#soTotpSecret'); const codeEl = $('#soTotpCode'); const barEl = $('#soTotpProgress'); if (!input || !codeEl) { stopTotpTick(); return; } const secret = (input.value || '').trim(); if (!secret) { codeEl.textContent = ''; codeEl.classList.remove('is-invalid'); if (barEl) barEl.style.width = '0%'; return; } try { const t = await generateTOTP(secret); // Format as "123 456" — the standard spacing for authenticator apps codeEl.textContent = t.code.slice(0, 3) + ' ' + t.code.slice(3); codeEl.classList.remove('is-invalid'); if (barEl) { const pct = (t.secondsLeft / t.period) * 100; barEl.style.width = pct + '%'; // Switch to red when < 5s left so the user notices the imminent roll barEl.style.background = t.secondsLeft < 5 ? '#dc2626' : 'var(--accent)'; } } catch (err) { codeEl.textContent = 'invalid secret'; codeEl.classList.add('is-invalid'); if (barEl) barEl.style.width = '0%'; } } function soTotpField(plainSecret) { const wrap = el('div', { class: 'slideover-field' }); wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Two-factor (TOTP)')); const row = el('div', { class: 'so-pw-row' }); const input = el('input', { type: 'password', id: 'soTotpSecret', value: plainSecret || '', class: 'so-input', placeholder: 'Paste base32 secret or otpauth:// URI', style: 'flex:1;font-family:JetBrains Mono,monospace', autocomplete: 'off', spellcheck: 'false', }); // If the user pastes a full otpauth:// URI, auto-extract the secret param // so the displayed value is the clean base32 only. Triggers via 'input' // (covers both paste events and manual typing). input.addEventListener('input', () => { const v = input.value.trim(); const fromUri = parseOtpAuthUri(v); if (fromUri) input.value = fromUri; updateTotpDisplay(); soDirtyCheck(); }); const toggle = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Show/hide secret' }); toggle.appendChild(icon('i-eye')); toggle.addEventListener('click', () => { input.type = input.type === 'password' ? 'text' : 'password'; }); const clear = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Remove TOTP' }); clear.appendChild(icon('i-x')); clear.addEventListener('click', () => { input.value = ''; updateTotpDisplay(); soDirtyCheck(); }); row.appendChild(input); row.appendChild(toggle); row.appendChild(clear); wrap.appendChild(row); // Live code panel — shows the current 6-digit code with a copy button // and a progress bar that drains over the 30s window. const panel = el('div', { class: 'totp-panel' }); const codeEl = el('div', { class: 'totp-code', id: 'soTotpCode' }); panel.appendChild(codeEl); const copyBtn = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Copy code' }); copyBtn.appendChild(icon('i-copy')); copyBtn.addEventListener('click', async () => { const secret = (input.value || '').trim(); if (!secret) return; try { const t = await generateTOTP(secret); if (Bridge.copySecure(t.code, 30000)) { toast('Code copied · clears in 30s'); } else { navigator.clipboard.writeText(t.code).then(() => { toast('Code copied · clears in 30s'); setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000); }); } } catch (e) { toast('Invalid TOTP secret', 'error'); } }); panel.appendChild(copyBtn); wrap.appendChild(panel); const barWrap = el('div', { class: 'totp-bar-wrap' }); const bar = el('div', { class: 'totp-bar', id: 'soTotpProgress' }); barWrap.appendChild(bar); wrap.appendChild(barWrap); startTotpTick(); return wrap; } function soFolderField(current) { const wrap = el('div', { class: 'slideover-field' }); wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Folder')); const sel = el('select', { id: 'soFolder', class: 'so-input' }); state.folders.forEach(f => { const opt = el('option', { value: f }, f); if (f === current) opt.selected = true; sel.appendChild(opt); }); wrap.appendChild(sel); return wrap; } function soTagsField() { const wrap = el('div', { class: 'slideover-field' }); wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Tags')); const cont = el('div', { class: 'chip-input', id: 'soTagsContainer' }); const input = el('input', { type: 'text', id: 'soTagsField', placeholder: 'add a tag…', autocomplete: 'off', }); cont.appendChild(input); wrap.appendChild(cont); // Render existing chips renderSoChips(); input.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); const v = input.value.trim().replace(/,/g, ''); if (v && !soState.tags.includes(v)) { soState.tags.push(v); renderSoChips(); soDirtyCheck(); } input.value = ''; } else if (e.key === 'Backspace' && !input.value && soState.tags.length) { soState.tags.pop(); renderSoChips(); soDirtyCheck(); } }); return wrap; } function renderSoChips() { const cont = $('#soTagsContainer'); if (!cont) return; const input = $('#soTagsField'); $$('.chip', cont).forEach(c => c.remove()); soState.tags.forEach((t, i) => { const chip = el('span', { class: 'chip' }); chip.appendChild(el('span', null, t)); const x = el('button', { type: 'button', on: { click: () => { soState.tags.splice(i, 1); renderSoChips(); soDirtyCheck(); } }, }); x.appendChild(icon('i-x')); chip.appendChild(x); cont.insertBefore(chip, input); }); } function soDirtyCheck() { if (!soState) return; const cur = { site: ($('#soSite') || {}).value || '', username: ($('#soUsername') || {}).value || '', password: ($('#soPassword') || {}).value || '', folder: ($('#soFolder') || {}).value || '', totp: ($('#soTotpSecret') || {}).value || '', tags: soState.tags.join(','), }; const dirty = cur.site !== soState.original.site || cur.username !== soState.original.username || cur.password !== soState.original.password || cur.folder !== soState.original.folder || cur.totp !== soState.original.totp || cur.tags !== soState.original.tags; const btn = $('#soSaveBtn'); if (btn) btn.style.display = dirty ? '' : 'none'; } async function soSave() { if (!soState) return; const site = $('#soSite').value.trim(); const user = $('#soUsername').value.trim(); const pwd = $('#soPassword').value; const fold = $('#soFolder').value; const totp = (($('#soTotpSecret') || {}).value || '').trim(); if (!site || !pwd) return toast('Site and password required', 'error'); // Only re-encrypt if password changed; otherwise reuse stored ciphertext let enc; if (pwd === soState.original.password) { enc = { encrypted: soState.originalEncrypted, iv: soState.originalIV }; } else { enc = await encryptPwd(pwd); } // Same idea for TOTP: re-encrypt only if changed, send empty strings when // cleared so the server stores NULL. let totpEnc = ''; let totpIv = ''; if (totp !== '') { if (totp === soState.original.totp && soState.originalTotpEncrypted) { totpEnc = soState.originalTotpEncrypted; totpIv = soState.originalTotpIV; } else { // Validate the secret can be decoded BEFORE saving — saving a // garbled base32 wouldn't break anything but would surprise the // user when the code panel shows "invalid secret" next time. try { base32Decode(totp); } catch (e) { return toast('Invalid TOTP secret (must be base32)', 'error'); } const tEnc = await encryptTotpSecret(totp); totpEnc = tEnc.encrypted; totpIv = tEnc.iv; } } try { await api('/entries/' + soState.id, { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ site, username: user, encrypted_password: enc.encrypted, iv: enc.iv, totp_secret: totpEnc, totp_iv: totpIv, folder: fold, tags: soState.tags.join(','), }), }); toast('Saved'); await loadEntries(); // Re-open with updated data const updated = state.entries.find(x => x.id === soState.id); if (updated) openSlideOver(updated.id); else closeSlideOver(); render(); } catch (err) { toast(err.message, 'error'); } } function withIcon(name, label) { const frag = document.createDocumentFragment(); frag.appendChild(icon(name)); frag.appendChild(document.createTextNode(label)); return frag; } function field(label, value) { const wrap = el('div', { class: 'slideover-field' }); wrap.appendChild(el('div', { class: 'slideover-field-label' }, label)); wrap.appendChild(el('div', { class: 'slideover-field-value' }, value)); return wrap; } function passwordField(plain) { const wrap = el('div', { class: 'slideover-field' }); wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Password')); let revealed = false; const valueRow = el('div', { class: 'slideover-field-value' }); const span = el('span', { style: 'flex:1;font-family:JetBrains Mono,monospace;user-select:none' }, '••••••••'); const toggle = el('button', { class: 'icon-btn icon-btn-sm', title: 'Show/hide' }); toggle.appendChild(icon('i-eye')); toggle.addEventListener('click', () => { revealed = !revealed; span.textContent = revealed ? plain : '••••••••'; span.style.userSelect = revealed ? 'text' : 'none'; }); const copy = el('button', { class: 'icon-btn icon-btn-sm', title: 'Copy' }); copy.appendChild(icon('i-copy')); copy.addEventListener('click', () => { if (Bridge.copySecure(plain, 0)) { toast('Copied'); } else { navigator.clipboard.writeText(plain).then(() => toast('Copied')); } }); valueRow.appendChild(span); valueRow.appendChild(toggle); valueRow.appendChild(copy); wrap.appendChild(valueRow); return wrap; } function closeSlideOver() { stopTotpTick(); $('#slideover').classList.remove('is-open'); state.selectedId = null; renderGrid(); } // ============================================================ // TAG CHIP INPUT // ============================================================ // Local mutable list of tags currently in the entry modal. Synced to the // hidden #entryTags field on every change so saveEntry can read it. let editingTags = []; let chipSuggestEl = null; let chipSuggestActive = -1; function syncTagsHidden() { $('#entryTags').value = editingTags.join(','); } function renderChips() { const container = $('#entryTagsInput'); // Wipe existing chips but keep the input element $$('.chip', container).forEach(c => c.remove()); const input = $('#entryTagsField'); editingTags.forEach((t, i) => { const chip = el('span', { class: 'chip' }); chip.appendChild(el('span', null, t)); const x = el('button', { type: 'button', on: { click: () => { 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; $('#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 }, f))); } async function saveEntry(e) { e && e.preventDefault(); // Flush any pending text in the chip input as a final tag const pending = $('#entryTagsField').value.trim(); if (pending) { addTag(pending); $('#entryTagsField').value = ''; } const id = $('#entryId').value; const 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, username: user, encrypted_password: enc.encrypted, iv: enc.iv, folder: fold, tags, }); try { if (id) { await api('/entries/' + id, { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), body, }); toast('Updated'); } else { await api('/entries', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body, }); toast('Saved'); } closeEntryModal(); await loadEntries(); render(); } catch (err) { toast(err.message, 'error'); } } async function restoreEntry(id) { try { await api('/entries/' + id + '/restore', { method: 'POST', headers: authHeaders() }); toast('Restored'); await loadEntries(); await loadTrash(); render(); } catch (err) { toast(err.message, 'error'); } } async function permanentDelete(id) { const ok = await confirmDialog({ title: 'Delete forever', message: 'This entry will be permanently deleted. This cannot be undone.', okText: 'Delete forever', danger: true, }); if (!ok) return; try { await api('/entries/' + id + '?permanent=1', { method: 'DELETE', headers: authHeaders() }); toast('Deleted permanently'); await loadTrash(); render(); } catch (err) { toast(err.message, 'error'); } } async function emptyTrash() { if (!state.trashed.length) return; const ok = await confirmDialog({ title: 'Empty trash', message: '' + state.trashed.length + ' entries will be deleted forever. This cannot be undone.', okText: 'Empty trash', danger: true, }); if (!ok) return; try { await api('/entries/trash/empty', { method: 'DELETE', headers: authHeaders() }); toast('Trash emptied'); await loadTrash(); render(); } catch (err) { toast(err.message, 'error'); } } function openTrashActions(id) { // For trash entries, we don't open the slide-over — actions are inline on the card. // But user can click outside the buttons to no-op. Could open a read-only view later. } async function deleteEntry(id) { const e = state.entries.find(x => x.id === id); if (state.askBeforeDelete) { const ok = await confirmDialog({ title: 'Move to trash', message: 'Send ' + (e ? e.site : 'this entry') + ' to trash? You can restore it later.', okText: 'Move to trash', danger: true, }); if (!ok) return; } try { await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() }); toast('Moved to trash'); closeSlideOver(); await loadEntries(); render(); } catch (err) { toast(err.message, 'error'); } } async function toggleFavorite(id) { try { await api('/entries/' + id + '/favorite', { method: 'POST', headers: authHeaders() }); const entry = state.entries.find(e => e.id === id); if (entry) entry.favorite = entry.favorite ? 0 : 1; render(); } catch (e) {} } async function moveEntryToFolder(id, folder) { const e = state.entries.find(x => x.id === id); if (!e || e.folder === folder) return; try { await api('/entries/' + id, { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ site: e.site, username: e.username, encrypted_password: e.encrypted_password, iv: e.iv, folder, tags: e.tags || '', }), }); e.folder = folder; render(); toast('Moved to ' + folder); } catch (err) { toast(err.message, 'error'); } } async function copyPassword(entry) { const p = await decryptPwd(entry.encrypted_password, entry.iv); if (p === '[ERROR]') return toast('Cannot decrypt', 'error'); if (Bridge.copySecure(p, 30000)) { toast('Password copied · clears in 30s'); } else { navigator.clipboard.writeText(p).then(() => toast('Password copied · clears in 30s')); setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000); } } function copyUsername(entry) { const u = entry.username || ''; if (!u) return toast('No username to copy', 'warning'); if (Bridge.copySecure(u, 0)) { toast('Username copied'); } else { navigator.clipboard.writeText(u).then(() => toast('Username copied')); } } // Display helper: when `maskUsernames` setting is on, show only the first 2 // chars followed by '***'. Used in cards/list (but slide-over always reveals). function displayUsername(u) { if (!u) return '—'; if (!state.maskUsernames) return u; if (u.length <= 2) return u + '***'; return u.slice(0, 2) + '***'; } // ============================================================ // FOLDERS CRUD // ============================================================ async function addFolder() { const name = await promptDialog({ title: 'New folder', message: 'Folder name', placeholder: 'e.g. Work', okText: 'Create', }); if (!name || !name.trim()) return; try { await api('/folders', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ name: name.trim() }), }); await loadFolders(); render(); toast('Folder created'); } catch (e) { toast(e.message, 'error'); } } // ============================================================ // PASSWORD GENERATOR // ============================================================ let genCurrent = ''; function genPassword() { const len = parseInt($('#genLen').value); $('#genLenLabel').textContent = len; let chars = ''; if ($('#genUpper').checked) chars += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; if ($('#genLower').checked) chars += 'abcdefghijklmnopqrstuvwxyz'; if ($('#genNum').checked) chars += '0123456789'; if ($('#genSym').checked) chars += '!@#$%^&*()_+-=[]{}|;:,.<>?'; if (!chars) { $('#genPreview').textContent = 'Select at least one'; return; } let p = ''; const max = 256 - (256 % chars.length); const buf = new Uint8Array(1); for (let i = 0; i < len; i++) { do { crypto.getRandomValues(buf); } while (buf[0] >= max); p += chars.charAt(buf[0] % chars.length); } genCurrent = p; $('#genPreview').textContent = p; } // genTarget: 'entry' (insert into entry form) or 'standalone' (just copy/dismiss) let genTarget = 'entry'; function openGen(target) { genTarget = target || 'entry'; // Show "Use" when targeting an editable field (entry modal or slide-over) $('#genUse').style.display = (genTarget === 'standalone') ? 'none' : ''; $('#genModal').classList.remove('is-hidden'); genPassword(); } function closeGen() { $('#genModal').classList.add('is-hidden'); } // ============================================================ // PASSWORD STRENGTH // ============================================================ function computeStrength(p) { let s = 0; if (p.length >= 8) s += 25; if (p.length >= 12) s += 15; if (/[A-Z]/.test(p) && /[a-z]/.test(p)) s += 20; if (/\d/.test(p)) s += 15; if (/[^A-Za-z0-9]/.test(p)) s += 25; return Math.min(100, s); } function updateRegStrength() { const p = $('#regPassword').value; $('#regStrengthBar').style.setProperty('--strength', computeStrength(p) + '%'); } function updateEntryStrength() { const p = $('#entryPassword').value; $('#entryStrengthBar').style.setProperty('--strength', computeStrength(p) + '%'); } // ============================================================ // COMMAND PALETTE // ============================================================ function openPalette() { $('#cmdPalette').classList.remove('is-hidden'); $('#cmdInput').value = ''; $('#cmdInput').focus(); renderPaletteResults(''); } function closePalette() { $('#cmdPalette').classList.add('is-hidden'); } function paletteCommands() { return [ { id: 'new', label: 'New entry', icon: 'i-plus', run: () => { closePalette(); openEntryModal(); } }, { id: 'lock', label: 'Lock vault', icon: 'i-lock', run: () => { closePalette(); lockVault(); } }, { id: 'logout', label: 'Sign out', icon: 'i-log-out', run: () => { closePalette(); doLogout(); } }, { id: 'theme', label: 'Toggle theme', icon: 'i-sun', run: () => { closePalette(); toggleTheme(); } }, { id: 'all', label: 'Show all items', icon: 'i-globe', run: () => { closePalette(); setView('all'); } }, { id: 'fav', label: 'Show favorites', icon: 'i-star', run: () => { closePalette(); setView('favorites'); } }, { id: 'trash', label: 'Show trash', icon: 'i-trash', run: () => { closePalette(); setView('trash'); } }, ]; } function renderPaletteResults(q) { const cmds = paletteCommands(); const entries = state.entries.map(e => ({ id: 'entry-' + e.id, label: e.site, sub: e.username || '', 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, 12).forEach((c, i) => { const it = el('div', { class: 'cmd-item' + (i === 0 ? ' is-active' : ''), on: { click: c.run }, }); it.appendChild(icon(c.icon)); it.appendChild(el('span', null, c.label)); if (c.sub) it.appendChild(el('span', { style: 'color:var(--text-faint);font-size:11px;margin-left:auto' }, c.sub)); out.appendChild(it); }); } // ============================================================ // IN-APP CONFIRM / PROMPT (no native alerts) // ============================================================ let confirmResolver = null; function confirmDialog(opts) { // opts: { title, message, okText, cancelText, danger } opts = opts || {}; $('#confirmTitle').textContent = opts.title || 'Confirm'; $('#confirmMessage').innerHTML = opts.message || 'Are you sure?'; $('#confirmOkBtn').lastChild.nodeValue = ' ' + (opts.okText || 'Confirm'); $('#confirmCancelBtn').textContent = opts.cancelText || 'Cancel'; $('#confirmOkBtn').classList.toggle('is-danger', !!opts.danger); $('#confirmInputField').classList.add('is-hidden'); $('#confirmModal').classList.remove('is-hidden'); setTimeout(() => $('#confirmOkBtn').focus(), 50); return new Promise(res => { confirmResolver = res; }); } function promptDialog(opts) { // opts: { title, message, okText, placeholder, value, password } opts = opts || {}; $('#confirmTitle').textContent = opts.title || 'Enter value'; $('#confirmMessage').innerHTML = opts.message || ''; $('#confirmOkBtn').lastChild.nodeValue = ' ' + (opts.okText || 'OK'); $('#confirmCancelBtn').textContent = 'Cancel'; $('#confirmOkBtn').classList.remove('is-danger'); $('#confirmInputField').classList.remove('is-hidden'); $('#confirmInput').value = opts.value || ''; $('#confirmInput').placeholder = opts.placeholder || ''; // Allow password-style masking (used by encrypted import/export). $('#confirmInput').type = opts.password ? 'password' : 'text'; $('#confirmModal').classList.remove('is-hidden'); setTimeout(() => $('#confirmInput').focus(), 50); return new Promise(res => { confirmResolver = res; }); } function closeConfirm(value) { $('#confirmModal').classList.add('is-hidden'); if (confirmResolver) { const cb = confirmResolver; confirmResolver = null; cb(value); } } // ============================================================ // RE-AUTH MODAL + EXPORT // ============================================================ let reauthResolve = null; function askReauth(message) { return new Promise(resolve => { reauthResolve = resolve; $('#reauthMessage').textContent = message || 'This action requires your master password.'; $('#reauthPassword').value = ''; $('#reauthModal').classList.remove('is-hidden'); setTimeout(() => $('#reauthPassword').focus(), 50); }); } function closeReauth(ok) { $('#reauthModal').classList.add('is-hidden'); if (reauthResolve) { const pwd = ok ? $('#reauthPassword').value : null; const cb = reauthResolve; reauthResolve = null; cb(pwd); } } // ============================================================ // RECOVERY KEY — one-shot emergency access // ============================================================ // // Generated at the user's request from Settings. The plaintext code is // shown exactly once; the server stores only SHA-256(code) for lookup // + an AES-GCM wrap of the current vault key under a KEK derived from // PBKDF2(code, kdfSalt, 600k). // // Recovery flow (master pw forgotten): // 1. Auth screen → "Use recovery code" → enter username + code // 2. Server hashes code, looks up user, verifies match, DELETES the // recovery row (single-use), returns wrapped key + KEK salt + // a fresh session. // 3. Client derives the KEK, unwraps the AES key. // 4. Client immediately forces a master-password change so the // account isn't left with the recovery code's KEK as the only // escape hatch. // Random recovery code: 16 chars in 4 groups of 4. ~96 bits entropy // from a 36-char alphabet (no ambiguous chars: no 0/O/I/l/1) so the // printed form is misreading-resistant. function generateRecoveryCode() { const A = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // 32 chars const bytes = crypto.getRandomValues(new Uint8Array(16)); let s = ''; for (let i = 0; i < 16; i++) { if (i > 0 && i % 4 === 0) s += '-'; s += A[bytes[i] % A.length]; } return s; } async function sha256HexLocal(input) { const buf = new TextEncoder().encode(input); const hashBuf = await crypto.subtle.digest('SHA-256', buf); const bytes = new Uint8Array(hashBuf); let hex = ''; for (const b of bytes) hex += b.toString(16).padStart(2, '0'); return hex; } // Derive a KEK from the recovery code + per-row salt, then wrap the // supplied AES key bytes under it. Returns base64 ciphertext + IV. async function wrapAesKeyForRecovery(aesKeyBytes, recoveryCode, kdfSaltHex) { const saltBytes = new TextEncoder().encode(kdfSaltHex); // match deriveKey's quirk const km = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(recoveryCode), 'PBKDF2', false, ['deriveKey']); const kek = await crypto.subtle.deriveKey( { name: 'PBKDF2', salt: saltBytes, iterations: 600000, hash: 'SHA-256' }, km, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); const iv = crypto.getRandomValues(new Uint8Array(12)); const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, kek, aesKeyBytes); return { wrappedKey: bytesToBase64(ct), wrappedIv: bytesToBase64(iv) }; } async function unwrapAesKeyFromRecovery(wrappedKeyB64, wrappedIvB64, recoveryCode, kdfSaltHex) { const saltBytes = new TextEncoder().encode(kdfSaltHex); const km = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(recoveryCode), 'PBKDF2', false, ['deriveKey']); const kek = await crypto.subtle.deriveKey( { name: 'PBKDF2', salt: saltBytes, iterations: 600000, hash: 'SHA-256' }, km, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); const iv = base64ToBytes(wrappedIvB64); const ct = base64ToBytes(wrappedKeyB64); return await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, kek, ct); // raw bytes } // Generate a new recovery key for the logged-in user. Shows the plaintext // code in a modal that the user must explicitly acknowledge before closing. async function doGenerateRecoveryKey() { if (!state.cryptoKey) { return toast('Vault locked', 'warning'); } const masterPwd = await askReauth( 'Confirm your master password to generate a recovery key.'); if (!masterPwd) return; // Generate the code + a fresh per-row salt for the KEK PBKDF2. Salt is // per-recovery so regenerating doesn't reuse the same KDF parameters. const code = generateRecoveryCode(); const codeHash = await sha256HexLocal(code); const kdfSalt = randomHexSalt(); // Export the current AES key as raw bytes so we can wrap it under // the recovery KEK. The export only works because deriveKey was // called with `extractable=true` — already the case in our code. const rawKey = await crypto.subtle.exportKey('raw', state.cryptoKey); const { wrappedKey, wrappedIv } = await wrapAesKeyForRecovery( rawKey, code, kdfSalt); try { await api('/recovery-key/setup', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ masterPassword: masterPwd, 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. 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 later will let you recover access if you forget ' + 'your master password. The code is single-use.

', 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) { lbl.textContent = 'Recovery key is configured.'; 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; 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\'re single-use — using one will remove it from your account.', 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(); sessionStorage.setItem('authToken', state.token); sessionStorage.setItem('csrfToken', state.csrf); sessionStorage.setItem('salt', state.salt); sessionStorage.setItem('username', state.username); // 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(); toast('Access recovered — please set a new master password'); await enterApp(); // Force a master pw change immediately. The recovery code is consumed // (server deleted the row); the account is currently orphaned from // a "we know who you are" perspective. Setting a new master pw both // restores normal login AND lets the user generate a fresh recovery // code afterwards. 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'; } $('#changeMasterModal').classList.remove('is-hidden'); setTimeout(() => $('#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 curPwd = $('#cmCurrentPwd').value; const newPwd = $('#cmNewPwd').value; const confPwd = $('#cmConfirmPwd').value; // Local validation. Server enforces these too, but failing fast saves // a round trip + leaves the modal open so the user can fix and retry. if (!curPwd || !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 (newPwd === curPwd) return showCmError('New password must differ from the current one'); // Disable the confirm button so a double-click doesn't fire two // re-encryption passes in parallel. const btn = $('#cmConfirmBtn'); if (btn) btn.disabled = true; try { // Step 1: generate the new salt and derive the new AES key. const newSalt = randomHexSalt(); const newKey = await deriveKey(newPwd, newSalt, 600000); // Step 2: re-encrypt every entry's password AND every entry's TOTP // secret (if present) under the new key. The current state.cryptoKey // still decrypts the existing ciphertext. const encrypted = []; for (const e of state.entries) { const plain = await decryptPwd(e.encrypted_password, e.iv); if (plain === '[ERROR]') { throw new Error('Could not decrypt entry id=' + e.id); } // Swap the key around encryptPwd so it picks up the new one. const oldKey = state.cryptoKey; state.cryptoKey = newKey; try { const re = await encryptPwd(plain); let totpEnc = '', totpIv = ''; if (e.totp_secret && e.totp_iv) { state.cryptoKey = oldKey; const plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv); state.cryptoKey = newKey; if (plainTotp !== '[ERROR]') { const t = await encryptPwd(plainTotp); totpEnc = t.encrypted; totpIv = t.iv; } } encrypted.push({ id: e.id, encrypted_password: re.encrypted, iv: re.iv, totp_secret: totpEnc, totp_iv: totpIv, }); } finally { state.cryptoKey = oldKey; // restore until server confirms } } // Step 3: send the atomic request. Server verifies the current pw, // updates the user row, swaps every entry's ciphertext, returns // the new salt + iter count. const r = await api('/change-master-password', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ currentMasterPassword: curPwd, newMasterPassword: newPwd, 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.cryptoKey = newKey; await persistCryptoKey(); sessionStorage.setItem('salt', state.salt); 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; } closeChangeMasterModal(); toast('Master password changed · other sessions signed out'); } catch (err) { if (err.status === 401) { showCmError('Current password is incorrect'); } else if (err.status === 429 && err.body && err.body.retry_after) { showCmError('Account locked, try again in ' + Math.ceil(err.body.retry_after / 60) + ' min'); } else { showCmError('Failed: ' + (err.message || 'unknown error')); } } finally { if (btn) btn.disabled = false; } } // ============================================================ // ENCRYPTED EXPORT CONTAINER // ============================================================ // // File format (JSON): // { // "format": "pm-encrypted-export-v1", // "kdf": "pbkdf2-sha256", // "kdf_iterations": 600000, // "kdf_salt": "", // "iv": "", // "ciphertext":"", // "created_at": "" // } // payload = same shape produced by the plaintext exporter (entries array). // // The encryption password is INDEPENDENT of the master password — the // user picks it at export time and provides it again at import time. // Decoupling means a master-password change doesn't brick old backups, // and the backup can be shared without revealing the master pw. function bytesToBase64(arr) { if (arr instanceof ArrayBuffer) arr = new Uint8Array(arr); let s = ''; for (let i = 0; i < arr.length; i++) s += String.fromCharCode(arr[i]); return btoa(s); } function base64ToBytes(b64) { return Uint8Array.from(atob(b64), c => c.charCodeAt(0)); } // Derive an AES-GCM key from a user-chosen export password + random salt. // Uses the same 600k iteration PBKDF2 as the rest of the app. async function deriveExportKey(password, saltBytes, iterations) { const km = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey']); return crypto.subtle.deriveKey( { name: 'PBKDF2', salt: saltBytes, iterations: iterations, hash: 'SHA-256' }, km, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); } async function encryptExportPayload(payloadObj, exportPwd) { const plaintext = new TextEncoder().encode(JSON.stringify(payloadObj)); const salt = crypto.getRandomValues(new Uint8Array(32)); const iv = crypto.getRandomValues(new Uint8Array(12)); const key = await deriveExportKey(exportPwd, salt, 600000); const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext); return { format: 'pm-encrypted-export-v1', kdf: 'pbkdf2-sha256', kdf_iterations: 600000, kdf_salt: bytesToBase64(salt), iv: bytesToBase64(iv), ciphertext: bytesToBase64(ct), created_at: new Date().toISOString(), }; } async function decryptExportContainer(container, exportPwd) { const salt = base64ToBytes(container.kdf_salt); const iv = base64ToBytes(container.iv); const ct = base64ToBytes(container.ciphertext); const key = await deriveExportKey(exportPwd, salt, container.kdf_iterations || 600000); const plainBuf = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct); return JSON.parse(new TextDecoder().decode(plainBuf)); } // ============================================================ // IMPORT — JSON (native round-trip) + CSV (universal) // ============================================================ // // Two supported input formats: // 1. Native JSON: the same shape produced by doExport() above // { version, exported_at, username, entries: [ // { site, username, password, folder, tags, favorite, ... } // ]} // 2. CSV: with a header row. Column names are mapped heuristically so // exports from Bitwarden / KeePass / Chrome / 1Password generally // "just work" without manual column mapping. // // Each parsed entry is encrypted client-side with the vault key (same // flow as a single-entry add), then sent to /entries/bulk-import as one // transactional batch. // Minimal RFC 4180-ish CSV parser. Handles quoted fields, escaped quotes // (""), commas inside quotes, and CRLF line endings. Returns an array of // arrays (rows × columns). No streaming — fine for the ~MB-scale imports // a password manager realistically deals with. function parseCSV(text) { const rows = []; let row = [], field = '', inQuotes = false; for (let i = 0; i < text.length; i++) { const c = text[i]; if (inQuotes) { if (c === '"') { if (text[i + 1] === '"') { field += '"'; i++; } // escaped "" else inQuotes = false; } else field += c; } else { if (c === '"') inQuotes = true; else if (c === ',') { row.push(field); field = ''; } else if (c === '\n' || c === '\r') { if (c === '\r' && text[i + 1] === '\n') i++; // CRLF row.push(field); field = ''; if (row.length > 1 || (row.length === 1 && row[0] !== '')) rows.push(row); row = []; } else field += c; } } // Flush trailing field/row (file without final newline) if (field !== '' || row.length > 0) { row.push(field); rows.push(row); } return rows; } // Header heuristics: pick the first matching column name (case-insensitive, // underscore/space-tolerant). Returns null if no candidate header matches. function findColumn(headers, candidates) { const norm = s => String(s || '').toLowerCase().replace(/[\s_-]+/g, ''); const cand = candidates.map(norm); for (let i = 0; i < headers.length; i++) { if (cand.indexOf(norm(headers[i])) >= 0) return i; } return null; } // Parse a CSV text into an array of plaintext entries // ({ site, username, password, folder, tags, totp_secret }). Returns // { entries, skipped, columns } so the preview can show what was matched. function parseEntriesFromCSV(text) { const rows = parseCSV(text); if (rows.length < 2) { throw new Error('CSV needs a header row and at least one data row'); } const headers = rows[0]; // Candidate names per format observed in real exports: // Bitwarden CSV : folder, name, login_uri, login_username, login_password, login_totp, notes // KeePass CSV : Title, URL, Username, Password, Group, Notes // Chrome/Edge : name, url, username, password // 1Password CSV : Title, URL, Username, Password, Notes // findColumn normalizes (lowercase, strip _ and -) so 'login_uri' and // 'loginuri' both match the same candidate. const colSite = findColumn(headers, ['name', 'title', 'url', 'site', 'website', 'login_uri', 'login_url', 'web_site']); const colUser = findColumn(headers, ['login_username', 'username', 'user', 'login', 'email', 'user_name']); const colPwd = findColumn(headers, ['login_password', 'password', 'pass', 'pwd']); const colFolder = findColumn(headers, ['folder', 'group', 'category', 'path', 'collection']); const colTags = findColumn(headers, ['tags', 'labels']); const colNotes = findColumn(headers, ['notes', 'note', 'comment', 'comments']); const colTotp = findColumn(headers, ['login_totp', 'totp', 'totp_secret', 'otp', 'otpauth', 'authenticator', 'two_factor', 'twofa']); if (colSite === null && colUser === null) throw new Error('No recognizable site/url or username column in CSV header'); if (colPwd === null) throw new Error('No recognizable password column in CSV header'); const entries = []; let skipped = 0; for (let i = 1; i < rows.length; i++) { const r = rows[i]; const site = (colSite !== null ? r[colSite] : '').trim() || (colUser !== null ? r[colUser] : '').trim(); const pwd = (colPwd !== null ? r[colPwd] : ''); if (!site || !pwd) { skipped++; continue; } // Tags: combine the tags column and any free-form notes into a // comma-separated string. Notes often contain useful metadata we // don't want to drop on the floor. let tagsArr = []; if (colTags !== null) { String(r[colTags] || '').split(/[,;]/).forEach(t => { t = t.trim(); if (t) tagsArr.push(t); }); } if (colNotes !== null) { const n = String(r[colNotes] || '').trim(); if (n && n.length < 80) tagsArr.push(n); // long notes become noise as tags } // TOTP: support raw base32 OR full otpauth:// URI in the cell. let totp = ''; if (colTotp !== null) { const raw = String(r[colTotp] || '').trim(); totp = parseOtpAuthUri(raw) || raw; } entries.push({ site: site, username: (colUser !== null ? r[colUser] : '').trim(), password: pwd, folder: (colFolder !== null ? r[colFolder] : '').trim() || 'All', tags: tagsArr.join(','), totp_secret: totp, }); } return { entries, skipped, columns: { site: colSite, username: colUser, password: colPwd, folder: colFolder, tags: colTags, notes: colNotes, totp: colTotp, } }; } // Parse a native JSON export. Forgiving: accepts both our own format and // a flat array of entry objects. function parseEntriesFromJSON(text) { let data; try { data = JSON.parse(text); } catch (e) { throw new Error('Invalid JSON: ' + e.message); } const raw = Array.isArray(data) ? data : (data.entries || []); if (!Array.isArray(raw) || raw.length === 0) throw new Error('No entries in JSON file'); const entries = []; let skipped = 0; for (const e of raw) { if (!e || typeof e !== 'object') { skipped++; continue; } const site = String(e.site || e.url || e.name || '').trim(); const pwd = String(e.password || ''); if (!site || !pwd) { skipped++; continue; } const tagsVal = e.tags; const tagsStr = Array.isArray(tagsVal) ? tagsVal.join(',') : String(tagsVal || ''); entries.push({ site: site, username: String(e.username || e.user || e.login || '').trim(), password: pwd, folder: String(e.folder || e.group || 'All').trim() || 'All', tags: tagsStr, totp_secret: String(e.totp || e.totp_secret || e.otpauth || '').trim(), }); } return { entries, skipped, columns: null }; // JSON: no column report } // Encrypt one parsed entry (plaintext password + optional TOTP) into the // shape the bulk-import endpoint expects. Reuses encryptPwd which already // generates a fresh IV per call. async function encryptImportEntry(plain) { const pw = await encryptPwd(plain.password); let totpEnc = '', totpIv = ''; if (plain.totp_secret) { try { base32Decode(plain.totp_secret); // validate before encrypting const t = await encryptPwd(plain.totp_secret); totpEnc = t.encrypted; totpIv = t.iv; } catch (e) { // Bad TOTP secret in source file — keep the entry but drop the // 2FA silently. The user can fix it later via the slide-over. } } return { site: plain.site, username: plain.username || '', encrypted_password: pw.encrypted, iv: pw.iv, folder: plain.folder || 'All', tags: plain.tags || '', totp_secret: totpEnc, totp_iv: totpIv, }; } // Open a hidden file picker, route the result through the right parser, // show a preview confirmation, then bulk-encrypt + POST. async function doImport() { const fileInput = el('input', { type: 'file', accept: '.json,.csv,application/json,text/csv', style: 'display:none', }); document.body.appendChild(fileInput); fileInput.addEventListener('change', async () => { const file = fileInput.files && fileInput.files[0]; fileInput.remove(); if (!file) return; let text; try { text = await file.text(); } catch (e) { return toast('Cannot read file: ' + e.message, 'error'); } const isJSON = /\.json$/i.test(file.name) || text.trim().startsWith('{') || text.trim().startsWith('['); // If the JSON is an encrypted-export container, prompt for the // backup password and decrypt before handing the plaintext payload // to the regular JSON parser. if (isJSON) { let raw; try { raw = JSON.parse(text); } catch (e) { raw = null; } if (raw && raw.format === 'pm-encrypted-export-v1') { const pw = await promptDialog({ title: 'Encrypted backup', message: 'This backup is encrypted. Enter the password ' + 'you set when you exported it.', placeholder: 'Backup encryption password', okText: 'Decrypt', password: true, }); if (!pw) return; try { const payload = await decryptExportContainer(raw, pw); // Hand the decrypted payload back to parseEntriesFromJSON // via JSON.stringify — keeps the parser code path single. text = JSON.stringify(payload); } catch (e) { return toast('Decryption failed — wrong password or corrupted file', 'error'); } } } let parsed; try { parsed = isJSON ? parseEntriesFromJSON(text) : parseEntriesFromCSV(text); } catch (e) { return toast('Parse error: ' + e.message, 'error'); } if (parsed.entries.length === 0) { return toast('No valid entries found in file', 'warning'); } // Build the preview message (innerHTML target → escape user data) const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); const parts = []; parts.push('' + parsed.entries.length + ' entries detected in ' + esc(file.name) + ''); if (parsed.skipped > 0) parts.push('' + parsed.skipped + ' rows skipped (missing site or password)'); const sample = parsed.entries.slice(0, 3).map(e => '• ' + esc(e.site || '?') + (e.username ? ' (' + esc(e.username) + ')' : '') ).join('
'); parts.push('
' + sample + (parsed.entries.length > 3 ? '
…' : '') + '
'); parts.push('
Import now? This adds the entries to your existing vault.
'); const confirmed = await confirmDialog({ title: 'Import vault', message: parts.join('
'), okText: 'Import', }); if (!confirmed) return; // Encrypt all entries client-side, then POST as one transaction. toast('Encrypting ' + parsed.entries.length + ' entries…'); const encrypted = []; for (const e of parsed.entries) { encrypted.push(await encryptImportEntry(e)); } try { const r = await api('/entries/bulk-import', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ entries: encrypted }), }); toast('Imported ' + r.imported + ' entries'); await loadEntries(); render(); if (state.hibpEnabled) hibpCheckAllEntries(); // scan the new entries too } catch (err) { toast('Import failed: ' + err.message, 'error'); } }); fileInput.click(); } async function doExport() { // Step 1: reauth — verifies the human in front of the screen is the // vault owner before we hand them every plaintext password. Defense // against a stranger reaching the open laptop and exfiltrating data. const masterPwd = await askReauth( 'Enter your master password to start an encrypted export.'); if (!masterPwd) return; try { await api('/reauth', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ masterPassword: masterPwd }), }); } catch (err) { // 429 (account lockout) is possible here too — propagate as a clear // message rather than the generic "wrong master password" toast. if (err.status === 429 && err.body && err.body.retry_after) { return toast('Account locked. Try again in ' + Math.ceil(err.body.retry_after / 60) + ' min', 'warning'); } return toast('Wrong master password', 'error'); } // Step 2: ask for an INDEPENDENT export password. Decoupled from the // master pw so a master-pw change later doesn't invalidate the backup, // and so the backup can be shared without revealing the master pw. const exportPwd = await promptDialog({ title: 'Encrypted export', message: 'Choose a password to encrypt the backup file.
' + '' + 'You will need this password to restore the file. ' + 'It is independent of your master password.', placeholder: 'Backup encryption password', okText: 'Export', password: true, }); if (!exportPwd) return; if (exportPwd.length < 6) { return toast('Use at least 6 characters', 'warning'); } // Step 3: assemble the plaintext payload (same shape as the legacy // plaintext exporter — round-trips with the existing JSON importer // after decryption). const payload = { version: 1, exported_at: new Date().toISOString(), username: state.username, entries: [], }; for (const e of state.entries) { const plain = await decryptPwd(e.encrypted_password, e.iv); let plainTotp = ''; if (e.totp_secret && e.totp_iv) { plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv); if (plainTotp === '[ERROR]') plainTotp = ''; } payload.entries.push({ site: e.site, username: e.username, password: plain, folder: e.folder, tags: parseTags(e.tags), favorite: !!e.favorite, totp_secret: plainTotp, created_at: e.created_at, updated_at: e.updated_at, }); } // Step 4: encrypt + download const container = await encryptExportPayload(payload, exportPwd); const blob = new Blob([JSON.stringify(container, null, 2)], { type: 'application/json', }); const url = URL.createObjectURL(blob); const a = el('a', { href: url, download: 'vault-export-' + new Date().toISOString().slice(0, 10) + '.json', }); document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100); toast(payload.entries.length + ' entries exported (encrypted)'); } // ============================================================ // VIEWS / NAV // ============================================================ async function setView(v) { state.view = v; 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; } function openSettings() { $('#settingTheme').value = state.theme; $('#settingAutoLock').value = String(state.autoLock); $('#settingAskDelete').checked = state.askBeforeDelete; $('#settingCompact').checked = state.compactActions; $('#settingMaskUser').checked = state.maskUsernames; $('#settingHIBP').checked = state.hibpEnabled; $('#settingUser').textContent = state.username; // Async: query server for recovery key state and update the label refreshRecoveryStatus(); $('#settingsPanel').classList.add('is-open'); } function closeSettings() { $('#settingsPanel').classList.remove('is-open'); } // ---- Auto-lock with 30s warning countdown ------------------- const WARNING_SECONDS = 30; let autoLockTimer = null; let warningTimer = null; let countdownInterval = null; function hideIdleWarning() { $('#idleWarning').classList.add('is-hidden'); if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; } } function showIdleWarning() { $('#idleCountdown').textContent = WARNING_SECONDS; $('#idleWarning').classList.remove('is-hidden'); let s = WARNING_SECONDS; countdownInterval = setInterval(() => { s -= 1; $('#idleCountdown').textContent = Math.max(0, s); if (s <= 0) { clearInterval(countdownInterval); countdownInterval = null; } }, 1000); } function resetAutoLock() { if (autoLockTimer) clearTimeout(autoLockTimer); if (warningTimer) clearTimeout(warningTimer); hideIdleWarning(); if (!state.autoLock || !state.token || !state.cryptoKey) return; const totalMs = state.autoLock * 60 * 1000; const warningAt = Math.max(0, totalMs - WARNING_SECONDS * 1000); warningTimer = setTimeout(showIdleWarning, warningAt); autoLockTimer = setTimeout(() => { hideIdleWarning(); toast('Auto-locked due to inactivity', 'warning'); lockVault(); }, totalMs); } // Reset idle on user interaction — but ignore events that fire while the // warning popup is visible (otherwise the popup would never auto-dismiss). ['mousemove', 'keydown', 'click', 'touchstart'].forEach(ev => document.addEventListener(ev, e => { // Allow clicks on the "Stay unlocked" button to also reset if ($('#idleWarning').classList.contains('is-hidden')) { resetAutoLock(); } }, { passive: true }) ); function showAuth() { $('#authScreen').classList.remove('is-hidden'); $('#appShell').classList.add('is-hidden'); if (autoLockTimer) { clearTimeout(autoLockTimer); autoLockTimer = null; } } async function enterApp() { $('#authScreen').classList.add('is-hidden'); $('#appShell').classList.remove('is-hidden'); $('#userName').textContent = state.username; // Show skeleton cards immediately while the initial fetch runs showSkeletons(6); await loadFolders(); await loadEntries(); 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(); } // ============================================================ // INIT // ============================================================ async function init() { document.documentElement.setAttribute('data-theme', state.theme); // Auth tabs $$('.auth-tab').forEach(t => { t.addEventListener('click', () => { $$('.auth-tab').forEach(x => x.classList.remove('is-active')); t.classList.add('is-active'); const tab = t.dataset.tab; $('#loginForm').classList.toggle('is-hidden', tab !== 'login'); $('#registerForm').classList.toggle('is-hidden', tab !== 'register'); }); }); // Forms $('#loginForm').addEventListener('submit', doLogin); $('#registerForm').addEventListener('submit', doRegister); $('#regPassword').addEventListener('input', updateRegStrength); $('#entryPassword').addEventListener('input', updateEntryStrength); // Top-bar function applyViewMode() { $$('.view-btn').forEach(b => b.classList.toggle('is-active', b.dataset.view === state.viewMode)); renderGrid(); } applyViewMode(); $$('.view-btn').forEach(b => b.addEventListener('click', () => { state.viewMode = b.dataset.view; localStorage.setItem('viewMode', state.viewMode); applyViewMode(); })); $('#themeBtn').addEventListener('click', toggleTheme); $('#newEntryBtn').addEventListener('click', () => openEntryModal()); $('#userChip').addEventListener('click', () => $('#userDropdown').classList.toggle('is-hidden')); $('#lockBtn').addEventListener('click', lockVault); $('#logoutBtn').addEventListener('click', doLogout); document.addEventListener('click', e => { if (!e.target.closest('.user-menu')) $('#userDropdown').classList.add('is-hidden'); // Close any open kebab menu when clicking outside it if (!e.target.closest('.entry-kebab-wrap')) { $$('.entry-kebab-menu.is-open').forEach(m => m.classList.remove('is-open')); } }); // Sidebar nav $$('#appShell .nav-item[data-view]').forEach(n => { n.addEventListener('click', () => setView(n.dataset.view)); }); $('#addFolderBtn').addEventListener('click', addFolder); // Drag-to-trash: dropping an entry onto the Trash nav item soft-deletes it const trashItem = $('#appShell .nav-item[data-view="trash"]'); if (trashItem) { trashItem.addEventListener('dragover', ev => { ev.preventDefault(); trashItem.classList.add('drag-over'); }); trashItem.addEventListener('dragleave', () => trashItem.classList.remove('drag-over')); trashItem.addEventListener('drop', async ev => { ev.preventDefault(); trashItem.classList.remove('drag-over'); const id = parseInt(ev.dataTransfer.getData('text/plain')); if (!id) return; try { await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() }); toast('Moved to trash'); await loadEntries(); await loadTrash(); render(); } catch (err) { toast(err.message, 'error'); } }); } // Search $('#searchInput').addEventListener('input', e => { state.search = e.target.value; renderGrid(); }); // Marquee rubber-band selection on the entry grid $('#entryGrid').addEventListener('mousedown', startMarquee); // Slide-over $('#slideoverClose').addEventListener('click', closeSlideOver); // Click outside the slide-over closes it. Clicks on cards re-open it for // another entry (so we don't close in that case; the card's own handler // will switch state.selectedId). document.addEventListener('click', e => { if (!$('#slideover').classList.contains('is-open')) return; if (e.target.closest('.slideover')) return; if (e.target.closest('.entry-card')) return; if (e.target.closest('.modal')) return; if (e.target.closest('.cmd-palette')) return; if (e.target.closest('.idle-warning')) return; closeSlideOver(); }); // 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'; }); $('#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); // Idle warning "Stay unlocked" $('#idleStayBtn').addEventListener('click', resetAutoLock); // Settings slide-over $('#settingsBtn').addEventListener('click', openSettings); $('#settingsClose').addEventListener('click', closeSettings); $('#settingTheme').addEventListener('change', e => setTheme(e.target.value)); $('#settingAutoLock').addEventListener('change', e => { state.autoLock = parseInt(e.target.value); localStorage.setItem('autoLockMin', String(state.autoLock)); resetAutoLock(); 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'); 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(); }); $('#settingMaskUser').addEventListener('change', e => { state.maskUsernames = e.target.checked; localStorage.setItem('maskUsernames', state.maskUsernames ? '1' : '0'); render(); }); $('#settingHIBP').addEventListener('change', e => { state.hibpEnabled = e.target.checked; localStorage.setItem('hibpEnabled', state.hibpEnabled ? '1' : '0'); if (state.hibpEnabled) { toast('Checking passwords against breach database…'); hibpCheckAllEntries(); } else { state.hibpResults.clear(); render(); toast('Breach check disabled'); } }); $('#openClipboardSettings').addEventListener('click', () => { toast('Open Windows Settings → System → Clipboard → turn off "Clipboard history"', 'warning'); }); $('#exportBtn').addEventListener('click', doExport); $('#importBtn').addEventListener('click', doImport); $('#changeMasterBtn').addEventListener('click', openChangeMasterModal); $('#changeMasterForm').addEventListener('submit', e => { e.preventDefault(); doChangeMasterPassword(); }); $$('#changeMasterModal [data-close]').forEach(b => b.addEventListener('click', closeChangeMasterModal)); // Recovery key $('#recoverySetupBtn').addEventListener('click', doGenerateRecoveryKey); $('#recoveryRemoveBtn').addEventListener('click', doRemoveRecoveryKey); $('#recoveryBtn').addEventListener('click', doRecoveryRedeem); // 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)) ); // Command palette document.addEventListener('keydown', e => { if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); openPalette(); } else if (e.key === 'Escape') { // Close in priority order: confirm first (most modal-y) then others if (!$('#confirmModal').classList.contains('is-hidden')) { closeConfirm(false); return; } if (!$('#changeMasterModal').classList.contains('is-hidden')) { closeChangeMasterModal(); return; } closePalette(); closeSlideOver(); closeEntryModal(); closeGen(); } }); $('#cmdInput').addEventListener('input', e => renderPaletteResults(e.target.value)); $$('#cmdPalette [data-close]').forEach(b => b.addEventListener('click', closePalette)); // Restore session if any if (state.token && state.salt) { const ok = await restoreCryptoKey(); if (ok) { await enterApp(); } else { // session token exists but crypto key gone — user must re-enter master pw showAuth(); $('#loginUsername').value = state.username; } } else { showAuth(); } } document.addEventListener('DOMContentLoaded', init);