Files
Password-Manager/js/app.js
T
r-zakarya 1751f0534f fix(slideover): opening the editor closes Settings (was hidden underneath)
Ctrl+Shift+A with Settings open created the entry panel BELOW the Settings
panel — invisible. openSlideOver now closes Settings first (root fix: covers
every editor-open path, not just the hotkey), placed after the discard guard
so cancelling keeps Settings. The inverse — opening Settings over the editor
— stays as-is by design (Esc: Settings first, editor second).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 04:57:20 +01:00

8076 lines
342 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: 0 = never auto-clear (username copies). Any positive
// value means "auto-clear this secret" and is overridden by the user's
// clipboardClearSeconds setting (0 there = user disabled auto-clear).
// Returns true when the bridge handled the copy, false as fallback signal.
copySecure(text, clearAfterMs = 30000) {
if (!active) return false;
const ms = clearAfterMs > 0 ? (state.clipboardClearSeconds * 1000) : 0;
cmd('cmd://clipboard/copy?text=' + encodeURIComponent(text) +
'&clear=' + ms);
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();
}
},
// ---- 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 after cmd://autofill/execute with whether the
// keystrokes were actually sent (false = elevated target, UIPI).
onAutofillResult(ok) {
autofillReportResult(!!ok);
},
// 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' : '') +
(state.autofillClearField ? '' : '&clear=0') +
(state.autofillFailBalloon ? '' : '&notify=0'));
},
// 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
autofillClearField: localStorage.getItem('autofillClearField') !== '0', // default ON
clipboardClearSeconds: parseInt(localStorage.getItem('clipboardClearSeconds') ?? '30', 10), // 0 = never
autofillFailBalloon: localStorage.getItem('autofillFailBalloon') !== '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 + TOTP/custom-field crypto — extracted to js/app.totp.js
// (§3.1), loaded as a separate <script> before this file.
// ============================================================
// ============================================================
// FAVICONS — extracted to js/app.favicon.js (§3.1), loaded as a
// separate <script> before this file (pure declarations).
// ============================================================
// ============================================================
// QUICK SEARCH + CHEATSHEET + HISTORY — extracted to
// js/app.overlays.js (§3.1), loaded as a separate <script> before
// this file (pure declarations).
// ============================================================
// 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)); }
// Toast suffix for a just-copied secret, honouring the auto-clear setting.
// '' when the user disabled auto-clear (Never).
function clipClearSuffix() {
const s = state.clipboardClearSeconds;
return s > 0 ? ' · clears in ' + s + 's' : '';
}
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) || '' };
}
// Metadata-at-rest: username/site/title/tags are stored encrypted
// (<f>_enc/<f>_iv). Decrypt each into e.<f> in place so all downstream code
// (render, search, autofill-match, sort, favicon) works on the plaintext
// transparently — exactly as when they were cleartext columns. Rows not yet
// migrated have no <f>_enc → their cleartext e.<f> is kept (fallback).
// (ENCRYPTED_META_FIELDS is declared just below, resolved at call time.)
async function decryptEntryMeta(list) {
for (const e of (list || [])) {
if (!e) continue;
for (const f of ENCRYPTED_META_FIELDS) {
const enc = e[f + '_enc'], iv = e[f + '_iv'];
if (enc && iv) {
const v = await decryptPwd(enc, iv);
if (v !== '[ERROR]') e[f] = v;
} else if (e[f] == null) {
e[f] = ''; // site/username cleartext columns dropped → default
}
}
}
}
// Fields encrypted at rest as metadata (§1.3). Each `f` has cleartext `f`
// (blanked on write) + ciphertext `f_enc`/`f_iv`. Search/sort/render all run
// client-side on the decrypted in-memory value, so encrypting these is
// transparent. `folder` stays cleartext (server folder-reassign query).
const ENCRYPTED_META_FIELDS = ['username', 'site', 'title', 'tags', 'template'];
// Choke point for the write path: take an entry body object whose metadata
// fields hold PLAINTEXT, encrypt each into <f>_enc/<f>_iv, and blank the
// cleartext so nothing readable is persisted. Wrap every POST/PUT /entries
// body in this. Empty field → cleared (server stores NULL ciphertext + '').
// Mutates + returns the object for convenience.
async function withEncryptedMeta(obj) {
if (!obj) return obj;
for (const f of ENCRYPTED_META_FIELDS) {
// Partial re-ships (add-tag, move-to-folder, batch ops) omit `template`
// on purpose — the server preserves it when the key is absent
// (LHasTemplate). Synthesising an empty one here would blank the key
// and wipe the stored template. The 4 core fields are always present,
// so this only ever skips `template`.
if (!(f in obj)) continue;
const plain = obj[f] || '';
if (plain) {
const c = await encryptPwd(plain);
obj[f + '_enc'] = c.encrypted;
obj[f + '_iv'] = c.iv;
} else {
obj[f + '_enc'] = '';
obj[f + '_iv'] = '';
}
obj[f] = '';
}
return obj;
}
// True when a row still holds cleartext in a metadata field that hasn't been
// encrypted yet (cleartext present but no matching <f>_enc).
function entryNeedsMetaMigration(e) {
if (!e) return false;
return ENCRYPTED_META_FIELDS.some(f => (e[f] || '') !== '' && !e[f + '_enc']);
}
// One-time sweep: re-save (full re-ship PUT) every entry that still carries
// cleartext metadata (username/site/title/tags) with no ciphertext yet, so the
// cleartext is wiped from the DB. Runs at enterApp; no-op once every row is
// migrated. Best-effort — individual failures are skipped and retried on the
// next unlock. The PUT bumps updated_at (accepted one-time sync churn; the
// plaintext is unchanged so other devices converge to the same value).
async function migrateMetadataAtRest() {
if (!state.cryptoKey) return;
let pool = (state.entries || []).slice();
// Trashed rows live in state.trashed (loaded on demand), not state.entries,
// so the live-only sweep would leave a soft-deleted entry's metadata in
// cleartext until purge. Fetch + decrypt the trash so it's covered too —
// the PUT updates the row's fields without touching `deleted`, so it stays
// in the trash. Trashed rows aren't in the sync snapshot, so no churn.
try {
const trash = await api('/entries?deleted=1', { headers: authHeaders() });
if (Array.isArray(trash)) {
await decryptEntryMeta(trash);
pool = pool.concat(trash);
}
} catch (_) {}
const todo = pool.filter(entryNeedsMetaMigration);
if (todo.length === 0) return;
let migrated = 0;
for (const e of todo) {
try {
await api('/entries/' + e.id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(await withEncryptedMeta({
site: e.site,
title: e.title || '',
username: e.username,
encrypted_password: e.encrypted_password,
iv: e.iv,
folder: e.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 || '',
template: e.template || '',
})),
});
migrated++;
} catch (_) { /* retry next unlock */ }
}
if (migrated > 0) await loadEntries();
}
async function loadEntries() {
try {
const r = await api('/entries', { headers: authHeaders() });
state.entries = Array.isArray(r) ? r : [];
await decryptEntryMeta(state.entries);
} 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 : [];
await decryptEntryMeta(state.trashed);
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(await withEncryptedMeta({
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 — extracted to js/app.health.js (§3.1), loaded as a
// separate <script> before this file (pure declarations).
// ============================================================
// ============================================================
// 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(await withEncryptedMeta({
// Re-ship the full payload — partial PUT would wipe
// TOTP / custom_fields / kind / template / username_enc
// (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(await withEncryptedMeta({
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.
// A "switch" = the panel is open on a different target. soState.id !== id
// catches every cross-id case EXCEPT new→new (both ids null), which is
// still a real switch (a dirty unsaved new entry replaced by a fresh one,
// e.g. Ctrl+Shift+A) — so OR that case in explicitly.
const switching = $('#slideover').classList.contains('is-open') &&
soState && (soState.id !== id || (id == null && soState.id == null));
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;
// The editor must be visible when it opens: Settings sits ABOVE the
// slideover, so a hotkey (Ctrl+Shift+A) fired with Settings open left
// the new-entry panel hidden underneath. Close Settings — placed AFTER
// the discard guard so cancelling it keeps Settings untouched. The
// inverse (opening Settings over the editor) is deliberate and stays:
// Esc closes Settings first, then handles the editor.
if ($('#settingsPanel').classList.contains('is-open')) closeSettings();
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 if (!opts.noFocus) {
// 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.
// Skipped on the post-save re-open (opts.noFocus) — grabbing +
// selecting the title right after Save is jarring.
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' + clipClearSuffix());
});
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' + clipClearSuffix());
} else {
navigator.clipboard.writeText(input.value).then(() => {
toast('Copied' + clipClearSuffix());
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);
// Live strength bar — same .strength-bar/--strength pattern as the entry
// modal + register, reusing computeStrength (no zxcvbn).
const bar = el('div', { class: 'strength-bar', id: 'soStrengthBar' });
const setStr = () => bar.style.setProperty('--strength', computeStrength(input.value) + '%');
input.addEventListener('input', setStr);
setStr();
wrap.appendChild(bar);
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' + clipClearSuffix());
} else {
navigator.clipboard.writeText(t.code).then(() => {
toast('Code copied' + clipClearSuffix());
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';
}
// Re-entrancy latch: a double-click on Save (or Enter+click) while the first
// run awaits encryption/POST would fire a SECOND full save → duplicate entry.
// Same async-race class as the sync-button guard.
let soSaving = false;
async function soSave() {
if (soSaving) return;
soSaving = true;
try { await soSaveInner(); } finally { soSaving = false; }
}
async function soSaveInner() {
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(await withEncryptedMeta({
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;
// noFocus: keep the slideover open on the saved entry WITHOUT grabbing
// + selecting the title (jarring right after Save).
if (updated) openSlideOver(updated.id, { noFocus: true });
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(await withEncryptedMeta({
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(await withEncryptedMeta({
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(await withEncryptedMeta({
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' + clipClearSuffix());
} else {
navigator.clipboard.writeText(p).then(() => toast('Password copied' + clipClearSuffix()));
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 — extracted to js/app.attachments.js (§3.1),
// loaded as a separate <script> before this file (pure declarations).
// ============================================================
// 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 + PIN + RECOVERY KEY — extracted to js/app.unlock.js (§3.1),
// loaded AFTER this file (it assigns Bridge.onQuickUnlockResult / onPinResult
// etc. at top level, same rule as app.sync.js).
// ============================================================
// Clipboard-read bridge plumbing (custom context-menu Paste). Kept here —
// unrelated to unlock, it just used to live next to the quick-unlock helpers.
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;
}
};
// ============================================================
// 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 metadata (username/site/title/tags) — re-encrypt
// each plaintext (decrypted at load) under the NEW key.
state.cryptoKey = newKey;
const meta = {};
for (const f of ENCRYPTED_META_FIELDS) {
if (e[f]) {
const c = await encryptPwd(e[f]);
meta[f + '_enc'] = c.encrypted;
meta[f + '_iv'] = c.iv;
} else {
meta[f + '_enc'] = '';
meta[f + '_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,
...meta,
});
} 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 — extracted to js/app.autofill.js (§3.1), loaded as a
// separate <script> before this file (pure declarations).
// ============================================================
// ============================================================
// 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;
$('#settingAutofillClear').checked = state.autofillClearField;
$('#settingAutofillFailBalloon').checked = state.autofillFailBalloon;
$('#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);
$('#settingClipboardClear').value = String(state.clipboardClearSeconds ?? 30);
$('#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');
}
// Settings tabs — each section (.slideover-field, keyed by its label text)
// belongs to one tab; the active tab shows its sections, hides the rest.
const SETTINGS_TAB_OF = {
'Appearance': 'general',
'Security': 'security', 'Clipboard privacy': 'security',
'Autofill': 'autofill',
'Account': 'account',
'PIN unlock': 'account', 'Quick unlock': 'account', 'Recovery key': 'account',
'Export': 'sync', 'Sync (WebDAV)': 'sync', 'Auto-backup': 'sync', 'Import': 'sync',
};
let settingsActiveTab = 'general';
function applySettingsTab(tab) {
if (tab) settingsActiveTab = tab;
// A live search filters rows across ALL sections — suspend the tab filter
// so a match in another tab isn't hidden. Empty search → tab filter on.
const searching = !!($('#settingsSearch') && $('#settingsSearch').value.trim());
// Don't highlight any tab while searching (results are cross-tab, so a lit
// "General" over Sync results reads wrong). Restored when search clears.
$$('#settingsTabs .settings-tab').forEach(b =>
b.classList.toggle('is-active', !searching && b.dataset.tab === settingsActiveTab));
$$('#settingsBody > .slideover-field').forEach(sec => {
const lbl = sec.querySelector('.slideover-field-label');
const t = lbl ? (SETTINGS_TAB_OF[lbl.textContent.trim()] || 'general') : 'general';
sec.classList.toggle('is-tab-hidden', !searching && t !== settingsActiveTab);
});
}
// 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);
applySettingsTab(); // re-apply tab filter (off while searching, on when cleared)
}
// ---- 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();
// One-time metadata-at-rest migration: encrypt the cleartext username of
// any row that predates the encrypted column. Fire-and-forget so it never
// blocks the UI; each pass shrinks the backlog until nothing's left.
migrateMetadataAtRest();
// 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);
// First-run guided tour (spotlights the headline features once).
maybeStartTour();
}
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 — extracted to js/app.backup.js (§3.1), loaded as a
// separate <script> before this file (pure declarations).
// ============================================================
// ============================================================
// 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',
// Send Ctrl+A + Del before typing each autofill field (default ON).
// OFF for targets where Ctrl+A isn't select-all (terminals, RDP).
'autofillClearField',
// Tray balloon when a fill fails while the window is hidden (default ON).
'autofillFailBalloon',
// Seconds before a copied secret is auto-cleared (0 = never). Default 30.
'clipboardClearSeconds',
];
// 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;
case 'autofillClearField':
localStorage.setItem('autofillClearField', v ? '1' : '0');
break;
case 'autofillFailBalloon':
localStorage.setItem('autofillFailBalloon', v ? '1' : '0');
break;
case 'clipboardClearSeconds':
localStorage.setItem('clipboardClearSeconds', String(v));
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();
});
// Capture + stopPropagation: Esc with the menu open must close ONLY the
// menu — otherwise the same keystroke reaches the slideover/fallback
// handlers and also closes (or discard-prompts) whatever is behind.
document.addEventListener('keydown', ev => {
if (ev.key !== 'Escape') return;
if (menu.classList.contains('is-hidden')) return;
ev.stopPropagation();
hide();
}, true);
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;
// Command palette is .cmd-palette, not .modal — it sits on top of
// the editor, so its Esc must close IT (bubble handler), not pop
// the editor's discard-confirm underneath.
if (!$('#cmdPalette').classList.contains('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;
if (e.target.closest('#busyOverlay')) return; // sync/backup spinner
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' + clipClearSuffix());
} else {
navigator.clipboard.writeText(genCurrent).then(() => {
toast('Copied' + clipClearSuffix());
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;
// Busy overlay up (sync/backup running) — don't close under it.
if ($('#busyOverlay') && !$('#busyOverlay').classList.contains('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).
$$('#settingsTabs .settings-tab').forEach(b =>
b.addEventListener('click', () => applySettingsTab(b.dataset.tab)));
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)');
});
$('#settingClipboardClear').addEventListener('change', e => {
const n = parseInt(e.target.value, 10) || 0;
state.clipboardClearSeconds = n;
localStorage.setItem('clipboardClearSeconds', String(n));
saveServerSettings();
toast(n === 0 ? 'Clipboard auto-clear disabled' : 'Copied secrets clear after ' + n + 's');
});
$('#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);
// Guard the user entry point: ignore clicks while a sync is running (no
// concurrent syncs) and call runSyncNow() with NO arg — the click event
// must not land in its `_attempt` retry counter. Internal retries call
// runSyncNow directly, so they're unaffected.
if (syncNow) syncNow.addEventListener('click', async () => {
if (syncNow.disabled) return;
syncNow.disabled = true;
try { await runSyncNow(); } finally { syncNow.disabled = false; }
});
$('#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));
});
$('#autoBackupSetPwdBtn').addEventListener('click', changeBackupPwd);
$('#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');
});
$('#settingAutofillClear').addEventListener('change', e => {
state.autofillClearField = e.target.checked;
localStorage.setItem('autofillClearField', e.target.checked ? '1' : '0');
saveServerSettings();
});
$('#settingAutofillFailBalloon').addEventListener('change', e => {
state.autofillFailBalloon = e.target.checked;
localStorage.setItem('autofillFailBalloon', e.target.checked ? '1' : '0');
saveServerSettings();
});
// ---- 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));
$('#startTourBtn').addEventListener('click', () => {
closeSettings();
setTimeout(startTour, 250); // let the panel slide out first
});
$('#openClipboardSettings').addEventListener('click', () => {
toast('Copies use the ExcludeClipboardContentFromMonitorProcessing flag, so Windows skips them in Win+V history and cloud sync.');
});
$('#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;
}
// ONE surface per keystroke, topmost first — closing several at
// once meant "Esc closes the palette AND pops the editor's
// discard prompt" (same class as the quick-search Esc bug).
if (!$('#cmdPalette').classList.contains('is-hidden')) {
closePalette();
return;
}
if (!$('#entryModal').classList.contains('is-hidden')) {
closeEntryModal();
return;
}
if (!$('#genModal').classList.contains('is-hidden')) {
closeGen();
return;
}
if ($('#slideover').classList.contains('is-open')) {
requestCloseSlideOver();
return;
}
// Nothing else needed dismissing — clear an active selection.
// Replaces the "click empty space to deselect" marquee path.
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();
// Kill the keystroke: without this the SAME Esc reaches the
// document-level handlers, which now see "no modal open"
// (we just closed it) and also close the dirty slideover.
e.stopPropagation();
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 → full (user + Tab + password), Ctrl+Enter →
// username only, plain Enter → password only.
const mode = e.shiftKey ? 'full'
: (e.ctrlKey || e.metaKey) ? 'user'
: 'pwd';
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);