4b15811221
Round-trip companion to the existing doExport(). Supports two file
formats with auto-detection (extension + first-char sniff):
JSON
====
Native shape produced by doExport() AND a forgiving fallback for any
flat array of entry objects with site/url + password fields. Accepts:
- { version, exported_at, entries: [...] } (native)
- [{ ... }, { ... }] (flat array)
- mixed keys: site|url|name, username|user|login|email, etc.
CSV
===
RFC-4180-ish parser (~30 lines): quoted fields, escaped "", commas
inside quotes, CRLF line endings. No streaming since password-manager
imports are realistically MB-scale at most.
Heuristic column mapping (case + underscore tolerant) covers the
common exporters out of the box:
Site/URL : name, title, url, site, website, login_uri, login_url
Username : login_username, username, user, login, email
Password : login_password, password, pass, pwd
Folder : folder, group, category, path, collection
Tags : tags, labels (comma/semicolon-split)
Notes : notes, note, comment (short notes joined into tags)
TOTP : login_totp, totp, otpauth, authenticator, two_factor
If the TOTP column holds a full otpauth:// URI it's parsed and only
the secret param is stored — same path used by the slide-over TOTP
field. Invalid base32 TOTP secrets are dropped silently rather than
failing the whole import.
Backend
=======
New endpoint: POST /entries/bulk-import
Body: { entries: [{ site, username, encrypted_password, iv, folder,
tags, totp_secret, totp_iv }, ... ] }
Caps at 10,000 entries per request as a sanity bound. Inserts inside
a single SQLite transaction — partial failure rolls back cleanly, the
user retries from the same source file. Returns { imported: N }.
Rows missing site or ciphertext are skipped within the transaction
(not failed) so one bad row in a 500-entry import doesn't blow up
the whole batch.
Client flow
===========
doImport():
1. Hidden <input type="file" accept=".json,.csv"> picker
2. Read text, detect format, route to parseEntriesFromJSON or CSV
3. confirmDialog preview: count + first 3 sample sites + skipped rows
4. On confirm: encryptImportEntry() each plaintext entry with the
current vault key (reuses encryptPwd / base32Decode validation)
5. Single POST to /entries/bulk-import
6. Reload entries, refresh UI, trigger HIBP scan if enabled
UI
==
Two entry points (mirroring Export):
- Sidebar "Import vault" nav item, next to "Export vault"
- Settings panel "Import" section with descriptive blurb
Both call doImport(). New i-log-in icon added to the SVG sprite (mirror
of i-log-out used by Export).
Limitations
===========
- No de-duplication: importing the same file twice yields duplicate
entries. Trade-off to keep the v1 simple — the user can sort it
out with the existing trash/multi-select UI.
- No password-protected vault formats (Bitwarden encrypted JSON,
KeePass kdbx). Only plaintext exports — same trade-off as
doExport() which produces plaintext JSON.
3089 lines
117 KiB
JavaScript
3089 lines
117 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);
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// IMPORT — JSON (native round-trip) + CSV (universal)
|
||
// ============================================================
|
||
//
|
||
// Two supported input formats:
|
||
// 1. Native JSON: the same shape produced by doExport() above
|
||
// { version, exported_at, username, entries: [
|
||
// { site, username, password, folder, tags, favorite, ... }
|
||
// ]}
|
||
// 2. CSV: with a header row. Column names are mapped heuristically so
|
||
// exports from Bitwarden / KeePass / Chrome / 1Password generally
|
||
// "just work" without manual column mapping.
|
||
//
|
||
// Each parsed entry is encrypted client-side with the vault key (same
|
||
// flow as a single-entry add), then sent to /entries/bulk-import as one
|
||
// transactional batch.
|
||
|
||
// Minimal RFC 4180-ish CSV parser. Handles quoted fields, escaped quotes
|
||
// (""), commas inside quotes, and CRLF line endings. Returns an array of
|
||
// arrays (rows × columns). No streaming — fine for the ~MB-scale imports
|
||
// a password manager realistically deals with.
|
||
function parseCSV(text) {
|
||
const rows = [];
|
||
let row = [], field = '', inQuotes = false;
|
||
for (let i = 0; i < text.length; i++) {
|
||
const c = text[i];
|
||
if (inQuotes) {
|
||
if (c === '"') {
|
||
if (text[i + 1] === '"') { field += '"'; i++; } // escaped ""
|
||
else inQuotes = false;
|
||
} else field += c;
|
||
} else {
|
||
if (c === '"') inQuotes = true;
|
||
else if (c === ',') { row.push(field); field = ''; }
|
||
else if (c === '\n' || c === '\r') {
|
||
if (c === '\r' && text[i + 1] === '\n') i++; // CRLF
|
||
row.push(field); field = '';
|
||
if (row.length > 1 || (row.length === 1 && row[0] !== '')) rows.push(row);
|
||
row = [];
|
||
} else field += c;
|
||
}
|
||
}
|
||
// Flush trailing field/row (file without final newline)
|
||
if (field !== '' || row.length > 0) { row.push(field); rows.push(row); }
|
||
return rows;
|
||
}
|
||
|
||
// Header heuristics: pick the first matching column name (case-insensitive,
|
||
// underscore/space-tolerant). Returns null if no candidate header matches.
|
||
function findColumn(headers, candidates) {
|
||
const norm = s => String(s || '').toLowerCase().replace(/[\s_-]+/g, '');
|
||
const cand = candidates.map(norm);
|
||
for (let i = 0; i < headers.length; i++) {
|
||
if (cand.indexOf(norm(headers[i])) >= 0) return i;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Parse a CSV text into an array of plaintext entries
|
||
// ({ site, username, password, folder, tags, totp_secret }). Returns
|
||
// { entries, skipped, columns } so the preview can show what was matched.
|
||
function parseEntriesFromCSV(text) {
|
||
const rows = parseCSV(text);
|
||
if (rows.length < 2) {
|
||
throw new Error('CSV needs a header row and at least one data row');
|
||
}
|
||
const headers = rows[0];
|
||
// Candidate names per format observed in real exports:
|
||
// Bitwarden CSV : folder, name, login_uri, login_username, login_password, login_totp, notes
|
||
// KeePass CSV : Title, URL, Username, Password, Group, Notes
|
||
// Chrome/Edge : name, url, username, password
|
||
// 1Password CSV : Title, URL, Username, Password, Notes
|
||
// findColumn normalizes (lowercase, strip _ and -) so 'login_uri' and
|
||
// 'loginuri' both match the same candidate.
|
||
const colSite = findColumn(headers, ['name', 'title', 'url', 'site', 'website', 'login_uri', 'login_url', 'web_site']);
|
||
const colUser = findColumn(headers, ['login_username', 'username', 'user', 'login', 'email', 'user_name']);
|
||
const colPwd = findColumn(headers, ['login_password', 'password', 'pass', 'pwd']);
|
||
const colFolder = findColumn(headers, ['folder', 'group', 'category', 'path', 'collection']);
|
||
const colTags = findColumn(headers, ['tags', 'labels']);
|
||
const colNotes = findColumn(headers, ['notes', 'note', 'comment', 'comments']);
|
||
const colTotp = findColumn(headers, ['login_totp', 'totp', 'totp_secret', 'otp', 'otpauth', 'authenticator', 'two_factor', 'twofa']);
|
||
|
||
if (colSite === null && colUser === null)
|
||
throw new Error('No recognizable site/url or username column in CSV header');
|
||
if (colPwd === null)
|
||
throw new Error('No recognizable password column in CSV header');
|
||
|
||
const entries = [];
|
||
let skipped = 0;
|
||
for (let i = 1; i < rows.length; i++) {
|
||
const r = rows[i];
|
||
const site = (colSite !== null ? r[colSite] : '').trim() ||
|
||
(colUser !== null ? r[colUser] : '').trim();
|
||
const pwd = (colPwd !== null ? r[colPwd] : '');
|
||
if (!site || !pwd) { skipped++; continue; }
|
||
|
||
// Tags: combine the tags column and any free-form notes into a
|
||
// comma-separated string. Notes often contain useful metadata we
|
||
// don't want to drop on the floor.
|
||
let tagsArr = [];
|
||
if (colTags !== null) {
|
||
String(r[colTags] || '').split(/[,;]/).forEach(t => {
|
||
t = t.trim();
|
||
if (t) tagsArr.push(t);
|
||
});
|
||
}
|
||
if (colNotes !== null) {
|
||
const n = String(r[colNotes] || '').trim();
|
||
if (n && n.length < 80) tagsArr.push(n); // long notes become noise as tags
|
||
}
|
||
|
||
// TOTP: support raw base32 OR full otpauth:// URI in the cell.
|
||
let totp = '';
|
||
if (colTotp !== null) {
|
||
const raw = String(r[colTotp] || '').trim();
|
||
totp = parseOtpAuthUri(raw) || raw;
|
||
}
|
||
|
||
entries.push({
|
||
site: site,
|
||
username: (colUser !== null ? r[colUser] : '').trim(),
|
||
password: pwd,
|
||
folder: (colFolder !== null ? r[colFolder] : '').trim() || 'All',
|
||
tags: tagsArr.join(','),
|
||
totp_secret: totp,
|
||
});
|
||
}
|
||
return { entries, skipped, columns: {
|
||
site: colSite, username: colUser, password: colPwd,
|
||
folder: colFolder, tags: colTags, notes: colNotes, totp: colTotp,
|
||
} };
|
||
}
|
||
|
||
// Parse a native JSON export. Forgiving: accepts both our own format and
|
||
// a flat array of entry objects.
|
||
function parseEntriesFromJSON(text) {
|
||
let data;
|
||
try { data = JSON.parse(text); }
|
||
catch (e) { throw new Error('Invalid JSON: ' + e.message); }
|
||
const raw = Array.isArray(data) ? data : (data.entries || []);
|
||
if (!Array.isArray(raw) || raw.length === 0)
|
||
throw new Error('No entries in JSON file');
|
||
|
||
const entries = [];
|
||
let skipped = 0;
|
||
for (const e of raw) {
|
||
if (!e || typeof e !== 'object') { skipped++; continue; }
|
||
const site = String(e.site || e.url || e.name || '').trim();
|
||
const pwd = String(e.password || '');
|
||
if (!site || !pwd) { skipped++; continue; }
|
||
|
||
const tagsVal = e.tags;
|
||
const tagsStr = Array.isArray(tagsVal) ? tagsVal.join(',')
|
||
: String(tagsVal || '');
|
||
entries.push({
|
||
site: site,
|
||
username: String(e.username || e.user || e.login || '').trim(),
|
||
password: pwd,
|
||
folder: String(e.folder || e.group || 'All').trim() || 'All',
|
||
tags: tagsStr,
|
||
totp_secret: String(e.totp || e.totp_secret || e.otpauth || '').trim(),
|
||
});
|
||
}
|
||
return { entries, skipped, columns: null }; // JSON: no column report
|
||
}
|
||
|
||
// Encrypt one parsed entry (plaintext password + optional TOTP) into the
|
||
// shape the bulk-import endpoint expects. Reuses encryptPwd which already
|
||
// generates a fresh IV per call.
|
||
async function encryptImportEntry(plain) {
|
||
const pw = await encryptPwd(plain.password);
|
||
let totpEnc = '', totpIv = '';
|
||
if (plain.totp_secret) {
|
||
try {
|
||
base32Decode(plain.totp_secret); // validate before encrypting
|
||
const t = await encryptPwd(plain.totp_secret);
|
||
totpEnc = t.encrypted;
|
||
totpIv = t.iv;
|
||
} catch (e) {
|
||
// Bad TOTP secret in source file — keep the entry but drop the
|
||
// 2FA silently. The user can fix it later via the slide-over.
|
||
}
|
||
}
|
||
return {
|
||
site: plain.site,
|
||
username: plain.username || '',
|
||
encrypted_password: pw.encrypted,
|
||
iv: pw.iv,
|
||
folder: plain.folder || 'All',
|
||
tags: plain.tags || '',
|
||
totp_secret: totpEnc,
|
||
totp_iv: totpIv,
|
||
};
|
||
}
|
||
|
||
// Open a hidden file picker, route the result through the right parser,
|
||
// show a preview confirmation, then bulk-encrypt + POST.
|
||
async function doImport() {
|
||
const fileInput = el('input', {
|
||
type: 'file',
|
||
accept: '.json,.csv,application/json,text/csv',
|
||
style: 'display:none',
|
||
});
|
||
document.body.appendChild(fileInput);
|
||
|
||
fileInput.addEventListener('change', async () => {
|
||
const file = fileInput.files && fileInput.files[0];
|
||
fileInput.remove();
|
||
if (!file) return;
|
||
|
||
let text;
|
||
try { text = await file.text(); }
|
||
catch (e) { return toast('Cannot read file: ' + e.message, 'error'); }
|
||
|
||
const isJSON = /\.json$/i.test(file.name) || text.trim().startsWith('{') || text.trim().startsWith('[');
|
||
let parsed;
|
||
try {
|
||
parsed = isJSON ? parseEntriesFromJSON(text) : parseEntriesFromCSV(text);
|
||
} catch (e) {
|
||
return toast('Parse error: ' + e.message, 'error');
|
||
}
|
||
|
||
if (parsed.entries.length === 0) {
|
||
return toast('No valid entries found in file', 'warning');
|
||
}
|
||
|
||
// Build the preview message (innerHTML target → escape user data)
|
||
const esc = s => String(s).replace(/[&<>"]/g,
|
||
c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||
const parts = [];
|
||
parts.push('<b>' + parsed.entries.length + '</b> entries detected in <code>' +
|
||
esc(file.name) + '</code>');
|
||
if (parsed.skipped > 0)
|
||
parts.push('<span style="color:var(--text-dim)">' + parsed.skipped +
|
||
' rows skipped (missing site or password)</span>');
|
||
const sample = parsed.entries.slice(0, 3).map(e =>
|
||
'• ' + esc(e.site || '?') +
|
||
(e.username ? ' <span style="color:var(--text-dim)">(' + esc(e.username) + ')</span>' : '')
|
||
).join('<br>');
|
||
parts.push('<div style="margin-top:8px;font-size:12px">' + sample +
|
||
(parsed.entries.length > 3 ? '<br>…' : '') + '</div>');
|
||
parts.push('<div style="margin-top:10px">Import now? This adds the entries to your existing vault.</div>');
|
||
|
||
const confirmed = await confirmDialog({
|
||
title: 'Import vault',
|
||
message: parts.join('<br>'),
|
||
okText: 'Import',
|
||
});
|
||
if (!confirmed) return;
|
||
|
||
// Encrypt all entries client-side, then POST as one transaction.
|
||
toast('Encrypting ' + parsed.entries.length + ' entries…');
|
||
const encrypted = [];
|
||
for (const e of parsed.entries) {
|
||
encrypted.push(await encryptImportEntry(e));
|
||
}
|
||
|
||
try {
|
||
const r = await api('/entries/bulk-import', {
|
||
method: 'POST',
|
||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||
body: JSON.stringify({ entries: encrypted }),
|
||
});
|
||
toast('Imported ' + r.imported + ' entries');
|
||
await loadEntries();
|
||
render();
|
||
if (state.hibpEnabled) hibpCheckAllEntries(); // scan the new entries too
|
||
} catch (err) {
|
||
toast('Import failed: ' + err.message, 'error');
|
||
}
|
||
});
|
||
|
||
fileInput.click();
|
||
}
|
||
|
||
async function doExport() {
|
||
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);
|
||
$('#sidebarImportBtn').addEventListener('click', doImport);
|
||
|
||
// 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);
|
||
$('#importBtn').addEventListener('click', doImport);
|
||
|
||
// 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);
|