9351 lines
392 KiB
JavaScript
9351 lines
392 KiB
JavaScript
/* ============================================================
|
||
Vault — UI V2 app.js
|
||
Clean state + render layer. Crypto helpers preserved verbatim
|
||
from legacy. Backend (PHP api.php / Delphi loopback) is detected
|
||
from URL path.
|
||
============================================================ */
|
||
|
||
// ---- Backend detection -------------------------------------
|
||
const API = (location.pathname.indexOf('/password-manager/') === 0)
|
||
? '/password-manager/api.php'
|
||
: '';
|
||
|
||
// ---- Delphi native bridge ----------------------------------
|
||
// Active only when running inside the Delphi-hosted WebView2 (API === '').
|
||
// Falls back to navigator.clipboard for the standalone PHP frontend.
|
||
const prefResolvers = {};
|
||
const fileSaveResolvers = {};
|
||
const fileChunkResolvers = {};
|
||
// Stream a base64 string to Delphi in URL-sized pieces via cmd://file/chunk,
|
||
// each acked (onFileChunkAck) before the next is sent. Returns true when all
|
||
// pieces are buffered server-side, false on a stalled chunk. The ack CLEARS
|
||
// the pending timeout — without that, a resolved chunk's stale 30s timeout
|
||
// would later delete the CURRENT chunk's resolver and hang the transfer
|
||
// forever (only the last location.href navigation "wins" per event loop, so
|
||
// resolvers must be strictly 1-at-a-time and their timers torn down).
|
||
async function _streamChunks(reqId, b64, onProgress) {
|
||
const CHUNK = 1000000;
|
||
const total = Math.ceil(b64.length / CHUNK);
|
||
let done = 0;
|
||
for (let off = 0; off < b64.length; off += CHUNK) {
|
||
const piece = b64.slice(off, off + CHUNK);
|
||
const ok = await new Promise(res => {
|
||
const timer = setTimeout(() => {
|
||
if (fileChunkResolvers[reqId]) {
|
||
delete fileChunkResolvers[reqId];
|
||
res(false);
|
||
}
|
||
}, 30000);
|
||
fileChunkResolvers[reqId] = { resolve: res, timer };
|
||
window.location.href = 'cmd://file/chunk?reqId=' + encodeURIComponent(reqId) +
|
||
'&data=' + encodeURIComponent(piece);
|
||
});
|
||
if (!ok) return false;
|
||
done++;
|
||
if (typeof onProgress === 'function') onProgress(Math.round(done / total * 100));
|
||
}
|
||
return true;
|
||
}
|
||
let versionResolver = null;
|
||
let launchModeResolver = null;
|
||
const folderPickResolvers = {};
|
||
const fileWriteResolvers = {};
|
||
const fileListResolvers = {};
|
||
const fileDeleteResolvers = {};
|
||
let autoStartResolver = null;
|
||
const faviconResolvers = {};
|
||
let _faviconReqSeq = 0;
|
||
const Bridge = (() => {
|
||
const active = (API === '');
|
||
|
||
// Navigate to a cmd:// URL — intercepted synchronously by
|
||
// TTMSFNCWebBrowser OnBeforeNavigate before any actual navigation occurs.
|
||
function cmd(path) {
|
||
window.location.href = path;
|
||
}
|
||
|
||
return {
|
||
active,
|
||
|
||
// Copy text to clipboard, excluded from Win+V history.
|
||
// clearAfterMs: Delphi auto-clears after this many ms (0 = never).
|
||
// Returns true when the bridge handled the copy, false as fallback signal.
|
||
copySecure(text, clearAfterMs = 30000) {
|
||
if (!active) return false;
|
||
cmd('cmd://clipboard/copy?text=' + encodeURIComponent(text) +
|
||
'&clear=' + clearAfterMs);
|
||
return true;
|
||
},
|
||
|
||
// Called by Delphi (ExecuteJavaScript) on WTS_SESSION_LOCK and
|
||
// PBT_APMSUSPEND. When quick-unlock is enabled on this device the
|
||
// DPAPI blob already gates access via the Windows user account, so
|
||
// re-locking is redundant — we just stay unlocked and the user is
|
||
// back where they left off when they return.
|
||
onSystemLock() {
|
||
if (state.quickUnlockEnabled) {
|
||
if (typeof toast === 'function')
|
||
toast('System lock — vault kept unlocked (quick unlock active)');
|
||
return;
|
||
}
|
||
if (typeof lockVault === 'function') lockVault();
|
||
},
|
||
|
||
// Called by Delphi (ExecuteJavaScript) when the user restores the
|
||
// window from the tray icon. Useful for resetting auto-lock state
|
||
// and giving a subtle visual cue.
|
||
onTrayRestore() {
|
||
// If the user has been away long enough that the auto-lock
|
||
// should fire, lockVault was already called by either WTS lock
|
||
// or the local idle timer — so we only reset here when still
|
||
// unlocked.
|
||
if (state.cryptoKey && !state.locked) {
|
||
if (typeof resetAutoLock === 'function') resetAutoLock();
|
||
}
|
||
},
|
||
|
||
// ---- Autofill (Ctrl+Shift+L / Ctrl+Shift+P global hotkeys) ----------
|
||
|
||
// Called by Delphi when a hotkey fires. windowTitle = foreground
|
||
// window title at hotkey time. kind = "full" (user+Tab+pwd) or
|
||
// "password" (password only — for step-2 forms, unlock screens).
|
||
onAutofillRequest(windowTitle, kind) {
|
||
autofillHandleRequest(windowTitle, kind || 'full');
|
||
},
|
||
|
||
// Called by Delphi on Ctrl+Shift+A. Opens the new-entry modal with
|
||
// the foreground window title pre-filled (browser suffix stripped).
|
||
onNewEntryFromTitle(windowTitle) {
|
||
if (!state.cryptoKey || state.locked || !state.token) {
|
||
toast('Unlock the vault first', 'warning');
|
||
return;
|
||
}
|
||
const cleaned = autofillStripBrowserSuffix(windowTitle);
|
||
openSlideOver(null, { presetTitle: cleaned });
|
||
},
|
||
|
||
// Tell Delphi to simulate keystrokes. Empty username = password only
|
||
// (no Tab is sent). hideAfter=true asks Delphi to hide our window
|
||
// back to the tray AFTER SendInput completes — necessary for the
|
||
// Ctrl+Shift+Q-from-tray flow (we cannot hide before SendInput or
|
||
// Win10/11 anti-focus-stealing rules block the target).
|
||
executeAutofill(username, password, hideAfter, field) {
|
||
if (!active) return;
|
||
// field='user' → type only the username (no Tab / password).
|
||
cmd('cmd://autofill/execute?username=' + encodeURIComponent(username) +
|
||
'&password=' + encodeURIComponent(password) +
|
||
(hideAfter ? '&hide_after=1' : '') +
|
||
(field === 'user' ? '&field=user' : ''));
|
||
},
|
||
|
||
// Ask Delphi to bring the main window to front (used when the
|
||
// multi-match picker opens, so the user definitely sees it even
|
||
// if the app was minimised to tray or hidden behind other apps).
|
||
focusApp() {
|
||
if (!active) return;
|
||
cmd('cmd://app/focus');
|
||
},
|
||
|
||
// Tell Delphi the page is rendered and waiting for input — Delphi
|
||
// calls WebBrowser.SetFocus (the FMX control needs OS-level focus
|
||
// before any input.focus() inside the DOM can work) then injects
|
||
// a focus script targeting the visible auth field.
|
||
appReady() {
|
||
if (!active) return;
|
||
cmd('cmd://app/ready');
|
||
},
|
||
|
||
// Sync the Windows title bar with the app theme (dark vs light).
|
||
// Calls DwmSetWindowAttribute DWMWA_USE_IMMERSIVE_DARK_MODE on the
|
||
// form's HWND. No-op on Windows < 10 build 19044.
|
||
syncTitleBarTheme(mode) {
|
||
if (!active) return;
|
||
cmd('cmd://app/theme?mode=' + (mode === 'light' ? 'light' : 'dark'));
|
||
},
|
||
|
||
// Tell Delphi we couldn't find a match / user cancelled.
|
||
cancelAutofill() {
|
||
if (!active) return;
|
||
cmd('cmd://autofill/cancel');
|
||
},
|
||
|
||
// Sync the hotkey registration state with this device's preference.
|
||
configureAutofill(enabled) {
|
||
if (!active) return;
|
||
cmd('cmd://autofill/configure?enabled=' + (enabled ? '1' : '0'));
|
||
},
|
||
|
||
// Send the full hotkey configuration to Delphi (enabled + combos).
|
||
// combos = { full: {ctrl,shift,alt,win,key}, password: {…} }.
|
||
setAutofillHotkeys(enabled, combos) {
|
||
if (!active) return;
|
||
const f = autofillComboToWin32(combos.full);
|
||
const p = autofillComboToWin32(combos.password);
|
||
let qs = '';
|
||
if (combos.quickSearch) {
|
||
const q = autofillComboToWin32(combos.quickSearch);
|
||
qs = '&qs_mods=' + q.mods + '&qs_vk=' + q.vk;
|
||
}
|
||
cmd('cmd://autofill/hotkeys?enabled=' + (enabled ? '1' : '0') +
|
||
'&full_mods=' + f.mods + '&full_vk=' + f.vk +
|
||
'&pwd_mods=' + p.mods + '&pwd_vk=' + p.vk + qs);
|
||
},
|
||
|
||
// Called by Delphi after a setAutofillHotkeys request, with true if
|
||
// BOTH combos registered successfully, false otherwise (clash with
|
||
// another app holding a global hotkey). Surfaces a toast.
|
||
onAutofillHotkeysResult(allOk) {
|
||
if (allOk) {
|
||
toast('Autofill hotkeys updated');
|
||
} else {
|
||
toast('Autofill: one or both hotkeys are already used by another app', 'warning');
|
||
}
|
||
},
|
||
|
||
// ---- Device-bound prefs (DPAPI-backed) ----------------------------
|
||
// localStorage is keyed by origin, and our HTTP port is random on
|
||
// every launch — so anything we put there is wiped at reboot. For
|
||
// prefs that need to survive a reboot (remembered username, etc.),
|
||
// round-trip through Delphi which persists via DPAPI.
|
||
getPref(key) {
|
||
if (!active) return Promise.resolve('');
|
||
return new Promise(resolve => {
|
||
prefResolvers[key] = resolve;
|
||
cmd('cmd://prefs/get?key=' + encodeURIComponent(key));
|
||
setTimeout(() => {
|
||
if (prefResolvers[key] === resolve) {
|
||
delete prefResolvers[key];
|
||
resolve('');
|
||
}
|
||
}, 2000);
|
||
});
|
||
},
|
||
setPref(key, value) {
|
||
if (!active) return;
|
||
cmd('cmd://prefs/set?key=' + encodeURIComponent(key) +
|
||
'&value=' + encodeURIComponent(value || ''));
|
||
},
|
||
onPrefResult(key, value) {
|
||
const r = prefResolvers[key];
|
||
if (r) {
|
||
delete prefResolvers[key];
|
||
r(value || '');
|
||
}
|
||
},
|
||
|
||
// Native Save As dialog — bypasses WebView2's browser download UI
|
||
// (which Edge wraps with "Téléchargements" popup + Open File prompt).
|
||
// The caller passes raw bytes as Uint8Array OR a string; we base64
|
||
// it and ship to Delphi which writes the file after the user picks.
|
||
saveFile(name, content, onProgress) {
|
||
if (!active) return Promise.resolve({ ok: false, error: 'bridge offline' });
|
||
let bytes;
|
||
if (typeof content === 'string') bytes = new TextEncoder().encode(content);
|
||
else bytes = content;
|
||
const b64 = bytesToBase64(bytes);
|
||
const reqId = 'fs_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
|
||
// cmd:// goes through window.location.href, which WebView2 caps at
|
||
// roughly a couple MB of URL. A big attachment base64'd blows past
|
||
// that → the navigation blanks the document (black screen). So for
|
||
// anything large we stream the data in chunks small enough to fit
|
||
// a URL, each acknowledged before the next is sent (sequential —
|
||
// otherwise repeated location.href assignments coalesce and only
|
||
// the last lands). Small payloads keep the fast single-shot path.
|
||
// ~1 MB base64 per chunk — comfortably under WebView2's ~2 MB
|
||
// URL cap even after percent-encoding, while keeping the number
|
||
// of round-trips (and total time) low on big exports.
|
||
const CHUNK = 1000000;
|
||
if (b64.length <= CHUNK) {
|
||
return new Promise(resolve => {
|
||
fileSaveResolvers[reqId] = resolve;
|
||
cmd('cmd://file/save?name=' + encodeURIComponent(name) +
|
||
'&data=' + encodeURIComponent(b64) +
|
||
'&reqId=' + encodeURIComponent(reqId));
|
||
setTimeout(() => {
|
||
if (fileSaveResolvers[reqId]) {
|
||
delete fileSaveResolvers[reqId];
|
||
resolve({ ok: false, error: 'timeout' });
|
||
}
|
||
}, 120000);
|
||
});
|
||
}
|
||
return (async () => {
|
||
const ok = await _streamChunks(reqId, b64, onProgress);
|
||
if (!ok) return { ok: false, error: 'chunk transfer failed' };
|
||
return await new Promise(resolve => {
|
||
fileSaveResolvers[reqId] = resolve;
|
||
cmd('cmd://file/save-commit?reqId=' + encodeURIComponent(reqId) +
|
||
'&name=' + encodeURIComponent(name));
|
||
setTimeout(() => {
|
||
if (fileSaveResolvers[reqId]) {
|
||
delete fileSaveResolvers[reqId];
|
||
resolve({ ok: false, error: 'timeout' });
|
||
}
|
||
}, 120000);
|
||
});
|
||
})();
|
||
},
|
||
onFileChunkAck(reqId) {
|
||
const r = fileChunkResolvers[reqId];
|
||
if (r) {
|
||
delete fileChunkResolvers[reqId];
|
||
if (r.timer) clearTimeout(r.timer);
|
||
r.resolve(true);
|
||
}
|
||
},
|
||
onFileSaveResult(reqId, ok, path, err) {
|
||
const r = fileSaveResolvers[reqId];
|
||
if (r) {
|
||
delete fileSaveResolvers[reqId];
|
||
r({ ok: !!ok, path: path || '', error: err || '' });
|
||
}
|
||
},
|
||
|
||
// ---- Auto-backup helpers (folder pick + silent write + list + delete)
|
||
pickFolder() {
|
||
if (!active) return Promise.resolve('');
|
||
const reqId = 'fp_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
|
||
return new Promise(resolve => {
|
||
folderPickResolvers[reqId] = resolve;
|
||
cmd('cmd://folder/pick?reqId=' + encodeURIComponent(reqId));
|
||
setTimeout(() => {
|
||
if (folderPickResolvers[reqId]) {
|
||
delete folderPickResolvers[reqId];
|
||
resolve('');
|
||
}
|
||
}, 120000);
|
||
});
|
||
},
|
||
onFolderPickResult(reqId, path) {
|
||
const r = folderPickResolvers[reqId];
|
||
if (r) { delete folderPickResolvers[reqId]; r(path || ''); }
|
||
},
|
||
|
||
writeFile(path, content, onProgress) {
|
||
if (!active) return Promise.resolve({ ok: false, error: 'bridge offline' });
|
||
let bytes;
|
||
if (typeof content === 'string') bytes = new TextEncoder().encode(content);
|
||
else bytes = content;
|
||
const b64 = bytesToBase64(bytes);
|
||
const reqId = 'fw_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
|
||
// Same URL-length trap as saveFile: a big auto-backup base64'd
|
||
// into a single cmd:// URL blows past WebView2's cap and the
|
||
// write fails (silently, or blanks the page). Chunk it.
|
||
const CHUNK = 1000000;
|
||
if (b64.length <= CHUNK) {
|
||
return new Promise(resolve => {
|
||
fileWriteResolvers[reqId] = resolve;
|
||
cmd('cmd://file/write?path=' + encodeURIComponent(path) +
|
||
'&data=' + encodeURIComponent(b64) +
|
||
'&reqId=' + encodeURIComponent(reqId));
|
||
setTimeout(() => {
|
||
if (fileWriteResolvers[reqId]) {
|
||
delete fileWriteResolvers[reqId];
|
||
resolve({ ok: false, error: 'timeout' });
|
||
}
|
||
}, 30000);
|
||
});
|
||
}
|
||
return (async () => {
|
||
const ok = await _streamChunks(reqId, b64, onProgress);
|
||
if (!ok) return { ok: false, error: 'chunk transfer failed' };
|
||
return await new Promise(resolve => {
|
||
fileWriteResolvers[reqId] = resolve;
|
||
cmd('cmd://file/write-commit?reqId=' + encodeURIComponent(reqId) +
|
||
'&path=' + encodeURIComponent(path));
|
||
setTimeout(() => {
|
||
if (fileWriteResolvers[reqId]) {
|
||
delete fileWriteResolvers[reqId];
|
||
resolve({ ok: false, error: 'timeout' });
|
||
}
|
||
}, 30000);
|
||
});
|
||
})();
|
||
},
|
||
onFileWriteResult(reqId, ok, err) {
|
||
const r = fileWriteResolvers[reqId];
|
||
if (r) { delete fileWriteResolvers[reqId]; r({ ok: !!ok, error: err || '' }); }
|
||
},
|
||
|
||
listFiles(dir, prefix) {
|
||
if (!active) return Promise.resolve([]);
|
||
const reqId = 'fl_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
|
||
return new Promise(resolve => {
|
||
fileListResolvers[reqId] = resolve;
|
||
cmd('cmd://file/listMatch?dir=' + encodeURIComponent(dir) +
|
||
'&prefix=' + encodeURIComponent(prefix || '') +
|
||
'&reqId=' + encodeURIComponent(reqId));
|
||
setTimeout(() => {
|
||
if (fileListResolvers[reqId]) {
|
||
delete fileListResolvers[reqId];
|
||
resolve([]);
|
||
}
|
||
}, 10000);
|
||
});
|
||
},
|
||
onFileListResult(reqId, json) {
|
||
const r = fileListResolvers[reqId];
|
||
if (!r) return;
|
||
delete fileListResolvers[reqId];
|
||
try { r(JSON.parse(json || '[]')); }
|
||
catch { r([]); }
|
||
},
|
||
|
||
deleteFile(path) {
|
||
if (!active) return Promise.resolve(false);
|
||
const reqId = 'fd_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
|
||
return new Promise(resolve => {
|
||
fileDeleteResolvers[reqId] = resolve;
|
||
cmd('cmd://file/delete?path=' + encodeURIComponent(path) +
|
||
'&reqId=' + encodeURIComponent(reqId));
|
||
setTimeout(() => {
|
||
if (fileDeleteResolvers[reqId]) {
|
||
delete fileDeleteResolvers[reqId];
|
||
resolve(false);
|
||
}
|
||
}, 10000);
|
||
});
|
||
},
|
||
onFileDeleteResult(reqId, ok) {
|
||
const r = fileDeleteResolvers[reqId];
|
||
if (r) { delete fileDeleteResolvers[reqId]; r(!!ok); }
|
||
},
|
||
|
||
// ---- Start with Windows (HKCU Run registry) -----------------------
|
||
getAutoStart() {
|
||
if (!active) return Promise.resolve(false);
|
||
return new Promise(resolve => {
|
||
autoStartResolver = resolve;
|
||
cmd('cmd://autostart/get');
|
||
setTimeout(() => {
|
||
if (autoStartResolver === resolve) {
|
||
autoStartResolver = null;
|
||
resolve(false);
|
||
}
|
||
}, 2000);
|
||
});
|
||
},
|
||
setAutoStart(enabled) {
|
||
if (!active) return;
|
||
cmd('cmd://autostart/set?enabled=' + (enabled ? '1' : '0'));
|
||
},
|
||
onAutoStartStatus(enabled) {
|
||
if (autoStartResolver) {
|
||
const r = autoStartResolver;
|
||
autoStartResolver = null;
|
||
r(!!enabled);
|
||
}
|
||
state.autoStartEnabled = !!enabled;
|
||
const cb = document.getElementById('settingAutoStart');
|
||
if (cb) cb.checked = !!enabled;
|
||
},
|
||
|
||
// ---- Favicon fetch (via Delphi proxy → DuckDuckGo icons) ----------
|
||
// Returns a Promise<dataUri|''>. Multiple in-flight requests for
|
||
// distinct hosts are tracked per reqId so they can't collide.
|
||
fetchFavicon(host) {
|
||
if (!active) return Promise.resolve('');
|
||
if (!host) return Promise.resolve('');
|
||
const reqId = 'fav_' + (++_faviconReqSeq);
|
||
return new Promise(resolve => {
|
||
faviconResolvers[reqId] = resolve;
|
||
cmd('cmd://favicon/fetch?host=' + encodeURIComponent(host) +
|
||
'&reqId=' + encodeURIComponent(reqId));
|
||
setTimeout(() => {
|
||
if (faviconResolvers[reqId]) {
|
||
delete faviconResolvers[reqId];
|
||
resolve('');
|
||
}
|
||
}, 8000);
|
||
});
|
||
},
|
||
onFaviconResult(reqId, host, dataUri) {
|
||
const r = faviconResolvers[reqId];
|
||
if (r) {
|
||
delete faviconResolvers[reqId];
|
||
r(dataUri || '');
|
||
}
|
||
},
|
||
|
||
// Tray menu "Quick search…" → open a compact modal. wasHidden
|
||
// (passed by Delphi) tells us whether the window was in the tray
|
||
// before — if so, after the user picks an entry we ask Delphi to
|
||
// hide the window again so the paste workflow is one keystroke
|
||
// (Ctrl+V in the target app).
|
||
// Locked vault → fall through to the master-password screen.
|
||
openQuickSearch(wasHidden, forFill) {
|
||
if (state.locked || !state.cryptoKey || !state.token) {
|
||
const pwd = document.getElementById('loginPassword');
|
||
if (pwd && !document.getElementById('authScreen').classList.contains('is-hidden')) {
|
||
setTimeout(() => pwd.focus(), 60);
|
||
}
|
||
if (typeof toast === 'function')
|
||
toast('Vault is locked — unlock to search', 'warning');
|
||
// Tell Delphi we cancelled so the captured HWND doesn't
|
||
// linger waiting for a never-coming /execute.
|
||
if (forFill && Bridge.cancelAutofill) Bridge.cancelAutofill();
|
||
return;
|
||
}
|
||
if (typeof openQuickSearchModal === 'function')
|
||
openQuickSearchModal(!!wasHidden, !!forFill);
|
||
},
|
||
|
||
// Hide the window back to the tray icon. Used by Quick search to
|
||
// restore "was in tray" state after a password copy.
|
||
minimizeToTray(keepClipboard) {
|
||
if (!active) return;
|
||
// keepClipboard=true → the just-copied password survives the
|
||
// minimise (quick-search copy-then-hide). Default clears it.
|
||
cmd('cmd://app/minimize' + (keepClipboard ? '?keepclip=1' : ''));
|
||
},
|
||
|
||
// Push the "show tray notifications" preference to Delphi so the
|
||
// bridge gates the Shell_NotifyIcon NIF_INFO balloons accordingly.
|
||
setTrayNotifications(enabled) {
|
||
if (!active) return;
|
||
cmd('cmd://tray/notifications?enabled=' + (enabled ? '1' : '0'));
|
||
},
|
||
|
||
// Hardcoded build version surfaced from the Delphi host. Promise-
|
||
// based with a 2s safety timeout — if the host is unreachable the
|
||
// resolver fires with '' rather than hanging the UI.
|
||
getAppVersion() {
|
||
if (!active) return Promise.resolve('');
|
||
return new Promise(resolve => {
|
||
versionResolver = resolve;
|
||
cmd('cmd://app/version');
|
||
setTimeout(() => {
|
||
if (versionResolver === resolve) {
|
||
versionResolver = null;
|
||
resolve('');
|
||
}
|
||
}, 2000);
|
||
});
|
||
},
|
||
onVersionResult(v) {
|
||
if (versionResolver) {
|
||
const r = versionResolver;
|
||
versionResolver = null;
|
||
r(v || '');
|
||
}
|
||
},
|
||
|
||
// Was the app launched at Windows boot (via the HKCU Run entry's
|
||
// -tray flag), or did the user double-click the exe?
|
||
// Returns 'auto' | 'manual' | '' (bridge offline).
|
||
getLaunchMode() {
|
||
if (!active) return Promise.resolve('');
|
||
return new Promise(resolve => {
|
||
launchModeResolver = resolve;
|
||
cmd('cmd://app/launch-mode');
|
||
setTimeout(() => {
|
||
if (launchModeResolver === resolve) {
|
||
launchModeResolver = null;
|
||
resolve('');
|
||
}
|
||
}, 2000);
|
||
});
|
||
},
|
||
onLaunchModeResult(mode) {
|
||
if (launchModeResolver) {
|
||
const r = launchModeResolver;
|
||
launchModeResolver = null;
|
||
r(mode || '');
|
||
}
|
||
},
|
||
|
||
// Open an http(s) URL in the user's default browser via ShellExecute.
|
||
// Delphi validates the scheme so a malformed entry can't smuggle a
|
||
// file:// or custom handler.
|
||
openUrl(url) {
|
||
if (!active) return;
|
||
cmd('cmd://app/open-url?url=' + encodeURIComponent(url));
|
||
},
|
||
};
|
||
})();
|
||
|
||
// Expose Bridge on window so Delphi's ExecuteJavaScript can reach it.
|
||
window.Bridge = Bridge;
|
||
|
||
// ---- Global state ------------------------------------------
|
||
const state = {
|
||
token: sessionStorage.getItem('authToken') || '',
|
||
csrf: sessionStorage.getItem('csrfToken') || '',
|
||
salt: sessionStorage.getItem('salt') || '',
|
||
username: sessionStorage.getItem('username') || '',
|
||
// Profile picture as a data URI. Loaded from the server at enterApp
|
||
// (users.avatar_b64). Empty → the initials avatar is shown instead.
|
||
avatarDataUri: '',
|
||
// KDF iteration count of the currently-logged-in user. Cached so reauth
|
||
// and on-the-fly verifier computations don't need a /login/challenge
|
||
// round trip every time. Refreshed from every auth response.
|
||
kdfIterations: parseInt(sessionStorage.getItem('kdfIterations') || '0') || 0,
|
||
// Auth-hash scheme of the current account. Drives which verifier formula
|
||
// the client sends: 'pbkdf2-sha256-v2' → SHA256(keyHex + domain) so the
|
||
// transmitted verifier is NOT the raw AES key; anything else → keyHex
|
||
// (legacy / pre-decoupling accounts, byte-identical to before). Set from
|
||
// the /login/challenge response, from the cold-start blob, or hardcoded
|
||
// to v2 on register / master-pw change.
|
||
hashAlgo: sessionStorage.getItem('hashAlgo') || '',
|
||
// Argon2id KDF params { m, t, p } for the current account, or null for
|
||
// PBKDF2 accounts. Set from /login/challenge, the cold-start blobs, or
|
||
// ARGON2_DEFAULT_PARAMS on register / master-pw change. Needed wherever a
|
||
// key/verifier is DERIVED FROM THE PASSWORD (login, reauth, rotation) —
|
||
// NOT for cold-start verifier-from-raw-key paths.
|
||
argon2Params: (() => {
|
||
try { return JSON.parse(sessionStorage.getItem('argon2Params') || 'null'); }
|
||
catch { return null; }
|
||
})(),
|
||
cryptoKey: null,
|
||
entries: [],
|
||
trashed: [],
|
||
trashedCount: 0, // server-side count, updated separately from state.trashed
|
||
folders: [{ name: 'All', color: '', icon: '' }],
|
||
// Index in filteredEntries() of the keyboard-focused card (j/k nav).
|
||
// -1 = no cursor. Cleared on view/search change so it never points
|
||
// outside the current list.
|
||
cursorIdx: -1,
|
||
view: 'all', // 'all' | 'favorites' | 'folder:<name>' | 'tag:<name>' | 'trash'
|
||
search: '',
|
||
selectedId: null,
|
||
theme: localStorage.getItem('theme') || 'dark',
|
||
locked: false, // true after user clicks Lock (token still valid server-side)
|
||
autoLock: parseInt(localStorage.getItem('autoLockMin') || '5'),
|
||
askBeforeDelete: localStorage.getItem('askBeforeDelete') !== '0', // default true
|
||
maskUsernames: localStorage.getItem('maskUsernames') === '1', // default false
|
||
// Show the raw site/URL under the display name on cards. Default OFF
|
||
// because the display name is meant to be the user-friendly label;
|
||
// most users don't want the hostname cluttering the card layout.
|
||
showSiteOnCards: localStorage.getItem('showSiteOnCards') === '1', // default false
|
||
compactActions: localStorage.getItem('compactActions') === '1', // default false
|
||
viewMode: localStorage.getItem('viewMode') || 'cards', // 'cards' | 'list' | 'table'
|
||
// Sort criterion + direction. Defaults: alphabetical by display name —
|
||
// the most common pattern for a password manager (predictable lookup).
|
||
// Other values: 'updated' (last modified), 'created' (creation order),
|
||
// 'site' (raw site/URL, distinct from name when user set a title).
|
||
sortBy: localStorage.getItem('sortBy') || 'name',
|
||
sortDir: localStorage.getItem('sortDir') || 'asc',
|
||
pageSize: parseInt(localStorage.getItem('pageSize') || '25') || 25,
|
||
currentPage: 1,
|
||
checked: new Set(), // entry IDs checked for batch operations
|
||
// Advanced filters — runtime only (cleared on lockVault). Set of string
|
||
// keys; see FILTER_DEFS for the available predicates.
|
||
activeFilters: new Set(),
|
||
hibpEnabled: localStorage.getItem('hibpEnabled') === '1', // default OFF
|
||
// entry.id → count from HIBP (0 = clean, >0 = pwned, undefined = unchecked)
|
||
hibpResults: new Map(),
|
||
quickUnlockEnabled: localStorage.getItem('quickUnlockEnabled') === '1',
|
||
recoveryConfigured: false, // refreshed by refreshRecoveryStatus on Settings open
|
||
autofillEnabled: localStorage.getItem('autofillEnabled') !== '0', // default ON
|
||
// Hotkey combos. Each combo = { ctrl, shift, alt, win, key }.
|
||
// key is the uppercase character or VK label ('A'..'Z', '0'..'9',
|
||
// 'F1'..'F12'). Default: Ctrl+Shift+L / Ctrl+Shift+P. Combos are
|
||
// local-by-default but ALSO synced via settings_json so they travel
|
||
// with the user (still Windows-only at runtime — non-Windows clients
|
||
// ignore the value).
|
||
autofillHotkeyFull: JSON.parse(localStorage.getItem('autofillHotkeyFull') ||
|
||
'{"ctrl":true,"shift":true,"alt":false,"win":false,"key":"L"}'),
|
||
autofillHotkeyPwd: JSON.parse(localStorage.getItem('autofillHotkeyPwd') ||
|
||
'{"ctrl":true,"shift":true,"alt":false,"win":false,"key":"P"}'),
|
||
quickSearchHotkey: JSON.parse(localStorage.getItem('quickSearchHotkey') ||
|
||
'{"ctrl":true,"shift":true,"alt":false,"win":false,"key":"Q"}'),
|
||
sidebarCollapsed: JSON.parse(localStorage.getItem('sidebarCollapsed') ||
|
||
'{"folders":false,"tags":false,"tools":false}'),
|
||
// Fetch website favicons via the Delphi DuckDuckGo proxy. OFF by
|
||
// default — opt-in because it sends each entry's domain to a third
|
||
// party (DuckDuckGo). Synced because it's a portable preference.
|
||
faviconsEnabled: localStorage.getItem('faviconsEnabled') === '1',
|
||
// Show the "running in tray" balloon (and any future tray balloon).
|
||
// Default ON — gates Shell_NotifyIcon NIF_INFO calls in PM.Bridge.
|
||
trayNotificationsEnabled: localStorage.getItem('trayNotificationsEnabled') !== '0',
|
||
// Days after which trashed entries are permanently purged. 0 = never.
|
||
// Synced across devices because it's a user-level preference.
|
||
trashAutoPurgeDays: parseInt(localStorage.getItem('trashAutoPurgeDays') || '0') || 0,
|
||
passwordExpiryDays: parseInt(localStorage.getItem('passwordExpiryDays') || '0') || 0,
|
||
// Table-view hidden columns. Array of keys that the user opted to
|
||
// hide (e.g. ['folder', 'updated']). Synced across devices.
|
||
tableColsHidden: (() => {
|
||
try { return JSON.parse(localStorage.getItem('tableColsHidden') || '[]'); }
|
||
catch { return []; }
|
||
})(),
|
||
editorPosition: localStorage.getItem('editorPosition') || 'right',
|
||
confirmOnUnsaved: localStorage.getItem('confirmOnUnsaved') !== '0',
|
||
// 'pw' (master only) | 'pin' (PIN only) | 'both' (master + PIN). 'pw'
|
||
// is the safe default — any device without a configured PIN behaves
|
||
// identically to the legacy unlock flow.
|
||
unlockMode: localStorage.getItem('unlockMode') || 'pw',
|
||
// Runtime mirror of the DPAPI blob existence — populated on init by
|
||
// bridgePinStatus(). Editing this directly doesn't touch storage.
|
||
pinConfigured: false,
|
||
};
|
||
|
||
// ============================================================
|
||
// CRYPTO — extracted to js/app.crypto.js (§3.1), loaded as a
|
||
// separate <script> before this file. Kept out of the monofile
|
||
// so the crypto core can be navigated + syntax-checked alone.
|
||
// ============================================================
|
||
|
||
// ============================================================
|
||
// HTTP HELPERS
|
||
// ============================================================
|
||
|
||
function authHeaders(extra) {
|
||
const h = Object.assign({ 'Authorization': 'Bearer ' + state.token }, extra || {});
|
||
if (state.csrf) h['X-CSRF-Token'] = state.csrf;
|
||
return h;
|
||
}
|
||
|
||
async function api(path, opts) {
|
||
opts = opts || {};
|
||
const r = await fetch(API + path, opts);
|
||
let body = null;
|
||
try { body = await r.json(); } catch (e) { body = {}; }
|
||
if (!r.ok) {
|
||
// Preserve status + body on the Error so callers can distinguish
|
||
// 429-with-retry_after (account lockout) from a generic auth error.
|
||
const err = new Error(body.error || ('HTTP ' + r.status));
|
||
err.status = r.status;
|
||
err.body = body || {};
|
||
throw err;
|
||
}
|
||
return body;
|
||
}
|
||
|
||
// ============================================================
|
||
// TOTP + 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)); }
|
||
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'];
|
||
|
||
// 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) {
|
||
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 & 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.
|
||
const switching = $('#slideover').classList.contains('is-open') &&
|
||
soState && (soState.id !== id) &&
|
||
!(isNew && soState.id == null && soState.id === id);
|
||
if (switching && state.confirmOnUnsaved && isSoDirty()) {
|
||
const ok = await confirmDialog({
|
||
title: 'Discard unsaved changes?',
|
||
message: 'You have edits in the open entry that haven\'t been saved. ' +
|
||
'Switch to the other entry anyway?' +
|
||
'<p style="margin:14px 0 0;font-size:11px;color:var(--text-faint);line-height:1.5">' +
|
||
'Tip: turn this prompt off in <b>Settings → Appearance → Confirm before closing unsaved edits</b>.' +
|
||
'</p>',
|
||
okText: 'Discard',
|
||
danger: true,
|
||
});
|
||
if (!ok) return;
|
||
}
|
||
if (!isNew && !e) return;
|
||
|
||
state.selectedId = isNew ? null : id;
|
||
if (!isNew) touchEntry(id);
|
||
// Templates: resolve a preset bundle of {kind, title, customFields,
|
||
// body} and merge it into opts so the rest of this function sees a
|
||
// pre-seeded new-entry payload. Only applies to fresh entries.
|
||
const tplId = isNew ? opts.template : null;
|
||
const tpl = tplId ? ENTRY_TEMPLATES[tplId] : null;
|
||
if (tpl) {
|
||
opts = Object.assign({}, opts, {
|
||
kind: tpl.kind || opts.kind || 'login',
|
||
presetTitle: tpl.title || opts.presetTitle,
|
||
presetBody: tpl.body || '',
|
||
presetFields: tpl.customFields || [],
|
||
presetTemplate: tplId,
|
||
});
|
||
}
|
||
// Resolve kind early: opts.kind for new entries (login default), entry's
|
||
// own kind for existing. Drives the field layout below.
|
||
const kind = isNew ? (opts.kind || 'login') : (e.kind || 'login');
|
||
const isNote = (kind === 'note');
|
||
// Title prefix reflects mode + kind (note vs login).
|
||
const titleEl = $('#slideoverTitle');
|
||
const kindLabel = isNote ? 'note' : 'entry';
|
||
titleEl.textContent = isNew
|
||
? ('+ New ' + kindLabel)
|
||
: ('Edit · ' + entryDisplayName(e));
|
||
titleEl.classList.toggle('is-new-mode', isNew);
|
||
titleEl.classList.toggle('is-edit-mode', !isNew);
|
||
const body = $('#slideoverBody');
|
||
body.innerHTML = '';
|
||
|
||
let plain = isNew ? (opts.presetBody || '') : '';
|
||
let plainTotp = '';
|
||
let plainCustom = isNew ? (opts.presetFields || []).map(f => {
|
||
const out = { label: f.label || '', value: f.value || '', is_secret: !!f.is_secret };
|
||
if (Array.isArray(f.options) && f.options.length > 0)
|
||
out.options = f.options.slice();
|
||
return out;
|
||
}) : [];
|
||
if (!isNew) {
|
||
plain = await decryptPwd(e.encrypted_password, e.iv);
|
||
// Decrypt TOTP secret if present. Empty string when no TOTP configured
|
||
// OR when decryption fails (orphan ciphertext, key mismatch, etc.).
|
||
if (e.totp_secret && e.totp_iv) {
|
||
plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv);
|
||
if (plainTotp === '[ERROR]') plainTotp = '';
|
||
}
|
||
// Custom fields: same crypto pipeline, but the plaintext is a JSON
|
||
// array of {label, value, is_secret}.
|
||
if (e.custom_fields && e.custom_fields_iv) {
|
||
plainCustom = await decryptCustomFields(e.custom_fields, e.custom_fields_iv);
|
||
}
|
||
}
|
||
|
||
// Track original values so we can detect "dirty". For new entries the
|
||
// originals are empty strings — typing anything triggers the Save button.
|
||
const defaultFolder = state.view.startsWith('folder:')
|
||
? state.view.slice(7) : 'All';
|
||
soState = {
|
||
id: isNew ? null : e.id,
|
||
kind: kind,
|
||
original: {
|
||
site: isNew ? (opts.presetSite || '') : (e.site || ''),
|
||
title: isNew ? (opts.presetTitle || '') : (e.title || ''),
|
||
username: isNew ? '' : (e.username || ''),
|
||
password: plain, // for notes this holds the note body
|
||
folder: isNew ? defaultFolder : (e.folder || 'All'),
|
||
tags: isNew ? '' : parseTags(e.tags).join(','),
|
||
totp: plainTotp,
|
||
},
|
||
tags: isNew ? [] : parseTags(e.tags),
|
||
// Working copy of the custom-fields array — mutated in place by
|
||
// buildCustomFieldRow handlers. The serialized JSON of this array
|
||
// at Save time is what gets encrypted into custom_fields/iv.
|
||
// customFields + originalCustomJson are assigned just after soState
|
||
// is constructed (see below) so both sides of the dirty check use
|
||
// the SAME normalized shape — comparing raw plainCustom against the
|
||
// mapped working copy would falsely fire dirty on entries whose
|
||
// stored blob carries extra/legacy keys.
|
||
customFields: [],
|
||
originalCustomJson: '[]',
|
||
originalEncrypted: isNew ? null : e.encrypted_password,
|
||
originalIV: isNew ? null : e.iv,
|
||
originalTotpEncrypted: isNew ? null : e.totp_secret,
|
||
originalTotpIV: isNew ? null : e.totp_iv,
|
||
originalCustomEncrypted: isNew ? null : (e.custom_fields || null),
|
||
originalCustomIV: isNew ? null : (e.custom_fields_iv || null),
|
||
// Template identifier — propagated from the entry on edit, or
|
||
// injected by the template picker on new. Sent back to the server
|
||
// on Save so the card/table can label it correctly.
|
||
template: isNew ? (opts.presetTemplate || '') : (e.template || ''),
|
||
// Attachments staged BEFORE the entry exists server-side. Empty on
|
||
// edit (existing attachments are fetched live via the dedicated
|
||
// /entries/:id/attachments endpoint). Flushed by soSave after the
|
||
// POST returns the new entry id.
|
||
pendingAttachments: [],
|
||
};
|
||
|
||
// Normalize the custom-fields array ONCE — the working copy and the
|
||
// dirty-check baseline must share the same shape, otherwise stripped
|
||
// legacy keys (empty options[], stray metadata) make the JSON diverge
|
||
// on open and the entry looks dirty without any user input.
|
||
soState.customFields = plainCustom.map(f => {
|
||
const out = {
|
||
label: f.label || '', value: f.value || '',
|
||
is_secret: !!f.is_secret,
|
||
};
|
||
if (Array.isArray(f.options) && f.options.length > 0)
|
||
out.options = f.options.slice();
|
||
return out;
|
||
});
|
||
soState.originalCustomJson = JSON.stringify(soState.customFields);
|
||
|
||
if (isNote) {
|
||
// Notes: minimal layout — name + multiline body + folder + tags.
|
||
// No icon (covered by sidebar icon), no site/user/totp.
|
||
body.appendChild(soEditableField('Title', 'soTitle', soState.original.title));
|
||
body.appendChild(soNoteBodyField(plain));
|
||
const hist = soHistoryButton();
|
||
if (hist) body.appendChild(hist);
|
||
body.appendChild(soCustomFieldsField());
|
||
// For new entries the attachments are staged in soState.pendingAttachments
|
||
// and uploaded after the entry has been created (we need its id).
|
||
body.appendChild(soAttachmentsField(isNew ? null : id));
|
||
body.appendChild(soFolderField(soState.original.folder));
|
||
body.appendChild(soTagsField());
|
||
} else {
|
||
// Login (existing layout).
|
||
const eForIcon = isNew
|
||
? { id: null, icon_b64: null, site: soState.original.site,
|
||
title: soState.original.title }
|
||
: e;
|
||
body.appendChild(soIconField(eForIcon));
|
||
body.appendChild(soEditableField('Display name', 'soTitle', soState.original.title));
|
||
body.appendChild(soEditableField('Site', 'soSite', soState.original.site));
|
||
body.appendChild(soEditableField('Username', 'soUsername', soState.original.username));
|
||
body.appendChild(soPasswordField(plain));
|
||
const histLogin = soHistoryButton();
|
||
if (histLogin) body.appendChild(histLogin);
|
||
body.appendChild(soTotpField(plainTotp));
|
||
body.appendChild(soCustomFieldsField());
|
||
body.appendChild(soAttachmentsField(isNew ? null : id));
|
||
body.appendChild(soFolderField(soState.original.folder));
|
||
body.appendChild(soTagsField());
|
||
}
|
||
|
||
// Action row — Save button is hidden until dirty. No Delete here:
|
||
// the quick-X on each card handles deletion (avoids duplication).
|
||
const actions = el('div', { class: 'slideover-actions' });
|
||
const saveBtn = el('button', {
|
||
class: 'btn btn-primary',
|
||
id: 'soSaveBtn',
|
||
style: 'display:none',
|
||
on: { click: soSave },
|
||
}, withIcon('i-check', 'Save'));
|
||
actions.appendChild(saveBtn);
|
||
body.appendChild(actions);
|
||
|
||
// Wire change detection (selectors absent for notes are ignored).
|
||
['#soTitle', '#soSite', '#soUsername', '#soPassword', '#soFolder',
|
||
'#soNoteBody'].forEach(sel => {
|
||
const el = $(sel); if (el) el.addEventListener('input', soDirtyCheck);
|
||
if (el) el.addEventListener('change', soDirtyCheck);
|
||
});
|
||
|
||
$('#slideover').classList.add('is-open');
|
||
|
||
// New entries: Save visible from the start so the action is obvious,
|
||
// and focus the most relevant pivot field (Title for notes, Site for logins).
|
||
if (isNew) {
|
||
const save = document.getElementById('soSaveBtn');
|
||
if (save) save.style.display = '';
|
||
setTimeout(() => {
|
||
const f = isNote
|
||
? document.getElementById('soTitle')
|
||
: document.getElementById('soSite');
|
||
if (f) f.focus();
|
||
}, 50);
|
||
} else 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 · clears in 30s');
|
||
});
|
||
|
||
const delBtn = el('button', { class: 'icon-btn icon-btn-sm', type: 'button',
|
||
title: 'Remove field' });
|
||
delBtn.appendChild(icon('i-x'));
|
||
delBtn.addEventListener('click', ev => {
|
||
ev.stopPropagation();
|
||
soState.customFields.splice(idx, 1);
|
||
rerender();
|
||
soDirtyCheck();
|
||
});
|
||
|
||
row.appendChild(labelInput);
|
||
row.appendChild(valueSlot);
|
||
row.appendChild(eye);
|
||
row.appendChild(secretBtn);
|
||
row.appendChild(copyBtn);
|
||
row.appendChild(delBtn);
|
||
return row;
|
||
}
|
||
|
||
// Multiline note body. Maps to soState.original.password and the
|
||
// encrypted_password+iv columns (same crypto pipeline as login passwords).
|
||
function soNoteBodyField(value) {
|
||
const wrap = el('div', { class: 'slideover-field' });
|
||
wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Note'));
|
||
const ta = el('textarea', {
|
||
id: 'soNoteBody',
|
||
class: 'so-input so-note-body',
|
||
rows: 12,
|
||
placeholder: 'Encrypted with your vault key. Nothing leaves your device.',
|
||
autocomplete: 'off', autocorrect: 'off', spellcheck: 'false',
|
||
});
|
||
ta.value = value || '';
|
||
wrap.appendChild(ta);
|
||
return wrap;
|
||
}
|
||
|
||
function soEditableField(label, id, value) {
|
||
const wrap = el('div', { class: 'slideover-field' });
|
||
wrap.appendChild(el('div', { class: 'slideover-field-label' }, label));
|
||
const input = el('input', {
|
||
type: 'text', id, value, class: 'so-input',
|
||
// Disable Edge / Chromium "saved form data" history — these
|
||
// fields can carry titles, usernames and other identifying
|
||
// info that shouldn't end up in the browser's autocomplete
|
||
// dropdown (visible via arrow-down on a focused field).
|
||
autocomplete: 'off',
|
||
autocorrect: 'off',
|
||
spellcheck: 'false',
|
||
});
|
||
input.addEventListener('keydown', soOnEnterSave);
|
||
wrap.appendChild(input);
|
||
return wrap;
|
||
}
|
||
|
||
// Custom icon: lets the user upload an image when auto-fetch fails (private
|
||
// sites behind auth, self-hosted apps DDG doesn't index, etc.). Stored as
|
||
// base64 data URI via the same POST /entries/{id}/icon endpoint as the
|
||
// auto-fetched icons — the JS render path doesn't care which source it
|
||
// came from.
|
||
const ICON_MAX_BYTES = 256 * 1024; // matches server-side cap (256 KB)
|
||
|
||
function soIconField(entry) {
|
||
const wrap = el('div', { class: 'slideover-field so-icon-field' });
|
||
const preview = el('div', { class: 'so-icon-preview' });
|
||
|
||
function paintPreview(dataUriOrNull) {
|
||
preview.innerHTML = '';
|
||
if (dataUriOrNull) {
|
||
const img = el('img', { src: dataUriOrNull, alt: '' });
|
||
img.addEventListener('error', () => {
|
||
preview.innerHTML = '';
|
||
preview.textContent = initials(entryDisplayName(entry));
|
||
});
|
||
preview.appendChild(img);
|
||
} else {
|
||
preview.textContent = initials(entryDisplayName(entry));
|
||
}
|
||
}
|
||
paintPreview(entry.icon_b64 || null);
|
||
|
||
const actions = el('div', { class: 'so-icon-actions' });
|
||
|
||
const uploadBtn = el('button', { class: 'btn btn-ghost btn-sm', type: 'button' },
|
||
'Upload icon');
|
||
const fileInput = el('input', {
|
||
type: 'file',
|
||
accept: 'image/png,image/jpeg,image/gif,image/svg+xml,image/x-icon,image/webp',
|
||
style: 'display:none',
|
||
});
|
||
uploadBtn.addEventListener('click', () => fileInput.click());
|
||
fileInput.addEventListener('change', () => {
|
||
const f = fileInput.files && fileInput.files[0];
|
||
if (!f) return;
|
||
if (!/^image\//.test(f.type)) {
|
||
toast('Not an image file', 'error');
|
||
return;
|
||
}
|
||
if (f.size > ICON_MAX_BYTES) {
|
||
toast('Icon too large (max 256 KB)', 'error');
|
||
return;
|
||
}
|
||
const reader = new FileReader();
|
||
reader.onload = async ev => {
|
||
const dataUri = ev.target.result; // 'data:image/...;base64,...'
|
||
entry.icon_b64 = dataUri;
|
||
paintPreview(dataUri);
|
||
await saveEntryIcon(entry.id, dataUri);
|
||
// Refresh entry cards / health view so they show the new icon too.
|
||
render();
|
||
toast('Custom icon set');
|
||
};
|
||
reader.readAsDataURL(f);
|
||
});
|
||
|
||
const resetBtn = el('button', { class: 'btn btn-ghost btn-sm', type: 'button' },
|
||
'Remove');
|
||
resetBtn.addEventListener('click', async () => {
|
||
entry.icon_b64 = null;
|
||
paintPreview(null);
|
||
await saveEntryIcon(entry.id, '');
|
||
render();
|
||
toast('Icon removed');
|
||
});
|
||
|
||
actions.appendChild(uploadBtn);
|
||
actions.appendChild(resetBtn);
|
||
actions.appendChild(fileInput);
|
||
|
||
wrap.appendChild(preview);
|
||
wrap.appendChild(actions);
|
||
return wrap;
|
||
}
|
||
|
||
function soOnEnterSave(ev) {
|
||
if (ev.key !== 'Enter') return;
|
||
ev.preventDefault();
|
||
soSave();
|
||
}
|
||
|
||
function soPasswordField(plain) {
|
||
const wrap = el('div', { class: 'slideover-field' });
|
||
wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Password'));
|
||
const row = el('div', { class: 'so-pw-row' });
|
||
const input = el('input', {
|
||
type: 'password', id: 'soPassword', value: plain,
|
||
class: 'so-input', style: 'flex:1;font-family:JetBrains Mono,monospace',
|
||
autocomplete: 'new-password', spellcheck: 'false',
|
||
});
|
||
input.addEventListener('keydown', soOnEnterSave);
|
||
const toggle = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Show/hide' });
|
||
toggle.appendChild(icon('i-eye'));
|
||
toggle.addEventListener('click', () => {
|
||
input.type = input.type === 'password' ? 'text' : 'password';
|
||
});
|
||
const copy = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Copy' });
|
||
copy.appendChild(icon('i-copy'));
|
||
copy.addEventListener('click', () => {
|
||
if (Bridge.copySecure(input.value, 30000)) {
|
||
toast('Copied · clears in 30s');
|
||
} else {
|
||
navigator.clipboard.writeText(input.value).then(() => {
|
||
toast('Copied · clears in 30s');
|
||
setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000);
|
||
});
|
||
}
|
||
});
|
||
const gen = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Generate' });
|
||
gen.appendChild(icon('i-dice'));
|
||
gen.addEventListener('click', () => {
|
||
openGen('slideover');
|
||
});
|
||
row.appendChild(input);
|
||
row.appendChild(toggle);
|
||
row.appendChild(copy);
|
||
row.appendChild(gen);
|
||
wrap.appendChild(row);
|
||
// 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 · clears in 30s');
|
||
} else {
|
||
navigator.clipboard.writeText(t.code).then(() => {
|
||
toast('Code copied · clears in 30s');
|
||
setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000);
|
||
});
|
||
}
|
||
} catch (e) {
|
||
toast('Invalid TOTP secret', 'error');
|
||
}
|
||
});
|
||
panel.appendChild(copyBtn);
|
||
wrap.appendChild(panel);
|
||
|
||
const barWrap = el('div', { class: 'totp-bar-wrap' });
|
||
const bar = el('div', { class: 'totp-bar', id: 'soTotpProgress' });
|
||
barWrap.appendChild(bar);
|
||
wrap.appendChild(barWrap);
|
||
|
||
startTotpTick();
|
||
return wrap;
|
||
}
|
||
|
||
function soFolderField(current) {
|
||
const wrap = el('div', { class: 'slideover-field' });
|
||
wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Folder'));
|
||
const sel = el('select', { id: 'soFolder', class: 'so-input' });
|
||
state.folders.forEach(f => {
|
||
const opt = el('option', { value: f.name }, folderLabel(f.name));
|
||
if (f.name === current) opt.selected = true;
|
||
sel.appendChild(opt);
|
||
});
|
||
wrap.appendChild(sel);
|
||
return wrap;
|
||
}
|
||
|
||
function soTagsField() {
|
||
const wrap = el('div', { class: 'slideover-field', style: 'position:relative' });
|
||
wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Tags'));
|
||
const cont = el('div', { class: 'chip-input', id: 'soTagsContainer' });
|
||
const input = el('input', {
|
||
type: 'text', id: 'soTagsField',
|
||
placeholder: 'add a tag…', autocomplete: 'off',
|
||
});
|
||
// Autocomplete dropdown built lazily on focus/input. Pulled from
|
||
// allTags() and filtered against what's already selected + what the
|
||
// user is typing.
|
||
const suggBox = el('div', { class: 'tag-suggest is-hidden', id: 'soTagsSuggest' });
|
||
function refreshSuggestions() {
|
||
const typed = (input.value || '').trim().toLowerCase();
|
||
const matches = allTags()
|
||
.filter(t => !soState.tags.includes(t))
|
||
.filter(t => typed === '' || t.toLowerCase().includes(typed))
|
||
.slice(0, 8);
|
||
suggBox.innerHTML = '';
|
||
if (matches.length === 0) {
|
||
suggBox.classList.add('is-hidden');
|
||
return;
|
||
}
|
||
matches.forEach(t => {
|
||
const item = el('button', { type: 'button', class: 'tag-suggest-item' });
|
||
item.appendChild(icon('i-tag'));
|
||
item.appendChild(el('span', null, t));
|
||
// mousedown beats the input's blur so the click registers
|
||
// before the dropdown is hidden.
|
||
item.addEventListener('mousedown', ev => {
|
||
ev.preventDefault();
|
||
if (!soState.tags.includes(t)) {
|
||
soState.tags.push(t);
|
||
renderSoChips();
|
||
soDirtyCheck();
|
||
}
|
||
input.value = '';
|
||
refreshSuggestions();
|
||
input.focus();
|
||
});
|
||
suggBox.appendChild(item);
|
||
});
|
||
suggBox.classList.remove('is-hidden');
|
||
}
|
||
input.addEventListener('input', refreshSuggestions);
|
||
input.addEventListener('focus', refreshSuggestions);
|
||
input.addEventListener('blur', () => {
|
||
// Delay so a mousedown on a suggestion gets handled first.
|
||
setTimeout(() => suggBox.classList.add('is-hidden'), 120);
|
||
});
|
||
// Render existing chips inline (renderSoChips() would no-op here because
|
||
// the container isn't attached to the DOM yet, $ would return null).
|
||
soState.tags.forEach((t, i) => {
|
||
const chip = el('span', { class: 'chip' });
|
||
chip.appendChild(el('span', null, t));
|
||
const x = el('button', {
|
||
type: 'button',
|
||
on: { click: ev => {
|
||
ev.stopPropagation();
|
||
soState.tags.splice(i, 1);
|
||
renderSoChips();
|
||
soDirtyCheck();
|
||
} },
|
||
});
|
||
x.appendChild(icon('i-x'));
|
||
chip.appendChild(x);
|
||
cont.appendChild(chip);
|
||
});
|
||
cont.appendChild(input);
|
||
wrap.appendChild(cont);
|
||
wrap.appendChild(suggBox);
|
||
input.addEventListener('keydown', e => {
|
||
if (e.key === 'Enter' || e.key === ',') {
|
||
e.preventDefault();
|
||
const v = input.value.trim().replace(/,/g, '');
|
||
if (v && !soState.tags.includes(v)) {
|
||
soState.tags.push(v);
|
||
renderSoChips();
|
||
soDirtyCheck();
|
||
}
|
||
input.value = '';
|
||
refreshSuggestions();
|
||
} else if (e.key === 'Backspace' && !input.value && soState.tags.length) {
|
||
soState.tags.pop();
|
||
renderSoChips();
|
||
soDirtyCheck();
|
||
refreshSuggestions();
|
||
} else if (e.key === 'Escape') {
|
||
suggBox.classList.add('is-hidden');
|
||
}
|
||
});
|
||
return wrap;
|
||
}
|
||
|
||
function renderSoChips() {
|
||
const cont = $('#soTagsContainer');
|
||
if (!cont) return;
|
||
const input = $('#soTagsField');
|
||
$$('.chip', cont).forEach(c => c.remove());
|
||
soState.tags.forEach((t, i) => {
|
||
const chip = el('span', { class: 'chip' });
|
||
chip.appendChild(el('span', null, t));
|
||
const x = el('button', {
|
||
type: 'button',
|
||
on: { click: ev => {
|
||
ev.stopPropagation();
|
||
soState.tags.splice(i, 1);
|
||
renderSoChips();
|
||
soDirtyCheck();
|
||
} },
|
||
});
|
||
x.appendChild(icon('i-x'));
|
||
chip.appendChild(x);
|
||
cont.insertBefore(chip, input);
|
||
});
|
||
}
|
||
|
||
// Returns true when the slideover has unsaved user changes. For new
|
||
// entries: any non-empty content (text, custom fields, staged
|
||
// attachments) counts. For edits: any field that differs from the
|
||
// snapshot taken at openSlideOver time.
|
||
function isSoDirty() {
|
||
if (!soState) return false;
|
||
const cur = {
|
||
title: ($('#soTitle') || {}).value || '',
|
||
site: ($('#soSite') || {}).value || '',
|
||
username: ($('#soUsername') || {}).value || '',
|
||
password: (($('#soPassword') || $('#soNoteBody')) || {}).value || '',
|
||
folder: ($('#soFolder') || {}).value || '',
|
||
totp: ($('#soTotpSecret') || {}).value || '',
|
||
tags: soState.tags.join(','),
|
||
};
|
||
const curCustom = JSON.stringify(soState.customFields || []);
|
||
if (soState.id == null) {
|
||
const orig = soState.original || {};
|
||
const staged = (soState.pendingAttachments || []).length > 0;
|
||
return staged
|
||
|| cur.title !== (orig.title || '')
|
||
|| cur.site !== (orig.site || '')
|
||
|| cur.username !== (orig.username || '')
|
||
|| cur.password !== (orig.password || '')
|
||
|| cur.folder !== (orig.folder || 'All')
|
||
|| cur.totp !== (orig.totp || '')
|
||
|| cur.tags !== ''
|
||
|| curCustom !== (soState.originalCustomJson || '[]');
|
||
}
|
||
return (
|
||
cur.title !== soState.original.title ||
|
||
cur.site !== soState.original.site ||
|
||
cur.username !== soState.original.username ||
|
||
cur.password !== soState.original.password ||
|
||
cur.folder !== soState.original.folder ||
|
||
cur.totp !== soState.original.totp ||
|
||
cur.tags !== soState.original.tags ||
|
||
curCustom !== (soState.originalCustomJson || '[]'));
|
||
}
|
||
|
||
function soDirtyCheck() {
|
||
if (!soState) return;
|
||
// New entries: Save button stays visible regardless of dirty state so
|
||
// the action is always obvious.
|
||
if (soState.id == null) {
|
||
const btn = $('#soSaveBtn');
|
||
if (btn) btn.style.display = '';
|
||
return;
|
||
}
|
||
const btn = $('#soSaveBtn');
|
||
if (btn) btn.style.display = isSoDirty() ? '' : 'none';
|
||
}
|
||
|
||
async function soSave() {
|
||
if (!soState) return;
|
||
// Flush any uncommitted tag text — user may have typed in the chip
|
||
// input without pressing Enter / comma before clicking Save.
|
||
const pendingTag = (($('#soTagsField') || {}).value || '').trim();
|
||
if (pendingTag && !soState.tags.includes(pendingTag)) {
|
||
soState.tags.push(pendingTag);
|
||
$('#soTagsField').value = '';
|
||
}
|
||
const kind = soState.kind || 'login';
|
||
const isNote = (kind === 'note');
|
||
const title = ($('#soTitle') || {}).value || '';
|
||
const site = isNote ? '' : ($('#soSite').value.trim());
|
||
const user = isNote ? '' : ($('#soUsername').value.trim());
|
||
// For notes the textarea body is the encrypted payload (reuses
|
||
// encrypted_password/iv column pair).
|
||
const pwd = isNote
|
||
? (($('#soNoteBody') || {}).value || '')
|
||
: ($('#soPassword').value);
|
||
const fold = ($('#soFolder') || {}).value || 'All';
|
||
const totp = isNote ? '' : (($('#soTotpSecret') || {}).value || '').trim();
|
||
if (isNote) {
|
||
if (!title.trim()) return toast('Title required', 'error');
|
||
// Template-typed notes carry their data in custom_fields, so an
|
||
// empty body is fine as long as at least one field has a value.
|
||
if (!pwd) {
|
||
const hasFieldValue = (soState.customFields || [])
|
||
.some(f => (f.value || '').trim() !== '');
|
||
if (!(soState.template && hasFieldValue))
|
||
return toast('Note body required', 'error');
|
||
}
|
||
} else {
|
||
if (!site || !pwd) return toast('Site and password required', 'error');
|
||
}
|
||
|
||
const isNew = (soState.id == null);
|
||
|
||
// Only re-encrypt if password changed; otherwise reuse stored ciphertext.
|
||
// For new entries there's nothing stored, always encrypt fresh.
|
||
let enc;
|
||
if (!isNew && pwd === soState.original.password) {
|
||
enc = { encrypted: soState.originalEncrypted, iv: soState.originalIV };
|
||
} else {
|
||
enc = await encryptPwd(pwd);
|
||
}
|
||
|
||
// Same idea for TOTP: re-encrypt only if changed, send empty strings when
|
||
// cleared so the server stores NULL.
|
||
let totpEnc = '';
|
||
let totpIv = '';
|
||
if (totp !== '') {
|
||
if (!isNew && totp === soState.original.totp && soState.originalTotpEncrypted) {
|
||
totpEnc = soState.originalTotpEncrypted;
|
||
totpIv = soState.originalTotpIV;
|
||
} else {
|
||
// Validate the secret can be decoded BEFORE saving — saving a
|
||
// garbled base32 wouldn't break anything but would surprise the
|
||
// user when the code panel shows "invalid secret" next time.
|
||
try { base32Decode(totp); }
|
||
catch (e) { return toast('Invalid TOTP secret (must be base32)', 'error'); }
|
||
const tEnc = await encryptTotpSecret(totp);
|
||
totpEnc = tEnc.encrypted;
|
||
totpIv = tEnc.iv;
|
||
}
|
||
}
|
||
|
||
// Custom fields: drop rows with an empty label (treat as removed).
|
||
// Re-encrypt only when the array actually changed; otherwise reuse the
|
||
// stored ciphertext so the row's updated_at doesn't get bumped for nothing.
|
||
const cleanCustom = (soState.customFields || [])
|
||
.filter(f => (f.label || '').trim() !== '')
|
||
.map(f => {
|
||
const out = {
|
||
label: (f.label || '').trim(),
|
||
value: f.value || '',
|
||
is_secret: !!f.is_secret,
|
||
};
|
||
// Keep the dropdown options so the field re-renders as a
|
||
// <select> on next open (otherwise it would silently degrade
|
||
// to a free-text input after the first save).
|
||
if (Array.isArray(f.options) && f.options.length > 0) {
|
||
out.options = f.options.slice();
|
||
}
|
||
return out;
|
||
});
|
||
let cfEnc = '', cfIv = '';
|
||
const curCustomJson = JSON.stringify(cleanCustom);
|
||
if (cleanCustom.length === 0) {
|
||
// Empty array → send empty strings → server stores NULL.
|
||
cfEnc = ''; cfIv = '';
|
||
} else if (!isNew && curCustomJson === soState.originalCustomJson &&
|
||
soState.originalCustomEncrypted) {
|
||
cfEnc = soState.originalCustomEncrypted;
|
||
cfIv = soState.originalCustomIV;
|
||
} else {
|
||
const e = await encryptCustomFields(cleanCustom);
|
||
cfEnc = e.encrypted;
|
||
cfIv = e.iv;
|
||
}
|
||
|
||
const body = JSON.stringify(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 · clears in 30s');
|
||
} else {
|
||
navigator.clipboard.writeText(p).then(() => toast('Password copied · clears in 30s'));
|
||
setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000);
|
||
}
|
||
touchEntry(entry.id);
|
||
}
|
||
|
||
function copyUsername(entry) {
|
||
const u = entry.username || '';
|
||
if (!u) return toast('No username to copy', 'warning');
|
||
if (Bridge.copySecure(u, 0)) {
|
||
toast('Username copied');
|
||
} else {
|
||
navigator.clipboard.writeText(u).then(() => toast('Username copied'));
|
||
}
|
||
touchEntry(entry.id);
|
||
}
|
||
|
||
// Bump accessed_at on the server (fire-and-forget) AND in local state so
|
||
// the "Recent" view updates without a full reload. Coalesces rapid calls
|
||
// for the same id within 2s to avoid hammering the server on bulk copies.
|
||
const _touchTimers = {};
|
||
function touchEntry(id) {
|
||
if (!id) return;
|
||
const now = new Date().toISOString();
|
||
const e = state.entries.find(x => x.id === id);
|
||
if (e) e.accessed_at = now;
|
||
if (_touchTimers[id]) return;
|
||
_touchTimers[id] = setTimeout(() => {
|
||
delete _touchTimers[id];
|
||
fetch(API + '/entries/' + id + '/touch', {
|
||
method: 'POST',
|
||
headers: authHeaders(),
|
||
}).catch(() => {}); // silent — local state already reflects it
|
||
}, 2000);
|
||
}
|
||
|
||
// ============================================================
|
||
// ENCRYPTED ATTACHMENTS
|
||
// ============================================================
|
||
// Per-entry files (PDFs, backup-code images, recovery sheets, …)
|
||
// encrypted client-side with the vault key, base64-shipped to the server
|
||
// for opaque storage. Listing returns metadata only — the ciphertext is
|
||
// fetched on demand when the user clicks Download.
|
||
|
||
const ATTACHMENT_MAX_BYTES = 5 * 1024 * 1024; // raw file size cap (5 MB)
|
||
|
||
async function encryptBlobBytes(bytes) {
|
||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||
const ct = await crypto.subtle.encrypt(
|
||
{ name: 'AES-GCM', iv }, state.cryptoKey, bytes);
|
||
return { encrypted: bytesToBase64(ct), iv: bytesToBase64(iv) };
|
||
}
|
||
async function decryptBlobBytes(encB64, ivB64) {
|
||
const ct = base64ToBytes(encB64);
|
||
const iv = base64ToBytes(ivB64);
|
||
const dec = await crypto.subtle.decrypt(
|
||
{ name: 'AES-GCM', iv }, state.cryptoKey, ct);
|
||
return new Uint8Array(dec);
|
||
}
|
||
|
||
function humanFileSize(n) {
|
||
if (n < 1024) return n + ' B';
|
||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
|
||
return (n / 1024 / 1024).toFixed(2) + ' MB';
|
||
}
|
||
|
||
async function uploadAttachment(entryId, file) {
|
||
if (!file) return null;
|
||
if (file.size > ATTACHMENT_MAX_BYTES) {
|
||
toast('File too large (max 5 MB raw)', 'error');
|
||
return null;
|
||
}
|
||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||
const { encrypted, iv } = await encryptBlobBytes(bytes);
|
||
const meta = await api('/entries/' + entryId + '/attachments', {
|
||
method: 'POST',
|
||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||
body: JSON.stringify({
|
||
filename: file.name,
|
||
mime: file.type || 'application/octet-stream',
|
||
encrypted_blob: encrypted,
|
||
iv,
|
||
size_bytes: file.size,
|
||
}),
|
||
});
|
||
return meta;
|
||
}
|
||
|
||
async function downloadAttachment(att) {
|
||
// Spinner for large attachments — fetch + decrypt + chunked transfer
|
||
// of a multi-MB file takes a moment before the Save dialog appears.
|
||
const big = (att.size_bytes || 0) > 512 * 1024;
|
||
try {
|
||
if (big) showBusy('Preparing download…');
|
||
const full = await api('/attachments/' + att.id, { headers: authHeaders() });
|
||
const bytes = await decryptBlobBytes(full.encrypted_blob, full.iv);
|
||
if (Bridge.active && typeof Bridge.saveFile === 'function') {
|
||
const res = await Bridge.saveFile(att.filename, bytes, pct => {
|
||
if (big) updateBusy('Preparing download… ' + pct + '%');
|
||
});
|
||
if (big) hideBusy();
|
||
if (res.ok) toast('Saved to ' + res.path);
|
||
else if (res.error) toast('Save failed: ' + res.error, 'error');
|
||
} else {
|
||
if (big) hideBusy();
|
||
// Web fallback: trigger a browser download.
|
||
const blob = new Blob([bytes], { type: att.mime || 'application/octet-stream' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = el('a', { href: url, download: att.filename });
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
|
||
}
|
||
} catch (e) {
|
||
if (big) hideBusy();
|
||
toast('Download failed: ' + (e && e.message ? e.message : e), 'error');
|
||
}
|
||
}
|
||
|
||
// Slideover field: header + upload button + list of attachments.
|
||
// Re-renders the list in place when the contents change (upload/delete).
|
||
function soAttachmentsField(entryId) {
|
||
const isStaging = (entryId === null || entryId === undefined);
|
||
const wrap = el('div', { class: 'slideover-field' });
|
||
wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Attachments'));
|
||
|
||
const addBtn = el('button', {
|
||
class: 'btn btn-ghost btn-sm', type: 'button',
|
||
title: 'Attach a file (encrypted before upload, max 5 MB)',
|
||
});
|
||
addBtn.appendChild(icon('i-paperclip'));
|
||
addBtn.appendChild(document.createTextNode(' Attach file'));
|
||
wrap.appendChild(addBtn);
|
||
|
||
const fileInput = el('input', { type: 'file', style: 'display:none' });
|
||
wrap.appendChild(fileInput);
|
||
|
||
const list = el('div', { class: 'so-attach-list', id: 'soAttachList' });
|
||
wrap.appendChild(list);
|
||
|
||
addBtn.addEventListener('click', ev => {
|
||
ev.stopPropagation();
|
||
fileInput.click();
|
||
});
|
||
fileInput.addEventListener('change', async ev => {
|
||
ev.stopPropagation();
|
||
const file = fileInput.files && fileInput.files[0];
|
||
if (!file) return;
|
||
fileInput.value = ''; // allow re-uploading the same name later
|
||
if (file.size > ATTACHMENT_MAX_BYTES) {
|
||
toast('File too large (max 5 MB raw)', 'error');
|
||
return;
|
||
}
|
||
addBtn.disabled = true;
|
||
try {
|
||
if (isStaging) {
|
||
// Stage in memory — uploaded by soSave after the new
|
||
// entry's id is known.
|
||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||
soState.pendingAttachments.push({
|
||
filename: file.name,
|
||
mime: file.type || 'application/octet-stream',
|
||
size_bytes: file.size,
|
||
bytes,
|
||
});
|
||
soDirtyCheck();
|
||
renderStagedAttachments(list);
|
||
} else {
|
||
await uploadAttachment(entryId, file);
|
||
toast('Attachment uploaded');
|
||
await refreshAttachments(entryId, list);
|
||
}
|
||
} catch (e) {
|
||
toast('Upload failed: ' + e.message, 'error');
|
||
} finally {
|
||
addBtn.disabled = false;
|
||
}
|
||
});
|
||
|
||
if (isStaging) renderStagedAttachments(list);
|
||
else refreshAttachments(entryId, list);
|
||
return wrap;
|
||
}
|
||
|
||
// Renders the pendingAttachments array (new-entry mode). Mirrors the
|
||
// existing-entry layout so users can't tell the difference until they
|
||
// hit Save.
|
||
function renderStagedAttachments(container) {
|
||
container.innerHTML = '';
|
||
const list = soState.pendingAttachments || [];
|
||
if (list.length === 0) {
|
||
container.appendChild(el('div', { class: 'so-attach-empty' },
|
||
'No attachments yet. Files will upload after you save.'));
|
||
return;
|
||
}
|
||
list.forEach((att, idx) => {
|
||
const row = el('div', { class: 'so-attach-row' });
|
||
const info = el('div', { class: 'so-attach-info' });
|
||
info.appendChild(el('div', { class: 'so-attach-name', title: att.filename }, att.filename));
|
||
info.appendChild(el('div', { class: 'so-attach-meta' },
|
||
humanFileSize(att.size_bytes) + ' · ' + (att.mime || '?') +
|
||
' · pending'));
|
||
row.appendChild(info);
|
||
|
||
const del = el('button',
|
||
{ class: 'icon-btn icon-btn-sm', type: 'button', title: 'Remove' });
|
||
del.appendChild(icon('i-trash'));
|
||
del.addEventListener('click', ev => {
|
||
ev.stopPropagation();
|
||
soState.pendingAttachments.splice(idx, 1);
|
||
soDirtyCheck();
|
||
renderStagedAttachments(container);
|
||
});
|
||
row.appendChild(del);
|
||
container.appendChild(row);
|
||
});
|
||
}
|
||
|
||
async function refreshAttachments(entryId, container) {
|
||
container.innerHTML = '';
|
||
let items = [];
|
||
try {
|
||
items = await api('/entries/' + entryId + '/attachments',
|
||
{ headers: authHeaders() });
|
||
} catch (e) {
|
||
container.appendChild(el('div', { class: 'so-attach-empty' },
|
||
'Failed to load attachments'));
|
||
return;
|
||
}
|
||
if (!items || items.length === 0) {
|
||
container.appendChild(el('div', { class: 'so-attach-empty' },
|
||
'No attachments yet.'));
|
||
return;
|
||
}
|
||
items.forEach(att => container.appendChild(buildAttachmentRow(att, entryId, container)));
|
||
}
|
||
|
||
function buildAttachmentRow(att, entryId, listContainer) {
|
||
const row = el('div', { class: 'so-attach-row' });
|
||
const name = el('div', { class: 'so-attach-name', title: att.filename }, att.filename);
|
||
const meta = el('div', { class: 'so-attach-meta' },
|
||
humanFileSize(att.size_bytes) + ' · ' + (att.mime || '?'));
|
||
const info = el('div', { class: 'so-attach-info' });
|
||
info.appendChild(name);
|
||
info.appendChild(meta);
|
||
row.appendChild(info);
|
||
|
||
const dl = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Download' });
|
||
dl.appendChild(icon('i-download'));
|
||
dl.addEventListener('click', ev => {
|
||
ev.stopPropagation();
|
||
downloadAttachment(att);
|
||
});
|
||
row.appendChild(dl);
|
||
|
||
const del = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Delete' });
|
||
del.appendChild(icon('i-trash'));
|
||
del.addEventListener('click', async ev => {
|
||
ev.stopPropagation();
|
||
const ok = await confirmDialog({
|
||
title: 'Delete attachment?',
|
||
message: 'This will permanently remove <b>' + att.filename + '</b>. There is no trash for attachments.',
|
||
okText: 'Delete',
|
||
danger: true,
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await api('/attachments/' + att.id, {
|
||
method: 'DELETE', headers: authHeaders(),
|
||
});
|
||
toast('Attachment deleted');
|
||
await refreshAttachments(entryId, listContainer);
|
||
} catch (e) {
|
||
toast('Delete failed: ' + e.message, 'error');
|
||
}
|
||
});
|
||
row.appendChild(del);
|
||
return row;
|
||
}
|
||
|
||
// Display helper: when `maskUsernames` setting is on, show only the first 2
|
||
// chars followed by '***'. Used in cards/list (but slide-over always reveals).
|
||
function displayUsername(u) {
|
||
if (!u) return '—';
|
||
if (!state.maskUsernames) return u;
|
||
if (u.length <= 2) return u + '***';
|
||
return u.slice(0, 2) + '***';
|
||
}
|
||
|
||
// ============================================================
|
||
// FOLDERS CRUD
|
||
// ============================================================
|
||
|
||
async function deleteFolder(name, entryCount) {
|
||
const message = entryCount > 0
|
||
? '<b>' + name + '</b> contains <b>' + entryCount + '</b> entries. They will be moved to <i>(no folder)</i>. Continue?'
|
||
: 'Delete folder <b>' + name + '</b>?';
|
||
const ok = await confirmDialog({
|
||
title: 'Delete folder',
|
||
message,
|
||
okText: 'Delete',
|
||
danger: true,
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await api('/folders/' + encodeURIComponent(name), {
|
||
method: 'DELETE',
|
||
headers: authHeaders(),
|
||
});
|
||
if (state.view === 'folder:' + name) state.view = 'all';
|
||
await loadFolders();
|
||
await loadEntries();
|
||
render();
|
||
toast('Folder deleted');
|
||
} catch (e) { toast(e.message || 'Delete failed', 'error'); }
|
||
}
|
||
|
||
// Curated swatches + icon choices. Kept small so the picker stays a
|
||
// single visual row each — paradox of choice tames itself.
|
||
const FOLDER_PALETTE = [
|
||
'', // 'no color' = default accent
|
||
'#ef4444', '#f97316', '#eab308', '#22c55e',
|
||
'#06b6d4', '#3b82f6', '#a855f7', '#ec4899',
|
||
];
|
||
const FOLDER_ICONS = [
|
||
'i-folder', 'i-shield', 'i-key', 'i-star',
|
||
'i-user', 'i-tag', 'i-globe', 'i-lock',
|
||
];
|
||
|
||
function addFolder() { openFolderModal(null); }
|
||
|
||
// Reorder: move `draggedName` directly before or after `targetName`.
|
||
// Builds the new names list and persists via POST /folders/reorder.
|
||
async function reorderFolderTo(draggedName, targetName, insertBefore) {
|
||
// Work on the user-visible (non-"All") slice — "All" is synthetic and
|
||
// never reordered, so it must stay at index 0 in state.folders.
|
||
const real = state.folders.filter(f => f.name !== 'All');
|
||
const dragged = real.find(f => f.name === draggedName);
|
||
if (!dragged) return;
|
||
const filtered = real.filter(f => f.name !== draggedName);
|
||
const targetIdx = filtered.findIndex(f => f.name === targetName);
|
||
if (targetIdx === -1) return;
|
||
const insertIdx = insertBefore ? targetIdx : targetIdx + 1;
|
||
filtered.splice(insertIdx, 0, dragged);
|
||
|
||
state.folders = [{ name: 'All', color: '', icon: '' }].concat(filtered);
|
||
render(); // optimistic — feels instant
|
||
|
||
try {
|
||
await api('/folders/reorder', {
|
||
method: 'POST',
|
||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||
body: JSON.stringify({ names: filtered.map(f => f.name) }),
|
||
});
|
||
} catch (e) {
|
||
// Roll back by reloading from the server on failure.
|
||
toast('Reorder failed — reverting', 'error');
|
||
await loadFolders();
|
||
render();
|
||
}
|
||
}
|
||
|
||
function openFolderModal(existing) {
|
||
const isEdit = !!existing;
|
||
const initName = isEdit ? existing.name : '';
|
||
const initColor = isEdit ? (existing.color || '') : '';
|
||
const initIcon = isEdit ? (existing.icon || 'i-folder') : 'i-folder';
|
||
|
||
// Build modal node fresh each call so swatches/icons don't leak listeners.
|
||
const old = document.getElementById('folderModal');
|
||
if (old) old.remove();
|
||
|
||
const modal = el('div', { id: 'folderModal', class: 'modal' });
|
||
const backdrop = el('div', { class: 'modal-backdrop' });
|
||
backdrop.addEventListener('click', () => modal.remove());
|
||
modal.appendChild(backdrop);
|
||
const panel = el('div', { class: 'modal-panel modal-panel-sm' });
|
||
|
||
const header = el('div', { class: 'modal-header' });
|
||
header.appendChild(el('h3', null, isEdit ? 'Customize folder' : 'New folder'));
|
||
panel.appendChild(header);
|
||
|
||
const body = el('div', { class: 'modal-body' });
|
||
|
||
// Name input (read-only when editing — rename out of scope for v1)
|
||
const nameField = el('div', { class: 'form-field' });
|
||
nameField.appendChild(el('label', null, 'Name'));
|
||
const nameInput = el('input', {
|
||
type: 'text', class: 'so-input', id: 'folderModalName',
|
||
placeholder: 'e.g. Work', value: initName,
|
||
});
|
||
if (isEdit) nameInput.disabled = true;
|
||
nameField.appendChild(nameInput);
|
||
body.appendChild(nameField);
|
||
|
||
// Selected state held by closure.
|
||
let pickedColor = initColor;
|
||
let pickedIcon = initIcon;
|
||
|
||
// Color row
|
||
const colorField = el('div', { class: 'form-field' });
|
||
colorField.appendChild(el('label', null, 'Color'));
|
||
const colorRow = el('div', { class: 'folder-swatch-row' });
|
||
FOLDER_PALETTE.forEach(c => {
|
||
const sw = el('button', {
|
||
type: 'button',
|
||
class: 'folder-swatch' + (c === pickedColor ? ' is-active' : ''),
|
||
'data-color': c,
|
||
title: c || 'Default',
|
||
});
|
||
if (c) sw.style.background = c;
|
||
else sw.classList.add('is-default');
|
||
sw.addEventListener('click', () => {
|
||
pickedColor = c;
|
||
colorRow.querySelectorAll('.folder-swatch').forEach(x =>
|
||
x.classList.toggle('is-active', x.getAttribute('data-color') === c));
|
||
refreshPreview();
|
||
});
|
||
colorRow.appendChild(sw);
|
||
});
|
||
colorField.appendChild(colorRow);
|
||
body.appendChild(colorField);
|
||
|
||
// Icon row
|
||
const iconField = el('div', { class: 'form-field' });
|
||
iconField.appendChild(el('label', null, 'Icon'));
|
||
const iconRow = el('div', { class: 'folder-icon-row' });
|
||
FOLDER_ICONS.forEach(name => {
|
||
const btn = el('button', {
|
||
type: 'button',
|
||
class: 'folder-icon-btn' + (name === pickedIcon ? ' is-active' : ''),
|
||
'data-icon': name,
|
||
});
|
||
btn.appendChild(icon(name));
|
||
btn.addEventListener('click', () => {
|
||
pickedIcon = name;
|
||
iconRow.querySelectorAll('.folder-icon-btn').forEach(x =>
|
||
x.classList.toggle('is-active', x.getAttribute('data-icon') === name));
|
||
refreshPreview();
|
||
});
|
||
iconRow.appendChild(btn);
|
||
});
|
||
iconField.appendChild(iconRow);
|
||
body.appendChild(iconField);
|
||
|
||
// Live preview
|
||
const previewWrap = el('div', { class: 'folder-preview' });
|
||
const previewIcon = icon(pickedIcon);
|
||
previewWrap.appendChild(previewIcon);
|
||
const previewLabel = el('span', null, initName || 'Folder');
|
||
previewWrap.appendChild(previewLabel);
|
||
body.appendChild(previewWrap);
|
||
panel.appendChild(body);
|
||
|
||
function refreshPreview() {
|
||
previewIcon.innerHTML = '';
|
||
const u = document.createElementNS('http://www.w3.org/2000/svg', 'use');
|
||
u.setAttribute('href', '#' + pickedIcon);
|
||
previewIcon.appendChild(u);
|
||
previewIcon.style.color = pickedColor || '';
|
||
previewLabel.textContent = (nameInput.value || 'Folder');
|
||
}
|
||
nameInput.addEventListener('input', refreshPreview);
|
||
|
||
// Footer buttons
|
||
const footer = el('div', { class: 'modal-footer' });
|
||
const cancelBtn = el('button', { class: 'btn btn-ghost', type: 'button' }, 'Cancel');
|
||
cancelBtn.addEventListener('click', () => modal.remove());
|
||
footer.appendChild(cancelBtn);
|
||
|
||
const saveBtn = el('button',
|
||
{ class: 'btn btn-primary', type: 'button' },
|
||
isEdit ? 'Save' : 'Create');
|
||
saveBtn.addEventListener('click', () => submitFolder());
|
||
footer.appendChild(saveBtn);
|
||
panel.appendChild(footer);
|
||
|
||
modal.appendChild(panel);
|
||
document.body.appendChild(modal);
|
||
setTimeout(() => nameInput.focus(), 30);
|
||
|
||
async function submitFolder() {
|
||
const clean = (nameInput.value || '').trim();
|
||
if (!isEdit && !clean) return toast('Name required', 'warning');
|
||
if (clean.toLowerCase() === 'all') return toast('"All" is reserved', 'warning');
|
||
try {
|
||
if (isEdit) {
|
||
await api('/folders/' + encodeURIComponent(initName), {
|
||
method: 'PUT',
|
||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||
body: JSON.stringify({ color: pickedColor, icon: pickedIcon }),
|
||
});
|
||
toast('Folder updated');
|
||
} else {
|
||
await api('/folders', {
|
||
method: 'POST',
|
||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||
body: JSON.stringify({ name: clean, color: pickedColor, icon: pickedIcon }),
|
||
});
|
||
toast('Folder created');
|
||
}
|
||
modal.remove();
|
||
await loadFolders();
|
||
render();
|
||
} catch (e) { toast(e.message, 'error'); }
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// PASSWORD GENERATOR
|
||
// ============================================================
|
||
|
||
let genCurrent = '';
|
||
|
||
function genPassword() {
|
||
const len = parseInt($('#genLen').value);
|
||
$('#genLenLabel').textContent = len;
|
||
let chars = '';
|
||
if ($('#genUpper').checked) chars += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||
if ($('#genLower').checked) chars += 'abcdefghijklmnopqrstuvwxyz';
|
||
if ($('#genNum').checked) chars += '0123456789';
|
||
if ($('#genSym').checked) chars += '!@#$%^&*()_+-=[]{}|;:,.<>?';
|
||
if (!chars) { $('#genPreview').textContent = 'Select at least one'; return; }
|
||
let p = '';
|
||
const max = 256 - (256 % chars.length);
|
||
const buf = new Uint8Array(1);
|
||
for (let i = 0; i < len; i++) {
|
||
do { crypto.getRandomValues(buf); } while (buf[0] >= max);
|
||
p += chars.charAt(buf[0] % chars.length);
|
||
}
|
||
genCurrent = p;
|
||
$('#genPreview').textContent = p;
|
||
}
|
||
|
||
// genTarget: 'entry' (insert into entry form) or 'standalone' (just copy/dismiss)
|
||
let genTarget = 'entry';
|
||
|
||
function openGen(target) {
|
||
genTarget = target || 'entry';
|
||
// Show "Use" when targeting an editable field (entry modal or slide-over)
|
||
$('#genUse').style.display = (genTarget === 'standalone') ? 'none' : '';
|
||
$('#genModal').classList.remove('is-hidden');
|
||
genPassword();
|
||
}
|
||
function closeGen() { $('#genModal').classList.add('is-hidden'); }
|
||
|
||
// ============================================================
|
||
// PASSWORD STRENGTH
|
||
// ============================================================
|
||
|
||
function computeStrength(p) {
|
||
let s = 0;
|
||
if (p.length >= 8) s += 25;
|
||
if (p.length >= 12) s += 15;
|
||
if (/[A-Z]/.test(p) && /[a-z]/.test(p)) s += 20;
|
||
if (/\d/.test(p)) s += 15;
|
||
if (/[^A-Za-z0-9]/.test(p)) s += 25;
|
||
return Math.min(100, s);
|
||
}
|
||
|
||
function updateRegStrength() {
|
||
const p = $('#regPassword').value;
|
||
$('#regStrengthBar').style.setProperty('--strength', computeStrength(p) + '%');
|
||
}
|
||
function updateEntryStrength() {
|
||
const p = $('#entryPassword').value;
|
||
$('#entryStrengthBar').style.setProperty('--strength', computeStrength(p) + '%');
|
||
}
|
||
|
||
// ============================================================
|
||
// COMMAND PALETTE
|
||
// ============================================================
|
||
|
||
function openPalette() {
|
||
$('#cmdPalette').classList.remove('is-hidden');
|
||
$('#cmdInput').value = '';
|
||
$('#cmdInput').focus();
|
||
renderPaletteResults('');
|
||
}
|
||
function closePalette() { $('#cmdPalette').classList.add('is-hidden'); }
|
||
|
||
function paletteCommands() {
|
||
return [
|
||
{ id: 'new', label: 'New entry', icon: 'i-plus', run: () => { closePalette(); openSlideOver(null); } },
|
||
{ id: 'new-note', label: 'New note', icon: 'i-edit', run: () => { closePalette(); openSlideOver(null, { kind: 'note' }); } },
|
||
{ id: 'shortcuts', label: 'Show keyboard shortcuts (?)', icon: 'i-command',
|
||
run: () => { closePalette(); openCheatsheet(); } },
|
||
{ id: 'lock', label: 'Lock vault', icon: 'i-lock', run: async () => { closePalette(); if (await confirmDiscardForSessionExit('lock')) lockVault(); } },
|
||
{ id: 'logout', label: 'Sign out', icon: 'i-log-out', run: async () => { closePalette(); if (await confirmDiscardForSessionExit('logout')) doLogout(); } },
|
||
{ id: 'theme', label: 'Toggle theme', icon: 'i-sun', run: () => { closePalette(); toggleTheme(); } },
|
||
{ id: 'all', label: 'Show all items', icon: 'i-globe', run: () => { closePalette(); setView('all'); } },
|
||
{ id: 'fav', label: 'Show favorites', icon: 'i-star', run: () => { closePalette(); setView('favorites'); } },
|
||
{ id: 'notes', label: 'Show notes', icon: 'i-edit', run: () => { closePalette(); setView('notes'); } },
|
||
{ id: 'trash', label: 'Show trash', icon: 'i-trash', run: () => { closePalette(); setView('trash'); } },
|
||
];
|
||
}
|
||
|
||
function renderPaletteResults(q) {
|
||
const cmds = paletteCommands();
|
||
const entries = state.entries.map(e => ({
|
||
id: 'entry-' + e.id, label: entryDisplayName(e),
|
||
// Sub: site (when different from displayName) + username, joined.
|
||
sub: [
|
||
(e.title && e.title.trim() && e.title.trim() !== e.site) ? e.site : '',
|
||
e.username || '',
|
||
].filter(Boolean).join(' · '),
|
||
icon: 'i-globe', run: () => { closePalette(); openSlideOver(e.id); },
|
||
}));
|
||
const all = cmds.concat(entries);
|
||
q = (q || '').toLowerCase();
|
||
const filtered = q ? all.filter(c => c.label.toLowerCase().includes(q) || (c.sub||'').toLowerCase().includes(q)) : all;
|
||
const out = $('#cmdResults');
|
||
out.innerHTML = '';
|
||
filtered.slice(0, 50).forEach((c, i) => {
|
||
const it = el('div', {
|
||
class: 'cmd-item' + (i === 0 ? ' is-active' : ''),
|
||
on: { click: c.run },
|
||
});
|
||
it.appendChild(icon(c.icon));
|
||
it.appendChild(el('span', null, c.label));
|
||
if (c.sub) it.appendChild(el('span', { style: 'color:var(--text-faint);font-size:11px;margin-left:auto' }, c.sub));
|
||
out.appendChild(it);
|
||
});
|
||
}
|
||
|
||
// ============================================================
|
||
// IN-APP CONFIRM / PROMPT (no native alerts)
|
||
// ============================================================
|
||
|
||
let confirmResolver = null;
|
||
|
||
function confirmDialog(opts) {
|
||
// opts: { title, message, okText, cancelText, danger }
|
||
opts = opts || {};
|
||
$('#confirmTitle').textContent = opts.title || 'Confirm';
|
||
$('#confirmMessage').innerHTML = opts.message || 'Are you sure?';
|
||
$('#confirmOkBtn').lastChild.nodeValue = ' ' + (opts.okText || 'Confirm');
|
||
$('#confirmCancelBtn').textContent = opts.cancelText || 'Cancel';
|
||
$('#confirmOkBtn').classList.toggle('is-danger', !!opts.danger);
|
||
$('#confirmInputField').classList.add('is-hidden');
|
||
$('#confirmModal').classList.remove('is-hidden');
|
||
setTimeout(() => $('#confirmOkBtn').focus(), 50);
|
||
return new Promise(res => { confirmResolver = res; });
|
||
}
|
||
|
||
function promptDialog(opts) {
|
||
// opts: { title, message, okText, placeholder, value, password, error }
|
||
opts = opts || {};
|
||
$('#confirmTitle').textContent = opts.title || 'Enter value';
|
||
// Append a transient red error line under the message so the modal
|
||
// can recycle for retries (wrong password, too-short, etc.) without
|
||
// a toast that swallows the context.
|
||
const msgHtml = opts.message || '';
|
||
const errHtml = opts.error
|
||
? '<p style="margin:8px 0 0;color:var(--danger);font-size:12px">' +
|
||
String(opts.error).replace(/[&<>"]/g,
|
||
c => ({'&':'&','<':'<','>':'>','"':'"'}[c])) +
|
||
'</p>'
|
||
: '';
|
||
$('#confirmMessage').innerHTML = msgHtml + errHtml;
|
||
$('#confirmOkBtn').lastChild.nodeValue = ' ' + (opts.okText || 'OK');
|
||
$('#confirmCancelBtn').textContent = 'Cancel';
|
||
$('#confirmOkBtn').classList.remove('is-danger');
|
||
$('#confirmInputField').classList.remove('is-hidden');
|
||
$('#confirmInput').value = opts.value || '';
|
||
$('#confirmInput').placeholder = opts.placeholder || '';
|
||
// Allow password-style masking (used by encrypted import/export).
|
||
const inp = $('#confirmInput');
|
||
const eye = $('#confirmInputEye');
|
||
inp.type = opts.password ? 'password' : 'text';
|
||
if (eye) {
|
||
// Eye toggle only for password prompts. Reset to masked + leave
|
||
// room on the right so text doesn't run under the button.
|
||
eye.style.display = opts.password ? '' : 'none';
|
||
inp.style.paddingRight = opts.password ? '34px' : '';
|
||
eye.onclick = () => {
|
||
inp.type = inp.type === 'password' ? 'text' : 'password';
|
||
inp.focus();
|
||
};
|
||
}
|
||
$('#confirmModal').classList.remove('is-hidden');
|
||
setTimeout(() => $('#confirmInput').focus(), 50);
|
||
return new Promise(res => { confirmResolver = res; });
|
||
}
|
||
|
||
// Global busy overlay for long operations (export, big file saves).
|
||
function showBusy(text) {
|
||
const t = $('#busyText'); if (t) t.textContent = text || 'Working…';
|
||
const o = $('#busyOverlay'); if (o) o.classList.remove('is-hidden');
|
||
}
|
||
function updateBusy(text) {
|
||
const t = $('#busyText'); if (t) t.textContent = text || '';
|
||
}
|
||
function hideBusy() {
|
||
const o = $('#busyOverlay'); if (o) o.classList.add('is-hidden');
|
||
}
|
||
|
||
function closeConfirm(value) {
|
||
$('#confirmModal').classList.add('is-hidden');
|
||
if (confirmResolver) {
|
||
const cb = confirmResolver;
|
||
confirmResolver = null;
|
||
cb(value);
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// RE-AUTH MODAL + EXPORT
|
||
// ============================================================
|
||
|
||
let reauthResolve = null;
|
||
|
||
function askReauth(message, opts) {
|
||
opts = opts || {};
|
||
return new Promise(resolve => {
|
||
reauthResolve = resolve;
|
||
$('#reauthMessage').textContent = message || 'This action requires your master password.';
|
||
$('#reauthPassword').value = '';
|
||
const errEl = $('#reauthError');
|
||
if (errEl) {
|
||
if (opts.error) {
|
||
errEl.textContent = opts.error;
|
||
errEl.classList.remove('is-hidden');
|
||
} else {
|
||
errEl.textContent = '';
|
||
errEl.classList.add('is-hidden');
|
||
}
|
||
}
|
||
$('#reauthModal').classList.remove('is-hidden');
|
||
setTimeout(() => $('#reauthPassword').focus(), 50);
|
||
});
|
||
}
|
||
|
||
function closeReauth(ok) {
|
||
$('#reauthModal').classList.add('is-hidden');
|
||
if (reauthResolve) {
|
||
const pwd = ok ? $('#reauthPassword').value : null;
|
||
const cb = reauthResolve;
|
||
reauthResolve = null;
|
||
cb(pwd);
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// QUICK UNLOCK — remember on this device (DPAPI-backed)
|
||
// ============================================================
|
||
//
|
||
// User-controlled convenience feature. When opted in, the current vault
|
||
// state (raw AES key + salt + username + session token) is bundled into
|
||
// a JSON blob and handed to the Delphi side, which DPAPI-encrypts it
|
||
// (CRYPTPROTECT_CURRENT_USER) and stashes the blob on disk. Subsequent
|
||
// app starts can recover the entire session without re-entering the
|
||
// master password.
|
||
//
|
||
// Honest trade-off: the encrypted blob is readable by ANY process
|
||
// running as the same Windows user. The protection is no stronger than
|
||
// the Windows account itself. Users with Windows Hello / fingerprint /
|
||
// PIN configured at the OS level get biometric gating transitively
|
||
// (via login). Without that, this is "remember on trusted device".
|
||
//
|
||
// Only available when running inside the Delphi host (Bridge.active);
|
||
// the PHP standalone has no DPAPI equivalent.
|
||
|
||
// Resolver for the Delphi → JS callback. Delphi side fires
|
||
// Bridge.onQuickUnlockResult(b64|null) after processing cmd://quickunlock/get.
|
||
let quickUnlockResolver = null;
|
||
|
||
// Same for the status query.
|
||
let quickUnlockStatusResolver = null;
|
||
|
||
function bridgeRequestQuickUnlock() {
|
||
if (!Bridge.active) return Promise.resolve(null);
|
||
return new Promise(resolve => {
|
||
quickUnlockResolver = resolve;
|
||
// Safety: if Delphi never responds, time out after 3 s so the auth
|
||
// screen doesn't hang. Falls back to master-pw login.
|
||
setTimeout(() => {
|
||
if (quickUnlockResolver === resolve) {
|
||
quickUnlockResolver = null;
|
||
resolve(null);
|
||
}
|
||
}, 3000);
|
||
window.location.href = 'cmd://quickunlock/get';
|
||
});
|
||
}
|
||
|
||
let clipboardReadResolver = null;
|
||
|
||
function bridgeReadClipboard() {
|
||
if (!Bridge.active) return Promise.resolve('');
|
||
return new Promise(resolve => {
|
||
clipboardReadResolver = resolve;
|
||
setTimeout(() => {
|
||
if (clipboardReadResolver === resolve) {
|
||
clipboardReadResolver = null;
|
||
resolve('');
|
||
}
|
||
}, 1500);
|
||
window.location.href = 'cmd://clipboard/read';
|
||
});
|
||
}
|
||
|
||
Bridge.onClipboardRead = function(text) {
|
||
if (clipboardReadResolver) {
|
||
clipboardReadResolver(text || '');
|
||
clipboardReadResolver = null;
|
||
}
|
||
};
|
||
|
||
function bridgeQuickUnlockStatus() {
|
||
if (!Bridge.active) return Promise.resolve(false);
|
||
return new Promise(resolve => {
|
||
quickUnlockStatusResolver = resolve;
|
||
setTimeout(() => {
|
||
if (quickUnlockStatusResolver === resolve) {
|
||
quickUnlockStatusResolver = null;
|
||
resolve(false);
|
||
}
|
||
}, 2000);
|
||
window.location.href = 'cmd://quickunlock/status';
|
||
});
|
||
}
|
||
|
||
// Patch Bridge.onQuickUnlockResult / Status to feed the resolvers above.
|
||
Bridge.onQuickUnlockResult = function(b64) {
|
||
if (quickUnlockResolver) {
|
||
const cb = quickUnlockResolver;
|
||
quickUnlockResolver = null;
|
||
cb(b64);
|
||
}
|
||
};
|
||
Bridge.onQuickUnlockStatus = function(configured) {
|
||
if (quickUnlockStatusResolver) {
|
||
const cb = quickUnlockStatusResolver;
|
||
quickUnlockStatusResolver = null;
|
||
cb(!!configured);
|
||
}
|
||
};
|
||
|
||
// ============================================================
|
||
// PIN UNLOCK (DPAPI blob, PIN-derived wrap of the vault key)
|
||
// ============================================================
|
||
// Three modes governed by state.unlockMode (synced setting):
|
||
// 'pw' — current behaviour, master pw only (PIN ignored even if set)
|
||
// 'pin' — PIN unlocks the vault on this device
|
||
// 'both' — master pw unlocks; PIN is then verified before access
|
||
//
|
||
// Anti-brute-force: each failed PIN attempt rewrites the blob with an
|
||
// incremented counter. Past PIN_MAX_ATTEMPTS the blob self-destructs and
|
||
// the user has to fall back to master pw (and re-set the PIN if desired).
|
||
//
|
||
// Sensitive actions (export, change master pw, recovery code…) still go
|
||
// through askReauth which always asks for the master pw — the PIN never
|
||
// substitutes there.
|
||
|
||
const PIN_KDF_ITERS = 100000; // lower than master pw — PIN entropy
|
||
// is low (4–6 digits) so high iters
|
||
// mostly slow down honest users.
|
||
const PIN_MAX_ATTEMPTS = 5;
|
||
|
||
let pinResolver = null;
|
||
let pinStatusResolver = null;
|
||
|
||
function bridgePinGet() {
|
||
if (!Bridge.active) return Promise.resolve(null);
|
||
return new Promise(resolve => {
|
||
pinResolver = resolve;
|
||
setTimeout(() => {
|
||
if (pinResolver === resolve) { pinResolver = null; resolve(null); }
|
||
}, 3000);
|
||
window.location.href = 'cmd://pin/get';
|
||
});
|
||
}
|
||
function bridgePinStatus() {
|
||
if (!Bridge.active) return Promise.resolve(false);
|
||
return new Promise(resolve => {
|
||
pinStatusResolver = resolve;
|
||
setTimeout(() => {
|
||
if (pinStatusResolver === resolve) {
|
||
pinStatusResolver = null; resolve(false);
|
||
}
|
||
}, 2000);
|
||
window.location.href = 'cmd://pin/status';
|
||
});
|
||
}
|
||
function bridgePinStore(payloadB64) {
|
||
if (!Bridge.active) return;
|
||
window.location.href = 'cmd://pin/store?data=' + encodeURIComponent(payloadB64);
|
||
}
|
||
function bridgePinClear() {
|
||
if (!Bridge.active) return;
|
||
window.location.href = 'cmd://pin/clear';
|
||
}
|
||
Bridge.onPinResult = function(b64) {
|
||
if (pinResolver) { const cb = pinResolver; pinResolver = null; cb(b64); }
|
||
};
|
||
Bridge.onPinStatus = function(configured) {
|
||
if (pinStatusResolver) {
|
||
const cb = pinStatusResolver;
|
||
pinStatusResolver = null;
|
||
cb(!!configured);
|
||
}
|
||
};
|
||
|
||
async function derivePinWrapKey(pin, saltBytes, iters) {
|
||
const km = await crypto.subtle.importKey(
|
||
'raw', new TextEncoder().encode(pin), 'PBKDF2', false, ['deriveKey']);
|
||
return crypto.subtle.deriveKey(
|
||
{ name: 'PBKDF2', salt: saltBytes, iterations: iters, hash: 'SHA-256' },
|
||
km,
|
||
{ name: 'AES-GCM', length: 256 },
|
||
true, // extractable: false would be safer but we need to re-wrap on attempt increment
|
||
['encrypt', 'decrypt']);
|
||
}
|
||
|
||
async function pinBuildBlob(pin) {
|
||
// Snapshot of everything the cold-start unlock needs to log back in
|
||
// without the master pw. Mirrors the Quick Unlock payload shape.
|
||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||
const wrap = await derivePinWrapKey(pin, salt, PIN_KDF_ITERS);
|
||
const raw = await crypto.subtle.exportKey('raw', state.cryptoKey);
|
||
const wrapped = await crypto.subtle.encrypt(
|
||
{ name: 'AES-GCM', iv }, wrap, raw);
|
||
return {
|
||
v: 1,
|
||
username: state.username,
|
||
loginSalt: state.salt,
|
||
loginIters: state.kdfIterations || 600000,
|
||
// Auth scheme so cold-start sends the right verifier (v2 accounts
|
||
// need the decoupled transform, not the raw key hex). Absent on
|
||
// pre-decoupling blobs → cold-start defaults to the key hex, which
|
||
// is correct for those (legacy) accounts.
|
||
hashAlgo: state.hashAlgo || '',
|
||
argon2Params: state.argon2Params || null,
|
||
salt: bytesToBase64(salt),
|
||
iters: PIN_KDF_ITERS,
|
||
iv: bytesToBase64(iv),
|
||
wrapped: bytesToBase64(wrapped),
|
||
attempts: 0,
|
||
};
|
||
}
|
||
|
||
function pinBlobToB64(obj) {
|
||
return bytesToBase64(new TextEncoder().encode(JSON.stringify(obj)));
|
||
}
|
||
function pinB64ToBlob(b64) {
|
||
return JSON.parse(new TextDecoder().decode(base64ToBytes(b64)));
|
||
}
|
||
|
||
async function pinFetchBlob() {
|
||
const b64 = await bridgePinGet();
|
||
if (!b64) return null;
|
||
try { return pinB64ToBlob(b64); }
|
||
catch { return null; }
|
||
}
|
||
|
||
// Validates a PIN against the stored blob. Returns the unwrapped vault
|
||
// key bytes on success, null on failure (and rewrites the blob with the
|
||
// incremented attempts counter or wipes it past the cap).
|
||
async function pinTryUnwrap(pin) {
|
||
const blob = await pinFetchBlob();
|
||
if (!blob) return null;
|
||
try {
|
||
const wrap = await derivePinWrapKey(pin,
|
||
base64ToBytes(blob.salt), blob.iters || PIN_KDF_ITERS);
|
||
const raw = await crypto.subtle.decrypt(
|
||
{ name: 'AES-GCM', iv: base64ToBytes(blob.iv) },
|
||
wrap, base64ToBytes(blob.wrapped));
|
||
// Successful unlock — reset the attempts counter so a future
|
||
// bad-then-good streak doesn't accidentally wipe the blob.
|
||
if ((blob.attempts || 0) !== 0) {
|
||
blob.attempts = 0;
|
||
bridgePinStore(pinBlobToB64(blob));
|
||
}
|
||
return { rawKey: new Uint8Array(raw), blob };
|
||
} catch (_) {
|
||
const next = (blob.attempts || 0) + 1;
|
||
if (next >= PIN_MAX_ATTEMPTS) {
|
||
bridgePinClear();
|
||
} else {
|
||
blob.attempts = next;
|
||
bridgePinStore(pinBlobToB64(blob));
|
||
}
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function setupPin(pin) {
|
||
if (!Bridge.active) return toast('PIN requires the Delphi app', 'warning');
|
||
if (!state.cryptoKey) return toast('Unlock the vault first', 'warning');
|
||
const blob = await pinBuildBlob(pin);
|
||
bridgePinStore(pinBlobToB64(blob));
|
||
state.pinConfigured = true;
|
||
toast('PIN set');
|
||
}
|
||
|
||
async function removePin() {
|
||
bridgePinClear();
|
||
state.pinConfigured = false;
|
||
// If we were in pin-only mode, fall back to pw — leaving the user
|
||
// unable to log in next time would be a self-foot-gun.
|
||
if (state.unlockMode === 'pin' || state.unlockMode === 'both') {
|
||
state.unlockMode = 'pw';
|
||
localStorage.setItem('unlockMode', 'pw');
|
||
saveServerSettings();
|
||
}
|
||
toast('PIN removed');
|
||
}
|
||
|
||
// Refresh the Settings panel PIN row: dropdown value, status text,
|
||
// button labels. Idempotent — safe to call from listeners or after the
|
||
// async pinStatus probe completes.
|
||
function refreshPinUnlockUI() {
|
||
const sel = document.getElementById('settingUnlockMode');
|
||
const status = document.getElementById('pinStatus');
|
||
const setBtn = document.getElementById('pinSetBtn');
|
||
const delBtn = document.getElementById('pinRemoveBtn');
|
||
if (sel) sel.value = state.unlockMode || 'pw';
|
||
if (status) {
|
||
status.textContent = state.pinConfigured
|
||
? 'PIN is set on this device.'
|
||
: 'No PIN set.';
|
||
}
|
||
if (setBtn) setBtn.textContent = state.pinConfigured ? 'Change PIN' : 'Set PIN';
|
||
if (delBtn) delBtn.style.display = state.pinConfigured ? '' : 'none';
|
||
}
|
||
|
||
// Prompt + setup flow used by the Set/Change PIN button. Validates the
|
||
// PIN client-side (4–12 digits) then writes the DPAPI blob.
|
||
async function pinSetupFlow() {
|
||
if (!Bridge.active) return toast('PIN requires the Delphi app', 'warning');
|
||
if (!state.cryptoKey) return toast('Unlock the vault first', 'warning');
|
||
|
||
// Setting/changing a PIN creates a new unlock path → treat as a
|
||
// sensitive operation. An unattended unlocked vault must not be
|
||
// possible for a passerby to pin-backdoor.
|
||
const masterPwd = await askReauth(
|
||
'Confirm your master password to set or change the PIN.');
|
||
if (!masterPwd) return;
|
||
try {
|
||
const verifier = await computeVerifier(
|
||
masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params);
|
||
await api('/reauth', {
|
||
method: 'POST',
|
||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||
body: JSON.stringify({ verifier: verifier }),
|
||
});
|
||
} catch (err) {
|
||
return toast('Wrong master password', 'error');
|
||
}
|
||
|
||
let lastError = '';
|
||
let attempts = 0;
|
||
for (;;) {
|
||
const pin = await promptDialog({
|
||
title: state.pinConfigured ? 'Change PIN' : 'Set a PIN',
|
||
message: '4–12 digits. PIN unlock is device-local and never leaves this machine.',
|
||
placeholder: 'PIN',
|
||
password: true,
|
||
okText: 'Save',
|
||
error: lastError,
|
||
});
|
||
// Cancel / Esc / X resolves with `false` (not null) — treat any
|
||
// falsy value as "user backed out", not as a wrong attempt.
|
||
if (pin === false || pin === null || pin === undefined || pin === '') return;
|
||
if (!/^\d{4,12}$/.test(pin)) {
|
||
attempts++;
|
||
if (attempts >= 5) return toast('Too many invalid attempts', 'error');
|
||
lastError = 'PIN must be 4–12 digits (attempt ' + attempts + ' / 5).';
|
||
continue;
|
||
}
|
||
await setupPin(pin);
|
||
refreshPinUnlockUI();
|
||
return;
|
||
}
|
||
}
|
||
|
||
// ----- Auth screen mode switching ---------------------------------
|
||
// Picks which inputs are visible on the lock screen based on the
|
||
// user's unlockMode + whether a PIN blob actually exists on this
|
||
// device. Always falls back to the master-pw layout if PIN unlock can't
|
||
// realistically work (no Delphi bridge, no DPAPI blob).
|
||
function applyAuthScreenMode() {
|
||
const pwField = document.getElementById('loginPasswordField');
|
||
const pinField = document.getElementById('loginPinField');
|
||
const useMaster = document.getElementById('loginUseMasterBtn');
|
||
if (!pwField || !pinField) return;
|
||
const havePin = Bridge.active && state.pinConfigured;
|
||
// Drift fix: if the user previously chose pin/both but the blob is
|
||
// no longer on disk (auto-wiped after 5 wrong attempts, or removed
|
||
// from another session), demote the mode locally so the Settings
|
||
// dropdown reflects reality. Server sync happens next time the user
|
||
// logs in and openSettings saves changes.
|
||
if (!havePin && (state.unlockMode === 'pin' || state.unlockMode === 'both')) {
|
||
state.unlockMode = 'pw';
|
||
localStorage.setItem('unlockMode', 'pw');
|
||
}
|
||
const mode = havePin ? (state.unlockMode || 'pw') : 'pw';
|
||
|
||
pwField.style.display = (mode === 'pin') ? 'none' : '';
|
||
pinField.style.display = (mode === 'pin' || mode === 'both') ? '' : 'none';
|
||
// PIN-only mode: offer a one-shot escape hatch so the user can fall
|
||
// back to master pw if the PIN blob got corrupted or they forgot it.
|
||
if (useMaster) useMaster.style.display = (mode === 'pin') ? '' : 'none';
|
||
|
||
// Required attribute on hidden inputs blocks form submission — keep
|
||
// it in sync with visibility.
|
||
const pwInput = document.getElementById('loginPassword');
|
||
const pinInput = document.getElementById('loginPin');
|
||
if (pwInput) pwInput.required = (mode !== 'pin');
|
||
if (pinInput) pinInput.required = (mode === 'pin' || mode === 'both');
|
||
}
|
||
|
||
// Try the PIN-unlock cold-start. Mirrors tryQuickUnlock but gated by a
|
||
// user-typed PIN. Returns true on success (vault unlocked), false on
|
||
// any failure (wrong PIN, missing blob, server refused). The caller is
|
||
// responsible for showing the master-pw fallback UI on false.
|
||
async function loginViaPin(pin) {
|
||
if (!Bridge.active) return false;
|
||
if (!pin) return false;
|
||
const ok = await pinTryUnwrap(pin);
|
||
if (!ok) return false;
|
||
const { rawKey, blob } = ok;
|
||
|
||
// Restore the identity bits we need to call /login with a verifier.
|
||
state.username = blob.username || state.username;
|
||
state.salt = blob.loginSalt || state.salt;
|
||
state.kdfIterations = blob.loginIters || state.kdfIterations || 600000;
|
||
state.hashAlgo = blob.hashAlgo || '';
|
||
state.argon2Params = blob.argon2Params || null;
|
||
|
||
try {
|
||
state.cryptoKey = await crypto.subtle.importKey(
|
||
'raw', rawKey, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']);
|
||
} catch (_) { return false; }
|
||
|
||
try {
|
||
// v2 accounts need the decoupled verifier; legacy → key hex.
|
||
const verifier = await verifierFromKeyHex(bytesToHex(rawKey), state.hashAlgo);
|
||
const r = await api('/login', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ username: state.username, verifier }),
|
||
});
|
||
state.token = r.token;
|
||
state.csrf = r.csrfToken;
|
||
if (r.salt) state.salt = r.salt;
|
||
if (r.kdfIterations) state.kdfIterations = r.kdfIterations;
|
||
} catch (_) {
|
||
// Server credentials drifted (master pw rotation since PIN
|
||
// setup). Force user back to master pw + ask them to redo PIN.
|
||
return false;
|
||
}
|
||
|
||
sessionStorage.setItem('username', state.username);
|
||
sessionStorage.setItem('salt', state.salt);
|
||
sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
|
||
sessionStorage.setItem('hashAlgo', state.hashAlgo);
|
||
sessionStorage.setItem('authToken', state.token);
|
||
sessionStorage.setItem('csrfToken', state.csrf);
|
||
await persistCryptoKey();
|
||
|
||
state.locked = false;
|
||
state.justRecovered = false;
|
||
return true;
|
||
}
|
||
|
||
// 'both' mode helper: master pw has already populated state.cryptoKey
|
||
// via the regular login flow. Now check the PIN matches the stored
|
||
// blob (validation only — we don't use the unwrapped key from here).
|
||
async function verifyPinAfterMasterUnlock(pin) {
|
||
const ok = await pinTryUnwrap(pin);
|
||
return !!ok;
|
||
}
|
||
|
||
async function enableQuickUnlock() {
|
||
if (!Bridge.active) return toast('Quick unlock requires the Delphi app', 'warning');
|
||
if (!state.cryptoKey) return toast('Unlock the vault first', 'warning');
|
||
|
||
const masterPwd = await askReauth(
|
||
'Confirm your master password to enable Quick unlock on this device.');
|
||
if (!masterPwd) return;
|
||
try {
|
||
const verifier = await computeVerifier(
|
||
masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params);
|
||
await api('/reauth', {
|
||
method: 'POST',
|
||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||
body: JSON.stringify({ verifier: verifier }),
|
||
});
|
||
} catch (err) {
|
||
return toast('Wrong master password', 'error');
|
||
}
|
||
|
||
// Export the raw key + identity. We don't store the session token —
|
||
// tryQuickUnlock re-logs in with a verifier derived from the key,
|
||
// which always yields a fresh server session (the stored token would
|
||
// expire after 24 h and break cold-start restore on a moved exe).
|
||
const raw = await crypto.subtle.exportKey('raw', state.cryptoKey);
|
||
const blob = JSON.stringify({
|
||
v: 2,
|
||
username: state.username,
|
||
salt: state.salt,
|
||
kdfIterations: state.kdfIterations,
|
||
// Auth scheme for cold-start verifier selection (see pinBuildBlob).
|
||
hashAlgo: state.hashAlgo || '',
|
||
argon2Params: state.argon2Params || null,
|
||
key: bytesToBase64(raw),
|
||
});
|
||
const b64 = bytesToBase64(new TextEncoder().encode(blob));
|
||
window.location.href = 'cmd://quickunlock/store?data=' + encodeURIComponent(b64);
|
||
|
||
localStorage.setItem('quickUnlockEnabled', '1');
|
||
state.quickUnlockEnabled = true;
|
||
if ($('#quickUnlockStatus')) updateQuickUnlockUI();
|
||
toast('Quick unlock enabled');
|
||
}
|
||
|
||
async function disableQuickUnlock() {
|
||
window.location.href = 'cmd://quickunlock/clear';
|
||
localStorage.removeItem('quickUnlockEnabled');
|
||
state.quickUnlockEnabled = false;
|
||
if ($('#quickUnlockStatus')) updateQuickUnlockUI();
|
||
toast('Quick unlock disabled');
|
||
}
|
||
|
||
function updateQuickUnlockUI() {
|
||
const lbl = $('#quickUnlockStatus');
|
||
const btn = $('#quickUnlockToggleBtn');
|
||
if (!lbl || !btn) return;
|
||
if (state.quickUnlockEnabled) {
|
||
lbl.textContent = 'Enabled on this device.';
|
||
btn.textContent = 'Disable';
|
||
btn.classList.add('is-danger');
|
||
} else {
|
||
lbl.textContent = 'Disabled.';
|
||
btn.textContent = 'Enable on this device';
|
||
btn.classList.remove('is-danger');
|
||
}
|
||
}
|
||
|
||
// Try to unlock via DPAPI. Returns true if the vault is now unlocked,
|
||
// false otherwise (caller falls back to master-pw login).
|
||
async function tryQuickUnlock() {
|
||
if (!Bridge.active) return false;
|
||
|
||
const b64 = await bridgeRequestQuickUnlock();
|
||
if (!b64) return false;
|
||
|
||
localStorage.setItem('quickUnlockEnabled', '1');
|
||
state.quickUnlockEnabled = true;
|
||
|
||
let parsed;
|
||
try {
|
||
const jsonStr = new TextDecoder().decode(base64ToBytes(b64));
|
||
parsed = JSON.parse(jsonStr);
|
||
} catch (e) {
|
||
return false;
|
||
}
|
||
if (!parsed || !parsed.key || !parsed.salt || !parsed.username) return false;
|
||
|
||
// Restore identity + crypto key from the blob.
|
||
state.username = parsed.username;
|
||
state.salt = parsed.salt;
|
||
state.kdfIterations = parsed.kdfIterations || 600000;
|
||
state.hashAlgo = parsed.hashAlgo || '';
|
||
state.argon2Params = parsed.argon2Params || null;
|
||
const rawKey = base64ToBytes(parsed.key);
|
||
|
||
try {
|
||
state.cryptoKey = await crypto.subtle.importKey(
|
||
'raw', rawKey,
|
||
{ name: 'AES-GCM' }, true, ['encrypt', 'decrypt']);
|
||
} catch (e) {
|
||
return false;
|
||
}
|
||
|
||
// Always request a fresh session token via /login using the key-derived
|
||
// verifier. The stored token (if any) may have expired or been cleaned
|
||
// up by the server's session GC, which used to drop the user back to
|
||
// the login screen on cold start.
|
||
try {
|
||
// v2 accounts need the decoupled verifier; legacy → key hex.
|
||
const verifier = await verifierFromKeyHex(bytesToHex(rawKey), state.hashAlgo);
|
||
const r = await api('/login', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ username: state.username, verifier }),
|
||
});
|
||
state.token = r.token;
|
||
state.csrf = r.csrfToken;
|
||
if (r.salt) state.salt = r.salt;
|
||
if (r.kdfIterations) state.kdfIterations = r.kdfIterations;
|
||
} catch (e) {
|
||
// Login failed — vault credentials may have changed (master pw
|
||
// rotation) since Quick Unlock was set up. Force a fresh master-pw
|
||
// login; the user will need to re-enable Quick Unlock afterwards.
|
||
return false;
|
||
}
|
||
|
||
sessionStorage.setItem('username', state.username);
|
||
sessionStorage.setItem('salt', state.salt);
|
||
sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
|
||
sessionStorage.setItem('hashAlgo', state.hashAlgo);
|
||
sessionStorage.setItem('authToken', state.token);
|
||
sessionStorage.setItem('csrfToken', state.csrf);
|
||
await persistCryptoKey();
|
||
|
||
state.locked = false;
|
||
return true;
|
||
}
|
||
|
||
// ============================================================
|
||
// RECOVERY KEY — one-shot emergency access
|
||
// ============================================================
|
||
//
|
||
// Generated at the user's request from Settings. The plaintext code is
|
||
// shown exactly once; the server stores only SHA-256(code) for lookup
|
||
// + an AES-GCM wrap of the current vault key under a KEK derived from
|
||
// PBKDF2(code, kdfSalt, 600k).
|
||
//
|
||
// Recovery flow (master pw forgotten):
|
||
// 1. Auth screen → "Use recovery code" → enter username + code
|
||
// 2. Server hashes code, looks up user, verifies match, DELETES the
|
||
// recovery row (single-use), returns wrapped key + KEK salt +
|
||
// a fresh session.
|
||
// 3. Client derives the KEK, unwraps the AES key.
|
||
// 4. Client immediately forces a master-password change so the
|
||
// account isn't left with the recovery code's KEK as the only
|
||
// escape hatch.
|
||
|
||
// Renders a printable A4 sheet (CSS-only, no external libs) with the
|
||
// recovery code in large monospace + a fold-and-stash instruction.
|
||
// Removes the print container after the print dialog closes.
|
||
function printRecoveryCode(code, username) {
|
||
const existing = document.getElementById('printRecoveryArea');
|
||
if (existing) existing.remove();
|
||
|
||
const wrap = document.createElement('div');
|
||
wrap.id = 'printRecoveryArea';
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
wrap.innerHTML =
|
||
'<div class="prc-sheet">' +
|
||
' <h1>PMServer · Recovery Code</h1>' +
|
||
' <div class="prc-meta">' +
|
||
' <div><b>Account:</b> ' + (username || '') + '</div>' +
|
||
' <div><b>Generated:</b> ' + today + '</div>' +
|
||
' </div>' +
|
||
' <div class="prc-code">' + code + '</div>' +
|
||
' <div class="prc-instructions">' +
|
||
' <p><b>Keep this sheet offline and physically secure.</b></p>' +
|
||
' <p>Use this code if you forget your master password:</p>' +
|
||
' <ol>' +
|
||
' <li>On the unlock screen, click <i>"Use recovery code"</i>.</li>' +
|
||
' <li>Enter your username and the 16-character code above.</li>' +
|
||
' <li>You will be asked to set a new master password — the code is then consumed.</li>' +
|
||
' </ol>' +
|
||
' <p>The code can be used up to <b>5 times</b>. It is permanently erased the moment you successfully change the master password.</p>' +
|
||
' <p style="font-size:11px;color:#666">Anyone with this code can reset your master password — store it like a paper key, not a sticky note.</p>' +
|
||
' </div>' +
|
||
'</div>';
|
||
document.body.appendChild(wrap);
|
||
|
||
// Restore on print-end and on focus (Edge fires focus when preview closes).
|
||
const cleanup = () => {
|
||
const n = document.getElementById('printRecoveryArea');
|
||
if (n) n.remove();
|
||
window.removeEventListener('afterprint', cleanup);
|
||
window.removeEventListener('focus', cleanup);
|
||
};
|
||
window.addEventListener('afterprint', cleanup);
|
||
window.addEventListener('focus', cleanup);
|
||
setTimeout(() => window.print(), 50);
|
||
}
|
||
|
||
// Random recovery code: 16 chars in 4 groups of 4. ~96 bits entropy
|
||
// from a 36-char alphabet (no ambiguous chars: no 0/O/I/l/1) so the
|
||
// printed form is misreading-resistant.
|
||
function generateRecoveryCode() {
|
||
const A = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // 32 chars
|
||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||
let s = '';
|
||
for (let i = 0; i < 16; i++) {
|
||
if (i > 0 && i % 4 === 0) s += '-';
|
||
s += A[bytes[i] % A.length];
|
||
}
|
||
return s;
|
||
}
|
||
|
||
async function sha256HexLocal(input) {
|
||
const buf = new TextEncoder().encode(input);
|
||
const hashBuf = await crypto.subtle.digest('SHA-256', buf);
|
||
const bytes = new Uint8Array(hashBuf);
|
||
let hex = '';
|
||
for (const b of bytes) hex += b.toString(16).padStart(2, '0');
|
||
return hex;
|
||
}
|
||
|
||
// Derive a KEK from the recovery code + per-row salt, then wrap the
|
||
// supplied AES key bytes under it. Returns base64 ciphertext + IV.
|
||
async function wrapAesKeyForRecovery(aesKeyBytes, recoveryCode, kdfSaltHex) {
|
||
const saltBytes = new TextEncoder().encode(kdfSaltHex); // match deriveKey's quirk
|
||
const km = await crypto.subtle.importKey(
|
||
'raw', new TextEncoder().encode(recoveryCode),
|
||
'PBKDF2', false, ['deriveKey']);
|
||
const kek = await crypto.subtle.deriveKey(
|
||
{ name: 'PBKDF2', salt: saltBytes, iterations: 600000, hash: 'SHA-256' },
|
||
km,
|
||
{ name: 'AES-GCM', length: 256 },
|
||
false, ['encrypt', 'decrypt']);
|
||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, kek, aesKeyBytes);
|
||
return { wrappedKey: bytesToBase64(ct), wrappedIv: bytesToBase64(iv) };
|
||
}
|
||
|
||
async function unwrapAesKeyFromRecovery(wrappedKeyB64, wrappedIvB64, recoveryCode, kdfSaltHex) {
|
||
const saltBytes = new TextEncoder().encode(kdfSaltHex);
|
||
const km = await crypto.subtle.importKey(
|
||
'raw', new TextEncoder().encode(recoveryCode),
|
||
'PBKDF2', false, ['deriveKey']);
|
||
const kek = await crypto.subtle.deriveKey(
|
||
{ name: 'PBKDF2', salt: saltBytes, iterations: 600000, hash: 'SHA-256' },
|
||
km,
|
||
{ name: 'AES-GCM', length: 256 },
|
||
false, ['encrypt', 'decrypt']);
|
||
const iv = base64ToBytes(wrappedIvB64);
|
||
const ct = base64ToBytes(wrappedKeyB64);
|
||
return await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, kek, ct); // raw bytes
|
||
}
|
||
|
||
// Generate a new recovery key for the logged-in user. Shows the plaintext
|
||
// code in a modal that the user must explicitly acknowledge before closing.
|
||
async function doGenerateRecoveryKey() {
|
||
if (!state.cryptoKey) { return toast('Vault locked', 'warning'); }
|
||
|
||
const masterPwd = await askReauth(
|
||
'Confirm your master password to generate a recovery key.');
|
||
if (!masterPwd) return;
|
||
|
||
// Generate the code + a fresh per-row salt for the KEK PBKDF2. Salt is
|
||
// per-recovery so regenerating doesn't reuse the same KDF parameters.
|
||
const code = generateRecoveryCode();
|
||
const codeHash = await sha256HexLocal(code);
|
||
const kdfSalt = randomHexSalt();
|
||
|
||
// Export the current AES key as raw bytes so we can wrap it under
|
||
// the recovery KEK. The export only works because deriveKey was
|
||
// called with `extractable=true` — already the case in our code.
|
||
const rawKey = await crypto.subtle.exportKey('raw', state.cryptoKey);
|
||
const { wrappedKey, wrappedIv } = await wrapAesKeyForRecovery(
|
||
rawKey, code, kdfSalt);
|
||
|
||
try {
|
||
// Send a verifier instead of the master pw — server proves the
|
||
// user still knows the master pw without ever seeing the plaintext.
|
||
const verifier = await computeVerifier(
|
||
masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params);
|
||
await api('/recovery-key/setup', {
|
||
method: 'POST',
|
||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||
body: JSON.stringify({
|
||
verifier: verifier,
|
||
codeHash: codeHash,
|
||
kdfSalt: kdfSalt,
|
||
wrappedKey: wrappedKey,
|
||
wrappedIv: wrappedIv,
|
||
}),
|
||
});
|
||
} catch (err) {
|
||
return toast('Setup failed: ' + (err.message || ''), 'error');
|
||
}
|
||
|
||
// Refresh the Settings button label
|
||
state.recoveryConfigured = true;
|
||
if ($('#recoveryStatus')) updateRecoveryStatusLabel();
|
||
|
||
// Show the code ONCE. Use the confirm modal so the user has to
|
||
// explicitly click "I saved it" before the value vanishes.
|
||
//
|
||
// Wire the inline Copy button BEFORE awaiting the dialog: confirmDialog
|
||
// injects the HTML synchronously, so a 0-ms task fires after the DOM
|
||
// is in place but before the user can interact. CSP forbids inline
|
||
// onclick handlers, hence the addEventListener route.
|
||
setTimeout(() => {
|
||
const btn = document.getElementById('copyRecoveryCodeBtn');
|
||
if (btn) {
|
||
btn.addEventListener('click', () => {
|
||
// Same path as password copy: secure-clipboard via Delphi
|
||
// (excluded from Win+V history, auto-cleared after 30s) when
|
||
// running embedded, navigator.clipboard with manual scrub
|
||
// otherwise.
|
||
if (Bridge.copySecure(code, 30000)) {
|
||
toast('Recovery code copied · clears in 30s');
|
||
} else {
|
||
navigator.clipboard.writeText(code).then(() => {
|
||
toast('Recovery code copied · clears in 30s');
|
||
setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000);
|
||
});
|
||
}
|
||
});
|
||
}
|
||
const printBtn = document.getElementById('printRecoveryCodeBtn');
|
||
if (printBtn) {
|
||
printBtn.addEventListener('click', () => printRecoveryCode(code, state.username));
|
||
}
|
||
}, 0);
|
||
|
||
await confirmDialog({
|
||
title: 'Your recovery code',
|
||
message: '<p>Save this code somewhere safe (password manager, ' +
|
||
'safe deposit box, printed copy). It will <b>not</b> be ' +
|
||
'shown again.</p>' +
|
||
'<div style="display:flex;align-items:center;gap:8px;' +
|
||
'padding:14px;background:var(--bg-elev);border-radius:6px">' +
|
||
'<span style="font-family:JetBrains Mono,monospace;font-size:20px;' +
|
||
'letter-spacing:2px;flex:1;text-align:center;user-select:all">' +
|
||
code + '</span>' +
|
||
'<button type="button" class="btn btn-ghost btn-sm" ' +
|
||
'id="copyRecoveryCodeBtn" title="Copy code">' +
|
||
'<svg><use href="#i-copy"/></svg> Copy</button>' +
|
||
'<button type="button" class="btn btn-ghost btn-sm" ' +
|
||
'id="printRecoveryCodeBtn" title="Print this code">' +
|
||
'<svg><use href="#i-printer"/></svg> Print</button>' +
|
||
'</div>' +
|
||
'<p style="color:var(--text-dim);font-size:12px">' +
|
||
'Using it lets you recover access if you forget your master ' +
|
||
'password. The code can be used up to <b>5 times</b>, and ' +
|
||
'is permanently erased as soon as you successfully change ' +
|
||
'your master password — so set a new one right after ' +
|
||
'recovering.</p>',
|
||
okText: 'I saved it',
|
||
});
|
||
toast('Recovery code generated');
|
||
}
|
||
|
||
async function doRemoveRecoveryKey() {
|
||
const ok = await confirmDialog({
|
||
title: 'Remove recovery key',
|
||
message: 'You will lose your ability to recover this account if you ' +
|
||
'forget the master password. Continue?',
|
||
okText: 'Remove',
|
||
danger: true,
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await api('/recovery-key', {
|
||
method: 'DELETE',
|
||
headers: authHeaders(),
|
||
});
|
||
state.recoveryConfigured = false;
|
||
if ($('#recoveryStatus')) updateRecoveryStatusLabel();
|
||
toast('Recovery key removed');
|
||
} catch (err) { toast(err.message, 'error'); }
|
||
}
|
||
|
||
function updateRecoveryStatusLabel() {
|
||
const lbl = $('#recoveryStatus');
|
||
const setupBtn = $('#recoverySetupBtn');
|
||
const removeBtn = $('#recoveryRemoveBtn');
|
||
if (!lbl) return;
|
||
if (state.recoveryConfigured) {
|
||
const left = state.recoveryRemainingUses;
|
||
const usesNote = (typeof left === 'number' && left < 5)
|
||
? ' (' + left + ' use' + (left === 1 ? '' : 's') + ' left)'
|
||
: '';
|
||
lbl.textContent = 'Recovery key is configured.' + usesNote;
|
||
if (setupBtn) setupBtn.textContent = 'Regenerate code';
|
||
if (removeBtn) removeBtn.style.display = '';
|
||
} else {
|
||
lbl.textContent = 'No recovery key set.';
|
||
if (setupBtn) setupBtn.textContent = 'Generate recovery code';
|
||
if (removeBtn) removeBtn.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
async function refreshRecoveryStatus() {
|
||
try {
|
||
const r = await api('/recovery-key/status', { headers: authHeaders() });
|
||
state.recoveryConfigured = !!r.configured;
|
||
state.recoveryRemainingUses = (typeof r.remaining_uses === 'number') ? r.remaining_uses : 5;
|
||
updateRecoveryStatusLabel();
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
// Recovery redeem flow — called from the auth screen when the user clicks
|
||
// "Use a recovery code". Prompts for username + code, redeems, unwraps the
|
||
// vault key, immediately forces a master password change.
|
||
async function doRecoveryRedeem() {
|
||
const u = await promptDialog({
|
||
title: 'Recover access',
|
||
message: 'Enter your username — we\'ll ask for the recovery code next.',
|
||
placeholder: 'Username',
|
||
okText: 'Continue',
|
||
});
|
||
if (!u) return;
|
||
const code = await promptDialog({
|
||
title: 'Enter recovery code',
|
||
message: 'Recovery codes look like XXXX-XXXX-XXXX-XXXX. They allow ' +
|
||
'up to 5 uses, and are erased when you set a new master ' +
|
||
'password — remember to generate a fresh code afterwards.',
|
||
placeholder: 'XXXX-XXXX-XXXX-XXXX',
|
||
okText: 'Recover',
|
||
password: true,
|
||
});
|
||
if (!code) return;
|
||
|
||
let r;
|
||
try {
|
||
r = await api('/recovery-key/redeem', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ username: u.trim(), code: code.trim() }),
|
||
});
|
||
} catch (err) {
|
||
if (err.status === 429 && err.body && err.body.retry_after) {
|
||
return showLockoutCountdown(err.body.retry_after);
|
||
}
|
||
return toast('Recovery failed: ' + (err.message || 'invalid code'), 'error');
|
||
}
|
||
|
||
// Unwrap the vault key with the code the user just typed.
|
||
let rawKey;
|
||
try {
|
||
rawKey = await unwrapAesKeyFromRecovery(
|
||
r.wrappedKey, r.wrappedIv, code.trim(), r.kdfSalt);
|
||
} catch (e) {
|
||
return toast('Could not decrypt vault — wrong code?', 'error');
|
||
}
|
||
|
||
// Reconstitute state from the new session.
|
||
state.token = r.token;
|
||
state.csrf = r.csrfToken;
|
||
state.salt = r.salt;
|
||
state.username = u.trim();
|
||
state.kdfIterations = r.kdfIterations || 600000;
|
||
// Account's auth scheme — needed so the recovery-mode master-pw change
|
||
// proves the current key under the right verifier transform.
|
||
state.hashAlgo = r.hashAlgo || '';
|
||
state.argon2Params = r.argon2 || null;
|
||
sessionStorage.setItem('authToken', state.token);
|
||
sessionStorage.setItem('csrfToken', state.csrf);
|
||
sessionStorage.setItem('salt', state.salt);
|
||
sessionStorage.setItem('username', state.username);
|
||
sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
|
||
sessionStorage.setItem('hashAlgo', state.hashAlgo);
|
||
|
||
// Import the raw key bytes as a fresh AES-GCM CryptoKey (extractable
|
||
// so master-pw change can later re-export and re-wrap as needed).
|
||
state.cryptoKey = await crypto.subtle.importKey(
|
||
'raw', rawKey, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']);
|
||
await persistCryptoKey();
|
||
|
||
const remaining = (typeof r.remainingUses === 'number') ? r.remainingUses : 0;
|
||
if (remaining <= 0) {
|
||
toast('Last recovery use — set a new master password now or the code is gone forever', 'warning');
|
||
} else {
|
||
toast('Recovery code used. ' + remaining + ' use(s) left before it expires. Change your master password now.', 'warning');
|
||
}
|
||
state.justRecovered = true;
|
||
await enterApp();
|
||
|
||
setTimeout(openChangeMasterModal, 300);
|
||
}
|
||
|
||
// ============================================================
|
||
// CHANGE MASTER PASSWORD
|
||
// ============================================================
|
||
//
|
||
// Two-step flow:
|
||
// 1. Modal collects current pw + new pw (×2) + validates locally.
|
||
// 2. Client re-encrypts every entry under the new key (derived from
|
||
// new pw + freshly-generated salt) and sends the whole payload to
|
||
// /change-master-password. Server verifies current pw, then commits
|
||
// user row + all entry ciphertexts in one transaction.
|
||
// On success: state.salt + state.cryptoKey are swapped, the user stays
|
||
// logged in (current session preserved), all OTHER sessions invalidated.
|
||
|
||
// 64-char hex salt — matches the format the server expects and the shape
|
||
// produced by Delphi RandomHex(32). Crypto-secure RNG.
|
||
function randomHexSalt() {
|
||
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
||
let hex = '';
|
||
for (const b of bytes) hex += b.toString(16).padStart(2, '0');
|
||
return hex;
|
||
}
|
||
|
||
function openChangeMasterModal() {
|
||
$('#cmCurrentPwd').value = '';
|
||
$('#cmNewPwd').value = '';
|
||
$('#cmConfirmPwd').value = '';
|
||
const errEl = $('#cmError');
|
||
if (errEl) { errEl.textContent = ''; errEl.style.display = 'none'; }
|
||
|
||
const curInput = $('#cmCurrentPwd');
|
||
const curWrap = curInput.closest('.field') || curInput.parentElement;
|
||
const titleEl = $('#changeMasterTitle');
|
||
if (state.justRecovered) {
|
||
if (curWrap) curWrap.classList.add('is-hidden');
|
||
if (curInput) curInput.required = false;
|
||
if (titleEl) titleEl.textContent = 'Set new master password';
|
||
} else {
|
||
if (curWrap) curWrap.classList.remove('is-hidden');
|
||
if (curInput) curInput.required = true;
|
||
if (titleEl) titleEl.textContent = 'Change master password';
|
||
}
|
||
|
||
$('#changeMasterModal').classList.remove('is-hidden');
|
||
setTimeout(() => {
|
||
if (state.justRecovered) $('#cmNewPwd').focus();
|
||
else $('#cmCurrentPwd').focus();
|
||
}, 50);
|
||
}
|
||
|
||
function closeChangeMasterModal() {
|
||
$('#changeMasterModal').classList.add('is-hidden');
|
||
}
|
||
|
||
function showCmError(msg) {
|
||
const el = $('#cmError');
|
||
if (!el) return;
|
||
el.textContent = msg;
|
||
el.style.display = '';
|
||
}
|
||
|
||
async function doChangeMasterPassword() {
|
||
const recoveryMode = !!state.justRecovered;
|
||
const curPwd = $('#cmCurrentPwd').value;
|
||
const newPwd = $('#cmNewPwd').value;
|
||
const confPwd = $('#cmConfirmPwd').value;
|
||
|
||
if (!recoveryMode && !curPwd) return showCmError('All fields are required');
|
||
if (!newPwd || !confPwd) return showCmError('All fields are required');
|
||
if (newPwd.length < 8) return showCmError('New password must be at least 8 characters');
|
||
if (newPwd !== confPwd) return showCmError('New password and confirmation do not match');
|
||
if (!recoveryMode && newPwd === curPwd) return showCmError('New password must differ from the current one');
|
||
|
||
// Disable the confirm button so a double-click doesn't fire two
|
||
// re-encryption passes in parallel.
|
||
const btn = $('#cmConfirmBtn');
|
||
if (btn) btn.disabled = true;
|
||
// Rotation re-encrypts every entry (and every attachment) under the new
|
||
// key — seconds to tens of seconds on a big vault. Show a spinner so it
|
||
// doesn't look frozen; 0ms yield lets it paint before the loop blocks.
|
||
showBusy('Re-encrypting vault…');
|
||
await new Promise(r => setTimeout(r, 0));
|
||
try {
|
||
// Step 1: generate the new salt and derive the new AES key + verifier.
|
||
// Also compute the verifier for the CURRENT pw so the server can
|
||
// authenticate the change without ever seeing the plaintext.
|
||
const newSalt = randomHexSalt();
|
||
// Rotate onto Argon2id (memory-hard KDF) + decoupled verifier — a
|
||
// master-pw change re-derives + re-encrypts everything anyway, so it's
|
||
// the natural migration point for existing PBKDF2 accounts.
|
||
const newArgon = { ...ARGON2_DEFAULT_PARAMS };
|
||
const newDerived = await deriveKeyAndVerifier(
|
||
newPwd, newSalt, 600000, HASH_ALGO_ARGON2, newArgon);
|
||
const newKey = newDerived.cryptoKey;
|
||
let currentVerifier;
|
||
if (recoveryMode) {
|
||
// Current pw is proven via the in-memory recovered key. The
|
||
// server compares under the account's CURRENT algo, so apply the
|
||
// same verifier transform (v2 → decoupled, else → key hex).
|
||
const rawCurrentKey = new Uint8Array(await crypto.subtle.exportKey('raw', state.cryptoKey));
|
||
currentVerifier = await verifierFromKeyHex(bytesToHex(rawCurrentKey), state.hashAlgo);
|
||
} else {
|
||
currentVerifier = await computeVerifier(
|
||
curPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params);
|
||
}
|
||
|
||
// Step 2: re-encrypt every entry's password AND every entry's TOTP
|
||
// secret (if present) under the new key. The current state.cryptoKey
|
||
// still decrypts the existing ciphertext.
|
||
// Hoisted so the attachment re-encryption loop after the server
|
||
// round-trip can still reach the OLD key.
|
||
const oldKey = state.cryptoKey;
|
||
const encrypted = [];
|
||
let _cmDone = 0;
|
||
const _cmTotal = state.entries.length;
|
||
for (const e of state.entries) {
|
||
_cmDone++;
|
||
if (_cmTotal > 10 && (_cmDone % 5 === 0 || _cmDone === _cmTotal))
|
||
updateBusy('Re-encrypting entries… ' + _cmDone + '/' + _cmTotal);
|
||
const plain = await decryptPwd(e.encrypted_password, e.iv);
|
||
if (plain === '[ERROR]') {
|
||
throw new Error('Could not decrypt entry id=' + e.id);
|
||
}
|
||
// Swap the key around encryptPwd so it picks up the new one.
|
||
state.cryptoKey = newKey;
|
||
try {
|
||
const re = await encryptPwd(plain);
|
||
let totpEnc = '', totpIv = '';
|
||
if (e.totp_secret && e.totp_iv) {
|
||
state.cryptoKey = oldKey;
|
||
const plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv);
|
||
state.cryptoKey = newKey;
|
||
if (plainTotp !== '[ERROR]') {
|
||
const t = await encryptPwd(plainTotp);
|
||
totpEnc = t.encrypted;
|
||
totpIv = t.iv;
|
||
}
|
||
}
|
||
// Custom fields — same dance: decrypt with OLD key, encrypt
|
||
// with NEW key, send fresh ciphertext.
|
||
let cfEnc = '', cfIv = '';
|
||
if (e.custom_fields && e.custom_fields_iv) {
|
||
state.cryptoKey = oldKey;
|
||
const plainCf = await decryptCustomFields(
|
||
e.custom_fields, e.custom_fields_iv);
|
||
state.cryptoKey = newKey;
|
||
if (plainCf.length > 0) {
|
||
const c = await encryptCustomFields(plainCf);
|
||
cfEnc = c.encrypted;
|
||
cfIv = c.iv;
|
||
}
|
||
}
|
||
// Encrypted 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 (Ctrl+Shift+L global hotkey)
|
||
// ============================================================
|
||
|
||
// Win32 modifier flags for RegisterHotKey.
|
||
const WIN32_MOD = { alt: 0x0001, ctrl: 0x0002, shift: 0x0004, win: 0x0008 };
|
||
|
||
// Format a combo for human display: "Ctrl+Shift+L".
|
||
function autofillComboLabel(c) {
|
||
if (!c || !c.key) return '— not set —';
|
||
const parts = [];
|
||
if (c.ctrl) parts.push('Ctrl');
|
||
if (c.alt) parts.push('Alt');
|
||
if (c.shift) parts.push('Shift');
|
||
if (c.win) parts.push('Win');
|
||
parts.push(c.key);
|
||
return parts.join('+');
|
||
}
|
||
|
||
// Convert a combo to the Win32 (mods bitmask, virtual-key code) pair that
|
||
// Delphi's RegisterHotKey takes. key='A'..'Z'/'0'..'9' → ASCII code;
|
||
// 'F1'..'F12' → 0x70..0x7B.
|
||
function autofillComboToWin32(c) {
|
||
let mods = 0;
|
||
if (c.ctrl) mods |= WIN32_MOD.ctrl;
|
||
if (c.alt) mods |= WIN32_MOD.alt;
|
||
if (c.shift) mods |= WIN32_MOD.shift;
|
||
if (c.win) mods |= WIN32_MOD.win;
|
||
let vk = 0;
|
||
const k = (c.key || '').toUpperCase();
|
||
if (/^F([1-9]|1[0-2])$/.test(k)) vk = 0x70 + parseInt(k.slice(1)) - 1;
|
||
else if (k.length === 1 && k >= 'A' && k <= 'Z') vk = k.charCodeAt(0);
|
||
else if (k.length === 1 && k >= '0' && k <= '9') vk = k.charCodeAt(0);
|
||
return { mods, vk };
|
||
}
|
||
|
||
// Validate a captured combo. Requires at least one modifier (otherwise a
|
||
// single key would steal that letter globally) and a valid main key.
|
||
function autofillComboValid(c) {
|
||
if (!c) return false;
|
||
if (!(c.ctrl || c.alt || c.win)) return false; // shift-only is unreliable
|
||
const w = autofillComboToWin32(c);
|
||
return w.vk !== 0;
|
||
}
|
||
|
||
// Capture a key combo from a single keydown event. Returns null if the
|
||
// event is "incomplete" (only modifiers pressed so far) or Escape.
|
||
function autofillCaptureFromEvent(e) {
|
||
const k = e.key;
|
||
if (k === 'Escape') return 'cancel';
|
||
// Ignore pure-modifier keydowns (user is still building the combo).
|
||
if (k === 'Control' || k === 'Shift' || k === 'Alt' ||
|
||
k === 'Meta' || k === 'OS') return null;
|
||
// Accept letter, digit, F1-F12.
|
||
let key = null;
|
||
if (k.length === 1 && /[a-z0-9]/i.test(k)) {
|
||
key = k.toUpperCase();
|
||
} else if (/^F([1-9]|1[0-2])$/i.test(k)) {
|
||
key = k.toUpperCase();
|
||
} else {
|
||
return 'invalid';
|
||
}
|
||
return {
|
||
ctrl: !!e.ctrlKey,
|
||
shift: !!e.shiftKey,
|
||
alt: !!e.altKey,
|
||
win: !!e.metaKey,
|
||
key,
|
||
};
|
||
}
|
||
|
||
// Push current state to Delphi (toggle + both combos) and persist.
|
||
// Called after any change so Delphi's RegisterHotKey reflects state.
|
||
function autofillPushHotkeys() {
|
||
localStorage.setItem('autofillHotkeyFull', JSON.stringify(state.autofillHotkeyFull));
|
||
localStorage.setItem('autofillHotkeyPwd', JSON.stringify(state.autofillHotkeyPwd));
|
||
localStorage.setItem('quickSearchHotkey', JSON.stringify(state.quickSearchHotkey));
|
||
if (Bridge.active) {
|
||
Bridge.setAutofillHotkeys(state.autofillEnabled, {
|
||
full: state.autofillHotkeyFull,
|
||
password: state.autofillHotkeyPwd,
|
||
quickSearch: state.quickSearchHotkey,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Extract a bare hostname from a site string for fuzzy matching.
|
||
// "https://www.github.com/login" → "github.com"
|
||
// Strip the browser brand suffix that lives at the end of every tab title
|
||
// ("Some Page - Google Chrome", "Page — Mozilla Firefox", etc.). Without
|
||
// this, entries whose site is "google" / "mozilla" / "edge" would match
|
||
// every single page that has Chrome / Firefox / Edge as the browser brand.
|
||
const BROWSER_SUFFIX_RE =
|
||
/\s*[-—–|]\s*(google chrome|chromium|mozilla firefox|firefox|microsoft edge|edge|brave|opera|vivaldi|safari|tor browser|tor|arc)\s*$/i;
|
||
|
||
function autofillStripBrowserSuffix(title) {
|
||
return (title || '').replace(BROWSER_SUFFIX_RE, '').trim();
|
||
}
|
||
|
||
function autofillExtractHost(site) {
|
||
return site.toLowerCase()
|
||
.replace(/^https?:\/\//i, '')
|
||
.replace(/^www\./i, '')
|
||
.split('/')[0]
|
||
.split(':')[0];
|
||
}
|
||
|
||
// Get the second-level domain (brand part) from a hostname.
|
||
// "github.com" → "github" ; "mail.google.com" → "google" ; "x.com" → "x"
|
||
function autofillSLD(host) {
|
||
const parts = host.split('.').filter(p => p.length > 0);
|
||
if (parts.length <= 1) return host;
|
||
return parts[parts.length - 2];
|
||
}
|
||
|
||
// Escape a string for safe insertion into a RegExp.
|
||
function autofillEscapeRegex(s) {
|
||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
}
|
||
|
||
// Score how well a vault entry matches the foreground window title.
|
||
// Returns 0 (no match) or a positive integer (higher = better).
|
||
//
|
||
// Strategy (browser titles rarely contain the full hostname — usually
|
||
// just the brand name, e.g. "Sign in to GitHub" or "X. C'est… - Google Chrome"):
|
||
// 1. Full hostname substring → strongest (score 1000 + len)
|
||
// 2. SLD ≥3 chars as substring → medium (score 500 + len)
|
||
// 3. SLD <3 chars as word → weak (score 100), requires word
|
||
// boundaries to avoid matching "x" inside arbitrary words.
|
||
function autofillScore(entry, titleLower) {
|
||
// 1. Display name (entry.title) lowercased substring — strongest brand
|
||
// match. Skips when title is empty or same as site (already tested
|
||
// via the site path below).
|
||
const displayTitle = (entry.title || '').trim().toLowerCase();
|
||
if (displayTitle.length >= 2 && titleLower.includes(displayTitle))
|
||
return 1500 + displayTitle.length;
|
||
|
||
if (!entry.site) return 0;
|
||
const host = autofillExtractHost(entry.site);
|
||
if (host.length < 2) return 0;
|
||
|
||
// 2. Full hostname (rare in tab titles, but strongest URL signal)
|
||
if (titleLower.includes(host)) return 1000 + host.length;
|
||
|
||
// 3/4. Second-level domain
|
||
const sld = autofillSLD(host);
|
||
if (sld.length === 0) return 0;
|
||
|
||
if (sld.length >= 3) {
|
||
if (titleLower.includes(sld)) return 500 + sld.length;
|
||
return 0;
|
||
}
|
||
|
||
// Short SLD ("x", "qq", "vk"…) — require word boundaries so we don't
|
||
// match the letter inside random words.
|
||
const re = new RegExp('(^|[^a-z0-9])' + autofillEscapeRegex(sld) +
|
||
'([^a-z0-9]|$)', 'i');
|
||
if (re.test(titleLower)) return 100;
|
||
|
||
return 0;
|
||
}
|
||
|
||
// Called by Bridge.onAutofillRequest when a hotkey fires.
|
||
// kind: 'full' = Ctrl+Shift+L (user + Tab + pwd) ; 'password' = Ctrl+Shift+P.
|
||
async function autofillHandleRequest(windowTitle, kind) {
|
||
if (!state.autofillEnabled) return;
|
||
|
||
if (!state.cryptoKey || state.locked || !state.token) {
|
||
// Vault is locked — bring the app to the front so the user can
|
||
// unlock immediately, rather than silently no-op'ing the hotkey.
|
||
Bridge.cancelAutofill();
|
||
Bridge.focusApp();
|
||
setTimeout(() => {
|
||
const pwd = document.getElementById('loginPassword');
|
||
const user = document.getElementById('loginUsername');
|
||
if (pwd && !document.getElementById('authScreen').classList.contains('is-hidden')) {
|
||
if (user && !user.value) user.focus();
|
||
else pwd.focus();
|
||
}
|
||
}, 80);
|
||
toast('Vault is locked — unlock to autofill', 'warning');
|
||
return;
|
||
}
|
||
|
||
const titleLower = autofillStripBrowserSuffix(windowTitle).toLowerCase();
|
||
const scored = state.entries
|
||
.map(e => ({ entry: e, score: autofillScore(e, titleLower) }))
|
||
.filter(x => x.score > 0)
|
||
.sort((a, b) => b.score - a.score);
|
||
|
||
if (scored.length === 0) {
|
||
toast('Autofill: no match for "' + windowTitle.slice(0, 40) + '"', 'warning');
|
||
Bridge.cancelAutofill();
|
||
return;
|
||
}
|
||
|
||
if (scored.length === 1) {
|
||
await autofillFillEntry(scored[0].entry, kind);
|
||
return;
|
||
}
|
||
|
||
// Multiple candidates — show picker. kind is captured so clicking a
|
||
// candidate honours password-only mode.
|
||
openAutofillPicker(scored.map(x => x.entry), windowTitle, kind);
|
||
}
|
||
|
||
// Decrypt and type an entry. kind = 'full' or 'password'.
|
||
async function autofillFillEntry(entry, kind) {
|
||
const password = await decryptPwd(entry.encrypted_password, entry.iv);
|
||
if (password === '[ERROR]') {
|
||
toast('Autofill: decryption error', 'error');
|
||
Bridge.cancelAutofill();
|
||
return;
|
||
}
|
||
// password-only kind → empty username → Delphi skips Tab.
|
||
// full kind with empty entry.username → also no Tab (Delphi handles it).
|
||
const user = (kind === 'password') ? '' : (entry.username || '');
|
||
Bridge.executeAutofill(user, password);
|
||
toast((kind === 'password' ? 'Password filled: ' : 'Autofilled: ') + entry.site);
|
||
// Audit (best-effort, ignore failures)
|
||
fetch('' + '/audit', {
|
||
method: 'POST',
|
||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||
body: JSON.stringify({
|
||
action: kind === 'password' ? 'autofill_pwd' : 'autofill',
|
||
site: entry.site,
|
||
}),
|
||
}).catch(() => {});
|
||
}
|
||
|
||
// Picker modal for multi-match case. kind is forwarded to autofillFillEntry
|
||
// so the user's hotkey intent (full vs password-only) is preserved through
|
||
// the manual choice.
|
||
function openAutofillPicker(entries, windowTitle, kind) {
|
||
// Bring the app to front so the picker is unambiguously visible —
|
||
// otherwise the modal opens behind / next to the user's original
|
||
// window (e.g. Notepad) and easy to miss. ExecuteAutofill restores
|
||
// the original target HWND via ForceForegroundWindow on selection.
|
||
Bridge.focusApp();
|
||
|
||
const list = $('#autofillPickerList');
|
||
list.innerHTML = '';
|
||
entries.forEach(e => {
|
||
const btn = el('button', {
|
||
class: 'autofill-pick-btn',
|
||
on: {
|
||
click: async () => {
|
||
closeAutofillPicker(false);
|
||
await autofillFillEntry(e, kind);
|
||
},
|
||
},
|
||
});
|
||
btn.appendChild(el('span', { class: 'autofill-pick-site' }, entryDisplayName(e)));
|
||
if (e.username) {
|
||
btn.appendChild(el('span', { class: 'autofill-pick-user' }, e.username));
|
||
}
|
||
list.appendChild(btn);
|
||
});
|
||
const head = (kind === 'password' ? 'Pick entry (password only) — ' : 'Pick entry — ')
|
||
+ entries.length + ' match "' + windowTitle.slice(0, 30) + '…"';
|
||
$('#autofillPickerTitle').textContent = head;
|
||
$('#autofillPickerModal').classList.remove('is-hidden');
|
||
}
|
||
|
||
function closeAutofillPicker(notifyCancel = true) {
|
||
$('#autofillPickerModal').classList.add('is-hidden');
|
||
if (notifyCancel) Bridge.cancelAutofill();
|
||
}
|
||
|
||
// ============================================================
|
||
// VIEWS / NAV
|
||
// ============================================================
|
||
|
||
async function setView(v) {
|
||
state.view = v;
|
||
state.currentPage = 1;
|
||
if (v === 'trash') {
|
||
await loadTrash();
|
||
}
|
||
render();
|
||
}
|
||
|
||
function toggleTheme() {
|
||
setTheme(state.theme === 'dark' ? 'light' : 'dark');
|
||
}
|
||
function setTheme(t) {
|
||
state.theme = t;
|
||
document.documentElement.setAttribute('data-theme', t);
|
||
localStorage.setItem('theme', t);
|
||
const sel = $('#settingTheme');
|
||
if (sel) sel.value = t;
|
||
Bridge.syncTitleBarTheme(t);
|
||
}
|
||
|
||
function openSettings() {
|
||
$('#settingTheme').value = state.theme;
|
||
$('#settingSort').value = state.sortBy + ':' + state.sortDir;
|
||
$('#settingAutoLock').value = String(state.autoLock);
|
||
$('#settingAskDelete').checked = state.askBeforeDelete;
|
||
$('#settingCompact').checked = state.compactActions;
|
||
$('#settingMaskUser').checked = state.maskUsernames;
|
||
$('#settingHIBP').checked = state.hibpEnabled;
|
||
$('#settingShowSite').checked = state.showSiteOnCards;
|
||
$('#settingFavicons').checked = state.faviconsEnabled;
|
||
// Action buttons + toggle row only meaningful when the Delphi bridge
|
||
// is available (the PHP frontend has no outbound proxy).
|
||
$('#settingFaviconsRow').style.display = Bridge.active ? '' : 'none';
|
||
$('#settingFaviconActionsRow').style.display = Bridge.active ? 'flex' : 'none';
|
||
$('#settingAutofill').checked = state.autofillEnabled;
|
||
$('#settingAutofillRow').style.display = Bridge.active ? '' : 'none';
|
||
// Hotkey capture buttons — labels reflect current combos.
|
||
$('#settingAutofillFullCombo').textContent = autofillComboLabel(state.autofillHotkeyFull);
|
||
$('#settingAutofillPwdCombo').textContent = autofillComboLabel(state.autofillHotkeyPwd);
|
||
$('#settingQuickSearchCombo').textContent = autofillComboLabel(state.quickSearchHotkey);
|
||
$('#settingAutofillHotkeysRow').style.display = Bridge.active ? '' : 'none';
|
||
// Start-with-Windows toggle: only meaningful inside the Delphi host
|
||
// (registry access). Hide for the PHP frontend.
|
||
$('#settingAutoStartRow').style.display = Bridge.active ? '' : 'none';
|
||
if (Bridge.active) {
|
||
// Fire-and-forget: the bridge response updates the checkbox via
|
||
// Bridge.onAutoStartStatus.
|
||
Bridge.getAutoStart();
|
||
}
|
||
$('#settingTrayNotif').checked = state.trayNotificationsEnabled !== false;
|
||
$('#settingTrayNotifRow').style.display = Bridge.active ? '' : 'none';
|
||
$('#settingTrashPurge').value = String(state.trashAutoPurgeDays || 0);
|
||
$('#settingPasswordExpiry').value = String(state.passwordExpiryDays || 0);
|
||
$('#settingEditorPosition').value = state.editorPosition || 'right';
|
||
$('#settingConfirmUnsaved').checked = state.confirmOnUnsaved !== false;
|
||
// PIN unlock — only meaningful when DPAPI is available.
|
||
const pinField = document.getElementById('pinUnlockField');
|
||
if (pinField) {
|
||
pinField.style.display = Bridge.active ? '' : 'none';
|
||
if (Bridge.active) bridgePinStatus().then(has => {
|
||
state.pinConfigured = has;
|
||
refreshPinUnlockUI();
|
||
});
|
||
}
|
||
$('#settingUser').textContent = state.username;
|
||
renderUserAvatar(); // sync the Account-section preview + Remove button
|
||
// Hide the version row entirely in the PHP/web frontend (no bridge).
|
||
if (Bridge.active) {
|
||
$('#settingVersionRow').style.display = '';
|
||
Promise.all([Bridge.getAppVersion(), Bridge.getLaunchMode()]).then(([v, mode]) => {
|
||
const tag = mode === 'auto' ? ' · started with Windows'
|
||
: mode === 'manual' ? ' · launched manually'
|
||
: '';
|
||
$('#settingVersion').textContent = (v ? 'v' + v : '(unknown)') + tag;
|
||
});
|
||
} else {
|
||
$('#settingVersionRow').style.display = 'none';
|
||
}
|
||
// Async: query server for recovery key state and update the label
|
||
refreshRecoveryStatus();
|
||
|
||
// Sync the quick-unlock UI from the actual DPAPI file (in case the
|
||
// user cleared the file outside the app or the file is gone for some
|
||
// other reason like a Windows profile reset).
|
||
if (Bridge.active) {
|
||
bridgeQuickUnlockStatus().then(actual => {
|
||
if (!actual && state.quickUnlockEnabled) {
|
||
// Drift: localStorage said enabled but Delphi has no blob.
|
||
localStorage.removeItem('quickUnlockEnabled');
|
||
state.quickUnlockEnabled = false;
|
||
}
|
||
updateQuickUnlockUI();
|
||
});
|
||
} else {
|
||
updateQuickUnlockUI();
|
||
}
|
||
|
||
// Auto-backup: only meaningful with the Delphi bridge (filesystem).
|
||
if (Bridge.active) {
|
||
loadAutoBackupConfig().then(refreshAutoBackupUI);
|
||
} else {
|
||
refreshAutoBackupUI(null);
|
||
}
|
||
// Sync: same gate (Bridge required for WebDAV HTTP + DPAPI prefs).
|
||
const syncField = document.getElementById('syncField');
|
||
if (syncField) {
|
||
syncField.style.display = Bridge.active ? '' : 'none';
|
||
if (Bridge.active) loadSyncConfig().then(refreshSyncUI);
|
||
}
|
||
|
||
$('#settingsPanel').classList.add('is-open');
|
||
// Reset the search filter every time Settings is re-opened so the
|
||
// user lands on the full panel, not the last filtered view.
|
||
const si = $('#settingsSearch');
|
||
if (si) { si.value = ''; applySettingsSearch(''); }
|
||
}
|
||
function closeSettings() {
|
||
$('#settingsPanel').classList.remove('is-open');
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
|
||
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',
|
||
];
|
||
|
||
// Sets `data-editor-position` on <body> so CSS can swap the slideover
|
||
// between right / left / centered. No JS-side render changes — same DOM,
|
||
// same JS, just different CSS.
|
||
function applyEditorPosition() {
|
||
const pos = ['right', 'left', 'center'].includes(state.editorPosition)
|
||
? state.editorPosition : 'right';
|
||
document.body.setAttribute('data-editor-position', pos);
|
||
}
|
||
|
||
function applySidebarCollapsed() {
|
||
const s = state.sidebarCollapsed || {};
|
||
document.querySelectorAll('.sidebar-section[data-section]').forEach(sec => {
|
||
const k = sec.getAttribute('data-section');
|
||
sec.classList.toggle('is-collapsed', !!s[k]);
|
||
});
|
||
}
|
||
|
||
async function loadServerSettings() {
|
||
try {
|
||
const r = await fetch(API + '/settings', { headers: authHeaders() });
|
||
if (!r.ok) return;
|
||
const remote = await r.json();
|
||
// Merge remote into state (remote wins). localStorage is updated
|
||
// too so first-paint on next reload uses the synced value.
|
||
SYNCED_SETTING_KEYS.forEach(k => {
|
||
if (!(k in remote)) return;
|
||
const v = remote[k];
|
||
state[k] = v;
|
||
switch (k) {
|
||
case 'theme': localStorage.setItem('theme', v); break;
|
||
case 'autoLock': localStorage.setItem('autoLockMin', String(v)); break;
|
||
case 'askBeforeDelete': localStorage.setItem('askBeforeDelete', v ? '1' : '0'); break;
|
||
case 'maskUsernames': localStorage.setItem('maskUsernames', v ? '1' : '0'); break;
|
||
case 'compactActions': localStorage.setItem('compactActions', v ? '1' : '0'); break;
|
||
case 'viewMode': localStorage.setItem('viewMode', v); break;
|
||
case 'hibpEnabled': localStorage.setItem('hibpEnabled', v ? '1' : '0'); break;
|
||
case 'showSiteOnCards': localStorage.setItem('showSiteOnCards', v ? '1' : '0'); break;
|
||
case 'sortBy': localStorage.setItem('sortBy', String(v)); break;
|
||
case 'sortDir': localStorage.setItem('sortDir', String(v)); break;
|
||
case 'pageSize': localStorage.setItem('pageSize', String(v)); break;
|
||
case 'autofillHotkeyFull':
|
||
case 'autofillHotkeyPwd':
|
||
case 'quickSearchHotkey':
|
||
case 'sidebarCollapsed':
|
||
// Object; persist as JSON so the next cold start picks it up.
|
||
localStorage.setItem(k, JSON.stringify(v));
|
||
break;
|
||
case 'faviconsEnabled':
|
||
localStorage.setItem('faviconsEnabled', v ? '1' : '0');
|
||
break;
|
||
case 'trayNotificationsEnabled':
|
||
localStorage.setItem('trayNotificationsEnabled', v ? '1' : '0');
|
||
// Push the synced value to Delphi so the bridge honours
|
||
// it from this point on (the user may have flipped it
|
||
// on another device).
|
||
if (Bridge.active && typeof Bridge.setTrayNotifications === 'function')
|
||
Bridge.setTrayNotifications(v);
|
||
break;
|
||
case 'trashAutoPurgeDays':
|
||
localStorage.setItem('trashAutoPurgeDays', String(v));
|
||
break;
|
||
case 'passwordExpiryDays':
|
||
localStorage.setItem('passwordExpiryDays', String(v));
|
||
break;
|
||
case 'tableColsHidden':
|
||
localStorage.setItem('tableColsHidden',
|
||
JSON.stringify(Array.isArray(v) ? v : []));
|
||
break;
|
||
case 'editorPosition':
|
||
localStorage.setItem('editorPosition', String(v || 'right'));
|
||
applyEditorPosition();
|
||
break;
|
||
case 'confirmOnUnsaved':
|
||
localStorage.setItem('confirmOnUnsaved', v ? '1' : '0');
|
||
break;
|
||
case 'unlockMode':
|
||
localStorage.setItem('unlockMode', String(v || 'pw'));
|
||
break;
|
||
}
|
||
});
|
||
// Apply visual settings immediately.
|
||
if (remote.theme) setTheme(remote.theme);
|
||
if ('sidebarCollapsed' in remote) applySidebarCollapsed();
|
||
} catch (e) {
|
||
// Network/server hiccup is harmless — localStorage cache still works.
|
||
}
|
||
}
|
||
|
||
let _settingsSaveTimer = null;
|
||
function saveServerSettings() {
|
||
// Debounce: collapse rapid toggles (e.g. user playing with the theme
|
||
// dropdown) into a single PUT.
|
||
if (_settingsSaveTimer) clearTimeout(_settingsSaveTimer);
|
||
_settingsSaveTimer = setTimeout(async () => {
|
||
_settingsSaveTimer = null;
|
||
const payload = {};
|
||
SYNCED_SETTING_KEYS.forEach(k => { payload[k] = state[k]; });
|
||
try {
|
||
await fetch(API + '/settings', {
|
||
method: 'PUT',
|
||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||
body: JSON.stringify(payload),
|
||
});
|
||
} catch (e) {
|
||
// Silent — next change will retry.
|
||
}
|
||
}, 400);
|
||
}
|
||
|
||
// ============================================================
|
||
// INIT
|
||
// ============================================================
|
||
|
||
function installCustomContextMenu() {
|
||
const menu = el('div', { class: 'custom-ctxmenu is-hidden' });
|
||
document.body.appendChild(menu);
|
||
|
||
function isEditable(node) {
|
||
if (!node) return false;
|
||
const tag = node.tagName;
|
||
if (tag === 'INPUT') return !['button','submit','checkbox','radio','range','color','file'].includes(node.type);
|
||
if (tag === 'TEXTAREA') return true;
|
||
if (node.isContentEditable) return true;
|
||
return false;
|
||
}
|
||
|
||
function hide() { menu.classList.add('is-hidden'); }
|
||
|
||
function buildItems(target) {
|
||
menu.innerHTML = '';
|
||
const isInput = isEditable(target);
|
||
const hasSelection = isInput && target.selectionStart !== target.selectionEnd;
|
||
const items = [
|
||
{ lbl: 'Cut', on: hasSelection, fn: () => doCut(target) },
|
||
{ lbl: 'Copy', on: hasSelection, fn: () => doCopy(target) },
|
||
{ lbl: 'Paste', on: isInput && !target.readOnly, fn: () => doPaste(target) },
|
||
{ lbl: 'Select all', on: isInput, fn: () => target.select() },
|
||
];
|
||
items.forEach(it => {
|
||
const mi = el('button', {
|
||
class: 'custom-ctxmenu-item' + (it.on ? '' : ' is-disabled'),
|
||
type: 'button',
|
||
on: { click: ev => {
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
if (!it.on) return;
|
||
hide();
|
||
it.fn();
|
||
} },
|
||
}, it.lbl);
|
||
menu.appendChild(mi);
|
||
});
|
||
}
|
||
|
||
async function doCopy(target) {
|
||
const sel = target.value.slice(target.selectionStart, target.selectionEnd);
|
||
try { await navigator.clipboard.writeText(sel); } catch (e) {}
|
||
}
|
||
async function doCut(target) {
|
||
await doCopy(target);
|
||
const s = target.selectionStart, e = target.selectionEnd;
|
||
target.value = target.value.slice(0, s) + target.value.slice(e);
|
||
target.selectionStart = target.selectionEnd = s;
|
||
target.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}
|
||
async function doPaste(target) {
|
||
const txt = Bridge.active ? await bridgeReadClipboard()
|
||
: await navigator.clipboard.readText().catch(() => '');
|
||
if (!txt) return;
|
||
const s = target.selectionStart, e = target.selectionEnd;
|
||
target.value = target.value.slice(0, s) + txt + target.value.slice(e);
|
||
target.selectionStart = target.selectionEnd = s + txt.length;
|
||
target.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}
|
||
|
||
document.addEventListener('contextmenu', ev => {
|
||
ev.preventDefault();
|
||
if (!isEditable(ev.target)) { hide(); return; }
|
||
buildItems(ev.target);
|
||
menu.classList.remove('is-hidden');
|
||
const vw = window.innerWidth, vh = window.innerHeight;
|
||
const mw = menu.offsetWidth || 160, mh = menu.offsetHeight || 140;
|
||
const x = Math.min(ev.clientX, vw - mw - 4);
|
||
const y = Math.min(ev.clientY, vh - mh - 4);
|
||
menu.style.left = x + 'px';
|
||
menu.style.top = y + 'px';
|
||
});
|
||
document.addEventListener('mousedown', ev => {
|
||
if (!ev.target.closest('.custom-ctxmenu')) hide();
|
||
});
|
||
document.addEventListener('keydown', ev => {
|
||
if (ev.key === 'Escape') hide();
|
||
});
|
||
window.addEventListener('blur', hide);
|
||
}
|
||
|
||
async function init() {
|
||
document.documentElement.setAttribute('data-theme', state.theme);
|
||
|
||
if (location.search.indexOf('pmt=') !== -1) {
|
||
history.replaceState(null, '', location.pathname + location.hash);
|
||
}
|
||
|
||
installCustomContextMenu();
|
||
document.addEventListener('keydown', e => {
|
||
const mod = e.ctrlKey || e.metaKey;
|
||
if (e.key === 'F12' || e.key === 'F5') return e.preventDefault();
|
||
if (mod && e.shiftKey && /^[ijIJ]$/.test(e.key)) return e.preventDefault(); // DevTools
|
||
if (mod && /^[uUjJhHsSpPtTnNrR]$/.test(e.key)) return e.preventDefault(); // View source / Downloads / History / Save / Print / New tab+win / Reload
|
||
if (mod && e.shiftKey && /^[nNwW]$/.test(e.key)) return e.preventDefault(); // New incognito / Close window
|
||
if (mod && e.shiftKey && e.key === 'Delete') return e.preventDefault(); // Clear browsing data
|
||
});
|
||
document.addEventListener('keydown', handleCardCursorKey);
|
||
|
||
// Delete / Backspace on the grid (no input focused, no modal open)
|
||
// triggers the batch action matching the current view: soft-trash for
|
||
// normal views, permanent-delete for the trash view. Mirrors what the
|
||
// batch bar does, just via keyboard.
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
|
||
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
||
const tag = (e.target && e.target.tagName || '').toLowerCase();
|
||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
|
||
if (e.target && e.target.isContentEditable) return;
|
||
if (!$('#appShell') || $('#appShell').classList.contains('is-hidden')) return;
|
||
if (document.querySelector('.modal:not(.is-hidden)')) return;
|
||
if ($('#slideover') && $('#slideover').classList.contains('is-open')) return;
|
||
if ($('#settingsPanel') && $('#settingsPanel').classList.contains('is-open')) return;
|
||
if (state.checked.size === 0) return;
|
||
e.preventDefault();
|
||
if (state.view === 'trash') batchPermDelete();
|
||
else batchDelete();
|
||
});
|
||
|
||
// Auth tabs
|
||
$$('.auth-tab').forEach(t => {
|
||
t.addEventListener('click', () => {
|
||
$$('.auth-tab').forEach(x => x.classList.remove('is-active'));
|
||
t.classList.add('is-active');
|
||
const tab = t.dataset.tab;
|
||
$('#loginForm').classList.toggle('is-hidden', tab !== 'login');
|
||
$('#registerForm').classList.toggle('is-hidden', tab !== 'register');
|
||
});
|
||
});
|
||
|
||
// Forms
|
||
$('#loginForm').addEventListener('submit', doLogin);
|
||
$('#registerForm').addEventListener('submit', doRegister);
|
||
$('#regPassword').addEventListener('input', updateRegStrength);
|
||
$('#entryPassword').addEventListener('input', updateEntryStrength);
|
||
|
||
// Top-bar
|
||
function applyViewMode() {
|
||
$$('.view-btn').forEach(b => b.classList.toggle('is-active', b.dataset.view === state.viewMode));
|
||
applyColsWrapVisibility();
|
||
renderGrid();
|
||
}
|
||
applyViewMode();
|
||
$$('.view-btn').forEach(b => b.addEventListener('click', () => {
|
||
state.viewMode = b.dataset.view;
|
||
state.currentPage = 1;
|
||
localStorage.setItem('viewMode', state.viewMode);
|
||
applyViewMode();
|
||
saveServerSettings();
|
||
}));
|
||
|
||
// Show/hide table column picker. Built lazily on first open so the
|
||
// checkbox state always reflects the current `state.tableColsHidden`.
|
||
const colsBtn = document.getElementById('colsBtn');
|
||
const colsMenu = document.getElementById('colsMenu');
|
||
if (colsBtn && colsMenu) {
|
||
colsBtn.addEventListener('click', ev => {
|
||
ev.stopPropagation();
|
||
const isHidden = colsMenu.classList.contains('is-hidden');
|
||
if (isHidden) renderTableColsMenu();
|
||
colsMenu.classList.toggle('is-hidden');
|
||
});
|
||
document.addEventListener('click', e => {
|
||
if (!e.target.closest('#colsWrap'))
|
||
colsMenu.classList.add('is-hidden');
|
||
});
|
||
}
|
||
|
||
$('#themeBtn').addEventListener('click', () => {
|
||
toggleTheme();
|
||
saveServerSettings();
|
||
});
|
||
$('#newEntryBtn').addEventListener('click', ev => {
|
||
ev.stopPropagation();
|
||
// Default click → new login (preserves the muscle memory of the
|
||
// existing button). The chevron next to it opens the kind picker.
|
||
$('#newEntryMenu').classList.add('is-hidden');
|
||
openSlideOver(null, { kind: 'login' });
|
||
});
|
||
$('#newEntryCaretBtn').addEventListener('click', ev => {
|
||
ev.stopPropagation();
|
||
$('#newEntryMenu').classList.toggle('is-hidden');
|
||
});
|
||
$$('#newEntryMenu [data-new-kind]').forEach(b => {
|
||
b.addEventListener('click', ev => {
|
||
ev.stopPropagation();
|
||
const kind = b.getAttribute('data-new-kind') || 'login';
|
||
$('#newEntryMenu').classList.add('is-hidden');
|
||
openSlideOver(null, { kind });
|
||
});
|
||
});
|
||
$$('#newEntryMenu [data-new-template]').forEach(b => {
|
||
b.addEventListener('click', ev => {
|
||
ev.stopPropagation();
|
||
const template = b.getAttribute('data-new-template');
|
||
$('#newEntryMenu').classList.add('is-hidden');
|
||
openSlideOver(null, { template });
|
||
});
|
||
});
|
||
// Close the dropdown on any other click.
|
||
document.addEventListener('click', e => {
|
||
if (!e.target.closest('.new-entry-wrap'))
|
||
$('#newEntryMenu').classList.add('is-hidden');
|
||
});
|
||
$('#userChip').addEventListener('click', () => $('#userDropdown').classList.toggle('is-hidden'));
|
||
$('#lockBtn').addEventListener('click', async () => {
|
||
if (await confirmDiscardForSessionExit('lock')) lockVault();
|
||
});
|
||
$('#dropdownSettingsBtn').addEventListener('click', () => {
|
||
$('#userDropdown').classList.add('is-hidden');
|
||
openSettings();
|
||
});
|
||
$('#logoutBtn').addEventListener('click', async () => {
|
||
if (await confirmDiscardForSessionExit('logout')) doLogout();
|
||
});
|
||
document.addEventListener('click', e => {
|
||
if (!e.target.closest('.user-menu')) $('#userDropdown').classList.add('is-hidden');
|
||
// Close any open kebab menu when clicking outside it
|
||
if (!e.target.closest('.entry-kebab-wrap')) {
|
||
$$('.entry-kebab-menu.is-open').forEach(m => m.classList.remove('is-open'));
|
||
}
|
||
});
|
||
|
||
// Sidebar nav
|
||
$$('#appShell .nav-item[data-view]').forEach(n => {
|
||
n.addEventListener('click', () => setView(n.dataset.view));
|
||
});
|
||
$('#addFolderBtn').addEventListener('click', addFolder);
|
||
|
||
// Drag-to-trash: dropping an entry onto the Trash nav item soft-deletes it
|
||
const trashItem = $('#appShell .nav-item[data-view="trash"]');
|
||
if (trashItem) {
|
||
trashItem.addEventListener('dragover', ev => { ev.preventDefault(); trashItem.classList.add('drag-over'); });
|
||
trashItem.addEventListener('dragleave', () => trashItem.classList.remove('drag-over'));
|
||
trashItem.addEventListener('drop', async ev => {
|
||
ev.preventDefault();
|
||
trashItem.classList.remove('drag-over');
|
||
const raw = ev.dataTransfer.getData('text/plain') || '';
|
||
const ids = raw.split(',').map(s => parseInt(s)).filter(n => n > 0);
|
||
if (!ids.length) return;
|
||
let ok = 0;
|
||
for (const id of ids) {
|
||
try {
|
||
await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() });
|
||
ok++;
|
||
} catch (err) { /* keep going for the rest */ }
|
||
}
|
||
toast(ok === 1 ? 'Moved to trash' : 'Moved ' + ok + ' entries to trash');
|
||
state.checked.clear();
|
||
await loadEntries();
|
||
await loadTrash();
|
||
render();
|
||
});
|
||
}
|
||
|
||
// Search
|
||
let searchHistoryDebounce = null;
|
||
$('#searchInput').addEventListener('input', e => {
|
||
state.search = e.target.value;
|
||
state.currentPage = 1;
|
||
renderGrid();
|
||
// Hide history while user is actively typing; it pops back on
|
||
// focus when the field is empty.
|
||
if (e.target.value) $('#searchHistoryMenu').classList.add('is-hidden');
|
||
else renderSearchHistoryMenu();
|
||
// Commit to history after a pause — captures the live-typing case
|
||
// where the user finds what they wanted without ever pressing
|
||
// Enter or blurring the field.
|
||
if (searchHistoryDebounce) clearTimeout(searchHistoryDebounce);
|
||
const v = e.target.value.trim();
|
||
if (v.length >= 2) {
|
||
searchHistoryDebounce = setTimeout(() => pushSearchHistory(v), 1000);
|
||
}
|
||
});
|
||
$('#searchInput').addEventListener('focus', () => {
|
||
if (!$('#searchInput').value) renderSearchHistoryMenu();
|
||
});
|
||
$('#searchInput').addEventListener('keydown', e => {
|
||
// Enter commits the current term to history. Blur with a non-empty
|
||
// value also commits — handled below.
|
||
if (e.key === 'Enter' && $('#searchInput').value.trim()) {
|
||
pushSearchHistory($('#searchInput').value.trim());
|
||
$('#searchHistoryMenu').classList.add('is-hidden');
|
||
} else if (e.key === 'Escape') {
|
||
$('#searchHistoryMenu').classList.add('is-hidden');
|
||
}
|
||
});
|
||
$('#searchInput').addEventListener('blur', () => {
|
||
const v = $('#searchInput').value.trim();
|
||
if (v) pushSearchHistory(v);
|
||
// Delay-hide so a click on a history item can still register.
|
||
setTimeout(() => $('#searchHistoryMenu').classList.add('is-hidden'), 120);
|
||
});
|
||
// Click-outside also dismisses (covers the case where focus moves via
|
||
// keyboard to a non-blurring element).
|
||
document.addEventListener('click', e => {
|
||
if (!e.target.closest('.search')) {
|
||
$('#searchHistoryMenu').classList.add('is-hidden');
|
||
}
|
||
});
|
||
|
||
// Slide-over
|
||
$('#slideoverClose').addEventListener('click', () => requestCloseSlideOver());
|
||
// Click outside the slide-over closes it. Clicks on cards re-open it for
|
||
// another entry (so we don't close in that case; the card's own handler
|
||
// will switch state.selectedId).
|
||
// mousedown origin is captured so a drag-selection that starts inside
|
||
// an input and ends outside doesn't trigger close (the resulting click
|
||
// event has a target outside the slideover even though the user never
|
||
// intended to dismiss it).
|
||
// Esc closes the slideover. Registered with capture=true so it fires
|
||
// BEFORE any input-level handler that might call stopPropagation, and
|
||
// before the browser swallows the keystroke for things like clearing
|
||
// an active form-autocomplete popup on a focused field.
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key !== 'Escape') return;
|
||
if (!$('#slideover').classList.contains('is-open')) return;
|
||
if (document.querySelector('.modal:not(.is-hidden)')) return;
|
||
// Settings panel takes priority — when both Settings AND the
|
||
// editor are open, the first Esc should close Settings (the
|
||
// thing the user just opened on top), the second Esc handles
|
||
// the editor's dirty-check. Let the Settings Esc handler run.
|
||
if ($('#settingsPanel').classList.contains('is-open')) return;
|
||
// Stop here so the global "Esc closes confirmModal" handler
|
||
// doesn't fire on the SAME keystroke and immediately dismiss
|
||
// the discard-confirm dialog that requestCloseSlideOver just
|
||
// opened — that bug left the user with no visible feedback at
|
||
// all (modal opened and closed in one tick).
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
requestCloseSlideOver();
|
||
}, true);
|
||
// Click-outside closes too. mousedown origin is captured so a
|
||
// drag-selection that starts inside an input and ends outside doesn't
|
||
// count as an outside click. Cards/rows/modals/etc. are whitelisted
|
||
// so clicks on them don't dismiss the panel.
|
||
let _slideoverMouseDownInside = false;
|
||
document.addEventListener('mousedown', e => {
|
||
_slideoverMouseDownInside = !!(e.target.closest && e.target.closest('.slideover'));
|
||
// Close any open custom combobox menu when the click lands outside
|
||
// a combo (the arrow toggle + item mousedown both stopPropagation,
|
||
// so this only fires for genuine outside clicks).
|
||
if (!(e.target.closest && e.target.closest('.so-combo'))) {
|
||
document.querySelectorAll('.so-combo-menu:not(.is-hidden)')
|
||
.forEach(m => m.classList.add('is-hidden'));
|
||
}
|
||
}, true);
|
||
document.addEventListener('click', e => {
|
||
if (!$('#slideover').classList.contains('is-open')) return;
|
||
if (_slideoverMouseDownInside) { _slideoverMouseDownInside = false; return; }
|
||
if (e.target.closest('.slideover')) return;
|
||
if (e.target.closest('.entry-card')) return;
|
||
if (e.target.closest('.entry-row')) return;
|
||
if (e.target.closest('.modal')) return;
|
||
if (e.target.closest('.cmd-palette')) return;
|
||
if (e.target.closest('.idle-warning')) return;
|
||
// Sidebar + topbar are UI chrome — clicking the theme toggle,
|
||
// user menu, filter button, pagination etc. shouldn't dismiss
|
||
// the editor. Same for the table column picker dropdown.
|
||
if (e.target.closest('.topbar')) return;
|
||
if (e.target.closest('.sidebar')) return;
|
||
if (e.target.closest('.pagination')) return;
|
||
if (e.target.closest('.cols-menu')) return;
|
||
if (e.target.closest('.filters-menu')) return;
|
||
if (e.target.closest('.user-menu')) return;
|
||
if (e.target.closest('.search-history')) return;
|
||
requestCloseSlideOver();
|
||
});
|
||
|
||
// Click outside the settings panel closes it. Handled on MOUSEDOWN
|
||
// (not click) so async handlers that reparent the DOM (e.g.
|
||
// backfillFavicons → render() during the click→bubble window) can't
|
||
// make us mistake an inside-click for an outside-click. Ignore the
|
||
// triggers and any open modal so reauth / confirm flows fired from
|
||
// inside Settings don't dismiss it.
|
||
document.addEventListener('mousedown', e => {
|
||
if (!$('#settingsPanel').classList.contains('is-open')) return;
|
||
if (!(e.target && e.target.closest)) return;
|
||
if (e.target.closest('#settingsPanel')) return;
|
||
if (e.target.closest('#settingsBtn')) return;
|
||
if (e.target.closest('#dropdownSettingsBtn')) return;
|
||
if (e.target.closest('.modal')) return;
|
||
if (e.target.closest('.cmd-palette')) return;
|
||
if (e.target.closest('.toast')) return;
|
||
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 · clears in 30s');
|
||
} else {
|
||
navigator.clipboard.writeText(genCurrent).then(() => {
|
||
toast('Copied · clears in 30s');
|
||
setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000);
|
||
});
|
||
}
|
||
});
|
||
$('#genUse').addEventListener('click', () => {
|
||
if (genTarget === 'slideover') {
|
||
const soPw = $('#soPassword');
|
||
if (soPw) { soPw.value = genCurrent; soDirtyCheck(); }
|
||
} else {
|
||
$('#entryPassword').value = genCurrent;
|
||
updateEntryStrength();
|
||
}
|
||
closeGen();
|
||
});
|
||
$$('#genModal [data-close]').forEach(b => b.addEventListener('click', closeGen));
|
||
|
||
// Sidebar Generator tool
|
||
$('#sidebarGenBtn').addEventListener('click', () => openGen('standalone'));
|
||
$('#sidebarExportBtn').addEventListener('click', doExport);
|
||
$('#sidebarImportBtn').addEventListener('click', doImport);
|
||
$('#sidebarAuthenticatorBtn').addEventListener('click', () => {
|
||
state.view = 'authenticator';
|
||
state.currentPage = 1;
|
||
$$('.nav-item').forEach(b => b.classList.remove('is-active'));
|
||
render();
|
||
});
|
||
$('#sidebarTotpToolBtn').addEventListener('click', openTotpTool);
|
||
$('#sidebarRecentBtn').addEventListener('click', () => {
|
||
state.view = 'recent';
|
||
state.currentPage = 1;
|
||
$$('.nav-item').forEach(b => b.classList.remove('is-active'));
|
||
render();
|
||
});
|
||
$('#sidebarHealthBtn').addEventListener('click', () => {
|
||
state.view = 'health';
|
||
state.currentPage = 1;
|
||
$$('.nav-item').forEach(b => b.classList.remove('is-active'));
|
||
// Invalidate any stale cache so we recompute fresh each open.
|
||
healthCache = null;
|
||
render();
|
||
});
|
||
$('#sidebarAuditBtn').addEventListener('click', () => {
|
||
state.view = 'audit';
|
||
$$('.nav-item').forEach(b => b.classList.remove('is-active'));
|
||
auditCache = null;
|
||
render();
|
||
});
|
||
|
||
// Sidebar section collapse toggles
|
||
document.querySelectorAll('[data-section-toggle]').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const key = btn.getAttribute('data-section-toggle');
|
||
state.sidebarCollapsed = state.sidebarCollapsed || {};
|
||
state.sidebarCollapsed[key] = !state.sidebarCollapsed[key];
|
||
applySidebarCollapsed();
|
||
localStorage.setItem('sidebarCollapsed', JSON.stringify(state.sidebarCollapsed));
|
||
saveServerSettings();
|
||
});
|
||
});
|
||
applySidebarCollapsed();
|
||
applyEditorPosition();
|
||
|
||
// Idle warning "Stay unlocked"
|
||
$('#idleStayBtn').addEventListener('click', resetAutoLock);
|
||
|
||
// Settings slide-over
|
||
$('#settingsBtn').addEventListener('click', openSettings);
|
||
$('#settingsClose').addEventListener('click', closeSettings);
|
||
|
||
// Escape closes the Settings panel — unless the search input is
|
||
// focused with a non-empty query (in that case its own handler
|
||
// already swallowed the event and cleared the query). Skip when a
|
||
// confirm/reauth modal is up so its Escape stays the priority.
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key !== 'Escape') return;
|
||
if (!$('#settingsPanel').classList.contains('is-open')) return;
|
||
if (document.querySelector('.modal:not(.is-hidden)')) return;
|
||
// 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)'
|
||
: 'Won’t start with Windows');
|
||
});
|
||
$('#settingTrayNotif').addEventListener('change', e => {
|
||
state.trayNotificationsEnabled = e.target.checked;
|
||
localStorage.setItem('trayNotificationsEnabled', e.target.checked ? '1' : '0');
|
||
if (Bridge.active) Bridge.setTrayNotifications(e.target.checked);
|
||
saveServerSettings();
|
||
toast(e.target.checked
|
||
? 'Tray notifications enabled'
|
||
: 'Tray notifications disabled');
|
||
});
|
||
$('#settingTrashPurge').addEventListener('change', e => {
|
||
const n = parseInt(e.target.value, 10) || 0;
|
||
state.trashAutoPurgeDays = n;
|
||
localStorage.setItem('trashAutoPurgeDays', String(n));
|
||
saveServerSettings();
|
||
if (n === 0) toast('Trash auto-purge disabled');
|
||
else toast('Trash will auto-purge after ' + n + ' days (next unlock)');
|
||
});
|
||
$('#settingPasswordExpiry').addEventListener('change', e => {
|
||
const n = parseInt(e.target.value, 10) || 0;
|
||
state.passwordExpiryDays = n;
|
||
localStorage.setItem('passwordExpiryDays', String(n));
|
||
saveServerSettings();
|
||
render();
|
||
if (n === 0) toast('Password aging reminders disabled');
|
||
else toast('Passwords older than ' + n + ' days will be flagged');
|
||
});
|
||
$('#settingEditorPosition').addEventListener('change', e => {
|
||
const v = ['right', 'left', 'center'].includes(e.target.value)
|
||
? e.target.value : 'right';
|
||
state.editorPosition = v;
|
||
localStorage.setItem('editorPosition', v);
|
||
applyEditorPosition();
|
||
saveServerSettings();
|
||
});
|
||
$('#settingConfirmUnsaved').addEventListener('change', e => {
|
||
state.confirmOnUnsaved = !!e.target.checked;
|
||
localStorage.setItem('confirmOnUnsaved', state.confirmOnUnsaved ? '1' : '0');
|
||
saveServerSettings();
|
||
});
|
||
// PIN unlock controls. Setting the mode to PIN/both without a PIN
|
||
// configured first would lock the user out — gate the dropdown so a
|
||
// missing PIN forces the user through Set PIN first.
|
||
const pinSet = document.getElementById('pinSetBtn');
|
||
const pinDel = document.getElementById('pinRemoveBtn');
|
||
const pinSel = document.getElementById('settingUnlockMode');
|
||
if (pinSet) pinSet.addEventListener('click', pinSetupFlow);
|
||
if (pinDel) pinDel.addEventListener('click', async () => {
|
||
const ok = await confirmDialog({
|
||
title: 'Remove PIN?',
|
||
message: 'You will need your master password to unlock until you set a new PIN.',
|
||
okText: 'Remove',
|
||
danger: true,
|
||
});
|
||
if (!ok) return;
|
||
await removePin();
|
||
refreshPinUnlockUI();
|
||
});
|
||
if (pinSel) pinSel.addEventListener('change', async e => {
|
||
const next = e.target.value;
|
||
if ((next === 'pin' || next === 'both') && !state.pinConfigured) {
|
||
toast('Set a PIN first', 'warning');
|
||
e.target.value = state.unlockMode || 'pw';
|
||
return;
|
||
}
|
||
state.unlockMode = next;
|
||
localStorage.setItem('unlockMode', next);
|
||
saveServerSettings();
|
||
toast('Unlock method updated');
|
||
});
|
||
// Sync (WebDAV) listeners
|
||
const syncCb = document.getElementById('settingSyncEnabled');
|
||
const syncCfgBox = document.getElementById('syncConfig');
|
||
const syncUrlEl = document.getElementById('syncUrl');
|
||
const syncUserEl = document.getElementById('syncUser');
|
||
const syncPwdEl = document.getElementById('syncPwd');
|
||
const syncPreEl = document.getElementById('syncPreBackup');
|
||
const syncSetPwd = document.getElementById('syncSetPwdBtn');
|
||
const syncTest = document.getElementById('syncTestBtn');
|
||
const syncNow = document.getElementById('syncNowBtn');
|
||
if (syncCb) syncCb.addEventListener('change', e => {
|
||
const on = !!e.target.checked;
|
||
Bridge.setPref(SYNC_PREFS.enabled, on ? '1' : '');
|
||
if (syncCfgBox) syncCfgBox.style.display = on ? '' : 'none';
|
||
toast(on ? 'Sync enabled' : 'Sync disabled');
|
||
});
|
||
if (syncUrlEl) syncUrlEl.addEventListener('change', e =>
|
||
Bridge.setPref(SYNC_PREFS.url, e.target.value.trim()));
|
||
if (syncUserEl) syncUserEl.addEventListener('change', e =>
|
||
Bridge.setPref(SYNC_PREFS.user, e.target.value.trim()));
|
||
if (syncPwdEl) syncPwdEl.addEventListener('change', e =>
|
||
Bridge.setPref(SYNC_PREFS.pwd, e.target.value));
|
||
if (syncPreEl) syncPreEl.addEventListener('change', e =>
|
||
Bridge.setPref(SYNC_PREFS.preBackup, e.target.checked ? '1' : ''));
|
||
if (syncSetPwd) syncSetPwd.addEventListener('click', syncSetEncPwdFlow);
|
||
if (syncTest) syncTest.addEventListener('click', syncTestConnection);
|
||
// 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));
|
||
});
|
||
$('#autoBackupNowBtn').addEventListener('click', () => runAutoBackupNow());
|
||
|
||
$('#settingFavicons').addEventListener('change', e => {
|
||
state.faviconsEnabled = e.target.checked;
|
||
localStorage.setItem('faviconsEnabled', state.faviconsEnabled ? '1' : '0');
|
||
saveServerSettings();
|
||
if (state.faviconsEnabled) {
|
||
// Auto-backfill on first opt-in so the user sees the effect
|
||
// immediately instead of having to click the refresh button.
|
||
backfillFavicons(false);
|
||
} else {
|
||
toast('Website icons disabled (cached icons kept)');
|
||
}
|
||
});
|
||
// stopPropagation on the favicon buttons — the document-level
|
||
// "click outside Settings closes it" handler was firing because the
|
||
// click target lost its #settingsPanel ancestor mid-bubble (the
|
||
// async backfill chain triggers a render that reparents nodes).
|
||
$('#settingFaviconsRefresh').addEventListener('click', ev => {
|
||
ev.stopPropagation();
|
||
backfillFavicons(false);
|
||
});
|
||
$('#settingFaviconsRefreshAll').addEventListener('click', ev => {
|
||
ev.stopPropagation();
|
||
backfillFavicons(true);
|
||
});
|
||
$('#settingFaviconsClear').addEventListener('click', async ev => {
|
||
ev.stopPropagation();
|
||
const ok = await confirmDialog({
|
||
title: 'Clear cached icons?',
|
||
message: 'All website icons cached in your vault will be removed. They will be re-fetched on demand if the toggle stays on.',
|
||
okText: 'Clear',
|
||
danger: true,
|
||
});
|
||
if (ok) clearAllFavicons();
|
||
});
|
||
$('#settingAutofill').addEventListener('change', e => {
|
||
state.autofillEnabled = e.target.checked;
|
||
localStorage.setItem('autofillEnabled', state.autofillEnabled ? '1' : '0');
|
||
// Push the FULL state (toggle + combos) so Delphi register/unregister
|
||
// uses the user's current combos, not the defaults.
|
||
autofillPushHotkeys();
|
||
const lbl = autofillComboLabel(state.autofillHotkeyFull);
|
||
toast(state.autofillEnabled ? ('Autofill enabled (' + lbl + ')') : 'Autofill disabled');
|
||
});
|
||
|
||
// ---- Hotkey capture buttons ----
|
||
// Click → button label becomes "Press combo…" → next keydown captures.
|
||
// While capturing, all other keys are swallowed so the user can press
|
||
// any modifier+letter combo without triggering app shortcuts.
|
||
function bindHotkeyCapture(btnId, kind) {
|
||
const btn = $(btnId);
|
||
if (!btn) return;
|
||
btn.addEventListener('click', () => {
|
||
if (btn.dataset.capturing === '1') return;
|
||
btn.dataset.capturing = '1';
|
||
btn.classList.add('is-capturing');
|
||
const original = btn.textContent;
|
||
btn.textContent = 'Press combo… (Esc to cancel)';
|
||
|
||
function finish(restore) {
|
||
btn.dataset.capturing = '';
|
||
btn.classList.remove('is-capturing');
|
||
document.removeEventListener('keydown', onKey, true);
|
||
if (restore) btn.textContent = original;
|
||
}
|
||
|
||
function onKey(e) {
|
||
// Swallow EVERYTHING while capturing so the user's combo
|
||
// doesn't trigger the cmd-palette etc.
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const captured = autofillCaptureFromEvent(e);
|
||
if (captured === null) return; // still building
|
||
if (captured === 'cancel') { finish(true); return; }
|
||
if (captured === 'invalid'){
|
||
toast('Unsupported key — use a letter, digit or F-key', 'warning');
|
||
finish(true);
|
||
return;
|
||
}
|
||
if (!autofillComboValid(captured)) {
|
||
toast('Combo needs at least Ctrl, Alt or Win as a modifier', 'warning');
|
||
finish(true);
|
||
return;
|
||
}
|
||
// Reject if it collides with any of the other configurable slots.
|
||
const others = [
|
||
kind !== 'full' ? state.autofillHotkeyFull : null,
|
||
kind !== 'password' ? state.autofillHotkeyPwd : null,
|
||
kind !== 'quickSearch' ? state.quickSearchHotkey : null,
|
||
].filter(Boolean);
|
||
const capJson = JSON.stringify(captured);
|
||
if (others.some(o => JSON.stringify(o) === capJson)) {
|
||
toast('That combo is already used by another hotkey', 'warning');
|
||
finish(true);
|
||
return;
|
||
}
|
||
// Commit.
|
||
if (kind === 'full') state.autofillHotkeyFull = captured;
|
||
else if (kind === 'password') state.autofillHotkeyPwd = captured;
|
||
else /* quickSearch */ state.quickSearchHotkey = captured;
|
||
btn.textContent = autofillComboLabel(captured);
|
||
finish(false);
|
||
autofillPushHotkeys(); // re-register in Delphi
|
||
saveServerSettings(); // sync to server (debounced)
|
||
}
|
||
document.addEventListener('keydown', onKey, true);
|
||
});
|
||
}
|
||
bindHotkeyCapture('#settingAutofillFullCombo', 'full');
|
||
bindHotkeyCapture('#settingAutofillPwdCombo', 'password');
|
||
bindHotkeyCapture('#settingQuickSearchCombo', 'quickSearch');
|
||
|
||
$('#settingAutofillResetHotkeys').addEventListener('click', () => {
|
||
state.autofillHotkeyFull = { ctrl: true, shift: true, alt: false, win: false, key: 'L' };
|
||
state.autofillHotkeyPwd = { ctrl: true, shift: true, alt: false, win: false, key: 'P' };
|
||
state.quickSearchHotkey = { ctrl: true, shift: true, alt: false, win: false, key: 'Q' };
|
||
$('#settingAutofillFullCombo').textContent = autofillComboLabel(state.autofillHotkeyFull);
|
||
$('#settingAutofillPwdCombo').textContent = autofillComboLabel(state.autofillHotkeyPwd);
|
||
$('#settingQuickSearchCombo').textContent = autofillComboLabel(state.quickSearchHotkey);
|
||
autofillPushHotkeys();
|
||
saveServerSettings();
|
||
toast('Hotkeys reset to defaults');
|
||
});
|
||
|
||
// Autofill picker modal close button
|
||
$$('#autofillPickerModal [data-close]').forEach(b =>
|
||
b.addEventListener('click', closeAutofillPicker));
|
||
$('#openClipboardSettings').addEventListener('click', () => {
|
||
toast('Open Windows Settings → System → Clipboard → turn off "Clipboard history"', 'warning');
|
||
});
|
||
$('#exportBtn').addEventListener('click', doExport);
|
||
$('#exportCsvBtn').addEventListener('click', doExportCSV);
|
||
$('#importBtn').addEventListener('click', doImport);
|
||
$('#changeMasterBtn').addEventListener('click', openChangeMasterModal);
|
||
|
||
// Profile picture: "Change picture" opens the hidden file input;
|
||
// selecting a file downscales + uploads it; "Remove" clears it.
|
||
const avaUpload = $('#settingAvatarUpload');
|
||
const avaInput = $('#settingAvatarInput');
|
||
const avaRemove = $('#settingAvatarRemove');
|
||
if (avaUpload && avaInput) {
|
||
avaUpload.addEventListener('click', () => avaInput.click());
|
||
avaInput.addEventListener('change', async e => {
|
||
const f = e.target.files && e.target.files[0];
|
||
e.target.value = ''; // allow re-picking the same file later
|
||
if (f) await uploadUserAvatar(f);
|
||
});
|
||
}
|
||
if (avaRemove) avaRemove.addEventListener('click', removeUserAvatar);
|
||
$('#changeMasterForm').addEventListener('submit', e => {
|
||
e.preventDefault();
|
||
doChangeMasterPassword();
|
||
});
|
||
$$('#changeMasterModal [data-close]').forEach(b =>
|
||
b.addEventListener('click', closeChangeMasterModal));
|
||
|
||
// Recovery key
|
||
$('#recoverySetupBtn').addEventListener('click', doGenerateRecoveryKey);
|
||
$('#recoveryRemoveBtn').addEventListener('click', doRemoveRecoveryKey);
|
||
$('#recoveryBtn').addEventListener('click', doRecoveryRedeem);
|
||
|
||
// Quick unlock (DPAPI)
|
||
$('#quickUnlockToggleBtn').addEventListener('click', () => {
|
||
if (state.quickUnlockEnabled) disableQuickUnlock();
|
||
else enableQuickUnlock();
|
||
});
|
||
|
||
// Re-auth modal
|
||
$('#reauthForm').addEventListener('submit', e => { e.preventDefault(); closeReauth(true); });
|
||
$$('#reauthModal [data-close]').forEach(b => b.addEventListener('click', () => closeReauth(false)));
|
||
|
||
// Custom confirm / prompt modal
|
||
$('#confirmForm').addEventListener('submit', e => {
|
||
e.preventDefault();
|
||
// If input field visible -> resolve with its value, else -> true
|
||
const hasInput = !$('#confirmInputField').classList.contains('is-hidden');
|
||
closeConfirm(hasInput ? $('#confirmInput').value : true);
|
||
});
|
||
$$('#confirmModal [data-confirm-cancel]').forEach(b =>
|
||
b.addEventListener('click', () => closeConfirm(false))
|
||
);
|
||
|
||
// Helper: is the user currently typing into an input/textarea/select
|
||
// or contenteditable surface? Shortcuts like Ctrl+A must NOT hijack
|
||
// input focus (browser default = select all text in the field).
|
||
function isTypingTarget(t) {
|
||
if (!t) return false;
|
||
const tag = t.tagName;
|
||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
||
if (t.isContentEditable) return true;
|
||
return false;
|
||
}
|
||
|
||
// Command palette + bulk shortcuts
|
||
document.addEventListener('keydown', e => {
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||
e.preventDefault();
|
||
openPalette();
|
||
} else if ((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')) {
|
||
// Ctrl+A = select every visible entry. Replaces the role of
|
||
// a marquee "drag across all cards" that we used to have.
|
||
// Only fire when the user isn't typing in a field — otherwise
|
||
// we'd steal the universal Select All in inputs.
|
||
if (isTypingTarget(e.target)) return;
|
||
// Don't fire if the app isn't actually showing the entry grid
|
||
// (auth screen, locked, etc.)
|
||
if ($('#appShell').classList.contains('is-hidden')) return;
|
||
e.preventDefault();
|
||
const visible = filteredEntries();
|
||
visible.forEach(en => state.checked.add(en.id));
|
||
renderGrid();
|
||
} else if (e.key === '?' &&
|
||
!/^(INPUT|TEXTAREA|SELECT)$/.test((e.target||{}).tagName) &&
|
||
!e.ctrlKey && !e.metaKey && !e.altKey) {
|
||
// '?' anywhere outside an input shows the hotkey cheatsheet.
|
||
// Skipped while the auth screen is up — discovery is for the
|
||
// unlocked workflow.
|
||
if ($('#appShell').classList.contains('is-hidden')) return;
|
||
e.preventDefault();
|
||
openCheatsheet();
|
||
} else if (e.key === 'Escape') {
|
||
// Close in priority order: confirm first (most modal-y) then others
|
||
if (!$('#confirmModal').classList.contains('is-hidden')) {
|
||
closeConfirm(false);
|
||
return;
|
||
}
|
||
if (!$('#changeMasterModal').classList.contains('is-hidden')) {
|
||
closeChangeMasterModal();
|
||
return;
|
||
}
|
||
if (!$('#cheatsheetModal').classList.contains('is-hidden')) {
|
||
closeCheatsheet();
|
||
return;
|
||
}
|
||
if (!$('#historyModal').classList.contains('is-hidden')) {
|
||
closeHistoryModal();
|
||
return;
|
||
}
|
||
closePalette();
|
||
requestCloseSlideOver();
|
||
closeEntryModal();
|
||
closeGen();
|
||
// If nothing else needed dismissing and there's an active
|
||
// selection, clear it. Replaces the "click empty space to
|
||
// deselect" path that the marquee provided.
|
||
if (state.checked.size > 0) {
|
||
state.checked.clear();
|
||
renderGrid();
|
||
}
|
||
}
|
||
});
|
||
$('#cmdInput').addEventListener('input', e => renderPaletteResults(e.target.value));
|
||
$$('#cmdPalette [data-close]').forEach(b => b.addEventListener('click', closePalette));
|
||
$$('#cheatsheetModal [data-close]').forEach(b =>
|
||
b.addEventListener('click', closeCheatsheet));
|
||
$$('#historyModal [data-close]').forEach(b =>
|
||
b.addEventListener('click', closeHistoryModal));
|
||
const cheatBtn = document.getElementById('cheatsheetBtn');
|
||
if (cheatBtn) cheatBtn.addEventListener('click', openCheatsheet);
|
||
|
||
// Filters dropdown
|
||
const filtersBtn = document.getElementById('filtersBtn');
|
||
const filtersMenu = document.getElementById('filtersMenu');
|
||
if (filtersBtn && filtersMenu) {
|
||
filtersBtn.addEventListener('click', ev => {
|
||
ev.stopPropagation();
|
||
renderFiltersMenu();
|
||
filtersMenu.classList.toggle('is-hidden');
|
||
});
|
||
// Click outside the menu closes it (not on a checkbox / button — those
|
||
// bubbleStop above so they don't reach this listener).
|
||
document.addEventListener('click', e => {
|
||
if (!e.target.closest('.filters-wrap'))
|
||
filtersMenu.classList.add('is-hidden');
|
||
});
|
||
}
|
||
renderFiltersBadge();
|
||
renderFilterChips();
|
||
|
||
// Quick-search modal (tray menu) — keyboard nav + close
|
||
const qsInput = document.getElementById('quickSearchInput');
|
||
if (qsInput) {
|
||
qsInput.addEventListener('input', () => {
|
||
quickSearchSelected = 0;
|
||
quickSearchRender();
|
||
});
|
||
qsInput.addEventListener('keydown', e => {
|
||
const rows = document.querySelectorAll('#quickSearchResults .quick-search-row');
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
closeQuickSearchModal();
|
||
} else if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
if (rows.length) { quickSearchSelected++; quickSearchRender(); }
|
||
} else if (e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
if (rows.length) { quickSearchSelected--; quickSearchRender(); }
|
||
} else if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
const sel = rows[quickSearchSelected];
|
||
if (!sel) return;
|
||
const id = parseInt(sel.dataset.id, 10);
|
||
const entry = state.entries.find(x => x.id === id);
|
||
// Shift+Enter → 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);
|