Files
Password-Manager/js/app.js
T
r-zakarya 97a19836a0 refactor(js): extract import/export module from app.js monofile (§3.1)
Third slice of the app.js split. Moves the encrypted export container,
CSV/JSON import parsing (parseCSV, findColumn, parseEntriesFromCSV,
parseEntriesFromJSON), and the doImport/doExport/doExportCSV flows to
js/app.import.js. encryptImportEntry moves here too (also called by
app.sync.js — resolved via shared global scope at call time).

- Byte-for-byte identical to the extracted block; no duplicate const;
  no top-level import ref left in app.js.
- Load order: BEFORE app.js (pure declarations, no top-level side effects),
  alongside app.crypto.js. Full order: argon2 → crypto → import → app → sync.
- index.html + BuildAssets whitelist + harness APP_PARTS updated.
- Safety net: the 14 CSV tests exercise parseCSV/parseEntriesFromCSV from
  the extracted file and stay green (42/42).

app.js: 11936 → 10253 lines (crypto + sync + import now separate, ~1700
lines moved into 3 modules).

NOTE: assets.res not regenerated here (needs brcc32/Delphi) — run
BuildAssets before the next Delphi build to embed js/app.import.js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 16:24:11 +01:00

10254 lines
427 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 = {};
const fileSaveResolvers = {};
const fileChunkResolvers = {};
// Stream a base64 string to Delphi in URL-sized pieces via cmd://file/chunk,
// each acked (onFileChunkAck) before the next is sent. Returns true when all
// pieces are buffered server-side, false on a stalled chunk. The ack CLEARS
// the pending timeout — without that, a resolved chunk's stale 30s timeout
// would later delete the CURRENT chunk's resolver and hang the transfer
// forever (only the last location.href navigation "wins" per event loop, so
// resolvers must be strictly 1-at-a-time and their timers torn down).
async function _streamChunks(reqId, b64, onProgress) {
const CHUNK = 1000000;
const total = Math.ceil(b64.length / CHUNK);
let done = 0;
for (let off = 0; off < b64.length; off += CHUNK) {
const piece = b64.slice(off, off + CHUNK);
const ok = await new Promise(res => {
const timer = setTimeout(() => {
if (fileChunkResolvers[reqId]) {
delete fileChunkResolvers[reqId];
res(false);
}
}, 30000);
fileChunkResolvers[reqId] = { resolve: res, timer };
window.location.href = 'cmd://file/chunk?reqId=' + encodeURIComponent(reqId) +
'&data=' + encodeURIComponent(piece);
});
if (!ok) return false;
done++;
if (typeof onProgress === 'function') onProgress(Math.round(done / total * 100));
}
return true;
}
let versionResolver = null;
let launchModeResolver = null;
const folderPickResolvers = {};
const fileWriteResolvers = {};
const fileListResolvers = {};
const fileDeleteResolvers = {};
let autoStartResolver = null;
const faviconResolvers = {};
let _faviconReqSeq = 0;
const Bridge = (() => {
const active = (API === '');
// Navigate to a cmd:// URL — intercepted synchronously by
// TTMSFNCWebBrowser OnBeforeNavigate before any actual navigation occurs.
function cmd(path) {
window.location.href = path;
}
return {
active,
// Copy text to clipboard, excluded from Win+V history.
// clearAfterMs: Delphi auto-clears after this many ms (0 = never).
// Returns true when the bridge handled the copy, false as fallback signal.
copySecure(text, clearAfterMs = 30000) {
if (!active) return false;
cmd('cmd://clipboard/copy?text=' + encodeURIComponent(text) +
'&clear=' + clearAfterMs);
return true;
},
// Called by Delphi (ExecuteJavaScript) on WTS_SESSION_LOCK and
// PBT_APMSUSPEND. When quick-unlock is enabled on this device the
// DPAPI blob already gates access via the Windows user account, so
// re-locking is redundant — we just stay unlocked and the user is
// back where they left off when they return.
onSystemLock() {
if (state.quickUnlockEnabled) {
if (typeof toast === 'function')
toast('System lock — vault kept unlocked (quick unlock active)');
return;
}
if (typeof lockVault === 'function') lockVault();
},
// Called by Delphi (ExecuteJavaScript) when the user restores the
// window from the tray icon. Useful for resetting auto-lock state
// and giving a subtle visual cue.
onTrayRestore() {
// If the user has been away long enough that the auto-lock
// should fire, lockVault was already called by either WTS lock
// or the local idle timer — so we only reset here when still
// unlocked.
if (state.cryptoKey && !state.locked) {
if (typeof resetAutoLock === 'function') resetAutoLock();
if (typeof toast === 'function') toast('Welcome back');
}
},
// ---- Autofill (Ctrl+Shift+L / Ctrl+Shift+P global hotkeys) ----------
// Called by Delphi when a hotkey fires. windowTitle = foreground
// window title at hotkey time. kind = "full" (user+Tab+pwd) or
// "password" (password only — for step-2 forms, unlock screens).
onAutofillRequest(windowTitle, kind) {
autofillHandleRequest(windowTitle, kind || 'full');
},
// Called by Delphi on Ctrl+Shift+A. Opens the new-entry modal with
// the foreground window title pre-filled (browser suffix stripped).
onNewEntryFromTitle(windowTitle) {
if (!state.cryptoKey || state.locked || !state.token) {
toast('Unlock the vault first', 'warning');
return;
}
const cleaned = autofillStripBrowserSuffix(windowTitle);
openSlideOver(null, { presetTitle: cleaned });
},
// Tell Delphi to simulate keystrokes. Empty username = password only
// (no Tab is sent). hideAfter=true asks Delphi to hide our window
// back to the tray AFTER SendInput completes — necessary for the
// Ctrl+Shift+Q-from-tray flow (we cannot hide before SendInput or
// Win10/11 anti-focus-stealing rules block the target).
executeAutofill(username, password, hideAfter, field) {
if (!active) return;
// field='user' → type only the username (no Tab / password).
cmd('cmd://autofill/execute?username=' + encodeURIComponent(username) +
'&password=' + encodeURIComponent(password) +
(hideAfter ? '&hide_after=1' : '') +
(field === 'user' ? '&field=user' : ''));
},
// Ask Delphi to bring the main window to front (used when the
// multi-match picker opens, so the user definitely sees it even
// if the app was minimised to tray or hidden behind other apps).
focusApp() {
if (!active) return;
cmd('cmd://app/focus');
},
// Tell Delphi the page is rendered and waiting for input — Delphi
// calls WebBrowser.SetFocus (the FMX control needs OS-level focus
// before any input.focus() inside the DOM can work) then injects
// a focus script targeting the visible auth field.
appReady() {
if (!active) return;
cmd('cmd://app/ready');
},
// Sync the Windows title bar with the app theme (dark vs light).
// Calls DwmSetWindowAttribute DWMWA_USE_IMMERSIVE_DARK_MODE on the
// form's HWND. No-op on Windows < 10 build 19044.
syncTitleBarTheme(mode) {
if (!active) return;
cmd('cmd://app/theme?mode=' + (mode === 'light' ? 'light' : 'dark'));
},
// Tell Delphi we couldn't find a match / user cancelled.
cancelAutofill() {
if (!active) return;
cmd('cmd://autofill/cancel');
},
// Sync the hotkey registration state with this device's preference.
configureAutofill(enabled) {
if (!active) return;
cmd('cmd://autofill/configure?enabled=' + (enabled ? '1' : '0'));
},
// Send the full hotkey configuration to Delphi (enabled + combos).
// combos = { full: {ctrl,shift,alt,win,key}, password: {…} }.
setAutofillHotkeys(enabled, combos) {
if (!active) return;
const f = autofillComboToWin32(combos.full);
const p = autofillComboToWin32(combos.password);
let qs = '';
if (combos.quickSearch) {
const q = autofillComboToWin32(combos.quickSearch);
qs = '&qs_mods=' + q.mods + '&qs_vk=' + q.vk;
}
cmd('cmd://autofill/hotkeys?enabled=' + (enabled ? '1' : '0') +
'&full_mods=' + f.mods + '&full_vk=' + f.vk +
'&pwd_mods=' + p.mods + '&pwd_vk=' + p.vk + qs);
},
// Called by Delphi after a setAutofillHotkeys request, with true if
// BOTH combos registered successfully, false otherwise (clash with
// another app holding a global hotkey). Surfaces a toast.
onAutofillHotkeysResult(allOk) {
if (allOk) {
toast('Autofill hotkeys updated');
} else {
toast('Autofill: one or both hotkeys are already used by another app', 'warning');
}
},
// ---- Device-bound prefs (DPAPI-backed) ----------------------------
// localStorage is keyed by origin, and our HTTP port is random on
// every launch — so anything we put there is wiped at reboot. For
// prefs that need to survive a reboot (remembered username, etc.),
// round-trip through Delphi which persists via DPAPI.
getPref(key) {
if (!active) return Promise.resolve('');
return new Promise(resolve => {
prefResolvers[key] = resolve;
cmd('cmd://prefs/get?key=' + encodeURIComponent(key));
setTimeout(() => {
if (prefResolvers[key] === resolve) {
delete prefResolvers[key];
resolve('');
}
}, 2000);
});
},
setPref(key, value) {
if (!active) return;
cmd('cmd://prefs/set?key=' + encodeURIComponent(key) +
'&value=' + encodeURIComponent(value || ''));
},
onPrefResult(key, value) {
const r = prefResolvers[key];
if (r) {
delete prefResolvers[key];
r(value || '');
}
},
// Native Save As dialog — bypasses WebView2's browser download UI
// (which Edge wraps with "Téléchargements" popup + Open File prompt).
// The caller passes raw bytes as Uint8Array OR a string; we base64
// it and ship to Delphi which writes the file after the user picks.
saveFile(name, content, onProgress) {
if (!active) return Promise.resolve({ ok: false, error: 'bridge offline' });
let bytes;
if (typeof content === 'string') bytes = new TextEncoder().encode(content);
else bytes = content;
const b64 = bytesToBase64(bytes);
const reqId = 'fs_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
// cmd:// goes through window.location.href, which WebView2 caps at
// roughly a couple MB of URL. A big attachment base64'd blows past
// that → the navigation blanks the document (black screen). So for
// anything large we stream the data in chunks small enough to fit
// a URL, each acknowledged before the next is sent (sequential —
// otherwise repeated location.href assignments coalesce and only
// the last lands). Small payloads keep the fast single-shot path.
// ~1 MB base64 per chunk — comfortably under WebView2's ~2 MB
// URL cap even after percent-encoding, while keeping the number
// of round-trips (and total time) low on big exports.
const CHUNK = 1000000;
if (b64.length <= CHUNK) {
return new Promise(resolve => {
fileSaveResolvers[reqId] = resolve;
cmd('cmd://file/save?name=' + encodeURIComponent(name) +
'&data=' + encodeURIComponent(b64) +
'&reqId=' + encodeURIComponent(reqId));
setTimeout(() => {
if (fileSaveResolvers[reqId]) {
delete fileSaveResolvers[reqId];
resolve({ ok: false, error: 'timeout' });
}
}, 120000);
});
}
return (async () => {
const ok = await _streamChunks(reqId, b64, onProgress);
if (!ok) return { ok: false, error: 'chunk transfer failed' };
return await new Promise(resolve => {
fileSaveResolvers[reqId] = resolve;
cmd('cmd://file/save-commit?reqId=' + encodeURIComponent(reqId) +
'&name=' + encodeURIComponent(name));
setTimeout(() => {
if (fileSaveResolvers[reqId]) {
delete fileSaveResolvers[reqId];
resolve({ ok: false, error: 'timeout' });
}
}, 120000);
});
})();
},
onFileChunkAck(reqId) {
const r = fileChunkResolvers[reqId];
if (r) {
delete fileChunkResolvers[reqId];
if (r.timer) clearTimeout(r.timer);
r.resolve(true);
}
},
onFileSaveResult(reqId, ok, path, err) {
const r = fileSaveResolvers[reqId];
if (r) {
delete fileSaveResolvers[reqId];
r({ ok: !!ok, path: path || '', error: err || '' });
}
},
// ---- Auto-backup helpers (folder pick + silent write + list + delete)
pickFolder() {
if (!active) return Promise.resolve('');
const reqId = 'fp_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
return new Promise(resolve => {
folderPickResolvers[reqId] = resolve;
cmd('cmd://folder/pick?reqId=' + encodeURIComponent(reqId));
setTimeout(() => {
if (folderPickResolvers[reqId]) {
delete folderPickResolvers[reqId];
resolve('');
}
}, 120000);
});
},
onFolderPickResult(reqId, path) {
const r = folderPickResolvers[reqId];
if (r) { delete folderPickResolvers[reqId]; r(path || ''); }
},
writeFile(path, content, onProgress) {
if (!active) return Promise.resolve({ ok: false, error: 'bridge offline' });
let bytes;
if (typeof content === 'string') bytes = new TextEncoder().encode(content);
else bytes = content;
const b64 = bytesToBase64(bytes);
const reqId = 'fw_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
// Same URL-length trap as saveFile: a big auto-backup base64'd
// into a single cmd:// URL blows past WebView2's cap and the
// write fails (silently, or blanks the page). Chunk it.
const CHUNK = 1000000;
if (b64.length <= CHUNK) {
return new Promise(resolve => {
fileWriteResolvers[reqId] = resolve;
cmd('cmd://file/write?path=' + encodeURIComponent(path) +
'&data=' + encodeURIComponent(b64) +
'&reqId=' + encodeURIComponent(reqId));
setTimeout(() => {
if (fileWriteResolvers[reqId]) {
delete fileWriteResolvers[reqId];
resolve({ ok: false, error: 'timeout' });
}
}, 30000);
});
}
return (async () => {
const ok = await _streamChunks(reqId, b64, onProgress);
if (!ok) return { ok: false, error: 'chunk transfer failed' };
return await new Promise(resolve => {
fileWriteResolvers[reqId] = resolve;
cmd('cmd://file/write-commit?reqId=' + encodeURIComponent(reqId) +
'&path=' + encodeURIComponent(path));
setTimeout(() => {
if (fileWriteResolvers[reqId]) {
delete fileWriteResolvers[reqId];
resolve({ ok: false, error: 'timeout' });
}
}, 30000);
});
})();
},
onFileWriteResult(reqId, ok, err) {
const r = fileWriteResolvers[reqId];
if (r) { delete fileWriteResolvers[reqId]; r({ ok: !!ok, error: err || '' }); }
},
listFiles(dir, prefix) {
if (!active) return Promise.resolve([]);
const reqId = 'fl_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
return new Promise(resolve => {
fileListResolvers[reqId] = resolve;
cmd('cmd://file/listMatch?dir=' + encodeURIComponent(dir) +
'&prefix=' + encodeURIComponent(prefix || '') +
'&reqId=' + encodeURIComponent(reqId));
setTimeout(() => {
if (fileListResolvers[reqId]) {
delete fileListResolvers[reqId];
resolve([]);
}
}, 10000);
});
},
onFileListResult(reqId, json) {
const r = fileListResolvers[reqId];
if (!r) return;
delete fileListResolvers[reqId];
try { r(JSON.parse(json || '[]')); }
catch { r([]); }
},
deleteFile(path) {
if (!active) return Promise.resolve(false);
const reqId = 'fd_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
return new Promise(resolve => {
fileDeleteResolvers[reqId] = resolve;
cmd('cmd://file/delete?path=' + encodeURIComponent(path) +
'&reqId=' + encodeURIComponent(reqId));
setTimeout(() => {
if (fileDeleteResolvers[reqId]) {
delete fileDeleteResolvers[reqId];
resolve(false);
}
}, 10000);
});
},
onFileDeleteResult(reqId, ok) {
const r = fileDeleteResolvers[reqId];
if (r) { delete fileDeleteResolvers[reqId]; r(!!ok); }
},
// ---- Start with Windows (HKCU Run registry) -----------------------
getAutoStart() {
if (!active) return Promise.resolve(false);
return new Promise(resolve => {
autoStartResolver = resolve;
cmd('cmd://autostart/get');
setTimeout(() => {
if (autoStartResolver === resolve) {
autoStartResolver = null;
resolve(false);
}
}, 2000);
});
},
setAutoStart(enabled) {
if (!active) return;
cmd('cmd://autostart/set?enabled=' + (enabled ? '1' : '0'));
},
onAutoStartStatus(enabled) {
if (autoStartResolver) {
const r = autoStartResolver;
autoStartResolver = null;
r(!!enabled);
}
state.autoStartEnabled = !!enabled;
const cb = document.getElementById('settingAutoStart');
if (cb) cb.checked = !!enabled;
},
// ---- Favicon fetch (via Delphi proxy → DuckDuckGo icons) ----------
// Returns a Promise<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 || '');
}
},
// Tray menu "Quick search…" → open a compact modal. wasHidden
// (passed by Delphi) tells us whether the window was in the tray
// before — if so, after the user picks an entry we ask Delphi to
// hide the window again so the paste workflow is one keystroke
// (Ctrl+V in the target app).
// Locked vault → fall through to the master-password screen.
openQuickSearch(wasHidden, forFill) {
if (state.locked || !state.cryptoKey || !state.token) {
const pwd = document.getElementById('loginPassword');
if (pwd && !document.getElementById('authScreen').classList.contains('is-hidden')) {
setTimeout(() => pwd.focus(), 60);
}
if (typeof toast === 'function')
toast('Vault is locked — unlock to search', 'warning');
// Tell Delphi we cancelled so the captured HWND doesn't
// linger waiting for a never-coming /execute.
if (forFill && Bridge.cancelAutofill) Bridge.cancelAutofill();
return;
}
if (typeof openQuickSearchModal === 'function')
openQuickSearchModal(!!wasHidden, !!forFill);
},
// Hide the window back to the tray icon. Used by Quick search to
// restore "was in tray" state after a password copy.
minimizeToTray(keepClipboard) {
if (!active) return;
// keepClipboard=true → the just-copied password survives the
// minimise (quick-search copy-then-hide). Default clears it.
cmd('cmd://app/minimize' + (keepClipboard ? '?keepclip=1' : ''));
},
// Push the "show tray notifications" preference to Delphi so the
// bridge gates the Shell_NotifyIcon NIF_INFO balloons accordingly.
setTrayNotifications(enabled) {
if (!active) return;
cmd('cmd://tray/notifications?enabled=' + (enabled ? '1' : '0'));
},
// Hardcoded build version surfaced from the Delphi host. Promise-
// based with a 2s safety timeout — if the host is unreachable the
// resolver fires with '' rather than hanging the UI.
getAppVersion() {
if (!active) return Promise.resolve('');
return new Promise(resolve => {
versionResolver = resolve;
cmd('cmd://app/version');
setTimeout(() => {
if (versionResolver === resolve) {
versionResolver = null;
resolve('');
}
}, 2000);
});
},
onVersionResult(v) {
if (versionResolver) {
const r = versionResolver;
versionResolver = null;
r(v || '');
}
},
// Was the app launched at Windows boot (via the HKCU Run entry's
// -tray flag), or did the user double-click the exe?
// Returns 'auto' | 'manual' | '' (bridge offline).
getLaunchMode() {
if (!active) return Promise.resolve('');
return new Promise(resolve => {
launchModeResolver = resolve;
cmd('cmd://app/launch-mode');
setTimeout(() => {
if (launchModeResolver === resolve) {
launchModeResolver = null;
resolve('');
}
}, 2000);
});
},
onLaunchModeResult(mode) {
if (launchModeResolver) {
const r = launchModeResolver;
launchModeResolver = null;
r(mode || '');
}
},
// Open an http(s) URL in the user's default browser via ShellExecute.
// Delphi validates the scheme so a malformed entry can't smuggle a
// file:// or custom handler.
openUrl(url) {
if (!active) return;
cmd('cmd://app/open-url?url=' + encodeURIComponent(url));
},
};
})();
// Expose Bridge on window so Delphi's ExecuteJavaScript can reach it.
window.Bridge = Bridge;
// ---- Global state ------------------------------------------
const state = {
token: sessionStorage.getItem('authToken') || '',
csrf: sessionStorage.getItem('csrfToken') || '',
salt: sessionStorage.getItem('salt') || '',
username: sessionStorage.getItem('username') || '',
// Profile picture as a data URI. Loaded from the server at enterApp
// (users.avatar_b64). Empty → the initials avatar is shown instead.
avatarDataUri: '',
// KDF iteration count of the currently-logged-in user. Cached so reauth
// and on-the-fly verifier computations don't need a /login/challenge
// round trip every time. Refreshed from every auth response.
kdfIterations: parseInt(sessionStorage.getItem('kdfIterations') || '0') || 0,
// Auth-hash scheme of the current account. Drives which verifier formula
// the client sends: 'pbkdf2-sha256-v2' → SHA256(keyHex + domain) so the
// transmitted verifier is NOT the raw AES key; anything else → keyHex
// (legacy / pre-decoupling accounts, byte-identical to before). Set from
// the /login/challenge response, from the cold-start blob, or hardcoded
// to v2 on register / master-pw change.
hashAlgo: sessionStorage.getItem('hashAlgo') || '',
// Argon2id KDF params { m, t, p } for the current account, or null for
// PBKDF2 accounts. Set from /login/challenge, the cold-start blobs, or
// ARGON2_DEFAULT_PARAMS on register / master-pw change. Needed wherever a
// key/verifier is DERIVED FROM THE PASSWORD (login, reauth, rotation) —
// NOT for cold-start verifier-from-raw-key paths.
argon2Params: (() => {
try { return JSON.parse(sessionStorage.getItem('argon2Params') || 'null'); }
catch { return null; }
})(),
cryptoKey: null,
entries: [],
trashed: [],
trashedCount: 0, // server-side count, updated separately from state.trashed
folders: [{ name: 'All', color: '', icon: '' }],
// Index in filteredEntries() of the keyboard-focused card (j/k nav).
// -1 = no cursor. Cleared on view/search change so it never points
// outside the current list.
cursorIdx: -1,
view: 'all', // 'all' | 'favorites' | 'folder:<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
// Advanced filters — runtime only (cleared on lockVault). Set of string
// keys; see FILTER_DEFS for the available predicates.
activeFilters: new Set(),
hibpEnabled: localStorage.getItem('hibpEnabled') === '1', // default OFF
// entry.id → count from HIBP (0 = clean, >0 = pwned, undefined = unchecked)
hibpResults: new Map(),
quickUnlockEnabled: localStorage.getItem('quickUnlockEnabled') === '1',
recoveryConfigured: false, // refreshed by refreshRecoveryStatus on Settings open
autofillEnabled: localStorage.getItem('autofillEnabled') !== '0', // default ON
// Hotkey combos. Each combo = { ctrl, shift, alt, win, key }.
// key is the uppercase character or VK label ('A'..'Z', '0'..'9',
// 'F1'..'F12'). Default: Ctrl+Shift+L / Ctrl+Shift+P. Combos are
// local-by-default but ALSO synced via settings_json so they travel
// with the user (still Windows-only at runtime — non-Windows clients
// ignore the value).
autofillHotkeyFull: JSON.parse(localStorage.getItem('autofillHotkeyFull') ||
'{"ctrl":true,"shift":true,"alt":false,"win":false,"key":"L"}'),
autofillHotkeyPwd: JSON.parse(localStorage.getItem('autofillHotkeyPwd') ||
'{"ctrl":true,"shift":true,"alt":false,"win":false,"key":"P"}'),
quickSearchHotkey: JSON.parse(localStorage.getItem('quickSearchHotkey') ||
'{"ctrl":true,"shift":true,"alt":false,"win":false,"key":"Q"}'),
sidebarCollapsed: JSON.parse(localStorage.getItem('sidebarCollapsed') ||
'{"folders":false,"tags":false,"tools":false}'),
// Fetch website favicons via the Delphi DuckDuckGo proxy. OFF by
// default — opt-in because it sends each entry's domain to a third
// party (DuckDuckGo). Synced because it's a portable preference.
faviconsEnabled: localStorage.getItem('faviconsEnabled') === '1',
// Show the "running in tray" balloon (and any future tray balloon).
// Default ON — gates Shell_NotifyIcon NIF_INFO calls in PM.Bridge.
trayNotificationsEnabled: localStorage.getItem('trayNotificationsEnabled') !== '0',
// Days after which trashed entries are permanently purged. 0 = never.
// Synced across devices because it's a user-level preference.
trashAutoPurgeDays: parseInt(localStorage.getItem('trashAutoPurgeDays') || '0') || 0,
passwordExpiryDays: parseInt(localStorage.getItem('passwordExpiryDays') || '0') || 0,
// Table-view hidden columns. Array of keys that the user opted to
// hide (e.g. ['folder', 'updated']). Synced across devices.
tableColsHidden: (() => {
try { return JSON.parse(localStorage.getItem('tableColsHidden') || '[]'); }
catch { return []; }
})(),
editorPosition: localStorage.getItem('editorPosition') || 'right',
confirmOnUnsaved: localStorage.getItem('confirmOnUnsaved') !== '0',
// 'pw' (master only) | 'pin' (PIN only) | 'both' (master + PIN). 'pw'
// is the safe default — any device without a configured PIN behaves
// identically to the legacy unlock flow.
unlockMode: localStorage.getItem('unlockMode') || 'pw',
// Runtime mirror of the DPAPI blob existence — populated on init by
// bridgePinStatus(). Editing this directly doesn't touch storage.
pinConfigured: false,
};
// ============================================================
// CRYPTO — extracted to js/app.crypto.js (§3.1), loaded as a
// separate <script> before this file. Kept out of the monofile
// so the crypto core can be navigated + syntax-checked alone.
// ============================================================
// ============================================================
// HTTP HELPERS
// ============================================================
function authHeaders(extra) {
const h = Object.assign({ 'Authorization': 'Bearer ' + state.token }, extra || {});
if (state.csrf) h['X-CSRF-Token'] = state.csrf;
return h;
}
async function api(path, opts) {
opts = opts || {};
const r = await fetch(API + path, opts);
let body = null;
try { body = await r.json(); } catch (e) { body = {}; }
if (!r.ok) {
// Preserve status + body on the Error so callers can distinguish
// 429-with-retry_after (account lockout) from a generic auth error.
const err = new Error(body.error || ('HTTP ' + r.status));
err.status = r.status;
err.body = body || {};
throw err;
}
return body;
}
// ============================================================
// TOTP (RFC 6238) — 6-digit time-based codes
// ============================================================
//
// Implementation is pure crypto.subtle (HMAC-SHA1) + a small base32
// decoder. No external library. The secret is stored encrypted with the
// vault's AES-GCM key (same flow as passwords), so the server never sees
// the plaintext base32 secret.
// Decode an RFC 4648 base32 string (Google Authenticator format) to bytes.
// Tolerates lowercase, spaces, and padding. Throws on invalid characters.
function base32Decode(s) {
const ALPH = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const clean = String(s).toUpperCase().replace(/[\s=]/g, '');
let bits = 0, buffer = 0;
const out = [];
for (const ch of clean) {
const v = ALPH.indexOf(ch);
if (v < 0) throw new Error('Invalid base32 character: ' + ch);
buffer = (buffer << 5) | v;
bits += 5;
if (bits >= 8) {
bits -= 8;
out.push((buffer >> bits) & 0xFF);
}
}
return new Uint8Array(out);
}
// Generate a TOTP code per RFC 6238. Returns the 6-digit code as a string
// (zero-padded) along with how many seconds remain in the current 30s window.
// Throws if the secret can't be decoded.
async function generateTOTP(secretBase32, period, digits) {
period = period || 30;
digits = digits || 6;
const keyBytes = base32Decode(secretBase32);
// Counter = floor(unix_time / period), encoded as 8-byte big-endian.
const nowSec = Math.floor(Date.now() / 1000);
let counter = Math.floor(nowSec / period);
const counterBytes = new Uint8Array(8);
for (let i = 7; i >= 0; i--) {
counterBytes[i] = counter & 0xFF;
counter = Math.floor(counter / 256);
}
const cryptoKey = await crypto.subtle.importKey(
'raw', keyBytes,
{ name: 'HMAC', hash: 'SHA-1' },
false, ['sign']
);
const sigBuf = await crypto.subtle.sign('HMAC', cryptoKey, counterBytes);
const sig = new Uint8Array(sigBuf);
// Dynamic truncation: low nibble of last byte = offset into HMAC output.
const offset = sig[sig.length - 1] & 0x0F;
const truncated =
((sig[offset] & 0x7F) << 24) |
((sig[offset + 1] & 0xFF) << 16) |
((sig[offset + 2] & 0xFF) << 8) |
( sig[offset + 3] & 0xFF);
const mod = Math.pow(10, digits);
const code = String(truncated % mod).padStart(digits, '0');
return {
code: code,
period: period,
secondsLeft: period - (nowSec % period),
};
}
// Parse a Google Authenticator-style otpauth:// URI and extract the secret.
// Example: otpauth://totp/Example:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example
// Returns the secret alone (we don't yet honor issuer/algorithm/digits/period
// overrides — assume SHA-1 / 6 digits / 30s, which covers ~all real services).
function parseOtpAuthUri(raw) {
raw = String(raw || '').trim();
if (!raw.toLowerCase().startsWith('otpauth://')) return null;
try {
const u = new URL(raw);
const sec = u.searchParams.get('secret');
return sec ? sec.trim() : null;
} catch (e) { return null; }
}
// Encrypt a TOTP secret with the vault key. Returns { encrypted, iv } in
// the same base64 shape as encryptPwd, ready to send to the server.
async function encryptTotpSecret(secretBase32) {
return await encryptPwd(secretBase32); // same crypto, just different field
}
async function decryptTotpSecret(encB64, ivB64) {
return await decryptPwd(encB64, ivB64);
}
// ---- Custom fields (per-entry encrypted JSON array) ----------------
//
// Stored as:
// vault_entries.custom_fields = base64 AES-GCM ciphertext of JSON
// vault_entries.custom_fields_iv = base64 12-byte IV
// Plaintext shape:
// [{ "label": "PIN", "value": "1234", "is_secret": true }, ...]
//
// Same crypto pipeline as encrypted_password (reuses encryptPwd /
// decryptPwd over the JSON string) so the master-pw rotation logic
// works without any special-casing — it just sees one more ciphertext
// blob per entry to re-encrypt.
async function encryptCustomFields(fieldsArray) {
if (!Array.isArray(fieldsArray) || fieldsArray.length === 0)
return { encrypted: '', iv: '' };
return await encryptPwd(JSON.stringify(fieldsArray));
}
async function decryptCustomFields(encB64, ivB64) {
if (!encB64 || !ivB64) return [];
const plain = await decryptPwd(encB64, ivB64);
if (plain === '[ERROR]' || !plain) return [];
try {
const arr = JSON.parse(plain);
return Array.isArray(arr) ? arr : [];
} catch (e) { return []; }
}
// ============================================================
// FAVICONS (opt-in, cached server-side as base64 data URI)
// ============================================================
// Extract a usable host from entry.site (we accept anything user-typed).
// Returns '' for values that don't look like real hostnames — common case
// is users storing a brand label ("Gitea", "Work GitHub") to help the
// autofill matcher. Sending those to DDG would leak meaningless tokens
// without ever producing an icon.
function faviconHost(siteRaw) {
if (!siteRaw) return '';
let s = String(siteRaw).trim().toLowerCase();
s = s.replace(/^https?:\/\//, '').replace(/^www\./, '');
s = s.split('/')[0].split(':')[0];
// Validate: dot-separated labels, only hostname-safe chars, TLD ≥ 2
// letters. Rejects "Gitea", "my work pwd", IP-like "1.2.3.4" stays
// valid (DDG handles IPs gracefully). 253-char overall cap mirrors
// the DNS spec.
if (!s || s.length > 253) return '';
if (!/^[a-z0-9.-]+$/.test(s)) return '';
if (s.indexOf('.') < 1) return '';
if (!/\.[a-z]{2,}$/.test(s)) return '';
if (s.startsWith('.') || s.endsWith('.')) return '';
if (s.indexOf('..') >= 0) return '';
return s;
}
// Save the icon for one entry via the dedicated endpoint (no full PUT,
// no re-encryption). Fire-and-forget: failures are silent so a flaky
// network doesn't break the user's flow.
async function saveEntryIcon(entryId, dataUri) {
try {
const r = await fetch(API + '/entries/' + entryId + '/icon', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ icon_b64: dataUri || '' }),
});
if (!r.ok) {
// Surface the server's reason so silent persistence failures
// (size cap, auth) stop being invisible bugs.
let msg = 'HTTP ' + r.status;
try { const b = await r.json(); if (b && b.error) msg = b.error; }
catch (_) {}
toast('Icon NOT saved: ' + msg, 'error');
}
} catch (e) {
toast('Icon save failed: ' + (e && e.message || e), 'error');
}
}
// Fetch + save the favicon for one entry. Updates state.entries in-place
// so the next render() picks it up. No-op if the entry already has one.
// opts: { force: bypass "already has icon" skip, manual: bypass the global
// faviconsEnabled toggle (for explicit user actions like the Refresh button) }
async function ensureEntryFavicon(entry, opts) {
opts = opts || {};
if (!Bridge.active) return;
if (!opts.manual && !state.faviconsEnabled) return;
if (!opts.force && entry.icon_b64) return;
const host = faviconHost(entry.site);
if (!host) return;
const dataUri = await Bridge.fetchFavicon(host);
if (!dataUri) return;
entry.icon_b64 = dataUri;
await saveEntryIcon(entry.id, dataUri);
// Full render() — patching the avatar in place is fragile because
// the avatar also contains the checkbox overlay.
render();
}
// Backfill: walk state.entries, fetch missing icons one at a time so we
// don't hammer the upstream. Used by the "Refresh icons" button.
async function backfillFavicons(force) {
if (!Bridge.active) return;
const all = state.entries;
const eligible = all.filter(e => faviconHost(e.site));
const skipped = all.length - eligible.length;
const targets = eligible.filter(e => force || !e.icon_b64);
if (targets.length === 0) {
if (skipped > 0) {
toast('No icons to fetch — ' + skipped +
' entries have a non-domain site (e.g. "Gitea")', 'warning');
} else {
toast('No icons to fetch');
}
return;
}
toast('Fetching ' + targets.length + ' icon' + (targets.length === 1 ? '' : 's') + '…');
let ok = 0;
for (const e of targets) {
// Explicit user action — bypass the global toggle so the buttons
// work even when "Fetch website icons" is OFF (the toggle only
// gates auto-fetch on save).
await ensureEntryFavicon(e, { force: !!force, manual: true });
if (e.icon_b64) ok++;
}
toast('Fetched ' + ok + ' / ' + targets.length + ' icons');
render();
}
async function clearAllFavicons() {
try {
await fetch(API + '/entries/icons/all', {
method: 'DELETE',
headers: authHeaders(),
});
} catch (e) {
toast('Failed to clear icons', 'error');
return;
}
state.entries.forEach(e => { e.icon_b64 = null; });
render();
toast('Cached icons cleared');
}
// ============================================================
// QUICK SEARCH MODAL (tray menu → fast password copy)
// ============================================================
//
// Trades on the autofill workflow when SendInput can't reach the target
// (UIPI-elevated app, native non-text-input UI, etc.): right-click tray →
// Quick search → type → Enter → password is on the clipboard, ready to
// paste with Ctrl+V. App returns to whatever state it was in afterwards.
let quickSearchSelected = 0;
// When opened from the tray menu, we hide back to tray after the user
// picks an entry — so the previously-foreground app comes back and
// Ctrl+V drops the password in.
let quickSearchHideAfter = false;
// When opened by Ctrl+Shift+Q hotkey, Delphi has saved the foreground
// HWND and is waiting for cmd://autofill/execute. On pick we SendInput
// the password instead of copying to the clipboard.
let quickSearchFillMode = false;
function quickSearchScoreEntry(e, q) {
if (!q) return 1; // empty query → all entries pass, ordering preserved
const ql = q.toLowerCase();
const fields = [
(e.title || ''),
(e.site || ''),
(e.username || ''),
].map(x => x.toLowerCase());
let score = 0;
fields.forEach((f, i) => {
if (!f) return;
if (f.startsWith(ql)) score += 100 - i; // strong prefix match
else if (f.includes(ql)) score += 50 - i; // substring fallback
});
return score;
}
function quickSearchRender() {
const q = document.getElementById('quickSearchInput').value.trim();
const list = state.entries
.map(e => ({ e, s: quickSearchScoreEntry(e, q) }))
.filter(x => x.s > 0)
.sort((a, b) => b.s - a.s)
.slice(0, 8)
.map(x => x.e);
const box = document.getElementById('quickSearchResults');
box.innerHTML = '';
if (list.length === 0) {
box.appendChild(el('div', { class: 'quick-search-empty' },
q ? 'No match for "' + q + '"' : 'No entries'));
quickSearchSelected = 0;
return;
}
if (quickSearchSelected >= list.length) quickSearchSelected = 0;
if (quickSearchSelected < 0) quickSearchSelected = list.length - 1;
list.forEach((e, i) => {
const row = el('div', {
class: 'quick-search-row' + (i === quickSearchSelected ? ' is-selected' : ''),
'data-id': String(e.id),
});
// Avatar — favicon if cached, else initials.
const avatar = el('div', { class: 'quick-search-avatar' });
if (e.icon_b64) {
const img = el('img', { src: e.icon_b64, alt: '' });
img.addEventListener('error', () => {
avatar.innerHTML = '';
avatar.textContent = initials(entryDisplayName(e));
});
avatar.appendChild(img);
} else {
avatar.textContent = initials(entryDisplayName(e));
}
const main = el('div', { class: 'quick-search-main' });
main.appendChild(el('div', { class: 'quick-search-name' }, entryDisplayName(e)));
if (e.username)
main.appendChild(el('div', { class: 'quick-search-sub' }, e.username));
row.appendChild(avatar);
row.appendChild(main);
// Left click → full (user + Tab + password); Ctrl+click → password
// only (step-2 forms / unlock screens).
row.addEventListener('click', ev =>
quickSearchPickEntry(e, (ev.ctrlKey || ev.metaKey) ? 'pwd' : 'full'));
// Right click → username only. preventDefault + stopPropagation so
// the custom context menu (installCustomContextMenu) doesn't pop.
row.addEventListener('contextmenu', ev => {
ev.preventDefault();
ev.stopPropagation();
quickSearchPickEntry(e, 'user');
});
box.appendChild(row);
});
}
// mode: 'full' (user + Tab + password), 'user' (username only) or 'pwd'
// (password only). In fill mode each maps to a SendInput variant; in copy
// mode 'full' has no meaning so it falls back to copying the password.
async function quickSearchPickEntry(entry, mode) {
mode = mode || 'full';
// Fill mode (Ctrl+Shift+Q hotkey): SendInput directly into the HWND
// Delphi saved when the hotkey fired. No clipboard touch.
if (quickSearchFillMode) {
if (mode === 'user') {
const u = entry.username || '';
if (!u) { toast('No username on this entry', 'warning'); return; }
if (Bridge.active) Bridge.executeAutofill(u, '', quickSearchHideAfter, 'user');
toast(entryDisplayName(entry) + ' · username sent');
} else {
const pwd = await decryptPwd(entry.encrypted_password, entry.iv);
if (pwd === '[ERROR]') {
toast('Decryption error', 'error');
if (Bridge.active) Bridge.cancelAutofill();
return;
}
// 'full' → user + Tab + password (needs a username to make sense);
// 'pwd' (or 'full' on an entry without a username) → password only.
const u = (mode === 'full') ? (entry.username || '') : '';
// Single command — Delphi defers the SendInput by 60 ms then,
// if hide_after=1, MinimizeToTray's AFTER the keystrokes land.
// Hiding before SendInput would tip the Win10/11 anti-focus-
// stealing rules into refusing to hand focus to the target.
if (Bridge.active) Bridge.executeAutofill(u, pwd, quickSearchHideAfter);
toast(entryDisplayName(entry) +
(u ? ' · username + password sent' : ' · password sent'));
}
// Flags consumed — closeQuickSearchModal must not re-trigger.
quickSearchFillMode = false;
quickSearchHideAfter = false;
closeQuickSearchModal();
return;
}
// Copy mode (tray / palette): no target window, so we can only place a
// single value on the clipboard. 'user' copies the username, everything
// else copies the password.
if (mode === 'user') {
const u = entry.username || '';
if (!u) { toast('No username on this entry', 'warning'); return; }
if (Bridge.active) Bridge.copySecure(u, 30000);
else { try { await navigator.clipboard.writeText(u); } catch (_) {} }
toast('Username copied · clears in 30s');
} else {
const pwd = await decryptPwd(entry.encrypted_password, entry.iv);
if (pwd === '[ERROR]') { toast('Decryption error', 'error'); return; }
if (Bridge.active) Bridge.copySecure(pwd, 30000);
else { try { await navigator.clipboard.writeText(pwd); } catch (_) {} }
toast(entryDisplayName(entry) + ' · password copied');
}
// keepClipboard=true — we just copied, so minimising back to the tray
// must NOT clear the clipboard (the 30s auto-clear still applies).
closeQuickSearchModal(true);
}
// ============================================================
// CHEATSHEET — press '?' anywhere to see all hotkeys
// ============================================================
//
// Discovery aid. Built dynamically so adding a new hotkey only requires
// extending CHEATSHEET_GROUPS — the overlay picks it up automatically.
const CHEATSHEET_GROUPS = [
{
title: 'Inside the app',
items: [
{ keys: ['Ctrl', 'K'], desc: 'Command palette / quick search' },
{ keys: ['?'], desc: 'Show this cheatsheet' },
{ keys: ['Esc'], desc: 'Close modal / panel / cheatsheet' },
{ keys: ['Enter'], desc: 'Open / confirm / submit' },
],
},
{
title: 'Global (Windows-only, works even when minimised)',
items: [
{ keys: ['Ctrl', 'Shift', 'L'], desc: 'Autofill username + password into the active window' },
{ keys: ['Ctrl', 'Shift', 'P'], desc: 'Autofill password only (step-2 forms, unlock screens)' },
{ keys: ['Ctrl', 'Shift', 'Q'], desc: 'Quick search → SendInput password into the active window' },
{ keys: ['Ctrl', 'Shift', 'A'], desc: 'Quick-add a new entry pre-filled with the foreground window title' },
],
},
{
title: 'Tray',
items: [
{ keys: ['Right-click tray'], desc: 'Open / Quick search… / Lock vault / Quit' },
{ keys: ['Click tray'], desc: 'Restore window' },
],
},
{
title: 'On each card',
items: [
{ keys: [{ icon: 'i-globe' }], desc: 'Open the site in your default browser' },
{ keys: [{ icon: 'i-copy' }], desc: 'Copy password to the secure clipboard (auto-clears in 30s)' },
{ keys: ['Click card'], desc: 'Open the entry details / edit panel' },
],
},
];
function renderCheatsheet() {
const body = document.getElementById('cheatsheetBody');
body.innerHTML = '';
CHEATSHEET_GROUPS.forEach(group => {
const section = el('section', { class: 'cheatsheet-group' });
section.appendChild(el('h4', null, group.title));
const list = el('div', { class: 'cheatsheet-list' });
group.items.forEach(item => {
const row = el('div', { class: 'cheatsheet-row' });
const kc = el('div', { class: 'cheatsheet-keys' });
item.keys.forEach((k, i) => {
if (i > 0) kc.appendChild(el('span', { class: 'cheatsheet-plus' }, '+'));
if (k && typeof k === 'object' && k.icon) {
// SVG icon — wrap in kbd-shaped chip for visual consistency
// with the text key chips next to it.
const chip = el('span', { class: 'cheatsheet-icon-chip' });
chip.appendChild(icon(k.icon));
kc.appendChild(chip);
} else {
kc.appendChild(el('kbd', null, String(k)));
}
});
row.appendChild(kc);
row.appendChild(el('div', { class: 'cheatsheet-desc' }, item.desc));
list.appendChild(row);
});
section.appendChild(list);
body.appendChild(section);
});
}
// ============================================================
// PASSWORD HISTORY — open the modal, decrypt previous versions,
// optionally revert one into the current field.
// ============================================================
async function openHistoryModal(entryId) {
const modal = document.getElementById('historyModal');
const body = document.getElementById('historyBody');
body.innerHTML = '';
body.appendChild(el('div', { class: 'history-loading' }, 'Loading…'));
modal.classList.remove('is-hidden');
let rows;
try {
rows = await fetch(API + '/entries/' + entryId + '/history', {
headers: authHeaders(),
}).then(r => r.ok ? r.json() : []);
} catch (e) {
rows = [];
}
body.innerHTML = '';
if (!rows.length) {
body.appendChild(el('p', { class: 'history-empty' },
'No previous versions yet — they accumulate on each save.'));
return;
}
// Decrypt each row's stored ciphertext with the CURRENT vault key
// (master-pw change wipes the history, so the key always works).
const list = el('ul', { class: 'history-list' });
for (const row of rows) {
const li = el('li', { class: 'history-row' });
const meta = el('div', { class: 'history-meta' });
meta.appendChild(el('span', { class: 'history-date' },
formatDateShort(row.changed_at) + ' · ' + row.changed_at.slice(11, 16)));
let plain = '';
try {
plain = await decryptPwd(row.encrypted_password, row.iv);
} catch (_) { plain = '[ERROR]'; }
if (plain === '[ERROR]') plain = '';
const preview = el('div', { class: 'history-preview' });
const isNote = (row.kind === 'note');
const snippet = isNote
? (plain.replace(/\s+/g, ' ').slice(0, 80) +
(plain.length > 80 ? '…' : ''))
: '•'.repeat(Math.max(plain.length, 8));
const valueSpan = el('span', { class: 'history-value' }, snippet);
preview.appendChild(valueSpan);
let revealed = false;
if (!isNote) {
const eye = el('button', { class: 'icon-btn icon-btn-sm', type: 'button',
title: 'Show / hide' });
eye.appendChild(icon('i-eye'));
eye.addEventListener('click', () => {
revealed = !revealed;
valueSpan.textContent = revealed ? plain
: '•'.repeat(Math.max(plain.length, 8));
});
preview.appendChild(eye);
}
const copy = el('button', { class: 'icon-btn icon-btn-sm', type: 'button',
title: 'Copy' });
copy.appendChild(icon('i-copy'));
copy.addEventListener('click', () => {
if (Bridge.active) Bridge.copySecure(plain, 30000);
else { try { navigator.clipboard.writeText(plain); } catch (_) {} }
toast('Copied · clears in 30s');
});
preview.appendChild(copy);
const revert = el('button', { class: 'btn btn-ghost btn-xs', type: 'button' });
revert.appendChild(icon('i-rotate-ccw'));
revert.appendChild(document.createTextNode(' Revert'));
revert.addEventListener('click', () => {
const target = isNote
? document.getElementById('soNoteBody')
: document.getElementById('soPassword');
if (target) {
target.value = plain;
target.dispatchEvent(new Event('input', { bubbles: true }));
soDirtyCheck();
toast('Restored — click Save to commit', 'warning');
}
closeHistoryModal();
});
const actions = el('div', { class: 'history-actions' });
actions.appendChild(revert);
li.appendChild(meta);
li.appendChild(preview);
li.appendChild(actions);
list.appendChild(li);
}
body.appendChild(list);
}
function closeHistoryModal() {
document.getElementById('historyModal').classList.add('is-hidden');
}
function openCheatsheet() {
renderCheatsheet();
document.getElementById('cheatsheetModal').classList.remove('is-hidden');
}
function closeCheatsheet() {
document.getElementById('cheatsheetModal').classList.add('is-hidden');
}
function openQuickSearchModal(hideAfter, forFill) {
const modal = document.getElementById('quickSearchModal');
const input = document.getElementById('quickSearchInput');
modal.classList.remove('is-hidden');
input.value = '';
quickSearchSelected = 0;
quickSearchHideAfter = !!hideAfter;
quickSearchFillMode = !!forFill;
// Subtle hint to the user about what Enter will do.
const hintEl = modal.querySelector('.quick-search-hint');
if (hintEl) {
hintEl.textContent = forFill
? 'Enter = fill user+password · Shift+Enter = username · Ctrl+Enter = password · Esc = cancel'
: 'Enter / click = copy password · Shift+Enter / right-click = copy username · Esc = close';
}
quickSearchRender();
setTimeout(() => input.focus(), 50);
}
function closeQuickSearchModal(keepClipboard) {
document.getElementById('quickSearchModal').classList.add('is-hidden');
// Fill-mode cancel: tell Delphi to drop the saved HWND so the next
// /execute (e.g. an unrelated Ctrl+Shift+L) doesn't accidentally
// target the stale window.
if (quickSearchFillMode) {
if (Bridge.active && typeof Bridge.cancelAutofill === 'function')
Bridge.cancelAutofill();
quickSearchFillMode = false;
}
// If the modal was opened from the tray (window was hidden), restore
// the previous "in tray" state so the user can paste straight into
// the target app. Cancel (Esc / close X) also triggers this — they
// came from the tray, they should go back to the tray. keepClipboard
// is set by the copy path so minimising doesn't wipe the password we
// just placed on the clipboard.
if (quickSearchHideAfter) {
quickSearchHideAfter = false;
if (Bridge.active && typeof Bridge.minimizeToTray === 'function')
Bridge.minimizeToTray(!!keepClipboard);
}
}
// Generate a cryptographically random RFC 4648 base32 secret. 20 bytes =
// 160 bits → 32 base32 chars, RFC 6238 §5.1 recommended TOTP key size.
function randomBase32Secret(numBytes) {
numBytes = numBytes || 20;
const ALPH = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const bytes = crypto.getRandomValues(new Uint8Array(numBytes));
let bits = 0, buffer = 0, out = '';
for (let i = 0; i < bytes.length; i++) {
buffer = (buffer << 8) | bytes[i];
bits += 8;
while (bits >= 5) {
bits -= 5;
out += ALPH[(buffer >> bits) & 0x1F];
}
}
if (bits > 0) out += ALPH[(buffer << (5 - bits)) & 0x1F];
return out;
}
// ---- Standalone TOTP generator modal (paste secret → live code) -----
let totpToolTimer = null;
function openTotpTool() {
const modal = document.getElementById('totpToolModal');
const input = document.getElementById('totpToolSecret');
const codeEl = document.getElementById('totpToolCode');
const barEl = document.getElementById('totpToolBar');
const errEl = document.getElementById('totpToolError');
modal.classList.remove('is-hidden');
input.value = '';
codeEl.textContent = '— — — — — —';
barEl.style.width = '100%';
errEl.style.display = 'none';
setTimeout(() => input.focus(), 0);
async function tick() {
let secret = input.value.trim();
if (!secret) {
codeEl.textContent = '— — — — — —';
barEl.style.width = '100%';
errEl.style.display = 'none';
return;
}
if (secret.toLowerCase().startsWith('otpauth://')) {
const fromUri = parseOtpAuthUri(secret);
if (fromUri) { secret = fromUri; input.value = fromUri; }
}
try {
const t = await generateTOTP(secret);
codeEl.textContent = t.code.replace(/(\d{3})(\d{3})/, '$1 $2');
const newPct = (t.secondsLeft / 30) * 100;
if (newPct > (tick._lastPct || 0) + 5) {
barEl.style.transition = 'none';
barEl.style.width = newPct.toFixed(1) + '%';
void barEl.offsetWidth;
barEl.style.transition = '';
} else {
barEl.style.width = newPct.toFixed(1) + '%';
}
tick._lastPct = newPct;
errEl.style.display = 'none';
} catch (e) {
codeEl.textContent = '— — — — — —';
barEl.style.width = '0%';
errEl.textContent = 'Invalid base32 secret';
errEl.style.display = '';
}
}
input.addEventListener('input', tick);
if (totpToolTimer) clearInterval(totpToolTimer);
totpToolTimer = setInterval(tick, 1000);
document.getElementById('totpToolCopy').onclick = async () => {
const code = codeEl.textContent.replace(/\s/g, '');
if (!/^\d{6}$/.test(code)) return;
if (Bridge.active) Bridge.copySecure(code, 30000);
else { try { await navigator.clipboard.writeText(code); } catch (e) {} }
toast('TOTP code copied');
};
document.getElementById('totpToolGen').onclick = () => {
input.value = randomBase32Secret(20);
tick();
toast('Random secret generated');
};
document.getElementById('totpToolCopySecret').onclick = async () => {
const s = input.value.trim();
if (!s) return;
if (Bridge.active) Bridge.copySecure(s, 30000);
else { try { await navigator.clipboard.writeText(s); } catch (e) {} }
toast('Secret copied');
};
modal.querySelectorAll('[data-close]').forEach(b => {
b.onclick = () => closeTotpTool();
});
// Esc dismisses. Capture phase so it beats other listeners (e.g.
// the keyboard cursor handler that also intercepts Escape).
const escHandler = (ev) => {
if (ev.key !== 'Escape') return;
if (modal.classList.contains('is-hidden')) {
document.removeEventListener('keydown', escHandler, true);
return;
}
ev.stopPropagation();
closeTotpTool();
document.removeEventListener('keydown', escHandler, true);
};
document.addEventListener('keydown', escHandler, true);
}
function closeTotpTool() {
document.getElementById('totpToolModal').classList.add('is-hidden');
if (totpToolTimer) { clearInterval(totpToolTimer); totpToolTimer = null; }
}
// ============================================================
// HIBP — Have I Been Pwned breach check (k-anonymity)
// ============================================================
//
// HIBP's range API exposes pwned password counts without ever seeing the
// password (or even its full hash):
// 1. Client computes SHA-1 of the password.
// 2. Client sends ONLY the first 5 hex chars to api.pwnedpasswords.com/range/XXXXX
// 3. Server returns up to ~500 suffixes (35 chars each) with counts.
// 4. Client searches the response for its own suffix locally.
//
// This means the network observer (and HIBP itself) sees only the 5-char
// prefix — which matches ~3,000 of the ~half-billion known pwned passwords.
// Information leakage is bounded by design.
//
// Toggle is OFF by default. When enabled, all entries are checked once
// after vault load, then individual entries are re-checked when the user
// edits the password. Results cached in state.hibpResults keyed by entry id.
async function sha1Hex(text) {
const buf = new TextEncoder().encode(text);
const hashBuf = await crypto.subtle.digest('SHA-1', buf);
const bytes = new Uint8Array(hashBuf);
let hex = '';
for (const b of bytes) hex += b.toString(16).padStart(2, '0');
return hex.toUpperCase();
}
// Returns the breach count (0 if not found, >0 if pwned). Throws on
// network failure — caller decides whether to silently skip or alert.
async function hibpCheckPassword(plaintext) {
if (!plaintext) return 0;
const hash = await sha1Hex(plaintext);
const prefix = hash.substring(0, 5);
const suffix = hash.substring(5);
const resp = await fetch('https://api.pwnedpasswords.com/range/' + prefix, {
// Padding mitigates side-channel attacks where an observer counts
// bytes in the response to narrow down the prefix queried.
headers: { 'Add-Padding': 'true' },
});
if (!resp.ok) throw new Error('HIBP HTTP ' + resp.status);
const body = await resp.text();
// Body lines: "SUFFIX:COUNT\r\n" — search for our suffix.
for (const line of body.split('\n')) {
const colonAt = line.indexOf(':');
if (colonAt <= 0) continue;
if (line.substring(0, colonAt).trim() === suffix) {
return parseInt(line.substring(colonAt + 1).trim(), 10) || 0;
}
}
return 0;
}
// Batch-check every entry currently in state.entries. Awaits all in
// parallel but with a small concurrency cap so we don't hammer HIBP
// or trip browser connection limits. Mutates state.hibpResults and
// re-renders to show the new badges.
async function hibpCheckAllEntries() {
if (!state.hibpEnabled || !state.entries.length) return;
const CONCURRENCY = 6;
const queue = state.entries.slice();
const workers = [];
for (let w = 0; w < CONCURRENCY; w++) {
workers.push((async () => {
while (queue.length) {
const entry = queue.shift();
try {
const pwd = await decryptPwd(entry.encrypted_password, entry.iv);
if (pwd === '[ERROR]') continue;
const count = await hibpCheckPassword(pwd);
state.hibpResults.set(entry.id, count);
} catch (e) {
// Network or decrypt failure: skip silently. Will retry
// next time the user opens the vault.
}
}
})());
}
await Promise.all(workers);
render();
}
// ============================================================
// KDF MIGRATION (PBKDF2 100k → 600k re-encryption)
// ============================================================
//
// When the server signals kdfMigration in /login or /reauth, we transparently
// re-encrypt every entry with a stronger key (600k PBKDF2 iterations) and
// commit the new ciphertext + the new server-side hash in one atomic
// /migrate-kdf request. If anything fails, the user stays on the legacy
// config and the migration retries at next login. The entries currently
// loaded in state.entries are encrypted with the OLD key (state.cryptoKey).
//
// Threading: runs in the background after enterApp completes. Locking the
// vault during migration is safe — we just lose the in-flight transition
// and the server's atomic rollback means nothing persisted.
let kdfMigrationInProgress = false;
async function runKdfMigration(masterPwd, fromIters, toIters) {
if (kdfMigrationInProgress) return; // dedupe concurrent calls
if (!state.entries || !state.cryptoKey) return;
kdfMigrationInProgress = true;
try {
// Two distinct migration scenarios:
// A. fromIters !== toIters: KDF iteration count is changing, so
// the AES key is changing. We re-encrypt every entry with the
// new key + fresh IVs, swap state.cryptoKey at the end.
// B. fromIters === toIters: same KDF, only the server-side hash
// format is being upgraded (legacy "pbkdf2" raw → "pbkdf2-sha256"
// wrapped). No entry re-encryption needed — just trigger the
// endpoint so the server rewrites the user row.
const kdfChange = fromIters !== toIters;
let newKey, newCiphertexts;
if (kdfChange) {
newKey = await deriveKey(masterPwd, state.salt, toIters);
// Re-encrypt every entry. Each entry gets a fresh random IV
// under the new key — never reuse the old IV with the new key
// (would be pointless but also a small information leak via IV
// reuse patterns).
newCiphertexts = [];
for (const entry of state.entries) {
const plain = await decryptPwd(entry.encrypted_password, entry.iv);
if (plain === '[ERROR]') {
// One decrypt failure aborts the whole migration —
// better to stay on the legacy config than commit
// partial state.
throw new Error('Could not decrypt entry id=' + entry.id);
}
const tmpKey = state.cryptoKey;
try {
state.cryptoKey = newKey;
const re = await encryptPwd(plain);
newCiphertexts.push({
id: entry.id,
encrypted_password: re.encrypted,
iv: re.iv,
});
} finally {
state.cryptoKey = tmpKey; // restore for any concurrent read
}
}
} else {
// Hash-format-only upgrade — server still wants an entries
// array (it's an idempotent transactional update), just empty.
newCiphertexts = [];
}
// Zero-knowledge: compute BOTH verifiers locally. oldVerifier proves
// the user knows the master pw under the current (legacy) iters;
// newVerifier is what the server will SHA-256-wrap to be the new
// stored hash after migration. Master pw never leaves the browser.
// Only non-v2 accounts ever reach the KDF migration (v2 accounts are
// 600k + decoupled → never signalled). Under a non-v2 algo the
// verifier is the key hex, so both derivations round-trip exactly as
// before; passing state.hashAlgo keeps it explicit.
const oldVerifier = await computeVerifier(masterPwd, state.salt, fromIters, state.hashAlgo, state.argon2Params);
const newVerifier = await computeVerifier(masterPwd, state.salt, toIters, state.hashAlgo, state.argon2Params);
await api('/migrate-kdf', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
oldVerifier: oldVerifier,
newVerifier: newVerifier,
entries: newCiphertexts,
}),
});
if (kdfChange) {
// Swap to the new AES key + update cached ciphertexts.
state.cryptoKey = newKey;
state.kdfIterations = toIters;
sessionStorage.setItem('kdfIterations', String(toIters));
await persistCryptoKey();
for (let i = 0; i < state.entries.length; i++) {
const nc = newCiphertexts[i];
state.entries[i].encrypted_password = nc.encrypted_password;
state.entries[i].iv = nc.iv;
}
toast('Vault security upgraded (' + fromIters.toLocaleString() +
' → ' + toIters.toLocaleString() + ' KDF iterations)');
} else {
// Format-only upgrade is silent — the user didn't perceive a
// weakness change, and nothing visible in the UI changed.
// (A subtle "Auth format upgraded" toast felt noisy.)
}
} catch (err) {
// Silent retry on next login — the migration is idempotent and
// safe to abandon (server rolled back).
console.warn('KDF migration aborted:', err);
} finally {
kdfMigrationInProgress = false;
}
}
// ============================================================
// ACCOUNT LOCKOUT UI
// ============================================================
let lockoutTimer = null;
// Called when the backend responds with 429 + retry_after on /login or
// /reauth. Disables the auth form and displays a live countdown in
// #authHint. When the countdown reaches 0, the form is re-enabled.
function showLockoutCountdown(seconds) {
if (lockoutTimer) { clearInterval(lockoutTimer); lockoutTimer = null; }
const hint = $('#authHint');
const btn = $('#loginBtn');
const fmt = (s) => {
if (s >= 3600) return Math.ceil(s / 3600) + ' h';
if (s >= 60) return Math.ceil(s / 60) + ' min';
return s + ' s';
};
const tick = () => {
if (seconds <= 0) {
clearInterval(lockoutTimer); lockoutTimer = null;
if (hint) hint.textContent = 'You can try again now.';
if (btn) btn.disabled = false;
return;
}
if (hint) hint.textContent = 'Account locked — try again in ' + fmt(seconds);
seconds--;
};
if (btn) btn.disabled = true;
tick(); // show first frame immediately
lockoutTimer = setInterval(tick, 1000);
}
// ============================================================
// TOAST
// ============================================================
function toast(msg, type) {
type = type || 'success';
const container = $('#toastContainer');
const t = document.createElement('div');
t.className = 'toast is-' + type;
t.textContent = msg;
container.appendChild(t);
setTimeout(() => t.remove(), 2800);
}
// ============================================================
// DOM HELPERS
// ============================================================
function $(sel, root) { return (root || document).querySelector(sel); }
function $$(sel, root) { return Array.from((root || document).querySelectorAll(sel)); }
function el(tag, props, ...kids) {
const e = document.createElement(tag);
if (props) for (const k in props) {
if (k === 'class') e.className = props[k];
else if (k === 'on') for (const ev in props.on) e.addEventListener(ev, props.on[ev]);
else if (k === 'html') e.innerHTML = props[k];
else if (k in e) e[k] = props[k];
else e.setAttribute(k, props[k]);
}
for (const k of kids) {
if (k == null) continue;
e.appendChild(typeof k === 'string' ? document.createTextNode(k) : k);
}
return e;
}
function icon(id) {
const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
const u = document.createElementNS('http://www.w3.org/2000/svg', 'use');
u.setAttribute('href', '#' + id);
s.appendChild(u);
return s;
}
// ============================================================
// AUTH
// ============================================================
async function doLogin(e) {
e && e.preventDefault();
const u = $('#loginUsername').value.trim();
const p = $('#loginPassword').value;
const pinInput = ($('#loginPin') || {}).value || '';
const havePin = Bridge.active && state.pinConfigured;
const mode = havePin ? (state.unlockMode || 'pw') : 'pw';
// ---- PIN-only mode -------------------------------------------------
if (mode === 'pin') {
if (!pinInput) return;
$('#loginBtn').disabled = true;
const ok = await loginViaPin(pinInput);
$('#loginBtn').disabled = false;
$('#loginPin').value = '';
if (ok) { await enterApp(); return; }
const fresh = await bridgePinStatus();
state.pinConfigured = fresh;
applyAuthScreenMode();
$('#authHint').textContent = fresh
? 'Wrong PIN. Try again or "Use master password".'
: 'Too many wrong PIN attempts. Sign in with your master password.';
return;
}
// ---- 'both' mode: master pw first, then PIN verification ----------
if (mode === 'both') {
if (!u || !p || !pinInput) return;
// Capture the PIN BEFORE the master-pw path clears the form, so
// a verification error doesn't lose what the user just typed.
const pendingPin = pinInput;
// Fall through to the master-pw login below (return after we
// tag a post-login PIN check). The hook is in enterApp via
// window._pinAfterMaster.
window._pinAfterMaster = pendingPin;
}
if (!u || !p) return;
// If we are in locked mode (token still valid), try fast unlock first.
if (state.locked && state.token && state.salt && u === state.username) {
$('#loginBtn').disabled = true;
const ok = await doUnlock(p);
$('#loginBtn').disabled = false;
if (ok) return;
// unlock failed — fall through to a full login
}
$('#loginBtn').disabled = true;
try {
// Zero-knowledge: ask the server for the user's salt + iter count,
// compute the verifier locally, send only the verifier. Master pw
// never leaves the browser.
const ch = await api('/login/challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: u }),
});
// The challenge tells us the account's auth scheme + KDF params;
// derive the key/verifier accordingly (argon2id-v2 → Argon2 with the
// echoed params; -v2 → decoupled; else → key hex).
state.hashAlgo = ch.hashAlgo || '';
state.argon2Params = ch.argon2 || null;
const derived = await deriveKeyAndVerifier(
p, ch.salt, ch.kdfIterations, state.hashAlgo, state.argon2Params);
const r = await api('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: u, verifier: derived.verifier }),
});
state.token = r.token;
state.csrf = r.csrfToken;
state.salt = r.salt;
state.username = u;
state.kdfIterations = r.kdfIterations || ch.kdfIterations;
sessionStorage.setItem('authToken', state.token);
sessionStorage.setItem('csrfToken', state.csrf);
sessionStorage.setItem('salt', state.salt);
sessionStorage.setItem('username', state.username);
sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
sessionStorage.setItem('hashAlgo', state.hashAlgo);
sessionStorage.setItem('argon2Params', JSON.stringify(state.argon2Params));
// Persist via DPAPI when running inside the Delphi host (localStorage
// is wiped on each restart because the HTTP port — and therefore the
// origin — changes every launch). Fall back to localStorage for the
// web/PHP frontend where the origin is stable.
const remember = $('#loginRememberUser').checked;
if (Bridge.active) {
Bridge.setPref('rememberedUsername', remember ? u : '');
} else {
if (remember) localStorage.setItem('rememberedUsername', u);
else localStorage.removeItem('rememberedUsername');
}
// cryptoKey is already derived — no second PBKDF2 pass.
state.cryptoKey = derived.cryptoKey;
state.justRecovered = false;
await persistCryptoKey();
toast('Welcome back, ' + u);
await enterApp();
// Trigger KDF migration AFTER entries are loaded into state.
if (r.kdfMigration && r.kdfMigration.target) {
runKdfMigration(p, r.kdfIterations, r.kdfMigration.target);
}
} catch (err) {
// 429 with retry_after = account lockout. Show countdown in the
// auth hint instead of a generic error toast, and keep the login
// button disabled until the lockout expires.
if (err.status === 429 && err.body && err.body.retry_after) {
showLockoutCountdown(err.body.retry_after);
return; // do NOT re-enable the button in finally
}
// Mask "Unknown user" from /login/challenge as a generic credentials
// failure — keeps user-existence enumeration consistent with the
// existing /login behavior.
if (err.status === 404) {
toast('Invalid credentials', 'error');
} else {
toast(err.message, 'error');
}
} finally {
// Only re-enable when not in lockout (showLockoutCountdown manages
// the button itself for the lockout case).
if (!lockoutTimer) $('#loginBtn').disabled = false;
}
}
async function doRegister(e) {
e && e.preventDefault();
const u = $('#regUsername').value.trim();
const p = $('#regPassword').value;
if (u.length < 3 || p.length < 8) return toast('Min 3 / 8 chars', 'error');
$('#registerBtn').disabled = true;
try {
// Zero-knowledge register: client generates salt + iters, computes
// the verifier locally, sends only the verifier. Master pw never
// leaves the browser.
const newSalt = randomHexSalt();
const newIters = 600000;
// New accounts use Argon2id (memory-hard) with the decoupled verifier.
state.hashAlgo = HASH_ALGO_ARGON2;
state.argon2Params = { ...ARGON2_DEFAULT_PARAMS };
const derived = await deriveKeyAndVerifier(
p, newSalt, newIters, HASH_ALGO_ARGON2, state.argon2Params);
const r = await api('/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: u,
salt: newSalt,
kdfIterations: newIters,
verifier: derived.verifier,
hashAlgo: HASH_ALGO_ARGON2,
argon2: state.argon2Params,
}),
});
state.token = r.token;
state.csrf = r.csrfToken;
state.salt = r.salt || newSalt;
state.username = u;
state.kdfIterations = r.kdfIterations || newIters;
sessionStorage.setItem('authToken', state.token);
sessionStorage.setItem('csrfToken', state.csrf);
sessionStorage.setItem('salt', state.salt);
sessionStorage.setItem('username', state.username);
sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
sessionStorage.setItem('hashAlgo', state.hashAlgo);
sessionStorage.setItem('argon2Params', JSON.stringify(state.argon2Params));
state.cryptoKey = derived.cryptoKey;
await persistCryptoKey();
toast('Vault created');
await enterApp();
} catch (err) {
toast(err.message, 'error');
} finally {
$('#registerBtn').disabled = false;
}
}
async function doLogout() {
try { await api('/logout', { method: 'POST', headers: authHeaders() }); } catch (e) {}
// Explicit logout → wipe the DPAPI quick-unlock blob too. Logout is the
// user saying "I'm done", quite different from a passive lock.
if (Bridge.active && localStorage.getItem('quickUnlockEnabled') === '1') {
window.location.href = 'cmd://quickunlock/clear';
localStorage.removeItem('quickUnlockEnabled');
state.quickUnlockEnabled = false;
}
sessionStorage.clear();
state.token = ''; state.csrf = ''; state.salt = ''; state.username = '';
state.kdfIterations = 0;
state.cryptoKey = null; state.entries = []; state.trashed = [];
state.folders = [{ name: 'All', color: '', icon: '' }];
state.locked = false;
showAuth();
$('#loginUsername').value = '';
$('#loginPassword').value = '';
$('#loginUsername').readOnly = false;
$('#authHint').textContent = '';
}
// Lock: do NOT hit /logout — keep server session alive, just drop the in-memory
// crypto key. On unlock, /reauth validates the master password and we re-derive.
// Confirms with the user when there are unsaved edits BEFORE clearing
// the session. Returns true if the lock/logout should proceed, false
// if the user cancelled.
async function confirmDiscardForSessionExit(action) {
if (!$('#slideover').classList.contains('is-open')) return true;
if (!state.confirmOnUnsaved) return true;
if (!isSoDirty()) return true;
return await confirmDialog({
title: action === 'logout' ? 'Log out without saving?' : 'Lock without saving?',
message: 'You have edits in the open entry that haven\'t been saved. ' +
(action === 'logout' ? 'Logging out' : 'Locking the vault') +
' will discard them.' +
'<p style="margin:14px 0 0;font-size:11px;color:var(--text-faint);line-height:1.5">' +
'Tip: turn this prompt off in <b>Settings → Appearance → Confirm before closing unsaved edits</b>.' +
'</p>',
okText: action === 'logout' ? 'Log out' : 'Lock',
danger: true,
});
}
function lockVault() {
sessionStorage.removeItem('cryptoKey');
state.cryptoKey = null;
state.entries = [];
state.trashed = [];
state.locked = true;
state.justRecovered = false;
state.avatarDataUri = ''; // reloaded from server on next unlock
if (typeof authTickTimer !== 'undefined' && authTickTimer) {
clearInterval(authTickTimer); authTickTimer = null;
}
if (typeof totpToolTimer !== 'undefined' && totpToolTimer) {
clearInterval(totpToolTimer); totpToolTimer = null;
}
if (typeof healthCache !== 'undefined') healthCache = null;
if (typeof auditCache !== 'undefined') auditCache = null;
auditFilter = '';
state.activeFilters.clear();
// Force-close any open editor / Settings panel BEFORE switching to
// the auth screen. Otherwise their .is-open class survives the lock
// and (a) the centered-modal backdrop keeps blurring the auth screen
// (b) the click-outside handler later prompts "discard changes?" for
// the slideover the user can no longer interact with.
soState = null;
const so = $('#slideover');
if (so) so.classList.remove('is-open');
const sp = $('#settingsPanel');
if (sp) sp.classList.remove('is-open');
showAuth();
// Two UI variants for the auth screen:
// - We know the username (user was logged in before lock) →
// pre-fill it as readonly so the user only types the master pw.
// - We don't know the username (lock fired before any login — e.g.
// tray "Lock vault" clicked on a fresh session, or Win+L right
// after launch) → show a normal fresh login (editable username).
if (state.username) {
$('#loginUsername').value = state.username;
$('#loginUsername').readOnly = true;
$('#authHint').textContent = 'Vault locked — enter master password to unlock';
$('#loginPassword').focus();
} else {
$('#loginUsername').value = '';
$('#loginUsername').readOnly = false;
$('#authHint').textContent = '';
$('#loginUsername').focus();
}
$('#loginPassword').value = '';
}
// Unlock flow: validate master pw via /reauth (which uses current session),
// then re-derive the crypto key locally without rotating session/csrf.
async function doUnlock(p) {
try {
// Compute the verifier locally with the salt+iters cached at login.
// Server compares verifier → never sees the plaintext master pw.
const iters = state.kdfIterations || 100000;
const derived = await deriveKeyAndVerifier(
p, state.salt, iters, state.hashAlgo, state.argon2Params);
const r = await api('/reauth', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ verifier: derived.verifier }),
});
// Refresh cached iter count in case the server has migrated us.
state.kdfIterations = r.kdfIterations || iters;
sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
state.cryptoKey = derived.cryptoKey;
await persistCryptoKey();
state.locked = false;
$('#loginUsername').readOnly = false;
$('#authHint').textContent = '';
toast('Unlocked');
await enterApp();
if (r.kdfMigration && r.kdfMigration.target) {
runKdfMigration(p, r.kdfIterations, r.kdfMigration.target);
}
return true;
} catch (err) {
// Account lockout (too many wrong master pw attempts): show
// countdown in the auth hint, keep the form disabled.
if (err.status === 429 && err.body && err.body.retry_after) {
showLockoutCountdown(err.body.retry_after);
return false;
}
if (err.message === 'Invalid password') {
toast('Wrong master password', 'error');
} else {
// session expired — fall back to full login
sessionStorage.clear();
state.token = ''; state.csrf = ''; state.salt = '';
state.locked = false;
$('#loginUsername').readOnly = false;
$('#authHint').textContent = 'Session expired, please sign in again';
toast('Session expired', 'warning');
}
return false;
}
}
// ============================================================
// DATA LOADING
// ============================================================
async function loadFolders() {
try {
const r = await api('/folders', { headers: authHeaders() });
// 'All' is always implicit first. The server returns object rows;
// tolerate legacy string-only responses too (older deployments).
const seen = (r || []).map(x => typeof x === 'string'
? { name: x, color: '', icon: '' }
: { name: x.name, color: x.color || '', icon: x.icon || '' });
state.folders = [{ name: 'All', color: '', icon: '' }]
.concat(seen.filter(f => f.name !== 'All'));
} catch (e) { /* ignore */ }
}
// Lookup helpers — UI code refers to folders by name everywhere, so we
// keep the name-keyed map for O(1) meta access without changing call sites.
function folderByName(name) {
return state.folders.find(f => f.name === name);
}
function folderMetaFor(name) {
const f = folderByName(name);
return { color: (f && f.color) || '', icon: (f && f.icon) || '' };
}
async function loadEntries() {
try {
const r = await api('/entries', { headers: authHeaders() });
state.entries = Array.isArray(r) ? r : [];
} catch (e) {
if (e.message === 'Invalid session' || e.message === 'Session expired') {
return doLogout();
}
toast(e.message, 'error');
}
}
async function loadTrash() {
try {
const r = await api('/entries?deleted=1', { headers: authHeaders() });
state.trashed = Array.isArray(r) ? r : [];
state.trashedCount = state.trashed.length;
} catch (e) { state.trashed = []; }
}
async function loadEntryCounts() {
try {
const r = await api('/entries/count', { headers: authHeaders() });
if (r && typeof r.trashed === 'number') state.trashedCount = r.trashed;
} catch (e) { /* silent — sidebar count just stays 0 */ }
}
// ============================================================
// FILTERS / DERIVED
// ============================================================
// Advanced filter definitions. Each filter has a label (shown in dropdown
// and chip), an icon, and a predicate. Predicates only inspect metadata
// the server already returned — no decryption — so they run cheaply on
// every render. "weak" / "pwned" / "old" read from already-computed caches
// (HIBP / vault-health) so they're consistent with those views.
const FILTER_DEFS = {
'kind-note': {
label: 'Notes only',
icon: 'i-edit',
predicate: e => e.kind === 'note',
},
'kind-login': {
label: 'Logins only',
icon: 'i-key',
predicate: e => (e.kind || 'login') === 'login',
},
'has-totp': {
label: 'Has 2FA / TOTP',
icon: 'i-shield',
predicate: e => !!(e.totp_secret && e.totp_iv),
},
'no-totp-login': {
label: 'Logins without 2FA',
icon: 'i-alert',
predicate: e => (e.kind || 'login') === 'login' && !(e.totp_secret && e.totp_iv),
},
'has-icon': {
label: 'Has favicon',
icon: 'i-globe',
predicate: e => !!e.icon_b64,
},
'has-custom-fields': {
label: 'Has custom fields',
icon: 'i-list',
predicate: e => !!(e.custom_fields && e.custom_fields_iv),
},
'pwned': {
label: 'Pwned (HIBP)',
icon: 'i-alert',
predicate: e => {
const n = state.hibpResults.get(e.id);
return typeof n === 'number' && n > 0;
},
},
'favorite': {
label: 'Favorites',
icon: 'i-star',
predicate: e => !!e.favorite,
},
'aged': {
label: 'Aged password',
icon: 'i-alert',
predicate: e => isPasswordAged(e),
},
};
function renderFiltersMenu() {
const menu = document.getElementById('filtersMenu');
if (!menu) return;
menu.innerHTML = '';
menu.appendChild(el('div', { class: 'filters-menu-title' }, 'Filters'));
Object.entries(FILTER_DEFS).forEach(([key, def]) => {
const row = el('label', { class: 'filters-menu-row' });
const cb = el('input', { type: 'checkbox', class: 'filters-menu-cb' });
cb.checked = state.activeFilters.has(key);
cb.addEventListener('change', ev => {
ev.stopPropagation();
if (cb.checked) state.activeFilters.add(key);
else state.activeFilters.delete(key);
state.currentPage = 1;
renderFiltersBadge();
renderFilterChips();
render();
});
row.appendChild(cb);
row.appendChild(icon(def.icon));
row.appendChild(el('span', null, def.label));
menu.appendChild(row);
});
if (state.activeFilters.size > 0) {
const clear = el('button', {
class: 'filters-menu-clear', type: 'button',
}, 'Clear all filters');
clear.addEventListener('click', ev => {
ev.stopPropagation();
state.activeFilters.clear();
state.currentPage = 1;
renderFiltersBadge();
renderFilterChips();
renderFiltersMenu();
render();
});
menu.appendChild(clear);
}
}
function renderFiltersBadge() {
const badge = document.getElementById('filtersCount');
if (!badge) return;
const n = state.activeFilters.size;
badge.textContent = String(n);
badge.style.display = n > 0 ? '' : 'none';
}
function renderFilterChips() {
const bar = document.getElementById('filterChips');
if (!bar) return;
bar.innerHTML = '';
if (state.activeFilters.size === 0) {
bar.classList.add('is-hidden');
return;
}
bar.classList.remove('is-hidden');
state.activeFilters.forEach(key => {
const def = FILTER_DEFS[key];
if (!def) return;
const chip = el('span', { class: 'filter-chip' });
chip.appendChild(icon(def.icon));
chip.appendChild(el('span', null, def.label));
const x = el('button', { type: 'button', class: 'filter-chip-x', title: 'Remove' });
x.appendChild(icon('i-x'));
x.addEventListener('click', ev => {
ev.stopPropagation();
state.activeFilters.delete(key);
state.currentPage = 1;
renderFiltersBadge();
renderFilterChips();
renderFiltersMenu();
render();
});
chip.appendChild(x);
bar.appendChild(chip);
});
const clear = el('button', { type: 'button', class: 'filter-chips-clear' },
'Clear all');
clear.addEventListener('click', ev => {
ev.stopPropagation();
state.activeFilters.clear();
state.currentPage = 1;
renderFiltersBadge();
renderFilterChips();
renderFiltersMenu();
render();
});
bar.appendChild(clear);
}
function filteredEntries() {
// Trash view shows its own list (loaded separately)
let list;
if (state.view === 'trash') {
list = state.trashed;
} else {
list = state.entries;
if (state.view === 'favorites') list = list.filter(e => e.favorite);
else if (state.view === 'notes') list = list.filter(e => e.kind === 'note');
else if (state.view === 'recent') {
// Top 10 actually-used entries, freshest first. accessed_at NULL
// (never touched) sinks to the bottom and is filtered out so the
// view only ever shows things the user reached for.
list = list.filter(e => !!e.accessed_at)
.sort((a, b) => (b.accessed_at || '').localeCompare(a.accessed_at || ''))
.slice(0, 10);
}
else if (state.view.startsWith('folder:')) {
const f = state.view.slice(7);
// 'folder:All' is now the "(no folder)" pseudo-entry → filter
// to entries that are uncategorized (folder is 'All', empty,
// or absent). All other folder names are exact-matched.
list = list.filter(e => (e.folder || 'All') === f);
} else if (state.view.startsWith('tag:')) {
const t = state.view.slice(4);
list = list.filter(e => parseTags(e.tags).includes(t));
}
}
if (state.search) {
const q = state.search.toLowerCase();
list = list.filter(e =>
(e.site || '').toLowerCase().includes(q) ||
(e.title || '').toLowerCase().includes(q) ||
(e.username || '').toLowerCase().includes(q) ||
(e.tags || '').toLowerCase().includes(q)
);
}
// Advanced filters (AND). Each active filter key maps to a predicate;
// the entry must satisfy ALL of them.
if (state.activeFilters && state.activeFilters.size > 0) {
for (const key of state.activeFilters) {
const def = FILTER_DEFS[key];
if (def && typeof def.predicate === 'function')
list = list.filter(def.predicate);
}
}
// Trash view keeps the deletion order (newest first) — re-sorting feels
// wrong for a recoverable archive. Recent view already sorted by
// accessed_at; re-applying the user sort would defeat the purpose.
if (state.view !== 'trash' && state.view !== 'recent') {
list = sortEntries(list, state.sortBy, state.sortDir);
}
return list;
}
// Deterministic avatar colour: hash the username to a hue so the same
// account always gets the same background (no flicker across renders).
// Uses a fixed palette of pleasant saturated colours rather than raw
// HSL so every avatar reads well on the dark chrome.
const AVATAR_COLORS = [
'#e05a5a', '#e0895a', '#e0b45a', '#8bc34a', '#4caf82',
'#4aa3c3', '#5a7be0', '#7b5ae0', '#b45ae0', '#e05a9e',
];
function avatarColorFor(name) {
const s = String(name || '?');
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
return AVATAR_COLORS[Math.abs(h) % AVATAR_COLORS.length];
}
// Paint the top-right user avatar: custom picture if one is set (data
// URI in state.avatarDataUri), otherwise the username's first letter on
// a deterministic colour.
function renderUserAvatar() {
const el = $('#userAvatar');
if (!el) return;
const pic = state.avatarDataUri || '';
if (pic) {
el.style.backgroundImage = 'url("' + pic + '")';
el.style.backgroundColor = 'transparent';
el.textContent = '';
} else {
el.style.backgroundImage = 'none';
el.style.backgroundColor = avatarColorFor(state.username);
el.textContent = (state.username || '?').trim().charAt(0) || '?';
}
// Keep the Settings preview (if the panel is open) in sync too.
const prev = $('#settingAvatarPreview');
if (prev) {
const pic2 = state.avatarDataUri || '';
if (pic2) {
prev.style.backgroundImage = 'url("' + pic2 + '")';
prev.style.backgroundColor = 'transparent';
prev.textContent = '';
} else {
prev.style.backgroundImage = 'none';
prev.style.backgroundColor = avatarColorFor(state.username);
prev.textContent = (state.username || '?').trim().charAt(0) || '?';
}
const rm = $('#settingAvatarRemove');
if (rm) rm.style.display = pic2 ? '' : 'none';
}
}
// Fetch the stored profile picture from the server and repaint.
async function loadUserAvatar() {
try {
const r = await api('/avatar', { headers: authHeaders() });
state.avatarDataUri = (r && r.avatar_b64) || '';
} catch (_) { state.avatarDataUri = ''; }
renderUserAvatar();
}
// Downscale + re-encode a picked image file to a small square JPEG data
// URI so we never store a multi-MB original. Returns a Promise<string>.
function processAvatarFile(file) {
return new Promise((resolve, reject) => {
// Read the file as a data: URI (not a blob: URL) — the app's CSP
// allows `img-src 'self' data:` but NOT blob:, so an <img> pointed
// at an object URL would fail to load.
const reader = new FileReader();
reader.onerror = () => reject(new Error('read failed'));
reader.onload = () => {
const img = new Image();
img.onload = () => {
const size = 128; // final square px
const canvas = document.createElement('canvas');
canvas.width = size; canvas.height = size;
const ctx = canvas.getContext('2d');
// Center-crop to a square, then draw scaled into 128×128.
const side = Math.min(img.width, img.height);
const sx = (img.width - side) / 2;
const sy = (img.height - side) / 2;
ctx.drawImage(img, sx, sy, side, side, 0, 0, size, size);
resolve(canvas.toDataURL('image/jpeg', 0.85));
};
img.onerror = () => reject(new Error('bad image'));
img.src = reader.result; // data:image/...;base64,...
};
reader.readAsDataURL(file);
});
}
async function uploadUserAvatar(file) {
if (!file || !/^image\//.test(file.type)) return toast('Pick an image file', 'error');
let dataUri;
try { dataUri = await processAvatarFile(file); }
catch (_) { return toast('Could not read that image', 'error'); }
try {
await api('/avatar', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ avatar_b64: dataUri }),
});
state.avatarDataUri = dataUri;
renderUserAvatar();
toast('Profile picture updated');
} catch (e) { toast(e.message || 'Upload failed', 'error'); }
}
async function removeUserAvatar() {
try {
await api('/avatar', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ avatar_b64: '' }),
});
state.avatarDataUri = '';
renderUserAvatar();
toast('Profile picture removed');
} catch (e) { toast(e.message || 'Failed', 'error'); }
}
function parseTags(s) {
if (!s) return [];
return s.split(',').map(t => t.trim()).filter(Boolean);
}
// ---- Search history (last 5 unique queries, localStorage-backed) ----
// Survives across sessions but not across port-changes (localStorage is
// origin-keyed). Acceptable: stale terms don't outlive a server restart.
const SEARCH_HISTORY_KEY = 'searchHistory';
const SEARCH_HISTORY_MAX = 5;
function getSearchHistory() {
try {
const raw = localStorage.getItem(SEARCH_HISTORY_KEY);
if (!raw) return [];
const arr = JSON.parse(raw);
return Array.isArray(arr) ? arr.filter(s => typeof s === 'string') : [];
} catch { return []; }
}
function pushSearchHistory(term) {
if (!term) return;
const t = term.trim();
if (!t) return;
const list = getSearchHistory().filter(s => s.toLowerCase() !== t.toLowerCase());
list.unshift(t);
while (list.length > SEARCH_HISTORY_MAX) list.pop();
try { localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(list)); } catch {}
}
function removeSearchHistory(term) {
const list = getSearchHistory().filter(s => s.toLowerCase() !== term.toLowerCase());
try { localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(list)); } catch {}
renderSearchHistoryMenu();
}
function clearSearchHistory() {
try { localStorage.removeItem(SEARCH_HISTORY_KEY); } catch {}
renderSearchHistoryMenu();
}
function renderSearchHistoryMenu() {
const menu = document.getElementById('searchHistoryMenu');
if (!menu) return;
const list = getSearchHistory();
if (list.length === 0) {
menu.classList.add('is-hidden');
return;
}
menu.innerHTML = '';
const header = el('div', { class: 'search-history-header' });
header.appendChild(el('span', null, 'Recent searches'));
const clearBtn = el('button',
{ class: 'search-history-clear', type: 'button' }, 'Clear');
clearBtn.addEventListener('mousedown', ev => {
// mousedown not click — blur on the input would hide the menu before
// a regular click reaches the button.
ev.preventDefault();
clearSearchHistory();
});
header.appendChild(clearBtn);
menu.appendChild(header);
list.forEach(term => {
const row = el('div', { class: 'search-history-row' });
const item = el('button', { class: 'search-history-item', type: 'button' });
item.appendChild(el('span', null, term));
item.addEventListener('mousedown', ev => {
ev.preventDefault();
$('#searchInput').value = term;
state.search = term;
state.currentPage = 1;
renderGrid();
pushSearchHistory(term); // bumps to top
menu.classList.add('is-hidden');
});
row.appendChild(item);
const del = el('button', {
class: 'search-history-del', type: 'button', title: 'Remove',
}, '×');
del.addEventListener('mousedown', ev => {
// mousedown to beat the input blur that would hide the menu first.
ev.preventDefault();
ev.stopPropagation();
removeSearchHistory(term);
});
row.appendChild(del);
menu.appendChild(row);
});
menu.classList.remove('is-hidden');
}
function allTags() {
const set = new Set();
state.entries.forEach(e => parseTags(e.tags).forEach(t => set.add(t)));
return Array.from(set).sort();
}
function viewTitle() {
if (state.view === 'all') return 'All items';
if (state.view === 'favorites') return 'Favorites';
if (state.view === 'notes') return 'Notes';
if (state.view === 'recent') return 'Recently used';
if (state.view === 'trash') return 'Trash';
if (state.view === 'authenticator') return 'Authenticator';
if (state.view === 'health') return 'Vault health';
if (state.view === 'audit') return 'Audit log';
if (state.view === 'folder:All') return '(no folder)';
if (state.view.startsWith('folder:')) return state.view.slice(7);
if (state.view.startsWith('tag:')) return '# ' + state.view.slice(4);
return 'Items';
}
// ============================================================
// RENDER
// ============================================================
function render() {
renderSidebar();
renderFilterChips();
renderFiltersBadge();
renderGrid();
}
function renderSidebar() {
// counts
$('#countAll').textContent = state.entries.length;
$('#countFav').textContent = state.entries.filter(e => e.favorite).length;
const noteCount = state.entries.filter(e => e.kind === 'note').length;
const countNotes = document.getElementById('countNotes');
if (countNotes) countNotes.textContent = noteCount || '';
// Prefer the server-side count (always up-to-date even if user never
// navigated to Trash this session) ; fall back to local array length.
const trashN = state.trashedCount || state.trashed.length || 0;
$('#countTrash').textContent = trashN || '';
// Section totals — shown next to the section header so the user still
// sees the count when the section is collapsed.
const folderCount = state.folders.filter(f => f.name !== 'All').length;
const tagCount = allTags().length;
const fc = document.getElementById('countFolders');
const tc = document.getElementById('countTags');
if (fc) fc.textContent = String(folderCount);
if (tc) tc.textContent = String(tagCount);
// active state for top-level items
$$('#appShell .nav-item[data-view]').forEach(n => {
n.classList.toggle('is-active', n.dataset.view === state.view);
});
// folders — hide the special "All" container (it doubles up with the
// "All items" view in the top nav and confuses users with two "All"
// entries that count different sets). Entries with folder='All' are
// still reachable via "All items".
const fList = $('#foldersList');
fList.innerHTML = '';
state.folders.filter(f => f.name !== 'All').forEach(folder => {
const name = folder.name;
const count = state.entries.filter(e => e.folder === name).length;
const key = 'folder:' + name;
const item = el('button', {
class: 'nav-item' + (state.view === key ? ' is-active' : ''),
'data-folder': name,
draggable: 'true',
on: { click: () => setView(key) },
});
// Custom icon if set, else default. Custom color tints the icon.
const iconEl = icon(folder.icon || 'i-folder');
if (folder.color) iconEl.style.color = folder.color;
item.appendChild(iconEl);
item.appendChild(el('span', null, name));
item.appendChild(el('span', { class: 'nav-count' }, String(count)));
const editBtn = el('button', {
class: 'folder-edit',
type: 'button',
title: 'Customize folder',
on: { click: ev => { ev.stopPropagation(); openFolderModal(folder); } },
});
editBtn.appendChild(icon('i-edit'));
item.appendChild(editBtn);
// Outgoing drag: reorder. Carries a custom MIME so the drop
// handler can distinguish folder-on-folder (reorder) from
// entry-on-folder (move entry).
item.addEventListener('dragstart', e => {
e.dataTransfer.setData('application/x-pm-folder', name);
e.dataTransfer.effectAllowed = 'move';
item.classList.add('is-dragging');
});
item.addEventListener('dragend', () => item.classList.remove('is-dragging'));
// Incoming drag: either an entry being moved here, OR another
// folder being reordered above/below this one.
item.addEventListener('dragover', e => {
e.preventDefault();
// dataTransfer.types is DOMStringList in some engines (no
// .includes) and frozen string[] in others — normalize first.
const types = Array.from(e.dataTransfer.types || []);
if (types.indexOf('application/x-pm-folder') !== -1) {
// Compute insert position based on mouse Y vs item midline.
const r = item.getBoundingClientRect();
const before = (e.clientY - r.top) < r.height / 2;
item.classList.toggle('drop-before', before);
item.classList.toggle('drop-after', !before);
item.classList.remove('drag-over');
} else {
item.classList.add('drag-over');
item.classList.remove('drop-before', 'drop-after');
}
});
item.addEventListener('dragleave', () =>
item.classList.remove('drag-over', 'drop-before', 'drop-after'));
item.addEventListener('drop', async e => {
e.preventDefault();
const draggedFolder = e.dataTransfer.getData('application/x-pm-folder');
const before = item.classList.contains('drop-before');
item.classList.remove('drag-over', 'drop-before', 'drop-after');
if (draggedFolder && draggedFolder !== name) {
await reorderFolderTo(draggedFolder, name, before);
return;
}
const raw = e.dataTransfer.getData('text/plain') || '';
const ids = raw.split(',').map(s => parseInt(s)).filter(n => n > 0);
if (ids.length) {
await moveEntriesToFolder(ids, name);
state.checked.clear();
}
});
const delBtn = el('button', {
class: 'folder-delete',
type: 'button',
title: 'Delete folder',
on: { click: ev => { ev.stopPropagation(); deleteFolder(name, count); } },
});
delBtn.appendChild(icon('i-x'));
item.appendChild(delBtn);
fList.appendChild(item);
});
// "(no folder)" pseudo-entry: filters to entries with no real folder
// (folder is empty or the default "All"). Skipped when there are zero
// such entries so the sidebar stays clean for organised users.
// No folder icon — visually communicates "this is the absence of a
// folder, not a folder". Drag target so users can quickly uncategorise.
const uncatCount = state.entries.filter(e => !e.folder || e.folder === 'All').length;
// Show the pseudo-entry whenever the user has real folders in the
// sidebar — it doubles as a drag target to uncategorise entries.
// Also stays visible in the currently-viewed folder:All so a mid-
// action move-out doesn't strand the user.
const hasRealFolders = state.folders.some(f => f && f.name && f.name !== 'All');
if (uncatCount > 0 || state.view === 'folder:All' || hasRealFolders) {
const key = 'folder:All';
const item = el('button', {
class: 'nav-item is-uncategorized' + (state.view === key ? ' is-active' : ''),
'data-folder': 'All',
on: { click: () => setView(key) },
});
// Spacer instead of the folder icon — keeps alignment with real
// folders without implying "this is a folder". MUST NOT be a <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 raw = e.dataTransfer.getData('text/plain') || '';
const ids = raw.split(',').map(s => parseInt(s)).filter(n => n > 0);
if (ids.length) {
await moveEntriesToFolder(ids, 'All');
state.checked.clear();
}
});
fList.appendChild(item);
}
// tags
const tList = $('#tagsList');
tList.innerHTML = '';
const tags = allTags();
if (tags.length === 0) {
tList.appendChild(el('div', { class: 'sidebar-section-header', style: 'padding:6px 10px;color:var(--text-faint);font-size:11px;text-transform:none;letter-spacing:0' }, 'No tags yet'));
} else {
tags.forEach(t => {
const key = 'tag:' + t;
const count = state.entries.filter(e => parseTags(e.tags).includes(t)).length;
const item = el('button', {
class: 'nav-item' + (state.view === key ? ' is-active' : ''),
on: { click: () => setView(key) },
});
item.appendChild(icon('i-tag'));
item.appendChild(el('span', null, t));
item.appendChild(el('span', { class: 'nav-count' }, String(count)));
// Drop target: drag a card here to add this tag to that entry
item.addEventListener('dragover', e => { e.preventDefault(); item.classList.add('drag-over'); });
item.addEventListener('dragleave', () => item.classList.remove('drag-over'));
item.addEventListener('drop', async e => {
e.preventDefault();
item.classList.remove('drag-over');
const id = parseInt(e.dataTransfer.getData('text/plain'));
if (id) await addTagToEntry(id, t);
});
tList.appendChild(item);
});
}
}
async function addTagToEntry(id, tag) {
const e = state.entries.find(x => x.id === id);
if (!e) return;
const tags = parseTags(e.tags);
if (tags.includes(tag)) {
toast('Already tagged with "' + tag + '"', 'warning');
return;
}
tags.push(tag);
try {
await api('/entries/' + id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
site: e.site,
title: e.title || '',
username: e.username,
encrypted_password: e.encrypted_password,
iv: e.iv,
folder: e.folder,
tags: tags.join(','),
kind: e.kind || 'login',
totp_secret: e.totp_secret || '',
totp_iv: e.totp_iv || '',
custom_fields: e.custom_fields || '',
custom_fields_iv: e.custom_fields_iv || '',
}),
});
e.tags = tags.join(',');
render();
toast('Tagged "' + tag + '"');
} catch (err) { toast(err.message, 'error'); }
}
function renderGrid() {
$('#contentTitle').textContent = viewTitle();
// Re-apply the keyboard cursor after the grid re-renders. Defer one
// tick so the new cards are in the DOM before we query for them.
setTimeout(applyCursorHighlight, 0);
// Audit log: read-only chronological feed, custom render.
if (state.view === 'audit') {
if (authTickTimer) { clearInterval(authTickTimer); authTickTimer = null; }
const oldBtn = $('#emptyTrashBtn'); if (oldBtn) oldBtn.remove();
renderBatchBar();
const grid = $('#entryGrid');
grid.className = 'entry-grid is-audit';
grid.innerHTML = '';
$('#emptyState').classList.add('is-hidden');
renderAuditLog(grid);
return;
}
// Vault health dashboard: bypass the standard list rendering entirely.
if (state.view === 'health') {
if (authTickTimer) { clearInterval(authTickTimer); authTickTimer = null; }
const oldBtn = $('#emptyTrashBtn'); if (oldBtn) oldBtn.remove();
renderBatchBar();
$('#contentMeta').textContent = state.entries.length +
(state.entries.length === 1 ? ' entry analysed' : ' entries analysed');
const grid = $('#entryGrid');
grid.className = 'entry-grid is-health';
grid.innerHTML = '';
$('#emptyState').classList.add('is-hidden');
renderHealthDashboard(grid);
return;
}
// Authenticator view: bypass the standard pipeline — render a dedicated
// grid of TOTP cards (only entries that have a TOTP secret configured).
if (state.view === 'authenticator') {
if (authTickTimer) { clearInterval(authTickTimer); authTickTimer = null; }
const oldBtn = $('#emptyTrashBtn'); if (oldBtn) oldBtn.remove();
renderBatchBar();
const totpEntries = state.entries.filter(e => e.totp_secret && e.totp_iv);
$('#contentMeta').textContent = totpEntries.length +
(totpEntries.length === 1 ? ' code' : ' codes');
const grid = $('#entryGrid');
grid.className = 'entry-grid is-auth';
grid.innerHTML = '';
if (totpEntries.length === 0) {
$('#emptyState').classList.remove('is-hidden');
const illu = $('#emptyIllustration use');
illu.setAttribute('href', '#i-empty-vault');
$('#emptyTitle').textContent = 'No TOTP codes yet';
$('#emptyMessage').innerHTML = 'Add a TOTP secret to any entry to see its live code here.';
return;
}
$('#emptyState').classList.add('is-hidden');
renderAuthenticatorGrid(grid, totpEntries);
return;
} else if (authTickTimer) {
clearInterval(authTickTimer); authTickTimer = null;
}
const list = filteredEntries();
$('#contentMeta').textContent = list.length + (list.length === 1 ? ' item' : ' items');
// Empty trash action button next to title (only in trash view)
const oldBtn = $('#emptyTrashBtn');
if (oldBtn) oldBtn.remove();
if (state.view === 'trash' && state.trashed.length > 0) {
const btn = el('button', {
class: 'btn btn-ghost btn-sm', id: 'emptyTrashBtn',
style: 'margin-left:auto',
on: { click: emptyTrash },
}, withIcon('i-trash', 'Empty trash'));
$('.content-header').appendChild(btn);
}
// Batch action bar (shown when selection is non-empty)
renderBatchBar();
const grid = $('#entryGrid');
grid.className = 'entry-grid'
+ (state.viewMode === 'list' ? ' is-list' : '')
+ (state.viewMode === 'table' ? ' is-table' : '');
grid.innerHTML = '';
if (list.length === 0) {
showEmptyState();
return;
}
$('#emptyState').classList.add('is-hidden');
const total = list.length;
const totalPages = Math.max(1, Math.ceil(total / state.pageSize));
if (state.currentPage > totalPages) state.currentPage = totalPages;
if (state.currentPage < 1) state.currentPage = 1;
const start = (state.currentPage - 1) * state.pageSize;
const pageList = list.slice(start, start + state.pageSize);
if (total > 10) {
grid.appendChild(renderPagination(total, totalPages));
}
if (state.viewMode === 'table') {
grid.appendChild(renderTable(pageList));
} else {
pageList.forEach(e => grid.appendChild(renderCard(e)));
}
}
// Authenticator view tick timer — recomputes every code once per second.
let authTickTimer = null;
function renderAuthenticatorGrid(grid, entries) {
// Decrypt all secrets once up-front (slow); render cards immediately
// with a placeholder, then patch in the codes as they decrypt.
const cards = entries.map(e => {
const wrap = el('div', { class: 'auth-card', 'data-id': String(e.id) });
const head = el('div', { class: 'auth-card-head' });
head.appendChild(el('div', { class: 'auth-card-title' },
e.title || e.site || '(no name)'));
if (e.username) head.appendChild(el('div', { class: 'auth-card-sub' }, e.username));
wrap.appendChild(head);
const codeRow = el('div', { class: 'auth-card-code-row' });
const codeEl = el('div', { class: 'auth-card-code' }, '— — — — — —');
const copyBtn = el('button', { class: 'icon-btn', title: 'Copy code', type: 'button' });
copyBtn.appendChild(icon('i-copy'));
copyBtn.addEventListener('click', async ev => {
ev.stopPropagation();
const code = codeEl.textContent.replace(/\s/g, '');
if (!/^\d{6}$/.test(code)) return;
if (Bridge.active) Bridge.copySecure(code, 30000);
else { try { await navigator.clipboard.writeText(code); } catch (_) {} }
toast('Code copied');
});
codeRow.appendChild(codeEl);
codeRow.appendChild(copyBtn);
wrap.appendChild(codeRow);
const barWrap = el('div', { class: 'auth-card-bar-wrap' });
const bar = el('div', { class: 'auth-card-bar' });
barWrap.appendChild(bar);
wrap.appendChild(barWrap);
// Click anywhere on card (outside copy) opens the entry detail.
wrap.addEventListener('click', () => openSlideOver(e.id));
return { entry: e, wrap, codeEl, bar, secret: null };
});
cards.forEach(c => grid.appendChild(c.wrap));
// Decrypt then start ticking
(async () => {
for (const c of cards) {
try {
c.secret = await decryptTotpSecret(c.entry.totp_secret, c.entry.totp_iv);
} catch (e) {
c.secret = null;
}
}
async function tick() {
for (const c of cards) {
if (!c.secret) { c.codeEl.textContent = 'error'; continue; }
try {
const t = await generateTOTP(c.secret);
c.codeEl.textContent = t.code.replace(/(\d{3})(\d{3})/, '$1 $2');
const newPct = (t.secondsLeft / 30) * 100;
// Detect period reset (countdown wrapped from ~0 back to 30s):
// snap the bar instantly to 100% instead of letting the CSS
// transition animate the jump backwards, which looks like a
// freeze / reverse glide.
if (newPct > (c.lastPct || 0) + 5) {
c.bar.style.transition = 'none';
c.bar.style.width = newPct.toFixed(1) + '%';
// Force reflow then restore the transition for the smooth
// forward countdown.
void c.bar.offsetWidth;
c.bar.style.transition = '';
} else {
c.bar.style.width = newPct.toFixed(1) + '%';
}
c.lastPct = newPct;
c.bar.classList.toggle('is-warning', t.secondsLeft <= 5);
} catch (e) {
c.codeEl.textContent = 'error';
}
}
}
await tick();
if (authTickTimer) clearInterval(authTickTimer);
authTickTimer = setInterval(tick, 1000);
})();
}
// ============================================================
// VAULT HEALTH dashboard
// ============================================================
//
// One-shot computation per session — decrypting every entry is the
// expensive part, so we cache the result and clear it on lock / entry
// edit / view re-entry (Tools → Vault health).
let healthCache = null;
// Audit log: array of {id, action, ip, created_at}. Refreshed on demand
// from the server. Cleared on lockVault.
let auditCache = null;
let auditFilter = '';
// Per-category expand state. Survives full re-renders of the dashboard
// (renderGrid runs from openSlideOver → would otherwise reset every
// "Show all" toggle back to collapsed).
const healthExpanded = { weak: false, reused: false, old: false, pwned: false };
const HEALTH_WEAK_THRESHOLD = 50; // computeStrength score < 50 → weak
const HEALTH_OLD_DAYS = 365; // entries not updated in > 1 year
function entryAgeDays(e) {
const ts = e.updated_at || e.created_at;
if (!ts) return 0;
// ISO 'yyyy-mm-dd hh:nn:ss' → assume UTC-ish, close enough for ranking.
const d = new Date(ts.replace(' ', 'T'));
if (isNaN(d)) return 0;
return Math.floor((Date.now() - d.getTime()) / 86400000);
}
async function computeHealthCache() {
const weak = [], old = [], pwned = [];
const byPwd = new Map(); // plaintext → [entries]
// Notes have no password to weigh — their encrypted_password is just
// the free-text body. Skipping them avoids polluting the "weak / reused"
// categories with note content.
for (const e of state.entries) {
if ((e.kind || 'login') !== 'login') continue;
const ageD = entryAgeDays(e);
if (ageD > HEALTH_OLD_DAYS) old.push({ entry: e, ageDays: ageD });
const pwn = state.hibpResults.get(e.id);
if (typeof pwn === 'number' && pwn > 0)
pwned.push({ entry: e, count: pwn });
// Decrypt for strength + reuse detection. '[ERROR]' bubbles up
// from decryptPwd for corrupted ciphertext — skip those silently.
const plain = await decryptPwd(e.encrypted_password, e.iv);
if (plain === '[ERROR]') continue;
const score = computeStrength(plain);
if (score < HEALTH_WEAK_THRESHOLD) weak.push({ entry: e, score });
if (!byPwd.has(plain)) byPwd.set(plain, []);
byPwd.get(plain).push(e);
}
// Reuse: groups of ≥2 entries sharing the same plaintext password.
const reused = [];
for (const [, entries] of byPwd) {
if (entries.length >= 2) reused.push(entries);
}
// Score: start at 100, subtract per issue (capped at 0). Weights
// chosen so a single pwned password dominates over a single old one.
let score = 100;
score -= Math.min(40, weak.length * 5);
score -= Math.min(30, reused.length * 10);
score -= Math.min(20, old.length * 2);
score -= Math.min(50, pwned.length * 15);
if (score < 0) score = 0;
return { weak, reused, old, pwned, score };
}
function healthScoreBand(score) {
if (score >= 80) return { label: 'Good', cls: 'is-ok' };
if (score >= 50) return { label: 'Fair', cls: 'is-fair' };
if (score >= 25) return { label: 'At risk', cls: 'is-warn' };
return { label: 'Critical', cls: 'is-danger' };
}
// Open the entry slideover, unmask the password, focus it, and pulse the
// generator button. The user keeps full context (which entry they're
// fixing) and decides whether to type a new password, click the dice, or
// dismiss. Auto-opening the generator modal hid the entry context and
// forced an extra Save click — worse UX than this lighter nudge.
async function openEntryForFix(entryId) {
await openSlideOver(entryId);
const pwd = document.getElementById('soPassword');
if (pwd) {
pwd.type = 'text'; // unmask so the user sees what they're replacing
pwd.focus();
pwd.select();
}
const genBtn = document.querySelector('.so-pw-row button[title="Generate"]');
if (genBtn) {
genBtn.classList.add('is-pulse');
setTimeout(() => genBtn.classList.remove('is-pulse'), 2000);
}
}
async function renderHealthDashboard(grid) {
// Recompute on demand. The "Recompute" button below also triggers it.
if (!healthCache) {
grid.appendChild(el('div', { class: 'health-loading' },
'Analysing ' + state.entries.length + ' entries…'));
healthCache = await computeHealthCache();
grid.innerHTML = '';
}
const h = healthCache;
const band = healthScoreBand(h.score);
// Header: big score + recompute action
const header = el('div', { class: 'health-header' });
const scoreEl = el('div', { class: 'health-score ' + band.cls });
scoreEl.appendChild(el('div', { class: 'health-score-num' }, String(h.score)));
scoreEl.appendChild(el('div', { class: 'health-score-lbl' }, band.label));
header.appendChild(scoreEl);
const intro = el('div', { class: 'health-intro' });
intro.appendChild(el('h3', null, 'How healthy is your vault?'));
intro.appendChild(el('p', null,
'A summary of weak, reused, old and breached passwords. ' +
'Click any item to open it and rotate the password.'));
const recompute = el('button', { class: 'btn btn-ghost btn-sm', type: 'button' });
recompute.appendChild(icon('i-rotate-ccw'));
recompute.appendChild(document.createTextNode(' Recompute'));
recompute.addEventListener('click', () => {
healthCache = null;
render();
});
intro.appendChild(recompute);
header.appendChild(intro);
grid.appendChild(header);
// Four category cards
grid.appendChild(renderHealthSection({
key: 'weak',
title: 'Weak passwords',
hint: 'Strength score below ' + HEALTH_WEAK_THRESHOLD +
'/100 (short / few character classes).',
items: h.weak,
empty: 'All passwords pass the strength check. 👍',
formatItem: it => entryDisplayName(it.entry) + ' — ' + it.score + '/100',
}));
grid.appendChild(renderHealthSection({
key: 'reused',
title: 'Reused passwords',
hint: 'Same password used on multiple entries — a single breach affects them all.',
items: h.reused,
empty: 'Every password is unique. 👍',
formatItem: group => group.map(e => entryDisplayName(e)).join(' · ') +
' (' + group.length + ' entries)',
// Click on a reused group: open the first entry. Could be smarter.
idOfItem: group => group[0].id,
}));
grid.appendChild(renderHealthSection({
key: 'old',
title: 'Old passwords',
hint: 'Not updated for more than ' + Math.round(HEALTH_OLD_DAYS / 30) +
' months. Consider rotating periodically for high-value accounts.',
items: h.old,
empty: 'No stale passwords.',
formatItem: it => entryDisplayName(it.entry) + ' — ' +
Math.floor(it.ageDays / 30) + ' months old',
}));
grid.appendChild(renderHealthSection({
key: 'pwned',
title: 'Breached passwords (HIBP)',
hint: state.hibpEnabled
? 'Found in the Have I Been Pwned database. Change them now.'
: 'Enable “Check passwords against breach database” in Settings to populate this list.',
items: h.pwned,
empty: state.hibpEnabled
? 'No password matches a known breach. 👍'
: '— breach check is OFF —',
formatItem: it => entryDisplayName(it.entry) +
' — seen ' + it.count.toLocaleString() + 'x',
}));
}
// Build one collapsible category card. opts:
// title, hint, items[], empty,
// formatItem(item) → text for the row,
// idOfItem(item) → entry id used by the Fix click. Default: item.entry.id
function renderHealthSection(opts) {
const card = el('section', { class: 'health-card' });
const head = el('header', { class: 'health-card-head' });
head.appendChild(el('h4', null, opts.title));
const badge = el('span', { class: 'health-badge' }, String(opts.items.length));
if (opts.items.length === 0) badge.classList.add('is-empty');
head.appendChild(badge);
card.appendChild(head);
card.appendChild(el('p', { class: 'health-hint' }, opts.hint));
if (opts.items.length === 0) {
card.appendChild(el('p', { class: 'health-empty' }, opts.empty));
return card;
}
const list = el('ul', { class: 'health-list' });
const getId = opts.idOfItem || (it => it.entry.id);
const INITIAL_LIMIT = 20;
// Read persisted expand state so a renderGrid() triggered by
// openSlideOver (after clicking "Fix") doesn't snap the list back
// to the collapsed view.
let expanded = !!(opts.key && healthExpanded[opts.key]);
let shown = expanded ? opts.items.length :
Math.min(INITIAL_LIMIT, opts.items.length);
function renderRows() {
list.innerHTML = '';
opts.items.slice(0, shown).forEach(it => {
const li = el('li', { class: 'health-item' });
li.appendChild(el('span', { class: 'health-item-label' }, opts.formatItem(it)));
const fix = el('button', { class: 'btn btn-ghost btn-xs', type: 'button' },
'Fix');
fix.addEventListener('click', ev => {
// Stop bubbling — the document-level "click outside slideover"
// handler would otherwise close the slideover we're about to
// open within the same click event.
ev.stopPropagation();
openEntryForFix(getId(it));
});
li.appendChild(fix);
list.appendChild(li);
});
}
renderRows();
card.appendChild(list);
if (opts.items.length > INITIAL_LIMIT) {
const more = el('button', {
class: 'btn btn-ghost btn-xs health-more-btn',
type: 'button',
}, expanded ? 'Show less' : ('Show all ' + opts.items.length));
more.addEventListener('click', ev => {
ev.stopPropagation();
expanded = !expanded;
shown = expanded ? opts.items.length : INITIAL_LIMIT;
more.textContent = expanded ? 'Show less'
: 'Show all ' + opts.items.length;
if (opts.key) healthExpanded[opts.key] = expanded;
renderRows();
});
card.appendChild(more);
}
return card;
}
// ============================================================
// AUDIT LOG VIEWER
// ============================================================
//
// audit_log is filled by every sensitive action server-side
// (add/edit/delete entry, autofill, lock, master-pw change, etc.).
// 30-day auto-purge runs at server start. Frontend is read-only.
async function renderAuditLog(grid) {
if (!auditCache) {
grid.appendChild(el('div', { class: 'audit-loading' }, 'Loading…'));
try {
const r = await fetch(API + '/audit?limit=500', { headers: authHeaders() });
auditCache = r.ok ? await r.json() : [];
} catch (e) { auditCache = []; }
grid.innerHTML = '';
}
const total = auditCache.length;
$('#contentMeta').textContent = total + (total === 1 ? ' event' : ' events');
// Header: search + refresh + clear notice
const head = el('div', { class: 'audit-header' });
const search = el('input', {
type: 'text', class: 'audit-search',
placeholder: 'Filter actions (e.g. autofill, edit, delete)…',
});
search.value = auditFilter;
search.addEventListener('input', () => {
auditFilter = search.value;
renderAuditRows();
});
head.appendChild(search);
const refresh = el('button', { class: 'btn btn-ghost btn-sm', type: 'button' });
refresh.appendChild(icon('i-rotate-ccw'));
refresh.appendChild(document.createTextNode(' Refresh'));
refresh.addEventListener('click', () => {
auditCache = null;
render();
});
head.appendChild(refresh);
grid.appendChild(head);
const note = el('p', { class: 'audit-note' },
'Entries older than 30 days are auto-purged. Server-side.');
grid.appendChild(note);
const list = el('div', { class: 'audit-list' });
grid.appendChild(list);
function renderAuditRows() {
list.innerHTML = '';
const q = auditFilter.trim().toLowerCase();
const filtered = q
? auditCache.filter(r =>
(r.action || '').toLowerCase().includes(q))
: auditCache;
if (filtered.length === 0) {
list.appendChild(el('p', { class: 'audit-empty' },
q ? 'No events match "' + q + '"' : 'No events recorded yet.'));
return;
}
filtered.forEach(row => {
const li = el('div', { class: 'audit-row' });
li.appendChild(el('span', { class: 'audit-date' },
row.created_at || ''));
li.appendChild(el('span', { class: 'audit-action' },
row.action || ''));
li.appendChild(el('span', { class: 'audit-ip' }, row.ip || ''));
list.appendChild(li);
});
}
renderAuditRows();
}
function showEmptyState() {
const illustration = $('#emptyIllustration use');
const title = $('#emptyTitle');
const msg = $('#emptyMessage');
if (state.search) {
illustration.setAttribute('href', '#i-empty-search');
title.textContent = 'No matches';
msg.innerHTML = 'Try a different search term, or click <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 === 'folder:All') {
illustration.setAttribute('href', '#i-empty-vault');
title.textContent = 'No uncategorised entries';
msg.innerHTML = 'Every entry currently belongs to a folder. Drag one here to remove it from its folder.';
} else if (state.view.startsWith('folder:')) {
illustration.setAttribute('href', '#i-empty-vault');
title.textContent = 'Folder is empty';
msg.innerHTML = 'Move entries here by drag &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 || '';
}
// Decide if entry.site can be opened in a browser. Accepts:
// "https://github.com/login" → kept as-is
// "github.com" → prefixed with https://
// "Gitea" / "my note" → returns '' (no dot or not a hostname)
// Returns the canonical URL to pass to ShellExecute, or '' if not openable.
function entryOpenUrl(site) {
if (!site) return '';
let s = String(site).trim();
// Already-scheme'd: only allow http(s).
if (/^https?:\/\//i.test(s)) return s;
if (/^[a-z][a-z0-9+.-]*:/i.test(s)) return ''; // ftp://, file://, mailto:…
// Plain hostname or hostname/path. Require at least one dot and a
// letter TLD ≥ 2 chars to avoid opening "Gitea" or "Brand name".
const host = s.split('/')[0].split(':')[0].toLowerCase();
if (!host.includes('.')) return '';
if (!/\.[a-z]{2,}$/i.test(host)) return '';
return 'https://' + s;
}
function entryOpenInBrowser(url) {
if (!url) return;
if (Bridge.active && typeof Bridge.openUrl === 'function') {
Bridge.openUrl(url);
} else {
// PHP frontend / fallback: regular window.open.
try { window.open(url, '_blank', 'noopener'); } catch (e) {}
}
}
// Display label for a folder value. "All" is the default "uncategorized"
// bucket; we relabel it so users don't see two "All" entries in folder
// pickers (the top nav "All items" also says "All").
// Returns the age (in days) of an entry's password, falling back to
// updated_at then created_at for legacy rows with no password_changed_at.
function passwordAgeDays(e) {
const stamp = e.password_changed_at || e.updated_at || e.created_at;
if (!stamp) return 0;
const ms = Date.now() - Date.parse(stamp);
return Math.floor(ms / 86400000);
}
function isPasswordAged(e) {
const limit = parseInt(state.passwordExpiryDays, 10) || 0;
if (limit <= 0) return false;
return passwordAgeDays(e) >= limit;
}
// ---- Keyboard cursor over cards (j/k + arrows) ----
// Stays inert when any input/textarea has focus, when a modal is open,
// when an open slideover would steal Enter, or the user is mid-edit.
function handleCardCursorKey(e) {
if (e.ctrlKey || e.altKey || e.metaKey) return;
const tag = (e.target && e.target.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
if (e.target && e.target.isContentEditable) return;
if (!$('#appShell') || $('#appShell').classList.contains('is-hidden')) return;
// Don't fight focus when a modal is up.
if (document.querySelector('.modal:not(.is-hidden)')) return;
// Slideover open: Enter would commit Save, leave it alone.
if ($('#slideover') && $('#slideover').classList.contains('is-open')) {
if (e.key === 'Escape') return; // handled elsewhere
}
const list = filteredEntries();
if (list.length === 0) return;
const goNext = (e.key === 'j' || e.key === 'ArrowDown');
const goPrev = (e.key === 'k' || e.key === 'ArrowUp');
if (goNext || goPrev) {
e.preventDefault();
let i = state.cursorIdx;
if (i < 0) {
// Bootstrap to the first visible card on the current page,
// not the very first filtered entry — otherwise pressing j
// on page 3 would send focus to page 1.
const start = (state.currentPage - 1) * state.pageSize;
i = goNext ? start : Math.min(list.length - 1, start + state.pageSize - 1);
} else {
i = Math.max(0, Math.min(list.length - 1, i + (goNext ? 1 : -1)));
}
state.cursorIdx = i;
// If the new cursor lies on a different page, flip to it. The
// re-render reapplies the highlight via the deferred call in
// renderGrid.
const targetPage = Math.floor(i / state.pageSize) + 1;
if (targetPage !== state.currentPage) {
state.currentPage = targetPage;
renderGrid();
} else {
applyCursorHighlight();
}
return;
}
if (e.key === 'Enter' && state.cursorIdx >= 0 && state.cursorIdx < list.length) {
e.preventDefault();
openSlideOver(list[state.cursorIdx].id);
return;
}
if (e.key === 'Escape' && state.cursorIdx >= 0) {
state.cursorIdx = -1;
applyCursorHighlight();
}
}
function applyCursorHighlight() {
document.querySelectorAll('.entry-card.is-cursor, .entry-row.is-cursor')
.forEach(el => el.classList.remove('is-cursor'));
if (state.cursorIdx < 0) return;
const list = filteredEntries();
const entry = list[state.cursorIdx];
if (!entry) return;
const node = document.querySelector(
'.entry-card[data-id="' + entry.id + '"], ' +
'.entry-row[data-id="' + entry.id + '"]');
if (node) {
node.classList.add('is-cursor');
node.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}
// Human-readable label for an entry template id. Returns '' for unknown
// / unset templates so callers can fall back to the kind-based defaults.
function templateLabel(tpl) {
switch ((tpl || '').toLowerCase()) {
case 'credit-card': return 'Credit card';
case 'ssh-key': return 'SSH key';
case 'server': return 'Server';
case 'recovery-codes': return 'Recovery codes';
default: return '';
}
}
function folderLabel(f) {
if (!f || f === 'All') return '(no folder)';
return f;
}
// Comparator for sorting entries. by ∈ {name, site, updated, created, folder}.
// dir ∈ {asc, desc}. Stable sort: ties keep their relative order (Array.sort
// is stable per the modern spec).
function sortEntries(list, by, dir) {
const mul = dir === 'desc' ? -1 : 1;
const get = e => {
switch (by) {
case 'site': return (e.site || '').toLowerCase();
case 'updated': return e.updated_at || '';
case 'created': return e.created_at || '';
case 'folder': return (e.folder || '').toLowerCase();
case 'name':
default: return entryDisplayName(e).toLowerCase();
}
};
return list.slice().sort((a, b) => {
// Pinned entries always float to the top regardless of sort. Inside
// each group (pinned / unpinned) the user's chosen sort applies.
const pa = a.pinned ? 1 : 0, pb = b.pinned ? 1 : 0;
if (pa !== pb) return pb - pa;
const va = get(a), vb = get(b);
if (va < vb) return -1 * mul;
if (va > vb) return 1 * mul;
return 0;
});
}
// Compact-action kebab menu shown on each card when state.compactActions is on.
function buildKebabMenu(entry) {
const wrap = el('div', { class: 'entry-kebab-wrap' });
const btn = el('button', {
class: 'entry-kebab',
title: 'More actions',
on: { click: ev => {
ev.stopPropagation();
// Close any other open menu, then toggle this one
$$('.entry-kebab-menu.is-open').forEach(m => {
if (m !== menu) m.classList.remove('is-open');
});
menu.classList.toggle('is-open');
} },
});
btn.appendChild(icon('i-more'));
wrap.appendChild(btn);
const menu = el('div', { class: 'entry-kebab-menu' });
const isNote = entry.kind === 'note';
const items = [
{ lbl: entry.pinned ? 'Unpin' : 'Pin to top',
ic: 'i-pin', fn: () => togglePin(entry.id) },
{ lbl: entry.favorite ? 'Unfavorite' : 'Favorite',
ic: 'i-star', fn: () => toggleFavorite(entry.id) },
{ lbl: isNote ? 'Copy note content' : 'Copy password',
ic: 'i-copy', fn: () => copyPassword(entry) },
];
// Username is login-only — hide the menu item for notes (no username field).
if (!isNote)
items.push({ lbl: 'Copy username', ic: 'i-user', fn: () => copyUsername(entry) });
items.push(
{ lbl: 'Edit', ic: 'i-edit', fn: () => openSlideOver(entry.id) },
{ lbl: 'Duplicate', ic: 'i-copy', fn: () => duplicateEntry(entry) },
{ lbl: 'Move to trash', ic: 'i-trash', fn: () => deleteEntry(entry.id), danger: true },
);
items.forEach(it => {
const mi = el('button', {
class: 'kebab-item' + (it.danger ? ' is-danger' : ''),
on: { click: ev => {
ev.stopPropagation();
menu.classList.remove('is-open');
it.fn();
} },
});
mi.appendChild(icon(it.ic));
mi.appendChild(el('span', null, it.lbl));
menu.appendChild(mi);
});
wrap.appendChild(menu);
return wrap;
}
function renderCard(e) {
const inTrash = state.view === 'trash';
const checked = state.checked.has(e.id);
const card = el('article', {
class: 'entry-card'
+ (state.selectedId === e.id ? ' is-selected' : '')
+ (checked ? ' is-checked' : ''),
'data-id': e.id,
draggable: inTrash ? 'false' : 'true',
on: { click: ev => handleCardClick(ev, e, inTrash) },
});
if (!inTrash) {
card.addEventListener('dragstart', ev => {
// If the dragged card is part of an active selection, carry
// ALL checked ids so a drop on a folder / trash moves the
// whole batch in one gesture. Otherwise carry just this one.
let ids;
if (state.checked.size > 1 && state.checked.has(e.id)) {
ids = Array.from(state.checked).join(',');
} else {
ids = String(e.id);
}
ev.dataTransfer.setData('text/plain', ids);
ev.dataTransfer.effectAllowed = 'move';
});
}
// head: avatar shows identity (initials). A checkbox overlay LIVES
// INSIDE the avatar (absolute inset:0) — it covers the avatar exactly
// when visible, no separate footprint that could collide with the
// avatar's position. Visible on hover or whenever the entry is
// checked. Card click anywhere not on the checkbox opens slideover.
const head = el('div', { class: 'entry-head' });
const displayName = entryDisplayName(e);
const avatar = el('div', { class: 'entry-avatar' });
if (e.icon_b64) {
const img = el('img', { src: e.icon_b64, alt: '', class: 'entry-avatar-img' });
// If the cached data URI fails to decode (corrupt blob), fall
// back to the initials so the card never shows a broken-image icon.
img.addEventListener('error', () => {
avatar.innerHTML = '';
avatar.textContent = initials(displayName);
});
avatar.appendChild(img);
} else {
avatar.textContent = initials(displayName);
}
const checkbox = el('button', {
class: 'entry-check' + (checked ? ' is-checked' : ''),
type: 'button',
title: checked ? 'Deselect' : 'Select',
on: { click: ev => { ev.stopPropagation(); toggleChecked(e.id); } },
});
if (checked) checkbox.appendChild(el('span', null, '✓'));
avatar.appendChild(checkbox); // NESTED inside avatar — no overlap
head.appendChild(avatar);
const title = el('div', { class: 'entry-title' });
title.appendChild(el('b', null, displayName));
// If user set a custom title AND it differs from site, optionally show
// site as a small subtitle. Hidden by default to keep cards clean —
// toggled via Settings > Appearance.
if (state.showSiteOnCards &&
e.title && e.title.trim() && e.title.trim() !== e.site) {
title.appendChild(el('span', { class: 'entry-subtitle' }, e.site));
}
// Username row with inline copy button (visible on card hover)
const userRow = el('small', { class: 'entry-user-row' });
userRow.appendChild(el('span', null, displayUsername(e.username)));
if (e.username) {
const copyUser = el('button', {
class: 'entry-copy-user',
title: 'Copy username',
on: { click: ev => { ev.stopPropagation(); copyUsername(e); } },
});
copyUser.appendChild(icon('i-copy'));
userRow.appendChild(copyUser);
}
title.appendChild(userRow);
head.appendChild(title);
if (inTrash) {
// In trash: show restore + permanent delete buttons
const restore = el('button', {
class: 'icon-btn icon-btn-sm', title: 'Restore',
on: { click: ev => { ev.stopPropagation(); restoreEntry(e.id); } },
});
restore.appendChild(icon('i-rotate-ccw'));
const purge = el('button', {
class: 'icon-btn icon-btn-sm', title: 'Delete forever',
style: 'color:var(--danger)',
on: { click: ev => { ev.stopPropagation(); permanentDelete(e.id); } },
});
purge.appendChild(icon('i-trash'));
head.appendChild(restore);
head.appendChild(purge);
} else if (state.compactActions) {
// Compact mode: single kebab menu replaces fav + dup + del
head.appendChild(buildKebabMenu(e));
} else {
const pin = el('button', {
class: 'entry-pin' + (e.pinned ? ' is-on' : ''),
title: e.pinned ? 'Unpin' : 'Pin to top',
on: { click: ev => { ev.stopPropagation(); togglePin(e.id); } },
});
pin.appendChild(icon('i-pin'));
head.appendChild(pin);
const fav = el('button', {
class: 'entry-fav' + (e.favorite ? ' is-on' : ''),
title: 'Favorite',
on: { click: ev => { ev.stopPropagation(); toggleFavorite(e.id); } },
});
fav.appendChild(icon('i-star'));
head.appendChild(fav);
const dup = el('button', {
class: 'entry-dup',
title: 'Duplicate',
on: { click: ev => { ev.stopPropagation(); duplicateEntry(e); } },
});
dup.appendChild(icon('i-copy'));
head.appendChild(dup);
const del = el('button', {
class: 'entry-del',
title: 'Move to trash',
on: { click: ev => { ev.stopPropagation(); deleteEntry(e.id); } },
});
del.appendChild(icon('i-x'));
head.appendChild(del);
}
card.appendChild(head);
// Body row: password placeholder for logins, content snippet for notes.
const isNoteCard = (e.kind === 'note');
if (isNoteCard) {
// Single placeholder line — the body is encrypted client-side,
// we don't decrypt it eagerly for every card. Template-typed
// entries surface their specific label so the card reads as
// "Credit card" / "SSH key" / etc. rather than the generic note
// copy.
const noteRow = el('div', { class: 'entry-note-row' });
const tplLbl = templateLabel(e.template);
noteRow.appendChild(el('span', { class: 'entry-note-placeholder' },
tplLbl || 'Encrypted note · click to read'));
// Same crypto pipeline as a password — copyPassword decrypts the
// body and drops it in the secure clipboard.
const copyBtn = el('button', {
class: 'icon-btn icon-btn-sm',
title: 'Copy note content',
on: { click: ev => { ev.stopPropagation(); copyPassword(e); } },
});
copyBtn.appendChild(icon('i-copy'));
noteRow.appendChild(copyBtn);
card.appendChild(noteRow);
} else {
const pwRow = el('div', { class: 'entry-pw-row' });
pwRow.appendChild(el('span', { class: 'entry-pw', id: 'pw-' + e.id }, '••••••••'));
const copyBtn = el('button', {
class: 'icon-btn icon-btn-sm',
title: 'Copy password',
on: { click: ev => { ev.stopPropagation(); copyPassword(e); } },
});
copyBtn.appendChild(icon('i-copy'));
pwRow.appendChild(copyBtn);
// Open URL — only when the site looks like a real http(s) target.
const openUrl = entryOpenUrl(e.site);
if (openUrl) {
const openBtn = el('button', {
class: 'icon-btn icon-btn-sm',
title: 'Open ' + openUrl + ' in browser',
on: { click: ev => { ev.stopPropagation(); entryOpenInBrowser(openUrl); } },
});
openBtn.appendChild(icon('i-globe'));
pwRow.appendChild(openBtn);
}
card.appendChild(pwRow);
}
// meta chips: folder + first 2 tags. "All" is the default "uncategorized"
// bucket and shouldn't be shown as a chip (visually duplicates "All items").
const meta = el('div', { class: 'entry-meta' });
if (e.folder && e.folder !== 'All') {
const fm = folderMetaFor(e.folder);
const chip = el('span', { class: 'entry-chip is-folder' });
const ic = icon(fm.icon || 'i-folder');
if (fm.color) ic.style.color = fm.color;
chip.appendChild(ic);
chip.appendChild(el('span', null, e.folder));
meta.appendChild(chip);
}
parseTags(e.tags).slice(0, 3).forEach(t => {
const chip = el('span', { class: 'entry-chip' });
chip.appendChild(icon('i-tag'));
chip.appendChild(el('span', null, t));
meta.appendChild(chip);
});
// HIBP pwned badge — only shown when the user enabled HIBP and the
// background scan completed with count > 0 for this entry.
const pwnedCount = state.hibpResults.get(e.id);
if (state.hibpEnabled && pwnedCount && pwnedCount > 0) {
const chip = el('span', {
class: 'entry-chip is-pwned',
title: 'This password appeared in ' + pwnedCount.toLocaleString() +
' known data breaches. Consider changing it.',
});
chip.appendChild(icon('i-alert'));
chip.appendChild(el('span', null, 'Pwned'));
meta.appendChild(chip);
}
// Aged password — flagged when the user-configured expiry window
// has elapsed since the password was last changed. Falls back to
// updated_at / created_at for legacy rows.
if (isPasswordAged(e)) {
const days = passwordAgeDays(e);
const chip = el('span', {
class: 'entry-chip is-aged',
title: 'Password unchanged for ' + days + ' days — time to rotate.',
});
chip.appendChild(icon('i-alert'));
chip.appendChild(el('span', null, 'Aged'));
meta.appendChild(chip);
}
// 2FA indicator — entry has a TOTP secret configured. Server returns
// null for both fields when none; truthy = configured (the actual
// secret stays encrypted until the user opens the slide-over).
if (e.totp_secret && e.totp_iv) {
const chip = el('span', {
class: 'entry-chip is-2fa',
title: 'Two-factor authentication (TOTP) configured',
});
chip.appendChild(icon('i-lock'));
chip.appendChild(el('span', null, '2FA'));
meta.appendChild(chip);
}
card.appendChild(meta);
return card;
}
// ============================================================
// TABLE VIEW
// ============================================================
// Dense, spreadsheet-like layout for users who manage many entries.
// Sortable headers — clicking a column header sets state.sortBy/sortDir
// (with toggle on the active column) and re-renders. Same source of
// truth as the Settings dropdown — both stay in sync.
// Columns built dynamically — the Site column tracks the user's
// "Show site under display name" preference so card view and table view
// stay consistent (default: site hidden, can be re-enabled from Settings).
// Keys that the column-picker can't toggle off — the table breaks
// visually or behaviourally without them.
const TABLE_PINNED_COLS = new Set(['check', 'name', 'actions']);
// Optional columns offered in the picker. Order = picker order.
const TABLE_TOGGLEABLE_COLS = [
{ key: 'site', label: 'Site' },
{ key: 'user', label: 'Username' },
{ key: 'folder', label: 'Folder' },
{ key: 'updated', label: 'Updated' },
];
function getTableColumns() {
const hidden = new Set(state.tableColsHidden || []);
// Default behaviour: Site stays hidden unless the user opts in via
// the column picker — matches the long-standing "Show site under
// display name" behaviour while still letting the picker reveal it.
if (!state.showSiteOnCards && !hidden.has('site')) hidden.add('site');
const cols = [
{ key: 'check', label: '', sortKey: null },
{ key: 'name', label: 'Name', sortKey: 'name' },
{ key: 'site', label: 'Site', sortKey: 'site' },
{ key: 'user', label: 'Username', sortKey: null }, // no sort: derived/varies
{ key: 'folder', label: 'Folder', sortKey: 'folder' },
{ key: 'updated', label: 'Updated', sortKey: 'updated' },
{ key: 'actions', label: '', sortKey: null },
];
return cols.filter(c => !hidden.has(c.key) || TABLE_PINNED_COLS.has(c.key));
}
function renderTableColsMenu() {
const menu = document.getElementById('colsMenu');
if (!menu) return;
// Visible columns = those actually rendered by getTableColumns().
// Using that set keeps the picker in sync with the legacy
// showSiteOnCards setting (which forces Site off by default).
const visible = new Set(getTableColumns().map(c => c.key));
menu.innerHTML = '';
TABLE_TOGGLEABLE_COLS.forEach(c => {
const item = el('label', { class: 'cols-menu-item' });
const cb = el('input', { type: 'checkbox' });
cb.checked = visible.has(c.key);
cb.addEventListener('change', () => {
// Site has a legacy second source of truth (showSiteOnCards in
// Settings). The picker now owns it: a check enables the
// setting + clears the hidden flag, an uncheck adds it to hidden.
if (c.key === 'site') {
state.showSiteOnCards = cb.checked;
localStorage.setItem('showSiteOnCards', cb.checked ? '1' : '0');
}
const next = new Set(state.tableColsHidden || []);
if (cb.checked) next.delete(c.key);
else next.add(c.key);
state.tableColsHidden = Array.from(next);
localStorage.setItem('tableColsHidden',
JSON.stringify(state.tableColsHidden));
saveServerSettings();
renderGrid();
renderTableColsMenu();
});
item.appendChild(cb);
item.appendChild(el('span', null, c.label));
menu.appendChild(item);
});
}
function applyColsWrapVisibility() {
const wrap = document.getElementById('colsWrap');
if (!wrap) return;
wrap.style.display = (state.viewMode === 'table') ? '' : 'none';
}
function renderPagination(total, totalPages) {
const wrap = el('div', { class: 'pagination' });
const start = (state.currentPage - 1) * state.pageSize + 1;
const end = Math.min(start + state.pageSize - 1, total);
wrap.appendChild(el('span', { class: 'pagination-info' },
start + '' + end + ' of ' + total));
function pageBtn(label, page, opts) {
opts = opts || {};
const b = el('button', {
class: 'pagination-btn'
+ (opts.active ? ' is-active' : '')
+ (opts.disabled ? ' is-disabled' : ''),
type: 'button',
on: { click: () => {
if (opts.disabled || opts.active) return;
state.currentPage = page;
render();
} },
}, String(label));
return b;
}
wrap.appendChild(pageBtn(' Prev', state.currentPage - 1,
{ disabled: state.currentPage <= 1 }));
const pages = computePageList(state.currentPage, totalPages);
pages.forEach(p => {
if (p === '…') wrap.appendChild(el('span', { class: 'pagination-ellipsis' }, '…'));
else wrap.appendChild(pageBtn(p, p, { active: p === state.currentPage }));
});
wrap.appendChild(pageBtn('Next ', state.currentPage + 1,
{ disabled: state.currentPage >= totalPages }));
const sizeSel = el('select', {
class: 'pagination-size',
on: { change: ev => {
state.pageSize = parseInt(ev.target.value);
state.currentPage = 1;
localStorage.setItem('pageSize', String(state.pageSize));
saveServerSettings();
render();
} },
});
[10, 25, 50, 100].forEach(n => {
const opt = el('option', { value: String(n) }, String(n) + ' / page');
if (n === state.pageSize) opt.selected = true;
sizeSel.appendChild(opt);
});
wrap.appendChild(sizeSel);
// Inline sort dropdown — same options as Settings → Appearance "Sort
// entries by" but accessible without opening the settings panel.
const sortSel = el('select', {
class: 'pagination-size pagination-sort',
on: { change: ev => {
const [by, dir] = ev.target.value.split(':');
state.sortBy = by;
state.sortDir = dir;
state.currentPage = 1;
localStorage.setItem('sortBy', state.sortBy);
localStorage.setItem('sortDir', state.sortDir);
saveServerSettings();
render();
} },
});
const SORT_OPTIONS = [
['name:asc', 'Name A → Z'],
['name:desc', 'Name Z → A'],
['updated:desc', 'Recently updated'],
['updated:asc', 'Oldest updated'],
['created:desc', 'Recently created'],
['created:asc', 'Oldest created'],
];
const cur = state.sortBy + ':' + state.sortDir;
SORT_OPTIONS.forEach(([v, label]) => {
const opt = el('option', { value: v }, label);
if (v === cur) opt.selected = true;
sortSel.appendChild(opt);
});
wrap.appendChild(sortSel);
return wrap;
}
function computePageList(current, total) {
if (total <= 7) {
const arr = [];
for (let i = 1; i <= total; i++) arr.push(i);
return arr;
}
const pages = [1];
if (current > 3) pages.push('…');
const from = Math.max(2, current - 1);
const to = Math.min(total - 1, current + 1);
for (let i = from; i <= to; i++) pages.push(i);
if (current < total - 2) pages.push('…');
pages.push(total);
return pages;
}
function renderTable(list) {
const columns = getTableColumns();
const table = el('table', { class: 'entry-table' });
// Header row with click-to-sort
const thead = el('thead');
const headerRow = el('tr');
columns.forEach(col => {
const th = el('th', { 'data-col': col.key });
if (col.sortKey) {
th.classList.add('is-sortable');
const isActive = state.sortBy === col.sortKey;
if (isActive) th.classList.add('is-active');
th.appendChild(el('span', null, col.label));
// Arrow indicator only on the active column.
if (isActive) {
th.appendChild(el('span', { class: 'sort-arrow' },
state.sortDir === 'asc' ? '↑' : '↓'));
}
th.addEventListener('click', () => {
if (state.sortBy === col.sortKey) {
// Same column → flip direction
state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc';
} else {
state.sortBy = col.sortKey;
// Sensible default direction per field
state.sortDir = (col.sortKey === 'updated' ||
col.sortKey === 'created') ? 'desc' : 'asc';
}
localStorage.setItem('sortBy', state.sortBy);
localStorage.setItem('sortDir', state.sortDir);
// Keep the Settings dropdown in sync if it's currently displayed.
const sel = $('#settingSort');
if (sel) sel.value = state.sortBy + ':' + state.sortDir;
render();
saveServerSettings();
});
} else {
th.appendChild(el('span', null, col.label));
}
headerRow.appendChild(th);
});
thead.appendChild(headerRow);
table.appendChild(thead);
// Body rows
const tbody = el('tbody');
list.forEach(e => tbody.appendChild(renderTableRow(e)));
table.appendChild(tbody);
return table;
}
function renderTableRow(e) {
const checked = state.checked.has(e.id);
const inTrash = state.view === 'trash';
const tr = el('tr', {
class: 'entry-row'
+ (state.selectedId === e.id ? ' is-selected' : '')
+ (checked ? ' is-checked' : ''),
'data-id': String(e.id),
draggable: !inTrash ? 'true' : 'false',
on: { click: ev => handleCardClick(ev, e, inTrash) },
});
if (!inTrash) {
tr.addEventListener('dragstart', ev => {
let ids;
if (state.checked.size > 1 && state.checked.has(e.id)) {
ids = Array.from(state.checked).join(',');
} else {
ids = String(e.id);
}
ev.dataTransfer.setData('text/plain', ids);
ev.dataTransfer.effectAllowed = 'move';
});
}
// Cells are emitted in EXACTLY the same order as getTableColumns()
// returns headers, otherwise THs and TDs drift apart and clicks land
// on the wrong column. Switch by col.key so add/remove of a column
// affects header + body in one place.
getTableColumns().forEach(col => {
let td;
switch (col.key) {
case 'check': {
td = el('td', { class: 'col-check' });
// Standalone variant — not nested inside an avatar, so it
// needs its own explicit dimensions. .entry-check-static
// overrides the absolute/inset:0 positioning used in the
// card-view (where the box gets its size from its avatar
// parent).
const checkbox = el('button', {
class: 'entry-check entry-check-static' + (checked ? ' is-checked' : ''),
type: 'button',
title: checked ? 'Deselect' : 'Select',
on: { click: ev => { ev.stopPropagation(); toggleChecked(e.id); } },
});
if (checked) checkbox.appendChild(el('span', null, '✓'));
td.appendChild(checkbox);
break;
}
case 'name': {
td = el('td', { class: 'col-name' });
const displayName = entryDisplayName(e);
const avatar = el('span', { class: 'entry-avatar entry-avatar-sm' });
if (e.icon_b64) {
const img = el('img', { src: e.icon_b64, alt: '',
class: 'entry-avatar-img' });
img.addEventListener('error', () => {
avatar.innerHTML = '';
avatar.textContent = initials(displayName);
});
avatar.appendChild(img);
} else {
avatar.textContent = initials(displayName);
}
td.appendChild(avatar);
const nameWrap = el('span', { class: 'cell-name-wrap' });
nameWrap.appendChild(el('b', null, entryDisplayName(e)));
if (e.kind === 'note') {
const tplLbl = templateLabel(e.template);
nameWrap.appendChild(el('span',
{ class: 'kind-badge', title: tplLbl || 'Secure note' },
(tplLbl || 'note').toLowerCase()));
}
if (e.favorite) nameWrap.appendChild(el('span', { class: 'fav-dot', title: 'Favorite' }, '★'));
td.appendChild(nameWrap);
break;
}
case 'site':
td = el('td', { class: 'col-site' },
e.kind === 'note' ? '' : (e.site || ''));
break;
case 'user': {
td = el('td', { class: 'col-user' });
if (e.kind === 'note') {
// The badge in the Name column already advertises the
// type (NOTE / SERVER / SSH KEY / …), so the username
// column stays empty here — repeating "Server" twice
// adds noise without information.
td.appendChild(el('span', { class: 'col-user-note' }, ''));
} else {
td.appendChild(el('span', null, displayUsername(e.username)));
if (e.username) {
const btn = el('button', {
class: 'icon-btn icon-btn-sm',
title: 'Copy username',
on: { click: ev => { ev.stopPropagation(); copyUsername(e); } },
});
btn.appendChild(icon('i-copy'));
td.appendChild(btn);
}
}
break;
}
case 'folder':
td = el('td', { class: 'col-folder' },
(e.folder && e.folder !== 'All') ? e.folder : '');
break;
case 'updated':
td = el('td', { class: 'col-updated' },
formatDateShort(e.updated_at));
break;
case 'actions': {
td = el('td', { class: 'col-actions' });
const pwBtn = el('button', {
class: 'icon-btn icon-btn-sm',
title: e.kind === 'note' ? 'Copy note content' : 'Copy password',
on: { click: ev => { ev.stopPropagation(); copyPassword(e); } },
});
pwBtn.appendChild(icon('i-copy'));
td.appendChild(pwBtn);
// Open URL — only for login entries whose site looks like
// a real http(s) target. Same gate as the card view.
const openUrl = entryOpenUrl(e.site);
if (e.kind !== 'note' && openUrl) {
const openBtn = el('button', {
class: 'icon-btn icon-btn-sm',
title: 'Open in browser',
on: { click: ev => { ev.stopPropagation(); entryOpenInBrowser(openUrl); } },
});
openBtn.appendChild(icon('i-globe'));
td.appendChild(openBtn);
}
td.appendChild(buildKebabMenu(e));
break;
}
}
tr.appendChild(td);
});
return tr;
}
// Compact date for the table column. ISO string in → "2026-05-23" out.
// Avoids per-locale parsing surprises (server uses ISO already).
function formatDateShort(iso) {
if (!iso) return '';
return iso.slice(0, 10);
}
// ============================================================
// MULTI-SELECTION + BATCH ACTIONS
// ============================================================
//
// Selection patterns (no rubber-band marquee — removed 2026-05-24, value
// for a password manager was too low vs the accidental-deselect cost):
// - Click the checkbox overlay on a card → toggle just that entry
// - Click a card body (when ≥1 is already checked) → toggle (sticky)
// - Ctrl+click row/card → toggle (always, no need for sticky mode)
// - Shift+click row/card → range select from last anchor
// - Ctrl+A (when not typing) → select every visible entry
// - Escape (when nothing else to dismiss) → clear selection
let selectionAnchor = null; // last single-clicked card, used for shift+click range
function toggleChecked(id) {
if (state.checked.has(id)) state.checked.delete(id);
else state.checked.add(id);
renderGrid();
}
function handleCardClick(ev, entry, inTrash) {
// Ctrl/Cmd+Click: toggle this card in selection
if (ev.ctrlKey || ev.metaKey) {
toggleChecked(entry.id);
selectionAnchor = entry.id;
return;
}
// Shift+Click: select range from anchor to this card
if (ev.shiftKey && selectionAnchor !== null) {
const list = filteredEntries();
const a = list.findIndex(x => x.id === selectionAnchor);
const b = list.findIndex(x => x.id === entry.id);
if (a >= 0 && b >= 0) {
const lo = Math.min(a, b), hi = Math.max(a, b);
for (let i = lo; i <= hi; i++) state.checked.add(list[i].id);
renderGrid();
return;
}
}
// If any cards are already checked, a plain click toggles (sticky multi-select)
if (state.checked.size > 0) {
toggleChecked(entry.id);
selectionAnchor = entry.id;
return;
}
// Default: open slide-over (or trash actions)
selectionAnchor = entry.id;
if (inTrash) openTrashActions(entry.id);
else openSlideOver(entry.id);
}
function clearChecked() {
state.checked.clear();
renderGrid();
}
async function batchMoveToFolder(folder) {
const ids = Array.from(state.checked);
if (!ids.length) return;
for (const id of ids) {
const e = state.entries.find(x => x.id === id);
if (!e || e.folder === folder) continue;
try {
await api('/entries/' + id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
// Re-ship the full payload — partial PUT would wipe
// TOTP / custom_fields / kind / template (see also
// moveEntryToFolder and the "places à toucher" list
// in CLAUDE.md).
site: e.site,
title: e.title || '',
username: e.username,
encrypted_password: e.encrypted_password,
iv: e.iv,
folder,
tags: e.tags || '',
kind: e.kind || 'login',
totp_secret: e.totp_secret || '',
totp_iv: e.totp_iv || '',
custom_fields: e.custom_fields || '',
custom_fields_iv: e.custom_fields_iv || '',
}),
});
e.folder = folder;
} catch (err) { /* ignore individual failures */ }
}
toast(ids.length + ' moved to ' + folder);
clearChecked();
}
async function batchAddTag(tag) {
tag = (tag || '').trim();
if (!tag) return;
const ids = Array.from(state.checked);
if (!ids.length) return;
for (const id of ids) {
const e = state.entries.find(x => x.id === id);
if (!e) continue;
const tags = parseTags(e.tags);
if (tags.includes(tag)) continue;
tags.push(tag);
try {
await api('/entries/' + id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
site: e.site,
title: e.title || '',
username: e.username,
encrypted_password: e.encrypted_password,
iv: e.iv,
folder: e.folder,
tags: tags.join(','),
kind: e.kind || 'login',
totp_secret: e.totp_secret || '',
totp_iv: e.totp_iv || '',
custom_fields: e.custom_fields || '',
custom_fields_iv: e.custom_fields_iv || '',
}),
});
e.tags = tags.join(',');
} catch (err) {}
}
toast('Tagged ' + ids.length + ' as "' + tag + '"');
clearChecked();
}
async function batchDelete() {
const ids = Array.from(state.checked);
if (!ids.length) return;
const ok = await confirmDialog({
title: 'Move to trash',
message: '<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();
state.checked.clear();
render(); // full render so sidebar counts (Trash, folders, tags) refresh
}
async function batchRestore() {
const ids = Array.from(state.checked);
if (!ids.length) return;
for (const id of ids) {
try {
await api('/entries/' + id + '/restore', { method: 'POST', headers: authHeaders() });
} catch (err) {}
}
toast(ids.length + ' restored');
await loadEntries();
await loadTrash();
state.checked.clear();
render();
}
async function batchPermDelete() {
const ids = Array.from(state.checked);
if (!ids.length) return;
const ok = await confirmDialog({
title: 'Delete forever',
message: '<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();
state.checked.clear();
render();
}
function renderBatchBar() {
const existing = $('#batchBar');
if (existing) existing.remove();
if (state.checked.size === 0) return;
const inTrash = state.view === 'trash';
const bar = el('div', { class: 'batch-bar', id: 'batchBar' });
bar.appendChild(el('span', { class: 'batch-bar-count' }, state.checked.size + ' selected'));
if (inTrash) {
// Trash view: Restore | Delete forever
bar.appendChild(el('button', {
class: 'btn btn-ghost btn-sm',
on: { click: batchRestore },
}, withIcon('i-rotate-ccw', 'Restore')));
bar.appendChild(el('button', {
class: 'btn btn-ghost btn-sm',
style: 'color:var(--danger)',
on: { click: batchPermDelete },
}, withIcon('i-trash', 'Delete forever')));
} else {
// Normal view: Move to folder | Add tag | Delete (soft)
const moveSel = el('select');
moveSel.appendChild(el('option', { value: '' }, 'Move to folder…'));
state.folders.forEach(f => moveSel.appendChild(el('option', { value: f.name }, folderLabel(f.name))));
moveSel.addEventListener('change', () => {
if (moveSel.value) batchMoveToFolder(moveSel.value);
});
bar.appendChild(moveSel);
bar.appendChild(el('button', {
class: 'btn btn-ghost btn-sm',
on: { click: async () => {
const t = await promptDialog({
title: 'Add tag',
message: 'Add a tag to <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;
// Pre-fill bundles for common entry shapes. Picked from the new-entry
// chevron menu. Each template seeds title + customFields + (for notes)
// the body — the user fills in the actual values. Crypto pipeline is
// unchanged: the fields go through the regular AES-GCM custom_fields
// blob like any user-added field.
// Helper lists for template dropdowns. Built once so each template
// reuses the same array.
const CARD_BRANDS = ['Visa', 'MasterCard', 'American Express',
'Discover', 'JCB', 'Diners Club', 'UnionPay', 'Maestro', 'Other'];
const MONTHS_01_12 = ['01','02','03','04','05','06','07','08','09','10','11','12'];
const NEXT_15_YEARS = (() => {
const out = [], y = new Date().getFullYear();
for (let i = 0; i < 15; i++) out.push(String(y + i));
return out;
})();
const SSH_PROTOCOLS = ['SSH', 'SFTP', 'SCP', 'rsync over SSH'];
const SERVER_PROTOS = ['SSH', 'HTTP', 'HTTPS', 'FTP', 'SFTP', 'RDP',
'VNC', 'Telnet', 'PostgreSQL', 'MySQL', 'MongoDB', 'Redis', 'Other'];
const ENTRY_TEMPLATES = {
'credit-card': {
// Note-kind: site/password aren't required for cards. Card
// number goes in a labelled field, not the password slot.
kind: 'note',
title: 'Credit card',
body: '',
customFields: [
{ label: 'Cardholder name', value: '', is_secret: false },
{ label: 'Card number', value: '', is_secret: false },
{ label: 'Brand', value: '', is_secret: false, options: CARD_BRANDS },
{ label: 'Expiration month',value: '', is_secret: false, options: MONTHS_01_12 },
{ label: 'Expiration year', value: '', is_secret: false, options: NEXT_15_YEARS },
{ label: 'Security code (CVV)', value: '', is_secret: true },
{ label: 'PIN', value: '', is_secret: true },
],
},
'ssh-key': {
kind: 'note',
title: 'SSH key',
body: '',
customFields: [
{ label: 'Key name', value: '', is_secret: false },
{ label: 'Fingerprint', value: '', is_secret: false },
{ label: 'Hosts', value: '', is_secret: false },
{ label: 'Protocol', value: '', is_secret: false, options: SSH_PROTOCOLS },
{ label: 'Passphrase', value: '', is_secret: true },
{ label: 'Private key', value: '', is_secret: true },
],
},
'server': {
kind: 'note',
title: 'Server',
body: '',
customFields: [
{ label: 'Hostname / IP', value: '', is_secret: false },
{ label: 'Port', value: '', is_secret: false },
{ label: 'Protocol', value: '', is_secret: false, options: SERVER_PROTOS },
{ label: 'Root password', value: '', is_secret: true },
],
},
'recovery-codes': {
kind: 'note',
title: 'Recovery codes',
body: 'Paste your one-time recovery codes here, one per line.\n\n',
customFields: [
{ label: 'Service', value: '', is_secret: false },
{ label: 'Account', value: '', is_secret: false },
],
},
};
// Open the slideover for either an existing entry (id = number) or a
// brand-new one (id = null). opts: { presetTitle, presetSite, template }
// pre-fill the corresponding fields for the new-entry path.
async function openSlideOver(id, opts) {
opts = opts || {};
const isNew = (id == null);
const e = isNew ? null : state.entries.find(x => x.id === id);
// Switching to another entry while the current edit has unsaved
// changes would silently drop them — gate on the same confirm dialog
// used by close paths. Only fires when the panel is already open AND
// we're actually moving to a different target.
const switching = $('#slideover').classList.contains('is-open') &&
soState && (soState.id !== id) &&
!(isNew && soState.id == null && soState.id === id);
if (switching && state.confirmOnUnsaved && isSoDirty()) {
const ok = await confirmDialog({
title: 'Discard unsaved changes?',
message: 'You have edits in the open entry that haven\'t been saved. ' +
'Switch to the other entry anyway?' +
'<p style="margin:14px 0 0;font-size:11px;color:var(--text-faint);line-height:1.5">' +
'Tip: turn this prompt off in <b>Settings → Appearance → Confirm before closing unsaved edits</b>.' +
'</p>',
okText: 'Discard',
danger: true,
});
if (!ok) return;
}
if (!isNew && !e) return;
state.selectedId = isNew ? null : id;
if (!isNew) touchEntry(id);
// Templates: resolve a preset bundle of {kind, title, customFields,
// body} and merge it into opts so the rest of this function sees a
// pre-seeded new-entry payload. Only applies to fresh entries.
const tplId = isNew ? opts.template : null;
const tpl = tplId ? ENTRY_TEMPLATES[tplId] : null;
if (tpl) {
opts = Object.assign({}, opts, {
kind: tpl.kind || opts.kind || 'login',
presetTitle: tpl.title || opts.presetTitle,
presetBody: tpl.body || '',
presetFields: tpl.customFields || [],
presetTemplate: tplId,
});
}
// Resolve kind early: opts.kind for new entries (login default), entry's
// own kind for existing. Drives the field layout below.
const kind = isNew ? (opts.kind || 'login') : (e.kind || 'login');
const isNote = (kind === 'note');
// Title prefix reflects mode + kind (note vs login).
const titleEl = $('#slideoverTitle');
const kindLabel = isNote ? 'note' : 'entry';
titleEl.textContent = isNew
? ('+ New ' + kindLabel)
: ('Edit · ' + entryDisplayName(e));
titleEl.classList.toggle('is-new-mode', isNew);
titleEl.classList.toggle('is-edit-mode', !isNew);
const body = $('#slideoverBody');
body.innerHTML = '';
let plain = isNew ? (opts.presetBody || '') : '';
let plainTotp = '';
let plainCustom = isNew ? (opts.presetFields || []).map(f => {
const out = { label: f.label || '', value: f.value || '', is_secret: !!f.is_secret };
if (Array.isArray(f.options) && f.options.length > 0)
out.options = f.options.slice();
return out;
}) : [];
if (!isNew) {
plain = await decryptPwd(e.encrypted_password, e.iv);
// Decrypt TOTP secret if present. Empty string when no TOTP configured
// OR when decryption fails (orphan ciphertext, key mismatch, etc.).
if (e.totp_secret && e.totp_iv) {
plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv);
if (plainTotp === '[ERROR]') plainTotp = '';
}
// Custom fields: same crypto pipeline, but the plaintext is a JSON
// array of {label, value, is_secret}.
if (e.custom_fields && e.custom_fields_iv) {
plainCustom = await decryptCustomFields(e.custom_fields, e.custom_fields_iv);
}
}
// Track original values so we can detect "dirty". For new entries the
// originals are empty strings — typing anything triggers the Save button.
const defaultFolder = state.view.startsWith('folder:')
? state.view.slice(7) : 'All';
soState = {
id: isNew ? null : e.id,
kind: kind,
original: {
site: isNew ? (opts.presetSite || '') : (e.site || ''),
title: isNew ? (opts.presetTitle || '') : (e.title || ''),
username: isNew ? '' : (e.username || ''),
password: plain, // for notes this holds the note body
folder: isNew ? defaultFolder : (e.folder || 'All'),
tags: isNew ? '' : parseTags(e.tags).join(','),
totp: plainTotp,
},
tags: isNew ? [] : parseTags(e.tags),
// Working copy of the custom-fields array — mutated in place by
// buildCustomFieldRow handlers. The serialized JSON of this array
// at Save time is what gets encrypted into custom_fields/iv.
// customFields + originalCustomJson are assigned just after soState
// is constructed (see below) so both sides of the dirty check use
// the SAME normalized shape — comparing raw plainCustom against the
// mapped working copy would falsely fire dirty on entries whose
// stored blob carries extra/legacy keys.
customFields: [],
originalCustomJson: '[]',
originalEncrypted: isNew ? null : e.encrypted_password,
originalIV: isNew ? null : e.iv,
originalTotpEncrypted: isNew ? null : e.totp_secret,
originalTotpIV: isNew ? null : e.totp_iv,
originalCustomEncrypted: isNew ? null : (e.custom_fields || null),
originalCustomIV: isNew ? null : (e.custom_fields_iv || null),
// Template identifier — propagated from the entry on edit, or
// injected by the template picker on new. Sent back to the server
// on Save so the card/table can label it correctly.
template: isNew ? (opts.presetTemplate || '') : (e.template || ''),
// Attachments staged BEFORE the entry exists server-side. Empty on
// edit (existing attachments are fetched live via the dedicated
// /entries/:id/attachments endpoint). Flushed by soSave after the
// POST returns the new entry id.
pendingAttachments: [],
};
// Normalize the custom-fields array ONCE — the working copy and the
// dirty-check baseline must share the same shape, otherwise stripped
// legacy keys (empty options[], stray metadata) make the JSON diverge
// on open and the entry looks dirty without any user input.
soState.customFields = plainCustom.map(f => {
const out = {
label: f.label || '', value: f.value || '',
is_secret: !!f.is_secret,
};
if (Array.isArray(f.options) && f.options.length > 0)
out.options = f.options.slice();
return out;
});
soState.originalCustomJson = JSON.stringify(soState.customFields);
if (isNote) {
// Notes: minimal layout — name + multiline body + folder + tags.
// No icon (covered by sidebar icon), no site/user/totp.
body.appendChild(soEditableField('Title', 'soTitle', soState.original.title));
body.appendChild(soNoteBodyField(plain));
const hist = soHistoryButton();
if (hist) body.appendChild(hist);
body.appendChild(soCustomFieldsField());
// For new entries the attachments are staged in soState.pendingAttachments
// and uploaded after the entry has been created (we need its id).
body.appendChild(soAttachmentsField(isNew ? null : id));
body.appendChild(soFolderField(soState.original.folder));
body.appendChild(soTagsField());
} else {
// Login (existing layout).
const eForIcon = isNew
? { id: null, icon_b64: null, site: soState.original.site,
title: soState.original.title }
: e;
body.appendChild(soIconField(eForIcon));
body.appendChild(soEditableField('Display name', 'soTitle', soState.original.title));
body.appendChild(soEditableField('Site', 'soSite', soState.original.site));
body.appendChild(soEditableField('Username', 'soUsername', soState.original.username));
body.appendChild(soPasswordField(plain));
const histLogin = soHistoryButton();
if (histLogin) body.appendChild(histLogin);
body.appendChild(soTotpField(plainTotp));
body.appendChild(soCustomFieldsField());
body.appendChild(soAttachmentsField(isNew ? null : id));
body.appendChild(soFolderField(soState.original.folder));
body.appendChild(soTagsField());
}
// Action row — Save button is hidden until dirty. No Delete here:
// the quick-X on each card handles deletion (avoids duplication).
const actions = el('div', { class: 'slideover-actions' });
const saveBtn = el('button', {
class: 'btn btn-primary',
id: 'soSaveBtn',
style: 'display:none',
on: { click: soSave },
}, withIcon('i-check', 'Save'));
actions.appendChild(saveBtn);
body.appendChild(actions);
// Wire change detection (selectors absent for notes are ignored).
['#soTitle', '#soSite', '#soUsername', '#soPassword', '#soFolder',
'#soNoteBody'].forEach(sel => {
const el = $(sel); if (el) el.addEventListener('input', soDirtyCheck);
if (el) el.addEventListener('change', soDirtyCheck);
});
$('#slideover').classList.add('is-open');
// New entries: Save visible from the start so the action is obvious,
// and focus the most relevant pivot field (Title for notes, Site for logins).
if (isNew) {
const save = document.getElementById('soSaveBtn');
if (save) save.style.display = '';
setTimeout(() => {
const f = isNote
? document.getElementById('soTitle')
: document.getElementById('soSite');
if (f) f.focus();
}, 50);
} else {
// Edit mode: focus the Title input so the user can start editing
// immediately (especially when opened via Enter from j/k nav).
// Defer so the slideover transition finishes first — otherwise
// the browser may steal focus back during the animation.
setTimeout(() => {
const f = document.getElementById('soTitle');
if (f) { f.focus(); f.select(); }
}, 80);
}
renderGrid();
}
// "Show history" launcher — placed below the password field for logins,
// below the note body for notes. Reads soState.id so it works in edit
// mode only (new entries have no history yet).
function soHistoryButton() {
if (!soState || soState.id == null) return null;
const wrap = el('div', { class: 'slideover-field so-history-wrap' });
const btn = el('button', {
class: 'btn btn-ghost btn-xs', type: 'button',
});
btn.appendChild(icon('i-rotate-ccw'));
btn.appendChild(document.createTextNode(' Show previous versions'));
btn.addEventListener('click', () => openHistoryModal(soState.id));
wrap.appendChild(btn);
return wrap;
}
// Custom fields editor — dynamic list of {label, value, is_secret} rows.
// Mutates soState.customFields in place; on change calls soDirtyCheck so
// the Save button surfaces. The whole array is re-encrypted on Save (no
// per-row IVs to keep simple).
function soCustomFieldsField() {
const wrap = el('div', { class: 'slideover-field so-custom-wrap' });
wrap.appendChild(el('div', { class: 'slideover-field-label' },
'Custom fields'));
const list = el('div', { class: 'so-custom-list', id: 'soCustomList' });
wrap.appendChild(list);
function renderRows() {
list.innerHTML = '';
(soState.customFields || []).forEach((f, idx) => {
list.appendChild(buildCustomFieldRow(f, idx, renderRows));
});
}
renderRows();
const addWrap = el('div', { class: 'so-custom-add' });
const addBtn = el('button', { class: 'btn btn-ghost btn-sm', type: 'button' });
addBtn.appendChild(icon('i-plus'));
addBtn.appendChild(document.createTextNode(' Add field'));
addBtn.addEventListener('click', ev => {
// stopPropagation — the document-level "click outside slideover"
// listener would otherwise see this click as outside (the button
// isn't in any of the allow-listed containers) and close the panel.
// Same pattern as #newEntryBtn, health-dashboard "Fix", etc.
ev.stopPropagation();
soState.customFields = soState.customFields || [];
soState.customFields.push({ label: '', value: '', is_secret: false });
renderRows();
soDirtyCheck();
setTimeout(() => {
const inputs = list.querySelectorAll('.so-custom-label');
const last = inputs[inputs.length - 1];
if (last) last.focus();
}, 0);
});
addWrap.appendChild(addBtn);
wrap.appendChild(addWrap);
return wrap;
}
function buildCustomFieldRow(field, idx, rerender) {
const row = el('div', { class: 'so-custom-row' });
const labelInput = el('input', {
type: 'text', class: 'so-input so-custom-label',
placeholder: 'Label (e.g. PIN, Account #)',
autocomplete: 'off', spellcheck: 'false',
});
labelInput.value = field.label || '';
labelInput.addEventListener('input', () => {
field.label = labelInput.value;
soDirtyCheck();
});
// Value field. When the field declares an `options` array (entry
// templates: card brand, expiry year/month, etc.) we wrap the input in
// a CUSTOM editable combobox: an arrow button that drops a menu of ALL
// options (unlike a native <datalist>, which filters to what's typed),
// while the input stays freely typeable for a value not in the list.
// Storage shape unchanged — `field.value` holds the string either way.
let valueInput;
let valueSlot; // what actually goes into the row (input or combo wrap)
const hasOptions = Array.isArray(field.options) && field.options.length > 0;
valueInput = el('input', {
type: field.is_secret ? 'password' : 'text',
class: 'so-input so-custom-value',
placeholder: hasOptions ? 'Pick or type…' : 'Value',
autocomplete: field.is_secret ? 'new-password' : 'off',
spellcheck: 'false',
});
valueInput.value = field.value || '';
valueInput.addEventListener('input', () => {
field.value = valueInput.value;
soDirtyCheck();
});
if (hasOptions && !field.is_secret) {
const combo = el('div', { class: 'so-combo' });
valueInput.classList.add('so-combo-input');
const arrow = el('button', {
class: 'so-combo-arrow', type: 'button', tabindex: '-1',
title: 'Show options',
});
arrow.appendChild(icon('i-chevron-down'));
const menu = el('div', { class: 'so-combo-menu is-hidden' });
field.options.forEach(opt => {
const item = el('div', { class: 'so-combo-item' }, opt);
// mousedown (not click) + preventDefault so the input doesn't
// blur-close the menu before we read the choice.
item.addEventListener('mousedown', ev => {
ev.preventDefault();
ev.stopPropagation();
valueInput.value = opt;
field.value = opt;
soDirtyCheck();
menu.classList.add('is-hidden');
});
menu.appendChild(item);
});
arrow.addEventListener('click', ev => {
ev.stopPropagation();
// Close any other open combo first, then toggle this one.
document.querySelectorAll('.so-combo-menu:not(.is-hidden)')
.forEach(m => { if (m !== menu) m.classList.add('is-hidden'); });
menu.classList.toggle('is-hidden');
});
combo.appendChild(valueInput);
combo.appendChild(arrow);
combo.appendChild(menu);
valueSlot = combo;
} else {
valueSlot = valueInput;
}
// Reveal eye — only meaningful for secret fields.
const eye = el('button', { class: 'icon-btn icon-btn-sm', type: 'button',
title: 'Show / hide' });
eye.appendChild(icon('i-eye'));
// All button handlers below stopPropagation — see CLAUDE.md
// "Click-outside-slideover bug" for why.
eye.addEventListener('click', ev => {
ev.stopPropagation();
if (!field.is_secret) return;
// No-op on <select> — it has no type=password equivalent.
if (valueInput.tagName !== 'INPUT') return;
valueInput.type = valueInput.type === 'password' ? 'text' : 'password';
});
if (!field.is_secret || valueInput.tagName !== 'INPUT')
eye.style.visibility = 'hidden';
// Secret-flag toggle (chip-style) — flips both the storage flag and
// the visible input type.
const secretBtn = el('button', {
class: 'so-custom-secret-toggle' + (field.is_secret ? ' is-on' : ''),
type: 'button',
title: field.is_secret ? 'Secret field — value hidden' : 'Public field',
}, field.is_secret ? '🔒' : '👁');
secretBtn.addEventListener('click', ev => {
ev.stopPropagation();
field.is_secret = !field.is_secret;
soDirtyCheck();
rerender();
});
const copyBtn = el('button', { class: 'icon-btn icon-btn-sm', type: 'button',
title: 'Copy value' });
copyBtn.appendChild(icon('i-copy'));
copyBtn.addEventListener('click', ev => {
ev.stopPropagation();
const v = field.value || '';
if (!v) return;
if (Bridge.active) Bridge.copySecure(v, 30000);
else { try { navigator.clipboard.writeText(v); } catch (_) {} }
toast('Copied · clears in 30s');
});
const delBtn = el('button', { class: 'icon-btn icon-btn-sm', type: 'button',
title: 'Remove field' });
delBtn.appendChild(icon('i-x'));
delBtn.addEventListener('click', ev => {
ev.stopPropagation();
soState.customFields.splice(idx, 1);
rerender();
soDirtyCheck();
});
row.appendChild(labelInput);
row.appendChild(valueSlot);
row.appendChild(eye);
row.appendChild(secretBtn);
row.appendChild(copyBtn);
row.appendChild(delBtn);
return row;
}
// Multiline note body. Maps to soState.original.password and the
// encrypted_password+iv columns (same crypto pipeline as login passwords).
function soNoteBodyField(value) {
const wrap = el('div', { class: 'slideover-field' });
wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Note'));
const ta = el('textarea', {
id: 'soNoteBody',
class: 'so-input so-note-body',
rows: 12,
placeholder: 'Encrypted with your vault key. Nothing leaves your device.',
autocomplete: 'off', autocorrect: 'off', spellcheck: 'false',
});
ta.value = value || '';
wrap.appendChild(ta);
return wrap;
}
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',
// Disable Edge / Chromium "saved form data" history — these
// fields can carry titles, usernames and other identifying
// info that shouldn't end up in the browser's autocomplete
// dropdown (visible via arrow-down on a focused field).
autocomplete: 'off',
autocorrect: 'off',
spellcheck: 'false',
});
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 = 256 * 1024; // matches server-side cap (256 KB)
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 256 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',
autocomplete: 'new-password', spellcheck: 'false',
});
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',
autocomplete: 'new-password', spellcheck: 'false',
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.name }, folderLabel(f.name));
if (f.name === current) opt.selected = true;
sel.appendChild(opt);
});
wrap.appendChild(sel);
return wrap;
}
function soTagsField() {
const wrap = el('div', { class: 'slideover-field', style: 'position:relative' });
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',
});
// Autocomplete dropdown built lazily on focus/input. Pulled from
// allTags() and filtered against what's already selected + what the
// user is typing.
const suggBox = el('div', { class: 'tag-suggest is-hidden', id: 'soTagsSuggest' });
function refreshSuggestions() {
const typed = (input.value || '').trim().toLowerCase();
const matches = allTags()
.filter(t => !soState.tags.includes(t))
.filter(t => typed === '' || t.toLowerCase().includes(typed))
.slice(0, 8);
suggBox.innerHTML = '';
if (matches.length === 0) {
suggBox.classList.add('is-hidden');
return;
}
matches.forEach(t => {
const item = el('button', { type: 'button', class: 'tag-suggest-item' });
item.appendChild(icon('i-tag'));
item.appendChild(el('span', null, t));
// mousedown beats the input's blur so the click registers
// before the dropdown is hidden.
item.addEventListener('mousedown', ev => {
ev.preventDefault();
if (!soState.tags.includes(t)) {
soState.tags.push(t);
renderSoChips();
soDirtyCheck();
}
input.value = '';
refreshSuggestions();
input.focus();
});
suggBox.appendChild(item);
});
suggBox.classList.remove('is-hidden');
}
input.addEventListener('input', refreshSuggestions);
input.addEventListener('focus', refreshSuggestions);
input.addEventListener('blur', () => {
// Delay so a mousedown on a suggestion gets handled first.
setTimeout(() => suggBox.classList.add('is-hidden'), 120);
});
// 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);
wrap.appendChild(suggBox);
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 = '';
refreshSuggestions();
} else if (e.key === 'Backspace' && !input.value && soState.tags.length) {
soState.tags.pop();
renderSoChips();
soDirtyCheck();
refreshSuggestions();
} else if (e.key === 'Escape') {
suggBox.classList.add('is-hidden');
}
});
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);
});
}
// Returns true when the slideover has unsaved user changes. For new
// entries: any non-empty content (text, custom fields, staged
// attachments) counts. For edits: any field that differs from the
// snapshot taken at openSlideOver time.
function isSoDirty() {
if (!soState) return false;
const cur = {
title: ($('#soTitle') || {}).value || '',
site: ($('#soSite') || {}).value || '',
username: ($('#soUsername') || {}).value || '',
password: (($('#soPassword') || $('#soNoteBody')) || {}).value || '',
folder: ($('#soFolder') || {}).value || '',
totp: ($('#soTotpSecret') || {}).value || '',
tags: soState.tags.join(','),
};
const curCustom = JSON.stringify(soState.customFields || []);
if (soState.id == null) {
const orig = soState.original || {};
const staged = (soState.pendingAttachments || []).length > 0;
return staged
|| cur.title !== (orig.title || '')
|| cur.site !== (orig.site || '')
|| cur.username !== (orig.username || '')
|| cur.password !== (orig.password || '')
|| cur.folder !== (orig.folder || 'All')
|| cur.totp !== (orig.totp || '')
|| cur.tags !== ''
|| curCustom !== (soState.originalCustomJson || '[]');
}
return (
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 ||
curCustom !== (soState.originalCustomJson || '[]'));
}
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 btn = $('#soSaveBtn');
if (btn) btn.style.display = isSoDirty() ? '' : '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 kind = soState.kind || 'login';
const isNote = (kind === 'note');
const title = ($('#soTitle') || {}).value || '';
const site = isNote ? '' : ($('#soSite').value.trim());
const user = isNote ? '' : ($('#soUsername').value.trim());
// For notes the textarea body is the encrypted payload (reuses
// encrypted_password/iv column pair).
const pwd = isNote
? (($('#soNoteBody') || {}).value || '')
: ($('#soPassword').value);
const fold = ($('#soFolder') || {}).value || 'All';
const totp = isNote ? '' : (($('#soTotpSecret') || {}).value || '').trim();
if (isNote) {
if (!title.trim()) return toast('Title required', 'error');
// Template-typed notes carry their data in custom_fields, so an
// empty body is fine as long as at least one field has a value.
if (!pwd) {
const hasFieldValue = (soState.customFields || [])
.some(f => (f.value || '').trim() !== '');
if (!(soState.template && hasFieldValue))
return toast('Note body required', 'error');
}
} else {
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;
}
}
// Custom fields: drop rows with an empty label (treat as removed).
// Re-encrypt only when the array actually changed; otherwise reuse the
// stored ciphertext so the row's updated_at doesn't get bumped for nothing.
const cleanCustom = (soState.customFields || [])
.filter(f => (f.label || '').trim() !== '')
.map(f => {
const out = {
label: (f.label || '').trim(),
value: f.value || '',
is_secret: !!f.is_secret,
};
// Keep the dropdown options so the field re-renders as a
// <select> on next open (otherwise it would silently degrade
// to a free-text input after the first save).
if (Array.isArray(f.options) && f.options.length > 0) {
out.options = f.options.slice();
}
return out;
});
let cfEnc = '', cfIv = '';
const curCustomJson = JSON.stringify(cleanCustom);
if (cleanCustom.length === 0) {
// Empty array → send empty strings → server stores NULL.
cfEnc = ''; cfIv = '';
} else if (!isNew && curCustomJson === soState.originalCustomJson &&
soState.originalCustomEncrypted) {
cfEnc = soState.originalCustomEncrypted;
cfIv = soState.originalCustomIV;
} else {
const e = await encryptCustomFields(cleanCustom);
cfEnc = e.encrypted;
cfIv = e.iv;
}
const body = JSON.stringify({
site, title: title.trim(), username: user,
encrypted_password: enc.encrypted, iv: enc.iv,
totp_secret: totpEnc, totp_iv: totpIv,
custom_fields: cfEnc, custom_fields_iv: cfIv,
folder: fold, tags: soState.tags.join(','),
kind,
template: soState.template || '',
});
try {
let targetId = soState.id;
if (isNew) {
const r = await api('/entries', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body,
});
if (r && typeof r.id === 'number') targetId = r.id;
// Flush staged attachments now that we have the entry id.
// Best-effort: a single failure doesn't break the save, but
// surfaces a warning so the user knows to retry from the
// re-opened slideover.
const staged = soState.pendingAttachments || [];
if (staged.length > 0 && typeof targetId === 'number') {
let attachOk = 0, attachFail = 0;
for (const att of staged) {
try {
const { encrypted, iv } = await encryptBlobBytes(att.bytes);
await api('/entries/' + targetId + '/attachments', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
filename: att.filename,
mime: att.mime,
encrypted_blob: encrypted,
iv,
size_bytes: att.size_bytes,
}),
});
attachOk++;
} catch (_) { attachFail++; }
}
soState.pendingAttachments = [];
if (attachFail > 0)
toast(attachOk + ' attachment(s) saved · ' + attachFail + ' failed', 'warning');
else
toast(attachOk + ' attachment(s) saved');
} else {
toast('Saved');
}
} else {
await api('/entries/' + soState.id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body,
});
toast('Saved');
}
await loadEntries();
// Invalidate the vault-health cache so the new/updated entry
// is reflected the next time the dashboard renders.
if (typeof healthCache !== 'undefined') healthCache = null;
const updated = state.entries.find(x => x.id === targetId);
// Clear soState BEFORE re-opening so openSlideOver doesn't mistake
// the re-open for a "switch entry while dirty" — the form still
// holds the just-saved values but soState.original is stale, which
// would falsely trigger the discard-confirm modal and (on Cancel)
// leave soState at id=null, causing a second Save to POST again
// and create a duplicate.
soState = null;
if (updated) openSlideOver(updated.id);
else closeSlideOver();
render();
if (isNew && targetId) {
flashEntry(targetId);
// Auto-fetch favicon only for logins (notes don't have a site).
if (state.faviconsEnabled && updated && (updated.kind || 'login') === 'login')
ensureEntryFavicon(updated);
}
} catch (err) { toast(err.message, 'error'); }
}
function withIcon(name, label) {
const frag = document.createDocumentFragment();
frag.appendChild(icon(name));
frag.appendChild(document.createTextNode(label));
return frag;
}
function field(label, value) {
const wrap = el('div', { class: 'slideover-field' });
wrap.appendChild(el('div', { class: 'slideover-field-label' }, label));
wrap.appendChild(el('div', { class: 'slideover-field-value' }, value));
return wrap;
}
function passwordField(plain) {
const wrap = el('div', { class: 'slideover-field' });
wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Password'));
let revealed = false;
const valueRow = el('div', { class: 'slideover-field-value' });
const span = el('span', { style: 'flex:1;font-family:JetBrains Mono,monospace;user-select:none' }, '••••••••');
const toggle = el('button', { class: 'icon-btn icon-btn-sm', title: 'Show/hide' });
toggle.appendChild(icon('i-eye'));
toggle.addEventListener('click', () => {
revealed = !revealed;
span.textContent = revealed ? plain : '••••••••';
span.style.userSelect = revealed ? 'text' : 'none';
});
const copy = el('button', { class: 'icon-btn icon-btn-sm', title: 'Copy' });
copy.appendChild(icon('i-copy'));
copy.addEventListener('click', () => {
if (Bridge.copySecure(plain, 0)) {
toast('Copied');
} else {
navigator.clipboard.writeText(plain).then(() => toast('Copied'));
}
});
valueRow.appendChild(span);
valueRow.appendChild(toggle);
valueRow.appendChild(copy);
wrap.appendChild(valueRow);
return wrap;
}
function closeSlideOver() {
stopTotpTick();
// Blur any input inside the slideover BEFORE we hide it — otherwise
// focus lingers on an invisible field and Edge's saved-form-data
// popup ("Informations enregistrées") can still pop on arrow-down /
// backspace, leaking past values to the user-visible UI.
const ae = document.activeElement;
if (ae && $('#slideover').contains(ae) && typeof ae.blur === 'function') ae.blur();
$('#slideover').classList.remove('is-open');
state.selectedId = null;
renderGrid();
}
// Gate every dismissal path through this so unsaved changes aren't lost
// silently when the user clicks outside, presses Esc, switches entry,
// or clicks the X. Returns true if the close went through, false if the
// user cancelled. Async because the confirm dialog awaits user input.
async function requestCloseSlideOver() {
if (!$('#slideover').classList.contains('is-open')) return true;
if (state.confirmOnUnsaved && isSoDirty()) {
const ok = await confirmDialog({
title: 'Discard unsaved changes?',
message: 'You have edits that haven\'t been saved. ' +
'Close anyway?' +
'<p style="margin:14px 0 0;font-size:11px;color:var(--text-faint);line-height:1.5">' +
'Tip: turn this prompt off in <b>Settings → Appearance → Confirm before closing unsaved edits</b>.' +
'</p>',
okText: 'Discard',
danger: true,
});
if (!ok) return false;
}
closeSlideOver();
return true;
}
// ============================================================
// TAG CHIP INPUT
// ============================================================
// Local mutable list of tags currently in the entry modal. Synced to the
// hidden #entryTags field on every change so saveEntry can read it.
let editingTags = [];
let chipSuggestEl = null;
let chipSuggestActive = -1;
function syncTagsHidden() {
$('#entryTags').value = editingTags.join(',');
}
function renderChips() {
const container = $('#entryTagsInput');
// Wipe existing chips but keep the input element
$$('.chip', container).forEach(c => c.remove());
const input = $('#entryTagsField');
editingTags.forEach((t, i) => {
const chip = el('span', { class: 'chip' });
chip.appendChild(el('span', null, t));
const x = el('button', {
type: 'button',
on: { click: ev => {
ev.stopPropagation();
editingTags.splice(i, 1);
renderChips();
syncTagsHidden();
} },
});
x.appendChild(icon('i-x'));
chip.appendChild(x);
container.insertBefore(chip, input);
});
syncTagsHidden();
}
function setEditingTags(arr) {
editingTags = (arr || []).filter(Boolean).map(t => t.trim()).filter(Boolean);
renderChips();
}
function addTag(raw) {
const t = (raw || '').trim().replace(/,/g, '');
if (!t) return;
if (editingTags.includes(t)) return;
editingTags.push(t);
renderChips();
}
function closeChipSuggest() {
if (chipSuggestEl) { chipSuggestEl.remove(); chipSuggestEl = null; }
chipSuggestActive = -1;
}
function openChipSuggest() {
closeChipSuggest();
const field = $('#entryTagsField');
const q = field.value.trim().toLowerCase();
const existing = allTags();
const candidates = existing
.filter(t => !editingTags.includes(t))
.filter(t => !q || t.toLowerCase().includes(q))
.slice(0, 8);
if (!q && candidates.length === 0) return;
chipSuggestEl = el('div', { class: 'chip-suggestions' });
if (candidates.length === 0) {
chipSuggestEl.appendChild(el('div', { class: 'chip-suggestion-empty' }, 'Press Enter to create "' + q + '"'));
} else {
candidates.forEach((t, i) => {
const item = el('div', {
class: 'chip-suggestion' + (i === 0 ? ' is-active' : ''),
on: { mousedown: ev => { ev.preventDefault(); addTag(t); field.value = ''; closeChipSuggest(); } },
}, t);
chipSuggestEl.appendChild(item);
});
chipSuggestActive = 0;
}
// Position under the chip input
const rect = $('#entryTagsInput').getBoundingClientRect();
chipSuggestEl.style.position = 'fixed';
chipSuggestEl.style.top = (rect.bottom + 4) + 'px';
chipSuggestEl.style.left = rect.left + 'px';
chipSuggestEl.style.width = Math.max(160, rect.width / 2) + 'px';
document.body.appendChild(chipSuggestEl);
}
function moveChipSuggest(dir) {
if (!chipSuggestEl) return;
const items = $$('.chip-suggestion', chipSuggestEl);
if (items.length === 0) return;
items.forEach(it => it.classList.remove('is-active'));
chipSuggestActive = (chipSuggestActive + dir + items.length) % items.length;
items[chipSuggestActive].classList.add('is-active');
}
function selectActiveSuggestion() {
if (!chipSuggestEl || chipSuggestActive < 0) return false;
const items = $$('.chip-suggestion', chipSuggestEl);
if (items[chipSuggestActive]) {
addTag(items[chipSuggestActive].textContent);
$('#entryTagsField').value = '';
closeChipSuggest();
return true;
}
return false;
}
// ============================================================
// ENTRY MODAL (new / edit)
// ============================================================
async function openEntryModal(entry) {
populateFolderSelect();
if (entry) {
$('#entryModalTitle').textContent = 'Edit entry';
$('#entryId').value = entry.id;
$('#entryTitle').value = entry.title || '';
$('#entrySite').value = entry.site;
$('#entryUsername').value = entry.username || '';
$('#entryFolder').value = entry.folder || 'All';
setEditingTags(parseTags(entry.tags));
$('#entryPassword').value = await decryptPwd(entry.encrypted_password, entry.iv);
if ($('#entryPassword').value === '[ERROR]') $('#entryPassword').value = '';
} else {
$('#entryModalTitle').textContent = 'New entry';
$('#entryId').value = '';
$('#entryForm').reset();
$('#entryFolder').value = state.view.startsWith('folder:') ? state.view.slice(7) : 'All';
setEditingTags([]);
}
$('#entryTagsField').value = '';
closeChipSuggest();
updateEntryStrength();
$('#entryModal').classList.remove('is-hidden');
$('#entrySite').focus();
}
function closeEntryModal() {
$('#entryModal').classList.add('is-hidden');
}
function populateFolderSelect() {
const sel = $('#entryFolder');
sel.innerHTML = '';
state.folders.forEach(f => sel.appendChild(el('option', { value: f.name }, folderLabel(f.name))));
}
async function saveEntry(e) {
e && e.preventDefault();
// Flush any pending text in the chip input as a final tag
const pending = $('#entryTagsField').value.trim();
if (pending) { addTag(pending); $('#entryTagsField').value = ''; }
const id = $('#entryId').value;
const title = $('#entryTitle').value.trim();
const site = $('#entrySite').value.trim();
const user = $('#entryUsername').value.trim();
const pwd = $('#entryPassword').value;
const fold = $('#entryFolder').value;
const tags = editingTags.join(',');
if (!site || !pwd) return toast('Site and password required', 'error');
const enc = await encryptPwd(pwd);
const body = JSON.stringify({
site, title, username: user, encrypted_password: enc.encrypted, iv: enc.iv,
folder: fold, tags,
});
try {
let savedId = id ? parseInt(id) : null;
if (id) {
await api('/entries/' + id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body,
});
toast('Updated');
} else {
const r = await api('/entries', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body,
});
if (r && typeof r.id === 'number') savedId = r.id;
toast('Saved');
}
closeEntryModal();
await loadEntries();
if (typeof healthCache !== 'undefined') healthCache = null;
render();
if (savedId) flashEntry(savedId);
// Fire-and-forget favicon fetch for the saved entry. Updates the
// card in place when it arrives. No-op when feature is off or
// the entry already has a cached icon.
if (savedId && state.faviconsEnabled) {
const saved = state.entries.find(e => e.id === savedId);
if (saved) ensureEntryFavicon(saved); // honours the toggle
}
} catch (err) {
toast(err.message, 'error');
}
}
async function restoreEntry(id) {
try {
await api('/entries/' + id + '/restore', { method: 'POST', headers: authHeaders() });
toast('Restored');
state.checked.delete(id);
await loadEntries();
await loadTrash();
render();
} catch (err) { toast(err.message, 'error'); }
}
async function permanentDelete(id) {
const ok = await confirmDialog({
title: 'Delete forever',
message: 'This entry will be <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');
state.checked.delete(id);
await loadTrash();
render();
} catch (err) { toast(err.message, 'error'); }
}
async function emptyTrash() {
if (!state.trashed.length) return;
const ok = await confirmDialog({
title: 'Empty trash',
message: '<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');
state.checked.clear();
await loadTrash();
render();
} catch (err) { toast(err.message, 'error'); }
}
function openTrashActions(id) {
// For trash entries, we don't open the slide-over — actions are inline on the card.
// But user can click outside the buttons to no-op. Could open a read-only view later.
}
async function duplicateEntry(entry) {
if (!entry) return;
try {
const r = await api('/entries', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
site: entry.site || '',
title: entryDisplayName(entry) + ' (copy)',
username: entry.username || '',
encrypted_password: entry.encrypted_password,
iv: entry.iv,
folder: entry.folder || 'All',
tags: entry.tags || '',
totp_secret: entry.totp_secret || '',
totp_iv: entry.totp_iv || '',
// Preserve the source kind — without this notes were
// sent without 'kind', the server defaulted to 'login',
// then rejected the empty site as "Site required".
kind: entry.kind || 'login',
// Carry the encrypted custom-fields blob across as-is; it's
// already encrypted with the current vault key so the copy
// decrypts the same way as the source.
custom_fields: entry.custom_fields || '',
custom_fields_iv: entry.custom_fields_iv || '',
// Carry the cached favicon / custom icon over too. Plain
// text data URI, server-side cap applies.
icon_b64: entry.icon_b64 || '',
// Carry the template identifier so the copy keeps the
// same card / table label as the source.
template: entry.template || '',
}),
});
// Copy attachments. The source's encrypted blobs are keyed to the
// current vault key, so we just download → re-upload (no re-encrypt
// needed; same key on both sides).
if (r && typeof r.id === 'number') {
try {
const metas = await api('/entries/' + entry.id + '/attachments',
{ headers: authHeaders() });
for (const m of (metas || [])) {
const full = await api('/attachments/' + m.id,
{ headers: authHeaders() });
await api('/entries/' + r.id + '/attachments', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
filename: m.filename,
mime: m.mime || 'application/octet-stream',
encrypted_blob: full.encrypted_blob,
iv: full.iv,
size_bytes: m.size_bytes,
}),
});
}
} catch (_) { /* best-effort — copy may end up partial */ }
}
await loadEntries();
render();
toast('Duplicated: ' + entryDisplayName(entry));
if (r && typeof r.id === 'number') flashEntry(r.id);
} catch (err) {
toast(err.message || 'Duplicate failed', 'error');
}
}
function flashEntry(id) {
if (!id) return;
setTimeout(() => {
const el = document.querySelector(
'.entry-card[data-id="' + id + '"], ' +
'.entry-row[data-id="' + id + '"]'
);
if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('is-flash');
setTimeout(() => el.classList.remove('is-flash'), 2600);
}, 50);
}
async function deleteEntry(id) {
const e = state.entries.find(x => x.id === id);
if (state.askBeforeDelete) {
const ok = await confirmDialog({
title: 'Move to trash',
message: 'Send <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();
state.checked.delete(id);
await loadEntries();
state.trashedCount = (state.trashedCount || 0) + 1;
render();
} catch (err) { toast(err.message, 'error'); }
}
async function toggleFavorite(id) {
try {
await api('/entries/' + id + '/favorite', { method: 'POST', headers: authHeaders() });
const entry = state.entries.find(e => e.id === id);
if (entry) entry.favorite = entry.favorite ? 0 : 1;
render();
} catch (e) {}
}
async function togglePin(id) {
try {
await api('/entries/' + id + '/pin', { method: 'POST', headers: authHeaders() });
const entry = state.entries.find(e => e.id === id);
if (entry) entry.pinned = entry.pinned ? 0 : 1;
render();
toast(entry && entry.pinned ? 'Pinned to top' : 'Unpinned');
} catch (e) { toast(e.message, 'error'); }
}
async function moveEntriesToFolder(ids, folder) {
let ok = 0, skipped = 0;
for (const id of ids) {
const e = state.entries.find(x => x.id === id);
if (!e) continue;
if (e.folder === folder) { skipped++; continue; }
try {
await api('/entries/' + id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
site: e.site,
title: e.title || '',
username: e.username,
encrypted_password: e.encrypted_password,
iv: e.iv,
folder,
tags: e.tags || '',
kind: e.kind || 'login',
totp_secret: e.totp_secret || '',
totp_iv: e.totp_iv || '',
custom_fields: e.custom_fields || '',
custom_fields_iv: e.custom_fields_iv || '',
}),
});
e.folder = folder;
ok++;
} catch (err) { /* try the rest */ }
}
state.checked.clear();
render();
if (ok === 0 && skipped > 0) return;
if (ok === 1) toast('Moved to ' + folder);
else if (ok > 1) toast('Moved ' + ok + ' entries to ' + folder);
}
async function moveEntryToFolder(id, folder) {
const e = state.entries.find(x => x.id === id);
if (!e || e.folder === folder) return;
try {
// PUT requires the full payload — missing fields default server-side
// (kind='login' wipes notes through the "Site required" check;
// totp_*/custom_fields_* default to empty → cleared via .Clear).
// Re-ship every blob bit-for-bit; only `folder` actually changes.
await api('/entries/' + id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
site: e.site,
title: e.title || '',
username: e.username,
encrypted_password: e.encrypted_password,
iv: e.iv,
folder,
tags: e.tags || '',
kind: e.kind || 'login',
totp_secret: e.totp_secret || '',
totp_iv: e.totp_iv || '',
custom_fields: e.custom_fields || '',
custom_fields_iv: e.custom_fields_iv || '',
}),
});
e.folder = folder;
render();
toast('Moved to ' + folder);
} catch (err) { toast(err.message, 'error'); }
}
async function copyPassword(entry) {
const p = await decryptPwd(entry.encrypted_password, entry.iv);
if (p === '[ERROR]') return toast('Cannot decrypt', 'error');
if (Bridge.copySecure(p, 30000)) {
toast('Password copied · clears in 30s');
} else {
navigator.clipboard.writeText(p).then(() => toast('Password copied · clears in 30s'));
setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000);
}
touchEntry(entry.id);
}
function copyUsername(entry) {
const u = entry.username || '';
if (!u) return toast('No username to copy', 'warning');
if (Bridge.copySecure(u, 0)) {
toast('Username copied');
} else {
navigator.clipboard.writeText(u).then(() => toast('Username copied'));
}
touchEntry(entry.id);
}
// Bump accessed_at on the server (fire-and-forget) AND in local state so
// the "Recent" view updates without a full reload. Coalesces rapid calls
// for the same id within 2s to avoid hammering the server on bulk copies.
const _touchTimers = {};
function touchEntry(id) {
if (!id) return;
const now = new Date().toISOString();
const e = state.entries.find(x => x.id === id);
if (e) e.accessed_at = now;
if (_touchTimers[id]) return;
_touchTimers[id] = setTimeout(() => {
delete _touchTimers[id];
fetch(API + '/entries/' + id + '/touch', {
method: 'POST',
headers: authHeaders(),
}).catch(() => {}); // silent — local state already reflects it
}, 2000);
}
// ============================================================
// ENCRYPTED ATTACHMENTS
// ============================================================
// Per-entry files (PDFs, backup-code images, recovery sheets, …)
// encrypted client-side with the vault key, base64-shipped to the server
// for opaque storage. Listing returns metadata only — the ciphertext is
// fetched on demand when the user clicks Download.
const ATTACHMENT_MAX_BYTES = 5 * 1024 * 1024; // raw file size cap (5 MB)
async function encryptBlobBytes(bytes) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv }, state.cryptoKey, bytes);
return { encrypted: bytesToBase64(ct), iv: bytesToBase64(iv) };
}
async function decryptBlobBytes(encB64, ivB64) {
const ct = base64ToBytes(encB64);
const iv = base64ToBytes(ivB64);
const dec = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv }, state.cryptoKey, ct);
return new Uint8Array(dec);
}
function humanFileSize(n) {
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
return (n / 1024 / 1024).toFixed(2) + ' MB';
}
async function uploadAttachment(entryId, file) {
if (!file) return null;
if (file.size > ATTACHMENT_MAX_BYTES) {
toast('File too large (max 5 MB raw)', 'error');
return null;
}
const bytes = new Uint8Array(await file.arrayBuffer());
const { encrypted, iv } = await encryptBlobBytes(bytes);
const meta = await api('/entries/' + entryId + '/attachments', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
filename: file.name,
mime: file.type || 'application/octet-stream',
encrypted_blob: encrypted,
iv,
size_bytes: file.size,
}),
});
return meta;
}
async function downloadAttachment(att) {
// Spinner for large attachments — fetch + decrypt + chunked transfer
// of a multi-MB file takes a moment before the Save dialog appears.
const big = (att.size_bytes || 0) > 512 * 1024;
try {
if (big) showBusy('Preparing download…');
const full = await api('/attachments/' + att.id, { headers: authHeaders() });
const bytes = await decryptBlobBytes(full.encrypted_blob, full.iv);
if (Bridge.active && typeof Bridge.saveFile === 'function') {
const res = await Bridge.saveFile(att.filename, bytes, pct => {
if (big) updateBusy('Preparing download… ' + pct + '%');
});
if (big) hideBusy();
if (res.ok) toast('Saved to ' + res.path);
else if (res.error) toast('Save failed: ' + res.error, 'error');
} else {
if (big) hideBusy();
// Web fallback: trigger a browser download.
const blob = new Blob([bytes], { type: att.mime || 'application/octet-stream' });
const url = URL.createObjectURL(blob);
const a = el('a', { href: url, download: att.filename });
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
}
} catch (e) {
if (big) hideBusy();
toast('Download failed: ' + (e && e.message ? e.message : e), 'error');
}
}
// Slideover field: header + upload button + list of attachments.
// Re-renders the list in place when the contents change (upload/delete).
function soAttachmentsField(entryId) {
const isStaging = (entryId === null || entryId === undefined);
const wrap = el('div', { class: 'slideover-field' });
wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Attachments'));
const addBtn = el('button', {
class: 'btn btn-ghost btn-sm', type: 'button',
title: 'Attach a file (encrypted before upload, max 5 MB)',
});
addBtn.appendChild(icon('i-paperclip'));
addBtn.appendChild(document.createTextNode(' Attach file'));
wrap.appendChild(addBtn);
const fileInput = el('input', { type: 'file', style: 'display:none' });
wrap.appendChild(fileInput);
const list = el('div', { class: 'so-attach-list', id: 'soAttachList' });
wrap.appendChild(list);
addBtn.addEventListener('click', ev => {
ev.stopPropagation();
fileInput.click();
});
fileInput.addEventListener('change', async ev => {
ev.stopPropagation();
const file = fileInput.files && fileInput.files[0];
if (!file) return;
fileInput.value = ''; // allow re-uploading the same name later
if (file.size > ATTACHMENT_MAX_BYTES) {
toast('File too large (max 5 MB raw)', 'error');
return;
}
addBtn.disabled = true;
try {
if (isStaging) {
// Stage in memory — uploaded by soSave after the new
// entry's id is known.
const bytes = new Uint8Array(await file.arrayBuffer());
soState.pendingAttachments.push({
filename: file.name,
mime: file.type || 'application/octet-stream',
size_bytes: file.size,
bytes,
});
soDirtyCheck();
renderStagedAttachments(list);
} else {
await uploadAttachment(entryId, file);
toast('Attachment uploaded');
await refreshAttachments(entryId, list);
}
} catch (e) {
toast('Upload failed: ' + e.message, 'error');
} finally {
addBtn.disabled = false;
}
});
if (isStaging) renderStagedAttachments(list);
else refreshAttachments(entryId, list);
return wrap;
}
// Renders the pendingAttachments array (new-entry mode). Mirrors the
// existing-entry layout so users can't tell the difference until they
// hit Save.
function renderStagedAttachments(container) {
container.innerHTML = '';
const list = soState.pendingAttachments || [];
if (list.length === 0) {
container.appendChild(el('div', { class: 'so-attach-empty' },
'No attachments yet. Files will upload after you save.'));
return;
}
list.forEach((att, idx) => {
const row = el('div', { class: 'so-attach-row' });
const info = el('div', { class: 'so-attach-info' });
info.appendChild(el('div', { class: 'so-attach-name', title: att.filename }, att.filename));
info.appendChild(el('div', { class: 'so-attach-meta' },
humanFileSize(att.size_bytes) + ' · ' + (att.mime || '?') +
' · pending'));
row.appendChild(info);
const del = el('button',
{ class: 'icon-btn icon-btn-sm', type: 'button', title: 'Remove' });
del.appendChild(icon('i-trash'));
del.addEventListener('click', ev => {
ev.stopPropagation();
soState.pendingAttachments.splice(idx, 1);
soDirtyCheck();
renderStagedAttachments(container);
});
row.appendChild(del);
container.appendChild(row);
});
}
async function refreshAttachments(entryId, container) {
container.innerHTML = '';
let items = [];
try {
items = await api('/entries/' + entryId + '/attachments',
{ headers: authHeaders() });
} catch (e) {
container.appendChild(el('div', { class: 'so-attach-empty' },
'Failed to load attachments'));
return;
}
if (!items || items.length === 0) {
container.appendChild(el('div', { class: 'so-attach-empty' },
'No attachments yet.'));
return;
}
items.forEach(att => container.appendChild(buildAttachmentRow(att, entryId, container)));
}
function buildAttachmentRow(att, entryId, listContainer) {
const row = el('div', { class: 'so-attach-row' });
const name = el('div', { class: 'so-attach-name', title: att.filename }, att.filename);
const meta = el('div', { class: 'so-attach-meta' },
humanFileSize(att.size_bytes) + ' · ' + (att.mime || '?'));
const info = el('div', { class: 'so-attach-info' });
info.appendChild(name);
info.appendChild(meta);
row.appendChild(info);
const dl = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Download' });
dl.appendChild(icon('i-download'));
dl.addEventListener('click', ev => {
ev.stopPropagation();
downloadAttachment(att);
});
row.appendChild(dl);
const del = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Delete' });
del.appendChild(icon('i-trash'));
del.addEventListener('click', async ev => {
ev.stopPropagation();
const ok = await confirmDialog({
title: 'Delete attachment?',
message: 'This will permanently remove <b>' + att.filename + '</b>. There is no trash for attachments.',
okText: 'Delete',
danger: true,
});
if (!ok) return;
try {
await api('/attachments/' + att.id, {
method: 'DELETE', headers: authHeaders(),
});
toast('Attachment deleted');
await refreshAttachments(entryId, listContainer);
} catch (e) {
toast('Delete failed: ' + e.message, 'error');
}
});
row.appendChild(del);
return row;
}
// Display helper: when `maskUsernames` setting is on, show only the first 2
// chars followed by '***'. Used in cards/list (but slide-over always reveals).
function displayUsername(u) {
if (!u) return '—';
if (!state.maskUsernames) return u;
if (u.length <= 2) return u + '***';
return u.slice(0, 2) + '***';
}
// ============================================================
// FOLDERS CRUD
// ============================================================
async function deleteFolder(name, entryCount) {
const message = entryCount > 0
? '<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'); }
}
// Curated swatches + icon choices. Kept small so the picker stays a
// single visual row each — paradox of choice tames itself.
const FOLDER_PALETTE = [
'', // 'no color' = default accent
'#ef4444', '#f97316', '#eab308', '#22c55e',
'#06b6d4', '#3b82f6', '#a855f7', '#ec4899',
];
const FOLDER_ICONS = [
'i-folder', 'i-shield', 'i-key', 'i-star',
'i-user', 'i-tag', 'i-globe', 'i-lock',
];
function addFolder() { openFolderModal(null); }
// Reorder: move `draggedName` directly before or after `targetName`.
// Builds the new names list and persists via POST /folders/reorder.
async function reorderFolderTo(draggedName, targetName, insertBefore) {
// Work on the user-visible (non-"All") slice — "All" is synthetic and
// never reordered, so it must stay at index 0 in state.folders.
const real = state.folders.filter(f => f.name !== 'All');
const dragged = real.find(f => f.name === draggedName);
if (!dragged) return;
const filtered = real.filter(f => f.name !== draggedName);
const targetIdx = filtered.findIndex(f => f.name === targetName);
if (targetIdx === -1) return;
const insertIdx = insertBefore ? targetIdx : targetIdx + 1;
filtered.splice(insertIdx, 0, dragged);
state.folders = [{ name: 'All', color: '', icon: '' }].concat(filtered);
render(); // optimistic — feels instant
try {
await api('/folders/reorder', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ names: filtered.map(f => f.name) }),
});
} catch (e) {
// Roll back by reloading from the server on failure.
toast('Reorder failed — reverting', 'error');
await loadFolders();
render();
}
}
function openFolderModal(existing) {
const isEdit = !!existing;
const initName = isEdit ? existing.name : '';
const initColor = isEdit ? (existing.color || '') : '';
const initIcon = isEdit ? (existing.icon || 'i-folder') : 'i-folder';
// Build modal node fresh each call so swatches/icons don't leak listeners.
const old = document.getElementById('folderModal');
if (old) old.remove();
const modal = el('div', { id: 'folderModal', class: 'modal' });
const backdrop = el('div', { class: 'modal-backdrop' });
backdrop.addEventListener('click', () => modal.remove());
modal.appendChild(backdrop);
const panel = el('div', { class: 'modal-panel modal-panel-sm' });
const header = el('div', { class: 'modal-header' });
header.appendChild(el('h3', null, isEdit ? 'Customize folder' : 'New folder'));
panel.appendChild(header);
const body = el('div', { class: 'modal-body' });
// Name input (read-only when editing — rename out of scope for v1)
const nameField = el('div', { class: 'form-field' });
nameField.appendChild(el('label', null, 'Name'));
const nameInput = el('input', {
type: 'text', class: 'so-input', id: 'folderModalName',
placeholder: 'e.g. Work', value: initName,
});
if (isEdit) nameInput.disabled = true;
nameField.appendChild(nameInput);
body.appendChild(nameField);
// Selected state held by closure.
let pickedColor = initColor;
let pickedIcon = initIcon;
// Color row
const colorField = el('div', { class: 'form-field' });
colorField.appendChild(el('label', null, 'Color'));
const colorRow = el('div', { class: 'folder-swatch-row' });
FOLDER_PALETTE.forEach(c => {
const sw = el('button', {
type: 'button',
class: 'folder-swatch' + (c === pickedColor ? ' is-active' : ''),
'data-color': c,
title: c || 'Default',
});
if (c) sw.style.background = c;
else sw.classList.add('is-default');
sw.addEventListener('click', () => {
pickedColor = c;
colorRow.querySelectorAll('.folder-swatch').forEach(x =>
x.classList.toggle('is-active', x.getAttribute('data-color') === c));
refreshPreview();
});
colorRow.appendChild(sw);
});
colorField.appendChild(colorRow);
body.appendChild(colorField);
// Icon row
const iconField = el('div', { class: 'form-field' });
iconField.appendChild(el('label', null, 'Icon'));
const iconRow = el('div', { class: 'folder-icon-row' });
FOLDER_ICONS.forEach(name => {
const btn = el('button', {
type: 'button',
class: 'folder-icon-btn' + (name === pickedIcon ? ' is-active' : ''),
'data-icon': name,
});
btn.appendChild(icon(name));
btn.addEventListener('click', () => {
pickedIcon = name;
iconRow.querySelectorAll('.folder-icon-btn').forEach(x =>
x.classList.toggle('is-active', x.getAttribute('data-icon') === name));
refreshPreview();
});
iconRow.appendChild(btn);
});
iconField.appendChild(iconRow);
body.appendChild(iconField);
// Live preview
const previewWrap = el('div', { class: 'folder-preview' });
const previewIcon = icon(pickedIcon);
previewWrap.appendChild(previewIcon);
const previewLabel = el('span', null, initName || 'Folder');
previewWrap.appendChild(previewLabel);
body.appendChild(previewWrap);
panel.appendChild(body);
function refreshPreview() {
previewIcon.innerHTML = '';
const u = document.createElementNS('http://www.w3.org/2000/svg', 'use');
u.setAttribute('href', '#' + pickedIcon);
previewIcon.appendChild(u);
previewIcon.style.color = pickedColor || '';
previewLabel.textContent = (nameInput.value || 'Folder');
}
nameInput.addEventListener('input', refreshPreview);
// Footer buttons
const footer = el('div', { class: 'modal-footer' });
const cancelBtn = el('button', { class: 'btn btn-ghost', type: 'button' }, 'Cancel');
cancelBtn.addEventListener('click', () => modal.remove());
footer.appendChild(cancelBtn);
const saveBtn = el('button',
{ class: 'btn btn-primary', type: 'button' },
isEdit ? 'Save' : 'Create');
saveBtn.addEventListener('click', () => submitFolder());
footer.appendChild(saveBtn);
panel.appendChild(footer);
modal.appendChild(panel);
document.body.appendChild(modal);
setTimeout(() => nameInput.focus(), 30);
async function submitFolder() {
const clean = (nameInput.value || '').trim();
if (!isEdit && !clean) return toast('Name required', 'warning');
if (clean.toLowerCase() === 'all') return toast('"All" is reserved', 'warning');
try {
if (isEdit) {
await api('/folders/' + encodeURIComponent(initName), {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ color: pickedColor, icon: pickedIcon }),
});
toast('Folder updated');
} else {
await api('/folders', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ name: clean, color: pickedColor, icon: pickedIcon }),
});
toast('Folder created');
}
modal.remove();
await loadFolders();
render();
} catch (e) { toast(e.message, 'error'); }
}
}
// ============================================================
// PASSWORD GENERATOR
// ============================================================
let genCurrent = '';
function genPassword() {
const len = parseInt($('#genLen').value);
$('#genLenLabel').textContent = len;
let chars = '';
if ($('#genUpper').checked) chars += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
if ($('#genLower').checked) chars += 'abcdefghijklmnopqrstuvwxyz';
if ($('#genNum').checked) chars += '0123456789';
if ($('#genSym').checked) chars += '!@#$%^&*()_+-=[]{}|;:,.<>?';
if (!chars) { $('#genPreview').textContent = 'Select at least one'; return; }
let p = '';
const max = 256 - (256 % chars.length);
const buf = new Uint8Array(1);
for (let i = 0; i < len; i++) {
do { crypto.getRandomValues(buf); } while (buf[0] >= max);
p += chars.charAt(buf[0] % chars.length);
}
genCurrent = p;
$('#genPreview').textContent = p;
}
// genTarget: 'entry' (insert into entry form) or 'standalone' (just copy/dismiss)
let genTarget = 'entry';
function openGen(target) {
genTarget = target || 'entry';
// Show "Use" when targeting an editable field (entry modal or slide-over)
$('#genUse').style.display = (genTarget === 'standalone') ? 'none' : '';
$('#genModal').classList.remove('is-hidden');
genPassword();
}
function closeGen() { $('#genModal').classList.add('is-hidden'); }
// ============================================================
// PASSWORD STRENGTH
// ============================================================
function computeStrength(p) {
let s = 0;
if (p.length >= 8) s += 25;
if (p.length >= 12) s += 15;
if (/[A-Z]/.test(p) && /[a-z]/.test(p)) s += 20;
if (/\d/.test(p)) s += 15;
if (/[^A-Za-z0-9]/.test(p)) s += 25;
return Math.min(100, s);
}
function updateRegStrength() {
const p = $('#regPassword').value;
$('#regStrengthBar').style.setProperty('--strength', computeStrength(p) + '%');
}
function updateEntryStrength() {
const p = $('#entryPassword').value;
$('#entryStrengthBar').style.setProperty('--strength', computeStrength(p) + '%');
}
// ============================================================
// COMMAND PALETTE
// ============================================================
function openPalette() {
$('#cmdPalette').classList.remove('is-hidden');
$('#cmdInput').value = '';
$('#cmdInput').focus();
renderPaletteResults('');
}
function closePalette() { $('#cmdPalette').classList.add('is-hidden'); }
function paletteCommands() {
return [
{ id: 'new', label: 'New entry', icon: 'i-plus', run: () => { closePalette(); openSlideOver(null); } },
{ id: 'new-note', label: 'New note', icon: 'i-edit', run: () => { closePalette(); openSlideOver(null, { kind: 'note' }); } },
{ id: 'shortcuts', label: 'Show keyboard shortcuts (?)', icon: 'i-command',
run: () => { closePalette(); openCheatsheet(); } },
{ id: 'lock', label: 'Lock vault', icon: 'i-lock', run: async () => { closePalette(); if (await confirmDiscardForSessionExit('lock')) lockVault(); } },
{ id: 'logout', label: 'Sign out', icon: 'i-log-out', run: async () => { closePalette(); if (await confirmDiscardForSessionExit('logout')) doLogout(); } },
{ id: 'theme', label: 'Toggle theme', icon: 'i-sun', run: () => { closePalette(); toggleTheme(); } },
{ id: 'all', label: 'Show all items', icon: 'i-globe', run: () => { closePalette(); setView('all'); } },
{ id: 'fav', label: 'Show favorites', icon: 'i-star', run: () => { closePalette(); setView('favorites'); } },
{ id: 'notes', label: 'Show notes', icon: 'i-edit', run: () => { closePalette(); setView('notes'); } },
{ id: 'trash', label: 'Show trash', icon: 'i-trash', run: () => { closePalette(); setView('trash'); } },
];
}
function renderPaletteResults(q) {
const cmds = paletteCommands();
const entries = state.entries.map(e => ({
id: 'entry-' + e.id, label: entryDisplayName(e),
// Sub: site (when different from displayName) + username, joined.
sub: [
(e.title && e.title.trim() && e.title.trim() !== e.site) ? e.site : '',
e.username || '',
].filter(Boolean).join(' · '),
icon: 'i-globe', run: () => { closePalette(); openSlideOver(e.id); },
}));
const all = cmds.concat(entries);
q = (q || '').toLowerCase();
const filtered = q ? all.filter(c => c.label.toLowerCase().includes(q) || (c.sub||'').toLowerCase().includes(q)) : all;
const out = $('#cmdResults');
out.innerHTML = '';
filtered.slice(0, 50).forEach((c, i) => {
const it = el('div', {
class: 'cmd-item' + (i === 0 ? ' is-active' : ''),
on: { click: c.run },
});
it.appendChild(icon(c.icon));
it.appendChild(el('span', null, c.label));
if (c.sub) it.appendChild(el('span', { style: 'color:var(--text-faint);font-size:11px;margin-left:auto' }, c.sub));
out.appendChild(it);
});
}
// ============================================================
// IN-APP CONFIRM / PROMPT (no native alerts)
// ============================================================
let confirmResolver = null;
function confirmDialog(opts) {
// opts: { title, message, okText, cancelText, danger }
opts = opts || {};
$('#confirmTitle').textContent = opts.title || 'Confirm';
$('#confirmMessage').innerHTML = opts.message || 'Are you sure?';
$('#confirmOkBtn').lastChild.nodeValue = ' ' + (opts.okText || 'Confirm');
$('#confirmCancelBtn').textContent = opts.cancelText || 'Cancel';
$('#confirmOkBtn').classList.toggle('is-danger', !!opts.danger);
$('#confirmInputField').classList.add('is-hidden');
$('#confirmModal').classList.remove('is-hidden');
setTimeout(() => $('#confirmOkBtn').focus(), 50);
return new Promise(res => { confirmResolver = res; });
}
function promptDialog(opts) {
// opts: { title, message, okText, placeholder, value, password, error }
opts = opts || {};
$('#confirmTitle').textContent = opts.title || 'Enter value';
// Append a transient red error line under the message so the modal
// can recycle for retries (wrong password, too-short, etc.) without
// a toast that swallows the context.
const msgHtml = opts.message || '';
const errHtml = opts.error
? '<p style="margin:8px 0 0;color:var(--danger);font-size:12px">' +
String(opts.error).replace(/[&<>"]/g,
c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])) +
'</p>'
: '';
$('#confirmMessage').innerHTML = msgHtml + errHtml;
$('#confirmOkBtn').lastChild.nodeValue = ' ' + (opts.okText || 'OK');
$('#confirmCancelBtn').textContent = 'Cancel';
$('#confirmOkBtn').classList.remove('is-danger');
$('#confirmInputField').classList.remove('is-hidden');
$('#confirmInput').value = opts.value || '';
$('#confirmInput').placeholder = opts.placeholder || '';
// Allow password-style masking (used by encrypted import/export).
const inp = $('#confirmInput');
const eye = $('#confirmInputEye');
inp.type = opts.password ? 'password' : 'text';
if (eye) {
// Eye toggle only for password prompts. Reset to masked + leave
// room on the right so text doesn't run under the button.
eye.style.display = opts.password ? '' : 'none';
inp.style.paddingRight = opts.password ? '34px' : '';
eye.onclick = () => {
inp.type = inp.type === 'password' ? 'text' : 'password';
inp.focus();
};
}
$('#confirmModal').classList.remove('is-hidden');
setTimeout(() => $('#confirmInput').focus(), 50);
return new Promise(res => { confirmResolver = res; });
}
// Global busy overlay for long operations (export, big file saves).
function showBusy(text) {
const t = $('#busyText'); if (t) t.textContent = text || 'Working…';
const o = $('#busyOverlay'); if (o) o.classList.remove('is-hidden');
}
function updateBusy(text) {
const t = $('#busyText'); if (t) t.textContent = text || '';
}
function hideBusy() {
const o = $('#busyOverlay'); if (o) o.classList.add('is-hidden');
}
function closeConfirm(value) {
$('#confirmModal').classList.add('is-hidden');
if (confirmResolver) {
const cb = confirmResolver;
confirmResolver = null;
cb(value);
}
}
// ============================================================
// RE-AUTH MODAL + EXPORT
// ============================================================
let reauthResolve = null;
function askReauth(message, opts) {
opts = opts || {};
return new Promise(resolve => {
reauthResolve = resolve;
$('#reauthMessage').textContent = message || 'This action requires your master password.';
$('#reauthPassword').value = '';
const errEl = $('#reauthError');
if (errEl) {
if (opts.error) {
errEl.textContent = opts.error;
errEl.classList.remove('is-hidden');
} else {
errEl.textContent = '';
errEl.classList.add('is-hidden');
}
}
$('#reauthModal').classList.remove('is-hidden');
setTimeout(() => $('#reauthPassword').focus(), 50);
});
}
function closeReauth(ok) {
$('#reauthModal').classList.add('is-hidden');
if (reauthResolve) {
const pwd = ok ? $('#reauthPassword').value : null;
const cb = reauthResolve;
reauthResolve = null;
cb(pwd);
}
}
// ============================================================
// QUICK UNLOCK — remember on this device (DPAPI-backed)
// ============================================================
//
// User-controlled convenience feature. When opted in, the current vault
// state (raw AES key + salt + username + session token) is bundled into
// a JSON blob and handed to the Delphi side, which DPAPI-encrypts it
// (CRYPTPROTECT_CURRENT_USER) and stashes the blob on disk. Subsequent
// app starts can recover the entire session without re-entering the
// master password.
//
// Honest trade-off: the encrypted blob is readable by ANY process
// running as the same Windows user. The protection is no stronger than
// the Windows account itself. Users with Windows Hello / fingerprint /
// PIN configured at the OS level get biometric gating transitively
// (via login). Without that, this is "remember on trusted device".
//
// Only available when running inside the Delphi host (Bridge.active);
// the PHP standalone has no DPAPI equivalent.
// Resolver for the Delphi → JS callback. Delphi side fires
// Bridge.onQuickUnlockResult(b64|null) after processing cmd://quickunlock/get.
let quickUnlockResolver = null;
// Same for the status query.
let quickUnlockStatusResolver = null;
function bridgeRequestQuickUnlock() {
if (!Bridge.active) return Promise.resolve(null);
return new Promise(resolve => {
quickUnlockResolver = resolve;
// Safety: if Delphi never responds, time out after 3 s so the auth
// screen doesn't hang. Falls back to master-pw login.
setTimeout(() => {
if (quickUnlockResolver === resolve) {
quickUnlockResolver = null;
resolve(null);
}
}, 3000);
window.location.href = 'cmd://quickunlock/get';
});
}
let clipboardReadResolver = null;
function bridgeReadClipboard() {
if (!Bridge.active) return Promise.resolve('');
return new Promise(resolve => {
clipboardReadResolver = resolve;
setTimeout(() => {
if (clipboardReadResolver === resolve) {
clipboardReadResolver = null;
resolve('');
}
}, 1500);
window.location.href = 'cmd://clipboard/read';
});
}
Bridge.onClipboardRead = function(text) {
if (clipboardReadResolver) {
clipboardReadResolver(text || '');
clipboardReadResolver = null;
}
};
function bridgeQuickUnlockStatus() {
if (!Bridge.active) return Promise.resolve(false);
return new Promise(resolve => {
quickUnlockStatusResolver = resolve;
setTimeout(() => {
if (quickUnlockStatusResolver === resolve) {
quickUnlockStatusResolver = null;
resolve(false);
}
}, 2000);
window.location.href = 'cmd://quickunlock/status';
});
}
// Patch Bridge.onQuickUnlockResult / Status to feed the resolvers above.
Bridge.onQuickUnlockResult = function(b64) {
if (quickUnlockResolver) {
const cb = quickUnlockResolver;
quickUnlockResolver = null;
cb(b64);
}
};
Bridge.onQuickUnlockStatus = function(configured) {
if (quickUnlockStatusResolver) {
const cb = quickUnlockStatusResolver;
quickUnlockStatusResolver = null;
cb(!!configured);
}
};
// ============================================================
// PIN UNLOCK (DPAPI blob, PIN-derived wrap of the vault key)
// ============================================================
// Three modes governed by state.unlockMode (synced setting):
// 'pw' — current behaviour, master pw only (PIN ignored even if set)
// 'pin' — PIN unlocks the vault on this device
// 'both' — master pw unlocks; PIN is then verified before access
//
// Anti-brute-force: each failed PIN attempt rewrites the blob with an
// incremented counter. Past PIN_MAX_ATTEMPTS the blob self-destructs and
// the user has to fall back to master pw (and re-set the PIN if desired).
//
// Sensitive actions (export, change master pw, recovery code…) still go
// through askReauth which always asks for the master pw — the PIN never
// substitutes there.
const PIN_KDF_ITERS = 100000; // lower than master pw — PIN entropy
// is low (46 digits) so high iters
// mostly slow down honest users.
const PIN_MAX_ATTEMPTS = 5;
let pinResolver = null;
let pinStatusResolver = null;
function bridgePinGet() {
if (!Bridge.active) return Promise.resolve(null);
return new Promise(resolve => {
pinResolver = resolve;
setTimeout(() => {
if (pinResolver === resolve) { pinResolver = null; resolve(null); }
}, 3000);
window.location.href = 'cmd://pin/get';
});
}
function bridgePinStatus() {
if (!Bridge.active) return Promise.resolve(false);
return new Promise(resolve => {
pinStatusResolver = resolve;
setTimeout(() => {
if (pinStatusResolver === resolve) {
pinStatusResolver = null; resolve(false);
}
}, 2000);
window.location.href = 'cmd://pin/status';
});
}
function bridgePinStore(payloadB64) {
if (!Bridge.active) return;
window.location.href = 'cmd://pin/store?data=' + encodeURIComponent(payloadB64);
}
function bridgePinClear() {
if (!Bridge.active) return;
window.location.href = 'cmd://pin/clear';
}
Bridge.onPinResult = function(b64) {
if (pinResolver) { const cb = pinResolver; pinResolver = null; cb(b64); }
};
Bridge.onPinStatus = function(configured) {
if (pinStatusResolver) {
const cb = pinStatusResolver;
pinStatusResolver = null;
cb(!!configured);
}
};
async function derivePinWrapKey(pin, saltBytes, iters) {
const km = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(pin), 'PBKDF2', false, ['deriveKey']);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: saltBytes, iterations: iters, hash: 'SHA-256' },
km,
{ name: 'AES-GCM', length: 256 },
true, // extractable: false would be safer but we need to re-wrap on attempt increment
['encrypt', 'decrypt']);
}
async function pinBuildBlob(pin) {
// Snapshot of everything the cold-start unlock needs to log back in
// without the master pw. Mirrors the Quick Unlock payload shape.
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const wrap = await derivePinWrapKey(pin, salt, PIN_KDF_ITERS);
const raw = await crypto.subtle.exportKey('raw', state.cryptoKey);
const wrapped = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv }, wrap, raw);
return {
v: 1,
username: state.username,
loginSalt: state.salt,
loginIters: state.kdfIterations || 600000,
// Auth scheme so cold-start sends the right verifier (v2 accounts
// need the decoupled transform, not the raw key hex). Absent on
// pre-decoupling blobs → cold-start defaults to the key hex, which
// is correct for those (legacy) accounts.
hashAlgo: state.hashAlgo || '',
argon2Params: state.argon2Params || null,
salt: bytesToBase64(salt),
iters: PIN_KDF_ITERS,
iv: bytesToBase64(iv),
wrapped: bytesToBase64(wrapped),
attempts: 0,
};
}
function pinBlobToB64(obj) {
return bytesToBase64(new TextEncoder().encode(JSON.stringify(obj)));
}
function pinB64ToBlob(b64) {
return JSON.parse(new TextDecoder().decode(base64ToBytes(b64)));
}
async function pinFetchBlob() {
const b64 = await bridgePinGet();
if (!b64) return null;
try { return pinB64ToBlob(b64); }
catch { return null; }
}
// Validates a PIN against the stored blob. Returns the unwrapped vault
// key bytes on success, null on failure (and rewrites the blob with the
// incremented attempts counter or wipes it past the cap).
async function pinTryUnwrap(pin) {
const blob = await pinFetchBlob();
if (!blob) return null;
try {
const wrap = await derivePinWrapKey(pin,
base64ToBytes(blob.salt), blob.iters || PIN_KDF_ITERS);
const raw = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: base64ToBytes(blob.iv) },
wrap, base64ToBytes(blob.wrapped));
// Successful unlock — reset the attempts counter so a future
// bad-then-good streak doesn't accidentally wipe the blob.
if ((blob.attempts || 0) !== 0) {
blob.attempts = 0;
bridgePinStore(pinBlobToB64(blob));
}
return { rawKey: new Uint8Array(raw), blob };
} catch (_) {
const next = (blob.attempts || 0) + 1;
if (next >= PIN_MAX_ATTEMPTS) {
bridgePinClear();
} else {
blob.attempts = next;
bridgePinStore(pinBlobToB64(blob));
}
return null;
}
}
async function setupPin(pin) {
if (!Bridge.active) return toast('PIN requires the Delphi app', 'warning');
if (!state.cryptoKey) return toast('Unlock the vault first', 'warning');
const blob = await pinBuildBlob(pin);
bridgePinStore(pinBlobToB64(blob));
state.pinConfigured = true;
toast('PIN set');
}
async function removePin() {
bridgePinClear();
state.pinConfigured = false;
// If we were in pin-only mode, fall back to pw — leaving the user
// unable to log in next time would be a self-foot-gun.
if (state.unlockMode === 'pin' || state.unlockMode === 'both') {
state.unlockMode = 'pw';
localStorage.setItem('unlockMode', 'pw');
saveServerSettings();
}
toast('PIN removed');
}
// Refresh the Settings panel PIN row: dropdown value, status text,
// button labels. Idempotent — safe to call from listeners or after the
// async pinStatus probe completes.
function refreshPinUnlockUI() {
const sel = document.getElementById('settingUnlockMode');
const status = document.getElementById('pinStatus');
const setBtn = document.getElementById('pinSetBtn');
const delBtn = document.getElementById('pinRemoveBtn');
if (sel) sel.value = state.unlockMode || 'pw';
if (status) {
status.textContent = state.pinConfigured
? 'PIN is set on this device.'
: 'No PIN set.';
}
if (setBtn) setBtn.textContent = state.pinConfigured ? 'Change PIN' : 'Set PIN';
if (delBtn) delBtn.style.display = state.pinConfigured ? '' : 'none';
}
// Prompt + setup flow used by the Set/Change PIN button. Validates the
// PIN client-side (412 digits) then writes the DPAPI blob.
async function pinSetupFlow() {
if (!Bridge.active) return toast('PIN requires the Delphi app', 'warning');
if (!state.cryptoKey) return toast('Unlock the vault first', 'warning');
// Setting/changing a PIN creates a new unlock path → treat as a
// sensitive operation. An unattended unlocked vault must not be
// possible for a passerby to pin-backdoor.
const masterPwd = await askReauth(
'Confirm your master password to set or change the PIN.');
if (!masterPwd) return;
try {
const verifier = await computeVerifier(
masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params);
await api('/reauth', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ verifier: verifier }),
});
} catch (err) {
return toast('Wrong master password', 'error');
}
let lastError = '';
let attempts = 0;
for (;;) {
const pin = await promptDialog({
title: state.pinConfigured ? 'Change PIN' : 'Set a PIN',
message: '412 digits. PIN unlock is device-local and never leaves this machine.',
placeholder: 'PIN',
password: true,
okText: 'Save',
error: lastError,
});
// Cancel / Esc / X resolves with `false` (not null) — treat any
// falsy value as "user backed out", not as a wrong attempt.
if (pin === false || pin === null || pin === undefined || pin === '') return;
if (!/^\d{4,12}$/.test(pin)) {
attempts++;
if (attempts >= 5) return toast('Too many invalid attempts', 'error');
lastError = 'PIN must be 412 digits (attempt ' + attempts + ' / 5).';
continue;
}
await setupPin(pin);
refreshPinUnlockUI();
return;
}
}
// ----- Auth screen mode switching ---------------------------------
// Picks which inputs are visible on the lock screen based on the
// user's unlockMode + whether a PIN blob actually exists on this
// device. Always falls back to the master-pw layout if PIN unlock can't
// realistically work (no Delphi bridge, no DPAPI blob).
function applyAuthScreenMode() {
const pwField = document.getElementById('loginPasswordField');
const pinField = document.getElementById('loginPinField');
const useMaster = document.getElementById('loginUseMasterBtn');
if (!pwField || !pinField) return;
const havePin = Bridge.active && state.pinConfigured;
// Drift fix: if the user previously chose pin/both but the blob is
// no longer on disk (auto-wiped after 5 wrong attempts, or removed
// from another session), demote the mode locally so the Settings
// dropdown reflects reality. Server sync happens next time the user
// logs in and openSettings saves changes.
if (!havePin && (state.unlockMode === 'pin' || state.unlockMode === 'both')) {
state.unlockMode = 'pw';
localStorage.setItem('unlockMode', 'pw');
}
const mode = havePin ? (state.unlockMode || 'pw') : 'pw';
pwField.style.display = (mode === 'pin') ? 'none' : '';
pinField.style.display = (mode === 'pin' || mode === 'both') ? '' : 'none';
// PIN-only mode: offer a one-shot escape hatch so the user can fall
// back to master pw if the PIN blob got corrupted or they forgot it.
if (useMaster) useMaster.style.display = (mode === 'pin') ? '' : 'none';
// Required attribute on hidden inputs blocks form submission — keep
// it in sync with visibility.
const pwInput = document.getElementById('loginPassword');
const pinInput = document.getElementById('loginPin');
if (pwInput) pwInput.required = (mode !== 'pin');
if (pinInput) pinInput.required = (mode === 'pin' || mode === 'both');
}
// Try the PIN-unlock cold-start. Mirrors tryQuickUnlock but gated by a
// user-typed PIN. Returns true on success (vault unlocked), false on
// any failure (wrong PIN, missing blob, server refused). The caller is
// responsible for showing the master-pw fallback UI on false.
async function loginViaPin(pin) {
if (!Bridge.active) return false;
if (!pin) return false;
const ok = await pinTryUnwrap(pin);
if (!ok) return false;
const { rawKey, blob } = ok;
// Restore the identity bits we need to call /login with a verifier.
state.username = blob.username || state.username;
state.salt = blob.loginSalt || state.salt;
state.kdfIterations = blob.loginIters || state.kdfIterations || 600000;
state.hashAlgo = blob.hashAlgo || '';
state.argon2Params = blob.argon2Params || null;
try {
state.cryptoKey = await crypto.subtle.importKey(
'raw', rawKey, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']);
} catch (_) { return false; }
try {
// v2 accounts need the decoupled verifier; legacy → key hex.
const verifier = await verifierFromKeyHex(bytesToHex(rawKey), state.hashAlgo);
const r = await api('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: state.username, verifier }),
});
state.token = r.token;
state.csrf = r.csrfToken;
if (r.salt) state.salt = r.salt;
if (r.kdfIterations) state.kdfIterations = r.kdfIterations;
} catch (_) {
// Server credentials drifted (master pw rotation since PIN
// setup). Force user back to master pw + ask them to redo PIN.
return false;
}
sessionStorage.setItem('username', state.username);
sessionStorage.setItem('salt', state.salt);
sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
sessionStorage.setItem('hashAlgo', state.hashAlgo);
sessionStorage.setItem('authToken', state.token);
sessionStorage.setItem('csrfToken', state.csrf);
await persistCryptoKey();
state.locked = false;
state.justRecovered = false;
return true;
}
// 'both' mode helper: master pw has already populated state.cryptoKey
// via the regular login flow. Now check the PIN matches the stored
// blob (validation only — we don't use the unwrapped key from here).
async function verifyPinAfterMasterUnlock(pin) {
const ok = await pinTryUnwrap(pin);
return !!ok;
}
async function enableQuickUnlock() {
if (!Bridge.active) return toast('Quick unlock requires the Delphi app', 'warning');
if (!state.cryptoKey) return toast('Unlock the vault first', 'warning');
const masterPwd = await askReauth(
'Confirm your master password to enable Quick unlock on this device.');
if (!masterPwd) return;
try {
const verifier = await computeVerifier(
masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params);
await api('/reauth', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ verifier: verifier }),
});
} catch (err) {
return toast('Wrong master password', 'error');
}
// Export the raw key + identity. We don't store the session token —
// tryQuickUnlock re-logs in with a verifier derived from the key,
// which always yields a fresh server session (the stored token would
// expire after 24 h and break cold-start restore on a moved exe).
const raw = await crypto.subtle.exportKey('raw', state.cryptoKey);
const blob = JSON.stringify({
v: 2,
username: state.username,
salt: state.salt,
kdfIterations: state.kdfIterations,
// Auth scheme for cold-start verifier selection (see pinBuildBlob).
hashAlgo: state.hashAlgo || '',
argon2Params: state.argon2Params || null,
key: bytesToBase64(raw),
});
const b64 = bytesToBase64(new TextEncoder().encode(blob));
window.location.href = 'cmd://quickunlock/store?data=' + encodeURIComponent(b64);
localStorage.setItem('quickUnlockEnabled', '1');
state.quickUnlockEnabled = true;
if ($('#quickUnlockStatus')) updateQuickUnlockUI();
toast('Quick unlock enabled');
}
async function disableQuickUnlock() {
window.location.href = 'cmd://quickunlock/clear';
localStorage.removeItem('quickUnlockEnabled');
state.quickUnlockEnabled = false;
if ($('#quickUnlockStatus')) updateQuickUnlockUI();
toast('Quick unlock disabled');
}
function updateQuickUnlockUI() {
const lbl = $('#quickUnlockStatus');
const btn = $('#quickUnlockToggleBtn');
if (!lbl || !btn) return;
if (state.quickUnlockEnabled) {
lbl.textContent = 'Enabled on this device.';
btn.textContent = 'Disable';
btn.classList.add('is-danger');
} else {
lbl.textContent = 'Disabled.';
btn.textContent = 'Enable on this device';
btn.classList.remove('is-danger');
}
}
// Try to unlock via DPAPI. Returns true if the vault is now unlocked,
// false otherwise (caller falls back to master-pw login).
async function tryQuickUnlock() {
if (!Bridge.active) return false;
const b64 = await bridgeRequestQuickUnlock();
if (!b64) return false;
localStorage.setItem('quickUnlockEnabled', '1');
state.quickUnlockEnabled = true;
let parsed;
try {
const jsonStr = new TextDecoder().decode(base64ToBytes(b64));
parsed = JSON.parse(jsonStr);
} catch (e) {
return false;
}
if (!parsed || !parsed.key || !parsed.salt || !parsed.username) return false;
// Restore identity + crypto key from the blob.
state.username = parsed.username;
state.salt = parsed.salt;
state.kdfIterations = parsed.kdfIterations || 600000;
state.hashAlgo = parsed.hashAlgo || '';
state.argon2Params = parsed.argon2Params || null;
const rawKey = base64ToBytes(parsed.key);
try {
state.cryptoKey = await crypto.subtle.importKey(
'raw', rawKey,
{ name: 'AES-GCM' }, true, ['encrypt', 'decrypt']);
} catch (e) {
return false;
}
// Always request a fresh session token via /login using the key-derived
// verifier. The stored token (if any) may have expired or been cleaned
// up by the server's session GC, which used to drop the user back to
// the login screen on cold start.
try {
// v2 accounts need the decoupled verifier; legacy → key hex.
const verifier = await verifierFromKeyHex(bytesToHex(rawKey), state.hashAlgo);
const r = await api('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: state.username, verifier }),
});
state.token = r.token;
state.csrf = r.csrfToken;
if (r.salt) state.salt = r.salt;
if (r.kdfIterations) state.kdfIterations = r.kdfIterations;
} catch (e) {
// Login failed — vault credentials may have changed (master pw
// rotation) since Quick Unlock was set up. Force a fresh master-pw
// login; the user will need to re-enable Quick Unlock afterwards.
return false;
}
sessionStorage.setItem('username', state.username);
sessionStorage.setItem('salt', state.salt);
sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
sessionStorage.setItem('hashAlgo', state.hashAlgo);
sessionStorage.setItem('authToken', state.token);
sessionStorage.setItem('csrfToken', state.csrf);
await persistCryptoKey();
state.locked = false;
return true;
}
// ============================================================
// RECOVERY KEY — one-shot emergency access
// ============================================================
//
// Generated at the user's request from Settings. The plaintext code is
// shown exactly once; the server stores only SHA-256(code) for lookup
// + an AES-GCM wrap of the current vault key under a KEK derived from
// PBKDF2(code, kdfSalt, 600k).
//
// Recovery flow (master pw forgotten):
// 1. Auth screen → "Use recovery code" → enter username + code
// 2. Server hashes code, looks up user, verifies match, DELETES the
// recovery row (single-use), returns wrapped key + KEK salt +
// a fresh session.
// 3. Client derives the KEK, unwraps the AES key.
// 4. Client immediately forces a master-password change so the
// account isn't left with the recovery code's KEK as the only
// escape hatch.
// Renders a printable A4 sheet (CSS-only, no external libs) with the
// recovery code in large monospace + a fold-and-stash instruction.
// Removes the print container after the print dialog closes.
function printRecoveryCode(code, username) {
const existing = document.getElementById('printRecoveryArea');
if (existing) existing.remove();
const wrap = document.createElement('div');
wrap.id = 'printRecoveryArea';
const today = new Date().toISOString().slice(0, 10);
wrap.innerHTML =
'<div class="prc-sheet">' +
' <h1>PMServer · Recovery Code</h1>' +
' <div class="prc-meta">' +
' <div><b>Account:</b> ' + (username || '') + '</div>' +
' <div><b>Generated:</b> ' + today + '</div>' +
' </div>' +
' <div class="prc-code">' + code + '</div>' +
' <div class="prc-instructions">' +
' <p><b>Keep this sheet offline and physically secure.</b></p>' +
' <p>Use this code if you forget your master password:</p>' +
' <ol>' +
' <li>On the unlock screen, click <i>"Use recovery code"</i>.</li>' +
' <li>Enter your username and the 16-character code above.</li>' +
' <li>You will be asked to set a new master password — the code is then consumed.</li>' +
' </ol>' +
' <p>The code can be used up to <b>5 times</b>. It is permanently erased the moment you successfully change the master password.</p>' +
' <p style="font-size:11px;color:#666">Anyone with this code can reset your master password — store it like a paper key, not a sticky note.</p>' +
' </div>' +
'</div>';
document.body.appendChild(wrap);
// Restore on print-end and on focus (Edge fires focus when preview closes).
const cleanup = () => {
const n = document.getElementById('printRecoveryArea');
if (n) n.remove();
window.removeEventListener('afterprint', cleanup);
window.removeEventListener('focus', cleanup);
};
window.addEventListener('afterprint', cleanup);
window.addEventListener('focus', cleanup);
setTimeout(() => window.print(), 50);
}
// Random recovery code: 16 chars in 4 groups of 4. ~96 bits entropy
// from a 36-char alphabet (no ambiguous chars: no 0/O/I/l/1) so the
// printed form is misreading-resistant.
function generateRecoveryCode() {
const A = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // 32 chars
const bytes = crypto.getRandomValues(new Uint8Array(16));
let s = '';
for (let i = 0; i < 16; i++) {
if (i > 0 && i % 4 === 0) s += '-';
s += A[bytes[i] % A.length];
}
return s;
}
async function sha256HexLocal(input) {
const buf = new TextEncoder().encode(input);
const hashBuf = await crypto.subtle.digest('SHA-256', buf);
const bytes = new Uint8Array(hashBuf);
let hex = '';
for (const b of bytes) hex += b.toString(16).padStart(2, '0');
return hex;
}
// Derive a KEK from the recovery code + per-row salt, then wrap the
// supplied AES key bytes under it. Returns base64 ciphertext + IV.
async function wrapAesKeyForRecovery(aesKeyBytes, recoveryCode, kdfSaltHex) {
const saltBytes = new TextEncoder().encode(kdfSaltHex); // match deriveKey's quirk
const km = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(recoveryCode),
'PBKDF2', false, ['deriveKey']);
const kek = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: saltBytes, iterations: 600000, hash: 'SHA-256' },
km,
{ name: 'AES-GCM', length: 256 },
false, ['encrypt', 'decrypt']);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, kek, aesKeyBytes);
return { wrappedKey: bytesToBase64(ct), wrappedIv: bytesToBase64(iv) };
}
async function unwrapAesKeyFromRecovery(wrappedKeyB64, wrappedIvB64, recoveryCode, kdfSaltHex) {
const saltBytes = new TextEncoder().encode(kdfSaltHex);
const km = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(recoveryCode),
'PBKDF2', false, ['deriveKey']);
const kek = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: saltBytes, iterations: 600000, hash: 'SHA-256' },
km,
{ name: 'AES-GCM', length: 256 },
false, ['encrypt', 'decrypt']);
const iv = base64ToBytes(wrappedIvB64);
const ct = base64ToBytes(wrappedKeyB64);
return await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, kek, ct); // raw bytes
}
// Generate a new recovery key for the logged-in user. Shows the plaintext
// code in a modal that the user must explicitly acknowledge before closing.
async function doGenerateRecoveryKey() {
if (!state.cryptoKey) { return toast('Vault locked', 'warning'); }
const masterPwd = await askReauth(
'Confirm your master password to generate a recovery key.');
if (!masterPwd) return;
// Generate the code + a fresh per-row salt for the KEK PBKDF2. Salt is
// per-recovery so regenerating doesn't reuse the same KDF parameters.
const code = generateRecoveryCode();
const codeHash = await sha256HexLocal(code);
const kdfSalt = randomHexSalt();
// Export the current AES key as raw bytes so we can wrap it under
// the recovery KEK. The export only works because deriveKey was
// called with `extractable=true` — already the case in our code.
const rawKey = await crypto.subtle.exportKey('raw', state.cryptoKey);
const { wrappedKey, wrappedIv } = await wrapAesKeyForRecovery(
rawKey, code, kdfSalt);
try {
// Send a verifier instead of the master pw — server proves the
// user still knows the master pw without ever seeing the plaintext.
const verifier = await computeVerifier(
masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params);
await api('/recovery-key/setup', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
verifier: verifier,
codeHash: codeHash,
kdfSalt: kdfSalt,
wrappedKey: wrappedKey,
wrappedIv: wrappedIv,
}),
});
} catch (err) {
return toast('Setup failed: ' + (err.message || ''), 'error');
}
// Refresh the Settings button label
state.recoveryConfigured = true;
if ($('#recoveryStatus')) updateRecoveryStatusLabel();
// Show the code ONCE. Use the confirm modal so the user has to
// explicitly click "I saved it" before the value vanishes.
//
// Wire the inline Copy button BEFORE awaiting the dialog: confirmDialog
// injects the HTML synchronously, so a 0-ms task fires after the DOM
// is in place but before the user can interact. CSP forbids inline
// onclick handlers, hence the addEventListener route.
setTimeout(() => {
const btn = document.getElementById('copyRecoveryCodeBtn');
if (btn) {
btn.addEventListener('click', () => {
// Same path as password copy: secure-clipboard via Delphi
// (excluded from Win+V history, auto-cleared after 30s) when
// running embedded, navigator.clipboard with manual scrub
// otherwise.
if (Bridge.copySecure(code, 30000)) {
toast('Recovery code copied · clears in 30s');
} else {
navigator.clipboard.writeText(code).then(() => {
toast('Recovery code copied · clears in 30s');
setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000);
});
}
});
}
const printBtn = document.getElementById('printRecoveryCodeBtn');
if (printBtn) {
printBtn.addEventListener('click', () => printRecoveryCode(code, state.username));
}
}, 0);
await confirmDialog({
title: 'Your recovery code',
message: '<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>' +
'<button type="button" class="btn btn-ghost btn-sm" ' +
'id="printRecoveryCodeBtn" title="Print this code">' +
'<svg><use href="#i-printer"/></svg> Print</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;
// Account's auth scheme — needed so the recovery-mode master-pw change
// proves the current key under the right verifier transform.
state.hashAlgo = r.hashAlgo || '';
state.argon2Params = r.argon2 || null;
sessionStorage.setItem('authToken', state.token);
sessionStorage.setItem('csrfToken', state.csrf);
sessionStorage.setItem('salt', state.salt);
sessionStorage.setItem('username', state.username);
sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
sessionStorage.setItem('hashAlgo', state.hashAlgo);
// Import the raw key bytes as a fresh AES-GCM CryptoKey (extractable
// so master-pw change can later re-export and re-wrap as needed).
state.cryptoKey = await crypto.subtle.importKey(
'raw', rawKey, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']);
await persistCryptoKey();
const remaining = (typeof r.remainingUses === 'number') ? r.remainingUses : 0;
if (remaining <= 0) {
toast('Last recovery use — set a new master password now or the code is gone forever', 'warning');
} else {
toast('Recovery code used. ' + remaining + ' use(s) left before it expires. Change your master password now.', 'warning');
}
state.justRecovered = true;
await enterApp();
setTimeout(openChangeMasterModal, 300);
}
// ============================================================
// CHANGE MASTER PASSWORD
// ============================================================
//
// Two-step flow:
// 1. Modal collects current pw + new pw (×2) + validates locally.
// 2. Client re-encrypts every entry under the new key (derived from
// new pw + freshly-generated salt) and sends the whole payload to
// /change-master-password. Server verifies current pw, then commits
// user row + all entry ciphertexts in one transaction.
// On success: state.salt + state.cryptoKey are swapped, the user stays
// logged in (current session preserved), all OTHER sessions invalidated.
// 64-char hex salt — matches the format the server expects and the shape
// produced by Delphi RandomHex(32). Crypto-secure RNG.
function randomHexSalt() {
const bytes = crypto.getRandomValues(new Uint8Array(32));
let hex = '';
for (const b of bytes) hex += b.toString(16).padStart(2, '0');
return hex;
}
function openChangeMasterModal() {
$('#cmCurrentPwd').value = '';
$('#cmNewPwd').value = '';
$('#cmConfirmPwd').value = '';
const errEl = $('#cmError');
if (errEl) { errEl.textContent = ''; errEl.style.display = 'none'; }
const curInput = $('#cmCurrentPwd');
const curWrap = curInput.closest('.field') || curInput.parentElement;
const titleEl = $('#changeMasterTitle');
if (state.justRecovered) {
if (curWrap) curWrap.classList.add('is-hidden');
if (curInput) curInput.required = false;
if (titleEl) titleEl.textContent = 'Set new master password';
} else {
if (curWrap) curWrap.classList.remove('is-hidden');
if (curInput) curInput.required = true;
if (titleEl) titleEl.textContent = 'Change master password';
}
$('#changeMasterModal').classList.remove('is-hidden');
setTimeout(() => {
if (state.justRecovered) $('#cmNewPwd').focus();
else $('#cmCurrentPwd').focus();
}, 50);
}
function closeChangeMasterModal() {
$('#changeMasterModal').classList.add('is-hidden');
}
function showCmError(msg) {
const el = $('#cmError');
if (!el) return;
el.textContent = msg;
el.style.display = '';
}
async function doChangeMasterPassword() {
const recoveryMode = !!state.justRecovered;
const curPwd = $('#cmCurrentPwd').value;
const newPwd = $('#cmNewPwd').value;
const confPwd = $('#cmConfirmPwd').value;
if (!recoveryMode && !curPwd) return showCmError('All fields are required');
if (!newPwd || !confPwd) return showCmError('All fields are required');
if (newPwd.length < 8) return showCmError('New password must be at least 8 characters');
if (newPwd !== confPwd) return showCmError('New password and confirmation do not match');
if (!recoveryMode && newPwd === curPwd) return showCmError('New password must differ from the current one');
// Disable the confirm button so a double-click doesn't fire two
// re-encryption passes in parallel.
const btn = $('#cmConfirmBtn');
if (btn) btn.disabled = true;
// Rotation re-encrypts every entry (and every attachment) under the new
// key — seconds to tens of seconds on a big vault. Show a spinner so it
// doesn't look frozen; 0ms yield lets it paint before the loop blocks.
showBusy('Re-encrypting vault…');
await new Promise(r => setTimeout(r, 0));
try {
// Step 1: generate the new salt and derive the new AES key + verifier.
// Also compute the verifier for the CURRENT pw so the server can
// authenticate the change without ever seeing the plaintext.
const newSalt = randomHexSalt();
// Rotate onto Argon2id (memory-hard KDF) + decoupled verifier — a
// master-pw change re-derives + re-encrypts everything anyway, so it's
// the natural migration point for existing PBKDF2 accounts.
const newArgon = { ...ARGON2_DEFAULT_PARAMS };
const newDerived = await deriveKeyAndVerifier(
newPwd, newSalt, 600000, HASH_ALGO_ARGON2, newArgon);
const newKey = newDerived.cryptoKey;
let currentVerifier;
if (recoveryMode) {
// Current pw is proven via the in-memory recovered key. The
// server compares under the account's CURRENT algo, so apply the
// same verifier transform (v2 → decoupled, else → key hex).
const rawCurrentKey = new Uint8Array(await crypto.subtle.exportKey('raw', state.cryptoKey));
currentVerifier = await verifierFromKeyHex(bytesToHex(rawCurrentKey), state.hashAlgo);
} else {
currentVerifier = await computeVerifier(
curPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params);
}
// Step 2: re-encrypt every entry's password AND every entry's TOTP
// secret (if present) under the new key. The current state.cryptoKey
// still decrypts the existing ciphertext.
// Hoisted so the attachment re-encryption loop after the server
// round-trip can still reach the OLD key.
const oldKey = state.cryptoKey;
const encrypted = [];
let _cmDone = 0;
const _cmTotal = state.entries.length;
for (const e of state.entries) {
_cmDone++;
if (_cmTotal > 10 && (_cmDone % 5 === 0 || _cmDone === _cmTotal))
updateBusy('Re-encrypting entries… ' + _cmDone + '/' + _cmTotal);
const plain = await decryptPwd(e.encrypted_password, e.iv);
if (plain === '[ERROR]') {
throw new Error('Could not decrypt entry id=' + e.id);
}
// Swap the key around encryptPwd so it picks up the new one.
state.cryptoKey = newKey;
try {
const re = await encryptPwd(plain);
let totpEnc = '', totpIv = '';
if (e.totp_secret && e.totp_iv) {
state.cryptoKey = oldKey;
const plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv);
state.cryptoKey = newKey;
if (plainTotp !== '[ERROR]') {
const t = await encryptPwd(plainTotp);
totpEnc = t.encrypted;
totpIv = t.iv;
}
}
// Custom fields — same dance: decrypt with OLD key, encrypt
// with NEW key, send fresh ciphertext.
let cfEnc = '', cfIv = '';
if (e.custom_fields && e.custom_fields_iv) {
state.cryptoKey = oldKey;
const plainCf = await decryptCustomFields(
e.custom_fields, e.custom_fields_iv);
state.cryptoKey = newKey;
if (plainCf.length > 0) {
const c = await encryptCustomFields(plainCf);
cfEnc = c.encrypted;
cfIv = c.iv;
}
}
encrypted.push({
id: e.id,
encrypted_password: re.encrypted,
iv: re.iv,
totp_secret: totpEnc,
totp_iv: totpIv,
custom_fields: cfEnc,
custom_fields_iv: cfIv,
});
} finally {
state.cryptoKey = oldKey; // restore until server confirms
}
}
// Step 3: send the atomic request. Server verifies the current pw,
// updates the user row, swaps every entry's ciphertext, returns
// the new salt + iter count.
const r = await api('/change-master-password', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
currentVerifier: currentVerifier,
newVerifier: newDerived.verifier,
newSalt: newSalt,
hashAlgo: HASH_ALGO_ARGON2,
argon2: newArgon,
entries: encrypted,
}),
});
// Step 4: server committed → switch the in-memory key & salt, refresh
// the cached ciphertexts, persist for F5 survival.
state.salt = r.salt || newSalt;
state.kdfIterations = r.kdfIterations || 600000;
// The account is now on the Argon2id + decoupled-verifier scheme.
state.hashAlgo = HASH_ALGO_ARGON2;
state.argon2Params = newArgon;
state.cryptoKey = newKey;
await persistCryptoKey();
sessionStorage.setItem('salt', state.salt);
sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
sessionStorage.setItem('hashAlgo', state.hashAlgo);
sessionStorage.setItem('argon2Params', JSON.stringify(state.argon2Params));
// Server invalidated every session for this user (including ours)
// and minted a fresh pair — adopt them so subsequent API calls
// don't bounce with "invalid session".
if (r.token) {
state.token = r.token;
sessionStorage.setItem('authToken', r.token);
}
if (r.csrf) {
state.csrf = r.csrf;
sessionStorage.setItem('csrfToken', r.csrf);
}
// Re-encrypt attachments under the new vault key. The server-side
// change-master endpoint can't touch these (they're opaque to it),
// so we fetch each blob, decrypt with the OLD key, encrypt with
// the NEW one, and ship it back. Best-effort — a single failure
// doesn't undo the rotation, but the user gets a warning toast.
try {
const allAttach = await api('/attachments/all', { headers: authHeaders() });
if (allAttach && allAttach.length > 0) {
let failed = 0;
let _atDone = 0;
const _atTotal = allAttach.length;
for (const meta of allAttach) {
_atDone++;
updateBusy('Re-encrypting attachments… ' + _atDone + '/' + _atTotal);
try {
// state.cryptoKey is already newKey at this point.
// Swap to oldKey for decryption, then back for upload.
const full = await api('/attachments/' + meta.id,
{ headers: authHeaders() });
state.cryptoKey = oldKey;
const plainBytes = await decryptBlobBytes(
full.encrypted_blob, full.iv);
state.cryptoKey = newKey;
const re = await encryptBlobBytes(plainBytes);
await api('/attachments/' + meta.id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
encrypted_blob: re.encrypted,
iv: re.iv,
}),
});
} catch (e) {
failed++;
}
}
state.cryptoKey = newKey; // make sure we end on the new key
if (failed > 0) {
toast(failed + ' attachment(s) failed to re-encrypt — they may be unreadable. Re-upload from a backup.', 'error');
} else {
toast('Attachments re-encrypted');
}
}
} catch (e) {
toast('Attachment re-encryption skipped: ' + (e && e.message || e), 'warning');
}
for (let i = 0; i < state.entries.length; i++) {
const nc = encrypted[i];
state.entries[i].encrypted_password = nc.encrypted_password;
state.entries[i].iv = nc.iv;
state.entries[i].totp_secret = nc.totp_secret || null;
state.entries[i].totp_iv = nc.totp_iv || null;
}
// Quick-unlock blob holds the raw AES key bundle. It stores the key
// directly (DPAPI-wrapped, no user secret), so instead of forcing
// the user to re-enable it after every rotation we transparently
// RE-WRAP it with the new key + salt + iters + algo. state.* already
// reflects the new values at this point (step 4 above). Cold-start
// then re-logs in with a verifier derived from the new key.
if (Bridge.active && localStorage.getItem('quickUnlockEnabled') === '1') {
try {
const raw = await crypto.subtle.exportKey('raw', state.cryptoKey);
const blob = JSON.stringify({
v: 2,
username: state.username,
salt: state.salt,
kdfIterations: state.kdfIterations,
hashAlgo: state.hashAlgo || '',
argon2Params: state.argon2Params || null,
key: bytesToBase64(new Uint8Array(raw)),
});
const b64 = bytesToBase64(new TextEncoder().encode(blob));
window.location.href = 'cmd://quickunlock/store?data=' +
encodeURIComponent(b64);
// stays enabled — flag + state unchanged
} catch (_) {
// Re-wrap failed → fall back to clearing so we never leave a
// stale (old-key) blob that would decrypt to garbage.
window.location.href = 'cmd://quickunlock/clear';
localStorage.removeItem('quickUnlockEnabled');
state.quickUnlockEnabled = false;
}
}
// Yield so the quick-unlock store navigation above is processed
// before the PIN clear below — both go through window.location.href
// and back-to-back assignments can coalesce (only the last lands),
// which would drop the quick-unlock re-wrap and leave a stale blob.
await new Promise(r => setTimeout(r, 0));
// Same problem for the PIN blob — wrapped key is from the old
// master, server verifier won't match anymore. Wipe so the user
// gets a clean fallback to master pw next time.
if (Bridge.active && state.pinConfigured) {
bridgePinClear();
state.pinConfigured = false;
if (state.unlockMode === 'pin' || state.unlockMode === 'both') {
state.unlockMode = 'pw';
localStorage.setItem('unlockMode', 'pw');
}
// Settings panel is open during the rotation — refresh its
// PIN row so the "Change/Remove PIN" buttons + status text
// reflect the wipe without needing to close + reopen.
if (typeof refreshPinUnlockUI === 'function') refreshPinUnlockUI();
}
state.justRecovered = false;
closeChangeMasterModal();
toast(recoveryMode ? 'New master password set' : 'Master password changed · other sessions signed out');
} catch (err) {
if (err.status === 401) {
showCmError('Current password is incorrect');
} else if (err.status === 429 && err.body && err.body.retry_after) {
showCmError('Account locked, try again in ' +
Math.ceil(err.body.retry_after / 60) + ' min');
} else {
showCmError('Failed: ' + (err.message || 'unknown error'));
}
} finally {
hideBusy();
if (btn) btn.disabled = false;
}
}
// ============================================================
// IMPORT / EXPORT — extracted to js/app.import.js (§3.1), loaded as
// a separate <script> before this file (pure declarations).
// ============================================================
// ============================================================
// AUTOFILL (Ctrl+Shift+L global hotkey)
// ============================================================
// Win32 modifier flags for RegisterHotKey.
const WIN32_MOD = { alt: 0x0001, ctrl: 0x0002, shift: 0x0004, win: 0x0008 };
// Format a combo for human display: "Ctrl+Shift+L".
function autofillComboLabel(c) {
if (!c || !c.key) return '— not set —';
const parts = [];
if (c.ctrl) parts.push('Ctrl');
if (c.alt) parts.push('Alt');
if (c.shift) parts.push('Shift');
if (c.win) parts.push('Win');
parts.push(c.key);
return parts.join('+');
}
// Convert a combo to the Win32 (mods bitmask, virtual-key code) pair that
// Delphi's RegisterHotKey takes. key='A'..'Z'/'0'..'9' → ASCII code;
// 'F1'..'F12' → 0x70..0x7B.
function autofillComboToWin32(c) {
let mods = 0;
if (c.ctrl) mods |= WIN32_MOD.ctrl;
if (c.alt) mods |= WIN32_MOD.alt;
if (c.shift) mods |= WIN32_MOD.shift;
if (c.win) mods |= WIN32_MOD.win;
let vk = 0;
const k = (c.key || '').toUpperCase();
if (/^F([1-9]|1[0-2])$/.test(k)) vk = 0x70 + parseInt(k.slice(1)) - 1;
else if (k.length === 1 && k >= 'A' && k <= 'Z') vk = k.charCodeAt(0);
else if (k.length === 1 && k >= '0' && k <= '9') vk = k.charCodeAt(0);
return { mods, vk };
}
// Validate a captured combo. Requires at least one modifier (otherwise a
// single key would steal that letter globally) and a valid main key.
function autofillComboValid(c) {
if (!c) return false;
if (!(c.ctrl || c.alt || c.win)) return false; // shift-only is unreliable
const w = autofillComboToWin32(c);
return w.vk !== 0;
}
// Capture a key combo from a single keydown event. Returns null if the
// event is "incomplete" (only modifiers pressed so far) or Escape.
function autofillCaptureFromEvent(e) {
const k = e.key;
if (k === 'Escape') return 'cancel';
// Ignore pure-modifier keydowns (user is still building the combo).
if (k === 'Control' || k === 'Shift' || k === 'Alt' ||
k === 'Meta' || k === 'OS') return null;
// Accept letter, digit, F1-F12.
let key = null;
if (k.length === 1 && /[a-z0-9]/i.test(k)) {
key = k.toUpperCase();
} else if (/^F([1-9]|1[0-2])$/i.test(k)) {
key = k.toUpperCase();
} else {
return 'invalid';
}
return {
ctrl: !!e.ctrlKey,
shift: !!e.shiftKey,
alt: !!e.altKey,
win: !!e.metaKey,
key,
};
}
// Push current state to Delphi (toggle + both combos) and persist.
// Called after any change so Delphi's RegisterHotKey reflects state.
function autofillPushHotkeys() {
localStorage.setItem('autofillHotkeyFull', JSON.stringify(state.autofillHotkeyFull));
localStorage.setItem('autofillHotkeyPwd', JSON.stringify(state.autofillHotkeyPwd));
localStorage.setItem('quickSearchHotkey', JSON.stringify(state.quickSearchHotkey));
if (Bridge.active) {
Bridge.setAutofillHotkeys(state.autofillEnabled, {
full: state.autofillHotkeyFull,
password: state.autofillHotkeyPwd,
quickSearch: state.quickSearchHotkey,
});
}
}
// Extract a bare hostname from a site string for fuzzy matching.
// "https://www.github.com/login" → "github.com"
// Strip the browser brand suffix that lives at the end of every tab title
// ("Some Page - Google Chrome", "Page — Mozilla Firefox", etc.). Without
// this, entries whose site is "google" / "mozilla" / "edge" would match
// every single page that has Chrome / Firefox / Edge as the browser brand.
const BROWSER_SUFFIX_RE =
/\s*[-—–|]\s*(google chrome|chromium|mozilla firefox|firefox|microsoft edge|edge|brave|opera|vivaldi|safari|tor browser|tor|arc)\s*$/i;
function autofillStripBrowserSuffix(title) {
return (title || '').replace(BROWSER_SUFFIX_RE, '').trim();
}
function autofillExtractHost(site) {
return site.toLowerCase()
.replace(/^https?:\/\//i, '')
.replace(/^www\./i, '')
.split('/')[0]
.split(':')[0];
}
// Get the second-level domain (brand part) from a hostname.
// "github.com" → "github" ; "mail.google.com" → "google" ; "x.com" → "x"
function autofillSLD(host) {
const parts = host.split('.').filter(p => p.length > 0);
if (parts.length <= 1) return host;
return parts[parts.length - 2];
}
// Escape a string for safe insertion into a RegExp.
function autofillEscapeRegex(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// Score how well a vault entry matches the foreground window title.
// Returns 0 (no match) or a positive integer (higher = better).
//
// Strategy (browser titles rarely contain the full hostname — usually
// just the brand name, e.g. "Sign in to GitHub" or "X. C'est… - Google Chrome"):
// 1. Full hostname substring → strongest (score 1000 + len)
// 2. SLD ≥3 chars as substring → medium (score 500 + len)
// 3. SLD <3 chars as word → weak (score 100), requires word
// boundaries to avoid matching "x" inside arbitrary words.
function autofillScore(entry, titleLower) {
// 1. Display name (entry.title) lowercased substring — strongest brand
// match. Skips when title is empty or same as site (already tested
// via the site path below).
const displayTitle = (entry.title || '').trim().toLowerCase();
if (displayTitle.length >= 2 && titleLower.includes(displayTitle))
return 1500 + displayTitle.length;
if (!entry.site) return 0;
const host = autofillExtractHost(entry.site);
if (host.length < 2) return 0;
// 2. Full hostname (rare in tab titles, but strongest URL signal)
if (titleLower.includes(host)) return 1000 + host.length;
// 3/4. Second-level domain
const sld = autofillSLD(host);
if (sld.length === 0) return 0;
if (sld.length >= 3) {
if (titleLower.includes(sld)) return 500 + sld.length;
return 0;
}
// Short SLD ("x", "qq", "vk"…) — require word boundaries so we don't
// match the letter inside random words.
const re = new RegExp('(^|[^a-z0-9])' + autofillEscapeRegex(sld) +
'([^a-z0-9]|$)', 'i');
if (re.test(titleLower)) return 100;
return 0;
}
// Called by Bridge.onAutofillRequest when a hotkey fires.
// kind: 'full' = Ctrl+Shift+L (user + Tab + pwd) ; 'password' = Ctrl+Shift+P.
async function autofillHandleRequest(windowTitle, kind) {
if (!state.autofillEnabled) return;
if (!state.cryptoKey || state.locked || !state.token) {
// Vault is locked — bring the app to the front so the user can
// unlock immediately, rather than silently no-op'ing the hotkey.
Bridge.cancelAutofill();
Bridge.focusApp();
setTimeout(() => {
const pwd = document.getElementById('loginPassword');
const user = document.getElementById('loginUsername');
if (pwd && !document.getElementById('authScreen').classList.contains('is-hidden')) {
if (user && !user.value) user.focus();
else pwd.focus();
}
}, 80);
toast('Vault is locked — unlock to autofill', 'warning');
return;
}
const titleLower = autofillStripBrowserSuffix(windowTitle).toLowerCase();
const scored = state.entries
.map(e => ({ entry: e, score: autofillScore(e, titleLower) }))
.filter(x => x.score > 0)
.sort((a, b) => b.score - a.score);
if (scored.length === 0) {
toast('Autofill: no match for "' + windowTitle.slice(0, 40) + '"', 'warning');
Bridge.cancelAutofill();
return;
}
if (scored.length === 1) {
await autofillFillEntry(scored[0].entry, kind);
return;
}
// Multiple candidates — show picker. kind is captured so clicking a
// candidate honours password-only mode.
openAutofillPicker(scored.map(x => x.entry), windowTitle, kind);
}
// Decrypt and type an entry. kind = 'full' or 'password'.
async function autofillFillEntry(entry, kind) {
const password = await decryptPwd(entry.encrypted_password, entry.iv);
if (password === '[ERROR]') {
toast('Autofill: decryption error', 'error');
Bridge.cancelAutofill();
return;
}
// password-only kind → empty username → Delphi skips Tab.
// full kind with empty entry.username → also no Tab (Delphi handles it).
const user = (kind === 'password') ? '' : (entry.username || '');
Bridge.executeAutofill(user, password);
toast((kind === 'password' ? 'Password filled: ' : 'Autofilled: ') + entry.site);
// Audit (best-effort, ignore failures)
fetch('' + '/audit', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
action: kind === 'password' ? 'autofill_pwd' : 'autofill',
site: entry.site,
}),
}).catch(() => {});
}
// Picker modal for multi-match case. kind is forwarded to autofillFillEntry
// so the user's hotkey intent (full vs password-only) is preserved through
// the manual choice.
function openAutofillPicker(entries, windowTitle, kind) {
// Bring the app to front so the picker is unambiguously visible —
// otherwise the modal opens behind / next to the user's original
// window (e.g. Notepad) and easy to miss. ExecuteAutofill restores
// the original target HWND via ForceForegroundWindow on selection.
Bridge.focusApp();
const list = $('#autofillPickerList');
list.innerHTML = '';
entries.forEach(e => {
const btn = el('button', {
class: 'autofill-pick-btn',
on: {
click: async () => {
closeAutofillPicker(false);
await autofillFillEntry(e, kind);
},
},
});
btn.appendChild(el('span', { class: 'autofill-pick-site' }, entryDisplayName(e)));
if (e.username) {
btn.appendChild(el('span', { class: 'autofill-pick-user' }, e.username));
}
list.appendChild(btn);
});
const head = (kind === 'password' ? 'Pick entry (password only) — ' : 'Pick entry — ')
+ entries.length + ' match "' + windowTitle.slice(0, 30) + '…"';
$('#autofillPickerTitle').textContent = head;
$('#autofillPickerModal').classList.remove('is-hidden');
}
function closeAutofillPicker(notifyCancel = true) {
$('#autofillPickerModal').classList.add('is-hidden');
if (notifyCancel) Bridge.cancelAutofill();
}
// ============================================================
// VIEWS / NAV
// ============================================================
async function setView(v) {
state.view = v;
state.currentPage = 1;
if (v === 'trash') {
await loadTrash();
}
render();
}
function toggleTheme() {
setTheme(state.theme === 'dark' ? 'light' : 'dark');
}
function setTheme(t) {
state.theme = t;
document.documentElement.setAttribute('data-theme', t);
localStorage.setItem('theme', t);
const sel = $('#settingTheme');
if (sel) sel.value = t;
Bridge.syncTitleBarTheme(t);
}
function openSettings() {
$('#settingTheme').value = state.theme;
$('#settingSort').value = state.sortBy + ':' + state.sortDir;
$('#settingAutoLock').value = String(state.autoLock);
$('#settingAskDelete').checked = state.askBeforeDelete;
$('#settingCompact').checked = state.compactActions;
$('#settingMaskUser').checked = state.maskUsernames;
$('#settingHIBP').checked = state.hibpEnabled;
$('#settingShowSite').checked = state.showSiteOnCards;
$('#settingFavicons').checked = state.faviconsEnabled;
// Action buttons + toggle row only meaningful when the Delphi bridge
// is available (the PHP frontend has no outbound proxy).
$('#settingFaviconsRow').style.display = Bridge.active ? '' : 'none';
$('#settingFaviconActionsRow').style.display = Bridge.active ? 'flex' : 'none';
$('#settingAutofill').checked = state.autofillEnabled;
$('#settingAutofillRow').style.display = Bridge.active ? '' : 'none';
// Hotkey capture buttons — labels reflect current combos.
$('#settingAutofillFullCombo').textContent = autofillComboLabel(state.autofillHotkeyFull);
$('#settingAutofillPwdCombo').textContent = autofillComboLabel(state.autofillHotkeyPwd);
$('#settingQuickSearchCombo').textContent = autofillComboLabel(state.quickSearchHotkey);
$('#settingAutofillHotkeysRow').style.display = Bridge.active ? '' : 'none';
// Start-with-Windows toggle: only meaningful inside the Delphi host
// (registry access). Hide for the PHP frontend.
$('#settingAutoStartRow').style.display = Bridge.active ? '' : 'none';
if (Bridge.active) {
// Fire-and-forget: the bridge response updates the checkbox via
// Bridge.onAutoStartStatus.
Bridge.getAutoStart();
}
$('#settingTrayNotif').checked = state.trayNotificationsEnabled !== false;
$('#settingTrayNotifRow').style.display = Bridge.active ? '' : 'none';
$('#settingTrashPurge').value = String(state.trashAutoPurgeDays || 0);
$('#settingPasswordExpiry').value = String(state.passwordExpiryDays || 0);
$('#settingEditorPosition').value = state.editorPosition || 'right';
$('#settingConfirmUnsaved').checked = state.confirmOnUnsaved !== false;
// PIN unlock — only meaningful when DPAPI is available.
const pinField = document.getElementById('pinUnlockField');
if (pinField) {
pinField.style.display = Bridge.active ? '' : 'none';
if (Bridge.active) bridgePinStatus().then(has => {
state.pinConfigured = has;
refreshPinUnlockUI();
});
}
$('#settingUser').textContent = state.username;
renderUserAvatar(); // sync the Account-section preview + Remove button
// Hide the version row entirely in the PHP/web frontend (no bridge).
if (Bridge.active) {
$('#settingVersionRow').style.display = '';
Promise.all([Bridge.getAppVersion(), Bridge.getLaunchMode()]).then(([v, mode]) => {
const tag = mode === 'auto' ? ' · started with Windows'
: mode === 'manual' ? ' · launched manually'
: '';
$('#settingVersion').textContent = (v ? 'v' + v : '(unknown)') + tag;
});
} else {
$('#settingVersionRow').style.display = 'none';
}
// Async: query server for recovery key state and update the label
refreshRecoveryStatus();
// Sync the quick-unlock UI from the actual DPAPI file (in case the
// user cleared the file outside the app or the file is gone for some
// other reason like a Windows profile reset).
if (Bridge.active) {
bridgeQuickUnlockStatus().then(actual => {
if (!actual && state.quickUnlockEnabled) {
// Drift: localStorage said enabled but Delphi has no blob.
localStorage.removeItem('quickUnlockEnabled');
state.quickUnlockEnabled = false;
}
updateQuickUnlockUI();
});
} else {
updateQuickUnlockUI();
}
// Auto-backup: only meaningful with the Delphi bridge (filesystem).
if (Bridge.active) {
loadAutoBackupConfig().then(refreshAutoBackupUI);
} else {
refreshAutoBackupUI(null);
}
// Sync: same gate (Bridge required for WebDAV HTTP + DPAPI prefs).
const syncField = document.getElementById('syncField');
if (syncField) {
syncField.style.display = Bridge.active ? '' : 'none';
if (Bridge.active) loadSyncConfig().then(refreshSyncUI);
}
$('#settingsPanel').classList.add('is-open');
// Reset the search filter every time Settings is re-opened so the
// user lands on the full panel, not the last filtered view.
const si = $('#settingsSearch');
if (si) { si.value = ''; applySettingsSearch(''); }
}
function closeSettings() {
$('#settingsPanel').classList.remove('is-open');
}
// Filter the settings panel by text. Matches against the label + the
// section body so e.g. "Ctrl" finds the hotkeys section via its button
// labels. Empty query = show everything. Adds a "No matches" hint when
// every section is hidden.
function applySettingsSearch(rawQuery) {
const panel = $('#settingsPanel');
if (!panel) return;
const wrap = $('.settings-search-wrap');
const q = (rawQuery || '').trim().toLowerCase();
wrap && wrap.classList.toggle('has-query', q.length > 0);
const sections = panel.querySelectorAll('.slideover-body > .slideover-field');
let totalShownRows = 0;
sections.forEach(sec => {
// No query: reset everything to visible.
if (!q) {
sec.classList.remove('is-search-hidden');
sec.querySelectorAll('.is-search-hidden').forEach(n =>
n.classList.remove('is-search-hidden'));
return;
}
// Section label text is part of the section's identity (e.g.
// "Sync" or "Security") — a query that hits the label keeps the
// whole section visible without per-row filtering.
const labelEl = sec.querySelector('.slideover-field-label');
const labelTxt = (labelEl ? labelEl.innerText : '').toLowerCase();
const labelMatch = labelTxt && labelTxt.indexOf(q) >= 0;
// Per-row filter: each .setting-row is an individually-matchable
// entry. Non-row children (paragraphs, button groups, hints) keep
// their default visibility — they're context for whichever row is
// shown, not standalone matches.
const rows = sec.querySelectorAll(':scope > .setting-row');
let rowMatches = 0;
rows.forEach(row => {
if (labelMatch) {
row.classList.remove('is-search-hidden');
rowMatches++;
return;
}
const txt = (row.innerText || '').toLowerCase();
const hit = txt.indexOf(q) >= 0;
row.classList.toggle('is-search-hidden', !hit);
if (hit) rowMatches++;
});
// Section has no rows at all (button-only section like Import /
// Recovery): match against the whole section text.
const sectionHasRows = rows.length > 0;
const sectionHit = labelMatch ||
(!sectionHasRows && (sec.innerText || '').toLowerCase().indexOf(q) >= 0) ||
rowMatches > 0;
sec.classList.toggle('is-search-hidden', !sectionHit);
if (sectionHit) totalShownRows += sectionHasRows ? rowMatches : 1;
});
let banner = panel.querySelector('.settings-no-results');
if (!banner) {
banner = el('div', { class: 'settings-no-results' },
'No settings match your search.');
const body = panel.querySelector('.slideover-body');
if (body) body.appendChild(banner);
}
banner.classList.toggle('is-visible', q.length > 0 && totalShownRows === 0);
}
// ---- Auto-lock with 30s warning countdown -------------------
const WARNING_SECONDS = 30;
let autoLockTimer = null;
let warningTimer = null;
let countdownInterval = null;
function hideIdleWarning() {
$('#idleWarning').classList.add('is-hidden');
if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; }
}
function showIdleWarning() {
$('#idleCountdown').textContent = WARNING_SECONDS;
$('#idleWarning').classList.remove('is-hidden');
let s = WARNING_SECONDS;
countdownInterval = setInterval(() => {
s -= 1;
$('#idleCountdown').textContent = Math.max(0, s);
if (s <= 0) { clearInterval(countdownInterval); countdownInterval = null; }
}, 1000);
}
function resetAutoLock() {
if (autoLockTimer) clearTimeout(autoLockTimer);
if (warningTimer) clearTimeout(warningTimer);
hideIdleWarning();
if (!state.autoLock || !state.token || !state.cryptoKey) return;
const totalMs = state.autoLock * 60 * 1000;
const warningAt = Math.max(0, totalMs - WARNING_SECONDS * 1000);
warningTimer = setTimeout(showIdleWarning, warningAt);
autoLockTimer = setTimeout(() => {
hideIdleWarning();
toast('Auto-locked due to inactivity', 'warning');
lockVault();
}, totalMs);
}
// Reset idle on user interaction — but ignore events that fire while the
// warning popup is visible (otherwise the popup would never auto-dismiss).
['mousemove', 'keydown', 'click', 'touchstart'].forEach(ev =>
document.addEventListener(ev, e => {
// Allow clicks on the "Stay unlocked" button to also reset
if ($('#idleWarning').classList.contains('is-hidden')) {
resetAutoLock();
}
}, { passive: true })
);
async function showAuth() {
$('#authScreen').classList.remove('is-hidden');
$('#appShell').classList.add('is-hidden');
if (autoLockTimer) { clearTimeout(autoLockTimer); autoLockTimer = null; }
// Re-query PIN presence each time we land on the auth screen — the
// user may have set/removed it from another instance, and we want
// the correct fields to show up without forcing a full reload.
state.pinConfigured = await bridgePinStatus();
applyAuthScreenMode();
// Clear residual PIN input from a previous attempt.
const pinInput = document.getElementById('loginPin');
if (pinInput) pinInput.value = '';
let remembered = '';
if (Bridge.active) {
remembered = await Bridge.getPref('rememberedUsername');
} else {
remembered = localStorage.getItem('rememberedUsername') || '';
}
const userInput = $('#loginUsername');
const remCb = $('#loginRememberUser');
if (remCb) remCb.checked = !!remembered;
if (userInput && !userInput.value && remembered) userInput.value = remembered;
// Delphi-hosted: ask Delphi to SetFocus the WebBrowser control first
// (DOM input.focus() is a no-op while the WebView2 lacks OS-level
// focus). Web fallback: direct DOM focus.
if (Bridge.active) {
Bridge.appReady();
} else {
setTimeout(() => {
const u = $('#loginUsername');
const p = $('#loginPassword');
if (u && u.value) p && p.focus();
else u && u.focus();
}, 0);
}
}
async function enterApp() {
// 'both' unlock mode: the master pw just passed — now verify the PIN
// the user typed before we open the vault. Bail back to lock screen
// if the PIN doesn't match the stored blob.
if (window._pinAfterMaster) {
const pin = window._pinAfterMaster;
window._pinAfterMaster = null;
const ok = await verifyPinAfterMasterUnlock(pin);
if (!ok) {
const fresh = await bridgePinStatus();
state.pinConfigured = fresh;
lockVault();
$('#authHint').textContent = fresh
? 'Wrong PIN. Try again.'
: 'Too many wrong PIN attempts — PIN has been removed. Re-set it from Settings after unlocking.';
return;
}
}
$('#authScreen').classList.add('is-hidden');
$('#appShell').classList.remove('is-hidden');
$('#userName').textContent = state.username;
renderUserAvatar();
loadUserAvatar(); // async — repaints the avatar once the pic arrives
// Server-side prefs override localStorage cache; runs before render so
// theme / view mode / mask flags are applied to the first paint.
await loadServerSettings();
// Drift fix: server may have stale unlockMode='pin'|'both' from a
// previous device where the PIN blob has since been auto-wiped (5
// wrong attempts). Re-check real PIN presence post-sync and push
// the corrected mode back up so the Settings dropdown matches the
// actual unlock options the user has on this device.
if (Bridge.active) {
state.pinConfigured = await bridgePinStatus();
if (!state.pinConfigured &&
(state.unlockMode === 'pin' || state.unlockMode === 'both')) {
state.unlockMode = 'pw';
localStorage.setItem('unlockMode', 'pw');
saveServerSettings();
}
}
// Show skeleton cards immediately while the initial fetch runs
showSkeletons(6);
await loadFolders();
await loadEntries();
await loadEntryCounts();
render();
resetAutoLock();
// Fire-and-forget HIBP scan if the user opted in. Runs in background,
// re-renders when done to show badges.
if (state.hibpEnabled) hibpCheckAllEntries();
// Push the user-configured hotkeys (combos + enabled state) to Delphi.
// Replaces the historical "always Ctrl+Shift+L on startup" path.
autofillPushHotkeys();
// Sync the tray notifications preference to Delphi (default ON;
// settings_json may have flipped it).
if (Bridge.active && typeof Bridge.setTrayNotifications === 'function')
Bridge.setTrayNotifications(state.trayNotificationsEnabled !== false);
// Trash auto-purge (configured via Settings → Security). Fire-and-
// forget: failures are silent — the user can run "Empty trash" manually.
autoPurgeTrashIfNeeded();
// Fire-and-forget periodic backup. Defer a few seconds so the unlock
// path isn't blocked by file I/O + AES-GCM over the full vault.
setTimeout(() => { runAutoBackupIfDue(); }, 5000);
}
async function autoPurgeTrashIfNeeded() {
const days = parseInt(state.trashAutoPurgeDays, 10) || 0;
if (days <= 0) return;
try {
const r = await fetch(API + '/entries/trash/old?days=' + days, {
method: 'DELETE',
headers: authHeaders(),
});
if (!r.ok) return;
const body = await r.json().catch(() => ({}));
const n = parseInt(body.purged, 10) || 0;
if (n > 0) {
toast(n + ' old entr' + (n === 1 ? 'y' : 'ies') +
' permanently removed from trash');
// Refresh the count so the sidebar reflects the purge.
await loadEntryCounts();
render();
}
} catch (e) {
// Silent — user can manually empty trash if they care.
}
}
// ============================================================
// SYNC (WebDAV) — extracted to js/app.sync.js (§3.1), loaded as a
// separate <script> AFTER this file (it needs Bridge/state/api at
// load time for its Bridge.onWebdavResult handler).
// ============================================================
// ============================================================
// AUTO-BACKUP (silent encrypted exports on a schedule)
// ============================================================
// All config is device-local (folder paths and passwords don't sync
// meaningfully across machines) and persisted via Delphi DPAPI prefs
// so it survives the port-rotation localStorage wipe.
const ABK = {
enabled: 'autoBackupEnabled', // '1' | ''
dir: 'autoBackupDir', // absolute Windows path
interval: 'autoBackupInterval', // days, int as string
keep: 'autoBackupKeep', // count, int as string
last: 'autoBackupLast', // ISO timestamp of last successful run
pwd: 'autoBackupPwd', // user-chosen pwd, used silently
};
const AUTO_BACKUP_PREFIX = 'vault-autobackup-';
async function loadAutoBackupConfig() {
if (!Bridge.active) return null;
const [enabled, dir, interval, keep, last, pwd] = await Promise.all([
Bridge.getPref(ABK.enabled),
Bridge.getPref(ABK.dir),
Bridge.getPref(ABK.interval),
Bridge.getPref(ABK.keep),
Bridge.getPref(ABK.last),
Bridge.getPref(ABK.pwd),
]);
return {
enabled: enabled === '1',
dir: dir || '',
interval: Math.max(1, parseInt(interval, 10) || 7),
keep: Math.max(1, parseInt(keep, 10) || 10),
last: last || '',
hasPwd: !!pwd,
pwd,
};
}
function refreshAutoBackupUI(cfg) {
if (!cfg) {
$('#autoBackupField').style.display = 'none';
return;
}
$('#autoBackupField').style.display = '';
$('#settingAutoBackupEnabled').checked = cfg.enabled;
$('#autoBackupConfig').style.display = cfg.enabled ? '' : 'none';
$('#autoBackupDir').textContent = cfg.dir || '(not set)';
$('#settingAutoBackupInterval').value = cfg.interval;
$('#settingAutoBackupKeep').value = cfg.keep;
$('#autoBackupLast').textContent = cfg.last
? 'Last run: ' + cfg.last.replace('T', ' ').slice(0, 16)
: 'Never run yet';
}
async function pickAutoBackupFolder() {
const path = await Bridge.pickFolder();
if (!path) return;
Bridge.setPref(ABK.dir, path);
$('#autoBackupDir').textContent = path;
toast('Backup folder set');
}
async function promptAndStoreBackupPwd() {
let lastError = '';
let attempts = 0;
const MAX_ATTEMPTS = 5;
for (;;) {
const pwd = await promptDialog({
title: 'Choose a backup password',
message: 'You will need this to restore the auto-backups. Save it somewhere safe — it is independent of your master password.',
placeholder: 'At least 6 characters',
password: true,
okText: 'Save',
error: lastError,
});
if (!pwd) return false; // cancelled (false / null / '' / undefined)
if (pwd.length >= 6) {
Bridge.setPref(ABK.pwd, pwd);
return true;
}
attempts++;
if (attempts >= MAX_ATTEMPTS) {
toast('Too many invalid attempts', 'error');
return false;
}
lastError = 'Password must be at least 6 characters (attempt ' +
attempts + ' / ' + MAX_ATTEMPTS + ').';
}
}
async function onToggleAutoBackup(ev) {
const enabled = ev.target.checked;
if (enabled) {
const cfg = await loadAutoBackupConfig();
if (!cfg.hasPwd && !(await promptAndStoreBackupPwd())) {
ev.target.checked = false;
return;
}
Bridge.setPref(ABK.enabled, '1');
$('#autoBackupConfig').style.display = '';
toast('Auto-backup enabled');
// Refresh the dir/last display in case we came from cold state.
const fresh = await loadAutoBackupConfig();
refreshAutoBackupUI(fresh);
} else {
Bridge.setPref(ABK.enabled, '');
// Forget the stored backup pwd so re-enabling prompts fresh —
// gives the user a way to change it without extra UI.
Bridge.setPref(ABK.pwd, '');
$('#autoBackupConfig').style.display = 'none';
toast('Auto-backup disabled');
}
}
async function runAutoBackupNow(silent) {
const cfg = await loadAutoBackupConfig();
if (!cfg) return silent || toast('Bridge not available', 'error');
if (!cfg.dir) return silent || toast('Choose a backup folder first', 'warning');
if (!cfg.hasPwd) return silent || toast('Backup password not set', 'warning');
if (!state.cryptoKey) return silent || toast('Vault is locked', 'warning');
// Manual "Backup now" shows a spinner (big vaults take ~30s). The
// scheduled on-unlock run stays silent (no overlay stealing focus).
if (!silent) {
showBusy('Reading vault…');
await new Promise(r => setTimeout(r, 0));
}
try {
const payload = {
version: 1,
exported_at: new Date().toISOString(),
username: state.username,
folders: (state.folders || [])
.filter(f => f && f.name && f.name !== 'All')
.map(f => ({
name: f.name,
color: f.color || '',
icon: f.icon || '',
})),
entries: [],
};
let _bkDone = 0;
const _bkTotal = state.entries.length;
for (const e of state.entries) {
_bkDone++;
if (!silent && _bkTotal > 10 && (_bkDone % 5 === 0 || _bkDone === _bkTotal))
updateBusy('Reading vault… ' + _bkDone + '/' + _bkTotal);
const plain = await decryptPwd(e.encrypted_password, e.iv);
let plainTotp = '';
if (e.totp_secret && e.totp_iv) {
plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv);
if (plainTotp === '[ERROR]') plainTotp = '';
}
let plainCustom = [];
if (e.custom_fields && e.custom_fields_iv) {
try { plainCustom = await decryptCustomFields(
e.custom_fields, e.custom_fields_iv); }
catch (_) { plainCustom = []; }
}
let attachments = [];
try {
const metas = await api('/entries/' + e.id + '/attachments',
{ headers: authHeaders() });
for (const m of (metas || [])) {
const full = await api('/attachments/' + m.id,
{ headers: authHeaders() });
const bytes = await decryptBlobBytes(
full.encrypted_blob, full.iv);
attachments.push({
filename: m.filename,
mime: m.mime,
size_bytes: m.size_bytes,
content_b64: bytesToBase64(bytes),
});
}
} catch (_) {}
payload.entries.push({
uuid: e.uuid || '',
site: e.site, title: e.title || '', username: e.username,
password: plain, folder: e.folder, tags: parseTags(e.tags),
favorite: !!e.favorite, totp_secret: plainTotp,
kind: e.kind || 'login',
template: e.template || '',
custom_fields: plainCustom,
attachments: attachments,
icon_b64: e.icon_b64 || '',
created_at: e.created_at, updated_at: e.updated_at,
});
}
if (!silent) updateBusy('Encrypting backup…');
const container = await encryptExportPayload(payload, cfg.pwd);
const json = JSON.stringify(container, null, 2);
// Filename: yyyymmdd-HHMMSS for filesystem-sort-friendliness.
const ts = new Date().toISOString()
.replace(/[-:]/g, '').replace('T', '-').slice(0, 15);
const fname = AUTO_BACKUP_PREFIX + ts + '.json';
const path = cfg.dir.replace(/[\\/]+$/, '') + '\\' + fname;
if (!silent) updateBusy('Writing file…');
const res = await Bridge.writeFile(path, json, pct => {
if (!silent) updateBusy('Writing file… ' + pct + '%');
});
if (!res.ok) {
if (!silent) toast('Backup failed: ' + (res.error || 'unknown'), 'error');
return;
}
const now = new Date().toISOString();
Bridge.setPref(ABK.last, now);
$('#autoBackupLast').textContent = 'Last run: ' + now.replace('T', ' ').slice(0, 16);
if (!silent) toast(payload.entries.length + ' entries backed up');
applyAutoBackupRetention(cfg.dir, cfg.keep);
} catch (err) {
if (!silent) toast('Backup failed: ' + (err && err.message ? err.message : err), 'error');
} finally {
if (!silent) hideBusy();
}
}
async function applyAutoBackupRetention(dir, keep) {
try {
const files = await Bridge.listFiles(dir, AUTO_BACKUP_PREFIX);
if (files.length <= keep) return;
// Sort by name desc (timestamps in filename → lexical = chronological)
files.sort((a, b) => (a.name < b.name ? 1 : -1));
const toDelete = files.slice(keep);
for (const f of toDelete) {
const path = dir.replace(/[\\/]+$/, '') + '\\' + f.name;
await Bridge.deleteFile(path);
}
} catch (e) {
// Retention is best-effort; user can clean up manually.
}
}
async function runAutoBackupIfDue() {
if (!Bridge.active) return;
const cfg = await loadAutoBackupConfig();
if (!cfg || !cfg.enabled || !cfg.dir || !cfg.hasPwd) return;
if (!state.cryptoKey) return;
const intervalMs = cfg.interval * 24 * 3600 * 1000;
const last = cfg.last ? Date.parse(cfg.last) : 0;
if (last && (Date.now() - last) < intervalMs) return;
await runAutoBackupNow(true); // silent — no spinner on the scheduled run
}
// ============================================================
// SERVER-SIDE SETTINGS SYNC
// ============================================================
//
// Synced keys (user preferences, portable across devices). Device-specific
// toggles (quickUnlockEnabled, autofillEnabled) stay in localStorage.
const SYNCED_SETTING_KEYS = [
'theme', 'autoLock', 'askBeforeDelete', 'maskUsernames',
'compactActions', 'viewMode', 'hibpEnabled', 'showSiteOnCards',
'sortBy', 'sortDir', 'pageSize',
// Hotkey combos are user preferences — values are portable. The
// registration itself is Windows-only, so non-Windows clients just
// ignore them.
'autofillHotkeyFull', 'autofillHotkeyPwd', 'quickSearchHotkey',
// Sidebar section collapsed state. Object of { folders, tags, tools }
// booleans. Synced so the user gets the same fold state across devices.
'sidebarCollapsed',
'faviconsEnabled',
'trayNotificationsEnabled',
'trashAutoPurgeDays',
// Password expiry reminder window (days, 0 = disabled). Cards show
// an "Aged" badge for entries whose password_changed_at exceeds it.
'passwordExpiryDays',
// Table view: columns the user has explicitly hidden.
'tableColsHidden',
// Where the entry editor docks: 'right' (default slideover),
// 'left' (mirrored slideover), or 'center' (centered modal).
'editorPosition',
// When the entry editor has unsaved changes, ask before dismissing.
'confirmOnUnsaved',
// Unlock method (pw / pin / both). The PIN blob itself is device-local
// DPAPI so this synced setting only carries the user's preferred mode.
'unlockMode',
];
// Sets `data-editor-position` on <body> so CSS can swap the slideover
// between right / left / centered. No JS-side render changes — same DOM,
// same JS, just different CSS.
function applyEditorPosition() {
const pos = ['right', 'left', 'center'].includes(state.editorPosition)
? state.editorPosition : 'right';
document.body.setAttribute('data-editor-position', pos);
}
function applySidebarCollapsed() {
const s = state.sidebarCollapsed || {};
document.querySelectorAll('.sidebar-section[data-section]').forEach(sec => {
const k = sec.getAttribute('data-section');
sec.classList.toggle('is-collapsed', !!s[k]);
});
}
async function loadServerSettings() {
try {
const r = await fetch(API + '/settings', { headers: authHeaders() });
if (!r.ok) return;
const remote = await r.json();
// Merge remote into state (remote wins). localStorage is updated
// too so first-paint on next reload uses the synced value.
SYNCED_SETTING_KEYS.forEach(k => {
if (!(k in remote)) return;
const v = remote[k];
state[k] = v;
switch (k) {
case 'theme': localStorage.setItem('theme', v); break;
case 'autoLock': localStorage.setItem('autoLockMin', String(v)); break;
case 'askBeforeDelete': localStorage.setItem('askBeforeDelete', v ? '1' : '0'); break;
case 'maskUsernames': localStorage.setItem('maskUsernames', v ? '1' : '0'); break;
case 'compactActions': localStorage.setItem('compactActions', v ? '1' : '0'); break;
case 'viewMode': localStorage.setItem('viewMode', v); break;
case 'hibpEnabled': localStorage.setItem('hibpEnabled', v ? '1' : '0'); break;
case 'showSiteOnCards': localStorage.setItem('showSiteOnCards', v ? '1' : '0'); break;
case 'sortBy': localStorage.setItem('sortBy', String(v)); break;
case 'sortDir': localStorage.setItem('sortDir', String(v)); break;
case 'pageSize': localStorage.setItem('pageSize', String(v)); break;
case 'autofillHotkeyFull':
case 'autofillHotkeyPwd':
case 'quickSearchHotkey':
case 'sidebarCollapsed':
// Object; persist as JSON so the next cold start picks it up.
localStorage.setItem(k, JSON.stringify(v));
break;
case 'faviconsEnabled':
localStorage.setItem('faviconsEnabled', v ? '1' : '0');
break;
case 'trayNotificationsEnabled':
localStorage.setItem('trayNotificationsEnabled', v ? '1' : '0');
// Push the synced value to Delphi so the bridge honours
// it from this point on (the user may have flipped it
// on another device).
if (Bridge.active && typeof Bridge.setTrayNotifications === 'function')
Bridge.setTrayNotifications(v);
break;
case 'trashAutoPurgeDays':
localStorage.setItem('trashAutoPurgeDays', String(v));
break;
case 'passwordExpiryDays':
localStorage.setItem('passwordExpiryDays', String(v));
break;
case 'tableColsHidden':
localStorage.setItem('tableColsHidden',
JSON.stringify(Array.isArray(v) ? v : []));
break;
case 'editorPosition':
localStorage.setItem('editorPosition', String(v || 'right'));
applyEditorPosition();
break;
case 'confirmOnUnsaved':
localStorage.setItem('confirmOnUnsaved', v ? '1' : '0');
break;
case 'unlockMode':
localStorage.setItem('unlockMode', String(v || 'pw'));
break;
}
});
// Apply visual settings immediately.
if (remote.theme) setTheme(remote.theme);
if ('sidebarCollapsed' in remote) applySidebarCollapsed();
} catch (e) {
// Network/server hiccup is harmless — localStorage cache still works.
}
}
let _settingsSaveTimer = null;
function saveServerSettings() {
// Debounce: collapse rapid toggles (e.g. user playing with the theme
// dropdown) into a single PUT.
if (_settingsSaveTimer) clearTimeout(_settingsSaveTimer);
_settingsSaveTimer = setTimeout(async () => {
_settingsSaveTimer = null;
const payload = {};
SYNCED_SETTING_KEYS.forEach(k => { payload[k] = state[k]; });
try {
await fetch(API + '/settings', {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(payload),
});
} catch (e) {
// Silent — next change will retry.
}
}, 400);
}
// ============================================================
// INIT
// ============================================================
function installCustomContextMenu() {
const menu = el('div', { class: 'custom-ctxmenu is-hidden' });
document.body.appendChild(menu);
function isEditable(node) {
if (!node) return false;
const tag = node.tagName;
if (tag === 'INPUT') return !['button','submit','checkbox','radio','range','color','file'].includes(node.type);
if (tag === 'TEXTAREA') return true;
if (node.isContentEditable) return true;
return false;
}
function hide() { menu.classList.add('is-hidden'); }
function buildItems(target) {
menu.innerHTML = '';
const isInput = isEditable(target);
const hasSelection = isInput && target.selectionStart !== target.selectionEnd;
const items = [
{ lbl: 'Cut', on: hasSelection, fn: () => doCut(target) },
{ lbl: 'Copy', on: hasSelection, fn: () => doCopy(target) },
{ lbl: 'Paste', on: isInput && !target.readOnly, fn: () => doPaste(target) },
{ lbl: 'Select all', on: isInput, fn: () => target.select() },
];
items.forEach(it => {
const mi = el('button', {
class: 'custom-ctxmenu-item' + (it.on ? '' : ' is-disabled'),
type: 'button',
on: { click: ev => {
ev.preventDefault();
ev.stopPropagation();
if (!it.on) return;
hide();
it.fn();
} },
}, it.lbl);
menu.appendChild(mi);
});
}
async function doCopy(target) {
const sel = target.value.slice(target.selectionStart, target.selectionEnd);
try { await navigator.clipboard.writeText(sel); } catch (e) {}
}
async function doCut(target) {
await doCopy(target);
const s = target.selectionStart, e = target.selectionEnd;
target.value = target.value.slice(0, s) + target.value.slice(e);
target.selectionStart = target.selectionEnd = s;
target.dispatchEvent(new Event('input', { bubbles: true }));
}
async function doPaste(target) {
const txt = Bridge.active ? await bridgeReadClipboard()
: await navigator.clipboard.readText().catch(() => '');
if (!txt) return;
const s = target.selectionStart, e = target.selectionEnd;
target.value = target.value.slice(0, s) + txt + target.value.slice(e);
target.selectionStart = target.selectionEnd = s + txt.length;
target.dispatchEvent(new Event('input', { bubbles: true }));
}
document.addEventListener('contextmenu', ev => {
ev.preventDefault();
if (!isEditable(ev.target)) { hide(); return; }
buildItems(ev.target);
menu.classList.remove('is-hidden');
const vw = window.innerWidth, vh = window.innerHeight;
const mw = menu.offsetWidth || 160, mh = menu.offsetHeight || 140;
const x = Math.min(ev.clientX, vw - mw - 4);
const y = Math.min(ev.clientY, vh - mh - 4);
menu.style.left = x + 'px';
menu.style.top = y + 'px';
});
document.addEventListener('mousedown', ev => {
if (!ev.target.closest('.custom-ctxmenu')) hide();
});
document.addEventListener('keydown', ev => {
if (ev.key === 'Escape') hide();
});
window.addEventListener('blur', hide);
}
async function init() {
document.documentElement.setAttribute('data-theme', state.theme);
if (location.search.indexOf('pmt=') !== -1) {
history.replaceState(null, '', location.pathname + location.hash);
}
installCustomContextMenu();
document.addEventListener('keydown', e => {
const mod = e.ctrlKey || e.metaKey;
if (e.key === 'F12' || e.key === 'F5') return e.preventDefault();
if (mod && e.shiftKey && /^[ijIJ]$/.test(e.key)) return e.preventDefault(); // DevTools
if (mod && /^[uUjJhHsSpPtTnNrR]$/.test(e.key)) return e.preventDefault(); // View source / Downloads / History / Save / Print / New tab+win / Reload
if (mod && e.shiftKey && /^[nNwW]$/.test(e.key)) return e.preventDefault(); // New incognito / Close window
if (mod && e.shiftKey && e.key === 'Delete') return e.preventDefault(); // Clear browsing data
});
document.addEventListener('keydown', handleCardCursorKey);
// Delete / Backspace on the grid (no input focused, no modal open)
// triggers the batch action matching the current view: soft-trash for
// normal views, permanent-delete for the trash view. Mirrors what the
// batch bar does, just via keyboard.
document.addEventListener('keydown', e => {
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
if (e.ctrlKey || e.altKey || e.metaKey) return;
const tag = (e.target && e.target.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
if (e.target && e.target.isContentEditable) return;
if (!$('#appShell') || $('#appShell').classList.contains('is-hidden')) return;
if (document.querySelector('.modal:not(.is-hidden)')) return;
if ($('#slideover') && $('#slideover').classList.contains('is-open')) return;
if ($('#settingsPanel') && $('#settingsPanel').classList.contains('is-open')) return;
if (state.checked.size === 0) return;
e.preventDefault();
if (state.view === 'trash') batchPermDelete();
else batchDelete();
});
// Auth tabs
$$('.auth-tab').forEach(t => {
t.addEventListener('click', () => {
$$('.auth-tab').forEach(x => x.classList.remove('is-active'));
t.classList.add('is-active');
const tab = t.dataset.tab;
$('#loginForm').classList.toggle('is-hidden', tab !== 'login');
$('#registerForm').classList.toggle('is-hidden', tab !== 'register');
});
});
// Forms
$('#loginForm').addEventListener('submit', doLogin);
$('#registerForm').addEventListener('submit', doRegister);
$('#regPassword').addEventListener('input', updateRegStrength);
$('#entryPassword').addEventListener('input', updateEntryStrength);
// Top-bar
function applyViewMode() {
$$('.view-btn').forEach(b => b.classList.toggle('is-active', b.dataset.view === state.viewMode));
applyColsWrapVisibility();
renderGrid();
}
applyViewMode();
$$('.view-btn').forEach(b => b.addEventListener('click', () => {
state.viewMode = b.dataset.view;
state.currentPage = 1;
localStorage.setItem('viewMode', state.viewMode);
applyViewMode();
saveServerSettings();
}));
// Show/hide table column picker. Built lazily on first open so the
// checkbox state always reflects the current `state.tableColsHidden`.
const colsBtn = document.getElementById('colsBtn');
const colsMenu = document.getElementById('colsMenu');
if (colsBtn && colsMenu) {
colsBtn.addEventListener('click', ev => {
ev.stopPropagation();
const isHidden = colsMenu.classList.contains('is-hidden');
if (isHidden) renderTableColsMenu();
colsMenu.classList.toggle('is-hidden');
});
document.addEventListener('click', e => {
if (!e.target.closest('#colsWrap'))
colsMenu.classList.add('is-hidden');
});
}
$('#themeBtn').addEventListener('click', () => {
toggleTheme();
saveServerSettings();
});
$('#newEntryBtn').addEventListener('click', ev => {
ev.stopPropagation();
// Default click → new login (preserves the muscle memory of the
// existing button). The chevron next to it opens the kind picker.
$('#newEntryMenu').classList.add('is-hidden');
openSlideOver(null, { kind: 'login' });
});
$('#newEntryCaretBtn').addEventListener('click', ev => {
ev.stopPropagation();
$('#newEntryMenu').classList.toggle('is-hidden');
});
$$('#newEntryMenu [data-new-kind]').forEach(b => {
b.addEventListener('click', ev => {
ev.stopPropagation();
const kind = b.getAttribute('data-new-kind') || 'login';
$('#newEntryMenu').classList.add('is-hidden');
openSlideOver(null, { kind });
});
});
$$('#newEntryMenu [data-new-template]').forEach(b => {
b.addEventListener('click', ev => {
ev.stopPropagation();
const template = b.getAttribute('data-new-template');
$('#newEntryMenu').classList.add('is-hidden');
openSlideOver(null, { template });
});
});
// Close the dropdown on any other click.
document.addEventListener('click', e => {
if (!e.target.closest('.new-entry-wrap'))
$('#newEntryMenu').classList.add('is-hidden');
});
$('#userChip').addEventListener('click', () => $('#userDropdown').classList.toggle('is-hidden'));
$('#lockBtn').addEventListener('click', async () => {
if (await confirmDiscardForSessionExit('lock')) lockVault();
});
$('#dropdownSettingsBtn').addEventListener('click', () => {
$('#userDropdown').classList.add('is-hidden');
openSettings();
});
$('#logoutBtn').addEventListener('click', async () => {
if (await confirmDiscardForSessionExit('logout')) doLogout();
});
document.addEventListener('click', e => {
if (!e.target.closest('.user-menu')) $('#userDropdown').classList.add('is-hidden');
// Close any open kebab menu when clicking outside it
if (!e.target.closest('.entry-kebab-wrap')) {
$$('.entry-kebab-menu.is-open').forEach(m => m.classList.remove('is-open'));
}
});
// Sidebar nav
$$('#appShell .nav-item[data-view]').forEach(n => {
n.addEventListener('click', () => setView(n.dataset.view));
});
$('#addFolderBtn').addEventListener('click', addFolder);
// Drag-to-trash: dropping an entry onto the Trash nav item soft-deletes it
const trashItem = $('#appShell .nav-item[data-view="trash"]');
if (trashItem) {
trashItem.addEventListener('dragover', ev => { ev.preventDefault(); trashItem.classList.add('drag-over'); });
trashItem.addEventListener('dragleave', () => trashItem.classList.remove('drag-over'));
trashItem.addEventListener('drop', async ev => {
ev.preventDefault();
trashItem.classList.remove('drag-over');
const raw = ev.dataTransfer.getData('text/plain') || '';
const ids = raw.split(',').map(s => parseInt(s)).filter(n => n > 0);
if (!ids.length) return;
let ok = 0;
for (const id of ids) {
try {
await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() });
ok++;
} catch (err) { /* keep going for the rest */ }
}
toast(ok === 1 ? 'Moved to trash' : 'Moved ' + ok + ' entries to trash');
state.checked.clear();
await loadEntries();
await loadTrash();
render();
});
}
// Search
let searchHistoryDebounce = null;
$('#searchInput').addEventListener('input', e => {
state.search = e.target.value;
state.currentPage = 1;
renderGrid();
// Hide history while user is actively typing; it pops back on
// focus when the field is empty.
if (e.target.value) $('#searchHistoryMenu').classList.add('is-hidden');
else renderSearchHistoryMenu();
// Commit to history after a pause — captures the live-typing case
// where the user finds what they wanted without ever pressing
// Enter or blurring the field.
if (searchHistoryDebounce) clearTimeout(searchHistoryDebounce);
const v = e.target.value.trim();
if (v.length >= 2) {
searchHistoryDebounce = setTimeout(() => pushSearchHistory(v), 1000);
}
});
$('#searchInput').addEventListener('focus', () => {
if (!$('#searchInput').value) renderSearchHistoryMenu();
});
$('#searchInput').addEventListener('keydown', e => {
// Enter commits the current term to history. Blur with a non-empty
// value also commits — handled below.
if (e.key === 'Enter' && $('#searchInput').value.trim()) {
pushSearchHistory($('#searchInput').value.trim());
$('#searchHistoryMenu').classList.add('is-hidden');
} else if (e.key === 'Escape') {
$('#searchHistoryMenu').classList.add('is-hidden');
}
});
$('#searchInput').addEventListener('blur', () => {
const v = $('#searchInput').value.trim();
if (v) pushSearchHistory(v);
// Delay-hide so a click on a history item can still register.
setTimeout(() => $('#searchHistoryMenu').classList.add('is-hidden'), 120);
});
// Click-outside also dismisses (covers the case where focus moves via
// keyboard to a non-blurring element).
document.addEventListener('click', e => {
if (!e.target.closest('.search')) {
$('#searchHistoryMenu').classList.add('is-hidden');
}
});
// Slide-over
$('#slideoverClose').addEventListener('click', () => requestCloseSlideOver());
// Click outside the slide-over closes it. Clicks on cards re-open it for
// another entry (so we don't close in that case; the card's own handler
// will switch state.selectedId).
// mousedown origin is captured so a drag-selection that starts inside
// an input and ends outside doesn't trigger close (the resulting click
// event has a target outside the slideover even though the user never
// intended to dismiss it).
// Esc closes the slideover. Registered with capture=true so it fires
// BEFORE any input-level handler that might call stopPropagation, and
// before the browser swallows the keystroke for things like clearing
// an active form-autocomplete popup on a focused field.
document.addEventListener('keydown', e => {
if (e.key !== 'Escape') return;
if (!$('#slideover').classList.contains('is-open')) return;
if (document.querySelector('.modal:not(.is-hidden)')) return;
// Settings panel takes priority — when both Settings AND the
// editor are open, the first Esc should close Settings (the
// thing the user just opened on top), the second Esc handles
// the editor's dirty-check. Let the Settings Esc handler run.
if ($('#settingsPanel').classList.contains('is-open')) return;
// Stop here so the global "Esc closes confirmModal" handler
// doesn't fire on the SAME keystroke and immediately dismiss
// the discard-confirm dialog that requestCloseSlideOver just
// opened — that bug left the user with no visible feedback at
// all (modal opened and closed in one tick).
e.preventDefault();
e.stopPropagation();
requestCloseSlideOver();
}, true);
// Click-outside closes too. mousedown origin is captured so a
// drag-selection that starts inside an input and ends outside doesn't
// count as an outside click. Cards/rows/modals/etc. are whitelisted
// so clicks on them don't dismiss the panel.
let _slideoverMouseDownInside = false;
document.addEventListener('mousedown', e => {
_slideoverMouseDownInside = !!(e.target.closest && e.target.closest('.slideover'));
// Close any open custom combobox menu when the click lands outside
// a combo (the arrow toggle + item mousedown both stopPropagation,
// so this only fires for genuine outside clicks).
if (!(e.target.closest && e.target.closest('.so-combo'))) {
document.querySelectorAll('.so-combo-menu:not(.is-hidden)')
.forEach(m => m.classList.add('is-hidden'));
}
}, true);
document.addEventListener('click', e => {
if (!$('#slideover').classList.contains('is-open')) return;
if (_slideoverMouseDownInside) { _slideoverMouseDownInside = false; return; }
if (e.target.closest('.slideover')) return;
if (e.target.closest('.entry-card')) return;
if (e.target.closest('.entry-row')) return;
if (e.target.closest('.modal')) return;
if (e.target.closest('.cmd-palette')) return;
if (e.target.closest('.idle-warning')) return;
// Sidebar + topbar are UI chrome — clicking the theme toggle,
// user menu, filter button, pagination etc. shouldn't dismiss
// the editor. Same for the table column picker dropdown.
if (e.target.closest('.topbar')) return;
if (e.target.closest('.sidebar')) return;
if (e.target.closest('.pagination')) return;
if (e.target.closest('.cols-menu')) return;
if (e.target.closest('.filters-menu')) return;
if (e.target.closest('.user-menu')) return;
if (e.target.closest('.search-history')) return;
requestCloseSlideOver();
});
// Click outside the settings panel closes it. Handled on MOUSEDOWN
// (not click) so async handlers that reparent the DOM (e.g.
// backfillFavicons → render() during the click→bubble window) can't
// make us mistake an inside-click for an outside-click. Ignore the
// triggers and any open modal so reauth / confirm flows fired from
// inside Settings don't dismiss it.
document.addEventListener('mousedown', e => {
if (!$('#settingsPanel').classList.contains('is-open')) return;
if (!(e.target && e.target.closest)) return;
if (e.target.closest('#settingsPanel')) return;
if (e.target.closest('#settingsBtn')) return;
if (e.target.closest('#dropdownSettingsBtn')) return;
if (e.target.closest('.modal')) return;
if (e.target.closest('.cmd-palette')) return;
if (e.target.closest('.toast')) return;
closeSettings();
});
// Entry modal
$('#entryForm').addEventListener('submit', saveEntry);
$('#entrySaveBtn').addEventListener('click', saveEntry);
$$('#entryModal [data-close]').forEach(b => b.addEventListener('click', closeEntryModal));
$('#entryPwToggle').addEventListener('click', () => {
const input = $('#entryPassword');
input.type = input.type === 'password' ? 'text' : 'password';
});
$('#loginPwToggle').addEventListener('click', () => {
const input = $('#loginPassword');
input.type = input.type === 'password' ? 'text' : 'password';
});
// PIN-mode escape hatch: temporarily switch to the master-pw layout
// for this unlock attempt (doesn't change the saved unlockMode).
const useMasterBtn = document.getElementById('loginUseMasterBtn');
if (useMasterBtn) useMasterBtn.addEventListener('click', () => {
state.pinConfigured = false; // local-only flag, restored by next showAuth()
applyAuthScreenMode();
const p = document.getElementById('loginPassword');
if (p) p.focus();
});
$('#entryPwGen').addEventListener('click', openGen);
// Chip input (tags)
$('#entryTagsInput').addEventListener('click', () => $('#entryTagsField').focus());
$('#entryTagsField').addEventListener('keydown', e => {
const field = e.target;
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
if (!selectActiveSuggestion()) {
addTag(field.value);
field.value = '';
closeChipSuggest();
}
} else if (e.key === 'Backspace' && !field.value && editingTags.length) {
editingTags.pop();
renderChips();
} else if (e.key === 'ArrowDown') { e.preventDefault(); moveChipSuggest(+1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); moveChipSuggest(-1); }
else if (e.key === 'Escape') { closeChipSuggest(); }
});
$('#entryTagsField').addEventListener('input', openChipSuggest);
$('#entryTagsField').addEventListener('focus', openChipSuggest);
$('#entryTagsField').addEventListener('blur', () => setTimeout(closeChipSuggest, 150));
// Generator
$('#genLen').addEventListener('input', genPassword);
$$('#genModal input[type=checkbox]').forEach(c => c.addEventListener('change', genPassword));
$('#genRegen').addEventListener('click', genPassword);
$('#genCopy').addEventListener('click', () => {
if (!genCurrent) return;
if (Bridge.copySecure(genCurrent, 30000)) {
toast('Copied · clears in 30s');
} else {
navigator.clipboard.writeText(genCurrent).then(() => {
toast('Copied · clears in 30s');
setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000);
});
}
});
$('#genUse').addEventListener('click', () => {
if (genTarget === 'slideover') {
const soPw = $('#soPassword');
if (soPw) { soPw.value = genCurrent; soDirtyCheck(); }
} else {
$('#entryPassword').value = genCurrent;
updateEntryStrength();
}
closeGen();
});
$$('#genModal [data-close]').forEach(b => b.addEventListener('click', closeGen));
// Sidebar Generator tool
$('#sidebarGenBtn').addEventListener('click', () => openGen('standalone'));
$('#sidebarExportBtn').addEventListener('click', doExport);
$('#sidebarImportBtn').addEventListener('click', doImport);
$('#sidebarAuthenticatorBtn').addEventListener('click', () => {
state.view = 'authenticator';
state.currentPage = 1;
$$('.nav-item').forEach(b => b.classList.remove('is-active'));
render();
});
$('#sidebarTotpToolBtn').addEventListener('click', openTotpTool);
$('#sidebarRecentBtn').addEventListener('click', () => {
state.view = 'recent';
state.currentPage = 1;
$$('.nav-item').forEach(b => b.classList.remove('is-active'));
render();
});
$('#sidebarHealthBtn').addEventListener('click', () => {
state.view = 'health';
state.currentPage = 1;
$$('.nav-item').forEach(b => b.classList.remove('is-active'));
// Invalidate any stale cache so we recompute fresh each open.
healthCache = null;
render();
});
$('#sidebarAuditBtn').addEventListener('click', () => {
state.view = 'audit';
$$('.nav-item').forEach(b => b.classList.remove('is-active'));
auditCache = null;
render();
});
// Sidebar section collapse toggles
document.querySelectorAll('[data-section-toggle]').forEach(btn => {
btn.addEventListener('click', () => {
const key = btn.getAttribute('data-section-toggle');
state.sidebarCollapsed = state.sidebarCollapsed || {};
state.sidebarCollapsed[key] = !state.sidebarCollapsed[key];
applySidebarCollapsed();
localStorage.setItem('sidebarCollapsed', JSON.stringify(state.sidebarCollapsed));
saveServerSettings();
});
});
applySidebarCollapsed();
applyEditorPosition();
// Idle warning "Stay unlocked"
$('#idleStayBtn').addEventListener('click', resetAutoLock);
// Settings slide-over
$('#settingsBtn').addEventListener('click', openSettings);
$('#settingsClose').addEventListener('click', closeSettings);
// Escape closes the Settings panel — unless the search input is
// focused with a non-empty query (in that case its own handler
// already swallowed the event and cleared the query). Skip when a
// confirm/reauth modal is up so its Escape stays the priority.
document.addEventListener('keydown', e => {
if (e.key !== 'Escape') return;
if (!$('#settingsPanel').classList.contains('is-open')) return;
if (document.querySelector('.modal:not(.is-hidden)')) return;
// When the search box has focus AND a query, the input's own
// handler clears the query (and stops propagation) — let it run.
// Capture phase fires document handlers BEFORE element handlers,
// so without this guard we'd close Settings before the search
// input ever saw the Esc.
const si = $('#settingsSearch');
if (si && si === document.activeElement && si.value) return;
// Don't let the global Esc-fallback handler fire on the same
// keystroke — its slideover-close branch would pop the discard
// confirm in the background while Settings closes, surprising
// the user who expected one Esc = close one panel.
e.stopPropagation();
closeSettings();
}, true);
// Settings search box: live filter on every input. Escape clears the
// query (without closing the panel — the existing Esc handler also
// closes settings, but only when focus isn't inside an input).
const sInput = $('#settingsSearch');
const sClear = $('#settingsSearchClear');
if (sInput) {
sInput.addEventListener('input', e => applySettingsSearch(e.target.value));
sInput.addEventListener('keydown', e => {
if (e.key === 'Escape' && sInput.value) {
e.stopPropagation();
sInput.value = '';
applySettingsSearch('');
}
});
}
if (sClear) {
sClear.addEventListener('click', () => {
if (!sInput) return;
sInput.value = '';
applySettingsSearch('');
sInput.focus();
});
}
// Theme: setTheme already writes localStorage. Capture before/after so
// sync only fires if it actually changed.
$('#settingTheme').addEventListener('change', e => {
setTheme(e.target.value);
state.theme = e.target.value;
saveServerSettings();
});
$('#settingSort').addEventListener('change', e => {
const [by, dir] = e.target.value.split(':');
state.sortBy = by; state.sortDir = dir;
state.currentPage = 1;
localStorage.setItem('sortBy', by);
localStorage.setItem('sortDir', dir);
render();
saveServerSettings();
});
$('#settingAutoLock').addEventListener('change', e => {
state.autoLock = parseInt(e.target.value);
localStorage.setItem('autoLockMin', String(state.autoLock));
resetAutoLock();
saveServerSettings();
toast(state.autoLock ? ('Auto-lock: ' + state.autoLock + ' min') : 'Auto-lock disabled');
});
$('#settingAskDelete').addEventListener('change', e => {
state.askBeforeDelete = e.target.checked;
localStorage.setItem('askBeforeDelete', state.askBeforeDelete ? '1' : '0');
saveServerSettings();
toast(state.askBeforeDelete ? 'Will ask before deleting' : 'Will delete without asking');
});
$('#settingCompact').addEventListener('change', e => {
state.compactActions = e.target.checked;
localStorage.setItem('compactActions', state.compactActions ? '1' : '0');
render();
saveServerSettings();
});
$('#settingMaskUser').addEventListener('change', e => {
state.maskUsernames = e.target.checked;
localStorage.setItem('maskUsernames', state.maskUsernames ? '1' : '0');
render();
saveServerSettings();
});
$('#settingShowSite').addEventListener('change', e => {
state.showSiteOnCards = e.target.checked;
localStorage.setItem('showSiteOnCards', state.showSiteOnCards ? '1' : '0');
render();
saveServerSettings();
});
$('#settingHIBP').addEventListener('change', e => {
state.hibpEnabled = e.target.checked;
localStorage.setItem('hibpEnabled', state.hibpEnabled ? '1' : '0');
saveServerSettings();
if (state.hibpEnabled) {
toast('Checking passwords against breach database…');
hibpCheckAllEntries();
} else {
state.hibpResults.clear();
render();
toast('Breach check disabled');
}
});
$('#settingAutoStart').addEventListener('change', e => {
if (!Bridge.active) return;
Bridge.setAutoStart(e.target.checked);
toast(e.target.checked
? 'Will start with Windows (in tray)'
: 'Wont start with Windows');
});
$('#settingTrayNotif').addEventListener('change', e => {
state.trayNotificationsEnabled = e.target.checked;
localStorage.setItem('trayNotificationsEnabled', e.target.checked ? '1' : '0');
if (Bridge.active) Bridge.setTrayNotifications(e.target.checked);
saveServerSettings();
toast(e.target.checked
? 'Tray notifications enabled'
: 'Tray notifications disabled');
});
$('#settingTrashPurge').addEventListener('change', e => {
const n = parseInt(e.target.value, 10) || 0;
state.trashAutoPurgeDays = n;
localStorage.setItem('trashAutoPurgeDays', String(n));
saveServerSettings();
if (n === 0) toast('Trash auto-purge disabled');
else toast('Trash will auto-purge after ' + n + ' days (next unlock)');
});
$('#settingPasswordExpiry').addEventListener('change', e => {
const n = parseInt(e.target.value, 10) || 0;
state.passwordExpiryDays = n;
localStorage.setItem('passwordExpiryDays', String(n));
saveServerSettings();
render();
if (n === 0) toast('Password aging reminders disabled');
else toast('Passwords older than ' + n + ' days will be flagged');
});
$('#settingEditorPosition').addEventListener('change', e => {
const v = ['right', 'left', 'center'].includes(e.target.value)
? e.target.value : 'right';
state.editorPosition = v;
localStorage.setItem('editorPosition', v);
applyEditorPosition();
saveServerSettings();
});
$('#settingConfirmUnsaved').addEventListener('change', e => {
state.confirmOnUnsaved = !!e.target.checked;
localStorage.setItem('confirmOnUnsaved', state.confirmOnUnsaved ? '1' : '0');
saveServerSettings();
});
// PIN unlock controls. Setting the mode to PIN/both without a PIN
// configured first would lock the user out — gate the dropdown so a
// missing PIN forces the user through Set PIN first.
const pinSet = document.getElementById('pinSetBtn');
const pinDel = document.getElementById('pinRemoveBtn');
const pinSel = document.getElementById('settingUnlockMode');
if (pinSet) pinSet.addEventListener('click', pinSetupFlow);
if (pinDel) pinDel.addEventListener('click', async () => {
const ok = await confirmDialog({
title: 'Remove PIN?',
message: 'You will need your master password to unlock until you set a new PIN.',
okText: 'Remove',
danger: true,
});
if (!ok) return;
await removePin();
refreshPinUnlockUI();
});
if (pinSel) pinSel.addEventListener('change', async e => {
const next = e.target.value;
if ((next === 'pin' || next === 'both') && !state.pinConfigured) {
toast('Set a PIN first', 'warning');
e.target.value = state.unlockMode || 'pw';
return;
}
state.unlockMode = next;
localStorage.setItem('unlockMode', next);
saveServerSettings();
toast('Unlock method updated');
});
// Sync (WebDAV) listeners
const syncCb = document.getElementById('settingSyncEnabled');
const syncCfgBox = document.getElementById('syncConfig');
const syncUrlEl = document.getElementById('syncUrl');
const syncUserEl = document.getElementById('syncUser');
const syncPwdEl = document.getElementById('syncPwd');
const syncPreEl = document.getElementById('syncPreBackup');
const syncSetPwd = document.getElementById('syncSetPwdBtn');
const syncTest = document.getElementById('syncTestBtn');
const syncNow = document.getElementById('syncNowBtn');
if (syncCb) syncCb.addEventListener('change', e => {
const on = !!e.target.checked;
Bridge.setPref(SYNC_PREFS.enabled, on ? '1' : '');
if (syncCfgBox) syncCfgBox.style.display = on ? '' : 'none';
toast(on ? 'Sync enabled' : 'Sync disabled');
});
if (syncUrlEl) syncUrlEl.addEventListener('change', e =>
Bridge.setPref(SYNC_PREFS.url, e.target.value.trim()));
if (syncUserEl) syncUserEl.addEventListener('change', e =>
Bridge.setPref(SYNC_PREFS.user, e.target.value.trim()));
if (syncPwdEl) syncPwdEl.addEventListener('change', e =>
Bridge.setPref(SYNC_PREFS.pwd, e.target.value));
if (syncPreEl) syncPreEl.addEventListener('change', e =>
Bridge.setPref(SYNC_PREFS.preBackup, e.target.checked ? '1' : ''));
if (syncSetPwd) syncSetPwd.addEventListener('click', syncSetEncPwdFlow);
if (syncTest) syncTest.addEventListener('click', syncTestConnection);
if (syncNow) syncNow.addEventListener('click', runSyncNow);
$('#settingAutoBackupEnabled').addEventListener('change', onToggleAutoBackup);
$('#autoBackupPickDirBtn').addEventListener('click', pickAutoBackupFolder);
$('#settingAutoBackupInterval').addEventListener('change', e => {
const n = Math.max(1, parseInt(e.target.value, 10) || 7);
e.target.value = n;
Bridge.setPref(ABK.interval, String(n));
});
$('#settingAutoBackupKeep').addEventListener('change', e => {
const n = Math.max(1, parseInt(e.target.value, 10) || 10);
e.target.value = n;
Bridge.setPref(ABK.keep, String(n));
});
$('#autoBackupNowBtn').addEventListener('click', () => runAutoBackupNow());
$('#settingFavicons').addEventListener('change', e => {
state.faviconsEnabled = e.target.checked;
localStorage.setItem('faviconsEnabled', state.faviconsEnabled ? '1' : '0');
saveServerSettings();
if (state.faviconsEnabled) {
// Auto-backfill on first opt-in so the user sees the effect
// immediately instead of having to click the refresh button.
backfillFavicons(false);
} else {
toast('Website icons disabled (cached icons kept)');
}
});
// stopPropagation on the favicon buttons — the document-level
// "click outside Settings closes it" handler was firing because the
// click target lost its #settingsPanel ancestor mid-bubble (the
// async backfill chain triggers a render that reparents nodes).
$('#settingFaviconsRefresh').addEventListener('click', ev => {
ev.stopPropagation();
backfillFavicons(false);
});
$('#settingFaviconsRefreshAll').addEventListener('click', ev => {
ev.stopPropagation();
backfillFavicons(true);
});
$('#settingFaviconsClear').addEventListener('click', async ev => {
ev.stopPropagation();
const ok = await confirmDialog({
title: 'Clear cached icons?',
message: 'All website icons cached in your vault will be removed. They will be re-fetched on demand if the toggle stays on.',
okText: 'Clear',
danger: true,
});
if (ok) clearAllFavicons();
});
$('#settingAutofill').addEventListener('change', e => {
state.autofillEnabled = e.target.checked;
localStorage.setItem('autofillEnabled', state.autofillEnabled ? '1' : '0');
// Push the FULL state (toggle + combos) so Delphi register/unregister
// uses the user's current combos, not the defaults.
autofillPushHotkeys();
const lbl = autofillComboLabel(state.autofillHotkeyFull);
toast(state.autofillEnabled ? ('Autofill enabled (' + lbl + ')') : 'Autofill disabled');
});
// ---- Hotkey capture buttons ----
// Click → button label becomes "Press combo…" → next keydown captures.
// While capturing, all other keys are swallowed so the user can press
// any modifier+letter combo without triggering app shortcuts.
function bindHotkeyCapture(btnId, kind) {
const btn = $(btnId);
if (!btn) return;
btn.addEventListener('click', () => {
if (btn.dataset.capturing === '1') return;
btn.dataset.capturing = '1';
btn.classList.add('is-capturing');
const original = btn.textContent;
btn.textContent = 'Press combo… (Esc to cancel)';
function finish(restore) {
btn.dataset.capturing = '';
btn.classList.remove('is-capturing');
document.removeEventListener('keydown', onKey, true);
if (restore) btn.textContent = original;
}
function onKey(e) {
// Swallow EVERYTHING while capturing so the user's combo
// doesn't trigger the cmd-palette etc.
e.preventDefault();
e.stopPropagation();
const captured = autofillCaptureFromEvent(e);
if (captured === null) return; // still building
if (captured === 'cancel') { finish(true); return; }
if (captured === 'invalid'){
toast('Unsupported key — use a letter, digit or F-key', 'warning');
finish(true);
return;
}
if (!autofillComboValid(captured)) {
toast('Combo needs at least Ctrl, Alt or Win as a modifier', 'warning');
finish(true);
return;
}
// Reject if it collides with any of the other configurable slots.
const others = [
kind !== 'full' ? state.autofillHotkeyFull : null,
kind !== 'password' ? state.autofillHotkeyPwd : null,
kind !== 'quickSearch' ? state.quickSearchHotkey : null,
].filter(Boolean);
const capJson = JSON.stringify(captured);
if (others.some(o => JSON.stringify(o) === capJson)) {
toast('That combo is already used by another hotkey', 'warning');
finish(true);
return;
}
// Commit.
if (kind === 'full') state.autofillHotkeyFull = captured;
else if (kind === 'password') state.autofillHotkeyPwd = captured;
else /* quickSearch */ state.quickSearchHotkey = captured;
btn.textContent = autofillComboLabel(captured);
finish(false);
autofillPushHotkeys(); // re-register in Delphi
saveServerSettings(); // sync to server (debounced)
}
document.addEventListener('keydown', onKey, true);
});
}
bindHotkeyCapture('#settingAutofillFullCombo', 'full');
bindHotkeyCapture('#settingAutofillPwdCombo', 'password');
bindHotkeyCapture('#settingQuickSearchCombo', 'quickSearch');
$('#settingAutofillResetHotkeys').addEventListener('click', () => {
state.autofillHotkeyFull = { ctrl: true, shift: true, alt: false, win: false, key: 'L' };
state.autofillHotkeyPwd = { ctrl: true, shift: true, alt: false, win: false, key: 'P' };
state.quickSearchHotkey = { ctrl: true, shift: true, alt: false, win: false, key: 'Q' };
$('#settingAutofillFullCombo').textContent = autofillComboLabel(state.autofillHotkeyFull);
$('#settingAutofillPwdCombo').textContent = autofillComboLabel(state.autofillHotkeyPwd);
$('#settingQuickSearchCombo').textContent = autofillComboLabel(state.quickSearchHotkey);
autofillPushHotkeys();
saveServerSettings();
toast('Hotkeys reset to defaults');
});
// Autofill picker modal close button
$$('#autofillPickerModal [data-close]').forEach(b =>
b.addEventListener('click', closeAutofillPicker));
$('#openClipboardSettings').addEventListener('click', () => {
toast('Open Windows Settings → System → Clipboard → turn off "Clipboard history"', 'warning');
});
$('#exportBtn').addEventListener('click', doExport);
$('#exportCsvBtn').addEventListener('click', doExportCSV);
$('#importBtn').addEventListener('click', doImport);
$('#changeMasterBtn').addEventListener('click', openChangeMasterModal);
// Profile picture: "Change picture" opens the hidden file input;
// selecting a file downscales + uploads it; "Remove" clears it.
const avaUpload = $('#settingAvatarUpload');
const avaInput = $('#settingAvatarInput');
const avaRemove = $('#settingAvatarRemove');
if (avaUpload && avaInput) {
avaUpload.addEventListener('click', () => avaInput.click());
avaInput.addEventListener('change', async e => {
const f = e.target.files && e.target.files[0];
e.target.value = ''; // allow re-picking the same file later
if (f) await uploadUserAvatar(f);
});
}
if (avaRemove) avaRemove.addEventListener('click', removeUserAvatar);
$('#changeMasterForm').addEventListener('submit', e => {
e.preventDefault();
doChangeMasterPassword();
});
$$('#changeMasterModal [data-close]').forEach(b =>
b.addEventListener('click', closeChangeMasterModal));
// Recovery key
$('#recoverySetupBtn').addEventListener('click', doGenerateRecoveryKey);
$('#recoveryRemoveBtn').addEventListener('click', doRemoveRecoveryKey);
$('#recoveryBtn').addEventListener('click', doRecoveryRedeem);
// Quick unlock (DPAPI)
$('#quickUnlockToggleBtn').addEventListener('click', () => {
if (state.quickUnlockEnabled) disableQuickUnlock();
else enableQuickUnlock();
});
// Re-auth modal
$('#reauthForm').addEventListener('submit', e => { e.preventDefault(); closeReauth(true); });
$$('#reauthModal [data-close]').forEach(b => b.addEventListener('click', () => closeReauth(false)));
// Custom confirm / prompt modal
$('#confirmForm').addEventListener('submit', e => {
e.preventDefault();
// If input field visible -> resolve with its value, else -> true
const hasInput = !$('#confirmInputField').classList.contains('is-hidden');
closeConfirm(hasInput ? $('#confirmInput').value : true);
});
$$('#confirmModal [data-confirm-cancel]').forEach(b =>
b.addEventListener('click', () => closeConfirm(false))
);
// Helper: is the user currently typing into an input/textarea/select
// or contenteditable surface? Shortcuts like Ctrl+A must NOT hijack
// input focus (browser default = select all text in the field).
function isTypingTarget(t) {
if (!t) return false;
const tag = t.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
if (t.isContentEditable) return true;
return false;
}
// Command palette + bulk shortcuts
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
openPalette();
} else if ((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')) {
// Ctrl+A = select every visible entry. Replaces the role of
// a marquee "drag across all cards" that we used to have.
// Only fire when the user isn't typing in a field — otherwise
// we'd steal the universal Select All in inputs.
if (isTypingTarget(e.target)) return;
// Don't fire if the app isn't actually showing the entry grid
// (auth screen, locked, etc.)
if ($('#appShell').classList.contains('is-hidden')) return;
e.preventDefault();
const visible = filteredEntries();
visible.forEach(en => state.checked.add(en.id));
renderGrid();
} else if (e.key === '?' &&
!/^(INPUT|TEXTAREA|SELECT)$/.test((e.target||{}).tagName) &&
!e.ctrlKey && !e.metaKey && !e.altKey) {
// '?' anywhere outside an input shows the hotkey cheatsheet.
// Skipped while the auth screen is up — discovery is for the
// unlocked workflow.
if ($('#appShell').classList.contains('is-hidden')) return;
e.preventDefault();
openCheatsheet();
} else if (e.key === 'Escape') {
// Close in priority order: confirm first (most modal-y) then others
if (!$('#confirmModal').classList.contains('is-hidden')) {
closeConfirm(false);
return;
}
if (!$('#changeMasterModal').classList.contains('is-hidden')) {
closeChangeMasterModal();
return;
}
if (!$('#cheatsheetModal').classList.contains('is-hidden')) {
closeCheatsheet();
return;
}
if (!$('#historyModal').classList.contains('is-hidden')) {
closeHistoryModal();
return;
}
closePalette();
requestCloseSlideOver();
closeEntryModal();
closeGen();
// If nothing else needed dismissing and there's an active
// selection, clear it. Replaces the "click empty space to
// deselect" path that the marquee provided.
if (state.checked.size > 0) {
state.checked.clear();
renderGrid();
}
}
});
$('#cmdInput').addEventListener('input', e => renderPaletteResults(e.target.value));
$$('#cmdPalette [data-close]').forEach(b => b.addEventListener('click', closePalette));
$$('#cheatsheetModal [data-close]').forEach(b =>
b.addEventListener('click', closeCheatsheet));
$$('#historyModal [data-close]').forEach(b =>
b.addEventListener('click', closeHistoryModal));
const cheatBtn = document.getElementById('cheatsheetBtn');
if (cheatBtn) cheatBtn.addEventListener('click', openCheatsheet);
// Filters dropdown
const filtersBtn = document.getElementById('filtersBtn');
const filtersMenu = document.getElementById('filtersMenu');
if (filtersBtn && filtersMenu) {
filtersBtn.addEventListener('click', ev => {
ev.stopPropagation();
renderFiltersMenu();
filtersMenu.classList.toggle('is-hidden');
});
// Click outside the menu closes it (not on a checkbox / button — those
// bubbleStop above so they don't reach this listener).
document.addEventListener('click', e => {
if (!e.target.closest('.filters-wrap'))
filtersMenu.classList.add('is-hidden');
});
}
renderFiltersBadge();
renderFilterChips();
// Quick-search modal (tray menu) — keyboard nav + close
const qsInput = document.getElementById('quickSearchInput');
if (qsInput) {
qsInput.addEventListener('input', () => {
quickSearchSelected = 0;
quickSearchRender();
});
qsInput.addEventListener('keydown', e => {
const rows = document.querySelectorAll('#quickSearchResults .quick-search-row');
if (e.key === 'Escape') {
e.preventDefault();
closeQuickSearchModal();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (rows.length) { quickSearchSelected++; quickSearchRender(); }
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (rows.length) { quickSearchSelected--; quickSearchRender(); }
} else if (e.key === 'Enter') {
e.preventDefault();
const sel = rows[quickSearchSelected];
if (!sel) return;
const id = parseInt(sel.dataset.id, 10);
const entry = state.entries.find(x => x.id === id);
// Shift+Enter → username, Ctrl+Enter → password only,
// plain Enter → full (user + Tab + password).
const mode = e.shiftKey ? 'user'
: (e.ctrlKey || e.metaKey) ? 'pwd'
: 'full';
if (entry) quickSearchPickEntry(entry, mode);
}
});
}
$$('#quickSearchModal [data-close]').forEach(b =>
b.addEventListener('click', closeQuickSearchModal));
// Re-sync quickUnlockEnabled from the DPAPI source of truth. localStorage
// is wiped at each launch (random port → new origin), so the cached value
// can lie about the actual server-side state.
if (Bridge.active) {
const has = await bridgeQuickUnlockStatus();
state.quickUnlockEnabled = has;
if (has) localStorage.setItem('quickUnlockEnabled', '1');
else localStorage.removeItem('quickUnlockEnabled');
}
// Restore session if any
if (state.token && state.salt) {
const ok = await restoreCryptoKey();
if (ok) {
await enterApp();
} else {
// sessionStorage still has token+salt but the crypto key was
// cleared (locked / tab closed). Try the DPAPI quick-unlock
// path before falling back to the master-pw prompt.
if (await tryQuickUnlock()) {
await enterApp();
} else {
showAuth();
$('#loginUsername').value = state.username;
}
}
} else {
// Cold start: no sessionStorage at all. Quick unlock can still
// restore everything (token + salt + key + username) from the
// DPAPI blob.
if (await tryQuickUnlock()) {
await enterApp();
} else {
showAuth();
}
}
}
document.addEventListener('DOMContentLoaded', init);