Files
Password-Manager/js/app.js
T
Zaki 2ef636ce30 feat: unified entry slideover + custom icons + UX fixes
Unified create/edit slideover
- openSlideOver(id) now accepts null for new entries. Same UI
  (icon, name, site, user, password, TOTP, folder, tags) for both
  create and edit. Drops the separate entry modal — no more "save
  first, then add TOTP" two-step.
- "+ New" button, Ctrl+K → New entry, and Ctrl+Shift+A all route
  through the slideover. Ctrl+Shift+A pre-fills the title with the
  foreground window's name.
- Save button visible from the start in new mode (no dirty wait).
- Title shows mode unambiguously: cyan "+ New entry" vs
  "Edit · <name>".

Custom icon upload (soIconField)
- 56×56 preview at the top of every slideover + Upload icon /
  Remove buttons. Same POST /entries/{id}/icon endpoint as the
  auto-fetch path. Validates type / size (64 KB cap matching server).
- Solves the case where DDG doesn't index a domain (self-hosted
  apps, private sites): the user pastes any image and it sticks.

Favicon: privacy-first, DDG only
- Removed the direct-fetch fallback steps (3-5). Privacy stance:
  zero DNS leak outside icons.duckduckgo.com. Domains DDG doesn't
  cover stay icon-less until the user uploads a custom one.
- PM.Favicon.FetchFaviconDataUri takes an optional TFaviconLog
  callback so UMainForm can stream per-step trace into LogLine for
  diagnostics.

Fixes
- Slideover z-index 30 → 50. The topbar's backdrop-filter creates a
  stacking context at z-index 40 which was clipping the slideover
  header (title + close button hidden behind topbar).
- RestoreFromTray no longer un-maximises a maximised window when
  called outside a tray-restore context (Ctrl+Shift+A, Ctrl+Shift+L
  picker, app/focus cmd). SW_RESTORE on a maximised window reverts
  to normal — now we only SW_RESTORE if IsIconic.
- "Show all"/"Show less" per-category state survives renderGrid
  re-renders (healthExpanded map).
- "+ New" and dashboard "Fix" buttons stopPropagation so the
  document-level click-outside handler doesn't close the slideover
  they just opened.
- soDirtyCheck keeps Save visible while in new mode regardless of
  diff.
- openSlideover → openSlideOver typo fix across all call sites.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 00:15:20 +01:00

