/** * 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 _mfaToken = null; let _currentUrl = ""; let _activeTab = "relevant"; // ── DOM helpers ─────────────────────────────────────────────────────────────── const $ = (id) => document.getElementById(id); function showView(name) { ["login", "mfa", "unlock", "vault", "generator"].forEach((v) => $(`view-${v}`).classList.toggle("hidden", v !== name), ); // Bottom nav is only visible when the user is in the authenticated area. const navViews = ["vault", "generator"]; $("main-bottom-nav").classList.toggle("hidden", !navViews.includes(name)); // Keep active state on nav buttons in sync. ["nav-vault", "nav-generator"].forEach((id) => { const btn = $(id); if (!btn) return; const matches = (id === "nav-vault" && name === "vault") || (id === "nav-generator" && name === "generator"); btn.classList.toggle("active", matches); }); } function showError(elId, msg) { const el = $(elId); el.textContent = msg; el.classList.remove("hidden"); } function hideError(elId) { $(elId).classList.add("hidden"); } function escHtml(str) { return String(str ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } // ── 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; } // ── Domain matching ─────────────────────────────────────────────────────────── function currentHostname() { if (!_currentUrl) return ""; try { return new URL(_currentUrl).hostname.replace(/^www\./, ""); } catch { return ""; } } function isMatch(item) { const host = currentHostname(); if (!host || item.item_type !== "password" || !item.plain?.url) return false; try { const h = new URL(item.plain.url).hostname.replace(/^www\./, ""); return h === host || h.endsWith(`.${host}`) || host.endsWith(`.${h}`); } catch { return false; } } // ── API helpers ─────────────────────────────────────────────────────────────── 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) { const refreshed = await tryRefreshToken(); 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) { _mfaToken = data.mfa_token; await chrome.storage.session.set({ _pending_enc_key_salt: data.enc_key_salt, }); handleMfaStage(password); 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 () => { const code = $("mfa-code").value.trim(); hideError("mfa-error"); if (code.length !== 6) { showError("mfa-error", "Enter the 6-digit code."); return; } $("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({ mfa_token: _mfaToken, totp_code: code }), }); const data = await res.json(); if (!res.ok) { showError("mfa-error", data.error || "Verification failed."); return; } _mfaToken = null; const { _pending_enc_key_salt } = await chrome.storage.session.get( "_pending_enc_key_salt", ); await completeLogin( { ...data, enc_key_salt: _pending_enc_key_salt }, password, ); } catch (err) { showError("mfa-error", "Error: " + err.message); } finally { $("btn-mfa-verify").disabled = false; } }; } 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"]); _vaultKey = null; _items = []; showView("login"); } // ── 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 = await apiFetch("/api/vault"); if (!res) return; const raw = await res.json(); _items = await Promise.all( raw.map(async (item) => { try { const plain = await ExtCrypto.decryptItem( _vaultKey, item.enc_data, item.iv, ); return { ...item, plain }; } catch { return { ...item, plain: null }; } }), ); await chrome.storage.session.set({ vault_items: _items }); chrome.runtime.sendMessage({ type: "VAULT_UPDATED" }).catch(() => {}); } catch (err) { console.error("fetchAndDecryptVault:", err); } finally { $("vault-spinner").classList.add("hidden"); } } // ── 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 matching items first, then others (only if there are matches or no query) const matches = items.filter(isMatch); const rest = items.filter((i) => !isMatch(i)); // If we have matches, show matches first; otherwise fall back to all items = matches.length ? [...matches, ...rest] : items; // Sort matched first by name, rest by name items = [ ...matches.sort((a, b) => a.name.localeCompare(b.name)), ...rest.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 { 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(); if (!items.length) { listEl.innerHTML = ""; emptyEl.classList.remove("hidden"); return; } emptyEl.classList.add("hidden"); // SVG icons for action buttons const svgCopy = ``; const svgDots = ``; const svgFill = ``; listEl.innerHTML = items .map((item) => { const matched = isMatch(item); const site = escHtml(siteLabel(item)); const name = escHtml(item.name); const badge = matched ? 'match' : ""; const color = avatarColor(item.name); const emoji = itemEmoji(item.item_type); const canFill = item.item_type === "password" && item.plain?.username && item.plain?.password; const canCopy = item.item_type === "password" && item.plain?.password; return `