a45897c33d
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;
2526 lines
94 KiB
JavaScript
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 & 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);
|