6300 lines
252 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
Vault — UI V2 app.js
Clean state + render layer. Crypto helpers preserved verbatim
from legacy. Backend (PHP api.php / Delphi loopback) is detected
from URL path.
============================================================ */
// ---- Backend detection -------------------------------------
const API = (location.pathname.indexOf('/password-manager/') === 0)
? '/password-manager/api.php'
: '';
// ---- Delphi native bridge ----------------------------------
// Active only when running inside the Delphi-hosted WebView2 (API === '').
// Falls back to navigator.clipboard for the standalone PHP frontend.
const prefResolvers = {};
let autoStartResolver = null;
const faviconResolvers = {};
let _faviconReqSeq = 0;
const Bridge = (() => {
const active = (API === '');
// Navigate to a cmd:// URL — intercepted synchronously by
// TTMSFNCWebBrowser OnBeforeNavigate before any actual navigation occurs.
function cmd(path) {
window.location.href = path;
}
return {
active,
// Copy text to clipboard, excluded from Win+V history.
// clearAfterMs: Delphi auto-clears after this many ms (0 = never).
// Returns true when the bridge handled the copy, false as fallback signal.
copySecure(text, clearAfterMs = 30000) {
if (!active) return false;
cmd('cmd://clipboard/copy?text=' + encodeURIComponent(text) +
'&clear=' + clearAfterMs);
return true;
},
// Called by Delphi (ExecuteJavaScript) on WTS_SESSION_LOCK and
// PBT_APMSUSPEND. When quick-unlock is enabled on this device the
// DPAPI blob already gates access via the Windows user account, so
// re-locking is redundant — we just stay unlocked and the user is
// back where they left off when they return.
onSystemLock() {
if (state.quickUnlockEnabled) {
if (typeof toast === 'function')
toast('System lock — vault kept unlocked (quick unlock active)');
return;
}
if (typeof lockVault === 'function') lockVault();
},
// Called by Delphi (ExecuteJavaScript) when the user restores the
// window from the tray icon. Useful for resetting auto-lock state
// and giving a subtle visual cue.
onTrayRestore() {
// If the user has been away long enough that the auto-lock
// should fire, lockVault was already called by either WTS lock
// or the local idle timer — so we only reset here when still
// unlocked.
if (state.cryptoKey && !state.locked) {
if (typeof resetAutoLock === 'function') resetAutoLock();
if (typeof toast === 'function') toast('Welcome back');
}
},
// ---- Autofill (Ctrl+Shift+L / Ctrl+Shift+P global hotkeys) ----------
// Called by Delphi when a hotkey fires. windowTitle = foreground
// window title at hotkey time. kind = "full" (user+Tab+pwd) or
// "password" (password only — for step-2 forms, unlock screens).
onAutofillRequest(windowTitle, kind) {
autofillHandleRequest(windowTitle, kind || 'full');
},
// Called by Delphi on Ctrl+Shift+A. Opens the new-entry modal with
// the foreground window title pre-filled (browser suffix stripped).
onNewEntryFromTitle(windowTitle) {
if (!state.cryptoKey || state.locked || !state.token) {
toast('Unlock the vault first', 'warning');
return;
}
const cleaned = autofillStripBrowserSuffix(windowTitle);
openSlideOver(null, { presetTitle: cleaned });
},
// Tell Delphi to simulate keystrokes. Empty username = password only
// (no Tab is sent).
executeAutofill(username, password) {
if (!active) return;
cmd('cmd://autofill/execute?username=' + encodeURIComponent(username) +
'&password=' + encodeURIComponent(password));
},
// Ask Delphi to bring the main window to front (used when the
// multi-match picker opens, so the user definitely sees it even
// if the app was minimised to tray or hidden behind other apps).
focusApp() {
if (!active) return;
cmd('cmd://app/focus');
},
// Tell Delphi the page is rendered and waiting for input — Delphi
// calls WebBrowser.SetFocus (the FMX control needs OS-level focus
// before any input.focus() inside the DOM can work) then injects
// a focus script targeting the visible auth field.
appReady() {
if (!active) return;
cmd('cmd://app/ready');
},
// Sync the Windows title bar with the app theme (dark vs light).
// Calls DwmSetWindowAttribute DWMWA_USE_IMMERSIVE_DARK_MODE on the
// form's HWND. No-op on Windows < 10 build 19044.
syncTitleBarTheme(mode) {
if (!active) return;
cmd('cmd://app/theme?mode=' + (mode === 'light' ? 'light' : 'dark'));
},
// Tell Delphi we couldn't find a match / user cancelled.
cancelAutofill() {
if (!active) return;
cmd('cmd://autofill/cancel');
},
// Sync the hotkey registration state with this device's preference.
configureAutofill(enabled) {
if (!active) return;
cmd('cmd://autofill/configure?enabled=' + (enabled ? '1' : '0'));
},
// Send the full hotkey configuration to Delphi (enabled + combos).
// combos = { full: {ctrl,shift,alt,win,key}, password: {…} }.
setAutofillHotkeys(enabled, combos) {
if (!active) return;
const f = autofillComboToWin32(combos.full);
const p = autofillComboToWin32(combos.password);
cmd('cmd://autofill/hotkeys?enabled=' + (enabled ? '1' : '0') +
'&full_mods=' + f.mods + '&full_vk=' + f.vk +
'&pwd_mods=' + p.mods + '&pwd_vk=' + p.vk);
},
// Called by Delphi after a setAutofillHotkeys request, with true if
// BOTH combos registered successfully, false otherwise (clash with
// another app holding a global hotkey). Surfaces a toast.
onAutofillHotkeysResult(allOk) {
if (allOk) {
toast('Autofill hotkeys updated');
} else {
toast('Autofill: one or both hotkeys are already used by another app', 'warning');
}
},
// ---- Device-bound prefs (DPAPI-backed) ----------------------------
// localStorage is keyed by origin, and our HTTP port is random on
// every launch — so anything we put there is wiped at reboot. For
// prefs that need to survive a reboot (remembered username, etc.),
// round-trip through Delphi which persists via DPAPI.
getPref(key) {
if (!active) return Promise.resolve('');
return new Promise(resolve => {
prefResolvers[key] = resolve;
cmd('cmd://prefs/get?key=' + encodeURIComponent(key));
setTimeout(() => {
if (prefResolvers[key] === resolve) {
delete prefResolvers[key];
resolve('');
}
}, 2000);
});
},
setPref(key, value) {
if (!active) return;
cmd('cmd://prefs/set?key=' + encodeURIComponent(key) +
'&value=' + encodeURIComponent(value || ''));
},
onPrefResult(key, value) {
const r = prefResolvers[key];
if (r) {
delete prefResolvers[key];
r(value || '');
}
},
// ---- Start with Windows (HKCU Run registry) -----------------------
getAutoStart() {
if (!active) return Promise.resolve(false);
return new Promise(resolve => {
autoStartResolver = resolve;
cmd('cmd://autostart/get');
setTimeout(() => {
if (autoStartResolver === resolve) {
autoStartResolver = null;
resolve(false);
}
}, 2000);
});
},
setAutoStart(enabled) {
if (!active) return;
cmd('cmd://autostart/set?enabled=' + (enabled ? '1' : '0'));
},
onAutoStartStatus(enabled) {
if (autoStartResolver) {
const r = autoStartResolver;
autoStartResolver = null;
r(!!enabled);
}
state.autoStartEnabled = !!enabled;
const cb = document.getElementById('settingAutoStart');
if (cb) cb.checked = !!enabled;
},
// ---- Favicon fetch (via Delphi proxy → DuckDuckGo icons) ----------
// Returns a Promise<dataUri|''>. Multiple in-flight requests for
// distinct hosts are tracked per reqId so they can't collide.
fetchFavicon(host) {
if (!active) return Promise.resolve('');
if (!host) return Promise.resolve('');
const reqId = 'fav_' + (++_faviconReqSeq);
return new Promise(resolve => {
faviconResolvers[reqId] = resolve;
cmd('cmd://favicon/fetch?host=' + encodeURIComponent(host) +
'&reqId=' + encodeURIComponent(reqId));
setTimeout(() => {
if (faviconResolvers[reqId]) {
delete faviconResolvers[reqId];
resolve('');
}
}, 8000);
});
},
onFaviconResult(reqId, host, dataUri) {
const r = faviconResolvers[reqId];
if (r) {
delete faviconResolvers[reqId];
r(dataUri || '');
}
},
};
})();
// Expose Bridge on window so Delphi's ExecuteJavaScript can reach it.
window.Bridge = Bridge;
// ---- Global state ------------------------------------------
const state = {
token: sessionStorage.getItem('authToken') || '',
csrf: sessionStorage.getItem('csrfToken') || '',
salt: sessionStorage.getItem('salt') || '',
username: sessionStorage.getItem('username') || '',
// KDF iteration count of the currently-logged-in user. Cached so reauth
// and on-the-fly verifier computations don't need a /login/challenge
// round trip every time. Refreshed from every auth response.
kdfIterations: parseInt(sessionStorage.getItem('kdfIterations') || '0') || 0,
cryptoKey: null,
entries: [],
trashed: [],
trashedCount: 0, // server-side count, updated separately from state.trashed
folders: ['All'],
view: 'all', // 'all' | 'favorites' | 'folder:<name>' | 'tag:<name>' | 'trash'
search: '',
selectedId: null,
theme: localStorage.getItem('theme') || 'dark',
locked: false, // true after user clicks Lock (token still valid server-side)
autoLock: parseInt(localStorage.getItem('autoLockMin') || '5'),
askBeforeDelete: localStorage.getItem('askBeforeDelete') !== '0', // default true
maskUsernames: localStorage.getItem('maskUsernames') === '1', // default false
// Show the raw site/URL under the display name on cards. Default OFF
// because the display name is meant to be the user-friendly label;
// most users don't want the hostname cluttering the card layout.
showSiteOnCards: localStorage.getItem('showSiteOnCards') === '1', // default false
compactActions: localStorage.getItem('compactActions') === '1', // default false
viewMode: localStorage.getItem('viewMode') || 'cards', // 'cards' | 'list' | 'table'
// Sort criterion + direction. Defaults: alphabetical by display name —
// the most common pattern for a password manager (predictable lookup).
// Other values: 'updated' (last modified), 'created' (creation order),
// 'site' (raw site/URL, distinct from name when user set a title).
sortBy: localStorage.getItem('sortBy') || 'name',
sortDir: localStorage.getItem('sortDir') || 'asc',
pageSize: parseInt(localStorage.getItem('pageSize') || '25') || 25,
currentPage: 1,
checked: new Set(), // entry IDs checked for batch operations
hibpEnabled: localStorage.getItem('hibpEnabled') === '1', // default OFF
// entry.id → count from HIBP (0 = clean, >0 = pwned, undefined = unchecked)
hibpResults: new Map(),
quickUnlockEnabled: localStorage.getItem('quickUnlockEnabled') === '1',
recoveryConfigured: false, // refreshed by refreshRecoveryStatus on Settings open
autofillEnabled: localStorage.getItem('autofillEnabled') !== '0', // default ON
// Hotkey combos. Each combo = { ctrl, shift, alt, win, key }.
// key is the uppercase character or VK label ('A'..'Z', '0'..'9',
// 'F1'..'F12'). Default: Ctrl+Shift+L / Ctrl+Shift+P. Combos are
// local-by-default but ALSO synced via settings_json so they travel
// with the user (still Windows-only at runtime — non-Windows clients
// ignore the value).
autofillHotkeyFull: JSON.parse(localStorage.getItem('autofillHotkeyFull') ||
'{"ctrl":true,"shift":true,"alt":false,"win":false,"key":"L"}'),
autofillHotkeyPwd: JSON.parse(localStorage.getItem('autofillHotkeyPwd') ||
'{"ctrl":true,"shift":true,"alt":false,"win":false,"key":"P"}'),
sidebarCollapsed: JSON.parse(localStorage.getItem('sidebarCollapsed') ||
'{"folders":false,"tags":false,"tools":false}'),
// Fetch website favicons via the Delphi DuckDuckGo proxy. OFF by
// default — opt-in because it sends each entry's domain to a third
// party (DuckDuckGo). Synced because it's a portable preference.
faviconsEnabled: localStorage.getItem('faviconsEnabled') === '1',
};
// ============================================================
// CRYPTO (preserved from legacy app.js — DO NOT TOUCH)
// ============================================================
async function deriveKey(pwd, saltHex, iterations) {
// Iterations parameter is the per-user value returned by the server in
// the /login response (legacy users = 100000, modern = 600000). Falling
// back to 100000 keeps backwards compatibility with old code paths that
// didn't pass the value, but every new caller should pass it explicitly.
iterations = iterations || 100000;
const enc = new TextEncoder();
const km = await crypto.subtle.importKey('raw', enc.encode(pwd), 'PBKDF2', false, ['deriveKey']);
// saltHex is the same string that PHP/Delphi passed to PBKDF2 — use its bytes.
const sb = enc.encode(saltHex);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: sb, iterations: iterations, hash: 'SHA-256' },
km,
{ name: 'AES-GCM', length: 256 },
true, ['encrypt', 'decrypt']
);
}
// ---- Zero-knowledge auth helpers --------------------------------
//
// Single PBKDF2 → both outputs at once:
// - cryptoKey: the AES-GCM key used to encrypt entries (= raw PBKDF2 bytes)
// - verifier: the same 32 bytes in hex form, sent to the server in place
// of the plaintext master password. Server then SHA-256-wraps
// it (HASH_ALGO_CURRENT) or compares directly (LEGACY) without
// ever seeing the plaintext.
//
// Doing it together avoids running PBKDF2 twice. computeVerifier() is for
// places that only need the hex (re-auth, current-pw verification on change,
// etc.) and skips the AES-GCM importKey work.
function bytesToHex(arr) {
if (arr instanceof ArrayBuffer) arr = new Uint8Array(arr);
let hex = '';
for (let i = 0; i < arr.length; i++)
hex += arr[i].toString(16).padStart(2, '0');
return hex;
}
async function deriveKeyAndVerifier(pwd, saltHex, iterations) {
iterations = iterations || 100000;
const enc = new TextEncoder();
const km = await crypto.subtle.importKey(
'raw', enc.encode(pwd), 'PBKDF2', false, ['deriveBits']);
const bits = await crypto.subtle.deriveBits(
{ name: 'PBKDF2', salt: enc.encode(saltHex),
iterations: iterations, hash: 'SHA-256' },
km, 256); // 256 bits = 32 bytes — matches PBKDF2_SHA256_Hex output
const keyBytes = new Uint8Array(bits);
const cryptoKey = await crypto.subtle.importKey(
'raw', keyBytes, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']);
return { cryptoKey, verifier: bytesToHex(keyBytes) };
}
async function computeVerifier(pwd, saltHex, iterations) {
const r = await deriveKeyAndVerifier(pwd, saltHex, iterations);
return r.verifier;
}
async function encryptPwd(plain) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const enc = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv }, state.cryptoKey,
new TextEncoder().encode(plain)
);
return {
encrypted: btoa(String.fromCharCode(...new Uint8Array(enc))),
iv: btoa(String.fromCharCode(...iv)),
};
}
async function decryptPwd(encB64, ivB64) {
try {
const enc = Uint8Array.from(atob(encB64), c => c.charCodeAt(0));
const iv = Uint8Array.from(atob(ivB64), c => c.charCodeAt(0));
const dec = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, state.cryptoKey, enc);
return new TextDecoder().decode(dec);
} catch (e) {
return '[ERROR]';
}
}
async function persistCryptoKey() {
const raw = await crypto.subtle.exportKey('raw', state.cryptoKey);
sessionStorage.setItem('cryptoKey', btoa(String.fromCharCode(...new Uint8Array(raw))));
}
async function restoreCryptoKey() {
const saved = sessionStorage.getItem('cryptoKey');
if (!saved) return false;
try {
const raw = Uint8Array.from(atob(saved), c => c.charCodeAt(0));
state.cryptoKey = await crypto.subtle.importKey(
'raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']
);
return true;
} catch (e) {
return false;
}
}
// ============================================================
// HTTP HELPERS
// ============================================================
function authHeaders(extra) {
const h = Object.assign({ 'Authorization': 'Bearer ' + state.token }, extra || {});
if (state.csrf) h['X-CSRF-Token'] = state.csrf;
return h;
}
async function api(path, opts) {
opts = opts || {};
const r = await fetch(API + path, opts);
let body = null;
try { body = await r.json(); } catch (e) { body = {}; }
if (!r.ok) {
// Preserve status + body on the Error so callers can distinguish
// 429-with-retry_after (account lockout) from a generic auth error.
const err = new Error(body.error || ('HTTP ' + r.status));
err.status = r.status;
err.body = body || {};
throw err;
}
return body;
}
// ============================================================
// TOTP (RFC 6238) — 6-digit time-based codes
// ============================================================
//
// Implementation is pure crypto.subtle (HMAC-SHA1) + a small base32
// decoder. No external library. The secret is stored encrypted with the
// vault's AES-GCM key (same flow as passwords), so the server never sees
// the plaintext base32 secret.
// Decode an RFC 4648 base32 string (Google Authenticator format) to bytes.
// Tolerates lowercase, spaces, and padding. Throws on invalid characters.
function base32Decode(s) {
const ALPH = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const clean = String(s).toUpperCase().replace(/[\s=]/g, '');
let bits = 0, buffer = 0;
const out = [];
for (const ch of clean) {
const v = ALPH.indexOf(ch);
if (v < 0) throw new Error('Invalid base32 character: ' + ch);
buffer = (buffer << 5) | v;
bits += 5;
if (bits >= 8) {
bits -= 8;
out.push((buffer >> bits) & 0xFF);
}
}
return new Uint8Array(out);
}
// Generate a TOTP code per RFC 6238. Returns the 6-digit code as a string
// (zero-padded) along with how many seconds remain in the current 30s window.
// Throws if the secret can't be decoded.
async function generateTOTP(secretBase32, period, digits) {
period = period || 30;
digits = digits || 6;
const keyBytes = base32Decode(secretBase32);
// Counter = floor(unix_time / period), encoded as 8-byte big-endian.
const nowSec = Math.floor(Date.now() / 1000);
let counter = Math.floor(nowSec / period);
const counterBytes = new Uint8Array(8);
for (let i = 7; i >= 0; i--) {
counterBytes[i] = counter & 0xFF;
counter = Math.floor(counter / 256);
}
const cryptoKey = await crypto.subtle.importKey(
'raw', keyBytes,
{ name: 'HMAC', hash: 'SHA-1' },
false, ['sign']
);
const sigBuf = await crypto.subtle.sign('HMAC', cryptoKey, counterBytes);
const sig = new Uint8Array(sigBuf);
// Dynamic truncation: low nibble of last byte = offset into HMAC output.
const offset = sig[sig.length - 1] & 0x0F;
const truncated =
((sig[offset] & 0x7F) << 24) |
((sig[offset + 1] & 0xFF) << 16) |
((sig[offset + 2] & 0xFF) << 8) |
( sig[offset + 3] & 0xFF);
const mod = Math.pow(10, digits);
const code = String(truncated % mod).padStart(digits, '0');
return {
code: code,
period: period,
secondsLeft: period - (nowSec % period),
};
}
// Parse a Google Authenticator-style otpauth:// URI and extract the secret.
// Example: otpauth://totp/Example:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example
// Returns the secret alone (we don't yet honor issuer/algorithm/digits/period
// overrides — assume SHA-1 / 6 digits / 30s, which covers ~all real services).
function parseOtpAuthUri(raw) {
raw = String(raw || '').trim();
if (!raw.toLowerCase().startsWith('otpauth://')) return null;
try {
const u = new URL(raw);
const sec = u.searchParams.get('secret');
return sec ? sec.trim() : null;
} catch (e) { return null; }
}
// Encrypt a TOTP secret with the vault key. Returns { encrypted, iv } in
// the same base64 shape as encryptPwd, ready to send to the server.
async function encryptTotpSecret(secretBase32) {
return await encryptPwd(secretBase32); // same crypto, just different field
}
async function decryptTotpSecret(encB64, ivB64) {
return await decryptPwd(encB64, ivB64);
}
// ============================================================
// FAVICONS (opt-in, cached server-side as base64 data URI)
// ============================================================
// Extract a usable host from entry.site (we accept anything user-typed).
// Returns '' for values that don't look like real hostnames — common case
// is users storing a brand label ("Gitea", "Work GitHub") to help the
// autofill matcher. Sending those to DDG would leak meaningless tokens
// without ever producing an icon.
function faviconHost(siteRaw) {
if (!siteRaw) return '';
let s = String(siteRaw).trim().toLowerCase();
s = s.replace(/^https?:\/\//, '').replace(/^www\./, '');
s = s.split('/')[0].split(':')[0];
// Validate: dot-separated labels, only hostname-safe chars, TLD ≥ 2
// letters. Rejects "Gitea", "my work pwd", IP-like "1.2.3.4" stays
// valid (DDG handles IPs gracefully). 253-char overall cap mirrors
// the DNS spec.
if (!s || s.length > 253) return '';
if (!/^[a-z0-9.-]+$/.test(s)) return '';
if (s.indexOf('.') < 1) return '';
if (!/\.[a-z]{2,}$/.test(s)) return '';
if (s.startsWith('.') || s.endsWith('.')) return '';
if (s.indexOf('..') >= 0) return '';
return s;
}
// Save the icon for one entry via the dedicated endpoint (no full PUT,
// no re-encryption). Fire-and-forget: failures are silent so a flaky
// network doesn't break the user's flow.
async function saveEntryIcon(entryId, dataUri) {
try {
await fetch(API + '/entries/' + entryId + '/icon', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ icon_b64: dataUri || '' }),
});
} catch (e) { /* silent */ }
}
// Fetch + save the favicon for one entry. Updates state.entries in-place
// so the next render() picks it up. No-op if the entry already has one.
// opts: { force: bypass "already has icon" skip, manual: bypass the global
// faviconsEnabled toggle (for explicit user actions like the Refresh button) }
async function ensureEntryFavicon(entry, opts) {
opts = opts || {};
if (!Bridge.active) return;
if (!opts.manual && !state.faviconsEnabled) return;
if (!opts.force && entry.icon_b64) return;
const host = faviconHost(entry.site);
if (!host) return;
const dataUri = await Bridge.fetchFavicon(host);
if (!dataUri) return;
entry.icon_b64 = dataUri;
await saveEntryIcon(entry.id, dataUri);
// Full render() — patching the avatar in place is fragile because
// the avatar also contains the checkbox overlay.
render();
}
// Backfill: walk state.entries, fetch missing icons one at a time so we
// don't hammer the upstream. Used by the "Refresh icons" button.
async function backfillFavicons(force) {
if (!Bridge.active) return;
const all = state.entries;
const eligible = all.filter(e => faviconHost(e.site));
const skipped = all.length - eligible.length;
const targets = eligible.filter(e => force || !e.icon_b64);
if (targets.length === 0) {
if (skipped > 0) {
toast('No icons to fetch — ' + skipped +
' entries have a non-domain site (e.g. "Gitea")', 'warning');
} else {
toast('No icons to fetch');
}
return;
}
toast('Fetching ' + targets.length + ' icon' + (targets.length === 1 ? '' : 's') + '…');
let ok = 0;
for (const e of targets) {
// Explicit user action — bypass the global toggle so the buttons
// work even when "Fetch website icons" is OFF (the toggle only
// gates auto-fetch on save).
await ensureEntryFavicon(e, { force: !!force, manual: true });
if (e.icon_b64) ok++;
}
toast('Fetched ' + ok + ' / ' + targets.length + ' icons');
render();
}
async function clearAllFavicons() {
try {
await fetch(API + '/entries/icons/all', {
method: 'DELETE',
headers: authHeaders(),
});
} catch (e) {
toast('Failed to clear icons', 'error');
return;
}
state.entries.forEach(e => { e.icon_b64 = null; });
render();
toast('Cached icons cleared');
}
// Generate a cryptographically random RFC 4648 base32 secret. 20 bytes =
// 160 bits → 32 base32 chars, RFC 6238 §5.1 recommended TOTP key size.
function randomBase32Secret(numBytes) {
numBytes = numBytes || 20;
const ALPH = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const bytes = crypto.getRandomValues(new Uint8Array(numBytes));
let bits = 0, buffer = 0, out = '';
for (let i = 0; i < bytes.length; i++) {
buffer = (buffer << 8) | bytes[i];
bits += 8;
while (bits >= 5) {
bits -= 5;
out += ALPH[(buffer >> bits) & 0x1F];
}
}
if (bits > 0) out += ALPH[(buffer << (5 - bits)) & 0x1F];
return out;
}
// ---- Standalone TOTP generator modal (paste secret → live code) -----
let totpToolTimer = null;
function openTotpTool() {
const modal = document.getElementById('totpToolModal');
const input = document.getElementById('totpToolSecret');
const codeEl = document.getElementById('totpToolCode');
const barEl = document.getElementById('totpToolBar');
const errEl = document.getElementById('totpToolError');
modal.classList.remove('is-hidden');
input.value = '';
codeEl.textContent = '— — — — — —';
barEl.style.width = '100%';
errEl.style.display = 'none';
setTimeout(() => input.focus(), 0);
async function tick() {
let secret = input.value.trim();
if (!secret) {
codeEl.textContent = '— — — — — —';
barEl.style.width = '100%';
errEl.style.display = 'none';
return;
}
if (secret.toLowerCase().startsWith('otpauth://')) {
const fromUri = parseOtpAuthUri(secret);
if (fromUri) { secret = fromUri; input.value = fromUri; }
}
try {
const t = await generateTOTP(secret);
codeEl.textContent = t.code.replace(/(\d{3})(\d{3})/, '$1 $2');
const newPct = (t.secondsLeft / 30) * 100;
if (newPct > (tick._lastPct || 0) + 5) {
barEl.style.transition = 'none';
barEl.style.width = newPct.toFixed(1) + '%';
void barEl.offsetWidth;
barEl.style.transition = '';
} else {
barEl.style.width = newPct.toFixed(1) + '%';
}
tick._lastPct = newPct;
errEl.style.display = 'none';
} catch (e) {
codeEl.textContent = '— — — — — —';
barEl.style.width = '0%';
errEl.textContent = 'Invalid base32 secret';
errEl.style.display = '';
}
}
input.addEventListener('input', tick);
if (totpToolTimer) clearInterval(totpToolTimer);
totpToolTimer = setInterval(tick, 1000);
document.getElementById('totpToolCopy').onclick = async () => {
const code = codeEl.textContent.replace(/\s/g, '');
if (!/^\d{6}$/.test(code)) return;
if (Bridge.active) Bridge.copySecure(code, 30000);
else { try { await navigator.clipboard.writeText(code); } catch (e) {} }
toast('TOTP code copied');
};
document.getElementById('totpToolGen').onclick = () => {
input.value = randomBase32Secret(20);
tick();
toast('Random secret generated');
};
document.getElementById('totpToolCopySecret').onclick = async () => {
const s = input.value.trim();
if (!s) return;
if (Bridge.active) Bridge.copySecure(s, 30000);
else { try { await navigator.clipboard.writeText(s); } catch (e) {} }
toast('Secret copied');
};
modal.querySelectorAll('[data-close]').forEach(b => {
b.onclick = () => closeTotpTool();
});
}
function closeTotpTool() {
document.getElementById('totpToolModal').classList.add('is-hidden');
if (totpToolTimer) { clearInterval(totpToolTimer); totpToolTimer = null; }
}
// ============================================================
// HIBP — Have I Been Pwned breach check (k-anonymity)
// ============================================================
//
// HIBP's range API exposes pwned password counts without ever seeing the
// password (or even its full hash):
// 1. Client computes SHA-1 of the password.
// 2. Client sends ONLY the first 5 hex chars to api.pwnedpasswords.com/range/XXXXX
// 3. Server returns up to ~500 suffixes (35 chars each) with counts.
// 4. Client searches the response for its own suffix locally.
//
// This means the network observer (and HIBP itself) sees only the 5-char
// prefix — which matches ~3,000 of the ~half-billion known pwned passwords.
// Information leakage is bounded by design.
//
// Toggle is OFF by default. When enabled, all entries are checked once
// after vault load, then individual entries are re-checked when the user
// edits the password. Results cached in state.hibpResults keyed by entry id.
async function sha1Hex(text) {
const buf = new TextEncoder().encode(text);
const hashBuf = await crypto.subtle.digest('SHA-1', buf);
const bytes = new Uint8Array(hashBuf);
let hex = '';
for (const b of bytes) hex += b.toString(16).padStart(2, '0');
return hex.toUpperCase();
}
// Returns the breach count (0 if not found, >0 if pwned). Throws on
// network failure — caller decides whether to silently skip or alert.
async function hibpCheckPassword(plaintext) {
if (!plaintext) return 0;
const hash = await sha1Hex(plaintext);
const prefix = hash.substring(0, 5);
const suffix = hash.substring(5);
const resp = await fetch('https://api.pwnedpasswords.com/range/' + prefix, {
// Padding mitigates side-channel attacks where an observer counts
// bytes in the response to narrow down the prefix queried.
headers: { 'Add-Padding': 'true' },
});
if (!resp.ok) throw new Error('HIBP HTTP ' + resp.status);
const body = await resp.text();
// Body lines: "SUFFIX:COUNT\r\n" — search for our suffix.
for (const line of body.split('\n')) {
const colonAt = line.indexOf(':');
if (colonAt <= 0) continue;
if (line.substring(0, colonAt).trim() === suffix) {
return parseInt(line.substring(colonAt + 1).trim(), 10) || 0;
}
}
return 0;
}
// Batch-check every entry currently in state.entries. Awaits all in
// parallel but with a small concurrency cap so we don't hammer HIBP
// or trip browser connection limits. Mutates state.hibpResults and
// re-renders to show the new badges.
async function hibpCheckAllEntries() {
if (!state.hibpEnabled || !state.entries.length) return;
const CONCURRENCY = 6;
const queue = state.entries.slice();
const workers = [];
for (let w = 0; w < CONCURRENCY; w++) {
workers.push((async () => {
while (queue.length) {
const entry = queue.shift();
try {
const pwd = await decryptPwd(entry.encrypted_password, entry.iv);
if (pwd === '[ERROR]') continue;
const count = await hibpCheckPassword(pwd);
state.hibpResults.set(entry.id, count);
} catch (e) {
// Network or decrypt failure: skip silently. Will retry
// next time the user opens the vault.
}
}
})());
}
await Promise.all(workers);
render();
}
// ============================================================
// KDF MIGRATION (PBKDF2 100k → 600k re-encryption)
// ============================================================
//
// When the server signals kdfMigration in /login or /reauth, we transparently
// re-encrypt every entry with a stronger key (600k PBKDF2 iterations) and
// commit the new ciphertext + the new server-side hash in one atomic
// /migrate-kdf request. If anything fails, the user stays on the legacy
// config and the migration retries at next login. The entries currently
// loaded in state.entries are encrypted with the OLD key (state.cryptoKey).
//
// Threading: runs in the background after enterApp completes. Locking the
// vault during migration is safe — we just lose the in-flight transition
// and the server's atomic rollback means nothing persisted.
let kdfMigrationInProgress = false;
async function runKdfMigration(masterPwd, fromIters, toIters) {
if (kdfMigrationInProgress) return; // dedupe concurrent calls
if (!state.entries || !state.cryptoKey) return;
kdfMigrationInProgress = true;
try {
// Two distinct migration scenarios:
// A. fromIters !== toIters: KDF iteration count is changing, so
// the AES key is changing. We re-encrypt every entry with the
// new key + fresh IVs, swap state.cryptoKey at the end.
// B. fromIters === toIters: same KDF, only the server-side hash
// format is being upgraded (legacy "pbkdf2" raw → "pbkdf2-sha256"
// wrapped). No entry re-encryption needed — just trigger the
// endpoint so the server rewrites the user row.
const kdfChange = fromIters !== toIters;
let newKey, newCiphertexts;
if (kdfChange) {
newKey = await deriveKey(masterPwd, state.salt, toIters);
// Re-encrypt every entry. Each entry gets a fresh random IV
// under the new key — never reuse the old IV with the new key
// (would be pointless but also a small information leak via IV
// reuse patterns).
newCiphertexts = [];
for (const entry of state.entries) {
const plain = await decryptPwd(entry.encrypted_password, entry.iv);
if (plain === '[ERROR]') {
// One decrypt failure aborts the whole migration —
// better to stay on the legacy config than commit
// partial state.
throw new Error('Could not decrypt entry id=' + entry.id);
}
const tmpKey = state.cryptoKey;
try {
state.cryptoKey = newKey;
const re = await encryptPwd(plain);
newCiphertexts.push({
id: entry.id,
encrypted_password: re.encrypted,
iv: re.iv,
});
} finally {
state.cryptoKey = tmpKey; // restore for any concurrent read
}
}
} else {
// Hash-format-only upgrade — server still wants an entries
// array (it's an idempotent transactional update), just empty.
newCiphertexts = [];
}
// Zero-knowledge: compute BOTH verifiers locally. oldVerifier proves
// the user knows the master pw under the current (legacy) iters;
// newVerifier is what the server will SHA-256-wrap to be the new
// stored hash after migration. Master pw never leaves the browser.
const oldVerifier = await computeVerifier(masterPwd, state.salt, fromIters);
const newVerifier = await computeVerifier(masterPwd, state.salt, toIters);
await api('/migrate-kdf', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
oldVerifier: oldVerifier,
newVerifier: newVerifier,
entries: newCiphertexts,
}),
});
if (kdfChange) {
// Swap to the new AES key + update cached ciphertexts.
state.cryptoKey = newKey;
state.kdfIterations = toIters;
sessionStorage.setItem('kdfIterations', String(toIters));
await persistCryptoKey();
for (let i = 0; i < state.entries.length; i++) {
const nc = newCiphertexts[i];
state.entries[i].encrypted_password = nc.encrypted_password;
state.entries[i].iv = nc.iv;
}
toast('Vault security upgraded (' + fromIters.toLocaleString() +
' → ' + toIters.toLocaleString() + ' KDF iterations)');
} else {
// Format-only upgrade is silent — the user didn't perceive a
// weakness change, and nothing visible in the UI changed.
// (A subtle "Auth format upgraded" toast felt noisy.)
}
} catch (err) {
// Silent retry on next login — the migration is idempotent and
// safe to abandon (server rolled back).
console.warn('KDF migration aborted:', err);
} finally {
kdfMigrationInProgress = false;
}
}
// ============================================================
// ACCOUNT LOCKOUT UI
// ============================================================
let lockoutTimer = null;
// Called when the backend responds with 429 + retry_after on /login or
// /reauth. Disables the auth form and displays a live countdown in
// #authHint. When the countdown reaches 0, the form is re-enabled.
function showLockoutCountdown(seconds) {
if (lockoutTimer) { clearInterval(lockoutTimer); lockoutTimer = null; }
const hint = $('#authHint');
const btn = $('#loginBtn');
const fmt = (s) => {
if (s >= 3600) return Math.ceil(s / 3600) + ' h';
if (s >= 60) return Math.ceil(s / 60) + ' min';
return s + ' s';
};
const tick = () => {
if (seconds <= 0) {
clearInterval(lockoutTimer); lockoutTimer = null;
if (hint) hint.textContent = 'You can try again now.';
if (btn) btn.disabled = false;
return;
}
if (hint) hint.textContent = 'Account locked — try again in ' + fmt(seconds);
seconds--;
};
if (btn) btn.disabled = true;
tick(); // show first frame immediately
lockoutTimer = setInterval(tick, 1000);
}
// ============================================================
// TOAST
// ============================================================
function toast(msg, type) {
type = type || 'success';
const container = $('#toastContainer');
const t = document.createElement('div');
t.className = 'toast is-' + type;
t.textContent = msg;
container.appendChild(t);
setTimeout(() => t.remove(), 2800);
}
// ============================================================
// DOM HELPERS
// ============================================================
function $(sel, root) { return (root || document).querySelector(sel); }
function $$(sel, root) { return Array.from((root || document).querySelectorAll(sel)); }
function el(tag, props, ...kids) {
const e = document.createElement(tag);
if (props) for (const k in props) {
if (k === 'class') e.className = props[k];
else if (k === 'on') for (const ev in props.on) e.addEventListener(ev, props.on[ev]);
else if (k === 'html') e.innerHTML = props[k];
else if (k in e) e[k] = props[k];
else e.setAttribute(k, props[k]);
}
for (const k of kids) {
if (k == null) continue;
e.appendChild(typeof k === 'string' ? document.createTextNode(k) : k);
}
return e;
}
function icon(id) {
const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
const u = document.createElementNS('http://www.w3.org/2000/svg', 'use');
u.setAttribute('href', '#' + id);
s.appendChild(u);
return s;
}
// ============================================================
// AUTH
// ============================================================
async function doLogin(e) {
e && e.preventDefault();
const u = $('#loginUsername').value.trim();
const p = $('#loginPassword').value;
if (!u || !p) return;
// If we are in locked mode (token still valid), try fast unlock first.
if (state.locked && state.token && state.salt && u === state.username) {
$('#loginBtn').disabled = true;
const ok = await doUnlock(p);
$('#loginBtn').disabled = false;
if (ok) return;
// unlock failed — fall through to a full login
}
$('#loginBtn').disabled = true;
try {
// Zero-knowledge: ask the server for the user's salt + iter count,
// compute the verifier locally, send only the verifier. Master pw
// never leaves the browser.
const ch = await api('/login/challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: u }),
});
const derived = await deriveKeyAndVerifier(p, ch.salt, ch.kdfIterations);
const r = await api('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: u, verifier: derived.verifier }),
});
state.token = r.token;
state.csrf = r.csrfToken;
state.salt = r.salt;
state.username = u;
state.kdfIterations = r.kdfIterations || ch.kdfIterations;
sessionStorage.setItem('authToken', state.token);
sessionStorage.setItem('csrfToken', state.csrf);
sessionStorage.setItem('salt', state.salt);
sessionStorage.setItem('username', state.username);
sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
// Persist via DPAPI when running inside the Delphi host (localStorage
// is wiped on each restart because the HTTP port — and therefore the
// origin — changes every launch). Fall back to localStorage for the
// web/PHP frontend where the origin is stable.
const remember = $('#loginRememberUser').checked;
if (Bridge.active) {
Bridge.setPref('rememberedUsername', remember ? u : '');
} else {
if (remember) localStorage.setItem('rememberedUsername', u);
else localStorage.removeItem('rememberedUsername');
}
// cryptoKey is already derived — no second PBKDF2 pass.
state.cryptoKey = derived.cryptoKey;
state.justRecovered = false;
await persistCryptoKey();
toast('Welcome back, ' + u);
await enterApp();
// Trigger KDF migration AFTER entries are loaded into state.
if (r.kdfMigration && r.kdfMigration.target) {
runKdfMigration(p, r.kdfIterations, r.kdfMigration.target);
}
} catch (err) {
// 429 with retry_after = account lockout. Show countdown in the
// auth hint instead of a generic error toast, and keep the login
// button disabled until the lockout expires.
if (err.status === 429 && err.body && err.body.retry_after) {
showLockoutCountdown(err.body.retry_after);
return; // do NOT re-enable the button in finally
}
// Mask "Unknown user" from /login/challenge as a generic credentials
// failure — keeps user-existence enumeration consistent with the
// existing /login behavior.
if (err.status === 404) {
toast('Invalid credentials', 'error');
} else {
toast(err.message, 'error');
}
} finally {
// Only re-enable when not in lockout (showLockoutCountdown manages
// the button itself for the lockout case).
if (!lockoutTimer) $('#loginBtn').disabled = false;
}
}
async function doRegister(e) {
e && e.preventDefault();
const u = $('#regUsername').value.trim();
const p = $('#regPassword').value;
if (u.length < 3 || p.length < 8) return toast('Min 3 / 8 chars', 'error');
$('#registerBtn').disabled = true;
try {
// Zero-knowledge register: client generates salt + iters, computes
// the verifier locally, sends only the verifier. Master pw never
// leaves the browser.
const newSalt = randomHexSalt();
const newIters = 600000;
const derived = await deriveKeyAndVerifier(p, newSalt, newIters);
const r = await api('/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: u,
salt: newSalt,
kdfIterations: newIters,
verifier: derived.verifier,
}),
});
state.token = r.token;
state.csrf = r.csrfToken;
state.salt = r.salt || newSalt;
state.username = u;
state.kdfIterations = r.kdfIterations || newIters;
sessionStorage.setItem('authToken', state.token);
sessionStorage.setItem('csrfToken', state.csrf);
sessionStorage.setItem('salt', state.salt);
sessionStorage.setItem('username', state.username);
sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
state.cryptoKey = derived.cryptoKey;
await persistCryptoKey();
toast('Vault created');
await enterApp();
} catch (err) {
toast(err.message, 'error');
} finally {
$('#registerBtn').disabled = false;
}
}
async function doLogout() {
try { await api('/logout', { method: 'POST', headers: authHeaders() }); } catch (e) {}
// Explicit logout → wipe the DPAPI quick-unlock blob too. Logout is the
// user saying "I'm done", quite different from a passive lock.
if (Bridge.active && localStorage.getItem('quickUnlockEnabled') === '1') {
window.location.href = 'cmd://quickunlock/clear';
localStorage.removeItem('quickUnlockEnabled');
state.quickUnlockEnabled = false;
}
sessionStorage.clear();
state.token = ''; state.csrf = ''; state.salt = ''; state.username = '';
state.kdfIterations = 0;
state.cryptoKey = null; state.entries = []; state.trashed = []; state.folders = ['All'];
state.locked = false;
showAuth();
$('#loginUsername').value = '';
$('#loginPassword').value = '';
$('#loginUsername').readOnly = false;
$('#authHint').textContent = '';
}
// Lock: do NOT hit /logout — keep server session alive, just drop the in-memory
// crypto key. On unlock, /reauth validates the master password and we re-derive.
function lockVault() {
sessionStorage.removeItem('cryptoKey');
state.cryptoKey = null;
state.entries = [];
state.trashed = [];
state.locked = true;
state.justRecovered = false;
if (typeof authTickTimer !== 'undefined' && authTickTimer) {
clearInterval(authTickTimer); authTickTimer = null;
}
if (typeof totpToolTimer !== 'undefined' && totpToolTimer) {
clearInterval(totpToolTimer); totpToolTimer = null;
}
if (typeof healthCache !== 'undefined') healthCache = null;
showAuth();
// Two UI variants for the auth screen:
// - We know the username (user was logged in before lock) →
// pre-fill it as readonly so the user only types the master pw.
// - We don't know the username (lock fired before any login — e.g.
// tray "Lock vault" clicked on a fresh session, or Win+L right
// after launch) → show a normal fresh login (editable username).
if (state.username) {
$('#loginUsername').value = state.username;
$('#loginUsername').readOnly = true;
$('#authHint').textContent = 'Vault locked — enter master password to unlock';
$('#loginPassword').focus();
} else {
$('#loginUsername').value = '';
$('#loginUsername').readOnly = false;
$('#authHint').textContent = '';
$('#loginUsername').focus();
}
$('#loginPassword').value = '';
}
// Unlock flow: validate master pw via /reauth (which uses current session),
// then re-derive the crypto key locally without rotating session/csrf.
async function doUnlock(p) {
try {
// Compute the verifier locally with the salt+iters cached at login.
// Server compares verifier → never sees the plaintext master pw.
const iters = state.kdfIterations || 100000;
const derived = await deriveKeyAndVerifier(p, state.salt, iters);
const r = await api('/reauth', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ verifier: derived.verifier }),
});
// Refresh cached iter count in case the server has migrated us.
state.kdfIterations = r.kdfIterations || iters;
sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
state.cryptoKey = derived.cryptoKey;
await persistCryptoKey();
state.locked = false;
$('#loginUsername').readOnly = false;
$('#authHint').textContent = '';
toast('Unlocked');
await enterApp();
if (r.kdfMigration && r.kdfMigration.target) {
runKdfMigration(p, r.kdfIterations, r.kdfMigration.target);
}
return true;
} catch (err) {
// Account lockout (too many wrong master pw attempts): show
// countdown in the auth hint, keep the form disabled.
if (err.status === 429 && err.body && err.body.retry_after) {
showLockoutCountdown(err.body.retry_after);
return false;
}
if (err.message === 'Invalid password') {
toast('Wrong master password', 'error');
} else {
// session expired — fall back to full login
sessionStorage.clear();
state.token = ''; state.csrf = ''; state.salt = '';
state.locked = false;
$('#loginUsername').readOnly = false;
$('#authHint').textContent = 'Session expired, please sign in again';
toast('Session expired', 'warning');
}
return false;
}
}
// ============================================================
// DATA LOADING
// ============================================================
async function loadFolders() {
try {
const r = await api('/folders', { headers: authHeaders() });
// 'All' is always implicit first
state.folders = ['All'].concat(r.filter(n => n !== 'All'));
} catch (e) { /* ignore */ }
}
async function loadEntries() {
try {
const r = await api('/entries', { headers: authHeaders() });
state.entries = Array.isArray(r) ? r : [];
} catch (e) {
if (e.message === 'Invalid session' || e.message === 'Session expired') {
return doLogout();
}
toast(e.message, 'error');
}
}
async function loadTrash() {
try {
const r = await api('/entries?deleted=1', { headers: authHeaders() });
state.trashed = Array.isArray(r) ? r : [];
state.trashedCount = state.trashed.length;
} catch (e) { state.trashed = []; }
}
async function loadEntryCounts() {
try {
const r = await api('/entries/count', { headers: authHeaders() });
if (r && typeof r.trashed === 'number') state.trashedCount = r.trashed;
} catch (e) { /* silent — sidebar count just stays 0 */ }
}
// ============================================================
// FILTERS / DERIVED
// ============================================================
function filteredEntries() {
// Trash view shows its own list (loaded separately)
let list;
if (state.view === 'trash') {
list = state.trashed;
} else {
list = state.entries;
if (state.view === 'favorites') list = list.filter(e => e.favorite);
else if (state.view.startsWith('folder:')) {
const f = state.view.slice(7);
// 'folder:All' is now the "(no folder)" pseudo-entry → filter
// to entries that are uncategorized (folder is 'All', empty,
// or absent). All other folder names are exact-matched.
list = list.filter(e => (e.folder || 'All') === f);
} else if (state.view.startsWith('tag:')) {
const t = state.view.slice(4);
list = list.filter(e => parseTags(e.tags).includes(t));
}
}
if (state.search) {
const q = state.search.toLowerCase();
list = list.filter(e =>
(e.site || '').toLowerCase().includes(q) ||
(e.title || '').toLowerCase().includes(q) ||
(e.username || '').toLowerCase().includes(q) ||
(e.tags || '').toLowerCase().includes(q)
);
}
// Trash view keeps the deletion order (newest first) — re-sorting feels
// wrong for a recoverable archive. All other views honour user choice.
if (state.view !== 'trash') {
list = sortEntries(list, state.sortBy, state.sortDir);
}
return list;
}
function parseTags(s) {
if (!s) return [];
return s.split(',').map(t => t.trim()).filter(Boolean);
}
function allTags() {
const set = new Set();
state.entries.forEach(e => parseTags(e.tags).forEach(t => set.add(t)));
return Array.from(set).sort();
}
function viewTitle() {
if (state.view === 'all') return 'All items';
if (state.view === 'favorites') return 'Favorites';
if (state.view === 'trash') return 'Trash';
if (state.view === 'authenticator') return 'Authenticator';
if (state.view === 'health') return 'Vault health';
if (state.view.startsWith('folder:')) return state.view.slice(7);
if (state.view.startsWith('tag:')) return '# ' + state.view.slice(4);
return 'Items';
}
// ============================================================
// RENDER
// ============================================================
function render() {
renderSidebar();
renderGrid();
}
function renderSidebar() {
// counts
$('#countAll').textContent = state.entries.length;
$('#countFav').textContent = state.entries.filter(e => e.favorite).length;
// Prefer the server-side count (always up-to-date even if user never
// navigated to Trash this session) ; fall back to local array length.
const trashN = state.trashedCount || state.trashed.length || 0;
$('#countTrash').textContent = trashN || '';
// Section totals — shown next to the section header so the user still
// sees the count when the section is collapsed.
const folderCount = state.folders.filter(n => n !== 'All').length;
const tagCount = allTags().length;
const fc = document.getElementById('countFolders');
const tc = document.getElementById('countTags');
if (fc) fc.textContent = String(folderCount);
if (tc) tc.textContent = String(tagCount);
// active state for top-level items
$$('#appShell .nav-item[data-view]').forEach(n => {
n.classList.toggle('is-active', n.dataset.view === state.view);
});
// folders — hide the special "All" container (it doubles up with the
// "All items" view in the top nav and confuses users with two "All"
// entries that count different sets). Entries with folder='All' are
// still reachable via "All items".
const fList = $('#foldersList');
fList.innerHTML = '';
state.folders.filter(n => n !== 'All').forEach(name => {
const count = state.entries.filter(e => e.folder === name).length;
const key = 'folder:' + name;
const item = el('button', {
class: 'nav-item' + (state.view === key ? ' is-active' : ''),
'data-folder': name,
on: { click: () => setView(key) },
});
item.appendChild(icon('i-folder'));
item.appendChild(el('span', null, name));
item.appendChild(el('span', { class: 'nav-count' }, String(count)));
// drag and drop target
item.addEventListener('dragover', e => { e.preventDefault(); item.classList.add('drag-over'); });
item.addEventListener('dragleave', () => item.classList.remove('drag-over'));
item.addEventListener('drop', async e => {
e.preventDefault();
item.classList.remove('drag-over');
const id = e.dataTransfer.getData('text/plain');
if (id) await moveEntryToFolder(parseInt(id), name);
});
const delBtn = el('button', {
class: 'folder-delete',
type: 'button',
title: 'Delete folder',
on: { click: ev => { ev.stopPropagation(); deleteFolder(name, count); } },
});
delBtn.appendChild(icon('i-x'));
item.appendChild(delBtn);
fList.appendChild(item);
});
// "(no folder)" pseudo-entry: filters to entries with no real folder
// (folder is empty or the default "All"). Skipped when there are zero
// such entries so the sidebar stays clean for organised users.
// No folder icon — visually communicates "this is the absence of a
// folder, not a folder". Drag target so users can quickly uncategorise.
const uncatCount = state.entries.filter(e => !e.folder || e.folder === 'All').length;
if (uncatCount > 0) {
const key = 'folder:All';
const item = el('button', {
class: 'nav-item is-uncategorized' + (state.view === key ? ' is-active' : ''),
'data-folder': 'All',
on: { click: () => setView(key) },
});
// Spacer instead of the folder icon — keeps alignment with real
// folders without implying "this is a folder". MUST NOT be a <span>
// because .nav-item > span:first-of-type { flex: 1 } would target
// the spacer instead of the label and push the count off to the
// right. Other nav-items have <svg> as their first child, so the
// label span naturally wins :first-of-type; we mimic that by
// making the spacer a non-span element.
item.appendChild(el('i', { class: 'nav-icon-spacer' }));
item.appendChild(el('span', null, '(no folder)'));
item.appendChild(el('span', { class: 'nav-count' }, String(uncatCount)));
item.addEventListener('dragover', e => { e.preventDefault(); item.classList.add('drag-over'); });
item.addEventListener('dragleave', () => item.classList.remove('drag-over'));
item.addEventListener('drop', async e => {
e.preventDefault();
item.classList.remove('drag-over');
const id = e.dataTransfer.getData('text/plain');
if (id) await moveEntryToFolder(parseInt(id), 'All');
});
fList.appendChild(item);
}
// tags
const tList = $('#tagsList');
tList.innerHTML = '';
const tags = allTags();
if (tags.length === 0) {
tList.appendChild(el('div', { class: 'sidebar-section-header', style: 'padding:6px 10px;color:var(--text-faint);font-size:11px;text-transform:none;letter-spacing:0' }, 'No tags yet'));
} else {
tags.forEach(t => {
const key = 'tag:' + t;
const count = state.entries.filter(e => parseTags(e.tags).includes(t)).length;
const item = el('button', {
class: 'nav-item' + (state.view === key ? ' is-active' : ''),
on: { click: () => setView(key) },
});
item.appendChild(icon('i-tag'));
item.appendChild(el('span', null, t));
item.appendChild(el('span', { class: 'nav-count' }, String(count)));
// Drop target: drag a card here to add this tag to that entry
item.addEventListener('dragover', e => { e.preventDefault(); item.classList.add('drag-over'); });
item.addEventListener('dragleave', () => item.classList.remove('drag-over'));
item.addEventListener('drop', async e => {
e.preventDefault();
item.classList.remove('drag-over');
const id = parseInt(e.dataTransfer.getData('text/plain'));
if (id) await addTagToEntry(id, t);
});
tList.appendChild(item);
});
}
}
async function addTagToEntry(id, tag) {
const e = state.entries.find(x => x.id === id);
if (!e) return;
const tags = parseTags(e.tags);
if (tags.includes(tag)) {
toast('Already tagged with "' + tag + '"', 'warning');
return;
}
tags.push(tag);
try {
await api('/entries/' + id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
site: e.site, title: e.title || '', username: e.username,
encrypted_password: e.encrypted_password, iv: e.iv,
folder: e.folder, tags: tags.join(','),
}),
});
e.tags = tags.join(',');
render();
toast('Tagged "' + tag + '"');
} catch (err) { toast(err.message, 'error'); }
}
function renderGrid() {
$('#contentTitle').textContent = viewTitle();
// Vault health dashboard: bypass the standard list rendering entirely.
if (state.view === 'health') {
if (authTickTimer) { clearInterval(authTickTimer); authTickTimer = null; }
const oldBtn = $('#emptyTrashBtn'); if (oldBtn) oldBtn.remove();
renderBatchBar();
$('#contentMeta').textContent = state.entries.length +
(state.entries.length === 1 ? ' entry analysed' : ' entries analysed');
const grid = $('#entryGrid');
grid.className = 'entry-grid is-health';
grid.innerHTML = '';
$('#emptyState').classList.add('is-hidden');
renderHealthDashboard(grid);
return;
}
// Authenticator view: bypass the standard pipeline — render a dedicated
// grid of TOTP cards (only entries that have a TOTP secret configured).
if (state.view === 'authenticator') {
if (authTickTimer) { clearInterval(authTickTimer); authTickTimer = null; }
const oldBtn = $('#emptyTrashBtn'); if (oldBtn) oldBtn.remove();
renderBatchBar();
const totpEntries = state.entries.filter(e => e.totp_secret && e.totp_iv);
$('#contentMeta').textContent = totpEntries.length +
(totpEntries.length === 1 ? ' code' : ' codes');
const grid = $('#entryGrid');
grid.className = 'entry-grid is-auth';
grid.innerHTML = '';
if (totpEntries.length === 0) {
$('#emptyState').classList.remove('is-hidden');
const illu = $('#emptyIllustration use');
illu.setAttribute('href', '#i-empty-vault');
$('#emptyTitle').textContent = 'No TOTP codes yet';
$('#emptyMessage').innerHTML = 'Add a TOTP secret to any entry to see its live code here.';
return;
}
$('#emptyState').classList.add('is-hidden');
renderAuthenticatorGrid(grid, totpEntries);
return;
} else if (authTickTimer) {
clearInterval(authTickTimer); authTickTimer = null;
}
const list = filteredEntries();
$('#contentMeta').textContent = list.length + (list.length === 1 ? ' item' : ' items');
// Empty trash action button next to title (only in trash view)
const oldBtn = $('#emptyTrashBtn');
if (oldBtn) oldBtn.remove();
if (state.view === 'trash' && state.trashed.length > 0) {
const btn = el('button', {
class: 'btn btn-ghost btn-sm', id: 'emptyTrashBtn',
style: 'margin-left:auto',
on: { click: emptyTrash },
}, withIcon('i-trash', 'Empty trash'));
$('.content-header').appendChild(btn);
}
// Batch action bar (shown when selection is non-empty)
renderBatchBar();
const grid = $('#entryGrid');
grid.className = 'entry-grid'
+ (state.viewMode === 'list' ? ' is-list' : '')
+ (state.viewMode === 'table' ? ' is-table' : '');
grid.innerHTML = '';
if (list.length === 0) {
showEmptyState();
return;
}
$('#emptyState').classList.add('is-hidden');
const total = list.length;
const totalPages = Math.max(1, Math.ceil(total / state.pageSize));
if (state.currentPage > totalPages) state.currentPage = totalPages;
if (state.currentPage < 1) state.currentPage = 1;
const start = (state.currentPage - 1) * state.pageSize;
const pageList = list.slice(start, start + state.pageSize);
if (total > 10) {
grid.appendChild(renderPagination(total, totalPages));
}
if (state.viewMode === 'table') {
grid.appendChild(renderTable(pageList));
} else {
pageList.forEach(e => grid.appendChild(renderCard(e)));
}
}
// Authenticator view tick timer — recomputes every code once per second.
let authTickTimer = null;
function renderAuthenticatorGrid(grid, entries) {
// Decrypt all secrets once up-front (slow); render cards immediately
// with a placeholder, then patch in the codes as they decrypt.
const cards = entries.map(e => {
const wrap = el('div', { class: 'auth-card', 'data-id': String(e.id) });
const head = el('div', { class: 'auth-card-head' });
head.appendChild(el('div', { class: 'auth-card-title' },
e.title || e.site || '(no name)'));
if (e.username) head.appendChild(el('div', { class: 'auth-card-sub' }, e.username));
wrap.appendChild(head);
const codeRow = el('div', { class: 'auth-card-code-row' });
const codeEl = el('div', { class: 'auth-card-code' }, '— — — — — —');
const copyBtn = el('button', { class: 'icon-btn', title: 'Copy code', type: 'button' });
copyBtn.appendChild(icon('i-copy'));
copyBtn.addEventListener('click', async ev => {
ev.stopPropagation();
const code = codeEl.textContent.replace(/\s/g, '');
if (!/^\d{6}$/.test(code)) return;
if (Bridge.active) Bridge.copySecure(code, 30000);
else { try { await navigator.clipboard.writeText(code); } catch (_) {} }
toast('Code copied');
});
codeRow.appendChild(codeEl);
codeRow.appendChild(copyBtn);
wrap.appendChild(codeRow);
const barWrap = el('div', { class: 'auth-card-bar-wrap' });
const bar = el('div', { class: 'auth-card-bar' });
barWrap.appendChild(bar);
wrap.appendChild(barWrap);
// Click anywhere on card (outside copy) opens the entry detail.
wrap.addEventListener('click', () => openSlideOver(e.id));
return { entry: e, wrap, codeEl, bar, secret: null };
});
cards.forEach(c => grid.appendChild(c.wrap));
// Decrypt then start ticking
(async () => {
for (const c of cards) {
try {
c.secret = await decryptTotpSecret(c.entry.totp_secret, c.entry.totp_iv);
} catch (e) {
c.secret = null;
}
}
async function tick() {
for (const c of cards) {
if (!c.secret) { c.codeEl.textContent = 'error'; continue; }
try {
const t = await generateTOTP(c.secret);
c.codeEl.textContent = t.code.replace(/(\d{3})(\d{3})/, '$1 $2');
const newPct = (t.secondsLeft / 30) * 100;
// Detect period reset (countdown wrapped from ~0 back to 30s):
// snap the bar instantly to 100% instead of letting the CSS
// transition animate the jump backwards, which looks like a
// freeze / reverse glide.
if (newPct > (c.lastPct || 0) + 5) {
c.bar.style.transition = 'none';
c.bar.style.width = newPct.toFixed(1) + '%';
// Force reflow then restore the transition for the smooth
// forward countdown.
void c.bar.offsetWidth;
c.bar.style.transition = '';
} else {
c.bar.style.width = newPct.toFixed(1) + '%';
}
c.lastPct = newPct;
c.bar.classList.toggle('is-warning', t.secondsLeft <= 5);
} catch (e) {
c.codeEl.textContent = 'error';
}
}
}
await tick();
if (authTickTimer) clearInterval(authTickTimer);
authTickTimer = setInterval(tick, 1000);
})();
}
// ============================================================
// VAULT HEALTH dashboard
// ============================================================
//
// One-shot computation per session — decrypting every entry is the
// expensive part, so we cache the result and clear it on lock / entry
// edit / view re-entry (Tools → Vault health).
let healthCache = null;
// Per-category expand state. Survives full re-renders of the dashboard
// (renderGrid runs from openSlideOver → would otherwise reset every
// "Show all" toggle back to collapsed).
const healthExpanded = { weak: false, reused: false, old: false, pwned: false };
const HEALTH_WEAK_THRESHOLD = 50; // computeStrength score < 50 → weak
const HEALTH_OLD_DAYS = 365; // entries not updated in > 1 year
function entryAgeDays(e) {
const ts = e.updated_at || e.created_at;
if (!ts) return 0;
// ISO 'yyyy-mm-dd hh:nn:ss' → assume UTC-ish, close enough for ranking.
const d = new Date(ts.replace(' ', 'T'));
if (isNaN(d)) return 0;
return Math.floor((Date.now() - d.getTime()) / 86400000);
}
async function computeHealthCache() {
const weak = [], old = [], pwned = [];
const byPwd = new Map(); // plaintext → [entries]
for (const e of state.entries) {
const ageD = entryAgeDays(e);
if (ageD > HEALTH_OLD_DAYS) old.push({ entry: e, ageDays: ageD });
const pwn = state.hibpResults.get(e.id);
if (typeof pwn === 'number' && pwn > 0)
pwned.push({ entry: e, count: pwn });
// Decrypt for strength + reuse detection. '[ERROR]' bubbles up
// from decryptPwd for corrupted ciphertext — skip those silently.
const plain = await decryptPwd(e.encrypted_password, e.iv);
if (plain === '[ERROR]') continue;
const score = computeStrength(plain);
if (score < HEALTH_WEAK_THRESHOLD) weak.push({ entry: e, score });
if (!byPwd.has(plain)) byPwd.set(plain, []);
byPwd.get(plain).push(e);
}
// Reuse: groups of ≥2 entries sharing the same plaintext password.
const reused = [];
for (const [, entries] of byPwd) {
if (entries.length >= 2) reused.push(entries);
}
// Score: start at 100, subtract per issue (capped at 0). Weights
// chosen so a single pwned password dominates over a single old one.
let score = 100;
score -= Math.min(40, weak.length * 5);
score -= Math.min(30, reused.length * 10);
score -= Math.min(20, old.length * 2);
score -= Math.min(50, pwned.length * 15);
if (score < 0) score = 0;
return { weak, reused, old, pwned, score };
}
function healthScoreBand(score) {
if (score >= 80) return { label: 'Good', cls: 'is-ok' };
if (score >= 50) return { label: 'Fair', cls: 'is-fair' };
if (score >= 25) return { label: 'At risk', cls: 'is-warn' };
return { label: 'Critical', cls: 'is-danger' };
}
// Open the entry slideover, unmask the password, focus it, and pulse the
// generator button. The user keeps full context (which entry they're
// fixing) and decides whether to type a new password, click the dice, or
// dismiss. Auto-opening the generator modal hid the entry context and
// forced an extra Save click — worse UX than this lighter nudge.
async function openEntryForFix(entryId) {
await openSlideOver(entryId);
const pwd = document.getElementById('soPassword');
if (pwd) {
pwd.type = 'text'; // unmask so the user sees what they're replacing
pwd.focus();
pwd.select();
}
const genBtn = document.querySelector('.so-pw-row button[title="Generate"]');
if (genBtn) {
genBtn.classList.add('is-pulse');
setTimeout(() => genBtn.classList.remove('is-pulse'), 2000);
}
}
async function renderHealthDashboard(grid) {
// Recompute on demand. The "Recompute" button below also triggers it.
if (!healthCache) {
grid.appendChild(el('div', { class: 'health-loading' },
'Analysing ' + state.entries.length + ' entries…'));
healthCache = await computeHealthCache();
grid.innerHTML = '';
}
const h = healthCache;
const band = healthScoreBand(h.score);
// Header: big score + recompute action
const header = el('div', { class: 'health-header' });
const scoreEl = el('div', { class: 'health-score ' + band.cls });
scoreEl.appendChild(el('div', { class: 'health-score-num' }, String(h.score)));
scoreEl.appendChild(el('div', { class: 'health-score-lbl' }, band.label));
header.appendChild(scoreEl);
const intro = el('div', { class: 'health-intro' });
intro.appendChild(el('h3', null, 'How healthy is your vault?'));
intro.appendChild(el('p', null,
'A summary of weak, reused, old and breached passwords. ' +
'Click any item to open it and rotate the password.'));
const recompute = el('button', { class: 'btn btn-ghost btn-sm', type: 'button' });
recompute.appendChild(icon('i-rotate-ccw'));
recompute.appendChild(document.createTextNode(' Recompute'));
recompute.addEventListener('click', () => {
healthCache = null;
render();
});
intro.appendChild(recompute);
header.appendChild(intro);
grid.appendChild(header);
// Four category cards
grid.appendChild(renderHealthSection({
key: 'weak',
title: 'Weak passwords',
hint: 'Strength score below ' + HEALTH_WEAK_THRESHOLD +
'/100 (short / few character classes).',
items: h.weak,
empty: 'All passwords pass the strength check. 👍',
formatItem: it => entryDisplayName(it.entry) + ' — ' + it.score + '/100',
}));
grid.appendChild(renderHealthSection({
key: 'reused',
title: 'Reused passwords',
hint: 'Same password used on multiple entries — a single breach affects them all.',
items: h.reused,
empty: 'Every password is unique. 👍',
formatItem: group => group.map(e => entryDisplayName(e)).join(' · ') +
' (' + group.length + ' entries)',
// Click on a reused group: open the first entry. Could be smarter.
idOfItem: group => group[0].id,
}));
grid.appendChild(renderHealthSection({
key: 'old',
title: 'Old passwords',
hint: 'Not updated for more than ' + Math.round(HEALTH_OLD_DAYS / 30) +
' months. Consider rotating periodically for high-value accounts.',
items: h.old,
empty: 'No stale passwords.',
formatItem: it => entryDisplayName(it.entry) + ' — ' +
Math.floor(it.ageDays / 30) + ' months old',
}));
grid.appendChild(renderHealthSection({
key: 'pwned',
title: 'Breached passwords (HIBP)',
hint: state.hibpEnabled
? 'Found in the Have I Been Pwned database. Change them now.'
: 'Enable “Check passwords against breach database” in Settings to populate this list.',
items: h.pwned,
empty: state.hibpEnabled
? 'No password matches a known breach. 👍'
: '— breach check is OFF —',
formatItem: it => entryDisplayName(it.entry) +
' — seen ' + it.count.toLocaleString() + 'x',
}));
}
// Build one collapsible category card. opts:
// title, hint, items[], empty,
// formatItem(item) → text for the row,
// idOfItem(item) → entry id used by the Fix click. Default: item.entry.id
function renderHealthSection(opts) {
const card = el('section', { class: 'health-card' });
const head = el('header', { class: 'health-card-head' });
head.appendChild(el('h4', null, opts.title));
const badge = el('span', { class: 'health-badge' }, String(opts.items.length));
if (opts.items.length === 0) badge.classList.add('is-empty');
head.appendChild(badge);
card.appendChild(head);
card.appendChild(el('p', { class: 'health-hint' }, opts.hint));
if (opts.items.length === 0) {
card.appendChild(el('p', { class: 'health-empty' }, opts.empty));
return card;
}
const list = el('ul', { class: 'health-list' });
const getId = opts.idOfItem || (it => it.entry.id);
const INITIAL_LIMIT = 20;
// Read persisted expand state so a renderGrid() triggered by
// openSlideOver (after clicking "Fix") doesn't snap the list back
// to the collapsed view.
let expanded = !!(opts.key && healthExpanded[opts.key]);
let shown = expanded ? opts.items.length :
Math.min(INITIAL_LIMIT, opts.items.length);
function renderRows() {
list.innerHTML = '';
opts.items.slice(0, shown).forEach(it => {
const li = el('li', { class: 'health-item' });
li.appendChild(el('span', { class: 'health-item-label' }, opts.formatItem(it)));
const fix = el('button', { class: 'btn btn-ghost btn-xs', type: 'button' },
'Fix');
fix.addEventListener('click', ev => {
// Stop bubbling — the document-level "click outside slideover"
// handler would otherwise close the slideover we're about to
// open within the same click event.
ev.stopPropagation();
openEntryForFix(getId(it));
});
li.appendChild(fix);
list.appendChild(li);
});
}
renderRows();
card.appendChild(list);
if (opts.items.length > INITIAL_LIMIT) {
const more = el('button', {
class: 'btn btn-ghost btn-xs health-more-btn',
type: 'button',
}, expanded ? 'Show less' : ('Show all ' + opts.items.length));
more.addEventListener('click', ev => {
ev.stopPropagation();
expanded = !expanded;
shown = expanded ? opts.items.length : INITIAL_LIMIT;
more.textContent = expanded ? 'Show less'
: 'Show all ' + opts.items.length;
if (opts.key) healthExpanded[opts.key] = expanded;
renderRows();
});
card.appendChild(more);
}
return card;
}
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 <b>+ New</b> 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 <b>★</b> 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 &amp; 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 <b>+ New</b> to add your first password. They\'re encrypted before they leave your machine.';
}
$('#emptyState').classList.remove('is-hidden');
}
// Skeleton loaders shown during the initial fetch right after login/unlock
function showSkeletons(n) {
const grid = $('#entryGrid');
grid.innerHTML = '';
$('#emptyState').classList.add('is-hidden');
for (let i = 0; i < n; i++) {
const card = el('div', { class: 'skeleton-card' });
const row = el('div', { class: 'skeleton-row' });
row.appendChild(el('div', { class: 'skeleton-circle' }));
const col = el('div', { style: 'flex:1' });
col.appendChild(el('div', { class: 'skeleton-line w-60' }));
col.appendChild(el('div', { class: 'skeleton-line w-40', style: 'margin-bottom:0' }));
row.appendChild(col);
card.appendChild(row);
card.appendChild(el('div', { class: 'skeleton-line w-80' }));
card.appendChild(el('div', { class: 'skeleton-line w-40', style: 'margin-bottom:0' }));
grid.appendChild(card);
}
}
function initials(s) {
return (s || '?').replace(/[^a-zA-Z0-9]/g, '').slice(0, 2).toUpperCase() || '?';
}
// User-facing name for an entry. Falls back to `site` when `title` is empty
// (default for legacy entries and any entry the user hasn't customised).
// IMPORTANT: do NOT use this for autofill domain matching or search-by-host
// — those need the raw site/URL/hostname.
function entryDisplayName(e) {
if (!e) return '';
const t = (e.title || '').trim();
return t || e.site || '';
}
// Display label for a folder value. "All" is the default "uncategorized"
// bucket; we relabel it so users don't see two "All" entries in folder
// pickers (the top nav "All items" also says "All").
function folderLabel(f) {
if (!f || f === 'All') return '(no folder)';
return f;
}
// Comparator for sorting entries. by ∈ {name, site, updated, created, folder}.
// dir ∈ {asc, desc}. Stable sort: ties keep their relative order (Array.sort
// is stable per the modern spec).
function sortEntries(list, by, dir) {
const mul = dir === 'desc' ? -1 : 1;
const get = e => {
switch (by) {
case 'site': return (e.site || '').toLowerCase();
case 'updated': return e.updated_at || '';
case 'created': return e.created_at || '';
case 'folder': return (e.folder || '').toLowerCase();
case 'name':
default: return entryDisplayName(e).toLowerCase();
}
};
return list.slice().sort((a, b) => {
const va = get(a), vb = get(b);
if (va < vb) return -1 * mul;
if (va > vb) return 1 * mul;
return 0;
});
}
// Compact-action kebab menu shown on each card when state.compactActions is on.
function buildKebabMenu(entry) {
const wrap = el('div', { class: 'entry-kebab-wrap' });
const btn = el('button', {
class: 'entry-kebab',
title: 'More actions',
on: { click: ev => {
ev.stopPropagation();
// Close any other open menu, then toggle this one
$$('.entry-kebab-menu.is-open').forEach(m => {
if (m !== menu) m.classList.remove('is-open');
});
menu.classList.toggle('is-open');
} },
});
btn.appendChild(icon('i-more'));
wrap.appendChild(btn);
const menu = el('div', { class: 'entry-kebab-menu' });
const items = [
{ lbl: entry.favorite ? 'Unfavorite' : 'Favorite', ic: 'i-star', fn: () => toggleFavorite(entry.id) },
{ lbl: 'Copy password', ic: 'i-copy', fn: () => copyPassword(entry) },
{ lbl: 'Copy username', ic: 'i-user', fn: () => copyUsername(entry) },
{ lbl: 'Edit', ic: 'i-edit', fn: () => openSlideOver(entry.id) },
{ lbl: 'Duplicate', ic: 'i-copy', fn: () => duplicateEntry(entry) },
{ lbl: 'Move to trash', ic: 'i-trash', fn: () => deleteEntry(entry.id), danger: true },
];
items.forEach(it => {
const mi = el('button', {
class: 'kebab-item' + (it.danger ? ' is-danger' : ''),
on: { click: ev => {
ev.stopPropagation();
menu.classList.remove('is-open');
it.fn();
} },
});
mi.appendChild(icon(it.ic));
mi.appendChild(el('span', null, it.lbl));
menu.appendChild(mi);
});
wrap.appendChild(menu);
return wrap;
}
function renderCard(e) {
const inTrash = state.view === 'trash';
const checked = state.checked.has(e.id);
const card = el('article', {
class: 'entry-card'
+ (state.selectedId === e.id ? ' is-selected' : '')
+ (checked ? ' is-checked' : ''),
'data-id': e.id,
draggable: inTrash ? 'false' : 'true',
on: { click: ev => handleCardClick(ev, e, inTrash) },
});
if (!inTrash) {
card.addEventListener('dragstart', ev => {
ev.dataTransfer.setData('text/plain', String(e.id));
ev.dataTransfer.effectAllowed = 'move';
});
}
// head: avatar shows identity (initials). A checkbox overlay LIVES
// INSIDE the avatar (absolute inset:0) — it covers the avatar exactly
// when visible, no separate footprint that could collide with the
// avatar's position. Visible on hover or whenever the entry is
// checked. Card click anywhere not on the checkbox opens slideover.
const head = el('div', { class: 'entry-head' });
const displayName = entryDisplayName(e);
const avatar = el('div', { class: 'entry-avatar' });
if (e.icon_b64) {
const img = el('img', { src: e.icon_b64, alt: '', class: 'entry-avatar-img' });
// If the cached data URI fails to decode (corrupt blob), fall
// back to the initials so the card never shows a broken-image icon.
img.addEventListener('error', () => {
avatar.innerHTML = '';
avatar.textContent = initials(displayName);
});
avatar.appendChild(img);
} else {
avatar.textContent = initials(displayName);
}
const checkbox = el('button', {
class: 'entry-check' + (checked ? ' is-checked' : ''),
type: 'button',
title: checked ? 'Deselect' : 'Select',
on: { click: ev => { ev.stopPropagation(); toggleChecked(e.id); } },
});
if (checked) checkbox.appendChild(el('span', null, '✓'));
avatar.appendChild(checkbox); // NESTED inside avatar — no overlap
head.appendChild(avatar);
const title = el('div', { class: 'entry-title' });
title.appendChild(el('b', null, displayName));
// If user set a custom title AND it differs from site, optionally show
// site as a small subtitle. Hidden by default to keep cards clean —
// toggled via Settings > Appearance.
if (state.showSiteOnCards &&
e.title && e.title.trim() && e.title.trim() !== e.site) {
title.appendChild(el('span', { class: 'entry-subtitle' }, e.site));
}
// Username row with inline copy button (visible on card hover)
const userRow = el('small', { class: 'entry-user-row' });
userRow.appendChild(el('span', null, displayUsername(e.username)));
if (e.username) {
const copyUser = el('button', {
class: 'entry-copy-user',
title: 'Copy username',
on: { click: ev => { ev.stopPropagation(); copyUsername(e); } },
});
copyUser.appendChild(icon('i-copy'));
userRow.appendChild(copyUser);
}
title.appendChild(userRow);
head.appendChild(title);
if (inTrash) {
// In trash: show restore + permanent delete buttons
const restore = el('button', {
class: 'icon-btn icon-btn-sm', title: 'Restore',
on: { click: ev => { ev.stopPropagation(); restoreEntry(e.id); } },
});
restore.appendChild(icon('i-rotate-ccw'));
const purge = el('button', {
class: 'icon-btn icon-btn-sm', title: 'Delete forever',
style: 'color:var(--danger)',
on: { click: ev => { ev.stopPropagation(); permanentDelete(e.id); } },
});
purge.appendChild(icon('i-trash'));
head.appendChild(restore);
head.appendChild(purge);
} else if (state.compactActions) {
// Compact mode: single kebab menu replaces fav + dup + del
head.appendChild(buildKebabMenu(e));
} else {
const fav = el('button', {
class: 'entry-fav' + (e.favorite ? ' is-on' : ''),
title: 'Favorite',
on: { click: ev => { ev.stopPropagation(); toggleFavorite(e.id); } },
});
fav.appendChild(icon('i-star'));
head.appendChild(fav);
const dup = el('button', {
class: 'entry-dup',
title: 'Duplicate',
on: { click: ev => { ev.stopPropagation(); duplicateEntry(e); } },
});
dup.appendChild(icon('i-copy'));
head.appendChild(dup);
const del = el('button', {
class: 'entry-del',
title: 'Move to trash',
on: { click: ev => { ev.stopPropagation(); deleteEntry(e.id); } },
});
del.appendChild(icon('i-x'));
head.appendChild(del);
}
card.appendChild(head);
// password row (placeholder dots, click reveals via slide-over)
const pwRow = el('div', { class: 'entry-pw-row' });
pwRow.appendChild(el('span', { class: 'entry-pw', id: 'pw-' + e.id }, '••••••••'));
const copyBtn = el('button', {
class: 'icon-btn icon-btn-sm',
title: 'Copy password',
on: { click: ev => { ev.stopPropagation(); copyPassword(e); } },
});
copyBtn.appendChild(icon('i-copy'));
pwRow.appendChild(copyBtn);
card.appendChild(pwRow);
// meta chips: folder + first 2 tags. "All" is the default "uncategorized"
// bucket and shouldn't be shown as a chip (visually duplicates "All items").
const meta = el('div', { class: 'entry-meta' });
if (e.folder && e.folder !== 'All') {
const chip = el('span', { class: 'entry-chip is-folder' });
chip.appendChild(icon('i-folder'));
chip.appendChild(el('span', null, e.folder));
meta.appendChild(chip);
}
parseTags(e.tags).slice(0, 3).forEach(t => {
const chip = el('span', { class: 'entry-chip' });
chip.appendChild(icon('i-tag'));
chip.appendChild(el('span', null, t));
meta.appendChild(chip);
});
// HIBP pwned badge — only shown when the user enabled HIBP and the
// background scan completed with count > 0 for this entry.
const pwnedCount = state.hibpResults.get(e.id);
if (state.hibpEnabled && pwnedCount && pwnedCount > 0) {
const chip = el('span', {
class: 'entry-chip is-pwned',
title: 'This password appeared in ' + pwnedCount.toLocaleString() +
' known data breaches. Consider changing it.',
});
chip.appendChild(icon('i-alert'));
chip.appendChild(el('span', null, 'Pwned'));
meta.appendChild(chip);
}
// 2FA indicator — entry has a TOTP secret configured. Server returns
// null for both fields when none; truthy = configured (the actual
// secret stays encrypted until the user opens the slide-over).
if (e.totp_secret && e.totp_iv) {
const chip = el('span', {
class: 'entry-chip is-2fa',
title: 'Two-factor authentication (TOTP) configured',
});
chip.appendChild(icon('i-lock'));
chip.appendChild(el('span', null, '2FA'));
meta.appendChild(chip);
}
card.appendChild(meta);
return card;
}
// ============================================================
// TABLE VIEW
// ============================================================
// Dense, spreadsheet-like layout for users who manage many entries.
// Sortable headers — clicking a column header sets state.sortBy/sortDir
// (with toggle on the active column) and re-renders. Same source of
// truth as the Settings dropdown — both stay in sync.
// Columns built dynamically — the Site column tracks the user's
// "Show site under display name" preference so card view and table view
// stay consistent (default: site hidden, can be re-enabled from Settings).
function getTableColumns() {
const cols = [
{ key: 'check', label: '', sortKey: null },
{ key: 'name', label: 'Name', sortKey: 'name' },
];
if (state.showSiteOnCards) {
cols.push({ key: 'site', label: 'Site', sortKey: 'site' });
}
cols.push(
{ key: 'user', label: 'Username', sortKey: null }, // no sort: derived/varies
{ key: 'folder', label: 'Folder', sortKey: 'folder' },
{ key: 'updated', label: 'Updated', sortKey: 'updated' },
{ key: 'actions', label: '', sortKey: null },
);
return cols;
}
function renderPagination(total, totalPages) {
const wrap = el('div', { class: 'pagination' });
const start = (state.currentPage - 1) * state.pageSize + 1;
const end = Math.min(start + state.pageSize - 1, total);
wrap.appendChild(el('span', { class: 'pagination-info' },
start + '' + end + ' of ' + total));
function pageBtn(label, page, opts) {
opts = opts || {};
const b = el('button', {
class: 'pagination-btn'
+ (opts.active ? ' is-active' : '')
+ (opts.disabled ? ' is-disabled' : ''),
type: 'button',
on: { click: () => {
if (opts.disabled || opts.active) return;
state.currentPage = page;
render();
} },
}, String(label));
return b;
}
wrap.appendChild(pageBtn(' Prev', state.currentPage - 1,
{ disabled: state.currentPage <= 1 }));
const pages = computePageList(state.currentPage, totalPages);
pages.forEach(p => {
if (p === '…') wrap.appendChild(el('span', { class: 'pagination-ellipsis' }, '…'));
else wrap.appendChild(pageBtn(p, p, { active: p === state.currentPage }));
});
wrap.appendChild(pageBtn('Next ', state.currentPage + 1,
{ disabled: state.currentPage >= totalPages }));
const sizeSel = el('select', {
class: 'pagination-size',
on: { change: ev => {
state.pageSize = parseInt(ev.target.value);
state.currentPage = 1;
localStorage.setItem('pageSize', String(state.pageSize));
saveServerSettings();
render();
} },
});
[10, 25, 50, 100].forEach(n => {
const opt = el('option', { value: String(n) }, String(n) + ' / page');
if (n === state.pageSize) opt.selected = true;
sizeSel.appendChild(opt);
});
wrap.appendChild(sizeSel);
return wrap;
}
function computePageList(current, total) {
if (total <= 7) {
const arr = [];
for (let i = 1; i <= total; i++) arr.push(i);
return arr;
}
const pages = [1];
if (current > 3) pages.push('…');
const from = Math.max(2, current - 1);
const to = Math.min(total - 1, current + 1);
for (let i = from; i <= to; i++) pages.push(i);
if (current < total - 2) pages.push('…');
pages.push(total);
return pages;
}
function renderTable(list) {
const columns = getTableColumns();
const table = el('table', { class: 'entry-table' });
// Header row with click-to-sort
const thead = el('thead');
const headerRow = el('tr');
columns.forEach(col => {
const th = el('th', { 'data-col': col.key });
if (col.sortKey) {
th.classList.add('is-sortable');
const isActive = state.sortBy === col.sortKey;
if (isActive) th.classList.add('is-active');
th.appendChild(el('span', null, col.label));
// Arrow indicator only on the active column.
if (isActive) {
th.appendChild(el('span', { class: 'sort-arrow' },
state.sortDir === 'asc' ? '↑' : '↓'));
}
th.addEventListener('click', () => {
if (state.sortBy === col.sortKey) {
// Same column → flip direction
state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc';
} else {
state.sortBy = col.sortKey;
// Sensible default direction per field
state.sortDir = (col.sortKey === 'updated' ||
col.sortKey === 'created') ? 'desc' : 'asc';
}
localStorage.setItem('sortBy', state.sortBy);
localStorage.setItem('sortDir', state.sortDir);
// Keep the Settings dropdown in sync if it's currently displayed.
const sel = $('#settingSort');
if (sel) sel.value = state.sortBy + ':' + state.sortDir;
render();
saveServerSettings();
});
} else {
th.appendChild(el('span', null, col.label));
}
headerRow.appendChild(th);
});
thead.appendChild(headerRow);
table.appendChild(thead);
// Body rows
const tbody = el('tbody');
list.forEach(e => tbody.appendChild(renderTableRow(e)));
table.appendChild(tbody);
return table;
}
function renderTableRow(e) {
const checked = state.checked.has(e.id);
const inTrash = state.view === 'trash';
const tr = el('tr', {
class: 'entry-row'
+ (state.selectedId === e.id ? ' is-selected' : '')
+ (checked ? ' is-checked' : ''),
'data-id': String(e.id),
on: { click: ev => handleCardClick(ev, e, inTrash) },
});
// Cells are emitted in EXACTLY the same order as getTableColumns()
// returns headers, otherwise THs and TDs drift apart and clicks land
// on the wrong column. Switch by col.key so add/remove of a column
// affects header + body in one place.
getTableColumns().forEach(col => {
let td;
switch (col.key) {
case 'check': {
td = el('td', { class: 'col-check' });
// Standalone variant — not nested inside an avatar, so it
// needs its own explicit dimensions. .entry-check-static
// overrides the absolute/inset:0 positioning used in the
// card-view (where the box gets its size from its avatar
// parent).
const checkbox = el('button', {
class: 'entry-check entry-check-static' + (checked ? ' is-checked' : ''),
type: 'button',
title: checked ? 'Deselect' : 'Select',
on: { click: ev => { ev.stopPropagation(); toggleChecked(e.id); } },
});
if (checked) checkbox.appendChild(el('span', null, '✓'));
td.appendChild(checkbox);
break;
}
case 'name': {
td = el('td', { class: 'col-name' });
const avatar = el('span', { class: 'entry-avatar entry-avatar-sm' },
initials(entryDisplayName(e)));
td.appendChild(avatar);
const nameWrap = el('span', { class: 'cell-name-wrap' });
nameWrap.appendChild(el('b', null, entryDisplayName(e)));
if (e.favorite) nameWrap.appendChild(el('span', { class: 'fav-dot', title: 'Favorite' }, '★'));
td.appendChild(nameWrap);
break;
}
case 'site':
td = el('td', { class: 'col-site' }, e.site || '');
break;
case 'user': {
td = el('td', { class: 'col-user' });
td.appendChild(el('span', null, displayUsername(e.username)));
if (e.username) {
const btn = el('button', {
class: 'icon-btn icon-btn-sm',
title: 'Copy username',
on: { click: ev => { ev.stopPropagation(); copyUsername(e); } },
});
btn.appendChild(icon('i-copy'));
td.appendChild(btn);
}
break;
}
case 'folder':
td = el('td', { class: 'col-folder' },
(e.folder && e.folder !== 'All') ? e.folder : '');
break;
case 'updated':
td = el('td', { class: 'col-updated' },
formatDateShort(e.updated_at));
break;
case 'actions': {
td = el('td', { class: 'col-actions' });
const pwBtn = el('button', {
class: 'icon-btn icon-btn-sm',
title: 'Copy password',
on: { click: ev => { ev.stopPropagation(); copyPassword(e); } },
});
pwBtn.appendChild(icon('i-copy'));
td.appendChild(pwBtn);
td.appendChild(buildKebabMenu(e));
break;
}
}
tr.appendChild(td);
});
return tr;
}
// Compact date for the table column. ISO string in → "2026-05-23" out.
// Avoids per-locale parsing surprises (server uses ISO already).
function formatDateShort(iso) {
if (!iso) return '';
return iso.slice(0, 10);
}
// ============================================================
// MULTI-SELECTION + BATCH ACTIONS
// ============================================================
//
// Selection patterns (no rubber-band marquee — removed 2026-05-24, value
// for a password manager was too low vs the accidental-deselect cost):
// - Click the checkbox overlay on a card → toggle just that entry
// - Click a card body (when ≥1 is already checked) → toggle (sticky)
// - Ctrl+click row/card → toggle (always, no need for sticky mode)
// - Shift+click row/card → range select from last anchor
// - Ctrl+A (when not typing) → select every visible entry
// - Escape (when nothing else to dismiss) → clear selection
let selectionAnchor = null; // last single-clicked card, used for shift+click range
function toggleChecked(id) {
if (state.checked.has(id)) state.checked.delete(id);
else state.checked.add(id);
renderGrid();
}
function handleCardClick(ev, entry, inTrash) {
// Ctrl/Cmd+Click: toggle this card in selection
if (ev.ctrlKey || ev.metaKey) {
toggleChecked(entry.id);
selectionAnchor = entry.id;
return;
}
// Shift+Click: select range from anchor to this card
if (ev.shiftKey && selectionAnchor !== null) {
const list = filteredEntries();
const a = list.findIndex(x => x.id === selectionAnchor);
const b = list.findIndex(x => x.id === entry.id);
if (a >= 0 && b >= 0) {
const lo = Math.min(a, b), hi = Math.max(a, b);
for (let i = lo; i <= hi; i++) state.checked.add(list[i].id);
renderGrid();
return;
}
}
// If any cards are already checked, a plain click toggles (sticky multi-select)
if (state.checked.size > 0) {
toggleChecked(entry.id);
selectionAnchor = entry.id;
return;
}
// Default: open slide-over (or trash actions)
selectionAnchor = entry.id;
if (inTrash) openTrashActions(entry.id);
else openSlideOver(entry.id);
}
function clearChecked() {
state.checked.clear();
renderGrid();
}
async function batchMoveToFolder(folder) {
const ids = Array.from(state.checked);
if (!ids.length) return;
for (const id of ids) {
const e = state.entries.find(x => x.id === id);
if (!e || e.folder === folder) continue;
try {
await api('/entries/' + id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
site: e.site, title: e.title || '', username: e.username,
encrypted_password: e.encrypted_password, iv: e.iv,
folder, tags: e.tags || '',
}),
});
e.folder = folder;
} catch (err) { /* ignore individual failures */ }
}
toast(ids.length + ' moved to ' + folder);
clearChecked();
}
async function batchAddTag(tag) {
tag = (tag || '').trim();
if (!tag) return;
const ids = Array.from(state.checked);
if (!ids.length) return;
for (const id of ids) {
const e = state.entries.find(x => x.id === id);
if (!e) continue;
const tags = parseTags(e.tags);
if (tags.includes(tag)) continue;
tags.push(tag);
try {
await api('/entries/' + id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
site: e.site, title: e.title || '', username: e.username,
encrypted_password: e.encrypted_password, iv: e.iv,
folder: e.folder, tags: tags.join(','),
}),
});
e.tags = tags.join(',');
} catch (err) {}
}
toast('Tagged ' + ids.length + ' as "' + tag + '"');
clearChecked();
}
async function batchDelete() {
const ids = Array.from(state.checked);
if (!ids.length) return;
const ok = await confirmDialog({
title: 'Move to trash',
message: '<b>' + ids.length + '</b> 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: '<b>' + ids.length + '</b> entries will be <b>permanently deleted</b>. This cannot be undone.',
okText: 'Delete forever',
danger: true,
});
if (!ok) return;
for (const id of ids) {
try {
await api('/entries/' + id + '?permanent=1', { method: 'DELETE', headers: authHeaders() });
} catch (err) {}
}
toast(ids.length + ' deleted permanently');
await loadTrash();
clearChecked();
}
function renderBatchBar() {
const existing = $('#batchBar');
if (existing) existing.remove();
if (state.checked.size === 0) return;
const inTrash = state.view === 'trash';
const bar = el('div', { class: 'batch-bar', id: 'batchBar' });
bar.appendChild(el('span', { class: 'batch-bar-count' }, state.checked.size + ' selected'));
if (inTrash) {
// Trash view: Restore | Delete forever
bar.appendChild(el('button', {
class: 'btn btn-ghost btn-sm',
on: { click: batchRestore },
}, withIcon('i-rotate-ccw', 'Restore')));
bar.appendChild(el('button', {
class: 'btn btn-ghost btn-sm',
style: 'color:var(--danger)',
on: { click: batchPermDelete },
}, withIcon('i-trash', 'Delete forever')));
} else {
// Normal view: Move to folder | Add tag | Delete (soft)
const moveSel = el('select');
moveSel.appendChild(el('option', { value: '' }, 'Move to folder…'));
state.folders.forEach(f => moveSel.appendChild(el('option', { value: f }, folderLabel(f))));
moveSel.addEventListener('change', () => {
if (moveSel.value) batchMoveToFolder(moveSel.value);
});
bar.appendChild(moveSel);
bar.appendChild(el('button', {
class: 'btn btn-ghost btn-sm',
on: { click: async () => {
const t = await promptDialog({
title: 'Add tag',
message: 'Add a tag to <b>' + state.checked.size + '</b> 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;
// Open the slideover for either an existing entry (id = number) or a
// brand-new one (id = null). opts: { presetTitle, presetSite } pre-fill
// the corresponding fields for the new-entry path (Ctrl+Shift+A hotkey).
async function openSlideOver(id, opts) {
opts = opts || {};
const isNew = (id == null);
const e = isNew ? null : state.entries.find(x => x.id === id);
if (!isNew && !e) return;
state.selectedId = isNew ? null : id;
// Title with a clear "what mode am I in?" prefix. Plain text so the
// existing .slideover-header h3 ellipsis / overflow rules still work.
const titleEl = $('#slideoverTitle');
titleEl.textContent = isNew
? '+ New entry'
: 'Edit · ' + entryDisplayName(e);
titleEl.classList.toggle('is-new-mode', isNew);
titleEl.classList.toggle('is-edit-mode', !isNew);
const body = $('#slideoverBody');
body.innerHTML = '';
let plain = '';
let plainTotp = '';
if (!isNew) {
plain = await decryptPwd(e.encrypted_password, e.iv);
// Decrypt TOTP secret if present. Empty string when no TOTP configured
// OR when decryption fails (orphan ciphertext, key mismatch, etc.).
if (e.totp_secret && e.totp_iv) {
plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv);
if (plainTotp === '[ERROR]') plainTotp = '';
}
}
// Track original values so we can detect "dirty". For new entries the
// originals are empty strings — typing anything triggers the Save button.
const defaultFolder = state.view.startsWith('folder:')
? state.view.slice(7) : 'All';
soState = {
id: isNew ? null : e.id,
original: {
site: isNew ? (opts.presetSite || '') : e.site,
title: isNew ? (opts.presetTitle || '') : (e.title || ''),
username: isNew ? '' : (e.username || ''),
password: plain,
folder: isNew ? defaultFolder : (e.folder || 'All'),
tags: isNew ? '' : parseTags(e.tags).join(','),
totp: plainTotp,
},
tags: isNew ? [] : parseTags(e.tags),
originalEncrypted: isNew ? null : e.encrypted_password,
originalIV: isNew ? null : e.iv,
originalTotpEncrypted: isNew ? null : e.totp_secret,
originalTotpIV: isNew ? null : e.totp_iv,
};
// Icon field needs SOMETHING to compute initials/fallback. For new
// entries we pass a synthetic placeholder.
const eForIcon = isNew
? { id: null, icon_b64: null, site: soState.original.site,
title: soState.original.title }
: e;
body.appendChild(soIconField(eForIcon));
body.appendChild(soEditableField('Display name', 'soTitle', soState.original.title));
body.appendChild(soEditableField('Site', 'soSite', soState.original.site));
body.appendChild(soEditableField('Username', 'soUsername', soState.original.username));
body.appendChild(soPasswordField(plain));
body.appendChild(soTotpField(plainTotp));
body.appendChild(soFolderField(soState.original.folder));
body.appendChild(soTagsField());
// Action row — Save button is hidden until dirty. No Delete here:
// the quick-X on each card handles deletion (avoids duplication).
const actions = el('div', { class: 'slideover-actions' });
const saveBtn = el('button', {
class: 'btn btn-primary',
id: 'soSaveBtn',
style: 'display:none',
on: { click: soSave },
}, withIcon('i-check', 'Save'));
actions.appendChild(saveBtn);
body.appendChild(actions);
// Wire change detection
['#soTitle', '#soSite', '#soUsername', '#soPassword', '#soFolder'].forEach(sel => {
const el = $(sel); if (el) el.addEventListener('input', soDirtyCheck);
if (el) el.addEventListener('change', soDirtyCheck);
});
$('#slideover').classList.add('is-open');
// New entries: Save visible from the start so the action is obvious,
// and auto-focus the Site field (most important pivot field).
if (isNew) {
const save = document.getElementById('soSaveBtn');
if (save) save.style.display = '';
setTimeout(() => {
const f = document.getElementById('soSite');
if (f) f.focus();
}, 50);
}
renderGrid();
}
function soEditableField(label, id, value) {
const wrap = el('div', { class: 'slideover-field' });
wrap.appendChild(el('div', { class: 'slideover-field-label' }, label));
const input = el('input', { type: 'text', id, value, class: 'so-input' });
input.addEventListener('keydown', soOnEnterSave);
wrap.appendChild(input);
return wrap;
}
// Custom icon: lets the user upload an image when auto-fetch fails (private
// sites behind auth, self-hosted apps DDG doesn't index, etc.). Stored as
// base64 data URI via the same POST /entries/{id}/icon endpoint as the
// auto-fetched icons — the JS render path doesn't care which source it
// came from.
const ICON_MAX_BYTES = 64 * 1024; // matches server-side cap
function soIconField(entry) {
const wrap = el('div', { class: 'slideover-field so-icon-field' });
const preview = el('div', { class: 'so-icon-preview' });
function paintPreview(dataUriOrNull) {
preview.innerHTML = '';
if (dataUriOrNull) {
const img = el('img', { src: dataUriOrNull, alt: '' });
img.addEventListener('error', () => {
preview.innerHTML = '';
preview.textContent = initials(entryDisplayName(entry));
});
preview.appendChild(img);
} else {
preview.textContent = initials(entryDisplayName(entry));
}
}
paintPreview(entry.icon_b64 || null);
const actions = el('div', { class: 'so-icon-actions' });
const uploadBtn = el('button', { class: 'btn btn-ghost btn-sm', type: 'button' },
'Upload icon');
const fileInput = el('input', {
type: 'file',
accept: 'image/png,image/jpeg,image/gif,image/svg+xml,image/x-icon,image/webp',
style: 'display:none',
});
uploadBtn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', () => {
const f = fileInput.files && fileInput.files[0];
if (!f) return;
if (!/^image\//.test(f.type)) {
toast('Not an image file', 'error');
return;
}
if (f.size > ICON_MAX_BYTES) {
toast('Icon too large (max 64 KB)', 'error');
return;
}
const reader = new FileReader();
reader.onload = async ev => {
const dataUri = ev.target.result; // 'data:image/...;base64,...'
entry.icon_b64 = dataUri;
paintPreview(dataUri);
await saveEntryIcon(entry.id, dataUri);
// Refresh entry cards / health view so they show the new icon too.
render();
toast('Custom icon set');
};
reader.readAsDataURL(f);
});
const resetBtn = el('button', { class: 'btn btn-ghost btn-sm', type: 'button' },
'Remove');
resetBtn.addEventListener('click', async () => {
entry.icon_b64 = null;
paintPreview(null);
await saveEntryIcon(entry.id, '');
render();
toast('Icon removed');
});
actions.appendChild(uploadBtn);
actions.appendChild(resetBtn);
actions.appendChild(fileInput);
wrap.appendChild(preview);
wrap.appendChild(actions);
return wrap;
}
function soOnEnterSave(ev) {
if (ev.key !== 'Enter') return;
ev.preventDefault();
soSave();
}
function soPasswordField(plain) {
const wrap = el('div', { class: 'slideover-field' });
wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Password'));
const row = el('div', { class: 'so-pw-row' });
const input = el('input', {
type: 'password', id: 'soPassword', value: plain,
class: 'so-input', style: 'flex:1;font-family:JetBrains Mono,monospace',
});
input.addEventListener('keydown', soOnEnterSave);
const toggle = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Show/hide' });
toggle.appendChild(icon('i-eye'));
toggle.addEventListener('click', () => {
input.type = input.type === 'password' ? 'text' : 'password';
});
const copy = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Copy' });
copy.appendChild(icon('i-copy'));
copy.addEventListener('click', () => {
if (Bridge.copySecure(input.value, 30000)) {
toast('Copied · clears in 30s');
} else {
navigator.clipboard.writeText(input.value).then(() => {
toast('Copied · clears in 30s');
setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000);
});
}
});
const gen = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Generate' });
gen.appendChild(icon('i-dice'));
gen.addEventListener('click', () => {
openGen('slideover');
});
row.appendChild(input);
row.appendChild(toggle);
row.appendChild(copy);
row.appendChild(gen);
wrap.appendChild(row);
return wrap;
}
// ---- TOTP field in slide-over (input + live code + countdown) ----
let totpTickTimer = null;
function startTotpTick() {
if (totpTickTimer) return;
// Refresh once per second so the countdown bar moves smoothly and the
// code auto-rolls when the 30s window expires.
totpTickTimer = setInterval(updateTotpDisplay, 1000);
// Defer the first immediate update: callers append the wrap to the DOM
// AFTER soTotpField() returns, so a sync $() lookup here would see null
// elements and stopTotpTick() would kill the interval we just created.
setTimeout(updateTotpDisplay, 0);
}
function stopTotpTick() {
if (totpTickTimer) { clearInterval(totpTickTimer); totpTickTimer = null; }
}
async function updateTotpDisplay() {
const input = $('#soTotpSecret');
const codeEl = $('#soTotpCode');
const barEl = $('#soTotpProgress');
if (!input || !codeEl) { stopTotpTick(); return; }
const secret = (input.value || '').trim();
if (!secret) {
codeEl.textContent = '';
codeEl.classList.remove('is-invalid');
if (barEl) barEl.style.width = '0%';
return;
}
try {
const t = await generateTOTP(secret);
// Format as "123 456" — the standard spacing for authenticator apps
codeEl.textContent = t.code.slice(0, 3) + ' ' + t.code.slice(3);
codeEl.classList.remove('is-invalid');
if (barEl) {
const pct = (t.secondsLeft / t.period) * 100;
barEl.style.width = pct + '%';
// Switch to red when < 5s left so the user notices the imminent roll
barEl.style.background = t.secondsLeft < 5 ? '#dc2626' : 'var(--accent)';
}
} catch (err) {
codeEl.textContent = 'invalid secret';
codeEl.classList.add('is-invalid');
if (barEl) barEl.style.width = '0%';
}
}
function soTotpField(plainSecret) {
const wrap = el('div', { class: 'slideover-field' });
wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Two-factor (TOTP)'));
const row = el('div', { class: 'so-pw-row' });
const input = el('input', {
type: 'password', id: 'soTotpSecret',
value: plainSecret || '',
class: 'so-input',
placeholder: 'Paste base32 secret or otpauth:// URI',
on: { keydown: soOnEnterSave },
style: 'flex:1;font-family:JetBrains Mono,monospace',
autocomplete: 'off', spellcheck: 'false',
});
// If the user pastes a full otpauth:// URI, auto-extract the secret param
// so the displayed value is the clean base32 only. Triggers via 'input'
// (covers both paste events and manual typing).
input.addEventListener('input', () => {
const v = input.value.trim();
const fromUri = parseOtpAuthUri(v);
if (fromUri) input.value = fromUri;
updateTotpDisplay();
soDirtyCheck();
});
const toggle = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Show/hide secret' });
toggle.appendChild(icon('i-eye'));
toggle.addEventListener('click', () => {
input.type = input.type === 'password' ? 'text' : 'password';
});
const clear = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Remove TOTP' });
clear.appendChild(icon('i-x'));
clear.addEventListener('click', () => {
input.value = '';
updateTotpDisplay();
soDirtyCheck();
});
row.appendChild(input);
row.appendChild(toggle);
row.appendChild(clear);
wrap.appendChild(row);
// Live code panel — shows the current 6-digit code with a copy button
// and a progress bar that drains over the 30s window.
const panel = el('div', { class: 'totp-panel' });
const codeEl = el('div', { class: 'totp-code', id: 'soTotpCode' });
panel.appendChild(codeEl);
const copyBtn = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Copy code' });
copyBtn.appendChild(icon('i-copy'));
copyBtn.addEventListener('click', async () => {
const secret = (input.value || '').trim();
if (!secret) return;
try {
const t = await generateTOTP(secret);
if (Bridge.copySecure(t.code, 30000)) {
toast('Code copied · clears in 30s');
} else {
navigator.clipboard.writeText(t.code).then(() => {
toast('Code copied · clears in 30s');
setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000);
});
}
} catch (e) {
toast('Invalid TOTP secret', 'error');
}
});
panel.appendChild(copyBtn);
wrap.appendChild(panel);
const barWrap = el('div', { class: 'totp-bar-wrap' });
const bar = el('div', { class: 'totp-bar', id: 'soTotpProgress' });
barWrap.appendChild(bar);
wrap.appendChild(barWrap);
startTotpTick();
return wrap;
}
function soFolderField(current) {
const wrap = el('div', { class: 'slideover-field' });
wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Folder'));
const sel = el('select', { id: 'soFolder', class: 'so-input' });
state.folders.forEach(f => {
const opt = el('option', { value: f }, folderLabel(f));
if (f === current) opt.selected = true;
sel.appendChild(opt);
});
wrap.appendChild(sel);
return wrap;
}
function soTagsField() {
const wrap = el('div', { class: 'slideover-field' });
wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Tags'));
const cont = el('div', { class: 'chip-input', id: 'soTagsContainer' });
const input = el('input', {
type: 'text', id: 'soTagsField',
placeholder: 'add a tag…', autocomplete: 'off',
});
// Render existing chips inline (renderSoChips() would no-op here because
// the container isn't attached to the DOM yet, $ would return null).
soState.tags.forEach((t, i) => {
const chip = el('span', { class: 'chip' });
chip.appendChild(el('span', null, t));
const x = el('button', {
type: 'button',
on: { click: ev => {
ev.stopPropagation();
soState.tags.splice(i, 1);
renderSoChips();
soDirtyCheck();
} },
});
x.appendChild(icon('i-x'));
chip.appendChild(x);
cont.appendChild(chip);
});
cont.appendChild(input);
wrap.appendChild(cont);
input.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
const v = input.value.trim().replace(/,/g, '');
if (v && !soState.tags.includes(v)) {
soState.tags.push(v);
renderSoChips();
soDirtyCheck();
}
input.value = '';
} else if (e.key === 'Backspace' && !input.value && soState.tags.length) {
soState.tags.pop();
renderSoChips();
soDirtyCheck();
}
});
return wrap;
}
function renderSoChips() {
const cont = $('#soTagsContainer');
if (!cont) return;
const input = $('#soTagsField');
$$('.chip', cont).forEach(c => c.remove());
soState.tags.forEach((t, i) => {
const chip = el('span', { class: 'chip' });
chip.appendChild(el('span', null, t));
const x = el('button', {
type: 'button',
on: { click: ev => {
ev.stopPropagation();
soState.tags.splice(i, 1);
renderSoChips();
soDirtyCheck();
} },
});
x.appendChild(icon('i-x'));
chip.appendChild(x);
cont.insertBefore(chip, input);
});
}
function soDirtyCheck() {
if (!soState) return;
// New entries: Save button stays visible regardless of dirty state so
// the action is always obvious.
if (soState.id == null) {
const btn = $('#soSaveBtn');
if (btn) btn.style.display = '';
return;
}
const cur = {
title: ($('#soTitle') || {}).value || '',
site: ($('#soSite') || {}).value || '',
username: ($('#soUsername') || {}).value || '',
password: ($('#soPassword') || {}).value || '',
folder: ($('#soFolder') || {}).value || '',
totp: ($('#soTotpSecret') || {}).value || '',
tags: soState.tags.join(','),
};
const dirty =
cur.title !== soState.original.title ||
cur.site !== soState.original.site ||
cur.username !== soState.original.username ||
cur.password !== soState.original.password ||
cur.folder !== soState.original.folder ||
cur.totp !== soState.original.totp ||
cur.tags !== soState.original.tags;
const btn = $('#soSaveBtn');
if (btn) btn.style.display = dirty ? '' : 'none';
}
async function soSave() {
if (!soState) return;
// Flush any uncommitted tag text — user may have typed in the chip
// input without pressing Enter / comma before clicking Save.
const pendingTag = (($('#soTagsField') || {}).value || '').trim();
if (pendingTag && !soState.tags.includes(pendingTag)) {
soState.tags.push(pendingTag);
$('#soTagsField').value = '';
}
const title = ($('#soTitle') || {}).value || '';
const site = $('#soSite').value.trim();
const user = $('#soUsername').value.trim();
const pwd = $('#soPassword').value;
const fold = $('#soFolder').value;
const totp = (($('#soTotpSecret') || {}).value || '').trim();
if (!site || !pwd) return toast('Site and password required', 'error');
const isNew = (soState.id == null);
// Only re-encrypt if password changed; otherwise reuse stored ciphertext.
// For new entries there's nothing stored, always encrypt fresh.
let enc;
if (!isNew && 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 (!isNew && 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;
}
}
const body = JSON.stringify({
site, title: title.trim(), username: user,
encrypted_password: enc.encrypted, iv: enc.iv,
totp_secret: totpEnc, totp_iv: totpIv,
folder: fold, tags: soState.tags.join(','),
});
try {
let targetId = soState.id;
if (isNew) {
const r = await api('/entries', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body,
});
if (r && typeof r.id === 'number') targetId = r.id;
toast('Saved');
} else {
await api('/entries/' + soState.id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body,
});
toast('Saved');
}
await loadEntries();
// Invalidate the vault-health cache so the new/updated entry
// is reflected the next time the dashboard renders.
if (typeof healthCache !== 'undefined') healthCache = null;
const updated = state.entries.find(x => x.id === targetId);
if (updated) openSlideOver(updated.id);
else closeSlideOver();
render();
if (isNew && targetId) {
flashEntry(targetId);
// Auto-fetch favicon for the new entry if the user opted in.
if (state.faviconsEnabled && updated) ensureEntryFavicon(updated);
}
} catch (err) { toast(err.message, 'error'); }
}
function withIcon(name, label) {
const frag = document.createDocumentFragment();
frag.appendChild(icon(name));
frag.appendChild(document.createTextNode(label));
return frag;
}
function field(label, value) {
const wrap = el('div', { class: 'slideover-field' });
wrap.appendChild(el('div', { class: 'slideover-field-label' }, label));
wrap.appendChild(el('div', { class: 'slideover-field-value' }, value));
return wrap;
}
function passwordField(plain) {
const wrap = el('div', { class: 'slideover-field' });
wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Password'));
let revealed = false;
const valueRow = el('div', { class: 'slideover-field-value' });
const span = el('span', { style: 'flex:1;font-family:JetBrains Mono,monospace;user-select:none' }, '••••••••');
const toggle = el('button', { class: 'icon-btn icon-btn-sm', title: 'Show/hide' });
toggle.appendChild(icon('i-eye'));
toggle.addEventListener('click', () => {
revealed = !revealed;
span.textContent = revealed ? plain : '••••••••';
span.style.userSelect = revealed ? 'text' : 'none';
});
const copy = el('button', { class: 'icon-btn icon-btn-sm', title: 'Copy' });
copy.appendChild(icon('i-copy'));
copy.addEventListener('click', () => {
if (Bridge.copySecure(plain, 0)) {
toast('Copied');
} else {
navigator.clipboard.writeText(plain).then(() => toast('Copied'));
}
});
valueRow.appendChild(span);
valueRow.appendChild(toggle);
valueRow.appendChild(copy);
wrap.appendChild(valueRow);
return wrap;
}
function closeSlideOver() {
stopTotpTick();
$('#slideover').classList.remove('is-open');
state.selectedId = null;
renderGrid();
}
// ============================================================
// TAG CHIP INPUT
// ============================================================
// Local mutable list of tags currently in the entry modal. Synced to the
// hidden #entryTags field on every change so saveEntry can read it.
let editingTags = [];
let chipSuggestEl = null;
let chipSuggestActive = -1;
function syncTagsHidden() {
$('#entryTags').value = editingTags.join(',');
}
function renderChips() {
const container = $('#entryTagsInput');
// Wipe existing chips but keep the input element
$$('.chip', container).forEach(c => c.remove());
const input = $('#entryTagsField');
editingTags.forEach((t, i) => {
const chip = el('span', { class: 'chip' });
chip.appendChild(el('span', null, t));
const x = el('button', {
type: 'button',
on: { click: ev => {
ev.stopPropagation();
editingTags.splice(i, 1);
renderChips();
syncTagsHidden();
} },
});
x.appendChild(icon('i-x'));
chip.appendChild(x);
container.insertBefore(chip, input);
});
syncTagsHidden();
}
function setEditingTags(arr) {
editingTags = (arr || []).filter(Boolean).map(t => t.trim()).filter(Boolean);
renderChips();
}
function addTag(raw) {
const t = (raw || '').trim().replace(/,/g, '');
if (!t) return;
if (editingTags.includes(t)) return;
editingTags.push(t);
renderChips();
}
function closeChipSuggest() {
if (chipSuggestEl) { chipSuggestEl.remove(); chipSuggestEl = null; }
chipSuggestActive = -1;
}
function openChipSuggest() {
closeChipSuggest();
const field = $('#entryTagsField');
const q = field.value.trim().toLowerCase();
const existing = allTags();
const candidates = existing
.filter(t => !editingTags.includes(t))
.filter(t => !q || t.toLowerCase().includes(q))
.slice(0, 8);
if (!q && candidates.length === 0) return;
chipSuggestEl = el('div', { class: 'chip-suggestions' });
if (candidates.length === 0) {
chipSuggestEl.appendChild(el('div', { class: 'chip-suggestion-empty' }, 'Press Enter to create "' + q + '"'));
} else {
candidates.forEach((t, i) => {
const item = el('div', {
class: 'chip-suggestion' + (i === 0 ? ' is-active' : ''),
on: { mousedown: ev => { ev.preventDefault(); addTag(t); field.value = ''; closeChipSuggest(); } },
}, t);
chipSuggestEl.appendChild(item);
});
chipSuggestActive = 0;
}
// Position under the chip input
const rect = $('#entryTagsInput').getBoundingClientRect();
chipSuggestEl.style.position = 'fixed';
chipSuggestEl.style.top = (rect.bottom + 4) + 'px';
chipSuggestEl.style.left = rect.left + 'px';
chipSuggestEl.style.width = Math.max(160, rect.width / 2) + 'px';
document.body.appendChild(chipSuggestEl);
}
function moveChipSuggest(dir) {
if (!chipSuggestEl) return;
const items = $$('.chip-suggestion', chipSuggestEl);
if (items.length === 0) return;
items.forEach(it => it.classList.remove('is-active'));
chipSuggestActive = (chipSuggestActive + dir + items.length) % items.length;
items[chipSuggestActive].classList.add('is-active');
}
function selectActiveSuggestion() {
if (!chipSuggestEl || chipSuggestActive < 0) return false;
const items = $$('.chip-suggestion', chipSuggestEl);
if (items[chipSuggestActive]) {
addTag(items[chipSuggestActive].textContent);
$('#entryTagsField').value = '';
closeChipSuggest();
return true;
}
return false;
}
// ============================================================
// ENTRY MODAL (new / edit)
// ============================================================
async function openEntryModal(entry) {
populateFolderSelect();
if (entry) {
$('#entryModalTitle').textContent = 'Edit entry';
$('#entryId').value = entry.id;
$('#entryTitle').value = entry.title || '';
$('#entrySite').value = entry.site;
$('#entryUsername').value = entry.username || '';
$('#entryFolder').value = entry.folder || 'All';
setEditingTags(parseTags(entry.tags));
$('#entryPassword').value = await decryptPwd(entry.encrypted_password, entry.iv);
if ($('#entryPassword').value === '[ERROR]') $('#entryPassword').value = '';
} else {
$('#entryModalTitle').textContent = 'New entry';
$('#entryId').value = '';
$('#entryForm').reset();
$('#entryFolder').value = state.view.startsWith('folder:') ? state.view.slice(7) : 'All';
setEditingTags([]);
}
$('#entryTagsField').value = '';
closeChipSuggest();
updateEntryStrength();
$('#entryModal').classList.remove('is-hidden');
$('#entrySite').focus();
}
function closeEntryModal() {
$('#entryModal').classList.add('is-hidden');
}
function populateFolderSelect() {
const sel = $('#entryFolder');
sel.innerHTML = '';
state.folders.forEach(f => sel.appendChild(el('option', { value: f }, folderLabel(f))));
}
async function saveEntry(e) {
e && e.preventDefault();
// Flush any pending text in the chip input as a final tag
const pending = $('#entryTagsField').value.trim();
if (pending) { addTag(pending); $('#entryTagsField').value = ''; }
const id = $('#entryId').value;
const title = $('#entryTitle').value.trim();
const site = $('#entrySite').value.trim();
const user = $('#entryUsername').value.trim();
const pwd = $('#entryPassword').value;
const fold = $('#entryFolder').value;
const tags = editingTags.join(',');
if (!site || !pwd) return toast('Site and password required', 'error');
const enc = await encryptPwd(pwd);
const body = JSON.stringify({
site, title, username: user, encrypted_password: enc.encrypted, iv: enc.iv,
folder: fold, tags,
});
try {
let savedId = id ? parseInt(id) : null;
if (id) {
await api('/entries/' + id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body,
});
toast('Updated');
} else {
const r = await api('/entries', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body,
});
if (r && typeof r.id === 'number') savedId = r.id;
toast('Saved');
}
closeEntryModal();
await loadEntries();
if (typeof healthCache !== 'undefined') healthCache = null;
render();
if (savedId) flashEntry(savedId);
// Fire-and-forget favicon fetch for the saved entry. Updates the
// card in place when it arrives. No-op when feature is off or
// the entry already has a cached icon.
if (savedId && state.faviconsEnabled) {
const saved = state.entries.find(e => e.id === savedId);
if (saved) ensureEntryFavicon(saved); // honours the toggle
}
} catch (err) {
toast(err.message, 'error');
}
}
async function restoreEntry(id) {
try {
await api('/entries/' + id + '/restore', { method: 'POST', headers: authHeaders() });
toast('Restored');
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 <b>permanently deleted</b>. 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: '<b>' + state.trashed.length + '</b> entries will be deleted forever. This cannot be undone.',
okText: 'Empty trash',
danger: true,
});
if (!ok) return;
try {
await api('/entries/trash/empty', { method: 'DELETE', headers: authHeaders() });
toast('Trash emptied');
await loadTrash();
render();
} catch (err) { toast(err.message, 'error'); }
}
function openTrashActions(id) {
// For trash entries, we don't open the slide-over — actions are inline on the card.
// But user can click outside the buttons to no-op. Could open a read-only view later.
}
async function duplicateEntry(entry) {
if (!entry) return;
try {
const r = await api('/entries', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
site: entry.site,
title: entryDisplayName(entry) + ' (copy)',
username: entry.username || '',
encrypted_password: entry.encrypted_password,
iv: entry.iv,
folder: entry.folder || 'All',
tags: entry.tags || '',
totp_secret: entry.totp_secret || '',
totp_iv: entry.totp_iv || '',
}),
});
await loadEntries();
render();
toast('Duplicated: ' + entryDisplayName(entry));
if (r && typeof r.id === 'number') flashEntry(r.id);
} catch (err) {
toast(err.message || 'Duplicate failed', 'error');
}
}
function flashEntry(id) {
if (!id) return;
setTimeout(() => {
const el = document.querySelector(
'.entry-card[data-id="' + id + '"], ' +
'.entry-row[data-id="' + id + '"]'
);
if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('is-flash');
setTimeout(() => el.classList.remove('is-flash'), 2600);
}, 50);
}
async function deleteEntry(id) {
const e = state.entries.find(x => x.id === id);
if (state.askBeforeDelete) {
const ok = await confirmDialog({
title: 'Move to trash',
message: 'Send <b>' + (e ? e.site : 'this entry') + '</b> to trash? You can restore it later.',
okText: 'Move to trash',
danger: true,
});
if (!ok) return;
}
try {
await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() });
toast('Moved to trash');
closeSlideOver();
await loadEntries();
state.trashedCount = (state.trashedCount || 0) + 1;
render();
} catch (err) { toast(err.message, 'error'); }
}
async function toggleFavorite(id) {
try {
await api('/entries/' + id + '/favorite', { method: 'POST', headers: authHeaders() });
const entry = state.entries.find(e => e.id === id);
if (entry) entry.favorite = entry.favorite ? 0 : 1;
render();
} catch (e) {}
}
async function moveEntryToFolder(id, folder) {
const e = state.entries.find(x => x.id === id);
if (!e || e.folder === folder) return;
try {
await api('/entries/' + id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
site: e.site, title: e.title || '', username: e.username,
encrypted_password: e.encrypted_password, iv: e.iv,
folder, tags: e.tags || '',
}),
});
e.folder = folder;
render();
toast('Moved to ' + folder);
} catch (err) { toast(err.message, 'error'); }
}
async function copyPassword(entry) {
const p = await decryptPwd(entry.encrypted_password, entry.iv);
if (p === '[ERROR]') return toast('Cannot decrypt', 'error');
if (Bridge.copySecure(p, 30000)) {
toast('Password copied · clears in 30s');
} else {
navigator.clipboard.writeText(p).then(() => toast('Password copied · clears in 30s'));
setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000);
}
}
function copyUsername(entry) {
const u = entry.username || '';
if (!u) return toast('No username to copy', 'warning');
if (Bridge.copySecure(u, 0)) {
toast('Username copied');
} else {
navigator.clipboard.writeText(u).then(() => toast('Username copied'));
}
}
// Display helper: when `maskUsernames` setting is on, show only the first 2
// chars followed by '***'. Used in cards/list (but slide-over always reveals).
function displayUsername(u) {
if (!u) return '—';
if (!state.maskUsernames) return u;
if (u.length <= 2) return u + '***';
return u.slice(0, 2) + '***';
}
// ============================================================
// FOLDERS CRUD
// ============================================================
async function deleteFolder(name, entryCount) {
const message = entryCount > 0
? '<b>' + name + '</b> contains <b>' + entryCount + '</b> entries. They will be moved to <i>(no folder)</i>. Continue?'
: 'Delete folder <b>' + name + '</b>?';
const ok = await confirmDialog({
title: 'Delete folder',
message,
okText: 'Delete',
danger: true,
});
if (!ok) return;
try {
await api('/folders/' + encodeURIComponent(name), {
method: 'DELETE',
headers: authHeaders(),
});
if (state.view === 'folder:' + name) state.view = 'all';
await loadFolders();
await loadEntries();
render();
toast('Folder deleted');
} catch (e) { toast(e.message || 'Delete failed', 'error'); }
}
async function addFolder() {
const name = await promptDialog({
title: 'New folder',
message: 'Folder name',
placeholder: 'e.g. Work',
okText: 'Create',
});
if (!name || !name.trim()) return;
const clean = name.trim();
// "All" is reserved as the internal default for "uncategorized" entries
// (and would visually duplicate the "All items" top nav). Reject here
// rather than letting the user create a confusing duplicate.
if (clean.toLowerCase() === 'all') {
toast('"All" is reserved — pick another name', 'warning');
return;
}
try {
await api('/folders', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ name: clean }),
});
await loadFolders();
render();
toast('Folder created');
} catch (e) { toast(e.message, 'error'); }
}
// ============================================================
// PASSWORD GENERATOR
// ============================================================
let genCurrent = '';
function genPassword() {
const len = parseInt($('#genLen').value);
$('#genLenLabel').textContent = len;
let chars = '';
if ($('#genUpper').checked) chars += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
if ($('#genLower').checked) chars += 'abcdefghijklmnopqrstuvwxyz';
if ($('#genNum').checked) chars += '0123456789';
if ($('#genSym').checked) chars += '!@#$%^&*()_+-=[]{}|;:,.<>?';
if (!chars) { $('#genPreview').textContent = 'Select at least one'; return; }
let p = '';
const max = 256 - (256 % chars.length);
const buf = new Uint8Array(1);
for (let i = 0; i < len; i++) {
do { crypto.getRandomValues(buf); } while (buf[0] >= max);
p += chars.charAt(buf[0] % chars.length);
}
genCurrent = p;
$('#genPreview').textContent = p;
}
// genTarget: 'entry' (insert into entry form) or 'standalone' (just copy/dismiss)
let genTarget = 'entry';
function openGen(target) {
genTarget = target || 'entry';
// Show "Use" when targeting an editable field (entry modal or slide-over)
$('#genUse').style.display = (genTarget === 'standalone') ? 'none' : '';
$('#genModal').classList.remove('is-hidden');
genPassword();
}
function closeGen() { $('#genModal').classList.add('is-hidden'); }
// ============================================================
// PASSWORD STRENGTH
// ============================================================
function computeStrength(p) {
let s = 0;
if (p.length >= 8) s += 25;
if (p.length >= 12) s += 15;
if (/[A-Z]/.test(p) && /[a-z]/.test(p)) s += 20;
if (/\d/.test(p)) s += 15;
if (/[^A-Za-z0-9]/.test(p)) s += 25;
return Math.min(100, s);
}
function updateRegStrength() {
const p = $('#regPassword').value;
$('#regStrengthBar').style.setProperty('--strength', computeStrength(p) + '%');
}
function updateEntryStrength() {
const p = $('#entryPassword').value;
$('#entryStrengthBar').style.setProperty('--strength', computeStrength(p) + '%');
}
// ============================================================
// COMMAND PALETTE
// ============================================================
function openPalette() {
$('#cmdPalette').classList.remove('is-hidden');
$('#cmdInput').value = '';
$('#cmdInput').focus();
renderPaletteResults('');
}
function closePalette() { $('#cmdPalette').classList.add('is-hidden'); }
function paletteCommands() {
return [
{ id: 'new', label: 'New entry', icon: 'i-plus', run: () => { closePalette(); openSlideOver(null); } },
{ id: 'lock', label: 'Lock vault', icon: 'i-lock', run: () => { closePalette(); lockVault(); } },
{ id: 'logout', label: 'Sign out', icon: 'i-log-out', run: () => { closePalette(); doLogout(); } },
{ id: 'theme', label: 'Toggle theme', icon: 'i-sun', run: () => { closePalette(); toggleTheme(); } },
{ id: 'all', label: 'Show all items', icon: 'i-globe', run: () => { closePalette(); setView('all'); } },
{ id: 'fav', label: 'Show favorites', icon: 'i-star', run: () => { closePalette(); setView('favorites'); } },
{ id: 'trash', label: 'Show trash', icon: 'i-trash', run: () => { closePalette(); setView('trash'); } },
];
}
function renderPaletteResults(q) {
const cmds = paletteCommands();
const entries = state.entries.map(e => ({
id: 'entry-' + e.id, label: entryDisplayName(e),
// Sub: site (when different from displayName) + username, joined.
sub: [
(e.title && e.title.trim() && e.title.trim() !== e.site) ? e.site : '',
e.username || '',
].filter(Boolean).join(' · '),
icon: 'i-globe', run: () => { closePalette(); openSlideOver(e.id); },
}));
const all = cmds.concat(entries);
q = (q || '').toLowerCase();
const filtered = q ? all.filter(c => c.label.toLowerCase().includes(q) || (c.sub||'').toLowerCase().includes(q)) : all;
const out = $('#cmdResults');
out.innerHTML = '';
filtered.slice(0, 50).forEach((c, i) => {
const it = el('div', {
class: 'cmd-item' + (i === 0 ? ' is-active' : ''),
on: { click: c.run },
});
it.appendChild(icon(c.icon));
it.appendChild(el('span', null, c.label));
if (c.sub) it.appendChild(el('span', { style: 'color:var(--text-faint);font-size:11px;margin-left:auto' }, c.sub));
out.appendChild(it);
});
}
// ============================================================
// IN-APP CONFIRM / PROMPT (no native alerts)
// ============================================================
let confirmResolver = null;
function confirmDialog(opts) {
// opts: { title, message, okText, cancelText, danger }
opts = opts || {};
$('#confirmTitle').textContent = opts.title || 'Confirm';
$('#confirmMessage').innerHTML = opts.message || 'Are you sure?';
$('#confirmOkBtn').lastChild.nodeValue = ' ' + (opts.okText || 'Confirm');
$('#confirmCancelBtn').textContent = opts.cancelText || 'Cancel';
$('#confirmOkBtn').classList.toggle('is-danger', !!opts.danger);
$('#confirmInputField').classList.add('is-hidden');
$('#confirmModal').classList.remove('is-hidden');
setTimeout(() => $('#confirmOkBtn').focus(), 50);
return new Promise(res => { confirmResolver = res; });
}
function promptDialog(opts) {
// opts: { title, message, okText, placeholder, value, password }
opts = opts || {};
$('#confirmTitle').textContent = opts.title || 'Enter value';
$('#confirmMessage').innerHTML = opts.message || '';
$('#confirmOkBtn').lastChild.nodeValue = ' ' + (opts.okText || 'OK');
$('#confirmCancelBtn').textContent = 'Cancel';
$('#confirmOkBtn').classList.remove('is-danger');
$('#confirmInputField').classList.remove('is-hidden');
$('#confirmInput').value = opts.value || '';
$('#confirmInput').placeholder = opts.placeholder || '';
// Allow password-style masking (used by encrypted import/export).
$('#confirmInput').type = opts.password ? 'password' : 'text';
$('#confirmModal').classList.remove('is-hidden');
setTimeout(() => $('#confirmInput').focus(), 50);
return new Promise(res => { confirmResolver = res; });
}
function closeConfirm(value) {
$('#confirmModal').classList.add('is-hidden');
if (confirmResolver) {
const cb = confirmResolver;
confirmResolver = null;
cb(value);
}
}
// ============================================================
// RE-AUTH MODAL + EXPORT
// ============================================================
let reauthResolve = null;
function askReauth(message) {
return new Promise(resolve => {
reauthResolve = resolve;
$('#reauthMessage').textContent = message || 'This action requires your master password.';
$('#reauthPassword').value = '';
$('#reauthModal').classList.remove('is-hidden');
setTimeout(() => $('#reauthPassword').focus(), 50);
});
}
function closeReauth(ok) {
$('#reauthModal').classList.add('is-hidden');
if (reauthResolve) {
const pwd = ok ? $('#reauthPassword').value : null;
const cb = reauthResolve;
reauthResolve = null;
cb(pwd);
}
}
// ============================================================
// QUICK UNLOCK — remember on this device (DPAPI-backed)
// ============================================================
//
// User-controlled convenience feature. When opted in, the current vault
// state (raw AES key + salt + username + session token) is bundled into
// a JSON blob and handed to the Delphi side, which DPAPI-encrypts it
// (CRYPTPROTECT_CURRENT_USER) and stashes the blob on disk. Subsequent
// app starts can recover the entire session without re-entering the
// master password.
//
// Honest trade-off: the encrypted blob is readable by ANY process
// running as the same Windows user. The protection is no stronger than
// the Windows account itself. Users with Windows Hello / fingerprint /
// PIN configured at the OS level get biometric gating transitively
// (via login). Without that, this is "remember on trusted device".
//
// Only available when running inside the Delphi host (Bridge.active);
// the PHP standalone has no DPAPI equivalent.
// Resolver for the Delphi → JS callback. Delphi side fires
// Bridge.onQuickUnlockResult(b64|null) after processing cmd://quickunlock/get.
let quickUnlockResolver = null;
// Same for the status query.
let quickUnlockStatusResolver = null;
function bridgeRequestQuickUnlock() {
if (!Bridge.active) return Promise.resolve(null);
return new Promise(resolve => {
quickUnlockResolver = resolve;
// Safety: if Delphi never responds, time out after 3 s so the auth
// screen doesn't hang. Falls back to master-pw login.
setTimeout(() => {
if (quickUnlockResolver === resolve) {
quickUnlockResolver = null;
resolve(null);
}
}, 3000);
window.location.href = 'cmd://quickunlock/get';
});
}
let clipboardReadResolver = null;
function bridgeReadClipboard() {
if (!Bridge.active) return Promise.resolve('');
return new Promise(resolve => {
clipboardReadResolver = resolve;
setTimeout(() => {
if (clipboardReadResolver === resolve) {
clipboardReadResolver = null;
resolve('');
}
}, 1500);
window.location.href = 'cmd://clipboard/read';
});
}
Bridge.onClipboardRead = function(text) {
if (clipboardReadResolver) {
clipboardReadResolver(text || '');
clipboardReadResolver = null;
}
};
function bridgeQuickUnlockStatus() {
if (!Bridge.active) return Promise.resolve(false);
return new Promise(resolve => {
quickUnlockStatusResolver = resolve;
setTimeout(() => {
if (quickUnlockStatusResolver === resolve) {
quickUnlockStatusResolver = null;
resolve(false);
}
}, 2000);
window.location.href = 'cmd://quickunlock/status';
});
}
// Patch Bridge.onQuickUnlockResult / Status to feed the resolvers above.
Bridge.onQuickUnlockResult = function(b64) {
if (quickUnlockResolver) {
const cb = quickUnlockResolver;
quickUnlockResolver = null;
cb(b64);
}
};
Bridge.onQuickUnlockStatus = function(configured) {
if (quickUnlockStatusResolver) {
const cb = quickUnlockStatusResolver;
quickUnlockStatusResolver = null;
cb(!!configured);
}
};
async function enableQuickUnlock() {
if (!Bridge.active) return toast('Quick unlock requires the Delphi app', 'warning');
if (!state.cryptoKey) return toast('Unlock the vault first', 'warning');
const masterPwd = await askReauth(
'Confirm your master password to enable Quick unlock on this device.');
if (!masterPwd) return;
try {
const verifier = await computeVerifier(
masterPwd, state.salt, state.kdfIterations || 100000);
await api('/reauth', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ verifier: verifier }),
});
} catch (err) {
return toast('Wrong master password', 'error');
}
// Export the raw key + identity. We don't store the session token —
// tryQuickUnlock re-logs in with a verifier derived from the key,
// which always yields a fresh server session (the stored token would
// expire after 24 h and break cold-start restore on a moved exe).
const raw = await crypto.subtle.exportKey('raw', state.cryptoKey);
const blob = JSON.stringify({
v: 2,
username: state.username,
salt: state.salt,
kdfIterations: state.kdfIterations,
key: bytesToBase64(raw),
});
const b64 = bytesToBase64(new TextEncoder().encode(blob));
window.location.href = 'cmd://quickunlock/store?data=' + encodeURIComponent(b64);
localStorage.setItem('quickUnlockEnabled', '1');
state.quickUnlockEnabled = true;
if ($('#quickUnlockStatus')) updateQuickUnlockUI();
toast('Quick unlock enabled');
}
async function disableQuickUnlock() {
window.location.href = 'cmd://quickunlock/clear';
localStorage.removeItem('quickUnlockEnabled');
state.quickUnlockEnabled = false;
if ($('#quickUnlockStatus')) updateQuickUnlockUI();
toast('Quick unlock disabled');
}
function updateQuickUnlockUI() {
const lbl = $('#quickUnlockStatus');
const btn = $('#quickUnlockToggleBtn');
if (!lbl || !btn) return;
if (state.quickUnlockEnabled) {
lbl.textContent = 'Enabled on this device.';
btn.textContent = 'Disable';
btn.classList.add('is-danger');
} else {
lbl.textContent = 'Disabled.';
btn.textContent = 'Enable on this device';
btn.classList.remove('is-danger');
}
}
// Try to unlock via DPAPI. Returns true if the vault is now unlocked,
// false otherwise (caller falls back to master-pw login).
async function tryQuickUnlock() {
if (!Bridge.active) return false;
const b64 = await bridgeRequestQuickUnlock();
if (!b64) return false;
localStorage.setItem('quickUnlockEnabled', '1');
state.quickUnlockEnabled = true;
let parsed;
try {
const jsonStr = new TextDecoder().decode(base64ToBytes(b64));
parsed = JSON.parse(jsonStr);
} catch (e) {
return false;
}
if (!parsed || !parsed.key || !parsed.salt || !parsed.username) return false;
// Restore identity + crypto key from the blob.
state.username = parsed.username;
state.salt = parsed.salt;
state.kdfIterations = parsed.kdfIterations || 600000;
const rawKey = base64ToBytes(parsed.key);
try {
state.cryptoKey = await crypto.subtle.importKey(
'raw', rawKey,
{ name: 'AES-GCM' }, true, ['encrypt', 'decrypt']);
} catch (e) {
return false;
}
// Always request a fresh session token via /login using the key-derived
// verifier. The stored token (if any) may have expired or been cleaned
// up by the server's session GC, which used to drop the user back to
// the login screen on cold start.
try {
const verifier = bytesToHex(rawKey);
const r = await api('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: state.username, verifier }),
});
state.token = r.token;
state.csrf = r.csrfToken;
if (r.salt) state.salt = r.salt;
if (r.kdfIterations) state.kdfIterations = r.kdfIterations;
} catch (e) {
// Login failed — vault credentials may have changed (master pw
// rotation) since Quick Unlock was set up. Force a fresh master-pw
// login; the user will need to re-enable Quick Unlock afterwards.
return false;
}
sessionStorage.setItem('username', state.username);
sessionStorage.setItem('salt', state.salt);
sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
sessionStorage.setItem('authToken', state.token);
sessionStorage.setItem('csrfToken', state.csrf);
await persistCryptoKey();
state.locked = false;
return true;
}
// ============================================================
// RECOVERY KEY — one-shot emergency access
// ============================================================
//
// Generated at the user's request from Settings. The plaintext code is
// shown exactly once; the server stores only SHA-256(code) for lookup
// + an AES-GCM wrap of the current vault key under a KEK derived from
// PBKDF2(code, kdfSalt, 600k).
//
// Recovery flow (master pw forgotten):
// 1. Auth screen → "Use recovery code" → enter username + code
// 2. Server hashes code, looks up user, verifies match, DELETES the
// recovery row (single-use), returns wrapped key + KEK salt +
// a fresh session.
// 3. Client derives the KEK, unwraps the AES key.
// 4. Client immediately forces a master-password change so the
// account isn't left with the recovery code's KEK as the only
// escape hatch.
// Random recovery code: 16 chars in 4 groups of 4. ~96 bits entropy
// from a 36-char alphabet (no ambiguous chars: no 0/O/I/l/1) so the
// printed form is misreading-resistant.
function generateRecoveryCode() {
const A = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // 32 chars
const bytes = crypto.getRandomValues(new Uint8Array(16));
let s = '';
for (let i = 0; i < 16; i++) {
if (i > 0 && i % 4 === 0) s += '-';
s += A[bytes[i] % A.length];
}
return s;
}
async function sha256HexLocal(input) {
const buf = new TextEncoder().encode(input);
const hashBuf = await crypto.subtle.digest('SHA-256', buf);
const bytes = new Uint8Array(hashBuf);
let hex = '';
for (const b of bytes) hex += b.toString(16).padStart(2, '0');
return hex;
}
// Derive a KEK from the recovery code + per-row salt, then wrap the
// supplied AES key bytes under it. Returns base64 ciphertext + IV.
async function wrapAesKeyForRecovery(aesKeyBytes, recoveryCode, kdfSaltHex) {
const saltBytes = new TextEncoder().encode(kdfSaltHex); // match deriveKey's quirk
const km = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(recoveryCode),
'PBKDF2', false, ['deriveKey']);
const kek = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: saltBytes, iterations: 600000, hash: 'SHA-256' },
km,
{ name: 'AES-GCM', length: 256 },
false, ['encrypt', 'decrypt']);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, kek, aesKeyBytes);
return { wrappedKey: bytesToBase64(ct), wrappedIv: bytesToBase64(iv) };
}
async function unwrapAesKeyFromRecovery(wrappedKeyB64, wrappedIvB64, recoveryCode, kdfSaltHex) {
const saltBytes = new TextEncoder().encode(kdfSaltHex);
const km = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(recoveryCode),
'PBKDF2', false, ['deriveKey']);
const kek = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: saltBytes, iterations: 600000, hash: 'SHA-256' },
km,
{ name: 'AES-GCM', length: 256 },
false, ['encrypt', 'decrypt']);
const iv = base64ToBytes(wrappedIvB64);
const ct = base64ToBytes(wrappedKeyB64);
return await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, kek, ct); // raw bytes
}
// Generate a new recovery key for the logged-in user. Shows the plaintext
// code in a modal that the user must explicitly acknowledge before closing.
async function doGenerateRecoveryKey() {
if (!state.cryptoKey) { return toast('Vault locked', 'warning'); }
const masterPwd = await askReauth(
'Confirm your master password to generate a recovery key.');
if (!masterPwd) return;
// Generate the code + a fresh per-row salt for the KEK PBKDF2. Salt is
// per-recovery so regenerating doesn't reuse the same KDF parameters.
const code = generateRecoveryCode();
const codeHash = await sha256HexLocal(code);
const kdfSalt = randomHexSalt();
// Export the current AES key as raw bytes so we can wrap it under
// the recovery KEK. The export only works because deriveKey was
// called with `extractable=true` — already the case in our code.
const rawKey = await crypto.subtle.exportKey('raw', state.cryptoKey);
const { wrappedKey, wrappedIv } = await wrapAesKeyForRecovery(
rawKey, code, kdfSalt);
try {
// Send a verifier instead of the master pw — server proves the
// user still knows the master pw without ever seeing the plaintext.
const verifier = await computeVerifier(
masterPwd, state.salt, state.kdfIterations || 100000);
await api('/recovery-key/setup', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
verifier: verifier,
codeHash: codeHash,
kdfSalt: kdfSalt,
wrappedKey: wrappedKey,
wrappedIv: wrappedIv,
}),
});
} catch (err) {
return toast('Setup failed: ' + (err.message || ''), 'error');
}
// Refresh the Settings button label
state.recoveryConfigured = true;
if ($('#recoveryStatus')) updateRecoveryStatusLabel();
// Show the code ONCE. Use the confirm modal so the user has to
// explicitly click "I saved it" before the value vanishes.
//
// Wire the inline Copy button BEFORE awaiting the dialog: confirmDialog
// injects the HTML synchronously, so a 0-ms task fires after the DOM
// is in place but before the user can interact. CSP forbids inline
// onclick handlers, hence the addEventListener route.
setTimeout(() => {
const btn = document.getElementById('copyRecoveryCodeBtn');
if (!btn) return;
btn.addEventListener('click', () => {
// Same path as password copy: secure-clipboard via Delphi
// (excluded from Win+V history, auto-cleared after 30s) when
// running embedded, navigator.clipboard with manual scrub
// otherwise.
if (Bridge.copySecure(code, 30000)) {
toast('Recovery code copied · clears in 30s');
} else {
navigator.clipboard.writeText(code).then(() => {
toast('Recovery code copied · clears in 30s');
setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000);
});
}
});
}, 0);
await confirmDialog({
title: 'Your recovery code',
message: '<p>Save this code somewhere safe (password manager, ' +
'safe deposit box, printed copy). It will <b>not</b> be ' +
'shown again.</p>' +
'<div style="display:flex;align-items:center;gap:8px;' +
'padding:14px;background:var(--bg-elev);border-radius:6px">' +
'<span style="font-family:JetBrains Mono,monospace;font-size:20px;' +
'letter-spacing:2px;flex:1;text-align:center;user-select:all">' +
code + '</span>' +
'<button type="button" class="btn btn-ghost btn-sm" ' +
'id="copyRecoveryCodeBtn" title="Copy code">' +
'<svg><use href="#i-copy"/></svg> Copy</button>' +
'</div>' +
'<p style="color:var(--text-dim);font-size:12px">' +
'Using it lets you recover access if you forget your master ' +
'password. The code can be used up to <b>5 times</b>, and ' +
'is permanently erased as soon as you successfully change ' +
'your master password — so set a new one right after ' +
'recovering.</p>',
okText: 'I saved it',
});
toast('Recovery code generated');
}
async function doRemoveRecoveryKey() {
const ok = await confirmDialog({
title: 'Remove recovery key',
message: 'You will lose your ability to recover this account if you ' +
'forget the master password. Continue?',
okText: 'Remove',
danger: true,
});
if (!ok) return;
try {
await api('/recovery-key', {
method: 'DELETE',
headers: authHeaders(),
});
state.recoveryConfigured = false;
if ($('#recoveryStatus')) updateRecoveryStatusLabel();
toast('Recovery key removed');
} catch (err) { toast(err.message, 'error'); }
}
function updateRecoveryStatusLabel() {
const lbl = $('#recoveryStatus');
const setupBtn = $('#recoverySetupBtn');
const removeBtn = $('#recoveryRemoveBtn');
if (!lbl) return;
if (state.recoveryConfigured) {
const left = state.recoveryRemainingUses;
const usesNote = (typeof left === 'number' && left < 5)
? ' (' + left + ' use' + (left === 1 ? '' : 's') + ' left)'
: '';
lbl.textContent = 'Recovery key is configured.' + usesNote;
if (setupBtn) setupBtn.textContent = 'Regenerate code';
if (removeBtn) removeBtn.style.display = '';
} else {
lbl.textContent = 'No recovery key set.';
if (setupBtn) setupBtn.textContent = 'Generate recovery code';
if (removeBtn) removeBtn.style.display = 'none';
}
}
async function refreshRecoveryStatus() {
try {
const r = await api('/recovery-key/status', { headers: authHeaders() });
state.recoveryConfigured = !!r.configured;
state.recoveryRemainingUses = (typeof r.remaining_uses === 'number') ? r.remaining_uses : 5;
updateRecoveryStatusLabel();
} catch (e) { /* ignore */ }
}
// Recovery redeem flow — called from the auth screen when the user clicks
// "Use a recovery code". Prompts for username + code, redeems, unwraps the
// vault key, immediately forces a master password change.
async function doRecoveryRedeem() {
const u = await promptDialog({
title: 'Recover access',
message: 'Enter your username — we\'ll ask for the recovery code next.',
placeholder: 'Username',
okText: 'Continue',
});
if (!u) return;
const code = await promptDialog({
title: 'Enter recovery code',
message: 'Recovery codes look like XXXX-XXXX-XXXX-XXXX. They allow ' +
'up to 5 uses, and are erased when you set a new master ' +
'password — remember to generate a fresh code afterwards.',
placeholder: 'XXXX-XXXX-XXXX-XXXX',
okText: 'Recover',
password: true,
});
if (!code) return;
let r;
try {
r = await api('/recovery-key/redeem', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: u.trim(), code: code.trim() }),
});
} catch (err) {
if (err.status === 429 && err.body && err.body.retry_after) {
return showLockoutCountdown(err.body.retry_after);
}
return toast('Recovery failed: ' + (err.message || 'invalid code'), 'error');
}
// Unwrap the vault key with the code the user just typed.
let rawKey;
try {
rawKey = await unwrapAesKeyFromRecovery(
r.wrappedKey, r.wrappedIv, code.trim(), r.kdfSalt);
} catch (e) {
return toast('Could not decrypt vault — wrong code?', 'error');
}
// Reconstitute state from the new session.
state.token = r.token;
state.csrf = r.csrfToken;
state.salt = r.salt;
state.username = u.trim();
state.kdfIterations = r.kdfIterations || 600000;
sessionStorage.setItem('authToken', state.token);
sessionStorage.setItem('csrfToken', state.csrf);
sessionStorage.setItem('salt', state.salt);
sessionStorage.setItem('username', state.username);
sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
// Import the raw key bytes as a fresh AES-GCM CryptoKey (extractable
// so master-pw change can later re-export and re-wrap as needed).
state.cryptoKey = await crypto.subtle.importKey(
'raw', rawKey, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']);
await persistCryptoKey();
const remaining = (typeof r.remainingUses === 'number') ? r.remainingUses : 0;
if (remaining <= 0) {
toast('Last recovery use — set a new master password now or the code is gone forever', 'warning');
} else {
toast('Recovery code used. ' + remaining + ' use(s) left before it expires. Change your master password now.', 'warning');
}
state.justRecovered = true;
await enterApp();
setTimeout(openChangeMasterModal, 300);
}
// ============================================================
// CHANGE MASTER PASSWORD
// ============================================================
//
// Two-step flow:
// 1. Modal collects current pw + new pw (×2) + validates locally.
// 2. Client re-encrypts every entry under the new key (derived from
// new pw + freshly-generated salt) and sends the whole payload to
// /change-master-password. Server verifies current pw, then commits
// user row + all entry ciphertexts in one transaction.
// On success: state.salt + state.cryptoKey are swapped, the user stays
// logged in (current session preserved), all OTHER sessions invalidated.
// 64-char hex salt — matches the format the server expects and the shape
// produced by Delphi RandomHex(32). Crypto-secure RNG.
function randomHexSalt() {
const bytes = crypto.getRandomValues(new Uint8Array(32));
let hex = '';
for (const b of bytes) hex += b.toString(16).padStart(2, '0');
return hex;
}
function openChangeMasterModal() {
$('#cmCurrentPwd').value = '';
$('#cmNewPwd').value = '';
$('#cmConfirmPwd').value = '';
const errEl = $('#cmError');
if (errEl) { errEl.textContent = ''; errEl.style.display = 'none'; }
const curInput = $('#cmCurrentPwd');
const curWrap = curInput.closest('.field') || curInput.parentElement;
const titleEl = $('#changeMasterTitle');
if (state.justRecovered) {
if (curWrap) curWrap.classList.add('is-hidden');
if (curInput) curInput.required = false;
if (titleEl) titleEl.textContent = 'Set new master password';
} else {
if (curWrap) curWrap.classList.remove('is-hidden');
if (curInput) curInput.required = true;
if (titleEl) titleEl.textContent = 'Change master password';
}
$('#changeMasterModal').classList.remove('is-hidden');
setTimeout(() => {
if (state.justRecovered) $('#cmNewPwd').focus();
else $('#cmCurrentPwd').focus();
}, 50);
}
function closeChangeMasterModal() {
$('#changeMasterModal').classList.add('is-hidden');
}
function showCmError(msg) {
const el = $('#cmError');
if (!el) return;
el.textContent = msg;
el.style.display = '';
}
async function doChangeMasterPassword() {
const recoveryMode = !!state.justRecovered;
const curPwd = $('#cmCurrentPwd').value;
const newPwd = $('#cmNewPwd').value;
const confPwd = $('#cmConfirmPwd').value;
if (!recoveryMode && !curPwd) return showCmError('All fields are required');
if (!newPwd || !confPwd) return showCmError('All fields are required');
if (newPwd.length < 8) return showCmError('New password must be at least 8 characters');
if (newPwd !== confPwd) return showCmError('New password and confirmation do not match');
if (!recoveryMode && newPwd === curPwd) return showCmError('New password must differ from the current one');
// Disable the confirm button so a double-click doesn't fire two
// re-encryption passes in parallel.
const btn = $('#cmConfirmBtn');
if (btn) btn.disabled = true;
try {
// Step 1: generate the new salt and derive the new AES key + verifier.
// Also compute the verifier for the CURRENT pw so the server can
// authenticate the change without ever seeing the plaintext.
const newSalt = randomHexSalt();
const newDerived = await deriveKeyAndVerifier(newPwd, newSalt, 600000);
const newKey = newDerived.cryptoKey;
let currentVerifier;
if (recoveryMode) {
const rawCurrentKey = new Uint8Array(await crypto.subtle.exportKey('raw', state.cryptoKey));
currentVerifier = bytesToHex(rawCurrentKey);
} else {
currentVerifier = await computeVerifier(
curPwd, state.salt, state.kdfIterations || 100000);
}
// Step 2: re-encrypt every entry's password AND every entry's TOTP
// secret (if present) under the new key. The current state.cryptoKey
// still decrypts the existing ciphertext.
const encrypted = [];
for (const e of state.entries) {
const plain = await decryptPwd(e.encrypted_password, e.iv);
if (plain === '[ERROR]') {
throw new Error('Could not decrypt entry id=' + e.id);
}
// Swap the key around encryptPwd so it picks up the new one.
const oldKey = state.cryptoKey;
state.cryptoKey = newKey;
try {
const re = await encryptPwd(plain);
let totpEnc = '', totpIv = '';
if (e.totp_secret && e.totp_iv) {
state.cryptoKey = oldKey;
const plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv);
state.cryptoKey = newKey;
if (plainTotp !== '[ERROR]') {
const t = await encryptPwd(plainTotp);
totpEnc = t.encrypted;
totpIv = t.iv;
}
}
encrypted.push({
id: e.id,
encrypted_password: re.encrypted,
iv: re.iv,
totp_secret: totpEnc,
totp_iv: totpIv,
});
} finally {
state.cryptoKey = oldKey; // restore until server confirms
}
}
// Step 3: send the atomic request. Server verifies the current pw,
// updates the user row, swaps every entry's ciphertext, returns
// the new salt + iter count.
const r = await api('/change-master-password', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
currentVerifier: currentVerifier,
newVerifier: newDerived.verifier,
newSalt: newSalt,
entries: encrypted,
}),
});
// Step 4: server committed → switch the in-memory key & salt, refresh
// the cached ciphertexts, persist for F5 survival.
state.salt = r.salt || newSalt;
state.kdfIterations = r.kdfIterations || 600000;
state.cryptoKey = newKey;
await persistCryptoKey();
sessionStorage.setItem('salt', state.salt);
sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
for (let i = 0; i < state.entries.length; i++) {
const nc = encrypted[i];
state.entries[i].encrypted_password = nc.encrypted_password;
state.entries[i].iv = nc.iv;
state.entries[i].totp_secret = nc.totp_secret || null;
state.entries[i].totp_iv = nc.totp_iv || null;
}
// The DPAPI quick-unlock blob (if any) still holds the OLD AES key
// bundle, which would unlock to entries encrypted with the new key
// → unreadable. Clear it; user can re-enable from Settings.
if (Bridge.active && localStorage.getItem('quickUnlockEnabled') === '1') {
window.location.href = 'cmd://quickunlock/clear';
localStorage.removeItem('quickUnlockEnabled');
state.quickUnlockEnabled = false;
}
state.justRecovered = false;
closeChangeMasterModal();
toast(recoveryMode ? 'New master password set' : 'Master password changed · other sessions signed out');
} catch (err) {
if (err.status === 401) {
showCmError('Current password is incorrect');
} else if (err.status === 429 && err.body && err.body.retry_after) {
showCmError('Account locked, try again in ' +
Math.ceil(err.body.retry_after / 60) + ' min');
} else {
showCmError('Failed: ' + (err.message || 'unknown error'));
}
} finally {
if (btn) btn.disabled = false;
}
}
// ============================================================
// ENCRYPTED EXPORT CONTAINER
// ============================================================
//
// File format (JSON):
// {
// "format": "pm-encrypted-export-v1",
// "kdf": "pbkdf2-sha256",
// "kdf_iterations": 600000,
// "kdf_salt": "<base64 random 32 bytes>",
// "iv": "<base64 random 12 bytes>",
// "ciphertext":"<base64 AES-GCM ciphertext of JSON.stringify(payload)>",
// "created_at": "<ISO timestamp>"
// }
// payload = same shape produced by the plaintext exporter (entries array).
//
// The encryption password is INDEPENDENT of the master password — the
// user picks it at export time and provides it again at import time.
// Decoupling means a master-password change doesn't brick old backups,
// and the backup can be shared without revealing the master pw.
function bytesToBase64(arr) {
if (arr instanceof ArrayBuffer) arr = new Uint8Array(arr);
let s = '';
for (let i = 0; i < arr.length; i++) s += String.fromCharCode(arr[i]);
return btoa(s);
}
function base64ToBytes(b64) {
return Uint8Array.from(atob(b64), c => c.charCodeAt(0));
}
// Derive an AES-GCM key from a user-chosen export password + random salt.
// Uses the same 600k iteration PBKDF2 as the rest of the app.
async function deriveExportKey(password, saltBytes, iterations) {
const km = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(password),
'PBKDF2', false, ['deriveKey']);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: saltBytes, iterations: iterations, hash: 'SHA-256' },
km,
{ name: 'AES-GCM', length: 256 },
false, ['encrypt', 'decrypt']);
}
async function encryptExportPayload(payloadObj, exportPwd) {
const plaintext = new TextEncoder().encode(JSON.stringify(payloadObj));
const salt = crypto.getRandomValues(new Uint8Array(32));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await deriveExportKey(exportPwd, salt, 600000);
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext);
return {
format: 'pm-encrypted-export-v1',
kdf: 'pbkdf2-sha256',
kdf_iterations: 600000,
kdf_salt: bytesToBase64(salt),
iv: bytesToBase64(iv),
ciphertext: bytesToBase64(ct),
created_at: new Date().toISOString(),
};
}
async function decryptExportContainer(container, exportPwd) {
const salt = base64ToBytes(container.kdf_salt);
const iv = base64ToBytes(container.iv);
const ct = base64ToBytes(container.ciphertext);
const key = await deriveExportKey(exportPwd, salt, container.kdf_iterations || 600000);
const plainBuf = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
return JSON.parse(new TextDecoder().decode(plainBuf));
}
// ============================================================
// IMPORT — JSON (native round-trip) + CSV (universal)
// ============================================================
//
// Two supported input formats:
// 1. Native JSON: the same shape produced by doExport() above
// { version, exported_at, username, entries: [
// { site, username, password, folder, tags, favorite, ... }
// ]}
// 2. CSV: with a header row. Column names are mapped heuristically so
// exports from Bitwarden / KeePass / Chrome / 1Password generally
// "just work" without manual column mapping.
//
// Each parsed entry is encrypted client-side with the vault key (same
// flow as a single-entry add), then sent to /entries/bulk-import as one
// transactional batch.
// Minimal RFC 4180-ish CSV parser. Handles quoted fields, escaped quotes
// (""), commas inside quotes, and CRLF line endings. Returns an array of
// arrays (rows × columns). No streaming — fine for the ~MB-scale imports
// a password manager realistically deals with.
function parseCSV(text) {
const rows = [];
let row = [], field = '', inQuotes = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (inQuotes) {
if (c === '"') {
if (text[i + 1] === '"') { field += '"'; i++; } // escaped ""
else inQuotes = false;
} else field += c;
} else {
if (c === '"') inQuotes = true;
else if (c === ',') { row.push(field); field = ''; }
else if (c === '\n' || c === '\r') {
if (c === '\r' && text[i + 1] === '\n') i++; // CRLF
row.push(field); field = '';
if (row.length > 1 || (row.length === 1 && row[0] !== '')) rows.push(row);
row = [];
} else field += c;
}
}
// Flush trailing field/row (file without final newline)
if (field !== '' || row.length > 0) { row.push(field); rows.push(row); }
return rows;
}
// Header heuristics: pick the first matching column name (case-insensitive,
// underscore/space-tolerant). Returns null if no candidate header matches.
function findColumn(headers, candidates) {
const norm = s => String(s || '').toLowerCase().replace(/[\s_-]+/g, '');
const cand = candidates.map(norm);
for (let i = 0; i < headers.length; i++) {
if (cand.indexOf(norm(headers[i])) >= 0) return i;
}
return null;
}
// Parse a CSV text into an array of plaintext entries
// ({ site, username, password, folder, tags, totp_secret }). Returns
// { entries, skipped, columns } so the preview can show what was matched.
function parseEntriesFromCSV(text) {
const rows = parseCSV(text);
if (rows.length < 2) {
throw new Error('CSV needs a header row and at least one data row');
}
const headers = rows[0];
// Candidate names per format observed in real exports:
// Bitwarden CSV : folder, name, login_uri, login_username, login_password, login_totp, notes
// KeePass CSV : Title, URL, Username, Password, Group, Notes
// Chrome/Edge : name, url, username, password
// 1Password CSV : Title, URL, Username, Password, Notes
// findColumn normalizes (lowercase, strip _ and -) so 'login_uri' and
// 'loginuri' both match the same candidate.
const colSite = findColumn(headers, ['name', 'title', 'url', 'site', 'website', 'login_uri', 'login_url', 'web_site']);
const colUser = findColumn(headers, ['login_username', 'username', 'user', 'login', 'email', 'user_name']);
const colPwd = findColumn(headers, ['login_password', 'password', 'pass', 'pwd']);
const colFolder = findColumn(headers, ['folder', 'group', 'category', 'path', 'collection']);
const colTags = findColumn(headers, ['tags', 'labels']);
const colNotes = findColumn(headers, ['notes', 'note', 'comment', 'comments']);
const colTotp = findColumn(headers, ['login_totp', 'totp', 'totp_secret', 'otp', 'otpauth', 'authenticator', 'two_factor', 'twofa']);
if (colSite === null && colUser === null)
throw new Error('No recognizable site/url or username column in CSV header');
if (colPwd === null)
throw new Error('No recognizable password column in CSV header');
const entries = [];
let skipped = 0;
for (let i = 1; i < rows.length; i++) {
const r = rows[i];
const site = (colSite !== null ? r[colSite] : '').trim() ||
(colUser !== null ? r[colUser] : '').trim();
const pwd = (colPwd !== null ? r[colPwd] : '');
if (!site || !pwd) { skipped++; continue; }
// Tags: combine the tags column and any free-form notes into a
// comma-separated string. Notes often contain useful metadata we
// don't want to drop on the floor.
let tagsArr = [];
if (colTags !== null) {
String(r[colTags] || '').split(/[,;]/).forEach(t => {
t = t.trim();
if (t) tagsArr.push(t);
});
}
if (colNotes !== null) {
const n = String(r[colNotes] || '').trim();
if (n && n.length < 80) tagsArr.push(n); // long notes become noise as tags
}
// TOTP: support raw base32 OR full otpauth:// URI in the cell.
let totp = '';
if (colTotp !== null) {
const raw = String(r[colTotp] || '').trim();
totp = parseOtpAuthUri(raw) || raw;
}
entries.push({
site: site,
username: (colUser !== null ? r[colUser] : '').trim(),
password: pwd,
folder: (colFolder !== null ? r[colFolder] : '').trim() || 'All',
tags: tagsArr.join(','),
totp_secret: totp,
});
}
return { entries, skipped, columns: {
site: colSite, username: colUser, password: colPwd,
folder: colFolder, tags: colTags, notes: colNotes, totp: colTotp,
} };
}
// Parse a native JSON export. Forgiving: accepts both our own format and
// a flat array of entry objects.
function parseEntriesFromJSON(text) {
let data;
try { data = JSON.parse(text); }
catch (e) { throw new Error('Invalid JSON: ' + e.message); }
const raw = Array.isArray(data) ? data : (data.entries || []);
if (!Array.isArray(raw) || raw.length === 0)
throw new Error('No entries in JSON file');
const entries = [];
let skipped = 0;
for (const e of raw) {
if (!e || typeof e !== 'object') { skipped++; continue; }
const site = String(e.site || e.url || e.name || '').trim();
const pwd = String(e.password || '');
if (!site || !pwd) { skipped++; continue; }
const tagsVal = e.tags;
const tagsStr = Array.isArray(tagsVal) ? tagsVal.join(',')
: String(tagsVal || '');
entries.push({
site: site,
username: String(e.username || e.user || e.login || '').trim(),
password: pwd,
folder: String(e.folder || e.group || 'All').trim() || 'All',
tags: tagsStr,
totp_secret: String(e.totp || e.totp_secret || e.otpauth || '').trim(),
});
}
return { entries, skipped, columns: null }; // JSON: no column report
}
// Encrypt one parsed entry (plaintext password + optional TOTP) into the
// shape the bulk-import endpoint expects. Reuses encryptPwd which already
// generates a fresh IV per call.
async function encryptImportEntry(plain) {
const pw = await encryptPwd(plain.password);
let totpEnc = '', totpIv = '';
if (plain.totp_secret) {
try {
base32Decode(plain.totp_secret); // validate before encrypting
const t = await encryptPwd(plain.totp_secret);
totpEnc = t.encrypted;
totpIv = t.iv;
} catch (e) {
// Bad TOTP secret in source file — keep the entry but drop the
// 2FA silently. The user can fix it later via the slide-over.
}
}
return {
site: plain.site,
title: plain.title || '',
username: plain.username || '',
encrypted_password: pw.encrypted,
iv: pw.iv,
folder: plain.folder || 'All',
tags: plain.tags || '',
totp_secret: totpEnc,
totp_iv: totpIv,
};
}
// Open a hidden file picker, route the result through the right parser,
// show a preview confirmation, then bulk-encrypt + POST.
async function doImport() {
const fileInput = el('input', {
type: 'file',
accept: '.json,.csv,application/json,text/csv',
style: 'display:none',
});
document.body.appendChild(fileInput);
fileInput.addEventListener('change', async () => {
const file = fileInput.files && fileInput.files[0];
fileInput.remove();
if (!file) return;
let text;
try { text = await file.text(); }
catch (e) { return toast('Cannot read file: ' + e.message, 'error'); }
const isJSON = /\.json$/i.test(file.name) || text.trim().startsWith('{') || text.trim().startsWith('[');
// If the JSON is an encrypted-export container, prompt for the
// backup password and decrypt before handing the plaintext payload
// to the regular JSON parser.
if (isJSON) {
let raw;
try { raw = JSON.parse(text); } catch (e) { raw = null; }
if (raw && raw.format === 'pm-encrypted-export-v1') {
const pw = await promptDialog({
title: 'Encrypted backup',
message: 'This backup is encrypted. Enter the password ' +
'you set when you exported it.',
placeholder: 'Backup encryption password',
okText: 'Decrypt',
password: true,
});
if (!pw) return;
try {
const payload = await decryptExportContainer(raw, pw);
// Hand the decrypted payload back to parseEntriesFromJSON
// via JSON.stringify — keeps the parser code path single.
text = JSON.stringify(payload);
} catch (e) {
return toast('Decryption failed — wrong password or corrupted file', 'error');
}
}
}
let parsed;
try {
parsed = isJSON ? parseEntriesFromJSON(text) : parseEntriesFromCSV(text);
} catch (e) {
return toast('Parse error: ' + e.message, 'error');
}
if (parsed.entries.length === 0) {
return toast('No valid entries found in file', 'warning');
}
// Build the preview message (innerHTML target → escape user data)
const esc = s => String(s).replace(/[&<>"]/g,
c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const parts = [];
parts.push('<b>' + parsed.entries.length + '</b> entries detected in <code>' +
esc(file.name) + '</code>');
if (parsed.skipped > 0)
parts.push('<span style="color:var(--text-dim)">' + parsed.skipped +
' rows skipped (missing site or password)</span>');
const sample = parsed.entries.slice(0, 3).map(e =>
'• ' + esc(e.site || '?') +
(e.username ? ' <span style="color:var(--text-dim)">(' + esc(e.username) + ')</span>' : '')
).join('<br>');
parts.push('<div style="margin-top:8px;font-size:12px">' + sample +
(parsed.entries.length > 3 ? '<br>…' : '') + '</div>');
parts.push('<div style="margin-top:10px">Import now? This adds the entries to your existing vault.</div>');
const confirmed = await confirmDialog({
title: 'Import vault',
message: parts.join('<br>'),
okText: 'Import',
});
if (!confirmed) return;
// Encrypt all entries client-side, then POST as one transaction.
toast('Encrypting ' + parsed.entries.length + ' entries…');
const encrypted = [];
for (const e of parsed.entries) {
encrypted.push(await encryptImportEntry(e));
}
try {
const r = await api('/entries/bulk-import', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ entries: encrypted }),
});
toast('Imported ' + r.imported + ' entries');
await loadEntries();
render();
if (state.hibpEnabled) hibpCheckAllEntries(); // scan the new entries too
} catch (err) {
toast('Import failed: ' + err.message, 'error');
}
});
fileInput.click();
}
async function doExport() {
// Step 1: reauth — verifies the human in front of the screen is the
// vault owner before we hand them every plaintext password. Defense
// against a stranger reaching the open laptop and exfiltrating data.
const masterPwd = await askReauth(
'Enter your master password to start an encrypted export.');
if (!masterPwd) return;
try {
const verifier = await computeVerifier(
masterPwd, state.salt, state.kdfIterations || 100000);
await api('/reauth', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ verifier: verifier }),
});
} catch (err) {
// 429 (account lockout) is possible here too — propagate as a clear
// message rather than the generic "wrong master password" toast.
if (err.status === 429 && err.body && err.body.retry_after) {
return toast('Account locked. Try again in ' +
Math.ceil(err.body.retry_after / 60) + ' min', 'warning');
}
return toast('Wrong master password', 'error');
}
// Step 2: ask for an INDEPENDENT export password. Decoupled from the
// master pw so a master-pw change later doesn't invalidate the backup,
// and so the backup can be shared without revealing the master pw.
const exportPwd = await promptDialog({
title: 'Encrypted export',
message: 'Choose a password to encrypt the backup file.<br>' +
'<small style="color:var(--text-dim)">' +
'You will need this password to restore the file. ' +
'It is independent of your master password.</small>',
placeholder: 'Backup encryption password',
okText: 'Export',
password: true,
});
if (!exportPwd) return;
if (exportPwd.length < 6) {
return toast('Use at least 6 characters', 'warning');
}
// Step 3: assemble the plaintext payload (same shape as the legacy
// plaintext exporter — round-trips with the existing JSON importer
// after decryption).
const payload = {
version: 1,
exported_at: new Date().toISOString(),
username: state.username,
entries: [],
};
for (const e of state.entries) {
const plain = await decryptPwd(e.encrypted_password, e.iv);
let plainTotp = '';
if (e.totp_secret && e.totp_iv) {
plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv);
if (plainTotp === '[ERROR]') plainTotp = '';
}
payload.entries.push({
site: e.site,
title: e.title || '',
username: e.username,
password: plain,
folder: e.folder,
tags: parseTags(e.tags),
favorite: !!e.favorite,
totp_secret: plainTotp,
created_at: e.created_at,
updated_at: e.updated_at,
});
}
// Step 4: encrypt + download
const container = await encryptExportPayload(payload, exportPwd);
const blob = new Blob([JSON.stringify(container, null, 2)], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
const a = el('a', {
href: url,
download: 'vault-export-' + new Date().toISOString().slice(0, 10) + '.json',
});
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
toast(payload.entries.length + ' entries exported (encrypted)');
}
// ============================================================
// AUTOFILL (Ctrl+Shift+L global hotkey)
// ============================================================
// Win32 modifier flags for RegisterHotKey.
const WIN32_MOD = { alt: 0x0001, ctrl: 0x0002, shift: 0x0004, win: 0x0008 };
// Format a combo for human display: "Ctrl+Shift+L".
function autofillComboLabel(c) {
if (!c || !c.key) return '— not set —';
const parts = [];
if (c.ctrl) parts.push('Ctrl');
if (c.alt) parts.push('Alt');
if (c.shift) parts.push('Shift');
if (c.win) parts.push('Win');
parts.push(c.key);
return parts.join('+');
}
// Convert a combo to the Win32 (mods bitmask, virtual-key code) pair that
// Delphi's RegisterHotKey takes. key='A'..'Z'/'0'..'9' → ASCII code;
// 'F1'..'F12' → 0x70..0x7B.
function autofillComboToWin32(c) {
let mods = 0;
if (c.ctrl) mods |= WIN32_MOD.ctrl;
if (c.alt) mods |= WIN32_MOD.alt;
if (c.shift) mods |= WIN32_MOD.shift;
if (c.win) mods |= WIN32_MOD.win;
let vk = 0;
const k = (c.key || '').toUpperCase();
if (/^F([1-9]|1[0-2])$/.test(k)) vk = 0x70 + parseInt(k.slice(1)) - 1;
else if (k.length === 1 && k >= 'A' && k <= 'Z') vk = k.charCodeAt(0);
else if (k.length === 1 && k >= '0' && k <= '9') vk = k.charCodeAt(0);
return { mods, vk };
}
// Validate a captured combo. Requires at least one modifier (otherwise a
// single key would steal that letter globally) and a valid main key.
function autofillComboValid(c) {
if (!c) return false;
if (!(c.ctrl || c.alt || c.win)) return false; // shift-only is unreliable
const w = autofillComboToWin32(c);
return w.vk !== 0;
}
// Capture a key combo from a single keydown event. Returns null if the
// event is "incomplete" (only modifiers pressed so far) or Escape.
function autofillCaptureFromEvent(e) {
const k = e.key;
if (k === 'Escape') return 'cancel';
// Ignore pure-modifier keydowns (user is still building the combo).
if (k === 'Control' || k === 'Shift' || k === 'Alt' ||
k === 'Meta' || k === 'OS') return null;
// Accept letter, digit, F1-F12.
let key = null;
if (k.length === 1 && /[a-z0-9]/i.test(k)) {
key = k.toUpperCase();
} else if (/^F([1-9]|1[0-2])$/i.test(k)) {
key = k.toUpperCase();
} else {
return 'invalid';
}
return {
ctrl: !!e.ctrlKey,
shift: !!e.shiftKey,
alt: !!e.altKey,
win: !!e.metaKey,
key,
};
}
// Push current state to Delphi (toggle + both combos) and persist.
// Called after any change so Delphi's RegisterHotKey reflects state.
function autofillPushHotkeys() {
localStorage.setItem('autofillHotkeyFull', JSON.stringify(state.autofillHotkeyFull));
localStorage.setItem('autofillHotkeyPwd', JSON.stringify(state.autofillHotkeyPwd));
if (Bridge.active) {
Bridge.setAutofillHotkeys(state.autofillEnabled, {
full: state.autofillHotkeyFull,
password: state.autofillHotkeyPwd,
});
}
}
// Extract a bare hostname from a site string for fuzzy matching.
// "https://www.github.com/login" → "github.com"
// Strip the browser brand suffix that lives at the end of every tab title
// ("Some Page - Google Chrome", "Page — Mozilla Firefox", etc.). Without
// this, entries whose site is "google" / "mozilla" / "edge" would match
// every single page that has Chrome / Firefox / Edge as the browser brand.
const BROWSER_SUFFIX_RE =
/\s*[-—–|]\s*(google chrome|chromium|mozilla firefox|firefox|microsoft edge|edge|brave|opera|vivaldi|safari|tor browser|tor|arc)\s*$/i;
function autofillStripBrowserSuffix(title) {
return (title || '').replace(BROWSER_SUFFIX_RE, '').trim();
}
function autofillExtractHost(site) {
return site.toLowerCase()
.replace(/^https?:\/\//i, '')
.replace(/^www\./i, '')
.split('/')[0]
.split(':')[0];
}
// Get the second-level domain (brand part) from a hostname.
// "github.com" → "github" ; "mail.google.com" → "google" ; "x.com" → "x"
function autofillSLD(host) {
const parts = host.split('.').filter(p => p.length > 0);
if (parts.length <= 1) return host;
return parts[parts.length - 2];
}
// Escape a string for safe insertion into a RegExp.
function autofillEscapeRegex(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// Score how well a vault entry matches the foreground window title.
// Returns 0 (no match) or a positive integer (higher = better).
//
// Strategy (browser titles rarely contain the full hostname — usually
// just the brand name, e.g. "Sign in to GitHub" or "X. C'est… - Google Chrome"):
// 1. Full hostname substring → strongest (score 1000 + len)
// 2. SLD ≥3 chars as substring → medium (score 500 + len)
// 3. SLD <3 chars as word → weak (score 100), requires word
// boundaries to avoid matching "x" inside arbitrary words.
function autofillScore(entry, titleLower) {
// 1. Display name (entry.title) lowercased substring — strongest brand
// match. Skips when title is empty or same as site (already tested
// via the site path below).
const displayTitle = (entry.title || '').trim().toLowerCase();
if (displayTitle.length >= 2 && titleLower.includes(displayTitle))
return 1500 + displayTitle.length;
if (!entry.site) return 0;
const host = autofillExtractHost(entry.site);
if (host.length < 2) return 0;
// 2. Full hostname (rare in tab titles, but strongest URL signal)
if (titleLower.includes(host)) return 1000 + host.length;
// 3/4. Second-level domain
const sld = autofillSLD(host);
if (sld.length === 0) return 0;
if (sld.length >= 3) {
if (titleLower.includes(sld)) return 500 + sld.length;
return 0;
}
// Short SLD ("x", "qq", "vk"…) — require word boundaries so we don't
// match the letter inside random words.
const re = new RegExp('(^|[^a-z0-9])' + autofillEscapeRegex(sld) +
'([^a-z0-9]|$)', 'i');
if (re.test(titleLower)) return 100;
return 0;
}
// Called by Bridge.onAutofillRequest when a hotkey fires.
// kind: 'full' = Ctrl+Shift+L (user + Tab + pwd) ; 'password' = Ctrl+Shift+P.
async function autofillHandleRequest(windowTitle, kind) {
if (!state.autofillEnabled) return;
if (!state.cryptoKey || state.locked || !state.token) {
// Vault is locked — bring the app to the front so the user can
// unlock immediately, rather than silently no-op'ing the hotkey.
Bridge.cancelAutofill();
Bridge.focusApp();
setTimeout(() => {
const pwd = document.getElementById('loginPassword');
const user = document.getElementById('loginUsername');
if (pwd && !document.getElementById('authScreen').classList.contains('is-hidden')) {
if (user && !user.value) user.focus();
else pwd.focus();
}
}, 80);
toast('Vault is locked — unlock to autofill', 'warning');
return;
}
const titleLower = autofillStripBrowserSuffix(windowTitle).toLowerCase();
const scored = state.entries
.map(e => ({ entry: e, score: autofillScore(e, titleLower) }))
.filter(x => x.score > 0)
.sort((a, b) => b.score - a.score);
if (scored.length === 0) {
toast('Autofill: no match for "' + windowTitle.slice(0, 40) + '"', 'warning');
Bridge.cancelAutofill();
return;
}
if (scored.length === 1) {
await autofillFillEntry(scored[0].entry, kind);
return;
}
// Multiple candidates — show picker. kind is captured so clicking a
// candidate honours password-only mode.
openAutofillPicker(scored.map(x => x.entry), windowTitle, kind);
}
// Decrypt and type an entry. kind = 'full' or 'password'.
async function autofillFillEntry(entry, kind) {
const password = await decryptPwd(entry.encrypted_password, entry.iv);
if (password === '[ERROR]') {
toast('Autofill: decryption error', 'error');
Bridge.cancelAutofill();
return;
}
// password-only kind → empty username → Delphi skips Tab.
// full kind with empty entry.username → also no Tab (Delphi handles it).
const user = (kind === 'password') ? '' : (entry.username || '');
Bridge.executeAutofill(user, password);
toast((kind === 'password' ? 'Password filled: ' : 'Autofilled: ') + entry.site);
// Audit (best-effort, ignore failures)
fetch('' + '/audit', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
action: kind === 'password' ? 'autofill_pwd' : 'autofill',
site: entry.site,
}),
}).catch(() => {});
}
// Picker modal for multi-match case. kind is forwarded to autofillFillEntry
// so the user's hotkey intent (full vs password-only) is preserved through
// the manual choice.
function openAutofillPicker(entries, windowTitle, kind) {
// Bring the app to front so the picker is unambiguously visible —
// otherwise the modal opens behind / next to the user's original
// window (e.g. Notepad) and easy to miss. ExecuteAutofill restores
// the original target HWND via ForceForegroundWindow on selection.
Bridge.focusApp();
const list = $('#autofillPickerList');
list.innerHTML = '';
entries.forEach(e => {
const btn = el('button', {
class: 'autofill-pick-btn',
on: {
click: async () => {
closeAutofillPicker(false);
await autofillFillEntry(e, kind);
},
},
});
btn.appendChild(el('span', { class: 'autofill-pick-site' }, entryDisplayName(e)));
if (e.username) {
btn.appendChild(el('span', { class: 'autofill-pick-user' }, e.username));
}
list.appendChild(btn);
});
const head = (kind === 'password' ? 'Pick entry (password only) — ' : 'Pick entry — ')
+ entries.length + ' match "' + windowTitle.slice(0, 30) + '…"';
$('#autofillPickerTitle').textContent = head;
$('#autofillPickerModal').classList.remove('is-hidden');
}
function closeAutofillPicker(notifyCancel = true) {
$('#autofillPickerModal').classList.add('is-hidden');
if (notifyCancel) Bridge.cancelAutofill();
}
// ============================================================
// VIEWS / NAV
// ============================================================
async function setView(v) {
state.view = v;
state.currentPage = 1;
if (v === 'trash') {
await loadTrash();
}
render();
}
function toggleTheme() {
setTheme(state.theme === 'dark' ? 'light' : 'dark');
}
function setTheme(t) {
state.theme = t;
document.documentElement.setAttribute('data-theme', t);
localStorage.setItem('theme', t);
const sel = $('#settingTheme');
if (sel) sel.value = t;
Bridge.syncTitleBarTheme(t);
}
function openSettings() {
$('#settingTheme').value = state.theme;
$('#settingSort').value = state.sortBy + ':' + state.sortDir;
$('#settingAutoLock').value = String(state.autoLock);
$('#settingAskDelete').checked = state.askBeforeDelete;
$('#settingCompact').checked = state.compactActions;
$('#settingMaskUser').checked = state.maskUsernames;
$('#settingHIBP').checked = state.hibpEnabled;
$('#settingShowSite').checked = state.showSiteOnCards;
$('#settingFavicons').checked = state.faviconsEnabled;
// Action buttons + toggle row only meaningful when the Delphi bridge
// is available (the PHP frontend has no outbound proxy).
$('#settingFaviconsRow').style.display = Bridge.active ? '' : 'none';
$('#settingFaviconActionsRow').style.display = Bridge.active ? 'flex' : 'none';
$('#settingAutofill').checked = state.autofillEnabled;
$('#settingAutofillRow').style.display = Bridge.active ? '' : 'none';
// Hotkey capture buttons — labels reflect current combos.
$('#settingAutofillFullCombo').textContent = autofillComboLabel(state.autofillHotkeyFull);
$('#settingAutofillPwdCombo').textContent = autofillComboLabel(state.autofillHotkeyPwd);
$('#settingAutofillHotkeysRow').style.display = Bridge.active ? '' : 'none';
// Start-with-Windows toggle: only meaningful inside the Delphi host
// (registry access). Hide for the PHP frontend.
$('#settingAutoStartRow').style.display = Bridge.active ? '' : 'none';
if (Bridge.active) {
// Fire-and-forget: the bridge response updates the checkbox via
// Bridge.onAutoStartStatus.
Bridge.getAutoStart();
}
$('#settingUser').textContent = state.username;
// Async: query server for recovery key state and update the label
refreshRecoveryStatus();
// Sync the quick-unlock UI from the actual DPAPI file (in case the
// user cleared the file outside the app or the file is gone for some
// other reason like a Windows profile reset).
if (Bridge.active) {
bridgeQuickUnlockStatus().then(actual => {
if (!actual && state.quickUnlockEnabled) {
// Drift: localStorage said enabled but Delphi has no blob.
localStorage.removeItem('quickUnlockEnabled');
state.quickUnlockEnabled = false;
}
updateQuickUnlockUI();
});
} else {
updateQuickUnlockUI();
}
$('#settingsPanel').classList.add('is-open');
}
function closeSettings() {
$('#settingsPanel').classList.remove('is-open');
}
// ---- Auto-lock with 30s warning countdown -------------------
const WARNING_SECONDS = 30;
let autoLockTimer = null;
let warningTimer = null;
let countdownInterval = null;
function hideIdleWarning() {
$('#idleWarning').classList.add('is-hidden');
if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; }
}
function showIdleWarning() {
$('#idleCountdown').textContent = WARNING_SECONDS;
$('#idleWarning').classList.remove('is-hidden');
let s = WARNING_SECONDS;
countdownInterval = setInterval(() => {
s -= 1;
$('#idleCountdown').textContent = Math.max(0, s);
if (s <= 0) { clearInterval(countdownInterval); countdownInterval = null; }
}, 1000);
}
function resetAutoLock() {
if (autoLockTimer) clearTimeout(autoLockTimer);
if (warningTimer) clearTimeout(warningTimer);
hideIdleWarning();
if (!state.autoLock || !state.token || !state.cryptoKey) return;
const totalMs = state.autoLock * 60 * 1000;
const warningAt = Math.max(0, totalMs - WARNING_SECONDS * 1000);
warningTimer = setTimeout(showIdleWarning, warningAt);
autoLockTimer = setTimeout(() => {
hideIdleWarning();
toast('Auto-locked due to inactivity', 'warning');
lockVault();
}, totalMs);
}
// Reset idle on user interaction — but ignore events that fire while the
// warning popup is visible (otherwise the popup would never auto-dismiss).
['mousemove', 'keydown', 'click', 'touchstart'].forEach(ev =>
document.addEventListener(ev, e => {
// Allow clicks on the "Stay unlocked" button to also reset
if ($('#idleWarning').classList.contains('is-hidden')) {
resetAutoLock();
}
}, { passive: true })
);
async function showAuth() {
$('#authScreen').classList.remove('is-hidden');
$('#appShell').classList.add('is-hidden');
if (autoLockTimer) { clearTimeout(autoLockTimer); autoLockTimer = null; }
let remembered = '';
if (Bridge.active) {
remembered = await Bridge.getPref('rememberedUsername');
} else {
remembered = localStorage.getItem('rememberedUsername') || '';
}
const userInput = $('#loginUsername');
const remCb = $('#loginRememberUser');
if (remCb) remCb.checked = !!remembered;
if (userInput && !userInput.value && remembered) userInput.value = remembered;
// Delphi-hosted: ask Delphi to SetFocus the WebBrowser control first
// (DOM input.focus() is a no-op while the WebView2 lacks OS-level
// focus). Web fallback: direct DOM focus.
if (Bridge.active) {
Bridge.appReady();
} else {
setTimeout(() => {
const u = $('#loginUsername');
const p = $('#loginPassword');
if (u && u.value) p && p.focus();
else u && u.focus();
}, 0);
}
}
async function enterApp() {
$('#authScreen').classList.add('is-hidden');
$('#appShell').classList.remove('is-hidden');
$('#userName').textContent = state.username;
// Server-side prefs override localStorage cache; runs before render so
// theme / view mode / mask flags are applied to the first paint.
await loadServerSettings();
// Show skeleton cards immediately while the initial fetch runs
showSkeletons(6);
await loadFolders();
await loadEntries();
await loadEntryCounts();
render();
resetAutoLock();
// Fire-and-forget HIBP scan if the user opted in. Runs in background,
// re-renders when done to show badges.
if (state.hibpEnabled) hibpCheckAllEntries();
// Push the user-configured hotkeys (combos + enabled state) to Delphi.
// Replaces the historical "always Ctrl+Shift+L on startup" path.
autofillPushHotkeys();
}
// ============================================================
// SERVER-SIDE SETTINGS SYNC
// ============================================================
//
// Synced keys (user preferences, portable across devices). Device-specific
// toggles (quickUnlockEnabled, autofillEnabled) stay in localStorage.
const SYNCED_SETTING_KEYS = [
'theme', 'autoLock', 'askBeforeDelete', 'maskUsernames',
'compactActions', 'viewMode', 'hibpEnabled', 'showSiteOnCards',
'sortBy', 'sortDir', 'pageSize',
// Hotkey combos are user preferences — values are portable. The
// registration itself is Windows-only, so non-Windows clients just
// ignore them.
'autofillHotkeyFull', 'autofillHotkeyPwd',
// Sidebar section collapsed state. Object of { folders, tags, tools }
// booleans. Synced so the user gets the same fold state across devices.
'sidebarCollapsed',
'faviconsEnabled',
];
function applySidebarCollapsed() {
const s = state.sidebarCollapsed || {};
document.querySelectorAll('.sidebar-section[data-section]').forEach(sec => {
const k = sec.getAttribute('data-section');
sec.classList.toggle('is-collapsed', !!s[k]);
});
}
async function loadServerSettings() {
try {
const r = await fetch(API + '/settings', { headers: authHeaders() });
if (!r.ok) return;
const remote = await r.json();
// Merge remote into state (remote wins). localStorage is updated
// too so first-paint on next reload uses the synced value.
SYNCED_SETTING_KEYS.forEach(k => {
if (!(k in remote)) return;
const v = remote[k];
state[k] = v;
switch (k) {
case 'theme': localStorage.setItem('theme', v); break;
case 'autoLock': localStorage.setItem('autoLockMin', String(v)); break;
case 'askBeforeDelete': localStorage.setItem('askBeforeDelete', v ? '1' : '0'); break;
case 'maskUsernames': localStorage.setItem('maskUsernames', v ? '1' : '0'); break;
case 'compactActions': localStorage.setItem('compactActions', v ? '1' : '0'); break;
case 'viewMode': localStorage.setItem('viewMode', v); break;
case 'hibpEnabled': localStorage.setItem('hibpEnabled', v ? '1' : '0'); break;
case 'showSiteOnCards': localStorage.setItem('showSiteOnCards', v ? '1' : '0'); break;
case 'sortBy': localStorage.setItem('sortBy', String(v)); break;
case 'sortDir': localStorage.setItem('sortDir', String(v)); break;
case 'pageSize': localStorage.setItem('pageSize', String(v)); break;
case 'autofillHotkeyFull':
case 'autofillHotkeyPwd':
case 'sidebarCollapsed':
// Object; persist as JSON so the next cold start picks it up.
localStorage.setItem(k, JSON.stringify(v));
break;
case 'faviconsEnabled':
localStorage.setItem('faviconsEnabled', v ? '1' : '0');
break;
}
});
// Apply visual settings immediately.
if (remote.theme) setTheme(remote.theme);
if ('sidebarCollapsed' in remote) applySidebarCollapsed();
} catch (e) {
// Network/server hiccup is harmless — localStorage cache still works.
}
}
let _settingsSaveTimer = null;
function saveServerSettings() {
// Debounce: collapse rapid toggles (e.g. user playing with the theme
// dropdown) into a single PUT.
if (_settingsSaveTimer) clearTimeout(_settingsSaveTimer);
_settingsSaveTimer = setTimeout(async () => {
_settingsSaveTimer = null;
const payload = {};
SYNCED_SETTING_KEYS.forEach(k => { payload[k] = state[k]; });
try {
await fetch(API + '/settings', {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(payload),
});
} catch (e) {
// Silent — next change will retry.
}
}, 400);
}
// ============================================================
// INIT
// ============================================================
function installCustomContextMenu() {
const menu = el('div', { class: 'custom-ctxmenu is-hidden' });
document.body.appendChild(menu);
function isEditable(node) {
if (!node) return false;
const tag = node.tagName;
if (tag === 'INPUT') return !['button','submit','checkbox','radio','range','color','file'].includes(node.type);
if (tag === 'TEXTAREA') return true;
if (node.isContentEditable) return true;
return false;
}
function hide() { menu.classList.add('is-hidden'); }
function buildItems(target) {
menu.innerHTML = '';
const isInput = isEditable(target);
const hasSelection = isInput && target.selectionStart !== target.selectionEnd;
const items = [
{ lbl: 'Cut', on: hasSelection, fn: () => doCut(target) },
{ lbl: 'Copy', on: hasSelection, fn: () => doCopy(target) },
{ lbl: 'Paste', on: isInput && !target.readOnly, fn: () => doPaste(target) },
{ lbl: 'Select all', on: isInput, fn: () => target.select() },
];
items.forEach(it => {
const mi = el('button', {
class: 'custom-ctxmenu-item' + (it.on ? '' : ' is-disabled'),
type: 'button',
on: { click: ev => {
ev.preventDefault();
ev.stopPropagation();
if (!it.on) return;
hide();
it.fn();
} },
}, it.lbl);
menu.appendChild(mi);
});
}
async function doCopy(target) {
const sel = target.value.slice(target.selectionStart, target.selectionEnd);
try { await navigator.clipboard.writeText(sel); } catch (e) {}
}
async function doCut(target) {
await doCopy(target);
const s = target.selectionStart, e = target.selectionEnd;
target.value = target.value.slice(0, s) + target.value.slice(e);
target.selectionStart = target.selectionEnd = s;
target.dispatchEvent(new Event('input', { bubbles: true }));
}
async function doPaste(target) {
const txt = Bridge.active ? await bridgeReadClipboard()
: await navigator.clipboard.readText().catch(() => '');
if (!txt) return;
const s = target.selectionStart, e = target.selectionEnd;
target.value = target.value.slice(0, s) + txt + target.value.slice(e);
target.selectionStart = target.selectionEnd = s + txt.length;
target.dispatchEvent(new Event('input', { bubbles: true }));
}
document.addEventListener('contextmenu', ev => {
ev.preventDefault();
if (!isEditable(ev.target)) { hide(); return; }
buildItems(ev.target);
menu.classList.remove('is-hidden');
const vw = window.innerWidth, vh = window.innerHeight;
const mw = menu.offsetWidth || 160, mh = menu.offsetHeight || 140;
const x = Math.min(ev.clientX, vw - mw - 4);
const y = Math.min(ev.clientY, vh - mh - 4);
menu.style.left = x + 'px';
menu.style.top = y + 'px';
});
document.addEventListener('mousedown', ev => {
if (!ev.target.closest('.custom-ctxmenu')) hide();
});
document.addEventListener('keydown', ev => {
if (ev.key === 'Escape') hide();
});
window.addEventListener('blur', hide);
}
async function init() {
document.documentElement.setAttribute('data-theme', state.theme);
if (location.search.indexOf('pmt=') !== -1) {
history.replaceState(null, '', location.pathname + location.hash);
}
installCustomContextMenu();
document.addEventListener('keydown', e => {
const mod = e.ctrlKey || e.metaKey;
if (e.key === 'F12' || e.key === 'F5') return e.preventDefault();
if (mod && e.shiftKey && /^[ijIJ]$/.test(e.key)) return e.preventDefault(); // DevTools
if (mod && /^[uUjJhHsSpPtTnNrR]$/.test(e.key)) return e.preventDefault(); // View source / Downloads / History / Save / Print / New tab+win / Reload
if (mod && e.shiftKey && /^[nNwW]$/.test(e.key)) return e.preventDefault(); // New incognito / Close window
if (mod && e.shiftKey && e.key === 'Delete') return e.preventDefault(); // Clear browsing data
});
// Auth tabs
$$('.auth-tab').forEach(t => {
t.addEventListener('click', () => {
$$('.auth-tab').forEach(x => x.classList.remove('is-active'));
t.classList.add('is-active');
const tab = t.dataset.tab;
$('#loginForm').classList.toggle('is-hidden', tab !== 'login');
$('#registerForm').classList.toggle('is-hidden', tab !== 'register');
});
});
// Forms
$('#loginForm').addEventListener('submit', doLogin);
$('#registerForm').addEventListener('submit', doRegister);
$('#regPassword').addEventListener('input', updateRegStrength);
$('#entryPassword').addEventListener('input', updateEntryStrength);
// Top-bar
function applyViewMode() {
$$('.view-btn').forEach(b => b.classList.toggle('is-active', b.dataset.view === state.viewMode));
renderGrid();
}
applyViewMode();
$$('.view-btn').forEach(b => b.addEventListener('click', () => {
state.viewMode = b.dataset.view;
state.currentPage = 1;
localStorage.setItem('viewMode', state.viewMode);
applyViewMode();
saveServerSettings();
}));
$('#themeBtn').addEventListener('click', () => {
toggleTheme();
saveServerSettings();
});
$('#newEntryBtn').addEventListener('click', ev => {
// Stop bubbling — the document-level "click outside slideover"
// handler would otherwise close the panel we just opened in the
// same click event (same fix as the health dashboard Fix button).
ev.stopPropagation();
openSlideOver(null);
});
$('#userChip').addEventListener('click', () => $('#userDropdown').classList.toggle('is-hidden'));
$('#lockBtn').addEventListener('click', lockVault);
$('#dropdownSettingsBtn').addEventListener('click', () => {
$('#userDropdown').classList.add('is-hidden');
openSettings();
});
$('#logoutBtn').addEventListener('click', doLogout);
document.addEventListener('click', e => {
if (!e.target.closest('.user-menu')) $('#userDropdown').classList.add('is-hidden');
// Close any open kebab menu when clicking outside it
if (!e.target.closest('.entry-kebab-wrap')) {
$$('.entry-kebab-menu.is-open').forEach(m => m.classList.remove('is-open'));
}
});
// Sidebar nav
$$('#appShell .nav-item[data-view]').forEach(n => {
n.addEventListener('click', () => setView(n.dataset.view));
});
$('#addFolderBtn').addEventListener('click', addFolder);
// Drag-to-trash: dropping an entry onto the Trash nav item soft-deletes it
const trashItem = $('#appShell .nav-item[data-view="trash"]');
if (trashItem) {
trashItem.addEventListener('dragover', ev => { ev.preventDefault(); trashItem.classList.add('drag-over'); });
trashItem.addEventListener('dragleave', () => trashItem.classList.remove('drag-over'));
trashItem.addEventListener('drop', async ev => {
ev.preventDefault();
trashItem.classList.remove('drag-over');
const id = parseInt(ev.dataTransfer.getData('text/plain'));
if (!id) return;
try {
await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() });
toast('Moved to trash');
await loadEntries();
await loadTrash();
render();
} catch (err) { toast(err.message, 'error'); }
});
}
// Search
$('#searchInput').addEventListener('input', e => {
state.search = e.target.value;
state.currentPage = 1;
renderGrid();
});
// Slide-over
$('#slideoverClose').addEventListener('click', closeSlideOver);
// Click outside the slide-over closes it. Clicks on cards re-open it for
// another entry (so we don't close in that case; the card's own handler
// will switch state.selectedId).
document.addEventListener('click', e => {
if (!$('#slideover').classList.contains('is-open')) return;
if (e.target.closest('.slideover')) return;
if (e.target.closest('.entry-card')) return;
if (e.target.closest('.modal')) return;
if (e.target.closest('.cmd-palette')) return;
if (e.target.closest('.idle-warning')) return;
closeSlideOver();
});
// Click outside the settings panel closes it. Each setting change has
// already pushed to server + localStorage, so "close = autosave" is
// implicit. Ignore clicks on the triggers and on any open modal (so the
// reauth / confirm flows fired from inside settings don't dismiss it).
document.addEventListener('click', e => {
if (!$('#settingsPanel').classList.contains('is-open')) return;
if (e.target.closest('#settingsPanel')) return;
if (e.target.closest('#settingsBtn')) return;
if (e.target.closest('#dropdownSettingsBtn')) return;
if (e.target.closest('.modal')) return;
closeSettings();
});
// Entry modal
$('#entryForm').addEventListener('submit', saveEntry);
$('#entrySaveBtn').addEventListener('click', saveEntry);
$$('#entryModal [data-close]').forEach(b => b.addEventListener('click', closeEntryModal));
$('#entryPwToggle').addEventListener('click', () => {
const input = $('#entryPassword');
input.type = input.type === 'password' ? 'text' : 'password';
});
$('#loginPwToggle').addEventListener('click', () => {
const input = $('#loginPassword');
input.type = input.type === 'password' ? 'text' : 'password';
});
$('#entryPwGen').addEventListener('click', openGen);
// Chip input (tags)
$('#entryTagsInput').addEventListener('click', () => $('#entryTagsField').focus());
$('#entryTagsField').addEventListener('keydown', e => {
const field = e.target;
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
if (!selectActiveSuggestion()) {
addTag(field.value);
field.value = '';
closeChipSuggest();
}
} else if (e.key === 'Backspace' && !field.value && editingTags.length) {
editingTags.pop();
renderChips();
} else if (e.key === 'ArrowDown') { e.preventDefault(); moveChipSuggest(+1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); moveChipSuggest(-1); }
else if (e.key === 'Escape') { closeChipSuggest(); }
});
$('#entryTagsField').addEventListener('input', openChipSuggest);
$('#entryTagsField').addEventListener('focus', openChipSuggest);
$('#entryTagsField').addEventListener('blur', () => setTimeout(closeChipSuggest, 150));
// Generator
$('#genLen').addEventListener('input', genPassword);
$$('#genModal input[type=checkbox]').forEach(c => c.addEventListener('change', genPassword));
$('#genRegen').addEventListener('click', genPassword);
$('#genCopy').addEventListener('click', () => {
if (!genCurrent) return;
if (Bridge.copySecure(genCurrent, 30000)) {
toast('Copied · clears in 30s');
} else {
navigator.clipboard.writeText(genCurrent).then(() => {
toast('Copied · clears in 30s');
setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000);
});
}
});
$('#genUse').addEventListener('click', () => {
if (genTarget === 'slideover') {
const soPw = $('#soPassword');
if (soPw) { soPw.value = genCurrent; soDirtyCheck(); }
} else {
$('#entryPassword').value = genCurrent;
updateEntryStrength();
}
closeGen();
});
$$('#genModal [data-close]').forEach(b => b.addEventListener('click', closeGen));
// Sidebar Generator tool
$('#sidebarGenBtn').addEventListener('click', () => openGen('standalone'));
$('#sidebarExportBtn').addEventListener('click', doExport);
$('#sidebarImportBtn').addEventListener('click', doImport);
$('#sidebarAuthenticatorBtn').addEventListener('click', () => {
state.view = 'authenticator';
state.currentPage = 1;
$$('.nav-item').forEach(b => b.classList.remove('is-active'));
render();
});
$('#sidebarTotpToolBtn').addEventListener('click', openTotpTool);
$('#sidebarHealthBtn').addEventListener('click', () => {
state.view = 'health';
state.currentPage = 1;
$$('.nav-item').forEach(b => b.classList.remove('is-active'));
// Invalidate any stale cache so we recompute fresh each open.
healthCache = null;
render();
});
// Sidebar section collapse toggles
document.querySelectorAll('[data-section-toggle]').forEach(btn => {
btn.addEventListener('click', () => {
const key = btn.getAttribute('data-section-toggle');
state.sidebarCollapsed = state.sidebarCollapsed || {};
state.sidebarCollapsed[key] = !state.sidebarCollapsed[key];
applySidebarCollapsed();
localStorage.setItem('sidebarCollapsed', JSON.stringify(state.sidebarCollapsed));
saveServerSettings();
});
});
applySidebarCollapsed();
// Idle warning "Stay unlocked"
$('#idleStayBtn').addEventListener('click', resetAutoLock);
// Settings slide-over
$('#settingsBtn').addEventListener('click', openSettings);
$('#settingsClose').addEventListener('click', closeSettings);
// Theme: setTheme already writes localStorage. Capture before/after so
// sync only fires if it actually changed.
$('#settingTheme').addEventListener('change', e => {
setTheme(e.target.value);
state.theme = e.target.value;
saveServerSettings();
});
$('#settingSort').addEventListener('change', e => {
const [by, dir] = e.target.value.split(':');
state.sortBy = by; state.sortDir = dir;
state.currentPage = 1;
localStorage.setItem('sortBy', by);
localStorage.setItem('sortDir', dir);
render();
saveServerSettings();
});
$('#settingAutoLock').addEventListener('change', e => {
state.autoLock = parseInt(e.target.value);
localStorage.setItem('autoLockMin', String(state.autoLock));
resetAutoLock();
saveServerSettings();
toast(state.autoLock ? ('Auto-lock: ' + state.autoLock + ' min') : 'Auto-lock disabled');
});
$('#settingAskDelete').addEventListener('change', e => {
state.askBeforeDelete = e.target.checked;
localStorage.setItem('askBeforeDelete', state.askBeforeDelete ? '1' : '0');
saveServerSettings();
toast(state.askBeforeDelete ? 'Will ask before deleting' : 'Will delete without asking');
});
$('#settingCompact').addEventListener('change', e => {
state.compactActions = e.target.checked;
localStorage.setItem('compactActions', state.compactActions ? '1' : '0');
render();
saveServerSettings();
});
$('#settingMaskUser').addEventListener('change', e => {
state.maskUsernames = e.target.checked;
localStorage.setItem('maskUsernames', state.maskUsernames ? '1' : '0');
render();
saveServerSettings();
});
$('#settingShowSite').addEventListener('change', e => {
state.showSiteOnCards = e.target.checked;
localStorage.setItem('showSiteOnCards', state.showSiteOnCards ? '1' : '0');
render();
saveServerSettings();
});
$('#settingHIBP').addEventListener('change', e => {
state.hibpEnabled = e.target.checked;
localStorage.setItem('hibpEnabled', state.hibpEnabled ? '1' : '0');
saveServerSettings();
if (state.hibpEnabled) {
toast('Checking passwords against breach database…');
hibpCheckAllEntries();
} else {
state.hibpResults.clear();
render();
toast('Breach check disabled');
}
});
$('#settingAutoStart').addEventListener('change', e => {
if (!Bridge.active) return;
Bridge.setAutoStart(e.target.checked);
toast(e.target.checked
? 'Will start with Windows (in tray)'
: 'Wont start with Windows');
});
$('#settingFavicons').addEventListener('change', e => {
state.faviconsEnabled = e.target.checked;
localStorage.setItem('faviconsEnabled', state.faviconsEnabled ? '1' : '0');
saveServerSettings();
if (state.faviconsEnabled) {
// Auto-backfill on first opt-in so the user sees the effect
// immediately instead of having to click the refresh button.
backfillFavicons(false);
} else {
toast('Website icons disabled (cached icons kept)');
}
});
$('#settingFaviconsRefresh').addEventListener('click', () => backfillFavicons(false));
$('#settingFaviconsRefreshAll').addEventListener('click', () => backfillFavicons(true));
$('#settingFaviconsClear').addEventListener('click', async () => {
const ok = await confirmDialog({
title: 'Clear cached icons?',
message: 'All website icons cached in your vault will be removed. They will be re-fetched on demand if the toggle stays on.',
okText: 'Clear',
danger: true,
});
if (ok) clearAllFavicons();
});
$('#settingAutofill').addEventListener('change', e => {
state.autofillEnabled = e.target.checked;
localStorage.setItem('autofillEnabled', state.autofillEnabled ? '1' : '0');
// Push the FULL state (toggle + combos) so Delphi register/unregister
// uses the user's current combos, not the defaults.
autofillPushHotkeys();
const lbl = autofillComboLabel(state.autofillHotkeyFull);
toast(state.autofillEnabled ? ('Autofill enabled (' + lbl + ')') : 'Autofill disabled');
});
// ---- Hotkey capture buttons ----
// Click → button label becomes "Press combo…" → next keydown captures.
// While capturing, all other keys are swallowed so the user can press
// any modifier+letter combo without triggering app shortcuts.
function bindHotkeyCapture(btnId, kind) {
const btn = $(btnId);
if (!btn) return;
btn.addEventListener('click', () => {
if (btn.dataset.capturing === '1') return;
btn.dataset.capturing = '1';
btn.classList.add('is-capturing');
const original = btn.textContent;
btn.textContent = 'Press combo… (Esc to cancel)';
function finish(restore) {
btn.dataset.capturing = '';
btn.classList.remove('is-capturing');
document.removeEventListener('keydown', onKey, true);
if (restore) btn.textContent = original;
}
function onKey(e) {
// Swallow EVERYTHING while capturing so the user's combo
// doesn't trigger the cmd-palette etc.
e.preventDefault();
e.stopPropagation();
const captured = autofillCaptureFromEvent(e);
if (captured === null) return; // still building
if (captured === 'cancel') { finish(true); return; }
if (captured === 'invalid'){
toast('Unsupported key — use a letter, digit or F-key', 'warning');
finish(true);
return;
}
if (!autofillComboValid(captured)) {
toast('Combo needs at least Ctrl, Alt or Win as a modifier', 'warning');
finish(true);
return;
}
// Reject if it collides with the other slot.
const other = (kind === 'full') ? state.autofillHotkeyPwd : state.autofillHotkeyFull;
if (JSON.stringify(other) === JSON.stringify(captured)) {
toast('That combo is already used by the other hotkey', 'warning');
finish(true);
return;
}
// Commit.
if (kind === 'full') state.autofillHotkeyFull = captured;
else state.autofillHotkeyPwd = captured;
btn.textContent = autofillComboLabel(captured);
finish(false);
autofillPushHotkeys(); // re-register in Delphi
saveServerSettings(); // sync to server (debounced)
}
document.addEventListener('keydown', onKey, true);
});
}
bindHotkeyCapture('#settingAutofillFullCombo', 'full');
bindHotkeyCapture('#settingAutofillPwdCombo', 'password');
$('#settingAutofillResetHotkeys').addEventListener('click', () => {
state.autofillHotkeyFull = { ctrl: true, shift: true, alt: false, win: false, key: 'L' };
state.autofillHotkeyPwd = { ctrl: true, shift: true, alt: false, win: false, key: 'P' };
$('#settingAutofillFullCombo').textContent = autofillComboLabel(state.autofillHotkeyFull);
$('#settingAutofillPwdCombo').textContent = autofillComboLabel(state.autofillHotkeyPwd);
autofillPushHotkeys();
saveServerSettings();
toast('Hotkeys reset to defaults');
});
// Autofill picker modal close button
$$('#autofillPickerModal [data-close]').forEach(b =>
b.addEventListener('click', closeAutofillPicker));
$('#openClipboardSettings').addEventListener('click', () => {
toast('Open Windows Settings → System → Clipboard → turn off "Clipboard history"', 'warning');
});
$('#exportBtn').addEventListener('click', doExport);
$('#importBtn').addEventListener('click', doImport);
$('#changeMasterBtn').addEventListener('click', openChangeMasterModal);
$('#changeMasterForm').addEventListener('submit', e => {
e.preventDefault();
doChangeMasterPassword();
});
$$('#changeMasterModal [data-close]').forEach(b =>
b.addEventListener('click', closeChangeMasterModal));
// Recovery key
$('#recoverySetupBtn').addEventListener('click', doGenerateRecoveryKey);
$('#recoveryRemoveBtn').addEventListener('click', doRemoveRecoveryKey);
$('#recoveryBtn').addEventListener('click', doRecoveryRedeem);
// Quick unlock (DPAPI)
$('#quickUnlockToggleBtn').addEventListener('click', () => {
if (state.quickUnlockEnabled) disableQuickUnlock();
else enableQuickUnlock();
});
// Re-auth modal
$('#reauthForm').addEventListener('submit', e => { e.preventDefault(); closeReauth(true); });
$$('#reauthModal [data-close]').forEach(b => b.addEventListener('click', () => closeReauth(false)));
// Custom confirm / prompt modal
$('#confirmForm').addEventListener('submit', e => {
e.preventDefault();
// If input field visible -> resolve with its value, else -> true
const hasInput = !$('#confirmInputField').classList.contains('is-hidden');
closeConfirm(hasInput ? $('#confirmInput').value : true);
});
$$('#confirmModal [data-confirm-cancel]').forEach(b =>
b.addEventListener('click', () => closeConfirm(false))
);
// Helper: is the user currently typing into an input/textarea/select
// or contenteditable surface? Shortcuts like Ctrl+A must NOT hijack
// input focus (browser default = select all text in the field).
function isTypingTarget(t) {
if (!t) return false;
const tag = t.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
if (t.isContentEditable) return true;
return false;
}
// Command palette + bulk shortcuts
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
openPalette();
} else if ((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')) {
// Ctrl+A = select every visible entry. Replaces the role of
// a marquee "drag across all cards" that we used to have.
// Only fire when the user isn't typing in a field — otherwise
// we'd steal the universal Select All in inputs.
if (isTypingTarget(e.target)) return;
// Don't fire if the app isn't actually showing the entry grid
// (auth screen, locked, etc.)
if ($('#appShell').classList.contains('is-hidden')) return;
e.preventDefault();
const visible = filteredEntries();
visible.forEach(en => state.checked.add(en.id));
renderGrid();
} else if (e.key === 'Escape') {
// Close in priority order: confirm first (most modal-y) then others
if (!$('#confirmModal').classList.contains('is-hidden')) {
closeConfirm(false);
return;
}
if (!$('#changeMasterModal').classList.contains('is-hidden')) {
closeChangeMasterModal();
return;
}
closePalette();
closeSlideOver();
closeEntryModal();
closeGen();
// If nothing else needed dismissing and there's an active
// selection, clear it. Replaces the "click empty space to
// deselect" path that the marquee provided.
if (state.checked.size > 0) {
state.checked.clear();
renderGrid();
}
}
});
$('#cmdInput').addEventListener('input', e => renderPaletteResults(e.target.value));
$$('#cmdPalette [data-close]').forEach(b => b.addEventListener('click', closePalette));
// Re-sync quickUnlockEnabled from the DPAPI source of truth. localStorage
// is wiped at each launch (random port → new origin), so the cached value
// can lie about the actual server-side state.
if (Bridge.active) {
const has = await bridgeQuickUnlockStatus();
state.quickUnlockEnabled = has;
if (has) localStorage.setItem('quickUnlockEnabled', '1');
else localStorage.removeItem('quickUnlockEnabled');
}
// Restore session if any
if (state.token && state.salt) {
const ok = await restoreCryptoKey();
if (ok) {
await enterApp();
} else {
// sessionStorage still has token+salt but the crypto key was
// cleared (locked / tab closed). Try the DPAPI quick-unlock
// path before falling back to the master-pw prompt.
if (await tryQuickUnlock()) {
await enterApp();
} else {
showAuth();
$('#loginUsername').value = state.username;
}
}
} else {
// Cold start: no sessionStorage at all. Quick unlock can still
// restore everything (token + salt + key + username) from the
// DPAPI blob.
if (await tryQuickUnlock()) {
await enterApp();
} else {
showAuth();
}
}
}
document.addEventListener('DOMContentLoaded', init);