Files
Password-Manager/js/app.js
T
Zaki a45897c33d feat(security): HIBP password breach check + CSP tightening
HIBP integration
================
Opt-in (default OFF) password breach check via the Have I Been Pwned
range API. The full master / entry password never leaves the machine —
only the first 5 characters of its SHA-1 hash. HIBP returns ~500
candidate suffixes; the client matches its own suffix locally.

UI:
 - New "Check passwords against breach database (HIBP)" toggle in
   Settings → Security with an explainer hint about k-anonymity.
 - On enable: background batch scan of all entries, results cached in
   state.hibpResults keyed by entry id. Concurrency capped at 6 to
   avoid hammering HIBP / hitting browser connection limits.
 - Entry cards show a red "Pwned" chip + breach count in the tooltip
   when count > 0. New i-alert icon added to the SVG sprite.
 - Auto-scan triggered after every enterApp() when the toggle is on.

Functions added to app.js:
 - sha1Hex(text)                       — crypto.subtle wrapper
 - hibpCheckPassword(plaintext)        — single-password check, returns count
 - hibpCheckAllEntries()               — batched scan over state.entries

The "Add-Padding: true" header is sent on every range request to defeat
the response-size side-channel (HIBP adds 800-1000 random extra entries
so an observer counting bytes can't narrow the prefix queried).

CSP tightening
==============
Audited the served HTML: zero <script> tags inline, only the external
js/app.js. Removed 'unsafe-inline' from script-src — real XSS defense.

Kept 'unsafe-inline' on style-src for now because index.html contains
inline style="" attributes and app.js calls element.style.cssText
extensively. Refactoring to CSS classes is a separate cleanup. Style
injection alone cannot execute code, so the residual risk is bounded
to visual manipulation in a single-user loopback app.

Added api.pwnedpasswords.com to connect-src as the only allowed
external origin (required by the HIBP feature above). Default still
'self' — everything else stays loopback.

Before:
  script-src 'self' 'unsafe-inline';
  style-src  'self' 'unsafe-inline';
  connect-src 'self';

After:
  script-src 'self';
  style-src  'self' 'unsafe-inline';
  connect-src 'self' https://api.pwnedpasswords.com;
2026-05-23 05:05:50 +01:00

2526 lines
94 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 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.
// Exposed as window.Bridge.onSystemLock so the Delphi side can call it,
// but the actual lock is triggered directly via lockVault() in Delphi.
onSystemLock() {
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');
}
},
};
})();
// 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') || '',
cryptoKey: null,
entries: [],
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
compactActions: localStorage.getItem('compactActions') === '1', // default false
viewMode: localStorage.getItem('viewMode') || 'cards', // 'cards' | 'list'
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(),
};
// ============================================================
// 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']
);
}
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;
}
// ============================================================
// 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 {
// Derive the new key. The old key is already in state.cryptoKey
// (used to decrypt the entries we just loaded).
const 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).
const 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 to 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
}
}
// Send the atomic migrate request. Server verifies the master pw
// against the OLD hash, then updates hash + iterations + every
// entry in a single transaction.
await api('/migrate-kdf', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
masterPassword: masterPwd,
entries: newCiphertexts,
}),
});
// Server committed → switch our in-memory crypto key and update
// the cached ciphertexts in state.entries so subsequent reads use
// the new key transparently.
state.cryptoKey = newKey;
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)');
} 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 {
const r = await api('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: u, masterPassword: p }),
});
state.token = r.token;
state.csrf = r.csrfToken;
state.salt = r.salt;
state.username = u;
sessionStorage.setItem('authToken', state.token);
sessionStorage.setItem('csrfToken', state.csrf);
sessionStorage.setItem('salt', state.salt);
sessionStorage.setItem('username', state.username);
// Derive with the server-specified iteration count — legacy users
// receive 100k, modern users 600k. The cryptoKey is what currently
// decrypts the entries on this server.
state.cryptoKey = await deriveKey(p, state.salt, r.kdfIterations);
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
}
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 {
const r = await api('/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: u, masterPassword: p }),
});
state.token = r.token;
state.csrf = r.csrfToken;
state.salt = r.salt;
state.username = u;
sessionStorage.setItem('authToken', state.token);
sessionStorage.setItem('csrfToken', state.csrf);
sessionStorage.setItem('salt', state.salt);
sessionStorage.setItem('username', state.username);
// Fresh account → server returns kdfIterations = current target.
// No migration ever needed for a brand-new vault.
state.cryptoKey = await deriveKey(p, state.salt, r.kdfIterations);
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) {}
sessionStorage.clear();
state.token = ''; state.csrf = ''; state.salt = ''; state.username = '';
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;
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 {
const r = await api('/reauth', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ masterPassword: p }),
});
// r now carries kdfIterations + optional kdfMigration, same as /login.
state.cryptoKey = await deriveKey(p, state.salt, r.kdfIterations);
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 : [];
} catch (e) { state.trashed = []; }
}
// ============================================================
// 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);
if (f !== 'All') list = list.filter(e => e.folder === 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.username || '').toLowerCase().includes(q) ||
(e.tags || '').toLowerCase().includes(q)
);
}
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.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;
$('#countTrash').textContent = state.trashed.length || '';
// active state for top-level items
$$('#appShell .nav-item[data-view]').forEach(n => {
n.classList.toggle('is-active', n.dataset.view === state.view);
});
// folders
const fList = $('#foldersList');
fList.innerHTML = '';
state.folders.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);
});
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, 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();
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' : '');
grid.innerHTML = '';
if (list.length === 0) {
showEmptyState();
return;
}
$('#emptyState').classList.add('is-hidden');
list.forEach(e => grid.appendChild(renderCard(e)));
}
function showEmptyState() {
const illustration = $('#emptyIllustration use');
const title = $('#emptyTitle');
const msg = $('#emptyMessage');
if (state.search) {
illustration.setAttribute('href', '#i-empty-search');
title.textContent = 'No matches';
msg.innerHTML = 'Try a different search term, or click <b>+ New</b> to add a new entry.';
} else if (state.view === 'trash') {
illustration.setAttribute('href', '#i-empty-trash');
title.textContent = 'Trash is empty';
msg.textContent = 'Deleted entries land here. They can be restored at any time.';
} else if (state.view === 'favorites') {
illustration.setAttribute('href', '#i-empty-vault');
title.textContent = 'No favorites yet';
msg.innerHTML = 'Click the <b>★</b> on any entry to add it to favorites.';
} else if (state.view.startsWith('folder:')) {
illustration.setAttribute('href', '#i-empty-vault');
title.textContent = 'Folder is empty';
msg.innerHTML = 'Move entries here by drag &amp; drop, or by setting their folder.';
} else if (state.view.startsWith('tag:')) {
illustration.setAttribute('href', '#i-empty-vault');
title.textContent = 'No entries with this tag';
msg.textContent = 'Drop a card on the tag to add this tag to that entry.';
} else {
illustration.setAttribute('href', '#i-empty-vault');
title.textContent = 'Your vault is empty';
msg.innerHTML = 'Click <b>+ New</b> to add your first password. They\'re encrypted before they leave your machine.';
}
$('#emptyState').classList.remove('is-hidden');
}
// Skeleton loaders shown during the initial fetch right after login/unlock
function showSkeletons(n) {
const grid = $('#entryGrid');
grid.innerHTML = '';
$('#emptyState').classList.add('is-hidden');
for (let i = 0; i < n; i++) {
const card = el('div', { class: 'skeleton-card' });
const row = el('div', { class: 'skeleton-row' });
row.appendChild(el('div', { class: 'skeleton-circle' }));
const col = el('div', { style: 'flex:1' });
col.appendChild(el('div', { class: 'skeleton-line w-60' }));
col.appendChild(el('div', { class: 'skeleton-line w-40', style: 'margin-bottom:0' }));
row.appendChild(col);
card.appendChild(row);
card.appendChild(el('div', { class: 'skeleton-line w-80' }));
card.appendChild(el('div', { class: 'skeleton-line w-40', style: 'margin-bottom:0' }));
grid.appendChild(card);
}
}
function initials(s) {
return (s || '?').replace(/[^a-zA-Z0-9]/g, '').slice(0, 2).toUpperCase() || '?';
}
// 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: '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 acts as a multi-select checkbox (click on avatar -> toggle)
const head = el('div', { class: 'entry-head' });
const avatar = el('div', {
class: 'entry-avatar is-checkable',
title: 'Click to select',
on: { click: ev => { ev.stopPropagation(); toggleChecked(e.id); } },
}, checked ? '✓' : initials(e.site));
head.appendChild(avatar);
const title = el('div', { class: 'entry-title' });
title.appendChild(el('b', null, 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 + 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);
// Quick-delete: small X visible on card hover. Always available
// without opening the slide-over.
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
const meta = el('div', { class: 'entry-meta' });
if (e.folder) {
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);
}
card.appendChild(meta);
return card;
}
// ============================================================
// MARQUEE (rubber-band) SELECTION
// ============================================================
// Click-drag on empty space in the entry grid draws a rectangle.
// Cards whose bounding box intersects the rectangle become selected.
// Shift/Ctrl held = add to existing selection (otherwise replace).
let marqueeEl = null;
let marqueeStart = null;
let marqueeAdditive = false;
let marqueeInitialSet = null;
function startMarquee(ev) {
// Only fire on left mouse button, and only when starting on grid background
if (ev.button !== 0) return;
if (ev.target.closest('.entry-card')) return; // ignore drags from cards
if (ev.target.closest('.batch-bar')) return;
if (!ev.target.closest('#entryGrid')) return;
marqueeAdditive = ev.shiftKey || ev.ctrlKey || ev.metaKey;
marqueeInitialSet = new Set(state.checked);
if (!marqueeAdditive) state.checked.clear();
marqueeStart = { x: ev.clientX, y: ev.clientY };
marqueeEl = el('div', { class: 'marquee' });
Object.assign(marqueeEl.style, {
left: marqueeStart.x + 'px',
top: marqueeStart.y + 'px',
width: '0px', height: '0px',
});
document.body.appendChild(marqueeEl);
ev.preventDefault();
document.addEventListener('mousemove', updateMarquee);
document.addEventListener('mouseup', endMarquee);
}
function updateMarquee(ev) {
if (!marqueeEl) return;
const x1 = Math.min(marqueeStart.x, ev.clientX);
const y1 = Math.min(marqueeStart.y, ev.clientY);
const x2 = Math.max(marqueeStart.x, ev.clientX);
const y2 = Math.max(marqueeStart.y, ev.clientY);
Object.assign(marqueeEl.style, {
left: x1 + 'px', top: y1 + 'px',
width: (x2 - x1) + 'px', height: (y2 - y1) + 'px',
});
// Re-check intersections
const marqueeRect = { left: x1, top: y1, right: x2, bottom: y2 };
state.checked = new Set(marqueeAdditive ? marqueeInitialSet : []);
$$('#entryGrid .entry-card').forEach(card => {
const r = card.getBoundingClientRect();
const intersects = !(r.right < marqueeRect.left || r.left > marqueeRect.right ||
r.bottom < marqueeRect.top || r.top > marqueeRect.bottom);
if (intersects) {
const id = parseInt(card.dataset.id);
state.checked.add(id);
card.classList.add('is-checked');
} else if (!marqueeInitialSet.has(parseInt(card.dataset.id))) {
card.classList.remove('is-checked');
}
});
}
function endMarquee() {
document.removeEventListener('mousemove', updateMarquee);
document.removeEventListener('mouseup', endMarquee);
if (marqueeEl) marqueeEl.remove();
marqueeEl = null;
marqueeStart = null;
marqueeInitialSet = null;
// Re-render so the batch bar appears with the new count + avatar states
renderGrid();
}
// ============================================================
// MULTI-SELECTION + BATCH ACTIONS
// ============================================================
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, 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, 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 }, 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;
async function openSlideOver(id) {
const e = state.entries.find(x => x.id === id);
if (!e) return;
state.selectedId = id;
$('#slideoverTitle').textContent = e.site;
const body = $('#slideoverBody');
body.innerHTML = '';
const plain = await decryptPwd(e.encrypted_password, e.iv);
// Track original values so we can detect "dirty"
soState = {
id: e.id,
original: {
site: e.site, username: e.username || '', password: plain,
folder: e.folder || 'All', tags: parseTags(e.tags).join(','),
},
tags: parseTags(e.tags),
originalEncrypted: e.encrypted_password,
originalIV: e.iv,
};
body.appendChild(soEditableField('Site', 'soSite', e.site));
body.appendChild(soEditableField('Username', 'soUsername', e.username || ''));
body.appendChild(soPasswordField(plain));
body.appendChild(soFolderField(e.folder || 'All'));
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
['#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');
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' });
wrap.appendChild(input);
return wrap;
}
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',
});
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;
}
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 }, 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',
});
cont.appendChild(input);
wrap.appendChild(cont);
// Render existing chips
renderSoChips();
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: () => { soState.tags.splice(i, 1); renderSoChips(); soDirtyCheck(); } },
});
x.appendChild(icon('i-x'));
chip.appendChild(x);
cont.insertBefore(chip, input);
});
}
function soDirtyCheck() {
if (!soState) return;
const cur = {
site: ($('#soSite') || {}).value || '',
username: ($('#soUsername') || {}).value || '',
password: ($('#soPassword') || {}).value || '',
folder: ($('#soFolder') || {}).value || '',
tags: soState.tags.join(','),
};
const dirty =
cur.site !== soState.original.site ||
cur.username !== soState.original.username ||
cur.password !== soState.original.password ||
cur.folder !== soState.original.folder ||
cur.tags !== soState.original.tags;
const btn = $('#soSaveBtn');
if (btn) btn.style.display = dirty ? '' : 'none';
}
async function soSave() {
if (!soState) return;
const site = $('#soSite').value.trim();
const user = $('#soUsername').value.trim();
const pwd = $('#soPassword').value;
const fold = $('#soFolder').value;
if (!site || !pwd) return toast('Site and password required', 'error');
// Only re-encrypt if password changed; otherwise reuse stored ciphertext
let enc;
if (pwd === soState.original.password) {
enc = { encrypted: soState.originalEncrypted, iv: soState.originalIV };
} else {
enc = await encryptPwd(pwd);
}
try {
await api('/entries/' + soState.id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
site, username: user,
encrypted_password: enc.encrypted, iv: enc.iv,
folder: fold, tags: soState.tags.join(','),
}),
});
toast('Saved');
await loadEntries();
// Re-open with updated data
const updated = state.entries.find(x => x.id === soState.id);
if (updated) openSlideOver(updated.id);
else closeSlideOver();
render();
} 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() {
$('#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: () => { 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;
$('#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 }, 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 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, username: user, encrypted_password: enc.encrypted, iv: enc.iv,
folder: fold, tags,
});
try {
if (id) {
await api('/entries/' + id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body,
});
toast('Updated');
} else {
await api('/entries', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body,
});
toast('Saved');
}
closeEntryModal();
await loadEntries();
render();
} 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 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();
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, 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 addFolder() {
const name = await promptDialog({
title: 'New folder',
message: 'Folder name',
placeholder: 'e.g. Work',
okText: 'Create',
});
if (!name || !name.trim()) return;
try {
await api('/folders', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ name: name.trim() }),
});
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(); openEntryModal(); } },
{ 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: e.site, sub: e.username || '',
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, 12).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 }
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 || '';
$('#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);
}
}
async function doExport() {
const pwd = await askReauth('Enter your master password to export the vault as JSON. The file will be UNENCRYPTED.');
if (!pwd) return;
try {
await api('/reauth', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ masterPassword: pwd }),
});
} catch (err) {
toast('Wrong master password', 'error');
return;
}
// Decrypt all entries
const out = { 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);
out.entries.push({
site: e.site, username: e.username,
password: plain, folder: e.folder,
tags: parseTags(e.tags), favorite: !!e.favorite,
created_at: e.created_at, updated_at: e.updated_at,
});
}
const blob = new Blob([JSON.stringify(out, 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(out.entries.length + ' entries exported');
}
// ============================================================
// VIEWS / NAV
// ============================================================
async function setView(v) {
state.view = v;
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;
}
function openSettings() {
$('#settingTheme').value = state.theme;
$('#settingAutoLock').value = String(state.autoLock);
$('#settingAskDelete').checked = state.askBeforeDelete;
$('#settingCompact').checked = state.compactActions;
$('#settingMaskUser').checked = state.maskUsernames;
$('#settingHIBP').checked = state.hibpEnabled;
$('#settingUser').textContent = state.username;
$('#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 })
);
function showAuth() {
$('#authScreen').classList.remove('is-hidden');
$('#appShell').classList.add('is-hidden');
if (autoLockTimer) { clearTimeout(autoLockTimer); autoLockTimer = null; }
}
async function enterApp() {
$('#authScreen').classList.add('is-hidden');
$('#appShell').classList.remove('is-hidden');
$('#userName').textContent = state.username;
// Show skeleton cards immediately while the initial fetch runs
showSkeletons(6);
await loadFolders();
await loadEntries();
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();
}
// ============================================================
// INIT
// ============================================================
async function init() {
document.documentElement.setAttribute('data-theme', state.theme);
// 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;
localStorage.setItem('viewMode', state.viewMode);
applyViewMode();
}));
$('#themeBtn').addEventListener('click', toggleTheme);
$('#newEntryBtn').addEventListener('click', () => openEntryModal());
$('#userChip').addEventListener('click', () => $('#userDropdown').classList.toggle('is-hidden'));
$('#lockBtn').addEventListener('click', lockVault);
$('#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;
renderGrid();
});
// Marquee rubber-band selection on the entry grid
$('#entryGrid').addEventListener('mousedown', startMarquee);
// 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();
});
// 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';
});
$('#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);
// Idle warning "Stay unlocked"
$('#idleStayBtn').addEventListener('click', resetAutoLock);
// Settings slide-over
$('#settingsBtn').addEventListener('click', openSettings);
$('#settingsClose').addEventListener('click', closeSettings);
$('#settingTheme').addEventListener('change', e => setTheme(e.target.value));
$('#settingAutoLock').addEventListener('change', e => {
state.autoLock = parseInt(e.target.value);
localStorage.setItem('autoLockMin', String(state.autoLock));
resetAutoLock();
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');
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();
});
$('#settingMaskUser').addEventListener('change', e => {
state.maskUsernames = e.target.checked;
localStorage.setItem('maskUsernames', state.maskUsernames ? '1' : '0');
render();
});
$('#settingHIBP').addEventListener('change', e => {
state.hibpEnabled = e.target.checked;
localStorage.setItem('hibpEnabled', state.hibpEnabled ? '1' : '0');
if (state.hibpEnabled) {
toast('Checking passwords against breach database…');
hibpCheckAllEntries();
} else {
state.hibpResults.clear();
render();
toast('Breach check disabled');
}
});
$('#openClipboardSettings').addEventListener('click', () => {
toast('Open Windows Settings → System → Clipboard → turn off "Clipboard history"', 'warning');
});
$('#exportBtn').addEventListener('click', doExport);
// 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))
);
// Command palette
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
openPalette();
} 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;
}
closePalette();
closeSlideOver();
closeEntryModal();
closeGen();
}
});
$('#cmdInput').addEventListener('input', e => renderPaletteResults(e.target.value));
$$('#cmdPalette [data-close]').forEach(b => b.addEventListener('click', closePalette));
// Restore session if any
if (state.token && state.salt) {
const ok = await restoreCryptoKey();
if (ok) {
await enterApp();
} else {
// session token exists but crypto key gone — user must re-enter master pw
showAuth();
$('#loginUsername').value = state.username;
}
} else {
showAuth();
}
}
document.addEventListener('DOMContentLoaded', init);