Files
PassKeeper/extension/popup/popup.js
T

1644 lines
56 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 _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
// ── DOM helpers ───────────────────────────────────────────────────────────────
const $ = (id) => document.getElementById(id);
function showView(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));
// Sync active state on nav buttons.
["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);
});
}
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;");
}
// Auto-clear clipboard 30 s after a sensitive copy.
let _clipTimer = null;
function _copyWithAutoClear(text) {
navigator.clipboard.writeText(text).catch(() => {});
if (_clipTimer) clearTimeout(_clipTimer);
_clipTimer = setTimeout(() => {
navigator.clipboard.writeText("").catch(() => {});
_clipTimer = null;
}, 30_000);
}
/**
* Show an in-popup master-password re-prompt overlay.
* Resolves true if the entered password matches the current vault key,
* false if the user cancels or enters a wrong password.
*/
async function _repromptMasterPassword() {
return new Promise((resolve) => {
// Build the overlay element.
const overlay = document.createElement("div");
overlay.id = "pk-reprompt-overlay";
overlay.className = "reprompt-overlay";
overlay.innerHTML = `
<div class="reprompt-card">
<div class="reprompt-header">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<rect x="3" y="10" width="18" height="12" rx="2" stroke="#c0392b" stroke-width="1.8"/>
<path d="M8 10V7a4 4 0 018 0v3" stroke="#c0392b" stroke-width="1.8" stroke-linecap="round"/>
</svg>
<span>Master password required</span>
</div>
<p class="reprompt-desc">This item is protected. Enter your master password to continue.</p>
<div class="form-group">
<label for="reprompt-pw">Master Password</label>
<input type="password" id="reprompt-pw" placeholder="Master password" autocomplete="current-password"/>
</div>
<p id="reprompt-error" class="pk-error hidden">Incorrect password.</p>
<div class="reprompt-actions">
<button id="reprompt-confirm" class="btn-primary btn-sm">Confirm</button>
<button id="reprompt-cancel" class="btn-ghost btn-sm">Cancel</button>
</div>
</div>`;
document.getElementById("app").appendChild(overlay);
const input = overlay.querySelector("#reprompt-pw");
const errEl = overlay.querySelector("#reprompt-error");
input.focus();
async function attempt() {
errEl.classList.add("hidden");
const pw = input.value;
if (!pw) { errEl.textContent = "Enter your master password."; errEl.classList.remove("hidden"); return; }
try {
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) { errEl.textContent = "Session expired. Please log in again."; errEl.classList.remove("hidden"); return; }
// Derive a candidate key and compare its JWK to the stored key.
const candidate = await ExtCrypto.deriveVaultKey(pw, enc_key_salt);
const candidateJwk = await ExtCrypto.exportVaultKey(candidate);
const { vault_key_jwk } = await chrome.storage.session.get("vault_key_jwk");
if (!vault_key_jwk || JSON.stringify(candidateJwk) !== JSON.stringify(vault_key_jwk)) {
throw new Error("mismatch");
}
cleanup(true);
} catch {
errEl.textContent = "Incorrect password.";
errEl.classList.remove("hidden");
input.value = "";
input.focus();
}
}
function cleanup(result) {
overlay.remove();
resolve(result);
}
overlay.querySelector("#reprompt-confirm").addEventListener("click", attempt);
overlay.querySelector("#reprompt-cancel").addEventListener("click", () => cleanup(false));
input.addEventListener("keydown", (e) => { if (e.key === "Enter") attempt(); if (e.key === "Escape") cleanup(false); });
});
}
// ── 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;
}
function folderName(id) {
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 "";
}
}
/**
* Normalise a stored URL so it is always parseable by `new URL()`.
* Handles bare domains ("github.com"), protocol-relative, and full URLs.
*/
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;
}
function isMatch(item) {
const host = currentHostname();
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\./, "");
// Match exact domain or any subdomain relationship.
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);
// Reset MFA view to TOTP mode each time it's shown
$("mfa-totp-section").classList.remove("hidden");
$("mfa-backup-section").classList.add("hidden");
$("btn-mfa-use-backup").classList.remove("hidden");
$("btn-mfa-use-totp").classList.add("hidden");
$("mfa-hint").textContent = "Enter your 6-digit authenticator code.";
$("mfa-code").value = "";
$("mfa-backup-code").value = "";
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 () => {
hideError("mfa-error");
const usingBackup = !$("mfa-backup-section").classList.contains("hidden");
const body = { mfa_token: _mfaToken };
if (usingBackup) {
const code = $("mfa-backup-code")
.value.trim()
.toLowerCase()
.replace(/[\s-]/g, "");
if (!code) {
showError("mfa-error", "Enter your backup code.");
return;
}
body.backup_code = code;
} else {
const code = $("mfa-code").value.trim();
if (code.length !== 6) {
showError("mfa-error", "Enter the 6-digit code.");
return;
}
body.totp_code = code;
}
$("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(body),
});
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;
}
};
$("btn-mfa-use-backup").onclick = () => {
$("mfa-totp-section").classList.add("hidden");
$("mfa-backup-section").classList.remove("hidden");
$("btn-mfa-use-backup").classList.add("hidden");
$("btn-mfa-use-totp").classList.remove("hidden");
$("mfa-hint").textContent = "Enter one of your saved backup codes.";
$("mfa-backup-code").focus();
};
$("btn-mfa-use-totp").onclick = () => {
$("mfa-backup-section").classList.add("hidden");
$("mfa-totp-section").classList.remove("hidden");
$("btn-mfa-use-totp").classList.add("hidden");
$("btn-mfa-use-backup").classList.remove("hidden");
$("mfa-hint").textContent = "Enter your 6-digit authenticator code.";
$("mfa-code").focus();
};
}
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"]);
// vault_items_cs is now in session storage — cleared by the session.clear() call above.
_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, foldersRes] = await Promise.all([
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 = [];
}
_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 };
}
}),
);
// Write to session for the popup's own use (badge, rendering).
await chrome.storage.session.set({ vault_items: _items });
// 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) => ({
id: item.id,
name: item.name,
item_type: item.item_type,
plain: item.plain,
}));
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(() => {});
} catch (err) {
console.error("fetchAndDecryptVault:", err);
} finally {
$("vault-spinner").classList.add("hidden");
}
}
// ── TOTP engine (RFC 6238) — pure Web Crypto, no library ─────────────────────
function _extractTotpSecret(uri) {
if (!uri) return null;
uri = uri.trim();
if (uri.startsWith("otpauth://")) {
try {
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;
}
function _base32ToBytes(b32) {
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);
}
}
return new Uint8Array(out);
}
async function getTotpCode(totpUri) {
const secret = _extractTotpSecret(totpUri);
if (!secret) return null;
const keyBytes = _base32ToBytes(secret);
if (!keyBytes.length) return null;
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);
}
const cryptoKey = await crypto.subtle.importKey(
"raw",
keyBytes,
{ name: "HMAC", hash: "SHA-1" },
false,
["sign"],
);
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");
}
function _totpSecondsLeft() {
return 30 - (Math.floor(Date.now() / 1000) % 30);
}
// Track active TOTP ticker intervals so we can clear them on re-render.
let _totpIntervals = [];
function _clearTotpTickers() {
_totpIntervals.forEach((id) => clearInterval(id));
_totpIntervals = [];
}
// ── 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 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));
}
return items;
}
function renderList() {
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") {
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 svgUser = `<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="8" r="4" stroke="currentColor" stroke-width="1.7"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></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>`;
const svgTotp = `<svg viewBox="0 0 24 24" fill="none" width="16" height="16"><rect x="5" y="2" width="14" height="20" rx="2" stroke="currentColor" stroke-width="1.7"/><circle cx="12" cy="17" r="1" fill="currentColor"/><path d="M9 7h6M9 11h4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>`;
function buildItemHtml(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 &&
item.plain?.autofill !== false; // respect Advanced Setting
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) => `<span class="pk-tag">${escHtml(t)}</span>`)
.join("");
const isFav = (item.plain?.tags || []).includes("favorite");
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}${isFav ? '<span class="pk-fav">★</span>' : ""}</div>
<div class="item-name">${name}</div>
${tagHtml ? `<div class="pk-tag-row">${tagHtml}</div>` : ""}
${hasTotp ? `<div class="item-totp-row"><span class="totp-code-inline" id="totp-${item.id}">······</span><span class="totp-timer-inline" id="totp-t-${item.id}"></span></div>` : ""}
</div>
<div class="item-actions">
${item.plain?.username ? `<button class="btn-item-action" data-copy-user="${item.id}" title="Copy username">${svgUser}</button>` : ""}
${canCopy ? `<button class="btn-item-action" data-copy-pass="${item.id}" title="Copy password">${svgCopy}</button>` : ""}
${hasTotp ? `<button class="btn-item-action totp-btn" data-copy-totp="${item.id}" title="Copy 2FA code">${svgTotp}</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 options">${svgDots}</button>
</div>
</div>`;
}
// Build the vault list — grouped by folder for 'all' tab, flat otherwise.
if (_activeTab === "all" && _folders.length > 0) {
const groups = {};
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);
});
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 `<div class="pk-group" data-group="${escHtml(groupName)}">
<div class="pk-group-header">
<span class="pk-group-name">${escHtml(groupName)}</span>
<span class="pk-group-meta">
<span class="pk-group-count">${count}</span>
<span class="pk-group-chevron">${chevron}</span>
</span>
</div>
<div class="pk-group-items">${itemsHtml}</div>
</div>`;
})
.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);
else _collapsedFolders.add(groupName);
renderList();
});
});
} else {
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;
const codeEl = $(`totp-${item.id}`);
const timerEl = $(`totp-t-${item.id}`);
if (!codeEl) return;
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);
const secs = _totpSecondsLeft();
timerEl.textContent = " " + secs + "s";
timerEl.style.color = secs <= 5 ? "#dc2626" : "#9ca3af";
}
tick();
const id = setInterval(tick, 1000);
_totpIntervals.push(id);
});
// Copy password
listEl.querySelectorAll("[data-copy-pass]").forEach((btn) =>
btn.addEventListener("click", async (e) => {
e.stopPropagation();
const item = _items.find((i) => i.id === parseInt(btn.dataset.copyPass));
if (!item?.plain?.password) return;
if (item.plain?.reprompt) {
const ok = await _repromptMasterPassword();
if (!ok) return;
}
_copyWithAutoClear(item.plain.password);
btn.title = "Copied!";
setTimeout(() => {
btn.title = "Copy password";
}, 1500);
}),
);
// Copy TOTP code
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));
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);
}
}),
);
// 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;
if (item.plain?.reprompt) {
const ok = await _repromptMasterPassword();
if (!ok) 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 || "",
autologin: !!item.plain.autologin,
})
.catch(() => {});
}
window.close();
}),
);
// Copy username (dedicated button)
listEl.querySelectorAll("[data-copy-user]").forEach((btn) =>
btn.addEventListener("click", async (e) => {
e.stopPropagation();
const item = _items.find((i) => i.id === parseInt(btn.dataset.copyUser));
if (!item?.plain?.username) return;
if (item.plain?.reprompt) {
const ok = await _repromptMasterPassword();
if (!ok) return;
}
_copyWithAutoClear(item.plain.username);
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) => {
e.stopPropagation();
// Close any already-open flyout first.
document.querySelectorAll(".pk-flyout").forEach((el) => el.remove());
const item = _items.find((i) => i.id === parseInt(btn.dataset.menu));
if (!item) return;
const flyout = document.createElement("div");
flyout.className = "pk-flyout";
const menuItems = [
item.plain?.url
? {
label: "Open URL",
icon: '<svg viewBox="0 0 24 24" fill="none" width="14" height="14"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/><path d="M15 3h6v6M10 14L21 3" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>',
action: async () => {
chrome.tabs.create({ url: item.plain.url });
flyout.remove();
},
}
: null,
item.plain?.username
? {
label: "Copy username",
icon: '<svg viewBox="0 0 24 24" fill="none" width="14" height="14"><circle cx="12" cy="8" r="4" stroke="currentColor" stroke-width="1.7"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>',
action: async () => {
if (item.plain?.reprompt) {
const ok = await _repromptMasterPassword();
if (!ok) return;
}
_copyWithAutoClear(item.plain.username);
flyout.remove();
},
}
: null,
item.plain?.password
? {
label: "Copy password",
icon: '<svg viewBox="0 0 24 24" fill="none" width="14" height="14"><rect x="3" y="10" width="18" height="11" rx="2" stroke="currentColor" stroke-width="1.7"/><path d="M8 10V7a4 4 0 018 0v3" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>',
action: async () => {
if (item.plain?.reprompt) {
const ok = await _repromptMasterPassword();
if (!ok) return;
}
_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 + "<span>" + escHtml(mi.label) + "</span>";
row.addEventListener("click", async (e) => {
e.stopPropagation();
await mi.action();
});
flyout.appendChild(row);
});
// Position flyout using fixed coords so it escapes vault-list overflow clipping.
const appEl = document.getElementById("app");
appEl.appendChild(flyout);
const btnRect = btn.getBoundingClientRect();
const flyoutH = flyout.offsetHeight || 120; // estimate if not yet painted
const spaceBelow = window.innerHeight - btnRect.bottom;
const flyoutW = flyout.offsetWidth || 160;
// Flip upward if not enough room below.
if (spaceBelow < flyoutH + 8) {
flyout.style.top = (btnRect.top - flyoutH - 4) + "px";
} else {
flyout.style.top = (btnRect.bottom + 4) + "px";
}
// Right-align with the button, but keep inside the popup.
const rightEdge = window.innerWidth - btnRect.right;
flyout.style.right = rightEdge + "px";
// Close on any outside click.
setTimeout(() => {
document.addEventListener("click", function handler() {
flyout.remove();
document.removeEventListener("click", handler);
});
}, 0);
}),
);
}
// ── 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 ─────────────────────────────────────────────────────────
/**
* Populate the folder <select> inside the save modal.
* Fetches /api/folders and rebuilds the option list.
* The first option ("— No folder —") with value "" is always kept.
*/
async function loadFoldersIntoSaveModal() {
const select = $("save-folder");
// Reset to just the placeholder option.
select.innerHTML = '<option value="">— No folder —</option>';
try {
const res = await apiFetch("/api/folders");
if (!res?.ok) return;
const folders = await res.json();
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);
}
}
/**
* 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?";
// Load folders (non-blocking — modal shows immediately, folders populate async).
$("save-folder").value = "";
loadFoldersIntoSaveModal();
// Show the overlay.
$("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(() => {});
}
$("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,
);
_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";
// Read selected folder — empty string means no folder (null).
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,
}),
});
if (res?.ok) {
console.log(
"[PassKeeper] Credential saved to vault:",
name,
"(name encrypted), folder_id:",
folder_id,
);
await fetchAndDecryptVault();
renderList();
}
} catch (err) {
console.error("saveCredential failed:", err);
}
}
// ── Password Generator ────────────────────────────────────────────────────────
const GEN_SETS = {
lower: "abcdefghijklmnopqrstuvwxyz",
upper: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
numbers: "0123456789",
symbols: "!@#$%^&*()-_=+[]{}|;:,.<>?",
};
/**
* Cryptographically secure random integer in [0, max).
* Uses crypto.getRandomValues exclusively — Math.random() is never called.
*/
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);
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 "";
// 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)]);
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 — 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("");
}
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;
}
// ── Add Item view ─────────────────────────────────────────────────────────────
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;
// 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\./, "");
}
}
} catch (e) {}
// Load folders into the add-folder select.
const addFolderSel = $("add-folder");
addFolderSel.innerHTML = '<option value="">— No folder —</option>';
try {
const res = await apiFetch("/api/folders");
if (res?.ok) {
const folders = await res.json();
folders.forEach((f) => {
const opt = document.createElement("option");
opt.value = f.id;
opt.textContent = f.name;
addFolderSel.appendChild(opt);
});
}
} catch (e) {}
// Wire one-time event listeners.
if (!_addViewInitialised) {
_addViewInitialised = true;
// Back button.
$("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";
});
// Generate password — generates via the generator logic and pastes into field.
$("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
});
// Save button.
$("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();
});
});
}
// 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();
}
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 folder_id = folderVal ? parseInt(folderVal, 10) : null;
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;
}
const btn = $("btn-add-save");
btn.disabled = true;
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,
}),
});
if (!res?.ok) {
const data = await res.json().catch(() => ({}));
showError("add-error", data.error || "Failed to save. Please try again.");
return;
}
console.log("[PassKeeper] Item added to vault:", name, "(name encrypted)");
// Refresh vault and go back.
await fetchAndDecryptVault();
renderList();
showView("vault");
} catch (err) {
showError("add-error", "Network error: " + err.message);
} finally {
btn.disabled = false;
btn.textContent = "Save to Vault";
}
}
// ── Account view ──────────────────────────────────────────────────────────────
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");
// Find the matching option; fall back to 600 if not found.
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);
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(() => {});
// 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");
});
$("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 {}
// Set vault link
$("btn-open-vault").href = VAULT_URL;
const sessionState = await restoreSessionIfAvailable();
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");
if (popup_nav) {
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(() => {});
// Wire event listeners below, then return early from vault-specific setup.
} else {
showView("vault");
await checkPendingSave();
if (_items.length) {
renderList();
fetchAndDecryptVault().then(() => renderList());
} else {
await fetchAndDecryptVault();
renderList();
}
}
} else {
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", () => {
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(() => {});
}, 20_000);
// Search
$("vault-search").addEventListener("input", () => renderList());
// Tabs
initTabs();
}
document.addEventListener("DOMContentLoaded", init);