/** * 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 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 { 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 = ""; 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 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 `
${emoji}
${site}${badge}
${name}
${canCopy ? `` : ""} ${canFill ? `` : ""}
`; }) .join(""); // Copy password listEl.querySelectorAll("[data-copy-pass]").forEach((btn) => btn.addEventListener("click", (e) => { e.stopPropagation(); const item = _items.find((i) => i.id === parseInt(btn.dataset.copyPass)); if (item?.plain?.password) { navigator.clipboard.writeText(item.plain.password); btn.title = "Copied!"; setTimeout(() => { btn.title = "Copy password"; }, 1500); } }), ); // Autofill listEl.querySelectorAll("[data-autofill]").forEach((btn) => btn.addEventListener("click", async (e) => { e.stopPropagation(); const item = _items.find((i) => i.id === parseInt(btn.dataset.autofill)); if (!item?.plain) 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 || "", }) .catch(() => {}); } window.close(); }), ); // Three-dot menu: copy username listEl.querySelectorAll("[data-menu]").forEach((btn) => btn.addEventListener("click", (e) => { e.stopPropagation(); const item = _items.find((i) => i.id === parseInt(btn.dataset.menu)); if (!item?.plain) return; // Simple: copy username on dots click (could be a dropdown in future) if (item.plain.username) { navigator.clipboard.writeText(item.plain.username); btn.title = "Username copied!"; setTimeout(() => { btn.title = "More"; }, 1500); } }), ); } // ── 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 ───────────────────────────────────────────────────────── /** * Show the save-prompt overlay. It is a blocking modal — no backdrop click, * no ✕ button — so the user MUST click "Save" or "Not now". * Called every time the popup opens while a pending_save is stored. * * pending_save is stored in chrome.storage.local (not session) so it survives * service worker restarts and is reliably present when the popup re-opens. */ async function checkPendingSave() { const { pending_save } = await chrome.storage.local.get("pending_save"); if (!pending_save) return; // Populate fields. $("save-name").value = pending_save.siteName || ""; $("save-username").value = pending_save.username || ""; $("save-prompt-subtitle").textContent = `${pending_save.username || "(no username)"} on ${pending_save.siteName || pending_save.url || "unknown site"}`; $("save-prompt-title").textContent = pending_save._isUpdate ? "Update in PassKeeper?" : "Save to PassKeeper?"; // Show the overlay. $("save-prompt-overlay").classList.remove("hidden"); $("save-name").focus(); // Wire buttons — use onclick so re-running checkPendingSave never double-binds. $("btn-save-yes").onclick = async () => { $("save-prompt-overlay").classList.add("hidden"); console.log( "[PassKeeper] User saved credential for", pending_save.siteName, ); await saveCredential(pending_save); await chrome.storage.local.remove("pending_save"); }; $("btn-save-no").onclick = async () => { $("save-prompt-overlay").classList.add("hidden"); console.log( "[PassKeeper] User dismissed save prompt for", pending_save.siteName, ); await chrome.storage.local.remove("pending_save"); }; } async function saveCredential(data) { if (!_vaultKey) return; const plain = { url: data.url || "", username: data.username || "", password: data.password || "", notes: "", }; const name = $("save-name").value.trim() || data.siteName || "Untitled"; const { enc_data, iv } = await ExtCrypto.encryptItem(_vaultKey, plain); try { const res = await apiFetch("/api/vault", { method: "POST", body: JSON.stringify({ name, item_type: "password", folder_id: null, enc_data, iv, }), }); if (res?.ok) { await fetchAndDecryptVault(); renderList(); } } catch (err) { console.error("saveCredential failed:", err); } } // ── Password Generator ──────────────────────────────────────────────────────── const GEN_SETS = { lower: "abcdefghijklmnopqrstuvwxyz", upper: "ABCDEFGHIJKLMNOPQRSTUVWXYZ", numbers: "0123456789", symbols: "!@#$%^&*()-_=+[]{}|;:,.<>?", }; function generatePassword(length, useLower, useUpper, useNumbers, useSymbols) { // Always fall back to lower if nothing selected, preventing infinite loop. const pool = [ useLower ? GEN_SETS.lower : "", useUpper ? GEN_SETS.upper : "", useNumbers ? GEN_SETS.numbers : "", useSymbols ? GEN_SETS.symbols : "", ].join(""); if (!pool) return ""; // Guarantee at least one character from each selected charset. const required = []; if (useLower) required.push( GEN_SETS.lower[Math.floor(Math.random() * GEN_SETS.lower.length)], ); if (useUpper) required.push( GEN_SETS.upper[Math.floor(Math.random() * GEN_SETS.upper.length)], ); if (useNumbers) required.push( GEN_SETS.numbers[Math.floor(Math.random() * GEN_SETS.numbers.length)], ); if (useSymbols) required.push( GEN_SETS.symbols[Math.floor(Math.random() * GEN_SETS.symbols.length)], ); const arr = new Uint32Array(length); crypto.getRandomValues(arr); const rest = Array.from(arr).map((n) => pool[n % pool.length]); // Splice required chars into random positions and trim to length. const combined = [...rest]; required.forEach((ch, i) => { combined[i] = ch; }); // Fisher-Yates shuffle for uniform distribution. for (let i = combined.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [combined[i], combined[j]] = [combined[j], combined[i]]; } return combined.slice(0, length).join(""); } function passwordStrength(pw) { if (!pw) return { label: "", color: "" }; let score = 0; if (pw.length >= 8) score++; if (pw.length >= 12) score++; if (pw.length >= 16) score++; if (/[a-z]/.test(pw)) score++; if (/[A-Z]/.test(pw)) score++; if (/[0-9]/.test(pw)) score++; if (/[^a-zA-Z0-9]/.test(pw)) score++; if (score <= 2) return { label: "Weak", color: "#dc2626" }; if (score <= 4) return { label: "Fair", color: "#d97706" }; if (score <= 5) return { label: "Good", color: "#2563eb" }; if (score <= 6) return { label: "Strong", color: "#16a34a" }; return { label: "Very Strong", color: "#15803d" }; } let _genInitialised = false; function initGenerator() { // Only bind events once — subsequent calls just re-generate a password. if (!_genInitialised) { _genInitialised = true; const slider = $("gen-length"); const numInput = $("gen-length-num"); // Keep slider and number input in sync. slider.addEventListener("input", () => { numInput.value = slider.value; refreshGen(); }); numInput.addEventListener("input", () => { const v = Math.min(64, Math.max(8, parseInt(numInput.value) || 16)); slider.value = v; numInput.value = v; refreshGen(); }); ["gen-lower", "gen-upper", "gen-numbers", "gen-symbols"].forEach((id) => { $(id).addEventListener("change", refreshGen); }); $("btn-gen-refresh").addEventListener("click", refreshGen); $("btn-gen-copy").addEventListener("click", () => { const pw = $("gen-output").textContent; if (!pw || pw === "Click refresh to generate") return; navigator.clipboard.writeText(pw).then(() => { $("btn-gen-copy").title = "Copied!"; setTimeout(() => { $("btn-gen-copy").title = "Copy password"; }, 1500); }); }); } // Always generate a fresh password when the view opens. refreshGen(); } function refreshGen() { const length = Math.min( 64, Math.max(8, parseInt($("gen-length").value) || 16), ); const useLower = $("gen-lower").checked; const useUpper = $("gen-upper").checked; const useNumbers = $("gen-numbers").checked; const useSymbols = $("gen-symbols").checked; // Require at least one charset. if (!useLower && !useUpper && !useNumbers && !useSymbols) { $("gen-output").textContent = "Select at least one character type"; $("gen-strength-label").textContent = ""; return; } const pw = generatePassword( length, useLower, useUpper, useNumbers, useSymbols, ); $("gen-output").textContent = pw; const { label, color } = passwordStrength(pw); const strengthEl = $("gen-strength-label"); strengthEl.textContent = label; strengthEl.style.color = color; } // ── Init ────────────────────────────────────────────────────────────────────── async function init() { try { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true, }); _currentUrl = tab?.url || ""; } catch {} // Set vault link $("btn-open-vault").href = VAULT_URL; const sessionState = await restoreSessionIfAvailable(); if (sessionState === "vault") { showView("vault"); await checkPendingSave(); if (_items.length) { renderList(); fetchAndDecryptVault().then(() => renderList()); } else { await fetchAndDecryptVault(); renderList(); } } else if (sessionState === "unlock") { showView("unlock"); $("unlock-password").focus(); } else { showView("login"); } // Login $("btn-login").addEventListener("click", handleLogin); $("login-password").addEventListener("keydown", (e) => { if (e.key === "Enter") handleLogin(); }); $("login-email").addEventListener("keydown", (e) => { if (e.key === "Enter") $("login-password").focus(); }); // MFA $("btn-mfa-back").addEventListener("click", () => { _mfaToken = null; showView("login"); }); $("mfa-code").addEventListener("keydown", (e) => { if (e.key === "Enter") $("btn-mfa-verify")?.click(); }); // Unlock-only $("btn-unlock").addEventListener("click", handleUnlockOnly); $("unlock-password").addEventListener("keydown", (e) => { if (e.key === "Enter") handleUnlockOnly(); }); $("btn-unlock-signout").addEventListener("click", signOut); // Vault actions $("btn-add-item").addEventListener("click", () => { chrome.tabs.create({ url: VAULT_URL }); }); $("nav-account").addEventListener("click", signOut); $("nav-vault").addEventListener("click", () => { showView("vault"); }); $("nav-generator").addEventListener("click", () => { showView("generator"); initGenerator(); }); $("nav-alerts").addEventListener("click", () => { chrome.tabs.create({ url: ALERTS_URL }); }); // Keep the service worker alive so chrome.storage.session survives while the // browser is open. We ping it every 20 s; the ping itself is a no-op but // prevents the SW from being killed between popup openings. const _keepalive = setInterval(() => { chrome.runtime.sendMessage({ type: "KEEPALIVE" }).catch(() => {}); }, 20_000); // Search $("vault-search").addEventListener("input", () => renderList()); // Tabs initTabs(); } document.addEventListener("DOMContentLoaded", init);