/** * 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'; // ── 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 `
${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(); }); }); } // ── Auto-save prompt ────────────────────────────────────────────────────────── async function checkPendingSave() { const { pending_save } = await chrome.storage.session.get('pending_save'); if (!pending_save) return; const prompt = $('save-prompt'); $('save-name').value = pending_save.siteName || ''; $('save-username').value = pending_save.username || ''; prompt.classList.remove('hidden'); $('btn-save-yes').onclick = async () => { prompt.classList.add('hidden'); await saveCredential(pending_save); await chrome.storage.session.remove('pending_save'); }; $('btn-save-no').onclick = async () => { prompt.classList.add('hidden'); await chrome.storage.session.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); } } // ── 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); // Search $('vault-search').addEventListener('input', () => renderList()); // Tabs initTabs(); } document.addEventListener('DOMContentLoaded', init);