/** * vault.js — Vault UI: all views (vault, security, sharing, emergency, settings) * * All sensitive data is decrypted client-side. The server only ever stores * encrypted blobs (zero-knowledge architecture). */ const Vault = (() => { let _items = []; let _folders = []; let _currentView = "vault"; // ── Web-app inactivity / session timeout ────────────────────────────────── // Mirrors the extension idle lock. Default 15 min; user can change in // Account Settings. Zero means never. Stored in localStorage so it persists // across page reloads without a server round-trip. const WEB_IDLE_KEY = "web_idle_minutes"; const WEB_IDLE_DEFAULT = 15; // minutes; 0 = never let _webIdleTimer = null; function _getWebIdleMinutes() { const v = parseInt(localStorage.getItem(WEB_IDLE_KEY), 10); return isNaN(v) ? WEB_IDLE_DEFAULT : v; } function _resetWebIdleTimer() { if (_webIdleTimer) clearTimeout(_webIdleTimer); const mins = _getWebIdleMinutes(); if (mins === 0) return; _webIdleTimer = setTimeout(() => { // Only lock if the vault is currently unlocked. if (!VaultSession.getKey()) return; console.log("[PassKeeper] Web inactivity lock after", mins, "min"); VaultSession.clear(); showUnlockOverlay(); showToast("Vault locked due to inactivity.", "info"); }, mins * 60_000); } function _startWebIdleTracking() { const events = [ "mousemove", "mousedown", "keydown", "touchstart", "scroll", ]; const handler = () => _resetWebIdleTimer(); events.forEach((e) => document.addEventListener(e, handler, { passive: true }), ); _resetWebIdleTimer(); // Lock when the tab is hidden for longer than the idle timeout. // This catches screen-lock, minimize, and long tab switches without // requiring the user to wait for the inactivity timer to fire after // returning to the tab. let _hiddenAt = null; document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") { _hiddenAt = Date.now(); // Pause the inactivity timer while hidden — user activity is impossible. if (_webIdleTimer) { clearTimeout(_webIdleTimer); _webIdleTimer = null; } } else { // Tab became visible again. const mins = _getWebIdleMinutes(); if (mins > 0 && _hiddenAt !== null) { const hiddenMs = Date.now() - _hiddenAt; if (hiddenMs >= mins * 60_000 && VaultSession.getKey()) { console.log( "[PassKeeper] Locking — tab was hidden for", Math.round(hiddenMs / 1000), "s (idle timeout:", mins, "min)", ); VaultSession.clear(); showUnlockOverlay(); showToast("Vault locked due to inactivity.", "info"); _hiddenAt = null; return; } } _hiddenAt = null; // Resume the inactivity timer now that the user is back. _resetWebIdleTimer(); } }); } let _activeFilter = null; let _sortOrder = "name-asc"; // ── Bulk selection state ────────────────────────────────────────────────── let _selectMode = false; let _selectedIds = new Set(); const FIELD_LABELS = { url: "Website URL", username: "Username", password: "Password", notes: "Notes", note_body: "Note", cardholder_name: "Cardholder Name", card_number: "Card Number", expiry_month: "Expiry Month", expiry_year: "Expiry Year", cvv: "CVV", bank_name: "Bank Name", account_type: "Account Type", routing_number: "Routing Number", account_number: "Account Number", first_name: "First Name", last_name: "Last Name", company: "Company", address_line: "Street Address", city: "City", state: "State", zip: "ZIP", country: "Country", phone: "Phone", email: "Email", ssn_number: "Social Security Number", // passkey rp_id: "RP ID (Domain)", credential_id: "Credential ID", user_handle: "User Handle", device_hint: "Device / Authenticator", }; const SENSITIVE_FIELDS = new Set([ "password", "cvv", "card_number", "account_number", "routing_number", "ssn_number", "credential_id", "user_handle", ]); // ── API helpers ─────────────────────────────────────────────────────────── async function apiFetch(path, options = {}) { let token = sessionStorage.getItem("access_token"); if (!token) { redirectToLogin(); return null; } const defaults = { headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, "X-CSRFToken": csrfToken(), }, }; const merged = { ...defaults, ...options }; merged.headers = { ...defaults.headers, ...(options.headers || {}) }; let res = await fetch(path, merged); if (res.status === 401) { const refreshed = await tryRefreshToken(); if (!refreshed) { redirectToLogin(); return null; } merged.headers["Authorization"] = `Bearer ${sessionStorage.getItem("access_token")}`; res = await fetch(path, merged); } if (!res.ok) { const err = await res.json().catch(() => ({})); const e = new Error(err.error || `HTTP ${res.status}`); e.status = res.status; throw e; } return res; } let _refreshPromise = null; async function tryRefreshToken() { // Singleton: if a refresh is already in flight, reuse it so concurrent 401s // don't each fire a separate rotation (which would blacklist each other's tokens). if (_refreshPromise) return _refreshPromise; _refreshPromise = (async () => { const refreshToken = localStorage.getItem("refresh_token"); if (!refreshToken) return false; try { const res = await fetch("/api/auth/refresh", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRFToken": csrfToken(), }, body: JSON.stringify({ refresh_token: refreshToken }), }); if (!res.ok) return false; const data = await res.json(); sessionStorage.setItem("access_token", data.access_token); if (data.refresh_token) localStorage.setItem("refresh_token", data.refresh_token); return true; } catch { return false; } })().finally(() => { _refreshPromise = null; }); return _refreshPromise; } function csrfToken() { return document.querySelector('meta[name="csrf-token"]')?.content || ""; } function redirectToLogin() { VaultSession.clear(); SharingSession.clear(); sessionStorage.removeItem("access_token"); window.dispatchEvent(new CustomEvent("passkeeper:logout")); window.location.href = "/login"; } // ── Load ────────────────────────────────────────────────────────────────── async function loadVault() { const vaultKey = VaultSession.getKey(); if (!vaultKey) { showUnlockOverlay(); return; } showLoadingState(true); try { const [itemsRes, foldersRes] = await Promise.all([ apiFetch("/api/vault"), apiFetch("/api/folders"), ]); if (!itemsRes || !foldersRes) { showUnlockOverlay(); return; } const rawItems = await itemsRes.json(); _folders = await foldersRes.json(); _items = await Promise.all( rawItems.map(async (item) => { try { const plain = await Crypto.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 Crypto.decryptName( vaultKey, item.enc_name, item.iv_name, ); if (decrypted) displayName = decrypted; } return { ...item, name: displayName, plain }; } catch { return { ...item, plain: null, decryptError: true }; } }), ); renderFolderList(); renderTagList(); applyCurrentFilter(); // Reset banner dismissed flag so fresh health results are visible. const banner = document.getElementById("health-banner"); if (banner) banner.dataset.dismissed = "0"; // Run health checks in the background — updates badge + banner without // blocking the vault render. Results are cached so opening the Security // tab doesn't re-run HIBP checks. runBackgroundHealthCheck(); } catch (err) { showToast("Failed to load vault: " + err.message, "error"); } finally { showLoadingState(false); } } async function loadFolders() { try { const res = await apiFetch("/api/folders"); if (!res) return; _folders = await res.json(); renderFolderList(); } catch (err) { showToast("Could not refresh folders: " + err.message, "error"); } } // ── View switching ──────────────────────────────────────────────────────── function switchView(view, { pushState = true } = {}) { // Exit bulk-select mode when navigating away from the vault view. if (view !== "vault" && _selectMode) _exitSelectMode(); _currentView = view; ["vault", "security", "sharing", "emergency", "import-export"].forEach( (v) => { document .getElementById(`view-${v}`) ?.classList.toggle("hidden", v !== view); }, ); document.querySelectorAll(".sidebar-item[data-view]").forEach((el) => { el.classList.toggle("active", el.dataset.view === view); }); if (view !== "vault") { document .querySelectorAll( ".sidebar-item[data-type-filter], .sidebar-item[data-folder-id]", ) .forEach((el) => el.classList.remove("active")); } // Push to browser history so the back button navigates between views. if (pushState) { const hash = view === "vault" ? "" : `#${view}`; history.pushState({ view }, "", hash || window.location.pathname); } if (view === "security") renderSecurityDashboard(); if (view === "sharing") loadSharingView(); if (view === "emergency") loadEmergencyView(); if (view === "import-export") loadImportExportView(); } // ── Vault render ────────────────────────────────────────────────────────── function renderFolderList() { const ul = document.getElementById("sidebar-folders"); if (!ul) return; ul.innerHTML = ""; _folders.forEach((folder) => { const li = document.createElement("li"); li.className = "sidebar-item folder-item"; li.dataset.folderId = folder.id; const label = document.createElement("span"); label.className = "folder-label"; label.innerHTML = `📁 ${escHtml(folder.name)}`; label.addEventListener("click", () => filterByFolder(folder.id, folder.name), ); const delBtn = document.createElement("button"); delBtn.className = "btn-delete-folder"; delBtn.title = "Delete folder"; delBtn.textContent = "🗑"; delBtn.addEventListener("click", (e) => { e.stopPropagation(); confirmDeleteFolder(folder); }); li.appendChild(label); li.appendChild(delBtn); ul.appendChild(li); }); } function getSortedItems(items) { const sorted = [...items]; switch (_sortOrder) { case "name-asc": sorted.sort((a, b) => a.name.localeCompare(b.name)); break; case "name-desc": sorted.sort((a, b) => b.name.localeCompare(a.name)); break; case "date-desc": sorted.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); break; case "date-asc": sorted.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); break; case "folder": sorted.sort( (a, b) => folderName(a.folder_id).localeCompare(folderName(b.folder_id)) || a.name.localeCompare(b.name), ); break; } return sorted; } function folderName(id) { if (!id) return "(No folder)"; return _folders.find((f) => f.id === id)?.name || "Unknown"; } /** Parse a comma-separated tag string into a sorted, deduped lowercase array. */ function _parseTags(str) { return [ ...new Set( (str || "") .split(",") .map((t) => t.trim().toLowerCase()) .filter(Boolean), ), ].sort(); } /** Collect all unique tags across all loaded items. */ function _allTags() { const set = new Set(); _items.forEach((i) => (i.plain?.tags || []).forEach((t) => set.add(t))); return [...set].sort(); } /** Render the tags sidebar list. */ function renderTagList() { const ul = document.getElementById("sidebar-tags"); if (!ul) return; ul.innerHTML = ""; const tags = _allTags(); if (!tags.length) { ul.innerHTML = ''; return; } tags.forEach((tag) => { const li = document.createElement("li"); li.className = "sidebar-item sidebar-tag-item"; li.dataset.tag = tag; li.innerHTML = `🏷️${escHtml(tag)}`; li.addEventListener("click", () => { document .querySelectorAll(".sidebar-item") .forEach((el) => el.classList.remove("active")); li.classList.add("active"); _activeFilter = { type: "tag", value: tag }; switchView("vault"); updateVaultTitle("#" + tag); renderItemList( _items.filter((i) => (i.plain?.tags || []).includes(tag)), ); }); ul.appendChild(li); }); } // Persists which folder groups are collapsed across re-renders. const _collapsedGroups = new Set(); function renderItemList(items) { const list = document.getElementById("vault-list"); if (!list) return; list.innerHTML = ""; // Apply or remove select-mode CSS class on the parent wrapper. const wrapper = list.closest(".vault-list"); if (wrapper) wrapper.classList.toggle("select-mode", _selectMode); if (items.length === 0) { list.innerHTML = '
  • No items found. Click + to add one.
  • '; return; } const sorted = getSortedItems(items); const groups = {}; sorted.forEach((item) => { const key = folderName(item.folder_id); if (!groups[key]) groups[key] = []; groups[key].push(item); }); const keys = _sortOrder === "folder" ? Object.keys(groups).sort() : ["(No folder)", ..._folders.map((f) => f.name)].filter( (k) => groups[k], ); Object.keys(groups).forEach((k) => { if (!keys.includes(k)) keys.push(k); }); keys.forEach((groupName) => { if (!groups[groupName]) return; const isCollapsed = _collapsedGroups.has(groupName); const count = groups[groupName].length; // ── Collapsible group header ──────────────────────────────────────────── const header = document.createElement("li"); header.className = "vault-group-header" + (isCollapsed ? " collapsed" : ""); header.innerHTML = `${escHtml(groupName)}` + `` + `${count}` + `${isCollapsed ? "▶" : "▼"}` + ``; header.addEventListener("click", () => { if (_collapsedGroups.has(groupName)) { _collapsedGroups.delete(groupName); } else { _collapsedGroups.add(groupName); } renderItemList(items); // re-render preserving collapse state }); list.appendChild(header); // ── Items (hidden when collapsed) ─────────────────────────────────────── if (!isCollapsed) { groups[groupName].forEach((item) => list.appendChild(createItemElement(item)), ); } }); } function createItemElement(item) { const li = document.createElement("li"); li.className = "vault-item"; li.dataset.id = item.id; const iconMap = { password: "🔑", note: "📝", card: "💳", bank: "🏦", address: "🏠", ssn: "🪪", passkey: "🔐", }; const icon = iconMap[item.item_type] || "🔑"; const itemTags = item.plain?.tags || []; let subText = ""; if (item.plain) { switch (item.item_type) { case "password": subText = item.plain.username || item.plain.url || ""; break; case "note": subText = (item.plain.note_body || "").slice(0, 60); break; case "card": subText = item.plain.card_number ? "•••• " + String(item.plain.card_number).replace(/\s/g, "").slice(-4) : ""; break; case "bank": subText = item.plain.bank_name || ""; break; case "address": subText = [item.plain.first_name, item.plain.last_name] .filter(Boolean) .join(" "); break; case "ssn": subText = "•••-••-••••"; break; case "passkey": subText = [ item.plain.username, item.plain.rp_id ? `@ ${item.plain.rp_id}` : "", ] .filter(Boolean) .join(" "); break; } } const showCopyUser = item.item_type === "password"; const showCopyPass = ["password", "card", "bank", "ssn"].includes( item.item_type, ); const showLaunch = item.item_type === "password" && item.plain?.url; const showTotp = item.item_type === "password" && !!extractTotpSecret(item.plain?.totp_uri); const isFavorite = itemTags.includes("favorite"); const visibleTags = itemTags.filter((t) => t !== "favorite"); const tagBadges = visibleTags .map((t) => `${escHtml(t)}`) .join(""); li.innerHTML = `
    ${icon}
    ${escHtml(item.name)} ${escHtml(subText)} ${tagBadges ? `
    ${tagBadges}
    ` : ""} ${showTotp ? `······` : ""}
    ${showLaunch ? `` : ""} ${showCopyUser ? `` : ""} ${showCopyPass ? `` : ""} ${showTotp ? `` : ""}
    `; if (showTotp) { // Start a live TOTP ticker for this item. const secret = extractTotpSecret(item.plain.totp_uri); const displayEl = li.querySelector(`#totp-display-${item.id}`); const codeEl = displayEl.querySelector(".totp-code"); const timerEl = displayEl.querySelector(".totp-timer"); async function refreshTotp() { const code = await getTotpCode(secret).catch(() => null); if (!code) return; // Insert a space in the middle for readability: 123 456 codeEl.textContent = code.slice(0, 3) + " " + code.slice(3); const secs = totpSecondsLeft(); timerEl.textContent = " (" + secs + "s)"; // Colour the timer red when it's about to expire. timerEl.classList.toggle("totp-timer--expiring", secs <= 5); } refreshTotp(); // Refresh every second. Store interval id on the element for cleanup. const intervalId = setInterval(refreshTotp, 1000); li.dataset.totpInterval = intervalId; } // Wire the bulk-select checkbox. const checkbox = li.querySelector(".item-checkbox"); if (checkbox) { checkbox.addEventListener("change", () => { if (checkbox.checked) { _selectedIds.add(item.id); li.classList.add("item-selected"); } else { _selectedIds.delete(item.id); li.classList.remove("item-selected"); } _updateBulkToolbar(); }); // Apply selected state from module state (survives re-renders). if (_selectedIds.has(item.id)) li.classList.add("item-selected"); } if (showLaunch) { li.querySelector('[data-action="launch"]').addEventListener( "click", (e) => { e.stopPropagation(); let url = item.plain?.url || ""; if (url && !/^https?:\/\//i.test(url)) url = "https://" + url; window.open(url, "_blank", "noopener,noreferrer"); }, ); } if (showCopyUser) { li.querySelector('[data-action="copy-user"]').addEventListener( "click", (e) => { e.stopPropagation(); copyToClipboard(item.plain?.username || "", "Username copied"); }, ); } if (showCopyPass) { li.querySelector('[data-action="copy-pass"]').addEventListener( "click", (e) => { e.stopPropagation(); const s = item.item_type === "password" ? item.plain?.password : item.item_type === "card" ? item.plain?.cvv : item.item_type === "bank" ? item.plain?.account_number : item.plain?.ssn_number; copyToClipboard(s || "", "Copied to clipboard"); }, ); } if (showTotp) { li.querySelector('[data-action="copy-totp"]').addEventListener( "click", async (e) => { e.stopPropagation(); const secret = extractTotpSecret(item.plain?.totp_uri); const code = await getTotpCode(secret).catch(() => null); if (code) copyToClipboard(code, "2FA code copied"); }, ); } li.querySelector('[data-action="favorite"]').addEventListener( "click", (e) => { e.stopPropagation(); toggleFavorite(item); }, ); li.querySelector('[data-action="edit"]').addEventListener("click", (e) => { e.stopPropagation(); openModal("edit", item); }); li.querySelector('[data-action="delete"]').addEventListener( "click", (e) => { e.stopPropagation(); confirmDeleteItem(item); }, ); li.addEventListener("click", () => openModal("edit", item)); return li; } // ── Security Dashboard ──────────────────────────────────────────────────── /** * Check a password against the HaveIBeenPwned Pwned Passwords API * using k-anonymity (only the first 5 hex chars of SHA-1 are sent). * Returns the breach count (0 = not found in any known breach). */ async function checkHibp(password) { try { const msgBuffer = new TextEncoder().encode(password); const hashBuffer = await crypto.subtle.digest("SHA-1", msgBuffer); const hashHex = Array.from(new Uint8Array(hashBuffer)) .map((b) => b.toString(16).padStart(2, "0")) .join("") .toUpperCase(); const prefix = hashHex.slice(0, 5); const suffix = hashHex.slice(5); const res = await fetch( `https://api.pwnedpasswords.com/range/${prefix}`, { headers: { "Add-Padding": "true" }, }, ); if (!res.ok) return 0; const text = await res.text(); const line = text.split("\n").find((l) => l.startsWith(suffix)); if (!line) return 0; return parseInt(line.split(":")[1], 10) || 0; } catch { return 0; // network error or API unavailable — fail safe (do not block UI) } } // ── Bulk operations ─────────────────────────────────────────────────────── function _enterSelectMode() { _selectMode = true; _selectedIds.clear(); document.getElementById("btn-select-mode")?.classList.add("active"); document.getElementById("bulk-toolbar")?.classList.remove("hidden"); _updateBulkToolbar(); applyCurrentFilter(); // re-render to show checkboxes } function _exitSelectMode() { _selectMode = false; _selectedIds.clear(); document.getElementById("btn-select-mode")?.classList.remove("active"); document.getElementById("bulk-toolbar")?.classList.add("hidden"); applyCurrentFilter(); // re-render to hide checkboxes } function _updateBulkToolbar() { const countEl = document.getElementById("bulk-count"); const n = _selectedIds.size; if (countEl) { countEl.textContent = n === 0 ? "0 selected" : `${n} item${n !== 1 ? "s" : ""} selected`; } // Disable action buttons when nothing is selected. ["btn-bulk-move", "btn-bulk-delete", "btn-bulk-export"].forEach((id) => { const btn = document.getElementById(id); if (btn) btn.disabled = n === 0; }); } async function _handleBulkDelete() { const ids = [..._selectedIds]; if (!ids.length) return; if (!confirm(`Delete ${ids.length} item${ids.length !== 1 ? "s" : ""}? This cannot be undone.`)) return; try { await Promise.all( ids.map((id) => apiFetch(`/api/vault/${id}`, { method: "DELETE" })), ); showToast(`${ids.length} item${ids.length !== 1 ? "s" : ""} deleted`); _exitSelectMode(); await loadVault(); } catch (err) { showToast("Delete failed: " + err.message, "error"); } } async function _handleBulkMove() { if (!_selectedIds.size) return; // Build a folder picker inline in the bulk toolbar. const toolbar = document.getElementById("bulk-toolbar"); if (!toolbar) return; // Remove any existing picker first. toolbar.querySelector(".bulk-move-select")?.remove(); const sel = document.createElement("select"); sel.className = "bulk-move-select"; sel.innerHTML = '' + _folders.map((f) => ``).join(""); sel.addEventListener("change", async () => { const folderId = sel.value ? parseInt(sel.value) : null; sel.remove(); try { await Promise.all( [..._selectedIds].map((id) => apiFetch(`/api/vault/${id}`, { method: "PUT", body: JSON.stringify({ folder_id: folderId }), }), ), ); const dest = folderId ? (_folders.find((f) => f.id === folderId)?.name || "folder") : "root"; showToast(`${_selectedIds.size} item${_selectedIds.size !== 1 ? "s" : ""} moved to ${dest}`); _exitSelectMode(); await loadVault(); } catch (err) { showToast("Move failed: " + err.message, "error"); } }); // Insert after the Move button. const moveBtn = document.getElementById("btn-bulk-move"); moveBtn?.after(sel); sel.focus(); } async function _handleBulkExport() { const ids = new Set(_selectedIds); if (!ids.size) return; const vaultKey = VaultSession.getKey(); if (!vaultKey) { showUnlockOverlay(); return; } const selected = _items.filter((i) => ids.has(i.id)); const envelope = { version: 1, exported_at: new Date().toISOString(), items: selected.map((i) => ({ id: i.id, name: i.name, item_type: i.item_type, folder_id: i.folder_id, enc_data: i.enc_data, iv: i.iv, enc_name: i.enc_name || null, iv_name: i.iv_name || null, })), }; const blob = new Blob([JSON.stringify(envelope, null, 2)], { type: "application/json", }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `passkeeper-selection-${new Date().toISOString().slice(0, 10)}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); showToast(`${selected.length} item${selected.length !== 1 ? "s" : ""} exported`); _exitSelectMode(); } function _initBulkToolbar() { document.getElementById("btn-select-mode")?.addEventListener("click", () => { if (_selectMode) _exitSelectMode(); else _enterSelectMode(); }); document.getElementById("btn-bulk-cancel")?.addEventListener("click", _exitSelectMode); document.getElementById("btn-bulk-delete")?.addEventListener("click", _handleBulkDelete); document.getElementById("btn-bulk-move")?.addEventListener("click", _handleBulkMove); document.getElementById("btn-bulk-export")?.addEventListener("click", _handleBulkExport); document.getElementById("btn-bulk-select-all")?.addEventListener("click", () => { // Select all currently visible items (from the rendered list). const list = document.getElementById("vault-list"); if (!list) return; const checkboxes = list.querySelectorAll(".item-checkbox"); const allChecked = [...checkboxes].every((cb) => cb.checked); checkboxes.forEach((cb) => { cb.checked = !allChecked; const id = parseInt(cb.dataset.id); if (!allChecked) { _selectedIds.add(id); cb.closest(".vault-item")?.classList.add("item-selected"); } else { _selectedIds.delete(id); cb.closest(".vault-item")?.classList.remove("item-selected"); } }); _updateBulkToolbar(); }); } // ── Background vault health checks ────────────────────────────────────────── // // Runs after every vault load. Computes weak/reused counts synchronously, // then fires HIBP checks in parallel. Updates the Security sidebar badge // and a dismissible top banner without requiring the user to open the // Security tab. Results are cached so re-opening the tab skips re-checking. let _healthCache = null; // { weak, reused, breached } — populated after first run let _hibpRunning = false; // prevents concurrent HIBP runs async function runBackgroundHealthCheck() { if (_hibpRunning) return; _hibpRunning = true; _healthCache = null; try { const pwItems = _items.filter( (i) => i.item_type === "password" && i.plain?.password, ); if (!pwItems.length) { _updateHealthUI({ weak: 0, reused: 0, breached: 0 }); return; } // Synchronous metrics — instant. const weak = pwItems.filter((i) => { const p = i.plain.password; if (p.length < 10) return true; return ( [/[A-Z]/, /[a-z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((r) => r.test(p)) .length < 2 ); }); const passCounts = {}; pwItems.forEach((i) => { const p = i.plain.password; (passCounts[p] = passCounts[p] || []).push(i); }); const reused = Object.values(passCounts) .filter((a) => a.length > 1) .flat(); // Update badge immediately with sync results — HIBP will update again. _updateHealthUI({ weak: weak.length, reused: reused.length, breached: null }); // HIBP — k-anonymity, runs in parallel. const hibpResults = await Promise.all( pwItems.map(async (item) => ({ item, count: await checkHibp(item.plain.password), })), ); const breachedItems = hibpResults.filter((r) => r.count > 0).map((r) => r.item); _healthCache = { weak: weak.length, reused: reused.length, breached: breachedItems.length, breachedItems, hibpResults, }; _updateHealthUI({ weak: weak.length, reused: reused.length, breached: breachedItems.length, }); } catch (err) { console.error("[PassKeeper] Background health check failed:", err); } finally { _hibpRunning = false; } } function _updateHealthUI({ weak, reused, breached }) { const badge = document.getElementById("security-badge"); const banner = document.getElementById("health-banner"); if (!badge || !banner) return; const knownBreached = breached !== null; const issueCount = (weak || 0) + (reused || 0) + (knownBreached ? (breached || 0) : 0); const hasBreaches = knownBreached && breached > 0; const hasSyncIssues = (weak || 0) + (reused || 0) > 0; // ── Sidebar badge ────────────────────────────────────────────────────── if (issueCount > 0) { badge.textContent = issueCount > 99 ? "99+" : String(issueCount); badge.classList.remove("hidden", "badge-warn"); if (hasBreaches) { badge.classList.remove("badge-warn"); // red (default) } else { badge.classList.add("badge-warn"); // amber } } else { badge.classList.add("hidden"); } // ── Banner ───────────────────────────────────────────────────────────── // Don't re-render if user already dismissed it this session. if (banner.dataset.dismissed === "1") return; if (issueCount === 0) { banner.classList.add("hidden"); return; } const parts = []; if (hasBreaches) parts.push(`${breached} breached password${breached !== 1 ? "s" : ""}`); if ((weak || 0) > 0) parts.push(`${weak} weak password${weak !== 1 ? "s" : ""}`); if ((reused || 0) > 0) parts.push(`${reused} reused password${reused !== 1 ? "s" : ""}`); const severity = hasBreaches ? "danger" : "warn"; const icon = hasBreaches ? "🚨" : "⚠️"; banner.className = `health-banner banner-${severity}`; banner.innerHTML = ` ${icon} ${parts.join(" · ")} `; banner.classList.remove("hidden"); document.getElementById("btn-health-banner-view")?.addEventListener("click", () => { switchView("security"); }); document.getElementById("btn-health-banner-dismiss")?.addEventListener("click", () => { banner.classList.add("hidden"); banner.dataset.dismissed = "1"; }); } async function renderSecurityDashboard() { const summaryEl = document.getElementById("security-summary"); const sectionsEl = document.getElementById("security-sections"); if (!summaryEl || !sectionsEl) return; // If vault hasn't loaded yet, wait for it then re-render if (!_items.length && VaultSession.getKey()) { summaryEl.innerHTML = '
    Loading…
    '; sectionsEl.innerHTML = ""; loadVault().then(() => renderSecurityDashboard()); return; } const pwItems = _items.filter( (i) => i.item_type === "password" && i.plain?.password, ); if (!pwItems.length) { summaryEl.innerHTML = '
    No passwords to analyze.
    '; sectionsEl.innerHTML = ""; return; } const weak = pwItems.filter((i) => { const p = i.plain.password; if (p.length < 10) return true; return ( [/[A-Z]/, /[a-z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((r) => r.test(p)) .length < 2 ); }); const passCounts = {}; pwItems.forEach((i) => { const p = i.plain.password; (passCounts[p] = passCounts[p] || []).push(i); }); const reused = Object.values(passCounts) .filter((a) => a.length > 1) .flat(); const cutoff = Date.now() - 180 * 86400000; // "Old" only penalises passwords that are ALSO weak or reused. // Uses plain.password_changed_at when available (set on create/edit), // falling back to created_at for items saved before this feature. const weakOrReusedIds = new Set([ ...weak.map((i) => i.id), ...reused.map((i) => i.id), ]); const old = pwItems.filter((i) => { const ageRef = i.plain?.password_changed_at ? new Date(i.plain.password_changed_at).getTime() : new Date(i.created_at).getTime(); return ageRef < cutoff && weakOrReusedIds.has(i.id); }); const total = pwItems.length; const score = Math.max( 0, Math.round( 100 - (weak.length / total) * 40 - (reused.length / total) * 30 - (old.length / total) * 15, ), ); const cls = score >= 80 ? "score-good" : score >= 50 ? "score-fair" : "score-poor"; const label = score >= 80 ? "Good" : score >= 50 ? "Fair" : "Needs attention"; summaryEl.innerHTML = `
    ${score}
    ${label}
    Based on ${total} password${total !== 1 ? "s" : ""}
    ${weak.length}Weak
    ${reused.length}Reused
    ${old.length}Old & Weak
    `; sectionsEl.innerHTML = ""; const makeSection = (title, icon, items, desc, renderItem) => { if (!items.length) return; const sec = document.createElement("div"); sec.className = "sec-section"; const defaultRender = (i) => `
  • ${escHtml(i.name)} ${escHtml(i.plain?.username || "")}
  • `; const renderFn = renderItem || defaultRender; sec.innerHTML = `
    ${icon}
    ${title} (${items.length})
    ${desc}
    `; sec.querySelectorAll("[data-sec-edit]").forEach((btn) => { btn.addEventListener("click", () => { const item = _items.find( (it) => it.id === parseInt(btn.dataset.secEdit), ); if (item) { switchView("vault"); openModal("edit", item); } }); }); sectionsEl.appendChild(sec); }; makeSection( "Weak Passwords", "⚠️", weak, "Short or low-complexity passwords.", ); makeSection( "Reused Passwords", "♻️", [...new Set(reused.map((i) => i.id))] .map((id) => _items.find((i) => i.id === id)) .filter(Boolean), "Same password used on multiple sites.", ); makeSection( "Old Passwords", "🕐", old, "Weak or reused passwords not changed in over 180 days.", (i) => { const ref = i.plain?.password_changed_at || i.created_at; const daysAgo = ref ? Math.floor((Date.now() - new Date(ref).getTime()) / 86400000) : null; const ageLabel = daysAgo !== null ? `Last changed ${daysAgo} day${daysAgo !== 1 ? "s" : ""} ago` : "Age unknown"; return `
  • ${escHtml(i.name)} ${escHtml(ageLabel)}
  • `; }, ); // ── Missing 2FA warning ────────────────────────────────────────────────── // Flag password items that have a URL but no TOTP URI saved. // These accounts likely support 2FA but the user hasn't stored it. const noTotp = pwItems.filter( (i) => i.plain?.url && !extractTotpSecret(i.plain?.totp_uri), ); makeSection( "No 2FA Saved", "🔓", noTotp, "These accounts may support two-factor authentication but have no TOTP code stored.", ); // ── HaveIBeenPwned breach check ────────────────────────────────────────── // Use cached results from the background check when available — avoids // re-querying HIBP every time the user opens the Security tab. const hibpSection = document.createElement("div"); hibpSection.className = "sec-section"; hibpSection.innerHTML = `
    🔓
    Checking for known breaches…
    Querying HaveIBeenPwned (k-anonymity — your passwords are never sent).
    `; sectionsEl.appendChild(hibpSection); // Use cached results if available, otherwise run fresh checks. let hibpResults; if (_healthCache?.hibpResults) { hibpResults = _healthCache.hibpResults; } else { // Run all HIBP checks in parallel — k-anonymity: only 5-char SHA-1 prefix sent. hibpResults = await Promise.all( pwItems.map(async (item) => ({ item, count: await checkHibp(item.plain.password), })), ); } const breached = hibpResults.filter((r) => r.count > 0).map((r) => r.item); if (!breached.length) { hibpSection.innerHTML = `
    No known breaches
    None of your passwords appeared in known data breaches (via HaveIBeenPwned).
    `; } else { hibpSection.innerHTML = `
    🔓
    Breached Passwords (${breached.length})
    These passwords appeared in known data breaches. Change them immediately.
    `; hibpSection.querySelectorAll("[data-sec-edit]").forEach((btn) => { btn.addEventListener("click", () => { const item = _items.find( (it) => it.id === parseInt(btn.dataset.secEdit), ); if (item) { switchView("vault"); openModal("edit", item); } }); }); } } // ── Import / Export View ───────────────────────────────────────────────── // Holds parsed rows from a chosen file, ready for import. let _importRows = []; /** * Parse a Chrome/Bitwarden/1Password CSV into a normalised array of plain objects. * Supported column sets: * Chrome: name, url, username, password * Bitwarden: name, login_uri, login_username, login_password, notes, type * 1Password: Title, Url, Username, Password, Notes */ function _parseCsvImport(text) { const lines = text.split(/\r?\n/); if (lines.length < 2) return []; const headers = lines[0] .split(",") .map((h) => h.trim().replace(/^"|"$/g, "").toLowerCase()); // Detect format by inspecting header names. const col = (candidates) => { for (const c of candidates) { const idx = headers.indexOf(c); if (idx !== -1) return idx; } return -1; }; const iName = col(["name", "title"]); const iUrl = col(["url", "login_uri"]); const iUser = col(["username", "login_username"]); const iPass = col(["password", "login_password"]); const iNotes = col(["notes", "note"]); const rows = []; for (let i = 1; i < lines.length; i++) { const line = lines[i].trim(); if (!line) continue; // RFC 4180-compliant CSV split — handles quoted fields containing commas // and embedded double-quotes escaped as "". const cells = []; let cur = "", inQuote = false; const src = line + ","; for (let ci = 0; ci < src.length; ci++) { const ch = src[ci]; if (ch === '"') { if (inQuote && src[ci + 1] === '"') { // Escaped quote inside a quoted field: "" → " cur += '"'; ci++; // skip the second quote } else { inQuote = !inQuote; } } else if (ch === "," && !inQuote) { cells.push(cur.trim()); cur = ""; } else { cur += ch; } } const get = (idx) => idx !== -1 && cells[idx] != null ? cells[idx].replace(/^"|"$/g, "") : ""; const name = get(iName); const password = get(iPass); if (!name || !password) continue; rows.push({ name, url: get(iUrl), username: get(iUser), password, notes: get(iNotes), }); } return rows; } let _importViewInitialised = false; function _resetImportView() { _importRows = []; const fileInput = document.getElementById("import-file-input"); const fileNameEl = document.getElementById("import-file-name"); const previewEl = document.getElementById("import-preview"); const confirmBtn = document.getElementById("btn-import-confirm"); const resultEl = document.getElementById("import-result"); if (fileInput) fileInput.value = ""; if (fileNameEl) fileNameEl.textContent = "No file chosen"; if (previewEl) { previewEl.innerHTML = ""; previewEl.classList.add("hidden"); } if (confirmBtn) { confirmBtn.disabled = true; confirmBtn.textContent = "Import items"; delete confirmBtn.dataset.mode; } if (resultEl) { resultEl.innerHTML = ""; resultEl.classList.add("hidden"); } } function loadImportExportView() { // Reset import panel state every time the view is entered so a previous // import result or file preview is never shown stale on re-entry. _resetImportView(); if (_importViewInitialised) return; _importViewInitialised = true; // ── Export ─────────────────────────────────────────────────────────────── document .getElementById("btn-export-json") ?.addEventListener("click", async () => { try { const res = await apiFetch("/api/vault"); if (!res) return; const items = await res.json(); const payload = JSON.stringify( { version: 1, exported_at: new Date().toISOString(), items }, null, 2, ); const blob = new Blob([payload], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `passkeeper-export-${new Date().toISOString().slice(0, 10)}.json`; a.click(); URL.revokeObjectURL(url); showToast("Encrypted vault exported."); console.log("[PassKeeper] Vault exported:", items.length, "items"); } catch (err) { showToast("Export failed: " + err.message, "error"); } }); document .getElementById("btn-export-csv") ?.addEventListener("click", async () => { const vaultKey = VaultSession.getKey(); if (!vaultKey) { showUnlockOverlay(); return; } // Warn the user that this export contains plaintext passwords before proceeding. const confirmed = await new Promise((resolve) => { const overlay = document.createElement("div"); overlay.style.cssText = [ "position:fixed", "inset:0", "background:rgba(0,0,0,0.55)", "z-index:9999", "display:flex", "align-items:center", "justify-content:center", ].join(";"); overlay.innerHTML = `
    Export plaintext passwords?

    The CSV file will contain all your passwords in plaintext. Anyone with access to the file can read them.

    Store the file in a secure location and delete it when you no longer need it.

    `; document.body.appendChild(overlay); overlay .querySelector("#_csv_cancel") .addEventListener("click", () => { overlay.remove(); resolve(false); }); overlay .querySelector("#_csv_confirm") .addEventListener("click", () => { overlay.remove(); resolve(true); }); overlay.addEventListener("click", (e) => { if (e.target === overlay) { overlay.remove(); resolve(false); } }); }); if (!confirmed) return; try { const res = await apiFetch("/api/vault"); if (!res) return; const raw = await res.json(); const decrypted = await Promise.all( raw.map(async (item) => { try { const plain = await Crypto.decryptItem( vaultKey, item.enc_data, item.iv, ); let displayName = item.name; if (item.enc_name && item.iv_name) { const n = await Crypto.decryptName( vaultKey, item.enc_name, item.iv_name, ); if (n) displayName = n; } return { name: displayName, ...plain }; } catch { return null; } }), ); const csvRows = [["name", "url", "username", "password", "notes"]]; decrypted.filter(Boolean).forEach((r) => { if (r.password) { const esc = (v) => `"${String(v ?? "").replace(/"/g, '""')}"`; csvRows.push( [r.name, r.url, r.username, r.password, r.notes].map(esc), ); } }); const csv = csvRows.map((r) => r.join(",")).join("\n"); const blob = new Blob([csv], { type: "text/csv" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `passkeeper-export-${new Date().toISOString().slice(0, 10)}.csv`; a.click(); URL.revokeObjectURL(url); showToast("CSV exported — store it securely."); console.log( "[PassKeeper] CSV export:", decrypted.filter(Boolean).length, "items", ); } catch (err) { showToast("CSV export failed: " + err.message, "error"); } }); // ── Import ─────────────────────────────────────────────────────────────── const fileInput = document.getElementById("import-file-input"); const fileNameEl = document.getElementById("import-file-name"); const previewEl = document.getElementById("import-preview"); const confirmBtn = document.getElementById("btn-import-confirm"); const resultEl = document.getElementById("import-result"); fileInput?.addEventListener("change", async () => { const file = fileInput.files[0]; if (!file) return; fileNameEl.textContent = file.name; previewEl.classList.add("hidden"); resultEl.classList.add("hidden"); confirmBtn.disabled = true; _importRows = []; const text = await file.text(); const isJson = file.name.endsWith(".json"); try { if (isJson) { // PassKeeper encrypted JSON export. const parsed = JSON.parse(text); const items = parsed.items || (Array.isArray(parsed) ? parsed : []); if (!items.length) throw new Error("No items found in JSON file."); _importRows = items; previewEl.innerHTML = `

    Found ${items.length} encrypted item(s) ready to import.

    These are already encrypted with your vault key — they will be imported as-is.

    `; } else { // CSV — decrypt and re-encrypt with current vault key. const vaultKey = VaultSession.getKey(); if (!vaultKey) { showUnlockOverlay(); return; } const rows = _parseCsvImport(text); if (!rows.length) throw new Error("No valid rows found. Check the CSV format."); _importRows = rows; // stored as plaintext — encrypted on confirm previewEl.innerHTML = `

    Found ${rows.length} password(s) to import.

    Preview (first 5):

    ${rows.length > 5 ? `

    …and ${rows.length - 5} more.

    ` : ""}`; } previewEl.classList.remove("hidden"); confirmBtn.disabled = false; confirmBtn.dataset.mode = isJson ? "json" : "csv"; } catch (err) { previewEl.innerHTML = `

    ⚠️ ${escHtml(err.message)}

    `; previewEl.classList.remove("hidden"); } }); confirmBtn?.addEventListener("click", async () => { const vaultKey = VaultSession.getKey(); if (!vaultKey) { showUnlockOverlay(); return; } confirmBtn.disabled = true; confirmBtn.textContent = "Importing…"; resultEl.classList.add("hidden"); try { let payload; if (confirmBtn.dataset.mode === "json") { // Already-encrypted items — send directly. payload = _importRows; } else { // Plaintext CSV rows — encrypt each one now. // Use the import timestamp as password_changed_at — best available // approximation since CSV exports don't carry a change date. const importedAt = new Date().toISOString(); payload = await Promise.all( _importRows.map(async (row) => { const plain = { url: row.url || "", username: row.username || "", password: row.password, notes: row.notes || "", password_changed_at: importedAt, }; const { enc_data, iv } = await Crypto.encryptItem( vaultKey, plain, ); const { enc_name, iv_name } = await Crypto.encryptName( vaultKey, row.name, ); return { name: "password", item_type: "password", enc_data, iv, enc_name, iv_name, }; }), ); } const res = await apiFetch("/api/vault/import", { method: "POST", body: JSON.stringify(payload), }); if (!res) return; const data = await res.json(); resultEl.innerHTML = `✅ Imported ${data.imported} item(s)${data.skipped ? `, skipped ${data.skipped} malformed row(s)` : ""}.`; resultEl.className = "import-result import-result-ok"; resultEl.classList.remove("hidden"); _importRows = []; confirmBtn.textContent = "Import items"; fileInput.value = ""; fileNameEl.textContent = "No file chosen"; previewEl.classList.add("hidden"); console.log( "[PassKeeper] Import complete:", data.imported, "imported,", data.skipped, "skipped", ); await loadVault(); } catch (err) { resultEl.innerHTML = `⚠️ ${escHtml(err.message)}`; resultEl.className = "import-result import-result-err"; resultEl.classList.remove("hidden"); confirmBtn.disabled = false; confirmBtn.textContent = "Import items"; } }); } // ── Sharing View ────────────────────────────────────────────────────────── async function loadSharingView() { try { const keysRes = await apiFetch("/api/sharing/keys"); if (!keysRes) return; const keysData = await keysRes.json(); const notice = document.getElementById("sharing-keys-notice"); if (!keysData.keys_setup) { notice.classList.remove("hidden"); } else { notice.classList.add("hidden"); // Load sharing private key into SharingSession if vault key available const vaultKey = VaultSession.getKey(); if (vaultKey && !SharingSession.isReady()) { try { const privKey = await SharingCrypto.decryptPrivateKey( vaultKey, keysData.private_key_enc, keysData.private_key_iv, ); SharingSession.setKey(privKey); } catch (err) { console.warn( "[PassKeeper] Could not load sharing private key:", err, ); } } } await Promise.all([loadSharedOut(), loadSharedIn()]); } catch (err) { showToast("Could not load sharing data: " + err.message, "error"); } } async function loadSharedOut() { try { const res = await apiFetch("/api/sharing"); if (!res) return; const shares = await res.json(); renderSharedOut(shares); } catch (err) { console.error("loadSharedOut:", err); } } async function loadSharedIn() { try { const res = await apiFetch("/api/sharing/inbox"); if (!res) return; const shares = await res.json(); renderSharedIn(shares); } catch (err) { console.error("loadSharedIn:", err); } } function renderSharedOut(shares) { const ul = document.getElementById("shared-out-list"); if (!ul) return; if (!shares.length) { ul.innerHTML = '
  • You haven\'t shared any items yet.
  • '; return; } ul.innerHTML = shares .map( (s) => { // Resolve the display name: prefer the decrypted name from _items // (owner's vault is already in memory), fall back to item_name which // is now the item_type label for new shares, or a legacy plaintext // name for shares created before the enc_name migration. const vaultItem = _items.find((i) => i.id === s.item_id); const displayName = vaultItem?.name || s.item_name; const expiryLabel = (() => { if (!s.expires_at) return ""; const daysLeft = Math.ceil( (new Date(s.expires_at).getTime() - Date.now()) / 86400000, ); if (daysLeft <= 0) return " · Expired"; if (daysLeft <= 3) return ` · Expires in ${daysLeft}d`; return ` · Expires ${new Date(s.expires_at).toLocaleDateString()}`; })(); return `
  • ${itemIcon(s.item_type)}
  • `; }, ) .join(""); ul.querySelectorAll("[data-revoke]").forEach((btn) => { btn.addEventListener("click", async () => { if (!confirm("Remove this share?")) return; try { await apiFetch(`/api/sharing/${btn.dataset.revoke}`, { method: "DELETE", }); showToast("Share removed"); loadSharedOut(); } catch (err) { showToast(err.message, "error"); } }); }); } function renderSharedIn(shares) { const ul = document.getElementById("shared-in-list"); if (!ul) return; if (!shares.length) { ul.innerHTML = '
  • No items have been shared with you.
  • '; return; } ul.innerHTML = shares .map( (s) => { // Expiry label for pending (unaccepted) items. const expiryLabel = (() => { if (!s.expires_at || s.accepted) return ""; const daysLeft = Math.ceil( (new Date(s.expires_at).getTime() - Date.now()) / 86400000, ); if (daysLeft <= 3) return ` · Expires in ${daysLeft}d`; return ` · Expires ${new Date(s.expires_at).toLocaleDateString()}`; })(); return `
  • ${itemIcon(s.item_type)}
    ${ !s.accepted ? `` : `` }
  • `; }, ) .join(""); ul.querySelectorAll("[data-accept]").forEach((btn) => { btn.addEventListener("click", async () => { try { await apiFetch(`/api/sharing/inbox/${btn.dataset.accept}/accept`, { method: "POST", }); showToast("Share accepted"); loadSharedIn(); } catch (err) { showToast(err.message, "error"); } }); }); ul.querySelectorAll("[data-view-share]").forEach((btn) => { btn.addEventListener("click", async () => { if (!SharingSession.isReady()) { showToast("Sharing keys not loaded. Check Settings.", "error"); return; } try { const ownerPubKey = await SharingCrypto.importPublicKey( btn.dataset.ownerKey, ); const sharedKey = await SharingCrypto.deriveSharedKey( SharingSession.getKey(), ownerPubKey, ); const plain = await SharingCrypto.decryptShare( sharedKey, btn.dataset.enc, btn.dataset.iv, ); // Decrypt the item name if an encrypted version is available. // Falls back to the non-sensitive label for legacy shares. let displayName = btn.dataset.name; if (btn.dataset.encName && btn.dataset.ivName) { const decrypted = await SharingCrypto.decryptName( sharedKey, btn.dataset.encName, btn.dataset.ivName, ); if (decrypted) displayName = decrypted; } showSharedItemDetails(displayName, btn.dataset.type, plain); } catch (err) { showToast("Could not decrypt: " + err.message, "error"); } }); }); } function showSharedItemDetails(name, itemType, plain) { document.getElementById("detail-modal-title").textContent = `${itemIcon(itemType || "password")} ${name}`; renderDetailFields(document.getElementById("detail-modal-body"), plain); document.getElementById("detail-modal").classList.add("open"); } function renderDetailFields(container, plain) { container.innerHTML = Object.entries(plain) .filter(([, v]) => v) .map(([k, v]) => { const label = FIELD_LABELS[k] || k.replace(/_/g, " "); const val = String(v); const sensitive = SENSITIVE_FIELDS.has(k); const valHtml = sensitive ? ` •••••••• ` : `${escHtml(val)}`; return `
    ${escHtml(label)}
    ${valHtml}
    `; }) .join("") || '

    No fields to display.

    '; container.querySelectorAll(".btn-reveal").forEach((btn) => { btn.addEventListener("click", () => { const span = btn.previousElementSibling; const revealed = span.dataset.revealed === "true"; span.dataset.revealed = String(!revealed); span .querySelector(".detail-masked") .classList.toggle("hidden", !revealed); span .querySelector(".detail-actual") .classList.toggle("hidden", revealed); }); }); container.querySelectorAll(".btn-copy-field").forEach((btn) => { btn.addEventListener("click", () => { navigator.clipboard .writeText(btn.dataset.copy) .then(() => showToast("Copied!")); }); }); } async function handleShareFormSubmit(e) { e.preventDefault(); const errEl = document.getElementById("share-error"); errEl.classList.add("hidden"); if (!SharingSession.isReady()) { showToast("Set up sharing keys first (Settings)", "error"); return; } const itemId = parseInt(document.getElementById("share-item-select").value); const recipientEmail = document .getElementById("share-recipient-email") .value.trim() .toLowerCase(); if (!itemId || !recipientEmail) { errEl.textContent = "Please select an item and enter a recipient email."; errEl.classList.remove("hidden"); return; } try { // Fetch recipient's public key const pkRes = await apiFetch( `/api/sharing/public-key?email=${encodeURIComponent(recipientEmail)}`, ); if (!pkRes) return; const pkData = await pkRes.json(); const recipientPubKey = await SharingCrypto.importPublicKey( pkData.public_key, ); const sharedKey = await SharingCrypto.deriveSharedKey( SharingSession.getKey(), recipientPubKey, ); // Find item plaintext const item = _items.find((i) => i.id === itemId); if (!item?.plain) { showToast("Item not found or not decrypted", "error"); return; } const { enc_data, iv } = await SharingCrypto.encryptForShare( sharedKey, item.plain, ); // Encrypt the display name with the same ECDH shared key so the server // never sees the plaintext name. item_name is set to item_type as a // non-sensitive fallback label for legacy-compat (server requires the field). const { enc_name, iv_name } = await SharingCrypto.encryptName( sharedKey, item.name, ); const res = await apiFetch("/api/sharing", { method: "POST", body: JSON.stringify({ item_id: itemId, recipient_email: recipientEmail, enc_data, iv, item_name: item.item_type, // non-sensitive label; real name is in enc_name item_type: item.item_type, enc_name, iv_name, expires_days: (() => { const sel = document.getElementById("share-expiry-select"); const v = sel ? parseInt(sel.value) : NaN; return isNaN(v) || v === 0 ? null : v; })(), }), }); if (!res) return; closeModal("share-modal"); showToast("Item shared successfully"); loadSharedOut(); } catch (err) { errEl.textContent = err.message; errEl.classList.remove("hidden"); } } function openShareModal() { const sel = document.getElementById("share-item-select"); sel.innerHTML = _items .map( (i) => ``, ) .join(""); document.getElementById("share-recipient-email").value = ""; document.getElementById("share-error").classList.add("hidden"); document.getElementById("share-modal").classList.add("open"); } // ── Emergency Access View ───────────────────────────────────────────────── async function loadEmergencyView() { try { const res = await apiFetch("/api/emergency"); if (!res) return; const data = await res.json(); renderEmergencyGrants(data.grants); renderEmergencyAccess(data.access); } catch (err) { showToast( "Could not load emergency access data: " + err.message, "error", ); } } function renderEmergencyGrants(grants) { const ul = document.getElementById("em-grants-list"); if (!ul) return; if (!grants.length) { ul.innerHTML = '
  • No trusted contacts set up yet.
  • '; return; } ul.innerHTML = grants .map((g) => { let actions = ""; if (g.status === "accepted") { actions = ``; } if (g.status === "ready") { // Check if the stored snapshot is in the old format (plaintext names). if (g.enc_vault_is_legacy) { actions = ` ⚠ Outdated snapshot `; } else { actions = ``; } } if (g.status === "pending") { const waitInfo = g.wait_elapsed ? "Wait period elapsed" : "Access requested"; actions = `${waitInfo} `; } return `
  • 🚨
    ${actions}
  • `; }) .join(""); ul.querySelectorAll("[data-provide]").forEach((btn) => { btn.addEventListener("click", () => handleProvideEmergencyData(parseInt(btn.dataset.provide)), ); }); ul.querySelectorAll("[data-deny]").forEach((btn) => { btn.addEventListener("click", async () => { if (!confirm("Deny this access request?")) return; try { await apiFetch(`/api/emergency/${btn.dataset.deny}/deny`, { method: "POST", }); showToast("Access request denied"); loadEmergencyView(); } catch (err) { showToast(err.message, "error"); } }); }); ul.querySelectorAll("[data-remove-ea]").forEach((btn) => { btn.addEventListener("click", async () => { if (!confirm("Remove this emergency access grant?")) return; try { await apiFetch(`/api/emergency/${btn.dataset.removeEa}`, { method: "DELETE", }); showToast("Emergency access removed"); loadEmergencyView(); } catch (err) { showToast(err.message, "error"); } }); }); } async function handleProvideEmergencyData(eaId) { if (!SharingSession.isReady()) { showToast("Set up sharing keys first (Settings → Sharing Keys)", "error"); return; } if ( !confirm( "This will create an encrypted snapshot of all your vault items for your trusted contact. Continue?", ) ) return; try { // Get grantee's public key from the emergency record const listRes = await apiFetch("/api/emergency"); const listData = await listRes.json(); const ea = listData.grants.find((g) => g.id === eaId); if (!ea) { showToast("Emergency access record not found", "error"); return; } // Fetch grantee's public key const pkRes = await apiFetch( `/api/sharing/public-key?email=${encodeURIComponent(ea.grantee_email)}`, ); if (!pkRes) return; const pkData = await pkRes.json(); const granteePubKey = await SharingCrypto.importPublicKey( pkData.public_key, ); const sharedKey = await SharingCrypto.deriveSharedKey( SharingSession.getKey(), granteePubKey, ); // Re-encrypt all decrypted vault items. // Item name is encrypted with the same ECDH shared key so the server // never sees plaintext names inside enc_vault (zero-knowledge). const encItems = await Promise.all( _items .filter((i) => i.plain) .map(async (i) => { const { enc_data, iv } = await SharingCrypto.encryptForShare( sharedKey, i.plain, ); const { enc_name, iv_name } = await SharingCrypto.encryptName( sharedKey, i.name, ); return { id: i.id, item_type: i.item_type, enc_data, iv, enc_name, iv_name, }; }), ); await apiFetch(`/api/emergency/${eaId}/provide`, { method: "POST", body: JSON.stringify({ enc_vault: JSON.stringify(encItems) }), }); showToast("Recovery data provided"); loadEmergencyView(); } catch (err) { showToast("Failed to provide recovery data: " + err.message, "error"); } } function renderEmergencyAccess(access) { const ul = document.getElementById("em-access-list"); if (!ul) return; if (!access.length) { ul.innerHTML = '
  • No one has designated you as an emergency contact.
  • '; return; } ul.innerHTML = access .map((a) => { let actions = ""; if (a.status === "invited") { actions = ``; } else if (a.status === "ready") { actions = ``; } else if (a.status === "pending") { let daysLeft = a.wait_days; if (a.request_initiated_at) { const elapsed = (Date.now() - new Date(a.request_initiated_at).getTime()) / 86400000; daysLeft = Math.max(0, Math.ceil(a.wait_days - elapsed)); } actions = a.wait_elapsed ? `` : `Waiting (~${daysLeft} day${daysLeft !== 1 ? "s" : ""} left)`; } return `
  • 🔐
    ${actions}
  • `; }) .join(""); ul.querySelectorAll("[data-accept-ea]").forEach((btn) => { btn.addEventListener("click", async () => { try { await apiFetch(`/api/emergency/${btn.dataset.acceptEa}/accept`, { method: "POST", }); showToast("Emergency access accepted"); loadEmergencyView(); } catch (err) { showToast(err.message, "error"); } }); }); ul.querySelectorAll("[data-request-ea]").forEach((btn) => { btn.addEventListener("click", async () => { if ( !confirm( "Request emergency access? This will start the waiting period. The vault owner will be notified.", ) ) return; try { await apiFetch(`/api/emergency/${btn.dataset.requestEa}/request`, { method: "POST", }); showToast("Access request sent — wait period has started"); loadEmergencyView(); } catch (err) { showToast(err.message, "error"); } }); }); ul.querySelectorAll("[data-get-vault]").forEach((btn) => { btn.addEventListener("click", () => handleGetEmergencyVault(parseInt(btn.dataset.getVault)), ); }); } async function handleGetEmergencyVault(eaId) { if (!SharingSession.isReady()) { showToast("Set up sharing keys first (Settings → Sharing Keys)", "error"); return; } try { const res = await apiFetch(`/api/emergency/${eaId}/vault`); if (!res) return; const data = await res.json(); const grantorPubKey = await SharingCrypto.importPublicKey( data.grantor_public_key, ); const sharedKey = await SharingCrypto.deriveSharedKey( SharingSession.getKey(), grantorPubKey, ); const encItems = JSON.parse(data.enc_vault); const decItems = await Promise.all( encItems.map(async (i) => { try { const plain = await SharingCrypto.decryptShare( sharedKey, i.enc_data, i.iv, ); // Decrypt the item display name if available (new format). // Fall back to item_type label for legacy enc_vault snapshots. let displayName = i.item_type || "password"; if (i.enc_name && i.iv_name) { const decName = await SharingCrypto.decryptName( sharedKey, i.enc_name, i.iv_name, ); if (decName) displayName = decName; } return { ...i, name: displayName, plain }; } catch { return { ...i, name: i.item_type || "password", plain: null }; } }), ); renderEmergencyVaultPanel(decItems); } catch (err) { showToast("Could not retrieve vault: " + err.message, "error"); } } function renderEmergencyVaultPanel(items) { const list = document.getElementById("em-access-list"); const panel = document.getElementById("em-vault-panel"); const vaultList = document.getElementById("em-vault-list"); if (!panel || !vaultList) return; list.classList.add("hidden"); panel.classList.remove("hidden"); if (!items.length) { vaultList.innerHTML = '
  • No items in emergency vault.
  • '; return; } vaultList.innerHTML = items .map((item, idx) => { const icon = itemIcon(item.item_type); const hasFields = item.plain && Object.values(item.plain).some((v) => v); return `
  • `; }) .join(""); // Populate detail fields and wire expand/collapse items.forEach((item, idx) => { const header = vaultList.querySelector(`[data-em-idx="${idx}"]`); const body = document.getElementById(`em-vault-body-${idx}`); const detailBody = body?.querySelector(".em-detail-body"); if (detailBody && item.plain) renderDetailFields(detailBody, item.plain); header?.addEventListener("click", () => { const expanded = header.getAttribute("aria-expanded") === "true"; header.setAttribute("aria-expanded", String(!expanded)); header.querySelector(".em-vault-chevron").textContent = expanded ? "▸" : "▾"; body?.classList.toggle("hidden", expanded); }); }); document .getElementById("btn-em-vault-back") ?.addEventListener("click", () => { panel.classList.add("hidden"); list.classList.remove("hidden"); }); } // ── Password Generator ──────────────────────────────────────────────── const GEN_CHARSETS = { upper: "ABCDEFGHIJKLMNOPQRSTUVWXYZ", lower: "abcdefghijklmnopqrstuvwxyz", digits: "0123456789", symbols: "!@#$%^&*()-_=+[]{}|;:,.<>?", }; function generatePassword( length = 20, opts = { upper: true, lower: true, digits: true, symbols: true }, ) { let pool = ""; const required = []; if (opts.upper) { pool += GEN_CHARSETS.upper; required.push(GEN_CHARSETS.upper); } if (opts.lower) { pool += GEN_CHARSETS.lower; required.push(GEN_CHARSETS.lower); } if (opts.digits) { pool += GEN_CHARSETS.digits; required.push(GEN_CHARSETS.digits); } if (opts.symbols) { pool += GEN_CHARSETS.symbols; required.push(GEN_CHARSETS.symbols); } if (!pool) pool = GEN_CHARSETS.lower; const arr = new Uint32Array(length + required.length); window.crypto.getRandomValues(arr); // Guarantee at least one char from each required charset const chars = required.map((cs, i) => cs[arr[i] % cs.length]); for (let i = required.length; i < length + required.length; i++) { chars.push(pool[arr[i] % pool.length]); } // Fisher-Yates shuffle using crypto random const shuffle = new Uint32Array(chars.length); window.crypto.getRandomValues(shuffle); for (let i = chars.length - 1; i > 0; i--) { const j = shuffle[i] % (i + 1); [chars[i], chars[j]] = [chars[j], chars[i]]; } return chars.slice(0, length).join(""); } function scorePassword(pw) { if (!pw) return { score: 0, label: "", cls: "" }; 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++; // clamp to 5 bands const band = Math.min(5, Math.ceil((score / 7) * 5)); const labels = ["", "Very Weak", "Weak", "Fair", "Strong", "Very Strong"]; const clses = [ "", "strength-1", "strength-2", "strength-3", "strength-4", "strength-5", ]; return { score: band, label: labels[band], cls: clses[band] }; } // Attach strength meter + generate button to the password field in the item modal function initPasswordFieldEnhancements() { const pwInput = document.getElementById("field-password"); if (!pwInput) return; // Strength bar below the password field if (!document.getElementById("field-password-strength")) { const bar = document.createElement("div"); bar.id = "field-password-strength"; bar.className = "password-strength"; pwInput .closest(".input-with-toggle") .insertAdjacentElement("afterend", bar); } pwInput.addEventListener("input", () => { const { label, cls } = scorePassword(pwInput.value); const bar = document.getElementById("field-password-strength"); bar.textContent = pwInput.value ? label : ""; bar.className = "password-strength " + (pwInput.value ? cls : ""); }); // Generate button — add only once if (!document.getElementById("btn-generate-pass")) { const btn = document.createElement("button"); btn.type = "button"; btn.id = "btn-generate-pass"; btn.className = "btn-secondary btn-sm btn-generate"; btn.textContent = "⚡ Generate"; pwInput.closest(".form-group").appendChild(btn); } document.getElementById("btn-generate-pass").onclick = () => { const pw = generatePassword(20); pwInput.value = pw; pwInput.type = "text"; // reveal so user can see what was generated pwInput.dispatchEvent(new Event("input")); // trigger strength bar }; } function openPasswordGeneratorModal() { // Build modal content dynamically — keeps the HTML lean let modal = document.getElementById("gen-modal"); if (!modal) { modal = document.createElement("div"); modal.id = "gen-modal"; modal.className = "modal-overlay"; modal.setAttribute("role", "dialog"); modal.setAttribute("aria-modal", "true"); modal.innerHTML = ` `; document.body.appendChild(modal); const lengthSlider = document.getElementById("gen-length"); const lengthDisplay = document.getElementById("gen-length-display"); const output = document.getElementById("gen-output"); const strengthEl = document.getElementById("gen-strength"); function refreshGen() { const pw = generatePassword(parseInt(lengthSlider.value), { upper: document.getElementById("gen-upper").checked, lower: document.getElementById("gen-lower").checked, digits: document.getElementById("gen-digits").checked, symbols: document.getElementById("gen-symbols").checked, }); output.value = pw; const { label, cls } = scorePassword(pw); strengthEl.textContent = label; strengthEl.className = "password-strength " + cls; } lengthSlider.addEventListener("input", () => { lengthDisplay.textContent = lengthSlider.value; refreshGen(); }); ["gen-upper", "gen-lower", "gen-digits", "gen-symbols"].forEach((id) => { document.getElementById(id).addEventListener("change", refreshGen); }); document .getElementById("btn-regenerate") .addEventListener("click", refreshGen); document.getElementById("btn-gen-copy").addEventListener("click", () => { navigator.clipboard .writeText(output.value) .then(() => showToast("Password copied")); }); const closeGen = () => modal.classList.remove("open"); document .getElementById("btn-close-gen-modal") .addEventListener("click", closeGen); document .getElementById("btn-close-gen-modal2") .addEventListener("click", closeGen); modal.addEventListener("click", (e) => { if (e.target === modal) closeGen(); }); refreshGen(); } modal.classList.add("open"); // Regenerate fresh password each time modal opens const lengthSlider = document.getElementById("gen-length"); document.getElementById("gen-length-display").textContent = lengthSlider.value; const pw = generatePassword(parseInt(lengthSlider.value), { upper: document.getElementById("gen-upper").checked, lower: document.getElementById("gen-lower").checked, digits: document.getElementById("gen-digits").checked, symbols: document.getElementById("gen-symbols").checked, }); document.getElementById("gen-output").value = pw; const { label, cls } = scorePassword(pw); const strengthEl = document.getElementById("gen-strength"); strengthEl.textContent = label; strengthEl.className = "password-strength " + cls; } // ── Account Settings ────────────────────────────────────────────────────── let _pendingMfaSecret = null; async function openSettingsModal() { document.getElementById("settings-modal").classList.add("open"); // Populate web-app session timeout select with saved value. const idleSel = document.getElementById("web-idle-select"); if (idleSel) idleSel.value = String(_getWebIdleMinutes()); // Reset change password fields ["cp-current", "cp-new", "cp-confirm"].forEach((id) => { const el = document.getElementById(id); if (el) el.value = ""; }); document.getElementById("cp-error")?.classList.add("hidden"); // Reset delete account area document.getElementById("delete-confirm-area")?.classList.add("hidden"); document.getElementById("btn-delete-account")?.classList.remove("hidden"); document.getElementById("delete-password").value = ""; document.getElementById("delete-error")?.classList.add("hidden"); // Reset recovery display document.getElementById("recovery-code-display")?.classList.add("hidden"); await Promise.all([ loadMfaStatus(), loadSharingKeysStatus(), loadRecoveryStatus(), loadAuditLog(0, true), loadPasskeys(), ]); } async function loadMfaStatus() { try { const res = await apiFetch("/api/auth/mfa/status"); if (!res) return; const data = await res.json(); renderMfaStatus(data.totp_enabled, data.backup_codes_remaining); } catch (err) { console.error("loadMfaStatus:", err); } } const _auditPageSize = 20; let _auditOffset = 0; let _auditTotal = 0; // ── Passkey (WebAuthn) management ──────────────────────────────────────────── async function loadPasskeys() { const listEl = document.getElementById("passkeys-list"); const errEl = document.getElementById("passkeys-error"); if (!listEl) return; errEl?.classList.add("hidden"); // Hide the section if WebAuthn is not supported in this browser. if (!window.PublicKeyCredential) { document.getElementById("passkeys-section")?.classList.add("hidden"); return; } try { const res = await apiFetch("/api/webauthn/credentials"); const creds = await res.json(); if (!Array.isArray(creds) || !creds.length) { listEl.innerHTML = '

    No passkeys registered yet.

    '; } else { listEl.innerHTML = creds .map((c) => { const created = c.created_at ? new Date(c.created_at).toLocaleDateString() : ""; const lastUsed = c.last_used_at ? `Last used ${new Date(c.last_used_at).toLocaleDateString()}` : "Never used"; const transports = c.transports || []; const typeLabel = transports.includes("internal") ? "📱 Device" : transports.some((t) => ["usb", "nfc", "ble", "smart-card"].includes(t)) ? "🔑 Security key" : "🔑 Passkey"; return `
    ${escHtml(c.name)} ${typeLabel} · ${lastUsed} · Added ${escHtml(created)}
    `; }) .join(""); // Wire rename buttons. listEl.querySelectorAll(".btn-rename-passkey").forEach((btn) => { btn.addEventListener("click", async () => { const newName = prompt("New passkey name:", btn.dataset.name); if (!newName?.trim()) return; try { await apiFetch(`/api/webauthn/credentials/${btn.dataset.id}`, { method: "PATCH", body: JSON.stringify({ name: newName.trim() }), }); await loadPasskeys(); } catch (e) { showToast("Failed to rename passkey", "error"); } }); }); // Wire delete buttons. listEl.querySelectorAll(".btn-delete-passkey").forEach((btn) => { btn.addEventListener("click", async () => { if (!confirm(`Remove passkey "${btn.dataset.name}"?`)) return; try { await apiFetch(`/api/webauthn/credentials/${btn.dataset.id}`, { method: "DELETE", }); showToast("Passkey removed"); await loadPasskeys(); } catch (e) { showToast("Failed to remove passkey", "error"); } }); }); } } catch (e) { listEl.innerHTML = '

    Could not load passkeys.

    '; } // Wire register button (only bind once). const registerBtn = document.getElementById("btn-register-passkey"); if (registerBtn && !registerBtn.dataset.bound) { registerBtn.dataset.bound = "1"; registerBtn.addEventListener("click", async () => { const nameInput = document.getElementById("passkey-name-input"); const attachSel = document.getElementById("passkey-attachment-select"); const name = (nameInput?.value || "").trim() || "Passkey"; const attachment = attachSel?.value || "platform"; const errEl = document.getElementById("passkeys-error"); errEl?.classList.add("hidden"); registerBtn.disabled = true; registerBtn.textContent = "Waiting…"; const result = await PasskeyAuth.registerPasskey(name, attachment); registerBtn.disabled = false; registerBtn.textContent = "+ Add passkey"; if (result.error) { if (errEl) { errEl.textContent = result.error; errEl.classList.remove("hidden"); } return; } if (nameInput) nameInput.value = ""; showToast(`Passkey "${escHtml(result.credential.name)}" registered`); await loadPasskeys(); }); } } async function loadAuditLog(offset = 0, reset = false) { try { const res = await apiFetch( `/api/auth/audit-log?limit=${_auditPageSize}&offset=${offset}`, ); if (!res) return; const data = await res.json(); _auditTotal = data.total || 0; _auditOffset = offset + (data.entries || []).length; const listEl = document.getElementById("audit-log-list"); if (!listEl) return; if (reset) listEl.innerHTML = ""; if (!data.entries || !data.entries.length) { if (reset) listEl.innerHTML = '

    No activity recorded yet.

    '; document.getElementById("btn-load-more-audit").style.display = "none"; return; } // Action label map for friendly display const ACTION_LABELS = { "auth.login": "Signed in", "auth.login_failed": "Failed sign-in attempt", "auth.login_blocked": "Sign-in blocked (account locked)", "auth.account_locked": "Account locked", "auth.logout": "Signed out", "auth.mfa_verify": "MFA verified", "auth.mfa_backup_code_used": "MFA backup code used", "auth.mfa_enable": "MFA enabled", "auth.mfa_disable": "MFA disabled", "auth.mfa_backup_codes_regenerated": "Backup codes regenerated", "auth.change_password": "Password changed", "auth.recovery_setup": "Recovery code configured", "auth.recovery_success": "Account recovered", "auth.recovery_failed": "Failed recovery attempt", "vault.create": "Vault item created", "vault.update": "Vault item updated", "vault.delete": "Vault item deleted", }; data.entries.forEach((e) => { const label = ACTION_LABELS[e.action] || e.action; const date = new Date(e.created_at).toLocaleString(); const isAlert = e.action.includes("failed") || e.action.includes("blocked") || e.action.includes("locked"); const row = document.createElement("div"); row.style.cssText = `display:flex;justify-content:space-between;align-items:flex-start;padding:7px 0;border-bottom:1px solid #f3f4f6;gap:8px;`; row.innerHTML = ` ${label} ${date}
    ${e.ip_address || ""}
    `; listEl.appendChild(row); }); const loadMoreBtn = document.getElementById("btn-load-more-audit"); if (_auditOffset < _auditTotal) { loadMoreBtn.style.display = "inline-block"; loadMoreBtn.onclick = () => loadAuditLog(_auditOffset, false); } else { loadMoreBtn.style.display = "none"; } } catch (err) { console.error("loadAuditLog:", err); } } function renderMfaStatus(enabled, backupCodesRemaining) { const statusText = document.getElementById("mfa-status-text"); const actions = document.getElementById("mfa-actions"); document.getElementById("mfa-setup-area").classList.add("hidden"); document.getElementById("mfa-disable-area").classList.add("hidden"); if (enabled) { const remaining = backupCodesRemaining != null ? backupCodesRemaining : "?"; statusText.innerHTML = `✅ Two-factor authentication is enabled. (${remaining} backup code${remaining !== 1 ? "s" : ""} remaining)`; actions.innerHTML = '' + ' '; document .getElementById("btn-mfa-disable") ?.addEventListener("click", () => { document .getElementById("mfa-disable-area") .classList.remove("hidden"); document.getElementById("mfa-actions").innerHTML = ""; document.getElementById("mfa-disable-code").focus(); }); document .getElementById("btn-regen-backup-codes") ?.addEventListener("click", handleRegenerateBackupCodes); } else { statusText.textContent = "Two-factor authentication is not enabled."; actions.innerHTML = ''; document .getElementById("btn-mfa-setup") ?.addEventListener("click", handleMfaSetup); } } async function handleMfaSetup() { try { const res = await apiFetch("/api/auth/mfa/setup"); if (!res) return; const data = await res.json(); _pendingMfaSecret = data.secret; document.getElementById("mfa-qr-img").src = data.qr_code; document.getElementById("mfa-secret-display").textContent = data.secret; document.getElementById("mfa-setup-area").classList.remove("hidden"); document.getElementById("mfa-actions").innerHTML = ""; document.getElementById("mfa-status-text").textContent = "Scan the QR code below with your authenticator app."; document.getElementById("mfa-verify-code").value = ""; document.getElementById("mfa-verify-code").focus(); } catch (err) { showToast("Could not start MFA setup: " + err.message, "error"); } } async function handleMfaConfirm() { const code = document.getElementById("mfa-verify-code").value.trim(); if (!code || !_pendingMfaSecret) return; try { const res = await apiFetch("/api/auth/mfa/enable", { method: "POST", body: JSON.stringify({ secret: _pendingMfaSecret, totp_code: code }), }); if (!res) return; const data = await res.json(); if (!res.ok) { showToast(data.error || "Failed to enable MFA", "error"); return; } _pendingMfaSecret = null; document.getElementById("mfa-setup-area").classList.add("hidden"); // Display one-time backup codes immediately after enabling if (data.backup_codes && data.backup_codes.length) { showBackupCodesModal(data.backup_codes, true); } else { showToast("MFA enabled successfully"); renderMfaStatus(true); } } catch (err) { showToast("Failed to enable MFA: " + err.message, "error"); } } /** * Show backup codes in a modal. When `isFirstTime` is true the modal * emphasises that these will not be shown again. */ function showBackupCodesModal(codes, isFirstTime = false) { const existing = document.getElementById("__pk_backup_modal__"); if (existing) existing.remove(); const codesHtml = codes .map( (c) => `${c}`, ) .join(" "); const overlay = document.createElement("div"); overlay.id = "__pk_backup_modal__"; overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.55);z-index:9999;display:flex;align-items:center;justify-content:center;"; overlay.innerHTML = `

    ${isFirstTime ? "🔐 MFA enabled — save your backup codes" : "🔐 New backup codes"}

    ${ isFirstTime ? `

    These codes let you sign in if you lose access to your authenticator app. Save them now — they will not be shown again.

    ` : `

    Your previous codes have been invalidated. Save these new codes securely.

    ` }
    ${codesHtml}
    `; document.body.appendChild(overlay); document .getElementById("__pk_copy_codes__") .addEventListener("click", () => { navigator.clipboard .writeText(codes.join("\n")) .then(() => showToast("Backup codes copied")); }); document .getElementById("__pk_close_backup__") .addEventListener("click", () => { overlay.remove(); renderMfaStatus(true); }); } async function handleRegenerateBackupCodes() { const code = prompt( "Enter your current authenticator code to regenerate backup codes:", ); if (!code) return; try { const res = await apiFetch("/api/auth/mfa/backup-codes/regenerate", { method: "POST", body: JSON.stringify({ totp_code: code.trim() }), }); if (!res) return; const data = await res.json(); if (!res.ok) { showToast(data.error || "Failed to regenerate", "error"); return; } showBackupCodesModal(data.backup_codes, false); console.log("[PassKeeper] MFA backup codes regenerated"); } catch (err) { showToast("Failed to regenerate backup codes: " + err.message, "error"); } } async function handleMfaDisableConfirm() { const code = document.getElementById("mfa-disable-code").value.trim(); if (!code) return; try { const res = await apiFetch("/api/auth/mfa/disable", { method: "POST", body: JSON.stringify({ totp_code: code }), }); if (!res) return; showToast("MFA disabled"); renderMfaStatus(false); } catch (err) { showToast("Failed to disable MFA: " + err.message, "error"); } } async function loadSharingKeysStatus() { try { const res = await apiFetch("/api/sharing/keys"); if (!res) return; const data = await res.json(); const statusEl = document.getElementById("sharing-keys-status"); const actionsEl = document.getElementById("sharing-keys-actions"); if (data.keys_setup) { statusEl.textContent = "✅ Sharing keys are set up. You can share items and use emergency access."; actionsEl.innerHTML = ''; document .getElementById("btn-regen-keys") ?.addEventListener("click", handleGenerateKeys); } else { statusEl.textContent = "No sharing keys set up. Generate keys to enable item sharing and emergency access."; actionsEl.innerHTML = ''; document .getElementById("btn-gen-keys") ?.addEventListener("click", handleGenerateKeys); } } catch (err) { console.error("loadSharingKeysStatus:", err); } } async function handleGenerateKeys() { const vaultKey = VaultSession.getKey(); if (!vaultKey) { showToast("Vault is locked. Unlock first.", "error"); return; } try { const keyPair = await SharingCrypto.generateKeyPair(); const public_key = await SharingCrypto.exportPublicKey(keyPair.publicKey); const { private_key_enc, private_key_iv } = await SharingCrypto.encryptPrivateKey(vaultKey, keyPair.privateKey); const res = await apiFetch("/api/sharing/keys", { method: "POST", body: JSON.stringify({ public_key, private_key_enc, private_key_iv }), }); if (!res) return; // Cache private key in session SharingSession.setKey(keyPair.privateKey); showToast("Sharing keys generated"); await loadSharingKeysStatus(); } catch (err) { showToast("Key generation failed: " + err.message, "error"); } } // ── Change Password ─────────────────────────────────────────────────────── async function handleChangePassword() { const cpError = document.getElementById("cp-error"); cpError.classList.add("hidden"); const currentPass = document.getElementById("cp-current").value; const newPass = document.getElementById("cp-new").value; const confirmPass = document.getElementById("cp-confirm").value; if (!currentPass || !newPass || !confirmPass) { cpError.textContent = "All fields are required."; cpError.classList.remove("hidden"); return; } if (newPass !== confirmPass) { cpError.textContent = "New passwords do not match."; cpError.classList.remove("hidden"); return; } if (newPass.length < 12) { cpError.textContent = "New password must be at least 12 characters."; cpError.classList.remove("hidden"); return; } if (currentPass === newPass) { cpError.textContent = "New password must be different from the current password."; cpError.classList.remove("hidden"); return; } const btn = document.getElementById("btn-change-password"); const originalText = btn.textContent; btn.disabled = true; btn.textContent = "Re-encrypting vault…"; try { const vaultKey = VaultSession.getKey(); if (!vaultKey) { showToast("Vault is locked. Reload and unlock first.", "error"); return; } // Fetch current user email from session storage const encKeySalt = sessionStorage.getItem("enc_key_salt"); // We need the email to derive auth hashes — fetch it from the profile const profileRes = await apiFetch("/api/auth/me"); if (!profileRes) return; const profile = await profileRes.json(); const email = profile.email; // Derive both auth hashes const currentAuthHash = await Crypto.deriveAuthHash(currentPass, email); const newAuthHash = await Crypto.deriveAuthHash(newPass, email); const newEncKeySalt = Crypto.generateSalt(16); const newVaultKey = await Crypto.deriveVaultKey(newPass, newEncKeySalt); // Fetch all vault items and re-encrypt const itemsRes = await apiFetch("/api/vault"); if (!itemsRes) return; const items = await itemsRes.json(); btn.textContent = `Re-encrypting ${items.length} item(s)…`; const reEncrypted = []; for (const item of items) { const plain = await Crypto.decryptItem( vaultKey, item.enc_data, item.iv, ); const { enc_data, iv } = await Crypto.encryptItem(newVaultKey, plain); // Re-encrypt the name if it was previously encrypted. let encNamePayload = {}; if (item.enc_name && item.iv_name) { const plainName = await Crypto.decryptName( vaultKey, item.enc_name, item.iv_name, ); if (plainName) { const { enc_name, iv_name } = await Crypto.encryptName( newVaultKey, plainName, ); encNamePayload = { enc_name, iv_name }; } } reEncrypted.push({ id: item.id, enc_data, iv, ...encNamePayload }); } // Submit atomic password change const res = await apiFetch("/api/auth/change-password", { method: "POST", body: JSON.stringify({ current_auth_hash: currentAuthHash, new_auth_hash: newAuthHash, new_enc_key_salt: newEncKeySalt, items: reEncrypted, }), }); if (!res) return; const data = await res.json(); if (!res.ok) { cpError.textContent = data.error || "Password change failed."; cpError.classList.remove("hidden"); return; } showToast("Password changed. Please log in again."); setTimeout(() => redirectToLogin(), 1500); } catch (err) { cpError.textContent = "An error occurred: " + err.message; cpError.classList.remove("hidden"); console.error(err); } finally { btn.disabled = false; btn.textContent = originalText; } } // ── Account Recovery Setup ──────────────────────────────────────────────── async function loadRecoveryStatus() { try { const res = await apiFetch("/api/auth/recovery/status"); if (!res) return; const data = await res.json(); const statusEl = document.getElementById("recovery-status-text"); const actionsEl = document.getElementById("recovery-actions"); if (data.recovery_configured) { statusEl.textContent = "✅ A recovery code is configured for your account."; actionsEl.innerHTML = ''; } else { statusEl.textContent = "No recovery code set up. If you forget your master password, your vault cannot be recovered."; actionsEl.innerHTML = ''; } document .getElementById("btn-gen-recovery") ?.addEventListener("click", handleSetupRecovery); document .getElementById("btn-regen-recovery") ?.addEventListener("click", handleSetupRecovery); } catch (err) { console.error("loadRecoveryStatus:", err); } } async function handleSetupRecovery() { const vaultKey = VaultSession.getKey(); if (!vaultKey) { showToast("Vault is locked. Unlock first.", "error"); return; } try { const encKeySalt = sessionStorage.getItem("enc_key_salt"); if (!encKeySalt) { showToast("Session error. Please reload.", "error"); return; } // Generate a random 128-bit (16-byte) recovery code displayed as hex const rawBytes = window.crypto.getRandomValues(new Uint8Array(16)); const recoveryCode = Array.from(rawBytes) .map((b) => b.toString(16).padStart(2, "0")) .join(""); // Derive recovery key from the code const recoveryKeyMaterial = await window.crypto.subtle.importKey( "raw", new TextEncoder().encode(recoveryCode), "PBKDF2", false, ["deriveKey"], ); const recoveryKey = await window.crypto.subtle.deriveKey( { name: "PBKDF2", salt: new TextEncoder().encode("passkeeper-recovery"), iterations: 200_000, hash: "SHA-256", }, recoveryKeyMaterial, { name: "AES-GCM", length: 256 }, false, ["encrypt"], ); // Encrypt enc_key_salt with recovery key const iv = window.crypto.getRandomValues(new Uint8Array(12)); const ciphertext = await window.crypto.subtle.encrypt( { name: "AES-GCM", iv }, recoveryKey, new TextEncoder().encode(encKeySalt), ); function bytesToBase64(bytes) { let bin = ""; new Uint8Array(bytes).forEach((b) => (bin += String.fromCharCode(b))); return btoa(bin); } const recovery_enc_salt = bytesToBase64(ciphertext); const recovery_iv = bytesToBase64(iv); // Store on server const res = await apiFetch("/api/auth/recovery/setup", { method: "POST", body: JSON.stringify({ recovery_enc_salt, recovery_iv }), }); if (!res) return; // Display the code to the user — formatted in groups of 4 const formatted = recoveryCode.match(/.{1,4}/g).join("-"); document.getElementById("recovery-code-value").textContent = formatted; document .getElementById("recovery-code-display") .classList.remove("hidden"); document.getElementById("recovery-actions").innerHTML = ""; document.getElementById("recovery-status-text").textContent = "Your new recovery code is shown below."; } catch (err) { showToast("Recovery code generation failed: " + err.message, "error"); console.error(err); } } // ── Delete Account ──────────────────────────────────────────────────────── async function handleDeleteAccount() { const deleteError = document.getElementById("delete-error"); deleteError.classList.add("hidden"); const password = document.getElementById("delete-password").value; if (!password) { deleteError.textContent = "Please enter your master password."; deleteError.classList.remove("hidden"); return; } const btn = document.getElementById("btn-delete-confirm"); const originalText = btn.textContent; btn.disabled = true; btn.textContent = "Deleting…"; try { const profileRes = await apiFetch("/api/auth/me"); if (!profileRes) return; const profile = await profileRes.json(); const authHash = await Crypto.deriveAuthHash(password, profile.email); const res = await apiFetch("/api/auth/account", { method: "DELETE", body: JSON.stringify({ auth_hash: authHash }), }); if (!res) return; const data = await res.json(); if (!res.ok) { deleteError.textContent = data.error || "Deletion failed."; deleteError.classList.remove("hidden"); return; } // Clear all local state and redirect sessionStorage.clear(); localStorage.clear(); window.location.href = "/login"; } catch (err) { deleteError.textContent = "An error occurred: " + err.message; deleteError.classList.remove("hidden"); console.error(err); } finally { btn.disabled = false; btn.textContent = originalText; } } // ── Folder CRUD ─────────────────────────────────────────────────────────── function showNewFolderRow() { document.getElementById("new-folder-row").classList.remove("hidden"); document.getElementById("new-folder-input").focus(); } function hideNewFolderRow() { document.getElementById("new-folder-row").classList.add("hidden"); document.getElementById("new-folder-input").value = ""; } async function handleNewFolder(e) { e.preventDefault(); const name = document.getElementById("new-folder-input").value.trim(); if (!name) return; try { const res = await apiFetch("/api/folders", { method: "POST", body: JSON.stringify({ name }), }); if (!res) return; hideNewFolderRow(); await loadFolders(); showToast("Folder created"); } catch (err) { showToast("Could not create folder: " + err.message, "error"); } } async function confirmDeleteFolder(folder) { if ( !confirm( `Delete folder "${folder.name}"?\nItems will be moved to No Folder.`, ) ) return; try { const res = await apiFetch(`/api/folders/${folder.id}`, { method: "DELETE", }); if (!res) return; _items = _items.map((i) => i.folder_id === folder.id ? { ...i, folder_id: null } : i, ); if ( _activeFilter?.type === "folder" && _activeFilter.value === folder.id ) { _activeFilter = null; updateVaultTitle("All Items"); } await loadFolders(); applyCurrentFilter(); showToast("Folder deleted"); } catch (err) { showToast("Could not delete folder: " + err.message, "error"); } } // ── Filtering / Search ──────────────────────────────────────────────────── function applyCurrentFilter() { if (!_activeFilter) { renderItemList(_items); return; } if (_activeFilter.type === "itemType") renderItemList(_items.filter((i) => i.item_type === _activeFilter.value)); else if (_activeFilter.type === "folder") renderItemList(_items.filter((i) => i.folder_id === _activeFilter.value)); else if (_activeFilter.type === "tag") renderItemList( _items.filter((i) => (i.plain?.tags || []).includes(_activeFilter.value), ), ); } function filterByFolder(folderId, name) { _activeFilter = { type: "folder", value: folderId }; switchView("vault"); document .querySelectorAll(".sidebar-item") .forEach((el) => el.classList.remove("active")); document .querySelector(`[data-folder-id="${folderId}"]`) ?.classList.add("active"); updateVaultTitle(name); renderItemList(_items.filter((i) => i.folder_id === folderId)); } function handleSearch(e) { const q = e.target.value.trim().toLowerCase(); if (!q) { applyCurrentFilter(); return; } const pool = !_activeFilter ? _items : _activeFilter.type === "itemType" ? _items.filter((i) => i.item_type === _activeFilter.value) : _activeFilter.type === "folder" ? _items.filter((i) => i.folder_id === _activeFilter.value) : _activeFilter.type === "tag" ? _items.filter((i) => (i.plain?.tags || []).includes(_activeFilter.value), ) : _items; renderItemList( pool.filter((item) => { const p = item.plain || {}; return ( item.name.toLowerCase().includes(q) || (p.username || "").toLowerCase().includes(q) || (p.url || "").toLowerCase().includes(q) || (p.note_body || "").toLowerCase().includes(q) || (p.rp_id || "").toLowerCase().includes(q) || (p.notes || "").toLowerCase().includes(q) || (p.cardholder_name || "").toLowerCase().includes(q) || (p.email || "").toLowerCase().includes(q) || (p.tags || []).some((t) => t.toLowerCase().includes(q)) ); }), ); } function updateVaultTitle(text) { const el = document.getElementById("vault-title"); if (el) el.textContent = text; } // ── Item Modal ──────────────────────────────────────────────────────────── const TYPE_LABELS = { password: "🔑 Password", note: "📝 Secure Note", card: "💳 Payment Card", bank: "🏦 Bank Account", address: "🏠 Address", ssn: "🪪 Identity (SSN)", passkey: "🔐 Passkey", }; function switchModalType(type) { document.querySelectorAll(".type-fields").forEach((el) => { el.classList.toggle( "hidden", !el.dataset.forTypes.split(" ").includes(type), ); }); } function openModal(mode, item = null) { const form = document.getElementById("item-form"); form.reset(); form.dataset.mode = mode; form.dataset.itemId = item ? item.id : ""; // Always start Advanced Settings collapsed when the modal opens. const advBody = document.getElementById("adv-settings-body"); const advToggle = document.getElementById("adv-settings-toggle"); if (advBody) advBody.classList.add("hidden"); if (advToggle) { advToggle.setAttribute("aria-expanded", "false"); const chevron = advToggle.querySelector(".adv-section-chevron"); if (chevron) chevron.textContent = "\u2964"; } const folderSel = document.getElementById("field-folder"); folderSel.innerHTML = ''; _folders.forEach((f) => { const opt = document.createElement("option"); opt.value = f.id; opt.textContent = f.name; folderSel.appendChild(opt); }); if (mode === "add") { document.getElementById("type-selector-group").classList.remove("hidden"); document.getElementById("modal-title").textContent = "Add Item"; switchModalType( document.getElementById("field-type").value || "password", ); } else { document.getElementById("type-selector-group").classList.add("hidden"); const t = item.item_type || "password"; document.getElementById("modal-title").textContent = `Edit ${TYPE_LABELS[t] || "Item"}`; switchModalType(t); form.dataset.itemType = t; setVal("field-name", item.name); setVal("field-folder", item.folder_id || ""); if (item.plain) { const p = item.plain; switch (t) { case "password": setVal("field-url", p.url); setVal("field-username", p.username); setVal("field-password", p.password); setVal("field-totp-uri", p.totp_uri || ""); setVal("field-notes", p.notes); setVal("field-tags", (p.tags || []).join(", ")); // Advanced Settings document.getElementById("field-autofill").checked = p.autofill !== false; // default true document.getElementById("field-autologin").checked = !!p.autologin; document.getElementById("field-reprompt").checked = !!p.reprompt; break; case "note": setVal("field-note-body", p.note_body); break; case "card": setVal("field-cardholder", p.cardholder_name); setVal("field-card-number", p.card_number); setVal("field-expiry-month", p.expiry_month); setVal("field-expiry-year", p.expiry_year); setVal("field-cvv", p.cvv); setVal("field-notes", p.notes); break; case "bank": setVal("field-bank-name", p.bank_name); setVal("field-account-type", p.account_type); setVal("field-routing", p.routing_number); setVal("field-account-number", p.account_number); setVal("field-notes", p.notes); break; case "address": [ "first_name", "last_name", "company", "address_line", "city", "state", "zip", "country", "phone", "email", ].forEach((k) => setVal("field-" + k.replace(/_/g, "-"), p[k])); setVal("field-notes", p.notes); break; case "ssn": setVal("field-ssn-number", p.ssn_number); setVal("field-notes", p.notes); break; case "passkey": setVal("field-pk-rp-id", p.rp_id); setVal("field-pk-username", p.username); setVal("field-pk-credential-id", p.credential_id); setVal("field-pk-user-handle", p.user_handle); setVal("field-pk-device-hint", p.device_hint); setVal("field-pk-notes", p.notes); setVal("field-tags", (p.tags || []).join(", ")); break; } } } document.getElementById("item-modal").classList.add("open"); document.getElementById("field-name").focus(); // Render password history panel for password items in edit mode. const historyPanel = document.getElementById("password-history-panel"); if (historyPanel) { if (mode === "edit" && item?.item_type === "password") { const history = item.plain?.password_history || []; if (history.length) { historyPanel.classList.remove("hidden"); historyPanel.innerHTML = `
    🕐 Previous passwords (${history.length})
    ` + history.map((h) => { const date = h.changed_at ? new Date(h.changed_at).toLocaleDateString() : "Unknown date"; return `
    •••••••• ${escHtml(date)}
    `; }).join(""); historyPanel.querySelectorAll(".pw-history-reveal").forEach((btn) => { btn.addEventListener("click", () => { const masked = btn.closest(".pw-history-row").querySelector(".pw-history-masked"); if (masked.textContent === "••••••••") { masked.textContent = btn.dataset.pw; btn.textContent = "🙈"; } else { masked.textContent = "••••••••"; btn.textContent = "👁"; } }); }); historyPanel.querySelectorAll(".pw-history-restore").forEach((btn) => { btn.addEventListener("click", () => { const pwField = document.getElementById("field-password"); if (pwField) { pwField.value = btn.dataset.pw; pwField.dispatchEvent(new Event("input")); showToast("Password restored — click Save to apply"); } }); }); } else { historyPanel.classList.add("hidden"); historyPanel.innerHTML = ""; } } else { historyPanel.classList.add("hidden"); historyPanel.innerHTML = ""; } } if ( (mode === "add" && (document.getElementById("field-type").value || "password") === "password") || (mode === "edit" && (item?.item_type || "password") === "password") ) { initPasswordFieldEnhancements(); } } function setVal(id, val) { const el = document.getElementById(id); if (el) el.value = val || ""; } function getVal(id) { const el = document.getElementById(id); return el ? el.value : ""; } function closeModal(id = "item-modal") { document.getElementById(id)?.classList.remove("open"); } function buildPlainData(t) { switch (t) { case "password": return { url: getVal("field-url").trim(), username: getVal("field-username").trim(), password: getVal("field-password"), totp_uri: getVal("field-totp-uri").trim(), notes: getVal("field-notes").trim(), tags: _parseTags(getVal("field-tags")), autofill: document.getElementById("field-autofill")?.checked ?? true, autologin: !!document.getElementById("field-autologin")?.checked, reprompt: !!document.getElementById("field-reprompt")?.checked, }; case "note": return { note_body: getVal("field-note-body") }; case "card": return { cardholder_name: getVal("field-cardholder").trim(), card_number: getVal("field-card-number").trim(), expiry_month: getVal("field-expiry-month"), expiry_year: getVal("field-expiry-year").trim(), cvv: getVal("field-cvv").trim(), notes: getVal("field-notes").trim(), }; case "bank": return { bank_name: getVal("field-bank-name").trim(), account_type: getVal("field-account-type"), routing_number: getVal("field-routing").trim(), account_number: getVal("field-account-number").trim(), notes: getVal("field-notes").trim(), }; case "address": return { first_name: getVal("field-first-name").trim(), last_name: getVal("field-last-name").trim(), company: getVal("field-company").trim(), address_line: getVal("field-address-line").trim(), city: getVal("field-city").trim(), state: getVal("field-state").trim(), zip: getVal("field-zip").trim(), country: getVal("field-country").trim(), phone: getVal("field-phone").trim(), email: getVal("field-email").trim(), notes: getVal("field-notes").trim(), }; case "ssn": return { ssn_number: getVal("field-ssn-number"), notes: getVal("field-notes").trim(), }; case "passkey": return { rp_id: getVal("field-pk-rp-id").trim(), username: getVal("field-pk-username").trim(), credential_id: getVal("field-pk-credential-id").trim(), user_handle: getVal("field-pk-user-handle").trim(), device_hint: getVal("field-pk-device-hint").trim(), notes: getVal("field-pk-notes").trim(), tags: _parseTags(getVal("field-tags")), }; default: return {}; } } async function handleFormSubmit(e) { e.preventDefault(); try { const vaultKey = VaultSession.getKey(); if (!vaultKey) { closeModal(); showUnlockOverlay(); return; } const form = e.target; const mode = form.dataset.mode; const itemId = form.dataset.itemId; const name = getVal("field-name").trim(); if (!name) { showToast("Name is required", "error"); return; } const itemType = mode === "add" ? getVal("field-type") : form.dataset.itemType || "password"; const plainData = buildPlainData(itemType); // Track when the password was last changed — stored inside the encrypted // blob so the server never sees it. Used by the security dashboard to // accurately flag old passwords vs. old items that had their password changed. if (itemType === "password") { if (mode === "add") { // New item — set password_changed_at to now. plainData.password_changed_at = new Date().toISOString(); plainData.password_history = []; } else { // Edit — only update if the password field actually changed. const existingItem = _items.find((i) => i.id === parseInt(itemId)); const existingPassword = existingItem?.plain?.password ?? null; const existingChangedAt = existingItem?.plain?.password_changed_at ?? null; const existingHistory = existingItem?.plain?.password_history ?? []; if (plainData.password !== existingPassword) { // Password changed — push the old password onto history (max 5 entries). const historyEntry = { password: existingPassword, changed_at: existingChangedAt || existingItem?.created_at || new Date().toISOString(), }; const newHistory = [historyEntry, ...existingHistory].slice(0, 5); plainData.password_changed_at = new Date().toISOString(); plainData.password_history = newHistory; } else { // Password unchanged — preserve existing tracking data. if (existingChangedAt) plainData.password_changed_at = existingChangedAt; plainData.password_history = existingHistory; } } } const { enc_data, iv } = await Crypto.encryptItem(vaultKey, plainData); // Encrypt the item name client-side so it is never stored in plaintext. const { enc_name, iv_name } = await Crypto.encryptName(vaultKey, name); const folderVal = getVal("field-folder"); const payload = { name: itemType, // non-sensitive server-side label — real name is in enc_name item_type: itemType, folder_id: folderVal ? parseInt(folderVal) : null, enc_data, iv, enc_name, iv_name, }; if (mode === "add") { const res = await apiFetch("/api/vault", { method: "POST", body: JSON.stringify(payload), }); if (!res) return; showToast("Item added"); } else { const res = await apiFetch(`/api/vault/${itemId}`, { method: "PUT", body: JSON.stringify(payload), }); if (!res) return; showToast("Item updated"); } closeModal(); await loadVault(); } catch (err) { showToast("Save failed: " + err.message, "error"); } } async function confirmDeleteItem(item) { if (!confirm(`Delete "${item.name}"? This cannot be undone.`)) return; try { const res = await apiFetch(`/api/vault/${item.id}`, { method: "DELETE" }); if (!res) return; showToast("Item deleted"); await loadVault(); } catch (err) { showToast("Delete failed: " + err.message, "error"); } } /** * Toggle the "favorite" tag on a vault item. * Re-encrypts and PUTs the item in-place, then patches _items and * re-renders so the star flips instantly without a full vault reload. */ async function toggleFavorite(item) { const vaultKey = VaultSession.getKey(); if (!vaultKey) { showUnlockOverlay(); return; } if (!item.plain) { showToast("Item could not be decrypted", "error"); return; } const currentTags = item.plain.tags || []; const isFavorite = currentTags.includes("favorite"); const newTags = isFavorite ? currentTags.filter((t) => t !== "favorite") : [...currentTags, "favorite"].sort(); const updatedPlain = { ...item.plain, tags: newTags }; try { const { enc_data, iv } = await Crypto.encryptItem(vaultKey, updatedPlain); const { enc_name, iv_name } = await Crypto.encryptName(vaultKey, item.name); const payload = { name: item.item_type, item_type: item.item_type, folder_id: item.folder_id || null, enc_data, iv, enc_name, iv_name, }; const res = await apiFetch(`/api/vault/${item.id}`, { method: "PUT", body: JSON.stringify(payload), }); if (!res) return; // Patch the in-memory item so the re-render is instant. const idx = _items.findIndex((i) => i.id === item.id); if (idx !== -1) { _items[idx] = { ..._items[idx], plain: updatedPlain }; } showToast(isFavorite ? "Removed from favorites" : "Added to favorites ★"); // Refresh the sidebar tag list ("favorite" tag may appear/disappear). renderTagList(); // Re-render the current view preserving the active filter. applyCurrentFilter(); } catch (err) { showToast("Could not update favorite: " + err.message, "error"); } } // ── Logout ──────────────────────────────────────────────────────────────── async function handleLogout() { const refreshToken = localStorage.getItem("refresh_token"); await fetch("/api/auth/logout", { method: "POST", headers: { Authorization: `Bearer ${sessionStorage.getItem("access_token") || ""}`, "Content-Type": "application/json", "X-CSRFToken": csrfToken(), }, body: JSON.stringify({ refresh_token: refreshToken }), }).catch(() => {}); VaultSession.clear(); SharingSession.clear(); sessionStorage.removeItem("access_token"); localStorage.removeItem("refresh_token"); window.dispatchEvent(new CustomEvent("passkeeper:logout")); window.location.href = "/login"; } // ── Utilities ───────────────────────────────────────────────────────────── // ── TOTP (RFC 6238) ── pure Web Crypto, no external library ────────────── /** * Extract the base32 secret from either a plain base32 string or an * otpauth://totp/... URI (both are common when users scan a QR code). */ 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 (e) { return null; } } // Plain base32 secret (spaces and hyphens stripped for convenience). return uri.toUpperCase().replace(/[\s-]/g, "") || null; } /** Decode a base32 string to a Uint8Array. */ 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); } /** * Compute the current TOTP code (6 digits, 30-second window). * Returns a Promise. */ async function getTotpCode(secret) { const keyBytes = base32ToBytes(secret); if (!keyBytes.length) return null; const counter = Math.floor(Date.now() / 1000 / 30); const msg = new Uint8Array(8); // Write counter as big-endian 64-bit integer. 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"); } /** Seconds remaining in the current 30-second TOTP window. */ function totpSecondsLeft() { return 30 - (Math.floor(Date.now() / 1000) % 30); } // Track the clipboard clear timer so multiple rapid copies don't stack. let _clipboardClearTimer = null; function copyToClipboard(text, msg) { navigator.clipboard .writeText(text) .then(() => { showToast(msg); // Auto-clear clipboard after 30 seconds — industry-standard hygiene. if (_clipboardClearTimer) clearTimeout(_clipboardClearTimer); _clipboardClearTimer = setTimeout(() => { navigator.clipboard.writeText("").catch(() => {}); _clipboardClearTimer = null; }, 30_000); }) .catch(() => {}); } function escHtml(str) { return String(str) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function itemIcon(type) { return ( { password: "🔑", note: "📝", card: "💳", bank: "🏦", address: "🏠", ssn: "🪪", passkey: "🔐", }[type] || "🔑" ); } function showToast(message, type = "success") { const toast = document.getElementById("toast"); if (!toast) return; toast.textContent = message; toast.className = `toast toast-${type} show`; clearTimeout(toast._timer); toast._timer = setTimeout(() => toast.classList.remove("show"), 2500); } function showLoadingState(loading) { document .getElementById("vault-spinner") ?.classList.toggle("hidden", !loading); } // ── Unlock overlay ──────────────────────────────────────────────────────── function showUnlockOverlay() { document.getElementById("unlock-overlay").classList.add("open"); document.getElementById("unlock-password").focus(); } async function handleUnlock(e) { e.preventDefault(); const password = document.getElementById("unlock-password").value; const enc_key_salt = sessionStorage.getItem("enc_key_salt"); const btn = e.target.querySelector('[type="submit"]'); const errEl = document.getElementById("unlock-error"); if (!enc_key_salt) { redirectToLogin(); return; } btn.disabled = true; btn.textContent = "Unlocking…"; errEl.classList.add("hidden"); try { const vaultKey = await Crypto.deriveVaultKey(password, enc_key_salt); VaultSession.setKey(vaultKey); document.getElementById("unlock-overlay").classList.remove("open"); await loadFolders(); await loadVault(); } catch { errEl.classList.remove("hidden"); } finally { btn.disabled = false; btn.textContent = "Unlock"; document.getElementById("unlock-password").value = ""; } } // ── Tabs ────────────────────────────────────────────────────────────────── function initTabs(container) { const tabs = container.querySelectorAll(".tab-btn"); tabs.forEach((btn) => { btn.addEventListener("click", () => { tabs.forEach((t) => t.classList.remove("active")); btn.classList.add("active"); const target = btn.dataset.tab; container.querySelectorAll(".tab-pane").forEach((pane) => { pane.classList.toggle("hidden", !pane.id.endsWith(target)); }); }); }); } // ── Init ────────────────────────────────────────────────────────────────── async function init() { if (!sessionStorage.getItem("access_token")) { // Give the extension bridge up to 800 ms to inject a session before redirecting await new Promise((resolve) => { const timer = setTimeout(resolve, 800); window.addEventListener( "passkeeper:ext-login", () => { clearTimeout(timer); resolve(); }, { once: true }, ); }); // Still no token — try a silent refresh using the persisted refresh_token // before giving up and redirecting. This handles hard-refresh / new-tab scenarios. if (!sessionStorage.getItem("access_token")) { const refreshed = await tryRefreshToken(); if (!refreshed) { window.location.href = "/login"; return; } } } // Vault document .getElementById("unlock-form") ?.addEventListener("submit", handleUnlock); document .getElementById("btn-new-folder") ?.addEventListener("click", showNewFolderRow); document .getElementById("btn-cancel-folder") ?.addEventListener("click", hideNewFolderRow); document .getElementById("new-folder-form") ?.addEventListener("submit", handleNewFolder); document .getElementById("search-input") ?.addEventListener("input", handleSearch); document .getElementById("btn-add-item") ?.addEventListener("click", () => openModal("add")); document .getElementById("btn-close-modal") ?.addEventListener("click", () => closeModal()); document .getElementById("btn-cancel-modal") ?.addEventListener("click", () => closeModal()); document .getElementById("item-form") ?.addEventListener("submit", handleFormSubmit); document .getElementById("btn-logout") ?.addEventListener("click", handleLogout); document.getElementById("sort-select")?.addEventListener("change", (e) => { _sortOrder = e.target.value; applyCurrentFilter(); }); document.getElementById("field-type")?.addEventListener("change", (e) => { switchModalType(e.target.value); if (e.target.value === "password") initPasswordFieldEnhancements(); }); document .getElementById("btn-toggle-pass") ?.addEventListener("click", () => { const el = document.getElementById("field-password"); el.type = el.type === "password" ? "text" : "password"; }); document.getElementById("btn-toggle-ssn")?.addEventListener("click", () => { const el = document.getElementById("field-ssn-number"); el.type = el.type === "password" ? "text" : "password"; }); // Live tag preview in the item modal. document.getElementById("field-tags")?.addEventListener("input", (e) => { const preview = document.getElementById("field-tags-preview"); if (!preview) return; const tags = _parseTags(e.target.value); preview.innerHTML = tags .map((t) => `${escHtml(t)}`) .join(""); }); // Advanced Settings collapsible toggle document .getElementById("adv-settings-toggle") ?.addEventListener("click", () => { const body = document.getElementById("adv-settings-body"); const btn = document.getElementById("adv-settings-toggle"); const chevron = btn.querySelector(".adv-section-chevron"); const isOpen = !body.classList.contains("hidden"); body.classList.toggle("hidden", isOpen); btn.setAttribute("aria-expanded", String(!isOpen)); chevron.textContent = isOpen ? "\u2964" : "\u2963"; }); // Sidebar navigation document.getElementById("sidebar-all")?.addEventListener("click", () => { _activeFilter = null; switchView("vault"); document .querySelectorAll(".sidebar-item") .forEach((el) => el.classList.remove("active")); document.getElementById("sidebar-all").classList.add("active"); updateVaultTitle("All Items"); renderItemList(_items); }); document.getElementById("sidebar-favorites")?.addEventListener("click", () => { _activeFilter = { type: "tag", value: "favorite" }; switchView("vault"); document .querySelectorAll(".sidebar-item") .forEach((el) => el.classList.remove("active")); document.getElementById("sidebar-favorites").classList.add("active"); updateVaultTitle("⭐ Favorites"); renderItemList(_items.filter((i) => (i.plain?.tags || []).includes("favorite"))); }); document .getElementById("sidebar-security") ?.addEventListener("click", () => switchView("security")); document .getElementById("sidebar-sharing") ?.addEventListener("click", () => switchView("sharing")); document .getElementById("sidebar-emergency") ?.addEventListener("click", () => switchView("emergency")); document .getElementById("sidebar-import-export") ?.addEventListener("click", () => switchView("import-export")); document .getElementById("sidebar-generator") ?.addEventListener("click", () => openPasswordGeneratorModal()); document.querySelectorAll("[data-type-filter]").forEach((el) => { el.addEventListener("click", () => { const type = el.dataset.typeFilter; const labels = { password: "Passwords", note: "Secure Notes", card: "Payment Cards", bank: "Bank Accounts", address: "Addresses", ssn: "Identities", passkey: "Passkeys", }; _activeFilter = { type: "itemType", value: type }; switchView("vault"); document .querySelectorAll(".sidebar-item") .forEach((s) => s.classList.remove("active")); el.classList.add("active"); updateVaultTitle(labels[type] || type); renderItemList(_items.filter((i) => i.item_type === type)); }); }); document.getElementById("item-modal")?.addEventListener("click", (e) => { /* backdrop click intentionally disabled — modal closes only via Cancel or Save */ }); document.addEventListener("keydown", (e) => { if (e.key === "Escape") { closeModal("share-modal"); closeModal("emergency-modal"); closeModal("settings-modal"); closeModal("detail-modal"); closeModal("gen-modal"); } }); // Detail modal (shared item view) const closeDetailModal = () => closeModal("detail-modal"); document .getElementById("btn-close-detail-modal") ?.addEventListener("click", closeDetailModal); document .getElementById("btn-detail-done") ?.addEventListener("click", closeDetailModal); document.getElementById("detail-modal")?.addEventListener("click", (e) => { if (e.target.id === "detail-modal") closeDetailModal(); }); document .getElementById("field-card-number") ?.addEventListener("input", (e) => { let v = e.target.value.replace(/\D/g, "").slice(0, 16); e.target.value = v.replace(/(.{4})/g, "$1 ").trim(); }); // Settings modal document .getElementById("btn-settings") ?.addEventListener("click", openSettingsModal); document .getElementById("btn-close-settings") ?.addEventListener("click", () => closeModal("settings-modal")); // Web-app session timeout setting. document .getElementById("web-idle-select") ?.addEventListener("change", (e) => { const mins = parseInt(e.target.value, 10); localStorage.setItem(WEB_IDLE_KEY, mins); _resetWebIdleTimer(); const label = mins === 0 ? "never" : mins + " min"; showToast("Auto-lock set to " + label + "."); console.log("[PassKeeper] Web idle timeout set to", label); }); document .getElementById("settings-modal") ?.addEventListener("click", (e) => { if (e.target.id === "settings-modal") closeModal("settings-modal"); }); document .getElementById("btn-mfa-confirm") ?.addEventListener("click", handleMfaConfirm); document .getElementById("btn-mfa-cancel-setup") ?.addEventListener("click", () => { document.getElementById("mfa-setup-area").classList.add("hidden"); loadMfaStatus(); }); document .getElementById("btn-mfa-disable-confirm") ?.addEventListener("click", handleMfaDisableConfirm); document .getElementById("btn-mfa-disable-cancel") ?.addEventListener("click", () => { document.getElementById("mfa-disable-area").classList.add("hidden"); loadMfaStatus(); }); document .getElementById("btn-change-password") ?.addEventListener("click", handleChangePassword); document .getElementById("btn-delete-account") ?.addEventListener("click", () => { document.getElementById("btn-delete-account").classList.add("hidden"); document .getElementById("delete-confirm-area") .classList.remove("hidden"); document.getElementById("delete-password").focus(); }); document .getElementById("btn-delete-cancel") ?.addEventListener("click", () => { document.getElementById("delete-confirm-area").classList.add("hidden"); document .getElementById("btn-delete-account") .classList.remove("hidden"); document.getElementById("delete-password").value = ""; document.getElementById("delete-error").classList.add("hidden"); }); document .getElementById("btn-delete-confirm") ?.addEventListener("click", handleDeleteAccount); document .getElementById("btn-copy-recovery-code") ?.addEventListener("click", () => { const code = document.getElementById("recovery-code-value").textContent; navigator.clipboard .writeText(code) .then(() => showToast("Recovery code copied")); }); document .getElementById("btn-recovery-done") ?.addEventListener("click", () => { document .getElementById("recovery-code-display") .classList.add("hidden"); loadRecoveryStatus(); }); // Share modal document .getElementById("btn-share-item") ?.addEventListener("click", openShareModal); document .getElementById("btn-close-share-modal") ?.addEventListener("click", () => closeModal("share-modal")); document .getElementById("btn-cancel-share") ?.addEventListener("click", () => closeModal("share-modal")); document.getElementById("share-modal")?.addEventListener("click", (e) => { if (e.target.id === "share-modal") closeModal("share-modal"); }); document .getElementById("share-form") ?.addEventListener("submit", handleShareFormSubmit); document .getElementById("btn-setup-keys-from-sharing") ?.addEventListener("click", () => { closeModal("share-modal"); openSettingsModal(); }); // Emergency modal document .getElementById("btn-add-emergency") ?.addEventListener("click", () => { document.getElementById("emergency-error").classList.add("hidden"); document.getElementById("emergency-modal").classList.add("open"); }); document .getElementById("btn-close-emergency-modal") ?.addEventListener("click", () => closeModal("emergency-modal")); document .getElementById("btn-cancel-emergency") ?.addEventListener("click", () => closeModal("emergency-modal")); document .getElementById("emergency-modal") ?.addEventListener("click", (e) => { if (e.target.id === "emergency-modal") closeModal("emergency-modal"); }); document .getElementById("emergency-form") ?.addEventListener("submit", handleEmergencyFormSubmit); // Tabs document.querySelectorAll(".panel-tabs").forEach((c) => initTabs(c)); // Sidebar collapse toggle — desktop only (mobile uses hamburger instead) const sidebar = document.getElementById("sidebar"); const toggleBtn = document.getElementById("btn-sidebar-toggle"); if ( sidebar && toggleBtn && document.body.classList.contains("is-desktop") ) { const STORAGE_KEY = "sidebar_collapsed"; if (localStorage.getItem(STORAGE_KEY) === "1") { sidebar.classList.add("collapsed"); toggleBtn.setAttribute("aria-label", "Expand sidebar"); toggleBtn.setAttribute("title", "Expand sidebar"); } toggleBtn.addEventListener("click", () => { const isCollapsed = sidebar.classList.toggle("collapsed"); localStorage.setItem(STORAGE_KEY, isCollapsed ? "1" : "0"); toggleBtn.setAttribute( "aria-label", isCollapsed ? "Expand sidebar" : "Collapse sidebar", ); toggleBtn.setAttribute( "title", isCollapsed ? "Expand sidebar" : "Collapse sidebar", ); }); } // Mobile sidebar: hamburger opens overlay, backdrop/item-click closes it const backdrop = document.getElementById("sidebar-backdrop"); const mobileMenuBtn = document.getElementById("btn-mobile-menu"); function openMobileSidebar() { sidebar?.classList.add("mobile-open"); backdrop?.classList.add("open"); document.documentElement.classList.add("sidebar-open"); } function closeMobileSidebar() { sidebar?.classList.remove("mobile-open"); backdrop?.classList.remove("open"); document.documentElement.classList.remove("sidebar-open"); } mobileMenuBtn?.addEventListener("click", openMobileSidebar); backdrop?.addEventListener("click", closeMobileSidebar); backdrop?.addEventListener("touchstart", closeMobileSidebar, { passive: true, }); // Close mobile sidebar when a nav item is tapped (mobile only) document.querySelectorAll(".sidebar-item").forEach((item) => { item.addEventListener("click", () => { if (document.body.classList.contains("is-mobile")) closeMobileSidebar(); }); }); // Escape key closes the sidebar document.addEventListener("keydown", (e) => { if (e.key === "Escape" && sidebar?.classList.contains("mobile-open")) closeMobileSidebar(); }); // Mobile FAB mirrors the desktop add-item button document .getElementById("btn-add-item-mobile") ?.addEventListener("click", () => { document.getElementById("btn-add-item")?.click(); }); // ── Browser history / back-button support ───────────────────────────────── const _hashToView = (hash) => { const v = (hash || "").replace("#", "").trim(); const valid = [ "vault", "security", "sharing", "emergency", "import-export", ]; return valid.includes(v) ? v : "vault"; }; const _initialView = _hashToView(window.location.hash); history.replaceState({ view: _initialView }, "", window.location.href); window.addEventListener("popstate", (e) => { const view = e.state?.view || _hashToView(window.location.hash); switchView(view, { pushState: false }); }); // Start web-app inactivity tracking. _startWebIdleTracking(); _initBulkToolbar(); if (!VaultSession.getKey()) { showUnlockOverlay(); } else { loadVault(); // Restore view from hash after vault loads (avoids rendering before decrypt). if (_initialView !== "vault") switchView(_initialView, { pushState: false }); } } async function handleEmergencyFormSubmit(e) { e.preventDefault(); const errEl = document.getElementById("emergency-error"); errEl.classList.add("hidden"); const email = document .getElementById("emergency-email") .value.trim() .toLowerCase(); const waitDays = document.getElementById("emergency-wait-days").value; if (!email) { errEl.textContent = "Email is required."; errEl.classList.remove("hidden"); return; } try { const res = await apiFetch("/api/emergency", { method: "POST", body: JSON.stringify({ grantee_email: email, wait_days: parseInt(waitDays), }), }); if (!res) return; closeModal("emergency-modal"); showToast("Invitation sent"); loadEmergencyView(); } catch (err) { errEl.textContent = err.message; errEl.classList.remove("hidden"); } } return { init, // Exposed for inline onclick on the sidebar li — ensures the click reaches // switchView regardless of addEventListener binding order or timing. switchToImportExport: () => switchView("import-export"), }; })(); /** * Mobile/desktop mode detection. * * Sets body.is-mobile or body.is-desktop BEFORE first paint so CSS * scoped to those classes takes effect without any flash or transition. * Uses matchMedia (same source of truth as CSS media queries) rather * than window.innerWidth (unreliable on some Android Chrome builds). * * MOBILE_BP must match the CSS breakpoint. 768px covers all phones and * iPad Mini portrait (744px). iPad Air portrait (820px) and larger get * the desktop sidebar layout. */ (function initLayout() { const MOBILE_BP = 768; const mq = window.matchMedia(`(max-width: ${MOBILE_BP}px)`); function applyMode(isMobile) { document.body.classList.toggle("is-mobile", isMobile); document.body.classList.toggle("is-desktop", !isMobile); } // Set immediately — body class is applied before CSS is rendered applyMode(mq.matches); // Re-apply on orientation change / window resize (e.g. split-screen, rotation) mq.addEventListener("change", (e) => { applyMode(e.matches); // If switching to desktop, ensure mobile sidebar is closed if (!e.matches) { document.getElementById("sidebar")?.classList.remove("mobile-open"); document.getElementById("sidebar-backdrop")?.classList.remove("open"); document.documentElement.classList.remove("sidebar-open"); } }); })(); /** * Viewport height fix for Chrome on Android. * * Chrome's 100vh includes the address bar height; window.innerHeight does not. * Setting --vh from innerHeight and using calc(var(--vh,1vh)*100) in CSS * gives the true usable viewport height on all browsers. */ (function setViewportHeight() { function update() { document.documentElement.style.setProperty( "--vh", `${window.innerHeight * 0.01}px`, ); } update(); window.addEventListener("resize", update, { passive: true }); window.addEventListener("orientationchange", () => setTimeout(update, 100), { passive: true, }); if (window.ResizeObserver) { new ResizeObserver(update).observe(document.documentElement); } })(); document.addEventListener("DOMContentLoaded", Vault.init);