60aa106a30
THE PROBLEM
===========
Before this commit, users.password_hash stored on the server contained
PBKDF2(pw, salt, iters) in hex — the exact same 32 bytes the client
uses as the AES-GCM key to encrypt every entry. Anyone who got hold of
vault.db (filesystem access, backup leak, etc.) had the encryption key
in their hand, no brute force needed. The increased PBKDF2 iteration
count from the previous commit helped against the cipher-text path,
but the easier path was right there in the user row.
THE FIX
=======
Wrap the PBKDF2 output in SHA-256 before storing:
password_hash = SHA256(PBKDF2(pw, salt, iters))
SHA-256 is one-way. The stored hash can still be verified at login
(server recomputes PBKDF2 from the posted master pw, then SHA-256s it,
compares to stored), but the AES key can no longer be recovered from
it. At rest, vault.db only contains an irreversible derivative.
The server still sees pw transiently during /login while computing
the comparison — eliminating that requires a redesigned auth
protocol where the client sends a pre-computed verifier (SRP, OPAQUE,
or simply SHA-256(PBKDF2(pw, salt, iters)) sent from the client).
That's a separate, larger refactor. This commit closes the at-rest
hole, which is the realistic attack surface for vault file leaks.
SCHEMA / MARKER
===============
users.hash_algo distinguishes the two schemes:
'pbkdf2' — LEGACY (raw hex, = AES key)
'pbkdf2-sha256' — CURRENT (SHA-256-wrapped, one-way)
A constant HASH_ALGO_CURRENT replaces the string literal everywhere
to avoid silent drift between the writer and the reader sides.
MIGRATION
=========
Folded into the existing /migrate-kdf endpoint introduced for the
100k→600k iteration bump. Login response now signals migration on
EITHER:
- kdf_iterations < PBKDF2_ITERATIONS_TARGET, OR
- hash_algo != 'pbkdf2-sha256'
The endpoint handles both transitions in one atomic transaction:
UPDATE users SET password_hash = SHA256(PBKDF2(pw, salt, 600k)),
kdf_iterations = 600000,
hash_algo = 'pbkdf2-sha256'
UPDATE vault_entries SET encrypted_password, iv (per entry, if KDF changed)
Idempotency tightened: the "already at target" short-circuit now
requires BOTH conditions, not just the iteration count. Without this,
users who migrated KDF before this commit landed would have been
stuck on the legacy hash format.
CLIENT
======
runKdfMigration() branches on whether the KDF actually changed:
- kdfChange (fromIters !== toIters): re-encrypt all entries with the
new key, send them in the entries array, swap state.cryptoKey on
success. Shows "Vault security upgraded" toast.
- !kdfChange (hash format only): skip the entry re-encryption loop
entirely, send entries: []. Silent — the user didn't perceive a
weakness change worth toasting about.
LOGIN / REAUTH
==============
Both now branch on hash_algo to pick the right verifier:
HASH_ALGO_LEGACY → ConstantTimeEquals(stored, PBKDF2(pw, salt, iters))
HASH_ALGO_CURRENT → ConstantTimeEquals(stored, SHA256(PBKDF2(pw, salt, iters)))
Same constant-time comparison helper as before. Same legacy bcrypt
fallback (still 501-not-implemented).
ALL THREE SCENARIOS AFTER THIS COMMIT
=====================================
1. New register: starts at HASH_ALGO_CURRENT + 600k. No migration ever.
2. Legacy 100k + 'pbkdf2': full migration on next login (hash format
+ iter count + entry re-encryption) in one transaction.
3. Mid-state (already-migrated KDF + still-'pbkdf2'): hash format
upgrade only on next login, no entry re-encryption.
2812 lines
106 KiB
JavaScript
2812 lines
106 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;
|
|
}
|
|
|
|
// ============================================================
|
|
// TOTP (RFC 6238) — 6-digit time-based codes
|
|
// ============================================================
|
|
//
|
|
// Implementation is pure crypto.subtle (HMAC-SHA1) + a small base32
|
|
// decoder. No external library. The secret is stored encrypted with the
|
|
// vault's AES-GCM key (same flow as passwords), so the server never sees
|
|
// the plaintext base32 secret.
|
|
|
|
// Decode an RFC 4648 base32 string (Google Authenticator format) to bytes.
|
|
// Tolerates lowercase, spaces, and padding. Throws on invalid characters.
|
|
function base32Decode(s) {
|
|
const ALPH = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
const clean = String(s).toUpperCase().replace(/[\s=]/g, '');
|
|
let bits = 0, buffer = 0;
|
|
const out = [];
|
|
for (const ch of clean) {
|
|
const v = ALPH.indexOf(ch);
|
|
if (v < 0) throw new Error('Invalid base32 character: ' + ch);
|
|
buffer = (buffer << 5) | v;
|
|
bits += 5;
|
|
if (bits >= 8) {
|
|
bits -= 8;
|
|
out.push((buffer >> bits) & 0xFF);
|
|
}
|
|
}
|
|
return new Uint8Array(out);
|
|
}
|
|
|
|
// Generate a TOTP code per RFC 6238. Returns the 6-digit code as a string
|
|
// (zero-padded) along with how many seconds remain in the current 30s window.
|
|
// Throws if the secret can't be decoded.
|
|
async function generateTOTP(secretBase32, period, digits) {
|
|
period = period || 30;
|
|
digits = digits || 6;
|
|
const keyBytes = base32Decode(secretBase32);
|
|
// Counter = floor(unix_time / period), encoded as 8-byte big-endian.
|
|
const nowSec = Math.floor(Date.now() / 1000);
|
|
let counter = Math.floor(nowSec / period);
|
|
const counterBytes = new Uint8Array(8);
|
|
for (let i = 7; i >= 0; i--) {
|
|
counterBytes[i] = counter & 0xFF;
|
|
counter = Math.floor(counter / 256);
|
|
}
|
|
|
|
const cryptoKey = await crypto.subtle.importKey(
|
|
'raw', keyBytes,
|
|
{ name: 'HMAC', hash: 'SHA-1' },
|
|
false, ['sign']
|
|
);
|
|
const sigBuf = await crypto.subtle.sign('HMAC', cryptoKey, counterBytes);
|
|
const sig = new Uint8Array(sigBuf);
|
|
|
|
// Dynamic truncation: low nibble of last byte = offset into HMAC output.
|
|
const offset = sig[sig.length - 1] & 0x0F;
|
|
const truncated =
|
|
((sig[offset] & 0x7F) << 24) |
|
|
((sig[offset + 1] & 0xFF) << 16) |
|
|
((sig[offset + 2] & 0xFF) << 8) |
|
|
( sig[offset + 3] & 0xFF);
|
|
const mod = Math.pow(10, digits);
|
|
const code = String(truncated % mod).padStart(digits, '0');
|
|
|
|
return {
|
|
code: code,
|
|
period: period,
|
|
secondsLeft: period - (nowSec % period),
|
|
};
|
|
}
|
|
|
|
// Parse a Google Authenticator-style otpauth:// URI and extract the secret.
|
|
// Example: otpauth://totp/Example:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example
|
|
// Returns the secret alone (we don't yet honor issuer/algorithm/digits/period
|
|
// overrides — assume SHA-1 / 6 digits / 30s, which covers ~all real services).
|
|
function parseOtpAuthUri(raw) {
|
|
raw = String(raw || '').trim();
|
|
if (!raw.toLowerCase().startsWith('otpauth://')) return null;
|
|
try {
|
|
const u = new URL(raw);
|
|
const sec = u.searchParams.get('secret');
|
|
return sec ? sec.trim() : null;
|
|
} catch (e) { return null; }
|
|
}
|
|
|
|
// Encrypt a TOTP secret with the vault key. Returns { encrypted, iv } in
|
|
// the same base64 shape as encryptPwd, ready to send to the server.
|
|
async function encryptTotpSecret(secretBase32) {
|
|
return await encryptPwd(secretBase32); // same crypto, just different field
|
|
}
|
|
async function decryptTotpSecret(encB64, ivB64) {
|
|
return await decryptPwd(encB64, ivB64);
|
|
}
|
|
|
|
// ============================================================
|
|
// HIBP — Have I Been Pwned breach check (k-anonymity)
|
|
// ============================================================
|
|
//
|
|
// HIBP's range API exposes pwned password counts without ever seeing the
|
|
// password (or even its full hash):
|
|
// 1. Client computes SHA-1 of the password.
|
|
// 2. Client sends ONLY the first 5 hex chars to api.pwnedpasswords.com/range/XXXXX
|
|
// 3. Server returns up to ~500 suffixes (35 chars each) with counts.
|
|
// 4. Client searches the response for its own suffix locally.
|
|
//
|
|
// This means the network observer (and HIBP itself) sees only the 5-char
|
|
// prefix — which matches ~3,000 of the ~half-billion known pwned passwords.
|
|
// Information leakage is bounded by design.
|
|
//
|
|
// Toggle is OFF by default. When enabled, all entries are checked once
|
|
// after vault load, then individual entries are re-checked when the user
|
|
// edits the password. Results cached in state.hibpResults keyed by entry id.
|
|
|
|
async function sha1Hex(text) {
|
|
const buf = new TextEncoder().encode(text);
|
|
const hashBuf = await crypto.subtle.digest('SHA-1', buf);
|
|
const bytes = new Uint8Array(hashBuf);
|
|
let hex = '';
|
|
for (const b of bytes) hex += b.toString(16).padStart(2, '0');
|
|
return hex.toUpperCase();
|
|
}
|
|
|
|
// Returns the breach count (0 if not found, >0 if pwned). Throws on
|
|
// network failure — caller decides whether to silently skip or alert.
|
|
async function hibpCheckPassword(plaintext) {
|
|
if (!plaintext) return 0;
|
|
const hash = await sha1Hex(plaintext);
|
|
const prefix = hash.substring(0, 5);
|
|
const suffix = hash.substring(5);
|
|
|
|
const resp = await fetch('https://api.pwnedpasswords.com/range/' + prefix, {
|
|
// Padding mitigates side-channel attacks where an observer counts
|
|
// bytes in the response to narrow down the prefix queried.
|
|
headers: { 'Add-Padding': 'true' },
|
|
});
|
|
if (!resp.ok) throw new Error('HIBP HTTP ' + resp.status);
|
|
const body = await resp.text();
|
|
// Body lines: "SUFFIX:COUNT\r\n" — search for our suffix.
|
|
for (const line of body.split('\n')) {
|
|
const colonAt = line.indexOf(':');
|
|
if (colonAt <= 0) continue;
|
|
if (line.substring(0, colonAt).trim() === suffix) {
|
|
return parseInt(line.substring(colonAt + 1).trim(), 10) || 0;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
// Batch-check every entry currently in state.entries. Awaits all in
|
|
// parallel but with a small concurrency cap so we don't hammer HIBP
|
|
// or trip browser connection limits. Mutates state.hibpResults and
|
|
// re-renders to show the new badges.
|
|
async function hibpCheckAllEntries() {
|
|
if (!state.hibpEnabled || !state.entries.length) return;
|
|
const CONCURRENCY = 6;
|
|
const queue = state.entries.slice();
|
|
const workers = [];
|
|
|
|
for (let w = 0; w < CONCURRENCY; w++) {
|
|
workers.push((async () => {
|
|
while (queue.length) {
|
|
const entry = queue.shift();
|
|
try {
|
|
const pwd = await decryptPwd(entry.encrypted_password, entry.iv);
|
|
if (pwd === '[ERROR]') continue;
|
|
const count = await hibpCheckPassword(pwd);
|
|
state.hibpResults.set(entry.id, count);
|
|
} catch (e) {
|
|
// Network or decrypt failure: skip silently. Will retry
|
|
// next time the user opens the vault.
|
|
}
|
|
}
|
|
})());
|
|
}
|
|
await Promise.all(workers);
|
|
render();
|
|
}
|
|
|
|
// ============================================================
|
|
// KDF MIGRATION (PBKDF2 100k → 600k re-encryption)
|
|
// ============================================================
|
|
//
|
|
// When the server signals kdfMigration in /login or /reauth, we transparently
|
|
// re-encrypt every entry with a stronger key (600k PBKDF2 iterations) and
|
|
// commit the new ciphertext + the new server-side hash in one atomic
|
|
// /migrate-kdf request. If anything fails, the user stays on the legacy
|
|
// config and the migration retries at next login. The entries currently
|
|
// loaded in state.entries are encrypted with the OLD key (state.cryptoKey).
|
|
//
|
|
// Threading: runs in the background after enterApp completes. Locking the
|
|
// vault during migration is safe — we just lose the in-flight transition
|
|
// and the server's atomic rollback means nothing persisted.
|
|
|
|
let kdfMigrationInProgress = false;
|
|
|
|
async function runKdfMigration(masterPwd, fromIters, toIters) {
|
|
if (kdfMigrationInProgress) return; // dedupe concurrent calls
|
|
if (!state.entries || !state.cryptoKey) return;
|
|
kdfMigrationInProgress = true;
|
|
|
|
try {
|
|
// Two distinct migration scenarios:
|
|
// A. fromIters !== toIters: KDF iteration count is changing, so
|
|
// the AES key is changing. We re-encrypt every entry with the
|
|
// new key + fresh IVs, swap state.cryptoKey at the end.
|
|
// B. fromIters === toIters: same KDF, only the server-side hash
|
|
// format is being upgraded (legacy "pbkdf2" raw → "pbkdf2-sha256"
|
|
// wrapped). No entry re-encryption needed — just trigger the
|
|
// endpoint so the server rewrites the user row.
|
|
const kdfChange = fromIters !== toIters;
|
|
let newKey, newCiphertexts;
|
|
|
|
if (kdfChange) {
|
|
newKey = await deriveKey(masterPwd, state.salt, toIters);
|
|
// Re-encrypt every entry. Each entry gets a fresh random IV
|
|
// under the new key — never reuse the old IV with the new key
|
|
// (would be pointless but also a small information leak via IV
|
|
// reuse patterns).
|
|
newCiphertexts = [];
|
|
for (const entry of state.entries) {
|
|
const plain = await decryptPwd(entry.encrypted_password, entry.iv);
|
|
if (plain === '[ERROR]') {
|
|
// One decrypt failure aborts the whole migration —
|
|
// better to stay on the legacy config than commit
|
|
// partial state.
|
|
throw new Error('Could not decrypt entry id=' + entry.id);
|
|
}
|
|
const tmpKey = state.cryptoKey;
|
|
try {
|
|
state.cryptoKey = newKey;
|
|
const re = await encryptPwd(plain);
|
|
newCiphertexts.push({
|
|
id: entry.id,
|
|
encrypted_password: re.encrypted,
|
|
iv: re.iv,
|
|
});
|
|
} finally {
|
|
state.cryptoKey = tmpKey; // restore for any concurrent read
|
|
}
|
|
}
|
|
} else {
|
|
// Hash-format-only upgrade — server still wants an entries
|
|
// array (it's an idempotent transactional update), just empty.
|
|
newCiphertexts = [];
|
|
}
|
|
|
|
// Send the atomic migrate request. Server verifies the master pw
|
|
// against the OLD hash, then updates the user row (hash, iter
|
|
// count, hash_algo) AND every entry's ciphertext in a single
|
|
// transaction.
|
|
await api('/migrate-kdf', {
|
|
method: 'POST',
|
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
|
body: JSON.stringify({
|
|
masterPassword: masterPwd,
|
|
entries: newCiphertexts,
|
|
}),
|
|
});
|
|
|
|
if (kdfChange) {
|
|
// Swap to the new AES key + update cached ciphertexts.
|
|
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)');
|
|
} else {
|
|
// Format-only upgrade is silent — the user didn't perceive a
|
|
// weakness change, and nothing visible in the UI changed.
|
|
// (A subtle "Auth format upgraded" toast felt noisy.)
|
|
}
|
|
} catch (err) {
|
|
// Silent retry on next login — the migration is idempotent and
|
|
// safe to abandon (server rolled back).
|
|
console.warn('KDF migration aborted:', err);
|
|
} finally {
|
|
kdfMigrationInProgress = false;
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// ACCOUNT LOCKOUT UI
|
|
// ============================================================
|
|
|
|
let lockoutTimer = null;
|
|
|
|
// Called when the backend responds with 429 + retry_after on /login or
|
|
// /reauth. Disables the auth form and displays a live countdown in
|
|
// #authHint. When the countdown reaches 0, the form is re-enabled.
|
|
function showLockoutCountdown(seconds) {
|
|
if (lockoutTimer) { clearInterval(lockoutTimer); lockoutTimer = null; }
|
|
const hint = $('#authHint');
|
|
const btn = $('#loginBtn');
|
|
const fmt = (s) => {
|
|
if (s >= 3600) return Math.ceil(s / 3600) + ' h';
|
|
if (s >= 60) return Math.ceil(s / 60) + ' min';
|
|
return s + ' s';
|
|
};
|
|
const tick = () => {
|
|
if (seconds <= 0) {
|
|
clearInterval(lockoutTimer); lockoutTimer = null;
|
|
if (hint) hint.textContent = 'You can try again now.';
|
|
if (btn) btn.disabled = false;
|
|
return;
|
|
}
|
|
if (hint) hint.textContent = 'Account locked — try again in ' + fmt(seconds);
|
|
seconds--;
|
|
};
|
|
if (btn) btn.disabled = true;
|
|
tick(); // show first frame immediately
|
|
lockoutTimer = setInterval(tick, 1000);
|
|
}
|
|
|
|
// ============================================================
|
|
// TOAST
|
|
// ============================================================
|
|
|
|
function toast(msg, type) {
|
|
type = type || 'success';
|
|
const container = $('#toastContainer');
|
|
const t = document.createElement('div');
|
|
t.className = 'toast is-' + type;
|
|
t.textContent = msg;
|
|
container.appendChild(t);
|
|
setTimeout(() => t.remove(), 2800);
|
|
}
|
|
|
|
// ============================================================
|
|
// DOM HELPERS
|
|
// ============================================================
|
|
|
|
function $(sel, root) { return (root || document).querySelector(sel); }
|
|
function $$(sel, root) { return Array.from((root || document).querySelectorAll(sel)); }
|
|
function el(tag, props, ...kids) {
|
|
const e = document.createElement(tag);
|
|
if (props) for (const k in props) {
|
|
if (k === 'class') e.className = props[k];
|
|
else if (k === 'on') for (const ev in props.on) e.addEventListener(ev, props.on[ev]);
|
|
else if (k === 'html') e.innerHTML = props[k];
|
|
else if (k in e) e[k] = props[k];
|
|
else e.setAttribute(k, props[k]);
|
|
}
|
|
for (const k of kids) {
|
|
if (k == null) continue;
|
|
e.appendChild(typeof k === 'string' ? document.createTextNode(k) : k);
|
|
}
|
|
return e;
|
|
}
|
|
function icon(id) {
|
|
const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
const u = document.createElementNS('http://www.w3.org/2000/svg', 'use');
|
|
u.setAttribute('href', '#' + id);
|
|
s.appendChild(u);
|
|
return s;
|
|
}
|
|
|
|
// ============================================================
|
|
// AUTH
|
|
// ============================================================
|
|
|
|
async function doLogin(e) {
|
|
e && e.preventDefault();
|
|
const u = $('#loginUsername').value.trim();
|
|
const p = $('#loginPassword').value;
|
|
if (!u || !p) return;
|
|
// If we are in locked mode (token still valid), try fast unlock first.
|
|
if (state.locked && state.token && state.salt && u === state.username) {
|
|
$('#loginBtn').disabled = true;
|
|
const ok = await doUnlock(p);
|
|
$('#loginBtn').disabled = false;
|
|
if (ok) return;
|
|
// unlock failed — fall through to a full login
|
|
}
|
|
$('#loginBtn').disabled = true;
|
|
try {
|
|
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);
|
|
}
|
|
// 2FA indicator — entry has a TOTP secret configured. Server returns
|
|
// null for both fields when none; truthy = configured (the actual
|
|
// secret stays encrypted until the user opens the slide-over).
|
|
if (e.totp_secret && e.totp_iv) {
|
|
const chip = el('span', {
|
|
class: 'entry-chip is-2fa',
|
|
title: 'Two-factor authentication (TOTP) configured',
|
|
});
|
|
chip.appendChild(icon('i-lock'));
|
|
chip.appendChild(el('span', null, '2FA'));
|
|
meta.appendChild(chip);
|
|
}
|
|
card.appendChild(meta);
|
|
|
|
return card;
|
|
}
|
|
|
|
// ============================================================
|
|
// 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);
|
|
|
|
// Decrypt TOTP secret if present. Empty string when no TOTP configured
|
|
// OR when decryption fails (orphan ciphertext, key mismatch, etc.) — the
|
|
// UI treats both cases as "no 2FA", so the user can re-paste a secret to
|
|
// recover.
|
|
let plainTotp = '';
|
|
if (e.totp_secret && e.totp_iv) {
|
|
plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv);
|
|
if (plainTotp === '[ERROR]') plainTotp = '';
|
|
}
|
|
|
|
// Track original values so we can detect "dirty"
|
|
soState = {
|
|
id: e.id,
|
|
original: {
|
|
site: e.site, username: e.username || '', password: plain,
|
|
folder: e.folder || 'All', tags: parseTags(e.tags).join(','),
|
|
totp: plainTotp,
|
|
},
|
|
tags: parseTags(e.tags),
|
|
originalEncrypted: e.encrypted_password,
|
|
originalIV: e.iv,
|
|
originalTotpEncrypted: e.totp_secret,
|
|
originalTotpIV: e.totp_iv,
|
|
};
|
|
|
|
body.appendChild(soEditableField('Site', 'soSite', e.site));
|
|
body.appendChild(soEditableField('Username', 'soUsername', e.username || ''));
|
|
body.appendChild(soPasswordField(plain));
|
|
body.appendChild(soTotpField(plainTotp));
|
|
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;
|
|
}
|
|
|
|
// ---- TOTP field in slide-over (input + live code + countdown) ----
|
|
let totpTickTimer = null;
|
|
|
|
function startTotpTick() {
|
|
if (totpTickTimer) return;
|
|
// Refresh once per second so the countdown bar moves smoothly and the
|
|
// code auto-rolls when the 30s window expires.
|
|
totpTickTimer = setInterval(updateTotpDisplay, 1000);
|
|
updateTotpDisplay();
|
|
}
|
|
|
|
function stopTotpTick() {
|
|
if (totpTickTimer) { clearInterval(totpTickTimer); totpTickTimer = null; }
|
|
}
|
|
|
|
async function updateTotpDisplay() {
|
|
const input = $('#soTotpSecret');
|
|
const codeEl = $('#soTotpCode');
|
|
const barEl = $('#soTotpProgress');
|
|
if (!input || !codeEl) { stopTotpTick(); return; }
|
|
const secret = (input.value || '').trim();
|
|
if (!secret) {
|
|
codeEl.textContent = '';
|
|
codeEl.classList.remove('is-invalid');
|
|
if (barEl) barEl.style.width = '0%';
|
|
return;
|
|
}
|
|
try {
|
|
const t = await generateTOTP(secret);
|
|
// Format as "123 456" — the standard spacing for authenticator apps
|
|
codeEl.textContent = t.code.slice(0, 3) + ' ' + t.code.slice(3);
|
|
codeEl.classList.remove('is-invalid');
|
|
if (barEl) {
|
|
const pct = (t.secondsLeft / t.period) * 100;
|
|
barEl.style.width = pct + '%';
|
|
// Switch to red when < 5s left so the user notices the imminent roll
|
|
barEl.style.background = t.secondsLeft < 5 ? '#dc2626' : 'var(--accent)';
|
|
}
|
|
} catch (err) {
|
|
codeEl.textContent = 'invalid secret';
|
|
codeEl.classList.add('is-invalid');
|
|
if (barEl) barEl.style.width = '0%';
|
|
}
|
|
}
|
|
|
|
function soTotpField(plainSecret) {
|
|
const wrap = el('div', { class: 'slideover-field' });
|
|
wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Two-factor (TOTP)'));
|
|
|
|
const row = el('div', { class: 'so-pw-row' });
|
|
const input = el('input', {
|
|
type: 'password', id: 'soTotpSecret',
|
|
value: plainSecret || '',
|
|
class: 'so-input',
|
|
placeholder: 'Paste base32 secret or otpauth:// URI',
|
|
style: 'flex:1;font-family:JetBrains Mono,monospace',
|
|
autocomplete: 'off', spellcheck: 'false',
|
|
});
|
|
// If the user pastes a full otpauth:// URI, auto-extract the secret param
|
|
// so the displayed value is the clean base32 only. Triggers via 'input'
|
|
// (covers both paste events and manual typing).
|
|
input.addEventListener('input', () => {
|
|
const v = input.value.trim();
|
|
const fromUri = parseOtpAuthUri(v);
|
|
if (fromUri) input.value = fromUri;
|
|
updateTotpDisplay();
|
|
soDirtyCheck();
|
|
});
|
|
const toggle = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Show/hide secret' });
|
|
toggle.appendChild(icon('i-eye'));
|
|
toggle.addEventListener('click', () => {
|
|
input.type = input.type === 'password' ? 'text' : 'password';
|
|
});
|
|
const clear = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Remove TOTP' });
|
|
clear.appendChild(icon('i-x'));
|
|
clear.addEventListener('click', () => {
|
|
input.value = '';
|
|
updateTotpDisplay();
|
|
soDirtyCheck();
|
|
});
|
|
row.appendChild(input);
|
|
row.appendChild(toggle);
|
|
row.appendChild(clear);
|
|
wrap.appendChild(row);
|
|
|
|
// Live code panel — shows the current 6-digit code with a copy button
|
|
// and a progress bar that drains over the 30s window.
|
|
const panel = el('div', { class: 'totp-panel' });
|
|
const codeEl = el('div', { class: 'totp-code', id: 'soTotpCode' });
|
|
panel.appendChild(codeEl);
|
|
const copyBtn = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Copy code' });
|
|
copyBtn.appendChild(icon('i-copy'));
|
|
copyBtn.addEventListener('click', async () => {
|
|
const secret = (input.value || '').trim();
|
|
if (!secret) return;
|
|
try {
|
|
const t = await generateTOTP(secret);
|
|
if (Bridge.copySecure(t.code, 30000)) {
|
|
toast('Code copied · clears in 30s');
|
|
} else {
|
|
navigator.clipboard.writeText(t.code).then(() => {
|
|
toast('Code copied · clears in 30s');
|
|
setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000);
|
|
});
|
|
}
|
|
} catch (e) {
|
|
toast('Invalid TOTP secret', 'error');
|
|
}
|
|
});
|
|
panel.appendChild(copyBtn);
|
|
wrap.appendChild(panel);
|
|
|
|
const barWrap = el('div', { class: 'totp-bar-wrap' });
|
|
const bar = el('div', { class: 'totp-bar', id: 'soTotpProgress' });
|
|
barWrap.appendChild(bar);
|
|
wrap.appendChild(barWrap);
|
|
|
|
startTotpTick();
|
|
return wrap;
|
|
}
|
|
|
|
function soFolderField(current) {
|
|
const wrap = el('div', { class: 'slideover-field' });
|
|
wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Folder'));
|
|
const sel = el('select', { id: 'soFolder', class: 'so-input' });
|
|
state.folders.forEach(f => {
|
|
const opt = el('option', { value: f }, 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 || '',
|
|
totp: ($('#soTotpSecret') || {}).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.totp !== soState.original.totp ||
|
|
cur.tags !== soState.original.tags;
|
|
const btn = $('#soSaveBtn');
|
|
if (btn) btn.style.display = dirty ? '' : 'none';
|
|
}
|
|
|
|
async function soSave() {
|
|
if (!soState) return;
|
|
const site = $('#soSite').value.trim();
|
|
const user = $('#soUsername').value.trim();
|
|
const pwd = $('#soPassword').value;
|
|
const fold = $('#soFolder').value;
|
|
const totp = (($('#soTotpSecret') || {}).value || '').trim();
|
|
if (!site || !pwd) return toast('Site and password required', 'error');
|
|
|
|
// 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);
|
|
}
|
|
|
|
// Same idea for TOTP: re-encrypt only if changed, send empty strings when
|
|
// cleared so the server stores NULL.
|
|
let totpEnc = '';
|
|
let totpIv = '';
|
|
if (totp !== '') {
|
|
if (totp === soState.original.totp && soState.originalTotpEncrypted) {
|
|
totpEnc = soState.originalTotpEncrypted;
|
|
totpIv = soState.originalTotpIV;
|
|
} else {
|
|
// Validate the secret can be decoded BEFORE saving — saving a
|
|
// garbled base32 wouldn't break anything but would surprise the
|
|
// user when the code panel shows "invalid secret" next time.
|
|
try { base32Decode(totp); }
|
|
catch (e) { return toast('Invalid TOTP secret (must be base32)', 'error'); }
|
|
const tEnc = await encryptTotpSecret(totp);
|
|
totpEnc = tEnc.encrypted;
|
|
totpIv = tEnc.iv;
|
|
}
|
|
}
|
|
|
|
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,
|
|
totp_secret: totpEnc, totp_iv: totpIv,
|
|
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() {
|
|
stopTotpTick();
|
|
$('#slideover').classList.remove('is-open');
|
|
state.selectedId = null;
|
|
renderGrid();
|
|
}
|
|
|
|
// ============================================================
|
|
// TAG CHIP INPUT
|
|
// ============================================================
|
|
// Local mutable list of tags currently in the entry modal. Synced to the
|
|
// hidden #entryTags field on every change so saveEntry can read it.
|
|
|
|
let editingTags = [];
|
|
let chipSuggestEl = null;
|
|
let chipSuggestActive = -1;
|
|
|
|
function syncTagsHidden() {
|
|
$('#entryTags').value = editingTags.join(',');
|
|
}
|
|
|
|
function renderChips() {
|
|
const container = $('#entryTagsInput');
|
|
// Wipe existing chips but keep the input element
|
|
$$('.chip', container).forEach(c => c.remove());
|
|
const input = $('#entryTagsField');
|
|
editingTags.forEach((t, i) => {
|
|
const chip = el('span', { class: 'chip' });
|
|
chip.appendChild(el('span', null, t));
|
|
const x = el('button', {
|
|
type: 'button',
|
|
on: { click: () => { 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);
|