From 9af7435cab2a00e16248998561419c0bfe793d45 Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Sat, 2 May 2026 15:12:49 -0400 Subject: [PATCH] 05/02/2026 fixed extension hook issues --- extension/background.js | 22 + extension/popup/popup.js | 1150 ++++++++++++++++++++++++-------------- 2 files changed, 738 insertions(+), 434 deletions(-) diff --git a/extension/background.js b/extension/background.js index dca6198..ae5e1da 100644 --- a/extension/background.js +++ b/extension/background.js @@ -33,6 +33,28 @@ async function applyIdleInterval() { // Apply on every SW startup (SW can be killed and restarted at any time). applyIdleInterval(); +// Allow content scripts to read chrome.storage.session. +// By default, MV3 session storage is restricted to TRUSTED_CONTEXTS only +// (background service worker + popup). Without this call, every +// chrome.storage.session.get() in content.js silently returns undefined, +// so vault_items_cs is never read and the autofill dropdown always shows +// "No saved passwords for this site." even when credentials exist. +chrome.storage.session.setAccessLevel( + { accessLevel: "TRUSTED_AND_UNTRUSTED_CONTEXTS" }, + () => { + if (chrome.runtime.lastError) { + console.warn( + "[PassKeeper] setAccessLevel failed:", + chrome.runtime.lastError.message, + ); + } else { + console.log( + "[PassKeeper] session storage access level set to TRUSTED_AND_UNTRUSTED_CONTEXTS", + ); + } + }, +); + chrome.idle.onStateChanged.addListener(async (newState) => { if (newState === "idle" || newState === "locked") { // Check if the user set "Never" before locking. diff --git a/extension/popup/popup.js b/extension/popup/popup.js index 891cf75..1aa77a0 100644 --- a/extension/popup/popup.js +++ b/extension/popup/popup.js @@ -11,54 +11,59 @@ * 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'; +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 _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 _currentUrl = ""; +let _activeTab = "relevant"; +const _collapsedFolders = new Set(); // persists collapsed state across re-renders // ── DOM helpers ─────────────────────────────────────────────────────────────── -const $ = id => document.getElementById(id); +const $ = (id) => document.getElementById(id); function showView(name) { - ['login', 'mfa', 'unlock', 'vault', 'generator', 'account', 'add'].forEach(v => - $(`view-${v}`).classList.toggle('hidden', v !== 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)); + 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 => { + ["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); + 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'); + el.classList.remove("hidden"); } -function hideError(elId) { $(elId).classList.add('hidden'); } +function hideError(elId) { + $(elId).classList.add("hidden"); +} function escHtml(str) { - return String(str ?? '') - .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + return String(str ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); } // Auto-clear clipboard 30 s after a sensitive copy. @@ -67,14 +72,20 @@ function _copyWithAutoClear(text) { navigator.clipboard.writeText(text).catch(() => {}); if (_clipTimer) clearTimeout(_clipTimer); _clipTimer = setTimeout(() => { - navigator.clipboard.writeText('').catch(() => {}); + navigator.clipboard.writeText("").catch(() => {}); _clipTimer = null; }, 30_000); } // ── Avatar helpers ──────────────────────────────────────────────────────────── -const AVATAR_COLORS = ['', 'color-red', 'color-green', 'color-purple', 'color-teal']; +const AVATAR_COLORS = [ + "", + "color-red", + "color-green", + "color-purple", + "color-teal", +]; function avatarColor(str) { let h = 0; @@ -83,26 +94,42 @@ function avatarColor(str) { } function itemEmoji(type) { - return { password: '🔑', note: '📝', card: '💳', bank: '🏦', address: '🏠', ssn: '🪪', passkey: '🔐' }[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 { } + 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)'; + 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 ''; } + if (!_currentUrl) return ""; + try { + return new URL(_currentUrl).hostname.replace(/^www\./, ""); + } catch { + return ""; + } } /** @@ -113,138 +140,179 @@ 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; + if (s.startsWith("//")) return "https:" + s; + return "https://" + s; } function isMatch(item) { const host = currentHostname(); - if (!host || item.item_type !== 'password' || !item.plain?.url) return false; + if (!host || item.item_type !== "password" || !item.plain?.url) return false; try { const normalised = _normaliseUrl(item.plain.url); if (!normalised) return false; - const h = new URL(normalised).hostname.replace(/^www\./, ''); + const h = new URL(normalised).hostname.replace(/^www\./, ""); // Match exact domain or any subdomain relationship. return h === host || h.endsWith(`.${host}`) || host.endsWith(`.${h}`); - } catch { return false; } + } 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}`; + 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}`; + 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'); + 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' }, + 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 }); + if (data.refresh_token) + await chrome.storage.local.set({ refresh_token: data.refresh_token }); return true; - } catch { return false; } + } 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 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…'; + 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' }, + 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 (!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 }); + await chrome.storage.session.set({ + _pending_enc_key_salt: data.enc_key_salt, + }); handleMfaStage(password); - showView('mfa'); - $('mfa-code').focus(); + showView("mfa"); + $("mfa-code").focus(); return; } await completeLogin(data, password); } catch (err) { - showError('login-error', 'Network error: ' + err.message); + showError("login-error", "Network error: " + err.message); } finally { - btn.disabled = false; btn.textContent = 'Unlock Vault'; + 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; + $("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' }, + 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; } + 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); + 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); + showError("mfa-error", "Error: " + err.message); } finally { - $('btn-mfa-verify').disabled = false; + $("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 }); + 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 }); + 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(() => { }); + 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'); + showView("vault"); await checkPendingSave(); await fetchAndDecryptVault(); renderList(); @@ -253,53 +321,65 @@ async function completeLogin(data, masterPassword) { // ── 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 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…'; + 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'); + 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'); + 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; } + 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'); + showView("vault"); await checkPendingSave(); await fetchAndDecryptVault(); renderList(); } catch { - showError('unlock-error', 'Incorrect password or session expired.'); + showError("unlock-error", "Incorrect password or session expired."); } finally { - btn.disabled = false; btn.textContent = 'Unlock'; + 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'); + 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}` }, + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${access_token}`, + }, body: JSON.stringify({ refresh_token }), - }).catch(() => { }); + }).catch(() => {}); } - } catch { } + } catch {} await chrome.storage.session.clear(); - await chrome.storage.local.remove(['refresh_token', 'enc_key_salt']); + 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; _items = []; - showView('login'); + _vaultKey = null; + _items = []; + showView("login"); } // ── Vault loading ───────────────────────────────────────────────────────────── @@ -311,25 +391,33 @@ async function signOut() { * 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']); + 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'; + return "vault"; } // Tokens exist (synced from web app) but vault key not yet derived - if (data.enc_key_salt) return 'unlock'; + 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']); + 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 "unlock"; } } @@ -337,31 +425,45 @@ async function restoreSessionIfAvailable() { } async function fetchAndDecryptVault() { - $('vault-spinner').classList.remove('hidden'); + $("vault-spinner").classList.remove("hidden"); try { const [res, foldersRes] = await Promise.all([ - apiFetch('/api/vault'), - apiFetch('/api/folders'), + 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 = []; } + } 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; + _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 }; } - return { ...item, name: displayName, plain }; - } catch { return { ...item, plain: null }; } - })); + }), + ); // Write to session for the popup's own use (badge, rendering). await chrome.storage.session.set({ vault_items: _items }); @@ -369,7 +471,7 @@ async function fetchAndDecryptVault() { // Write a lightweight copy to session storage for content scripts. // session storage is memory-only (cleared on browser close) — decrypted // vault data must never be persisted to disk via chrome.storage.local. - const itemsForContentScript = _items.map(item => ({ + const itemsForContentScript = _items.map((item) => ({ id: item.id, name: item.name, item_type: item.item_type, @@ -378,14 +480,16 @@ async function fetchAndDecryptVault() { await chrome.storage.session.set({ vault_items_cs: itemsForContentScript }); // Notify background to refresh badges and forward to content scripts. - chrome.runtime.sendMessage({ - type: 'VAULT_UPDATED', - vault_items: itemsForContentScript, - }).catch(() => { }); + chrome.runtime + .sendMessage({ + type: "VAULT_UPDATED", + vault_items: itemsForContentScript, + }) + .catch(() => {}); } catch (err) { - console.error('fetchAndDecryptVault:', err); + console.error("fetchAndDecryptVault:", err); } finally { - $('vault-spinner').classList.add('hidden'); + $("vault-spinner").classList.add("hidden"); } } @@ -394,26 +498,32 @@ async function fetchAndDecryptVault() { function _extractTotpSecret(uri) { if (!uri) return null; uri = uri.trim(); - if (uri.startsWith('otpauth://')) { + if (uri.startsWith("otpauth://")) { try { - const secret = new URL(uri).searchParams.get('secret'); - return secret ? secret.toUpperCase().replace(/\s+/g, '') : null; - } catch { return null; } + 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; + return uri.toUpperCase().replace(/[\s-]/g, "") || null; } function _base32ToBytes(b32) { - const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; - b32 = b32.replace(/=+$/, ''); - let bits = 0, val = 0; + 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); } + if (bits >= 8) { + bits -= 8; + out.push((val >> bits) & 0xff); + } } return new Uint8Array(out); } @@ -427,16 +537,27 @@ async function getTotpCode(totpUri) { 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); } + 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'] + "raw", + keyBytes, + { name: "HMAC", hash: "SHA-1" }, + false, + ["sign"], ); - const sig = new Uint8Array(await crypto.subtle.sign('HMAC', cryptoKey, msg)); + 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'); + 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() { @@ -447,33 +568,41 @@ function _totpSecondsLeft() { let _totpIntervals = []; function _clearTotpTickers() { - _totpIntervals.forEach(id => clearInterval(id)); + _totpIntervals.forEach((id) => clearInterval(id)); _totpIntervals = []; } // ── Rendering ───────────────────────────────────────────────────────────────── function getTabItems() { - const q = $('vault-search').value.trim().toLowerCase(); + 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) + items = items.filter( + (i) => + i.name.toLowerCase().includes(q) || + (i.plain?.username || "").toLowerCase().includes(q) || + (i.plain?.url || "").toLowerCase().includes(q), ); } - if (_activeTab === 'relevant') { + 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')) + } 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)); @@ -483,27 +612,27 @@ function getTabItems() { } function renderList() { - const listEl = $('vault-list'); - const emptyEl = $('vault-empty'); + 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') { + listEl.innerHTML = ""; + if (_activeTab === "relevant") { const host = currentHostname(); emptyEl.textContent = host ? `No saved passwords for ${host}.` - : 'No items match this page.'; + : "No items match this page."; } else { - emptyEl.textContent = 'No items found.'; + emptyEl.textContent = "No items found."; } - emptyEl.classList.remove('hidden'); + emptyEl.classList.remove("hidden"); return; } - emptyEl.classList.add('hidden'); + emptyEl.classList.add("hidden"); // SVG icons for action buttons const svgCopy = ``; @@ -516,51 +645,65 @@ function renderList() { const matched = isMatch(item); const site = escHtml(siteLabel(item)); const name = escHtml(item.name); - const badge = matched ? 'match' : ''; + 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; - 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'); + const canFill = + item.item_type === "password" && + item.plain?.username && + item.plain?.password; + 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}${isFav ? '' : ''}
+
${site}${badge}${isFav ? '' : ""}
${name}
- ${tagHtml ? `
${tagHtml}
` : ''} - ${hasTotp ? `
······
` : ''} + ${tagHtml ? `
${tagHtml}
` : ""} + ${hasTotp ? `
······
` : ""}
- ${item.plain?.username ? `` : ''} - ${canCopy ? `` : ''} - ${hasTotp ? `` : ''} - ${canFill ? `` : ''} + ${item.plain?.username ? `` : ""} + ${canCopy ? `` : ""} + ${hasTotp ? `` : ""} + ${canFill ? `` : ""}
`; } // Build the vault list — grouped by folder for 'all' tab, flat otherwise. - if (_activeTab === 'all' && _folders.length > 0) { + if (_activeTab === "all" && _folders.length > 0) { const groups = {}; - items.forEach(item => { + 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); }); + 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 `
+ 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)} @@ -570,24 +713,30 @@ function renderList() {
${itemsHtml}
`; - }).join(''); + }) + .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); + 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(''); + 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; + 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; @@ -595,10 +744,10 @@ function renderList() { 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); + codeEl.textContent = code.slice(0, 3) + " " + code.slice(3); const secs = _totpSecondsLeft(); - timerEl.textContent = ' ' + secs + 's'; - timerEl.style.color = secs <= 5 ? '#dc2626' : '#9ca3af'; + timerEl.textContent = " " + secs + "s"; + timerEl.style.color = secs <= 5 ? "#dc2626" : "#9ca3af"; } tick(); const id = setInterval(tick, 1000); @@ -606,126 +755,158 @@ function renderList() { }); // Copy password - listEl.querySelectorAll('[data-copy-pass]').forEach(btn => - btn.addEventListener('click', e => { + listEl.querySelectorAll("[data-copy-pass]").forEach((btn) => + btn.addEventListener("click", (e) => { e.stopPropagation(); - const item = _items.find(i => i.id === parseInt(btn.dataset.copyPass)); + const item = _items.find((i) => i.id === parseInt(btn.dataset.copyPass)); if (item?.plain?.password) { _copyWithAutoClear(item.plain.password); - btn.title = 'Copied!'; - setTimeout(() => { btn.title = 'Copy password'; }, 1500); + btn.title = "Copied!"; + setTimeout(() => { + btn.title = "Copy password"; + }, 1500); } - }) + }), ); // Copy TOTP code - listEl.querySelectorAll('[data-copy-totp]').forEach(btn => - btn.addEventListener('click', async e => { + listEl.querySelectorAll("[data-copy-totp]").forEach((btn) => + btn.addEventListener("click", async (e) => { e.stopPropagation(); - const item = _items.find(i => i.id === parseInt(btn.dataset.copyTotp)); + const item = _items.find((i) => i.id === parseInt(btn.dataset.copyTotp)); if (!item?.plain?.totp_uri) return; const code = await getTotpCode(item.plain.totp_uri).catch(() => null); if (code) { navigator.clipboard.writeText(code); - btn.title = 'Copied!'; - btn.style.color = '#16a34a'; - setTimeout(() => { btn.title = 'Copy 2FA code'; btn.style.color = ''; }, 1500); + 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 => { + listEl.querySelectorAll("[data-autofill]").forEach((btn) => + btn.addEventListener("click", async (e) => { e.stopPropagation(); - const item = _items.find(i => i.id === parseInt(btn.dataset.autofill)); + 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 }); + 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(() => { }); + chrome.tabs + .sendMessage(tab.id, { + type: "DO_AUTOFILL", + username: item.plain.username || "", + password: item.plain.password || "", + }) + .catch(() => {}); } window.close(); - }) + }), ); // Copy username (dedicated button) - listEl.querySelectorAll('[data-copy-user]').forEach(btn => - btn.addEventListener('click', e => { + listEl.querySelectorAll("[data-copy-user]").forEach((btn) => + btn.addEventListener("click", (e) => { e.stopPropagation(); - const item = _items.find(i => i.id === parseInt(btn.dataset.copyUser)); + const item = _items.find((i) => i.id === parseInt(btn.dataset.copyUser)); if (item?.plain?.username) { _copyWithAutoClear(item.plain.username); - btn.title = 'Copied!'; - setTimeout(() => { btn.title = 'Copy username'; }, 1500); + 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 => { + 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()); + document.querySelectorAll(".pk-flyout").forEach((el) => el.remove()); - const item = _items.find(i => i.id === parseInt(btn.dataset.menu)); + const item = _items.find((i) => i.id === parseInt(btn.dataset.menu)); if (!item) return; - const flyout = document.createElement('div'); - flyout.className = 'pk-flyout'; + const flyout = document.createElement("div"); + flyout.className = "pk-flyout"; const menuItems = [ - item.plain?.url ? { - label: 'Open URL', - icon: '', - action: () => { chrome.tabs.create({ url: item.plain.url }); flyout.remove(); }, - } : null, - item.plain?.username ? { - label: 'Copy username', - icon: '', - action: () => { _copyWithAutoClear(item.plain.username); flyout.remove(); }, - } : null, - item.plain?.password ? { - label: 'Copy password', - icon: '', - action: () => { _copyWithAutoClear(item.plain.password); flyout.remove(); }, - } : null, + item.plain?.url + ? { + label: "Open URL", + icon: '', + action: () => { + chrome.tabs.create({ url: item.plain.url }); + flyout.remove(); + }, + } + : null, + item.plain?.username + ? { + label: "Copy username", + icon: '', + action: () => { + _copyWithAutoClear(item.plain.username); + flyout.remove(); + }, + } + : null, + item.plain?.password + ? { + label: "Copy password", + icon: '', + action: () => { + _copyWithAutoClear(item.plain.password); + flyout.remove(); + }, + } + : null, ].filter(Boolean); - menuItems.forEach(mi => { - const row = document.createElement('button'); - row.className = 'pk-flyout-item'; - row.innerHTML = mi.icon + '' + escHtml(mi.label) + ''; - row.addEventListener('click', e => { e.stopPropagation(); mi.action(); }); + menuItems.forEach((mi) => { + const row = document.createElement("button"); + row.className = "pk-flyout-item"; + row.innerHTML = mi.icon + "" + escHtml(mi.label) + ""; + row.addEventListener("click", (e) => { + e.stopPropagation(); + mi.action(); + }); flyout.appendChild(row); }); // Position flyout below the button, right-aligned. - btn.style.position = 'relative'; + btn.style.position = "relative"; btn.appendChild(flyout); // Close on any outside click. setTimeout(() => { - document.addEventListener('click', function handler() { + document.addEventListener("click", function handler() { flyout.remove(); - document.removeEventListener('click', handler); + document.removeEventListener("click", handler); }); }, 0); - }) + }), ); } // ── Tabs ────────────────────────────────────────────────────────────────────── function initTabs() { - document.querySelectorAll('.tab-btn').forEach(btn => { - btn.addEventListener('click', () => { + 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)); + document + .querySelectorAll(".tab-btn") + .forEach((b) => b.classList.toggle("active", b === btn)); renderList(); }); }); @@ -739,21 +920,21 @@ function initTabs() { * The first option ("— No folder —") with value "" is always kept. */ async function loadFoldersIntoSaveModal() { - const select = $('save-folder'); + const select = $("save-folder"); // Reset to just the placeholder option. select.innerHTML = ''; try { - const res = await apiFetch('/api/folders'); + const res = await apiFetch("/api/folders"); if (!res?.ok) return; const folders = await res.json(); - folders.forEach(f => { - const opt = document.createElement('option'); + folders.forEach((f) => { + const opt = document.createElement("option"); opt.value = f.id; opt.textContent = f.name; select.appendChild(opt); }); } catch (err) { - console.error('[PassKeeper] loadFoldersIntoSaveModal failed:', err); + console.error("[PassKeeper] loadFoldersIntoSaveModal failed:", err); } } @@ -766,73 +947,100 @@ async function loadFoldersIntoSaveModal() { * 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'); + 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?'; + $("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?"; // Load folders (non-blocking — modal shows immediately, folders populate async). - $('save-folder').value = ''; + $("save-folder").value = ""; loadFoldersIntoSaveModal(); // Show the overlay. - $('save-prompt-overlay').classList.remove('hidden'); - $('save-name').focus(); + $("save-prompt-overlay").classList.remove("hidden"); + $("save-name").focus(); // Wire buttons — use onclick so re-running checkPendingSave never double-binds. // Helper to dismiss the overlay, clear storage, and remove the toolbar badge. function _clearPendingSave() { - $('save-prompt-overlay').classList.add('hidden'); - chrome.storage.local.remove('pending_save'); - chrome.runtime.sendMessage({ type: 'CLEAR_SAVE_BADGE' }).catch(() => {}); + $("save-prompt-overlay").classList.add("hidden"); + chrome.storage.local.remove("pending_save"); + chrome.runtime.sendMessage({ type: "CLEAR_SAVE_BADGE" }).catch(() => {}); } - $('btn-save-yes').onclick = async () => { - console.log('[PassKeeper] User saved credential for', pending_save.siteName); + $("btn-save-yes").onclick = async () => { + console.log( + "[PassKeeper] User saved credential for", + pending_save.siteName, + ); await saveCredential(pending_save); _clearPendingSave(); }; - $('btn-save-no').onclick = () => { - console.log('[PassKeeper] User dismissed save prompt for', pending_save.siteName); + $("btn-save-no").onclick = () => { + console.log( + "[PassKeeper] User dismissed save prompt for", + pending_save.siteName, + ); _clearPendingSave(); }; } 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 plain = { + url: data.url || "", + username: data.username || "", + password: data.password || "", + notes: "", + }; + const name = $("save-name").value.trim() || data.siteName || "Untitled"; // Read selected folder — empty string means no folder (null). - const folderVal = $('save-folder').value; + const folderVal = $("save-folder").value; const folder_id = folderVal ? parseInt(folderVal, 10) : null; const { enc_data, iv } = await ExtCrypto.encryptItem(_vaultKey, plain); const { enc_name, iv_name } = await ExtCrypto.encryptName(_vaultKey, name); try { - const res = await apiFetch('/api/vault', { - method: 'POST', - body: JSON.stringify({ name: 'password', item_type: 'password', folder_id, enc_data, iv, enc_name, iv_name }), + const res = await apiFetch("/api/vault", { + method: "POST", + body: JSON.stringify({ + name: "password", + item_type: "password", + folder_id, + enc_data, + iv, + enc_name, + iv_name, + }), }); if (res?.ok) { - console.log('[PassKeeper] Credential saved to vault:', name, '(name encrypted), folder_id:', folder_id); + console.log( + "[PassKeeper] Credential saved to vault:", + name, + "(name encrypted), folder_id:", + folder_id, + ); await fetchAndDecryptVault(); renderList(); } - } catch (err) { console.error('saveCredential failed:', err); } + } catch (err) { + console.error("saveCredential failed:", err); + } } // ── Password Generator ──────────────────────────────────────────────────────── const GEN_SETS = { - lower: 'abcdefghijklmnopqrstuvwxyz', - upper: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', - numbers: '0123456789', - symbols: '!@#$%^&*()-_=+[]{}|;:,.<>?', + lower: "abcdefghijklmnopqrstuvwxyz", + upper: "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + numbers: "0123456789", + symbols: "!@#$%^&*()-_=+[]{}|;:,.<>?", }; /** @@ -843,45 +1051,53 @@ function _cryptoRandInt(max) { // Rejection sampling to eliminate modulo bias. const limit = Math.floor(0x100000000 / max) * max; const buf = new Uint32Array(1); - do { crypto.getRandomValues(buf); } while (buf[0] >= limit); + do { + crypto.getRandomValues(buf); + } while (buf[0] >= limit); return buf[0] % max; } 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 ''; + 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 // using cryptographically secure random selection. const required = []; - if (useLower) required.push(GEN_SETS.lower[_cryptoRandInt(GEN_SETS.lower.length)]); - if (useUpper) required.push(GEN_SETS.upper[_cryptoRandInt(GEN_SETS.upper.length)]); - if (useNumbers) required.push(GEN_SETS.numbers[_cryptoRandInt(GEN_SETS.numbers.length)]); - if (useSymbols) required.push(GEN_SETS.symbols[_cryptoRandInt(GEN_SETS.symbols.length)]); + if (useLower) + required.push(GEN_SETS.lower[_cryptoRandInt(GEN_SETS.lower.length)]); + if (useUpper) + required.push(GEN_SETS.upper[_cryptoRandInt(GEN_SETS.upper.length)]); + if (useNumbers) + required.push(GEN_SETS.numbers[_cryptoRandInt(GEN_SETS.numbers.length)]); + if (useSymbols) + required.push(GEN_SETS.symbols[_cryptoRandInt(GEN_SETS.symbols.length)]); const arr = new Uint32Array(length); crypto.getRandomValues(arr); - const rest = Array.from(arr).map(n => pool[n % pool.length]); + 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; }); + required.forEach((ch, i) => { + combined[i] = ch; + }); // Fisher-Yates shuffle — fully CSPRNG, no Math.random(). for (let i = combined.length - 1; i > 0; i--) { const j = _cryptoRandInt(i + 1); [combined[i], combined[j]] = [combined[j], combined[i]]; } - return combined.slice(0, length).join(''); + return combined.slice(0, length).join(""); } function passwordStrength(pw) { - if (!pw) return { label: '', color: '' }; + if (!pw) return { label: "", color: "" }; let score = 0; if (pw.length >= 8) score++; if (pw.length >= 12) score++; @@ -890,11 +1106,11 @@ function passwordStrength(pw) { 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' }; + 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; @@ -904,33 +1120,35 @@ function initGenerator() { if (!_genInitialised) { _genInitialised = true; - const slider = $('gen-length'); - const numInput = $('gen-length-num'); + const slider = $("gen-length"); + const numInput = $("gen-length-num"); // Keep slider and number input in sync. - slider.addEventListener('input', () => { + slider.addEventListener("input", () => { numInput.value = slider.value; refreshGen(); }); - numInput.addEventListener('input', () => { + 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); + ["gen-lower", "gen-upper", "gen-numbers", "gen-symbols"].forEach((id) => { + $(id).addEventListener("change", refreshGen); }); - $('btn-gen-refresh').addEventListener('click', 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; + $("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); + $("btn-gen-copy").title = "Copied!"; + setTimeout(() => { + $("btn-gen-copy").title = "Copy password"; + }, 1500); }); }); } @@ -940,24 +1158,33 @@ function initGenerator() { } 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; + 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 = ''; + $("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 pw = generatePassword( + length, + useLower, + useUpper, + useNumbers, + useSymbols, + ); + $("gen-output").textContent = pw; const { label, color } = passwordStrength(pw); - const strengthEl = $('gen-strength-label'); + const strengthEl = $("gen-strength-label"); strengthEl.textContent = label; strengthEl.style.color = color; } @@ -969,180 +1196,211 @@ let _addViewInitialised = false; async function initAddView() { // Pre-fill URL from the current active tab. try { - const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); - if (tab?.url?.startsWith('http')) { - $('add-url').value = tab.url; + const [tab] = await chrome.tabs.query({ + active: true, + currentWindow: true, + }); + if (tab?.url?.startsWith("http")) { + $("add-url").value = tab.url; // Derive a human-friendly site name from the tab title or hostname. - if (!$('add-name').value) { - $('add-name').value = tab.title - ? tab.title.replace(/[-|–].*$/, '').trim() // strip " - Login" suffixes - : new URL(tab.url).hostname.replace(/^www\./, ''); + if (!$("add-name").value) { + $("add-name").value = tab.title + ? tab.title.replace(/[-|–].*$/, "").trim() // strip " - Login" suffixes + : new URL(tab.url).hostname.replace(/^www\./, ""); } } - } catch (e) { } + } catch (e) {} // Load folders into the add-folder select. - const addFolderSel = $('add-folder'); + const addFolderSel = $("add-folder"); addFolderSel.innerHTML = ''; try { - const res = await apiFetch('/api/folders'); + const res = await apiFetch("/api/folders"); if (res?.ok) { const folders = await res.json(); - folders.forEach(f => { - const opt = document.createElement('option'); + folders.forEach((f) => { + const opt = document.createElement("option"); opt.value = f.id; opt.textContent = f.name; addFolderSel.appendChild(opt); }); } - } catch (e) { } + } catch (e) {} // Wire one-time event listeners. if (!_addViewInitialised) { _addViewInitialised = true; // Back button. - $('btn-add-back').addEventListener('click', () => { - showView('vault'); - hideError('add-error'); + $("btn-add-back").addEventListener("click", () => { + showView("vault"); + hideError("add-error"); }); // Show/hide password toggle. - $('btn-add-toggle-pw').addEventListener('click', () => { - const pw = $('add-password'); - pw.type = pw.type === 'password' ? 'text' : 'password'; + $("btn-add-toggle-pw").addEventListener("click", () => { + const pw = $("add-password"); + pw.type = pw.type === "password" ? "text" : "password"; }); // Generate password — generates via the generator logic and pastes into field. - $('btn-add-generate').addEventListener('click', () => { + $("btn-add-generate").addEventListener("click", () => { const pw = generatePassword(16, true, true, true, true); - $('add-password').value = pw; - $('add-password').type = 'text'; // show it so the user can see what was generated + $("add-password").value = pw; + $("add-password").type = "text"; // show it so the user can see what was generated }); // Save button. - $('btn-add-save').addEventListener('click', addItemToVault); + $("btn-add-save").addEventListener("click", addItemToVault); // Allow Enter in any text field to submit. - ['add-name', 'add-url', 'add-username', 'add-password'].forEach(id => { - $(id).addEventListener('keydown', e => { if (e.key === 'Enter') addItemToVault(); }); + ["add-name", "add-url", "add-username", "add-password"].forEach((id) => { + $(id).addEventListener("keydown", (e) => { + if (e.key === "Enter") addItemToVault(); + }); }); } // Clear fields and error each time the view opens. - ['add-name', 'add-url', 'add-username', 'add-password', 'add-notes'].forEach(id => { - // Don't reset url/name — already pre-filled above. - if (id === 'add-url' || id === 'add-name') return; - $(id).value = ''; - }); - $('add-password').type = 'password'; - hideError('add-error'); - $('add-name').focus(); + ["add-name", "add-url", "add-username", "add-password", "add-notes"].forEach( + (id) => { + // Don't reset url/name — already pre-filled above. + if (id === "add-url" || id === "add-name") return; + $(id).value = ""; + }, + ); + $("add-password").type = "password"; + hideError("add-error"); + $("add-name").focus(); } async function addItemToVault() { - const name = $('add-name').value.trim(); - const url = $('add-url').value.trim(); - const username = $('add-username').value.trim(); - const password = $('add-password').value; - const notes = $('add-notes').value.trim(); - const folderVal = $('add-folder').value; + const name = $("add-name").value.trim(); + const url = $("add-url").value.trim(); + const username = $("add-username").value.trim(); + const password = $("add-password").value; + const notes = $("add-notes").value.trim(); + const folderVal = $("add-folder").value; const folder_id = folderVal ? parseInt(folderVal, 10) : null; - hideError('add-error'); + hideError("add-error"); - if (!name) { showError('add-error', 'Site name is required.'); return; } - if (!password) { showError('add-error', 'Password is required.'); return; } - if (!_vaultKey) { showError('add-error', 'Vault is locked. Please unlock first.'); return; } + if (!name) { + showError("add-error", "Site name is required."); + return; + } + if (!password) { + showError("add-error", "Password is required."); + return; + } + if (!_vaultKey) { + showError("add-error", "Vault is locked. Please unlock first."); + return; + } - const btn = $('btn-add-save'); + const btn = $("btn-add-save"); btn.disabled = true; - btn.textContent = 'Saving…'; + btn.textContent = "Saving…"; try { const plain = { url, username, password, notes }; const { enc_data, iv } = await ExtCrypto.encryptItem(_vaultKey, plain); const { enc_name, iv_name } = await ExtCrypto.encryptName(_vaultKey, name); - const res = await apiFetch('/api/vault', { - method: 'POST', - body: JSON.stringify({ name: 'password', item_type: 'password', folder_id, enc_data, iv, enc_name, iv_name }), + const res = await apiFetch("/api/vault", { + method: "POST", + body: JSON.stringify({ + name: "password", + item_type: "password", + folder_id, + enc_data, + iv, + enc_name, + iv_name, + }), }); if (!res?.ok) { const data = await res.json().catch(() => ({})); - showError('add-error', data.error || 'Failed to save. Please try again.'); + showError("add-error", data.error || "Failed to save. Please try again."); return; } - console.log('[PassKeeper] Item added to vault:', name, '(name encrypted)'); + console.log("[PassKeeper] Item added to vault:", name, "(name encrypted)"); // Refresh vault and go back. await fetchAndDecryptVault(); renderList(); - showView('vault'); + showView("vault"); } catch (err) { - showError('add-error', 'Network error: ' + err.message); + showError("add-error", "Network error: " + err.message); } finally { btn.disabled = false; - btn.textContent = 'Save to Vault'; + btn.textContent = "Save to Vault"; } } // ── Account view ────────────────────────────────────────────────────────────── -const IDLE_TIMEOUT_KEY = 'idle_lock_seconds'; +const IDLE_TIMEOUT_KEY = "idle_lock_seconds"; async function initAccountView() { // Load saved timeout value and reflect it in the select. const stored = await chrome.storage.local.get(IDLE_TIMEOUT_KEY); const saved = stored[IDLE_TIMEOUT_KEY]; if (saved !== undefined) { - const sel = $('acct-idle-timeout'); + const sel = $("acct-idle-timeout"); // Find the matching option; fall back to 600 if not found. - const opt = Array.from(sel.options).find(o => parseInt(o.value) === saved); + const opt = Array.from(sel.options).find( + (o) => parseInt(o.value) === saved, + ); if (opt) sel.value = String(saved); } // Save on change and notify the background to apply immediately. - $('acct-idle-timeout').addEventListener('change', async () => { - const val = parseInt($('acct-idle-timeout').value); + $("acct-idle-timeout").addEventListener("change", async () => { + const val = parseInt($("acct-idle-timeout").value); await chrome.storage.local.set({ [IDLE_TIMEOUT_KEY]: val }); // Tell background to re-apply the new interval. - chrome.runtime.sendMessage({ type: 'SET_IDLE_TIMEOUT', seconds: val }).catch(() => { }); + chrome.runtime + .sendMessage({ type: "SET_IDLE_TIMEOUT", seconds: val }) + .catch(() => {}); // Brief "Saved" confirmation. - const saved = $('acct-idle-saved'); - saved.classList.remove('hidden'); - setTimeout(() => saved.classList.add('hidden'), 2000); - console.log('[PassKeeper] Idle lock timeout set to', val, 'seconds'); + const saved = $("acct-idle-saved"); + saved.classList.remove("hidden"); + setTimeout(() => saved.classList.add("hidden"), 2000); + console.log("[PassKeeper] Idle lock timeout set to", val, "seconds"); }); - $('acct-open-vault').href = VAULT_URL; - $('acct-signout').addEventListener('click', signOut); + $("acct-open-vault").href = VAULT_URL; + $("acct-signout").addEventListener("click", signOut); } // ── Init ────────────────────────────────────────────────────────────────────── async function init() { try { - const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); - _currentUrl = tab?.url || ''; - } catch { } + const [tab] = await chrome.tabs.query({ + active: true, + currentWindow: true, + }); + _currentUrl = tab?.url || ""; + } catch {} // Set vault link - $('btn-open-vault').href = VAULT_URL; + $("btn-open-vault").href = VAULT_URL; const sessionState = await restoreSessionIfAvailable(); - if (sessionState === 'vault') { + if (sessionState === "vault") { // Check whether the content script requested a specific view (e.g. generator). - const { popup_nav } = await chrome.storage.session.get('popup_nav'); + const { popup_nav } = await chrome.storage.session.get("popup_nav"); if (popup_nav) { - await chrome.storage.session.remove('popup_nav'); - if (popup_nav === 'generator') { - showView('generator'); + await chrome.storage.session.remove("popup_nav"); + if (popup_nav === "generator") { + showView("generator"); initGenerator(); // Still load vault data in background so the Vault tab is ready. - fetchAndDecryptVault().then(() => { }); + fetchAndDecryptVault().then(() => {}); // Wire event listeners below, then return early from vault-specific setup. } else { - showView('vault'); + showView("vault"); await checkPendingSave(); if (_items.length) { renderList(); @@ -1153,7 +1411,7 @@ async function init() { } } } else { - showView('vault'); + showView("vault"); await checkPendingSave(); if (_items.length) { renderList(); @@ -1163,46 +1421,70 @@ async function init() { renderList(); } } - } else if (sessionState === 'unlock') { - showView('unlock'); - $('unlock-password').focus(); + } else if (sessionState === "unlock") { + showView("unlock"); + $("unlock-password").focus(); } else { - showView('login'); + 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(); }); + $("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(); }); + $("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); + $("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', () => { showView('add'); initAddView(); }); - $('nav-vault').addEventListener('click', () => { showView('vault'); }); - $('nav-generator').addEventListener('click', () => { showView('generator'); initGenerator(); }); - $('nav-alerts').addEventListener('click', () => { chrome.tabs.create({ url: ALERTS_URL }); }); - $('nav-account').addEventListener('click', () => { showView('account'); initAccountView(); }); + $("btn-add-item").addEventListener("click", () => { + showView("add"); + initAddView(); + }); + $("nav-vault").addEventListener("click", () => { + showView("vault"); + }); + $("nav-generator").addEventListener("click", () => { + showView("generator"); + initGenerator(); + }); + $("nav-alerts").addEventListener("click", () => { + chrome.tabs.create({ url: ALERTS_URL }); + }); + $("nav-account").addEventListener("click", () => { + showView("account"); + initAccountView(); + }); // 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(() => { }); + chrome.runtime.sendMessage({ type: "KEEPALIVE" }).catch(() => {}); }, 20_000); // Search - $('vault-search').addEventListener('input', () => renderList()); + $("vault-search").addEventListener("input", () => renderList()); // Tabs initTabs(); } -document.addEventListener('DOMContentLoaded', init); \ No newline at end of file +document.addEventListener("DOMContentLoaded", init);