/** * 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 GEN_URL = 'https://pwkeeper.ngodanguyen.tech/vault#generator'; 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'].forEach(v => $(`view-${v}`).classList.toggle('hidden', v !== name) ); } 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 `