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