Files
PassKeeper/extension/popup/popup.js
T

896 lines
28 KiB
JavaScript

/**
* extension/popup/popup.js — PassKeeper popup UI
*
* Storage layout:
* chrome.storage.session — access_token, vault_key_jwk, vault_items (cleared on browser close)
* chrome.storage.local — refresh_token, enc_key_salt (persists across restarts)
*
* SSO flow (web app → extension):
* bridge.js syncs tokens to background → background writes to chrome.storage.
* On popup open: if session has no vault_key but local has enc_key_salt + refresh_token,
* we refresh the access_token and show the unlock-only view (master password only).
*/
const API_BASE = "https://pwkeeper.ngodanguyen.tech";
const VAULT_URL = "https://pwkeeper.ngodanguyen.tech/vault";
const ALERTS_URL = "https://pwkeeper.ngodanguyen.tech/vault#security";
// ── State ─────────────────────────────────────────────────────────────────────
let _vaultKey = null;
let _items = [];
let _mfaToken = null;
let _currentUrl = "";
let _activeTab = "relevant";
// ── DOM helpers ───────────────────────────────────────────────────────────────
const $ = (id) => document.getElementById(id);
function showView(name) {
["login", "mfa", "unlock", "vault", "generator"].forEach((v) =>
$(`view-${v}`).classList.toggle("hidden", v !== name),
);
// Bottom nav is only visible when the user is in the authenticated area.
const navViews = ["vault", "generator"];
$("main-bottom-nav").classList.toggle("hidden", !navViews.includes(name));
// Keep active state on nav buttons in sync.
["nav-vault", "nav-generator"].forEach((id) => {
const btn = $(id);
if (!btn) return;
const matches =
(id === "nav-vault" && name === "vault") ||
(id === "nav-generator" && name === "generator");
btn.classList.toggle("active", matches);
});
}
function showError(elId, msg) {
const el = $(elId);
el.textContent = msg;
el.classList.remove("hidden");
}
function hideError(elId) {
$(elId).classList.add("hidden");
}
function escHtml(str) {
return String(str ?? "")
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// ── Avatar helpers ────────────────────────────────────────────────────────────
const AVATAR_COLORS = [
"",
"color-red",
"color-green",
"color-purple",
"color-teal",
];
function avatarColor(str) {
let h = 0;
for (const c of str) h = (h * 31 + c.charCodeAt(0)) >>> 0;
return AVATAR_COLORS[h % AVATAR_COLORS.length];
}
function itemEmoji(type) {
return (
{
password: "🔑",
note: "📝",
card: "💳",
bank: "🏦",
address: "🏠",
ssn: "🪪",
passkey: "🔐",
}[type] || "🔑"
);
}
function siteLabel(item) {
if (item.plain?.url) {
try {
return new URL(item.plain.url).hostname.replace(/^www\./, "");
} catch {}
}
return item.name;
}
// ── Domain matching ───────────────────────────────────────────────────────────
function currentHostname() {
if (!_currentUrl) return "";
try {
return new URL(_currentUrl).hostname.replace(/^www\./, "");
} catch {
return "";
}
}
function isMatch(item) {
const host = currentHostname();
if (!host || item.item_type !== "password" || !item.plain?.url) return false;
try {
const h = new URL(item.plain.url).hostname.replace(/^www\./, "");
return h === host || h.endsWith(`.${host}`) || host.endsWith(`.${h}`);
} catch {
return false;
}
}
// ── API helpers ───────────────────────────────────────────────────────────────
async function apiFetch(path, options = {}) {
const { access_token } = await chrome.storage.session.get("access_token");
const headers = {
"Content-Type": "application/json",
...(options.headers || {}),
};
if (access_token) headers["Authorization"] = `Bearer ${access_token}`;
let res = await fetch(`${API_BASE}${path}`, { ...options, headers });
if (res.status === 401) {
const refreshed = await tryRefreshToken();
if (!refreshed) {
signOut();
return null;
}
const { access_token: tok } =
await chrome.storage.session.get("access_token");
headers["Authorization"] = `Bearer ${tok}`;
res = await fetch(`${API_BASE}${path}`, { ...options, headers });
}
return res;
}
async function tryRefreshToken() {
const { refresh_token } = await chrome.storage.local.get("refresh_token");
if (!refresh_token) return false;
try {
const res = await fetch(`${API_BASE}/api/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token }),
});
if (!res.ok) return false;
const data = await res.json();
await chrome.storage.session.set({ access_token: data.access_token });
if (data.refresh_token)
await chrome.storage.local.set({ refresh_token: data.refresh_token });
return true;
} catch {
return false;
}
}
// ── Auth ──────────────────────────────────────────────────────────────────────
async function handleLogin() {
const email = $("login-email").value.trim().toLowerCase();
const password = $("login-password").value;
hideError("login-error");
if (!email || !password) {
showError("login-error", "Email and master password are required.");
return;
}
const btn = $("btn-login");
btn.disabled = true;
btn.textContent = "Unlocking…";
try {
const authHash = await ExtCrypto.deriveAuthHash(password, email);
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, auth_hash: authHash }),
});
const data = await res.json();
if (!res.ok) {
showError("login-error", data.error || "Login failed.");
return;
}
if (data.mfa_required) {
_mfaToken = data.mfa_token;
await chrome.storage.session.set({
_pending_enc_key_salt: data.enc_key_salt,
});
handleMfaStage(password);
showView("mfa");
$("mfa-code").focus();
return;
}
await completeLogin(data, password);
} catch (err) {
showError("login-error", "Network error: " + err.message);
} finally {
btn.disabled = false;
btn.textContent = "Unlock Vault";
}
}
function handleMfaStage(password) {
$("btn-mfa-verify").onclick = async () => {
const code = $("mfa-code").value.trim();
hideError("mfa-error");
if (code.length !== 6) {
showError("mfa-error", "Enter the 6-digit code.");
return;
}
$("btn-mfa-verify").disabled = true;
try {
const res = await fetch(`${API_BASE}/api/auth/mfa/verify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mfa_token: _mfaToken, totp_code: code }),
});
const data = await res.json();
if (!res.ok) {
showError("mfa-error", data.error || "Verification failed.");
return;
}
_mfaToken = null;
const { _pending_enc_key_salt } = await chrome.storage.session.get(
"_pending_enc_key_salt",
);
await completeLogin(
{ ...data, enc_key_salt: _pending_enc_key_salt },
password,
);
} catch (err) {
showError("mfa-error", "Error: " + err.message);
} finally {
$("btn-mfa-verify").disabled = false;
}
};
}
async function completeLogin(data, masterPassword) {
await chrome.storage.session.set({
access_token: data.access_token,
enc_key_salt: data.enc_key_salt,
});
// enc_key_salt also persisted locally — not sensitive without the master password,
// needed to show unlock-only view after browser restart.
await chrome.storage.local.set({
refresh_token: data.refresh_token,
enc_key_salt: data.enc_key_salt,
});
_vaultKey = await ExtCrypto.deriveVaultKey(masterPassword, data.enc_key_salt);
const vaultKeyJwk = await ExtCrypto.exportVaultKey(_vaultKey);
await chrome.storage.session.set({ vault_key_jwk: vaultKeyJwk });
// Push session to any open web app tabs so they don't need to re-login
chrome.runtime
.sendMessage({
type: "EXT_SESSION_SYNC",
access_token: data.access_token,
refresh_token: data.refresh_token,
enc_key_salt: data.enc_key_salt,
})
.catch(() => {});
showView("vault");
await checkPendingSave();
await fetchAndDecryptVault();
renderList();
}
// ── Unlock-only (tokens from web app, just need master password) ──────────────
async function handleUnlockOnly() {
const password = $("unlock-password").value;
hideError("unlock-error");
if (!password) {
showError("unlock-error", "Enter your master password.");
return;
}
const btn = $("btn-unlock");
btn.disabled = true;
btn.textContent = "Unlocking…";
try {
// enc_key_salt may be in session (fresh) or local (after browser restart)
let { enc_key_salt } = await chrome.storage.session.get("enc_key_salt");
if (!enc_key_salt) {
const local = await chrome.storage.local.get("enc_key_salt");
enc_key_salt = local.enc_key_salt;
}
if (!enc_key_salt) {
showError("unlock-error", "Session expired. Please log in again.");
return;
}
_vaultKey = await ExtCrypto.deriveVaultKey(password, enc_key_salt);
const vaultKeyJwk = await ExtCrypto.exportVaultKey(_vaultKey);
await chrome.storage.session.set({ vault_key_jwk: vaultKeyJwk });
showView("vault");
await checkPendingSave();
await fetchAndDecryptVault();
renderList();
} catch {
showError("unlock-error", "Incorrect password or session expired.");
} finally {
btn.disabled = false;
btn.textContent = "Unlock";
}
}
async function signOut() {
try {
const { access_token } = await chrome.storage.session.get("access_token");
const { refresh_token } = await chrome.storage.local.get("refresh_token");
if (access_token) {
fetch(`${API_BASE}/api/auth/logout`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${access_token}`,
},
body: JSON.stringify({ refresh_token }),
}).catch(() => {});
}
} catch {}
await chrome.storage.session.clear();
await chrome.storage.local.remove(["refresh_token", "enc_key_salt"]);
_vaultKey = null;
_items = [];
showView("login");
}
// ── Vault loading ─────────────────────────────────────────────────────────────
/**
* Returns:
* 'vault' — fully unlocked session restored
* 'unlock' — tokens exist (from web app SSO) but vault key needs deriving
* false — no session, show login
*/
async function restoreSessionIfAvailable() {
const data = await chrome.storage.session.get([
"access_token",
"vault_key_jwk",
"vault_items",
"enc_key_salt",
]);
if (data.access_token) {
if (data.vault_key_jwk) {
_vaultKey = await ExtCrypto.importVaultKey(data.vault_key_jwk);
_items = data.vault_items || [];
return "vault";
}
// Tokens exist (synced from web app) but vault key not yet derived
if (data.enc_key_salt) return "unlock";
}
// Session cleared (e.g. browser restart) — try refreshing with persisted local tokens
const local = await chrome.storage.local.get([
"refresh_token",
"enc_key_salt",
]);
if (local.refresh_token && local.enc_key_salt) {
const refreshed = await tryRefreshToken();
if (refreshed) {
await chrome.storage.session.set({ enc_key_salt: local.enc_key_salt });
return "unlock";
}
}
return false;
}
async function fetchAndDecryptVault() {
$("vault-spinner").classList.remove("hidden");
try {
const res = await apiFetch("/api/vault");
if (!res) return;
const raw = await res.json();
_items = await Promise.all(
raw.map(async (item) => {
try {
const plain = await ExtCrypto.decryptItem(
_vaultKey,
item.enc_data,
item.iv,
);
return { ...item, plain };
} catch {
return { ...item, plain: null };
}
}),
);
await chrome.storage.session.set({ vault_items: _items });
chrome.runtime.sendMessage({ type: "VAULT_UPDATED" }).catch(() => {});
} catch (err) {
console.error("fetchAndDecryptVault:", err);
} finally {
$("vault-spinner").classList.add("hidden");
}
}
// ── Rendering ─────────────────────────────────────────────────────────────────
function getTabItems() {
const q = $("vault-search").value.trim().toLowerCase();
let items = _items;
// Text filter
if (q) {
items = items.filter(
(i) =>
i.name.toLowerCase().includes(q) ||
(i.plain?.username || "").toLowerCase().includes(q) ||
(i.plain?.url || "").toLowerCase().includes(q),
);
}
if (_activeTab === "relevant") {
// Show ONLY items that match the current domain, sorted by name.
items = items.filter(isMatch).sort((a, b) => a.name.localeCompare(b.name));
} else if (_activeTab === "recents") {
items = [...items]
.sort(
(a, b) =>
new Date(b.updated_at || b.created_at) -
new Date(a.updated_at || a.created_at),
)
.slice(0, 20);
} else {
items = [...items].sort((a, b) => a.name.localeCompare(b.name));
}
return items;
}
function renderList() {
const listEl = $("vault-list");
const emptyEl = $("vault-empty");
const items = getTabItems();
if (!items.length) {
listEl.innerHTML = "";
if (_activeTab === "relevant") {
const host = currentHostname();
emptyEl.textContent = host
? `No saved passwords for ${host}.`
: "No items match this page.";
} else {
emptyEl.textContent = "No items found.";
}
emptyEl.classList.remove("hidden");
return;
}
emptyEl.classList.add("hidden");
// SVG icons for action buttons
const svgCopy = `<svg viewBox="0 0 24 24" fill="none"><rect x="9" y="9" width="11" height="11" rx="2" stroke="currentColor" stroke-width="1.7"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke="currentColor" stroke-width="1.7"/></svg>`;
const svgDots = `<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/></svg>`;
const svgFill = `<svg viewBox="0 0 24 24" fill="none"><path d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/><path d="M17 3l4 4-9 9H8v-4l9-9z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>`;
listEl.innerHTML = items
.map((item) => {
const matched = isMatch(item);
const site = escHtml(siteLabel(item));
const name = escHtml(item.name);
const badge = matched ? '<span class="badge-match">match</span>' : "";
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 `<div class="vault-item" data-id="${item.id}">
<div class="item-avatar ${color}">${emoji}</div>
<div class="item-info">
<div class="item-site">${site}${badge}</div>
<div class="item-name">${name}</div>
</div>
<div class="item-actions">
${canCopy ? `<button class="btn-item-action" data-copy-pass="${item.id}" title="Copy password">${svgCopy}</button>` : ""}
${canFill ? `<button class="btn-item-action" data-autofill="${item.id}" title="Autofill">${svgFill}</button>` : ""}
<button class="btn-item-action" data-menu="${item.id}" title="More">${svgDots}</button>
</div>
</div>`;
})
.join("");
// Copy password
listEl.querySelectorAll("[data-copy-pass]").forEach((btn) =>
btn.addEventListener("click", (e) => {
e.stopPropagation();
const item = _items.find((i) => i.id === parseInt(btn.dataset.copyPass));
if (item?.plain?.password) {
navigator.clipboard.writeText(item.plain.password);
btn.title = "Copied!";
setTimeout(() => {
btn.title = "Copy password";
}, 1500);
}
}),
);
// Autofill
listEl.querySelectorAll("[data-autofill]").forEach((btn) =>
btn.addEventListener("click", async (e) => {
e.stopPropagation();
const item = _items.find((i) => i.id === parseInt(btn.dataset.autofill));
if (!item?.plain) return;
const [tab] = await chrome.tabs.query({
active: true,
currentWindow: true,
});
if (tab?.id) {
chrome.tabs
.sendMessage(tab.id, {
type: "DO_AUTOFILL",
username: item.plain.username || "",
password: item.plain.password || "",
})
.catch(() => {});
}
window.close();
}),
);
// Three-dot menu: copy username
listEl.querySelectorAll("[data-menu]").forEach((btn) =>
btn.addEventListener("click", (e) => {
e.stopPropagation();
const item = _items.find((i) => i.id === parseInt(btn.dataset.menu));
if (!item?.plain) return;
// Simple: copy username on dots click (could be a dropdown in future)
if (item.plain.username) {
navigator.clipboard.writeText(item.plain.username);
btn.title = "Username copied!";
setTimeout(() => {
btn.title = "More";
}, 1500);
}
}),
);
}
// ── Tabs ──────────────────────────────────────────────────────────────────────
function initTabs() {
document.querySelectorAll(".tab-btn").forEach((btn) => {
btn.addEventListener("click", () => {
_activeTab = btn.dataset.tab;
document
.querySelectorAll(".tab-btn")
.forEach((b) => b.classList.toggle("active", b === btn));
renderList();
});
});
}
// ── Save-prompt modal ─────────────────────────────────────────────────────────
/**
* Show the save-prompt overlay. It is a blocking modal — no backdrop click,
* no ✕ button — so the user MUST click "Save" or "Not now".
* Called every time the popup opens while a pending_save is stored.
*
* pending_save is stored in chrome.storage.local (not session) so it survives
* service worker restarts and is reliably present when the popup re-opens.
*/
async function checkPendingSave() {
const { pending_save } = await chrome.storage.local.get("pending_save");
if (!pending_save) return;
// Populate fields.
$("save-name").value = pending_save.siteName || "";
$("save-username").value = pending_save.username || "";
$("save-prompt-subtitle").textContent =
`${pending_save.username || "(no username)"} on ${pending_save.siteName || pending_save.url || "unknown site"}`;
$("save-prompt-title").textContent = pending_save._isUpdate
? "Update in PassKeeper?"
: "Save to PassKeeper?";
// Show the overlay.
$("save-prompt-overlay").classList.remove("hidden");
$("save-name").focus();
// Wire buttons — use onclick so re-running checkPendingSave never double-binds.
$("btn-save-yes").onclick = async () => {
$("save-prompt-overlay").classList.add("hidden");
console.log(
"[PassKeeper] User saved credential for",
pending_save.siteName,
);
await saveCredential(pending_save);
await chrome.storage.local.remove("pending_save");
};
$("btn-save-no").onclick = async () => {
$("save-prompt-overlay").classList.add("hidden");
console.log(
"[PassKeeper] User dismissed save prompt for",
pending_save.siteName,
);
await chrome.storage.local.remove("pending_save");
};
}
async function saveCredential(data) {
if (!_vaultKey) return;
const plain = {
url: data.url || "",
username: data.username || "",
password: data.password || "",
notes: "",
};
const name = $("save-name").value.trim() || data.siteName || "Untitled";
const { enc_data, iv } = await ExtCrypto.encryptItem(_vaultKey, plain);
try {
const res = await apiFetch("/api/vault", {
method: "POST",
body: JSON.stringify({
name,
item_type: "password",
folder_id: null,
enc_data,
iv,
}),
});
if (res?.ok) {
await fetchAndDecryptVault();
renderList();
}
} catch (err) {
console.error("saveCredential failed:", err);
}
}
// ── Password Generator ────────────────────────────────────────────────────────
const GEN_SETS = {
lower: "abcdefghijklmnopqrstuvwxyz",
upper: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
numbers: "0123456789",
symbols: "!@#$%^&*()-_=+[]{}|;:,.<>?",
};
function generatePassword(length, useLower, useUpper, useNumbers, useSymbols) {
// Always fall back to lower if nothing selected, preventing infinite loop.
const pool = [
useLower ? GEN_SETS.lower : "",
useUpper ? GEN_SETS.upper : "",
useNumbers ? GEN_SETS.numbers : "",
useSymbols ? GEN_SETS.symbols : "",
].join("");
if (!pool) return "";
// Guarantee at least one character from each selected charset.
const required = [];
if (useLower)
required.push(
GEN_SETS.lower[Math.floor(Math.random() * GEN_SETS.lower.length)],
);
if (useUpper)
required.push(
GEN_SETS.upper[Math.floor(Math.random() * GEN_SETS.upper.length)],
);
if (useNumbers)
required.push(
GEN_SETS.numbers[Math.floor(Math.random() * GEN_SETS.numbers.length)],
);
if (useSymbols)
required.push(
GEN_SETS.symbols[Math.floor(Math.random() * GEN_SETS.symbols.length)],
);
const arr = new Uint32Array(length);
crypto.getRandomValues(arr);
const rest = Array.from(arr).map((n) => pool[n % pool.length]);
// Splice required chars into random positions and trim to length.
const combined = [...rest];
required.forEach((ch, i) => {
combined[i] = ch;
});
// Fisher-Yates shuffle for uniform distribution.
for (let i = combined.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[combined[i], combined[j]] = [combined[j], combined[i]];
}
return combined.slice(0, length).join("");
}
function passwordStrength(pw) {
if (!pw) return { label: "", color: "" };
let score = 0;
if (pw.length >= 8) score++;
if (pw.length >= 12) score++;
if (pw.length >= 16) score++;
if (/[a-z]/.test(pw)) score++;
if (/[A-Z]/.test(pw)) score++;
if (/[0-9]/.test(pw)) score++;
if (/[^a-zA-Z0-9]/.test(pw)) score++;
if (score <= 2) return { label: "Weak", color: "#dc2626" };
if (score <= 4) return { label: "Fair", color: "#d97706" };
if (score <= 5) return { label: "Good", color: "#2563eb" };
if (score <= 6) return { label: "Strong", color: "#16a34a" };
return { label: "Very Strong", color: "#15803d" };
}
let _genInitialised = false;
function initGenerator() {
// Only bind events once — subsequent calls just re-generate a password.
if (!_genInitialised) {
_genInitialised = true;
const slider = $("gen-length");
const numInput = $("gen-length-num");
// Keep slider and number input in sync.
slider.addEventListener("input", () => {
numInput.value = slider.value;
refreshGen();
});
numInput.addEventListener("input", () => {
const v = Math.min(64, Math.max(8, parseInt(numInput.value) || 16));
slider.value = v;
numInput.value = v;
refreshGen();
});
["gen-lower", "gen-upper", "gen-numbers", "gen-symbols"].forEach((id) => {
$(id).addEventListener("change", refreshGen);
});
$("btn-gen-refresh").addEventListener("click", refreshGen);
$("btn-gen-copy").addEventListener("click", () => {
const pw = $("gen-output").textContent;
if (!pw || pw === "Click refresh to generate") return;
navigator.clipboard.writeText(pw).then(() => {
$("btn-gen-copy").title = "Copied!";
setTimeout(() => {
$("btn-gen-copy").title = "Copy password";
}, 1500);
});
});
}
// Always generate a fresh password when the view opens.
refreshGen();
}
function refreshGen() {
const length = Math.min(
64,
Math.max(8, parseInt($("gen-length").value) || 16),
);
const useLower = $("gen-lower").checked;
const useUpper = $("gen-upper").checked;
const useNumbers = $("gen-numbers").checked;
const useSymbols = $("gen-symbols").checked;
// Require at least one charset.
if (!useLower && !useUpper && !useNumbers && !useSymbols) {
$("gen-output").textContent = "Select at least one character type";
$("gen-strength-label").textContent = "";
return;
}
const pw = generatePassword(
length,
useLower,
useUpper,
useNumbers,
useSymbols,
);
$("gen-output").textContent = pw;
const { label, color } = passwordStrength(pw);
const strengthEl = $("gen-strength-label");
strengthEl.textContent = label;
strengthEl.style.color = color;
}
// ── Init ──────────────────────────────────────────────────────────────────────
async function init() {
try {
const [tab] = await chrome.tabs.query({
active: true,
currentWindow: true,
});
_currentUrl = tab?.url || "";
} catch {}
// Set vault link
$("btn-open-vault").href = VAULT_URL;
const sessionState = await restoreSessionIfAvailable();
if (sessionState === "vault") {
showView("vault");
await checkPendingSave();
if (_items.length) {
renderList();
fetchAndDecryptVault().then(() => renderList());
} else {
await fetchAndDecryptVault();
renderList();
}
} else if (sessionState === "unlock") {
showView("unlock");
$("unlock-password").focus();
} else {
showView("login");
}
// Login
$("btn-login").addEventListener("click", handleLogin);
$("login-password").addEventListener("keydown", (e) => {
if (e.key === "Enter") handleLogin();
});
$("login-email").addEventListener("keydown", (e) => {
if (e.key === "Enter") $("login-password").focus();
});
// MFA
$("btn-mfa-back").addEventListener("click", () => {
_mfaToken = null;
showView("login");
});
$("mfa-code").addEventListener("keydown", (e) => {
if (e.key === "Enter") $("btn-mfa-verify")?.click();
});
// Unlock-only
$("btn-unlock").addEventListener("click", handleUnlockOnly);
$("unlock-password").addEventListener("keydown", (e) => {
if (e.key === "Enter") handleUnlockOnly();
});
$("btn-unlock-signout").addEventListener("click", signOut);
// Vault actions
$("btn-add-item").addEventListener("click", () => {
chrome.tabs.create({ url: VAULT_URL });
});
$("nav-account").addEventListener("click", signOut);
$("nav-vault").addEventListener("click", () => {
showView("vault");
});
$("nav-generator").addEventListener("click", () => {
showView("generator");
initGenerator();
});
$("nav-alerts").addEventListener("click", () => {
chrome.tabs.create({ url: ALERTS_URL });
});
// Keep the service worker alive so chrome.storage.session survives while the
// browser is open. We ping it every 20 s; the ping itself is a no-op but
// prevents the SW from being killed between popup openings.
const _keepalive = setInterval(() => {
chrome.runtime.sendMessage({ type: "KEEPALIVE" }).catch(() => {});
}, 20_000);
// Search
$("vault-search").addEventListener("input", () => renderList());
// Tabs
initTabs();
}
document.addEventListener("DOMContentLoaded", init);