/** * extension/popup/popup.js — PassKeeper popup UI * * Storage layout: * chrome.storage.session — access_token, vault_key_jwk, vault_items (cleared on browser close) * chrome.storage.local — refresh_token, enc_key_salt (persists across restarts) * * SSO flow (web app → extension): * bridge.js syncs tokens to background → background writes to chrome.storage. * On popup open: if session has no vault_key but local has enc_key_salt + refresh_token, * we refresh the access_token and show the unlock-only view (master password only). */ const API_BASE = "https://pwkeeper.ngodanguyen.tech"; const VAULT_URL = "https://pwkeeper.ngodanguyen.tech/vault"; const ALERTS_URL = "https://pwkeeper.ngodanguyen.tech/vault#security"; // ── State ───────────────────────────────────────────────────────────────────── let _vaultKey = null; let _items = []; let _folders = []; // fetched alongside vault items for folder grouping let _mfaToken = null; let _currentUrl = ""; let _activeTab = "relevant"; const _collapsedFolders = new Set(); // persists collapsed state across re-renders let _sharingPrivKey = null; // decrypted ECDH private key for the session // ── DOM helpers ─────────────────────────────────────────────────────────────── const $ = (id) => document.getElementById(id); function showView(name) { ["login", "mfa", "unlock", "vault", "generator", "account", "add"].forEach( (v) => $(`view-${v}`).classList.toggle("hidden", v !== name), ); // Bottom nav visible only in authenticated views. const navViews = ["vault", "generator", "account"]; $("main-bottom-nav").classList.toggle("hidden", !navViews.includes(name)); // Sync active state on nav buttons. ["nav-vault", "nav-generator", "nav-account"].forEach((id) => { const btn = $(id); if (!btn) return; const active = (id === "nav-vault" && name === "vault") || (id === "nav-generator" && name === "generator") || (id === "nav-account" && name === "account"); btn.classList.toggle("active", active); }); } function showError(elId, msg) { const el = $(elId); el.textContent = msg; el.classList.remove("hidden"); } function hideError(elId) { $(elId).classList.add("hidden"); } function escHtml(str) { // " and ' are both required: templates in this file use a mix of // double- and single-quoted attributes, and an unescaped quote of either // kind lets injected text break out of an attribute. return String(str ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } // Auto-clear clipboard 30 s after a sensitive copy. let _clipTimer = null; function _copyWithAutoClear(text) { navigator.clipboard.writeText(text).catch(() => { }); if (_clipTimer) clearTimeout(_clipTimer); _clipTimer = setTimeout(() => { navigator.clipboard.writeText("").catch(() => { }); _clipTimer = null; }, 30_000); } /** * Show an in-popup master-password re-prompt overlay. * Resolves true if the entered password matches the current vault key, * false if the user cancels or enters a wrong password. */ async function _repromptMasterPassword() { return new Promise((resolve) => { // Build the overlay element. const overlay = document.createElement("div"); overlay.id = "pk-reprompt-overlay"; overlay.className = "reprompt-overlay"; overlay.innerHTML = `
Master password required

This item is protected. Enter your master password to continue.

