/* ============================================================ 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; } // ============================================================ // 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 { // Derive the new key. The old key is already in state.cryptoKey // (used to decrypt the entries we just loaded). const 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). const 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 to 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 } } // Send the atomic migrate request. Server verifies the master pw // against the OLD hash, then updates hash + iterations + every // entry in a single transaction. await api('/migrate-kdf', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ masterPassword: masterPwd, entries: newCiphertexts, }), }); // Server committed → switch our in-memory crypto key and update // the cached ciphertexts in state.entries so subsequent reads use // the new key transparently. 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)'); } 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); } 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); // 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(','), }, tags: parseTags(e.tags), originalEncrypted: e.encrypted_password, originalIV: e.iv, }; body.appendChild(soEditableField('Site', 'soSite', e.site)); body.appendChild(soEditableField('Username', 'soUsername', e.username || '')); body.appendChild(soPasswordField(plain)); 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; } 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 || '', 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.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; 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); } 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, 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() { $('#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 } 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 || ''; $('#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); } } async function doExport() { const pwd = await askReauth('Enter your master password to export the vault as JSON. The file will be UNENCRYPTED.'); if (!pwd) return; try { await api('/reauth', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ masterPassword: pwd }), }); } catch (err) { toast('Wrong master password', 'error'); return; } // Decrypt all entries const out = { 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); out.entries.push({ site: e.site, username: e.username, password: plain, folder: e.folder, tags: parseTags(e.tags), favorite: !!e.favorite, created_at: e.created_at, updated_at: e.updated_at, }); } const blob = new Blob([JSON.stringify(out, 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(out.entries.length + ' entries exported'); } // ============================================================ // 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; $('#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); // 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); // 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; } 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);