`; document.getElementById("app").appendChild(overlay); const input = overlay.querySelector("#reprompt-pw"); const errEl = overlay.querySelector("#reprompt-error"); input.focus(); async function attempt() { errEl.classList.add("hidden"); const pw = input.value; if (!pw) { errEl.textContent = "Enter your master password."; errEl.classList.remove("hidden"); return; } try { let { enc_key_salt } = await chrome.storage.session.get("enc_key_salt"); if (!enc_key_salt) { const local = await chrome.storage.local.get("enc_key_salt"); enc_key_salt = local.enc_key_salt; } if (!enc_key_salt) { errEl.textContent = "Session expired. Please log in again."; errEl.classList.remove("hidden"); return; } // Derive a candidate key and compare its JWK to the stored key. const candidate = await ExtCrypto.deriveVaultKey(pw, enc_key_salt); const candidateJwk = await ExtCrypto.exportVaultKey(candidate); const { vault_key_jwk } = await chrome.storage.session.get("vault_key_jwk"); if (!vault_key_jwk || JSON.stringify(candidateJwk) !== JSON.stringify(vault_key_jwk)) { throw new Error("mismatch"); } cleanup(true); } catch { errEl.textContent = "Incorrect password."; errEl.classList.remove("hidden"); input.value = ""; input.focus(); } } function cleanup(result) { overlay.remove(); resolve(result); } overlay.querySelector("#reprompt-confirm").addEventListener("click", attempt); overlay.querySelector("#reprompt-cancel").addEventListener("click", () => cleanup(false)); input.addEventListener("keydown", (e) => { if (e.key === "Enter") attempt(); if (e.key === "Escape") cleanup(false); }); }); } // ── Avatar helpers ──────────────────────────────────────────────────────────── const AVATAR_COLORS = [ "", "color-red", "color-green", "color-purple", "color-teal", ]; function avatarColor(str) { let h = 0; for (const c of str) h = (h * 31 + c.charCodeAt(0)) >>> 0; return AVATAR_COLORS[h % AVATAR_COLORS.length]; } function itemEmoji(type) { return ( { password: "🔑", note: "📝", card: "💳", bank: "🏦", address: "🏠", ssn: "🪪", passkey: "🔐", }[type] || "🔑" ); } function siteLabel(item) { if (item.plain?.url) { try { return new URL(item.plain.url).hostname.replace(/^www\./, ""); } catch { } } return item.name; } function folderName(id) { if (!id) return "(none)"; return _folders.find((f) => f.id === id)?.name || "(none)"; } // ── Domain matching ─────────────────────────────────────────────────────────── function currentHostname() { if (!_currentUrl) return ""; try { return new URL(_currentUrl).hostname.replace(/^www\./, ""); } catch { return ""; } } /** * Normalise a stored URL so it is always parseable by `new URL()`. * Handles bare domains ("github.com"), protocol-relative, and full URLs. */ function _normaliseUrl(raw) { if (!raw) return null; const s = raw.trim(); if (/^https?:\/\//i.test(s)) return s; if (s.startsWith("//")) return "https:" + s; return "https://" + s; } /** * Does this stored item belong to the site in the active tab? * * Compared by registrable domain (PkPsl.isSameSite), not by suffix. The old * test had no notion of a public suffix, so victim.github.io matched * evil.github.io and an item saved for a bare TLD matched every site under it. * Must stay in step with _filterForHost() in content/content.js. * * Falls back to exact hostname equality if psl.js failed to load — strict, so a * load failure loses matches rather than leaking credentials. */ function isMatch(item) { const host = currentHostname(); if (!host || item.item_type !== "password" || !item.plain?.url) return false; try { const normalised = _normaliseUrl(item.plain.url); if (!normalised) return false; const itemHost = new URL(normalised).hostname; if (typeof PkPsl !== "undefined" && PkPsl?.isSameSite) { return PkPsl.isSameSite(itemHost, host); } return itemHost.toLowerCase() === String(host).toLowerCase(); } catch { return false; } } // ── API helpers ─────────────────────────────────────────────────────────────── // Singleton promise for the in-flight token refresh. // When a 401 triggers a refresh, all concurrent requests that also receive a // 401 await this same promise instead of starting their own — preventing the // second refresh from using an already-rotated (and therefore blacklisted) // refresh token, which would cause an unexpected sign-out. let _refreshPromise = null; async function apiFetch(path, options = {}) { const { access_token } = await chrome.storage.session.get("access_token"); const headers = { "Content-Type": "application/json", ...(options.headers || {}), }; if (access_token) headers["Authorization"] = `Bearer ${access_token}`; let res = await fetch(`${API_BASE}${path}`, { ...options, headers }); if (res.status === 401) { // Coalesce concurrent 401 retries onto a single refresh attempt. if (!_refreshPromise) { _refreshPromise = tryRefreshToken().finally(() => { _refreshPromise = null; }); } const refreshed = await _refreshPromise; if (!refreshed) { signOut(); return null; } const { access_token: tok } = await chrome.storage.session.get("access_token"); headers["Authorization"] = `Bearer ${tok}`; res = await fetch(`${API_BASE}${path}`, { ...options, headers }); } return res; } async function tryRefreshToken() { const { refresh_token } = await chrome.storage.local.get("refresh_token"); if (!refresh_token) return false; try { const res = await fetch(`${API_BASE}/api/auth/refresh`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refresh_token }), }); if (!res.ok) return false; const data = await res.json(); await chrome.storage.session.set({ access_token: data.access_token }); if (data.refresh_token) await chrome.storage.local.set({ refresh_token: data.refresh_token }); return true; } catch { return false; } } // ── Auth ────────────────────────────────────────────────────────────────────── async function handleLogin() { const email = $("login-email").value.trim().toLowerCase(); const password = $("login-password").value; hideError("login-error"); if (!email || !password) { showError("login-error", "Email and master password are required."); return; } const btn = $("btn-login"); btn.disabled = true; btn.textContent = "Unlocking…"; try { const authHash = await ExtCrypto.deriveAuthHash(password, email); const res = await fetch(`${API_BASE}/api/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, auth_hash: authHash }), }); const data = await res.json(); if (!res.ok) { showError("login-error", data.error || "Login failed."); return; } if (data.mfa_required) { // enc_key_salt is withheld by the server until the second factor is // verified — it now arrives with the /mfa/verify response instead. _mfaToken = data.mfa_token; handleMfaStage(password); // Reset MFA view to TOTP mode each time it's shown $("mfa-totp-section").classList.remove("hidden"); $("mfa-backup-section").classList.add("hidden"); $("btn-mfa-use-backup").classList.remove("hidden"); $("btn-mfa-use-totp").classList.add("hidden"); $("mfa-hint").textContent = "Enter your 6-digit authenticator code."; $("mfa-code").value = ""; $("mfa-backup-code").value = ""; showView("mfa"); $("mfa-code").focus(); return; } await completeLogin(data, password); } catch (err) { showError("login-error", "Network error: " + err.message); } finally { btn.disabled = false; btn.textContent = "Unlock Vault"; } } function handleMfaStage(password) { $("btn-mfa-verify").onclick = async () => { hideError("mfa-error"); const usingBackup = !$("mfa-backup-section").classList.contains("hidden"); const body = { mfa_token: _mfaToken }; if (usingBackup) { const code = $("mfa-backup-code") .value.trim() .toLowerCase() .replace(/[\s-]/g, ""); if (!code) { showError("mfa-error", "Enter your backup code."); return; } body.backup_code = code; } else { const code = $("mfa-code").value.trim(); if (code.length !== 6) { showError("mfa-error", "Enter the 6-digit code."); return; } body.totp_code = code; } $("btn-mfa-verify").disabled = true; try { const res = await fetch(`${API_BASE}/api/auth/mfa/verify`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); const data = await res.json(); if (!res.ok) { showError("mfa-error", data.error || "Verification failed."); return; } _mfaToken = null; await completeLogin(data, password); } catch (err) { showError("mfa-error", "Error: " + err.message); } finally { $("btn-mfa-verify").disabled = false; } }; $("btn-mfa-use-backup").onclick = () => { $("mfa-totp-section").classList.add("hidden"); $("mfa-backup-section").classList.remove("hidden"); $("btn-mfa-use-backup").classList.add("hidden"); $("btn-mfa-use-totp").classList.remove("hidden"); $("mfa-hint").textContent = "Enter one of your saved backup codes."; $("mfa-backup-code").focus(); }; $("btn-mfa-use-totp").onclick = () => { $("mfa-backup-section").classList.add("hidden"); $("mfa-totp-section").classList.remove("hidden"); $("btn-mfa-use-totp").classList.add("hidden"); $("btn-mfa-use-backup").classList.remove("hidden"); $("mfa-hint").textContent = "Enter your 6-digit authenticator code."; $("mfa-code").focus(); }; } async function completeLogin(data, masterPassword) { await chrome.storage.session.set({ access_token: data.access_token, enc_key_salt: data.enc_key_salt, }); // enc_key_salt also persisted locally — not sensitive without the master password, // needed to show unlock-only view after browser restart. await chrome.storage.local.set({ refresh_token: data.refresh_token, enc_key_salt: data.enc_key_salt, }); _vaultKey = await ExtCrypto.deriveVaultKey(masterPassword, data.enc_key_salt); const vaultKeyJwk = await ExtCrypto.exportVaultKey(_vaultKey); await chrome.storage.session.set({ vault_key_jwk: vaultKeyJwk }); // Push session to any open web app tabs so they don't need to re-login chrome.runtime .sendMessage({ type: "EXT_SESSION_SYNC", access_token: data.access_token, refresh_token: data.refresh_token, enc_key_salt: data.enc_key_salt, }) .catch(() => { }); showView("vault"); await checkPendingSave(); await fetchAndDecryptVault(); renderList(); } // ── Unlock-only (tokens from web app, just need master password) ────────────── async function handleUnlockOnly() { const password = $("unlock-password").value; hideError("unlock-error"); if (!password) { showError("unlock-error", "Enter your master password."); return; } const btn = $("btn-unlock"); btn.disabled = true; btn.textContent = "Unlocking…"; try { // enc_key_salt may be in session (fresh) or local (after browser restart) let { enc_key_salt } = await chrome.storage.session.get("enc_key_salt"); if (!enc_key_salt) { const local = await chrome.storage.local.get("enc_key_salt"); enc_key_salt = local.enc_key_salt; } if (!enc_key_salt) { showError("unlock-error", "Session expired. Please log in again."); return; } _vaultKey = await ExtCrypto.deriveVaultKey(password, enc_key_salt); const vaultKeyJwk = await ExtCrypto.exportVaultKey(_vaultKey); await chrome.storage.session.set({ vault_key_jwk: vaultKeyJwk }); showView("vault"); await checkPendingSave(); await fetchAndDecryptVault(); renderList(); } catch { showError("unlock-error", "Incorrect password or session expired."); } finally { btn.disabled = false; btn.textContent = "Unlock"; } } async function signOut() { try { const { access_token } = await chrome.storage.session.get("access_token"); const { refresh_token } = await chrome.storage.local.get("refresh_token"); if (access_token) { fetch(`${API_BASE}/api/auth/logout`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${access_token}`, }, body: JSON.stringify({ refresh_token }), }).catch(() => { }); } } catch { } await chrome.storage.session.clear(); await chrome.storage.local.remove(["refresh_token", "enc_key_salt"]); // vault_items_cs is now in session storage — cleared by the session.clear() call above. _vaultKey = null; _sharingPrivKey = null; _items = []; showView("login"); } // ── Sharing private key ────────────────────────────────────────────────────── /** * Load and decrypt the ECDH sharing private key into _sharingPrivKey. * No-op if already loaded or if the user hasn't set up sharing keys yet. * Called once per fetchAndDecryptVault() cycle. */ async function _loadSharingPrivKey() { if (_sharingPrivKey) return; // already loaded this session if (!_vaultKey) return; try { const res = await apiFetch('/api/sharing/keys'); if (!res?.ok) return; const data = await res.json(); if (!data.keys_setup || !data.private_key_enc || !data.private_key_iv) return; _sharingPrivKey = await ExtSharingCrypto.decryptPrivateKey( _vaultKey, data.private_key_enc, data.private_key_iv, ); } catch (err) { console.warn('[PassKeeper] Could not load sharing private key:', err); } } // ── Vault loading ───────────────────────────────────────────────────────────── /** * Returns: * 'vault' — fully unlocked session restored * 'unlock' — tokens exist (from web app SSO) but vault key needs deriving * false — no session, show login */ async function restoreSessionIfAvailable() { const data = await chrome.storage.session.get([ "access_token", "vault_key_jwk", "vault_items", "enc_key_salt", ]); if (data.access_token) { if (data.vault_key_jwk) { _vaultKey = await ExtCrypto.importVaultKey(data.vault_key_jwk); _items = data.vault_items || []; return "vault"; } // Tokens exist (synced from web app) but vault key not yet derived if (data.enc_key_salt) return "unlock"; } // Session cleared (e.g. browser restart) — try refreshing with persisted local tokens const local = await chrome.storage.local.get([ "refresh_token", "enc_key_salt", ]); if (local.refresh_token && local.enc_key_salt) { const refreshed = await tryRefreshToken(); if (refreshed) { await chrome.storage.session.set({ enc_key_salt: local.enc_key_salt }); return "unlock"; } } return false; } async function fetchAndDecryptVault() { $("vault-spinner").classList.remove("hidden"); try { const [res, foldersRes] = await Promise.all([ apiFetch("/api/vault"), apiFetch("/api/folders"), ]); if (!res) return; const raw = await res.json(); try { if (foldersRes?.ok) _folders = await foldersRes.json(); } catch (e) { _folders = []; } _items = await Promise.all( raw.map(async (item) => { try { const plain = await ExtCrypto.decryptItem( _vaultKey, item.enc_data, item.iv, ); // Decrypt the item name if an encrypted version exists. // Fall back to the server-stored plaintext name for legacy items. let displayName = item.name; if (item.enc_name && item.iv_name) { const decrypted = await ExtCrypto.decryptName( _vaultKey, item.enc_name, item.iv_name, ); if (decrypted) displayName = decrypted; } return { ...item, name: displayName, plain }; } catch { return { ...item, plain: null }; } }), ); // Write own items to session for popup use (badge, rendering). // vault_items_cs and VAULT_UPDATED are sent after shared items are merged below. await chrome.storage.session.set({ vault_items: _items }); // Load ECDH sharing private key (no-op if already loaded or keys not set up). await _loadSharingPrivKey(); // Fetch and decrypt accepted shared items, then merge into _items. // Shared items are treated like own items for autofill/copy but carry // _shared: true and _sharedFrom: ownerEmail for UI differentiation. if (_sharingPrivKey) { try { const inboxRes = await apiFetch('/api/sharing/inbox'); if (inboxRes?.ok) { const inbox = await inboxRes.json(); const accepted = inbox.filter((s) => s.accepted && s.owner_public_key); const sharedItems = (await Promise.all( accepted.map(async (s) => { try { const ownerPub = await ExtSharingCrypto.importPublicKey(s.owner_public_key); const sharedKey = await ExtSharingCrypto.deriveSharedKey(_sharingPrivKey, ownerPub); const plain = await ExtSharingCrypto.decryptShare(sharedKey, s.enc_data, s.iv); let displayName = s.item_name; if (s.enc_name && s.iv_name) { const dec = await ExtSharingCrypto.decryptName(sharedKey, s.enc_name, s.iv_name); if (dec) displayName = dec; } return { // Use a namespaced id so shared items never collide with own items. id: `shared-${s.id}`, _shareId: s.id, _shared: true, _sharedFrom: s.owner_email || 'Unknown', item_type: s.item_type, name: displayName, folder_id: null, created_at: s.created_at, updated_at: s.created_at, enc_data: s.enc_data, iv: s.iv, plain, }; } catch { return null; } }), )).filter(Boolean); // Append shared items after own items so they don't displace own-item matches. _items = [..._items, ...sharedItems]; } } catch (err) { console.warn('[PassKeeper] Could not load shared items:', err); } } // Persist the merged list (own + shared) to session for badge/content script use. // Shared items use their own namespaced ids so the content script can use them. const itemsForContentScript = _items.map((item) => ({ id: item.id, name: item.name, item_type: item.item_type, plain: item.plain, })); await chrome.storage.session.set({ vault_items_cs: itemsForContentScript }); chrome.runtime .sendMessage({ type: 'VAULT_UPDATED', vault_items: itemsForContentScript, }) .catch(() => { }); // Run health checks in the background — sync metrics first, then HIBP. // Results are sent to the background SW via HEALTH_UPDATE so the toolbar // badge reflects the vault health even when the popup is closed. _runPopupHealthCheck(); } catch (err) { console.error("fetchAndDecryptVault:", err); } finally { $("vault-spinner").classList.add("hidden"); } } // ── Vault health check ──────────────────────────────────────────────────────── /** * Compute weak/reused counts synchronously, then run HIBP in parallel. * Sends HEALTH_UPDATE to the background service worker so the toolbar * badge is updated even after the popup closes. * Called once per fetchAndDecryptVault() cycle. */ async function _runPopupHealthCheck() { try { const pwItems = _items.filter( (i) => !i._shared && i.item_type === "password" && i.plain?.password, ); if (!pwItems.length) { chrome.runtime .sendMessage({ type: "HEALTH_UPDATE", breached: 0, weak: 0, reused: 0 }) .catch(() => { }); return; } // Synchronous metrics. const weak = pwItems.filter((i) => { const p = i.plain.password; if (p.length < 10) return true; return ( [/[A-Z]/, /[a-z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((r) => r.test(p)) .length < 2 ); }); const passCounts = {}; pwItems.forEach((i) => { const p = i.plain.password; (passCounts[p] = passCounts[p] || []).push(i); }); const reused = Object.values(passCounts) .filter((a) => a.length > 1) .flat(); // Send sync results immediately so background badge updates quickly. chrome.runtime .sendMessage({ type: "HEALTH_UPDATE", breached: 0, weak: weak.length, reused: reused.length, }) .catch(() => { }); // HIBP — k-anonymity, parallel. const hibpResults = await Promise.all( pwItems.map(async (item) => ({ item, count: await _popupCheckHibp(item.plain.password), })), ); const breachedCount = hibpResults.filter((r) => r.count > 0).length; // Send final results with breach count. chrome.runtime .sendMessage({ type: "HEALTH_UPDATE", breached: breachedCount, weak: weak.length, reused: reused.length, }) .catch(() => { }); } catch (err) { console.error("[PassKeeper] Popup health check failed:", err); } } /** * HIBP k-anonymity check — identical to the web app's checkHibp(). * Only the first 5 hex chars of SHA-1(password) are sent; the full * hash never leaves the browser. */ async function _popupCheckHibp(password) { try { const enc = new TextEncoder().encode(password); const hashBuf = await crypto.subtle.digest("SHA-1", enc); const hashHex = Array.from(new Uint8Array(hashBuf)) .map((b) => b.toString(16).padStart(2, "0")) .join("") .toUpperCase(); const prefix = hashHex.slice(0, 5); const suffix = hashHex.slice(5); const res = await fetch( `https://api.pwnedpasswords.com/range/${prefix}`, { headers: { "Add-Padding": "true" } }, ); if (!res.ok) return 0; const text = await res.text(); const line = text.split("\n").find((l) => l.startsWith(suffix)); if (!line) return 0; return parseInt(line.split(":")[1], 10) || 0; } catch { return 0; } } // ── TOTP engine (RFC 6238) — pure Web Crypto, no library ───────────────────── function _extractTotpSecret(uri) { if (!uri) return null; uri = uri.trim(); if (uri.startsWith("otpauth://")) { try { const secret = new URL(uri).searchParams.get("secret"); return secret ? secret.toUpperCase().replace(/\s+/g, "") : null; } catch { return null; } } return uri.toUpperCase().replace(/[\s-]/g, "") || null; } function _base32ToBytes(b32) { const CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; b32 = b32.replace(/=+$/, ""); let bits = 0, val = 0; const out = []; for (const c of b32) { const idx = CHARS.indexOf(c); if (idx === -1) continue; val = (val << 5) | idx; bits += 5; if (bits >= 8) { bits -= 8; out.push((val >> bits) & 0xff); } } return new Uint8Array(out); } async function getTotpCode(totpUri) { const secret = _extractTotpSecret(totpUri); if (!secret) return null; const keyBytes = _base32ToBytes(secret); if (!keyBytes.length) return null; const counter = Math.floor(Date.now() / 1000 / 30); const msg = new Uint8Array(8); let c = counter; for (let i = 7; i >= 0; i--) { msg[i] = c & 0xff; c = Math.floor(c / 256); } const cryptoKey = await crypto.subtle.importKey( "raw", keyBytes, { name: "HMAC", hash: "SHA-1" }, false, ["sign"], ); const sig = new Uint8Array(await crypto.subtle.sign("HMAC", cryptoKey, msg)); const offset = sig[19] & 0x0f; const code = (((sig[offset] & 0x7f) << 24) | (sig[offset + 1] << 16) | (sig[offset + 2] << 8) | sig[offset + 3]) % 1_000_000; return String(code).padStart(6, "0"); } function _totpSecondsLeft() { return 30 - (Math.floor(Date.now() / 1000) % 30); } // Track active TOTP ticker intervals so we can clear them on re-render. let _totpIntervals = []; function _clearTotpTickers() { _totpIntervals.forEach((id) => clearInterval(id)); _totpIntervals = []; } // ── Rendering ───────────────────────────────────────────────────────────────── function getTabItems() { const q = $("vault-search").value.trim().toLowerCase(); let items = _items; // Text filter if (q) { items = items.filter( (i) => i.name.toLowerCase().includes(q) || (i.plain?.username || "").toLowerCase().includes(q) || (i.plain?.url || "").toLowerCase().includes(q), ); } if (_activeTab === "relevant") { // Show ONLY items that match the current domain, sorted by name. items = items.filter(isMatch).sort((a, b) => a.name.localeCompare(b.name)); } else if (_activeTab === "recents") { items = [...items] .sort( (a, b) => new Date(b.updated_at || b.created_at) - new Date(a.updated_at || a.created_at), ) .slice(0, 20); } else if (_activeTab === "favorites") { items = items .filter((i) => (i.plain?.tags || []).includes("favorite")) .sort((a, b) => a.name.localeCompare(b.name)); } else { items = [...items].sort((a, b) => a.name.localeCompare(b.name)); } return items; } function renderList() { const listEl = $("vault-list"); const emptyEl = $("vault-empty"); const items = getTabItems(); // Stop any running TOTP tickers from a previous render. _clearTotpTickers(); if (!items.length) { listEl.innerHTML = ""; if (_activeTab === "relevant") { const host = currentHostname(); emptyEl.textContent = host ? `No saved passwords for ${host}.` : "No items match this page."; } else { emptyEl.textContent = "No items found."; } emptyEl.classList.remove("hidden"); return; } emptyEl.classList.add("hidden"); // SVG icons for action buttons const svgCopy = ``; const svgUser = ``; const svgDots = ``; const svgFill = ``; const svgTotp = ``; function buildItemHtml(item) { const matched = isMatch(item); const site = escHtml(siteLabel(item)); const name = escHtml(item.name); const badge = matched ? 'match' : ""; const sharedBadge = item._shared ? `shared` : ""; const color = avatarColor(item.name); const emoji = itemEmoji(item.item_type); const canFill = item.item_type === "password" && item.plain?.username && item.plain?.password && item.plain?.autofill !== false; // respect Advanced Setting const canCopy = item.item_type === "password" && item.plain?.password; const hasTotp = item.item_type === "password" && !!_extractTotpSecret(item.plain?.totp_uri); const tags = (item.plain?.tags || []).filter((t) => t !== "favorite"); const tagHtml = tags .map((t) => `${escHtml(t)}`) .join(""); const isFav = (item.plain?.tags || []).includes("favorite"); return `
${emoji}
${site}${badge}${sharedBadge}${isFav ? '' : ""}
${name}
${tagHtml ? `
${tagHtml}
` : ""} ${hasTotp ? `
······
` : ""}
${item.plain?.username ? `` : ""} ${canCopy ? `` : ""} ${hasTotp ? `` : ""} ${canFill ? `` : ""}
`; } // Build the vault list — grouped by folder for 'all' tab, flat otherwise. if (_activeTab === "all" && _folders.length > 0) { const groups = {}; items.forEach((item) => { const key = folderName(item.folder_id); if (!groups[key]) groups[key] = []; groups[key].push(item); }); const keys = ["(none)", ..._folders.map((f) => f.name)].filter( (k) => groups[k], ); Object.keys(groups).forEach((k) => { if (!keys.includes(k)) keys.push(k); }); listEl.innerHTML = keys .map((groupName) => { if (!groups[groupName]) return ""; const isCollapsed = _collapsedFolders.has(groupName); const count = groups[groupName].length; const chevron = isCollapsed ? "›" : "⌄"; const itemsHtml = isCollapsed ? "" : groups[groupName].map(buildItemHtml).join(""); return `
${escHtml(groupName)} ${count} ${chevron}
${itemsHtml}
`; }) .join(""); // Wire collapse toggle on group headers. listEl.querySelectorAll(".pk-group-header").forEach((hdr) => { hdr.addEventListener("click", () => { const groupName = hdr.closest(".pk-group").dataset.group; if (_collapsedFolders.has(groupName)) _collapsedFolders.delete(groupName); else _collapsedFolders.add(groupName); renderList(); }); }); } else { listEl.innerHTML = items.map(buildItemHtml).join(""); } // Start TOTP tickers for items that have a totp_uri. items.forEach((item) => { if ( item.item_type !== "password" || !_extractTotpSecret(item.plain?.totp_uri) ) return; const codeEl = $(`totp-${item.id}`); const timerEl = $(`totp-t-${item.id}`); if (!codeEl) return; async function tick() { const code = await getTotpCode(item.plain.totp_uri).catch(() => null); if (!code || !codeEl.isConnected) return; codeEl.textContent = code.slice(0, 3) + " " + code.slice(3); const secs = _totpSecondsLeft(); timerEl.textContent = " " + secs + "s"; timerEl.style.color = secs <= 5 ? "#dc2626" : "#9ca3af"; } tick(); const id = setInterval(tick, 1000); _totpIntervals.push(id); }); // Copy password listEl.querySelectorAll("[data-copy-pass]").forEach((btn) => btn.addEventListener("click", async (e) => { e.stopPropagation(); const item = _items.find((i) => String(i.id) === btn.dataset.copyPass); if (!item?.plain?.password) return; if (item.plain?.reprompt) { const ok = await _repromptMasterPassword(); if (!ok) return; } _copyWithAutoClear(item.plain.password); btn.title = "Copied!"; setTimeout(() => { btn.title = "Copy password"; }, 1500); }), ); // Copy TOTP code listEl.querySelectorAll("[data-copy-totp]").forEach((btn) => btn.addEventListener("click", async (e) => { e.stopPropagation(); const item = _items.find((i) => String(i.id) === btn.dataset.copyTotp); if (!item?.plain?.totp_uri) return; const code = await getTotpCode(item.plain.totp_uri).catch(() => null); if (code) { // Use _copyWithAutoClear so the 2FA code is wiped from the clipboard // after 30 s, consistent with password copy behaviour. _copyWithAutoClear(code); btn.title = "Copied!"; btn.style.color = "#16a34a"; setTimeout(() => { btn.title = "Copy 2FA code"; btn.style.color = ""; }, 1500); } }), ); // Autofill listEl.querySelectorAll("[data-autofill]").forEach((btn) => btn.addEventListener("click", async (e) => { e.stopPropagation(); const item = _items.find((i) => String(i.id) === btn.dataset.autofill); if (!item?.plain) return; if (item.plain?.reprompt) { const ok = await _repromptMasterPassword(); if (!ok) return; } const [tab] = await chrome.tabs.query({ active: true, currentWindow: true, }); if (tab?.id) { chrome.tabs .sendMessage(tab.id, { type: "DO_AUTOFILL", username: item.plain.username || "", password: item.plain.password || "", autologin: !!item.plain.autologin, }) .catch(() => { }); } window.close(); }), ); // Copy username (dedicated button) listEl.querySelectorAll("[data-copy-user]").forEach((btn) => btn.addEventListener("click", async (e) => { e.stopPropagation(); const item = _items.find((i) => String(i.id) === btn.dataset.copyUser); if (!item?.plain?.username) return; if (item.plain?.reprompt) { const ok = await _repromptMasterPassword(); if (!ok) return; } _copyWithAutoClear(item.plain.username); btn.title = "Copied!"; setTimeout(() => { btn.title = "Copy username"; }, 1500); }), ); // Three-dot menu: proper flyout with contextual actions listEl.querySelectorAll("[data-menu]").forEach((btn) => btn.addEventListener("click", (e) => { e.stopPropagation(); // Close any already-open flyout first. document.querySelectorAll(".pk-flyout").forEach((el) => el.remove()); const item = _items.find((i) => String(i.id) === btn.dataset.menu); if (!item) return; const flyout = document.createElement("div"); flyout.className = "pk-flyout"; const menuItems = [ item.plain?.url ? { label: "Open URL", icon: '', action: async () => { chrome.tabs.create({ url: item.plain.url }); flyout.remove(); }, } : null, item.plain?.username ? { label: "Copy username", icon: '', action: async () => { if (item.plain?.reprompt) { const ok = await _repromptMasterPassword(); if (!ok) return; } _copyWithAutoClear(item.plain.username); flyout.remove(); }, } : null, item.plain?.password ? { label: "Copy password", icon: '', action: async () => { if (item.plain?.reprompt) { const ok = await _repromptMasterPassword(); if (!ok) return; } _copyWithAutoClear(item.plain.password); flyout.remove(); }, } : null, ].filter(Boolean); // Add a non-action info row showing when the password was last changed. // Only shown for password items that have password_changed_at set. if (item.item_type === "password" && item.plain?.password_changed_at) { const daysAgo = Math.floor( (Date.now() - new Date(item.plain.password_changed_at).getTime()) / 86400000, ); const ageText = daysAgo === 0 ? "Changed today" : daysAgo === 1 ? "Changed yesterday" : `Changed ${daysAgo} days ago`; const infoRow = document.createElement("div"); infoRow.className = "pk-flyout-info"; infoRow.textContent = ageText; // Append after action rows are added. flyout._ageInfoRow = infoRow; } menuItems.forEach((mi) => { const row = document.createElement("button"); row.className = "pk-flyout-item"; row.innerHTML = mi.icon + "" + escHtml(mi.label) + ""; row.addEventListener("click", async (e) => { e.stopPropagation(); await mi.action(); }); flyout.appendChild(row); }); // Append age info row if present. if (flyout._ageInfoRow) { flyout.appendChild(flyout._ageInfoRow); } // Position flyout using fixed coords so it escapes vault-list overflow clipping. const appEl = document.getElementById("app"); appEl.appendChild(flyout); const btnRect = btn.getBoundingClientRect(); const flyoutH = flyout.offsetHeight || 120; // estimate if not yet painted const spaceBelow = window.innerHeight - btnRect.bottom; const flyoutW = flyout.offsetWidth || 160; // Flip upward if not enough room below. if (spaceBelow < flyoutH + 8) { flyout.style.top = (btnRect.top - flyoutH - 4) + "px"; } else { flyout.style.top = (btnRect.bottom + 4) + "px"; } // Right-align with the button, but keep inside the popup. const rightEdge = window.innerWidth - btnRect.right; flyout.style.right = rightEdge + "px"; // Close on any outside click. setTimeout(() => { document.addEventListener("click", function handler() { flyout.remove(); document.removeEventListener("click", handler); }); }, 0); }), ); } // ── Tabs ────────────────────────────────────────────────────────────────────── function initTabs() { document.querySelectorAll(".tab-btn").forEach((btn) => { btn.addEventListener("click", () => { _activeTab = btn.dataset.tab; document .querySelectorAll(".tab-btn") .forEach((b) => b.classList.toggle("active", b === btn)); renderList(); }); }); } // ── Save-prompt modal ───────────────────────────────────────────────────────── /** * Populate the folder