/** * 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 _sharedItems = []; // Accepted shared items from other users, decrypted let _currentView = "vault"; let _vaultLoaded = false; // ── 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", ]); // Fields that are internal metadata and should never be shown in the detail // view or shared with recipients. const INTERNAL_PLAIN_FIELDS = new Set([ "password_history", "password_changed_at", "autofill", "autologin", "reprompt", ]); /** * Strip internal/owner-only fields from a plain object before encrypting it * for a recipient. Recipients should only see credential data, not owner * preferences or history. */ function prepareSharePlain(plain) { const out = {}; for (const [k, v] of Object.entries(plain)) { if (!INTERNAL_PLAIN_FIELDS.has(k)) out[k] = v; } return out; } // ── 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 }; } }), ); _vaultLoaded = 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(); // Load accepted shared items asynchronously so own items render first, // then re-render once shared items are decrypted. loadSharedItemsForVault().then(() => applyCurrentFilter()); } 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"); } } // ── Sharing session + shared-item vault integration ─────────────────────── /** * Load the user's ECDH private key into SharingSession if not already loaded. * Called before any operation that needs to decrypt shared items. */ async function ensureSharingSession() { if (SharingSession.isReady()) return true; const vaultKey = VaultSession.getKey(); if (!vaultKey) return false; try { const res = await apiFetch("/api/sharing/keys"); if (!res) return false; const data = await res.json(); if (!data.keys_setup) return false; const privKey = await SharingCrypto.decryptPrivateKey( vaultKey, data.private_key_enc, data.private_key_iv, ); SharingSession.setKey(privKey); return true; } catch { return false; } } /** * Fetch and decrypt all accepted shared items, populating _sharedItems. * Runs asynchronously after the vault loads so own items appear first. */ async function loadSharedItemsForVault() { const ready = await ensureSharingSession(); if (!ready) { _sharedItems = []; return; } try { const res = await apiFetch("/api/sharing/inbox"); if (!res) { _sharedItems = []; return; } const inbox = await res.json(); const accepted = inbox.filter((s) => s.accepted && s.owner_public_key); const results = await Promise.all( accepted.map(async (s) => { try { const ownerPub = await SharingCrypto.importPublicKey(s.owner_public_key); const sharedKey = await SharingCrypto.deriveSharedKey( SharingSession.getKey(), ownerPub, ); const plain = await SharingCrypto.decryptShare(sharedKey, s.enc_data, s.iv); let displayName = s.item_name; if (s.enc_name && s.iv_name) { const dec = await SharingCrypto.decryptName(sharedKey, s.enc_name, s.iv_name); if (dec) displayName = dec; } return { id: `shared-${s.id}`, _shareId: s.id, _sharedFrom: s.owner_email, _shared: true, item_type: s.item_type, name: displayName, folder_id: null, created_at: s.created_at, enc_data: s.enc_data, iv: s.iv, plain, }; } catch { return null; } }), ); _sharedItems = results.filter(Boolean); } catch (err) { console.error("[PassKeeper] loadSharedItemsForVault:", err); _sharedItems = []; } } /** * After editing a vault item, re-encrypt its data for every accepted share * so recipients always see the latest version. * Returns an array suitable for the shared_updates field of PUT /api/vault/. */ async function buildSharedUpdates(itemId, plainData, itemName) { // Ensure the owner's ECDH private key is loaded before attempting re-encryption. // The session may not be ready yet if the user edits before loadSharedItemsForVault // completes (async race on first load). await ensureSharingSession(); if (!SharingSession.isReady()) return []; try { const res = await apiFetch("/api/sharing"); if (!res) return []; const outgoing = await res.json(); const mine = outgoing.filter( (s) => s.item_id === parseInt(itemId) && s.accepted && s.recipient_public_key, ); if (!mine.length) return []; return Promise.all( mine.map(async (s) => { const recipPub = await SharingCrypto.importPublicKey(s.recipient_public_key); const sharedKey = await SharingCrypto.deriveSharedKey( SharingSession.getKey(), recipPub, ); const { enc_data, iv } = await SharingCrypto.encryptForShare(sharedKey, prepareSharePlain(plainData)); const { enc_name, iv_name } = await SharingCrypto.encryptName(sharedKey, itemName); return { share_id: s.id, enc_data, iv, enc_name, iv_name }; }), ); } catch (err) { console.error("[PassKeeper] buildSharedUpdates:", err); return []; } } // ── 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); applyCurrentFilter(); }); 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 SHARED_GROUP = "🔗 Shared with me"; const sorted = getSortedItems(items); const groups = {}; sorted.forEach((item) => { const key = item._shared ? SHARED_GROUP : 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), SHARED_GROUP].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)), ); } }); } // ── Helper: password strength label & CSS class ────────────────────────── function _passwordStrength(password) { if (!password) return null; const charTypes = [/[A-Z]/, /[a-z]/, /[0-9]/, /[^A-Za-z0-9]/].filter( (r) => r.test(password), ).length; if (password.length < 8 || charTypes < 2) return { label: "Weak", cls: "strength-weak" }; if (password.length < 12 || charTypes < 3) return { label: "Medium", cls: "strength-medium" }; return { label: "Strong", cls: "strength-strong" }; } // ── Helper: initials avatar for a vault item name ────────────────────────── function _itemInitials(name) { const words = name.trim().split(/\s+/); if (words.length >= 2) return (words[0][0] + words[1][0]).toUpperCase(); return name.slice(0, 2).toUpperCase(); } function _createSharedItemElement(item) { const li = document.createElement("li"); li.className = "vault-item vault-item--shared"; li.dataset.id = item.id; const p = item.plain || {}; let subText = ""; switch (item.item_type) { case "password": subText = p.username || p.url || ""; break; case "note": subText = (p.note_body || "").slice(0, 60); break; case "card": subText = p.card_number ? "•••• " + String(p.card_number).replace(/\s/g, "").slice(-4) : ""; break; case "bank": subText = p.bank_name || ""; break; case "address": subText = [p.first_name, p.last_name].filter(Boolean).join(" "); break; case "ssn": subText = "•••-••-••••"; break; case "passkey": subText = [p.username, p.rp_id ? `@ ${p.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" && p.url; li.innerHTML = `
    ${escHtml(item.name)} ${escHtml(subText)} From: ${escHtml(item._sharedFrom)}
    ${showLaunch ? `` : ""} ${showCopyUser ? `` : ""} ${showCopyPass ? `` : ""}
    `; if (showLaunch) { li.querySelector('[data-action="launch"]').addEventListener("click", (e) => { e.stopPropagation(); let url = p.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(p.username || "", "Username copied"); }); } if (showCopyPass) { li.querySelector('[data-action="copy-pass"]').addEventListener("click", (e) => { e.stopPropagation(); const secret = item.item_type === "password" ? p.password : item.item_type === "card" ? p.cvv : item.item_type === "bank" ? p.account_number : p.ssn_number; copyToClipboard(secret || "", "Copied to clipboard"); }); } async function openFreshSharedDetail() { // Re-fetch shared items so the grantee always sees the owner's latest // version, not a stale in-memory snapshot. await loadSharedItemsForVault(); const fresh = _sharedItems.find((si) => si._shareId === item._shareId); const target = fresh || item; showSharedItemDetails(target.name, target.item_type, target.plain); } li.querySelector('[data-action="view"]').addEventListener("click", (e) => { e.stopPropagation(); openFreshSharedDetail(); }); li.addEventListener("click", () => openFreshSharedDetail()); return li; } function createItemElement(item) { // ── Shared-item (read-only) card ────────────────────────────────────────── if (item._shared) return _createSharedItemElement(item); const li = document.createElement("li"); li.className = "vault-item"; li.dataset.id = item.id; 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(""); // Strength pill — only for password type const strength = item.item_type === "password" ? _passwordStrength(item.plain?.password) : null; const strengthPill = strength ? `${strength.label}` : ""; // Initials avatar const initials = _itemInitials(item.name); li.innerHTML = `
    ${escHtml(item.name)} ${escHtml(subText)} ${tagBadges ? `
    ${tagBadges}
    ` : ""} ${showTotp ? `······` : ""} ${strengthPill}
    ${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) { // Stop the click from bubbling to the li (which would open the modal). checkbox.addEventListener("click", (e) => e.stopPropagation()); 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", (e) => { if (_selectMode) { // Toggle the checkbox when the row itself is clicked (not the checkbox). const cb = li.querySelector(".item-checkbox"); if (cb && e.target !== cb) { cb.checked = !cb.checked; cb.dispatchEvent(new Event("change")); } return; } 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`); // Log the export server-side for the audit trail. apiFetch("/api/vault/audit-export", { method: "POST", body: JSON.stringify({ item_ids: selected.map((i) => i.id) }), }).catch(() => {}); // best-effort — don't block on this _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 (first open), wait for it then re-render. // Do NOT reload if the vault was already fetched — that would loop forever // for users with an empty vault. if (!_items.length && VaultSession.getKey() && !_vaultLoaded) { 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"; // ── Build new summary: circular ring + title + progress bars ──────────── const strongPct = Math.round(((total - weak.length) / total) * 100); const uniqueIds = new Set(reused.map((i) => i.id)); const reusedCount = uniqueIds.size; const uniquePct = Math.round(((total - reusedCount) / total) * 100); // 2FA pct: items with totp stored vs all password items const totpCount = pwItems.filter((i) => !!extractTotpSecret(i.plain?.totp_uri), ).length; const twoFAPct = Math.round((totpCount / total) * 100); // SVG ring math: r=42, circumference = 2*π*42 ≈ 264 const CIRC = 264; const ringFill = Math.round((score / 100) * CIRC); const ringColor = score >= 80 ? "#2e7d32" : score >= 50 ? "#e65100" : "#c62828"; const scoreLabel = score >= 80 ? "Good — keep improving" : score >= 50 ? "Fair — some issues to fix" : "Needs attention"; const scoreDesc = score >= 80 ? `Your vault is mostly secure. ${weak.length + reusedCount} issue${weak.length + reusedCount !== 1 ? "s" : ""} need attention.` : score >= 50 ? "Several passwords need your attention." : "Multiple security issues detected."; const makeBar = (label, pct, color) => `
    ${label}
    ${pct}%
    `; summaryEl.innerHTML = `
    ${score}
    ${escHtml(scoreLabel)}
    ${escHtml(scoreDesc)}
    ${makeBar("Strong passwords", strongPct, "#2e7d32")} ${makeBar("Unique passwords", uniquePct, "#1a73e8")} ${makeBar("2FA enabled", twoFAPct, "#e65100")}
    `; sectionsEl.innerHTML = ""; // ── Section card builder ────────────────────────────────────────────────── // Returns a div card element. renderItem(i) returns an HTML string for each li. const makeSection = (title, icon, iconBg, items, desc, renderItem) => { if (!items.length) return null; const sec = document.createElement("div"); sec.className = "sec-section"; const defaultRender = (i) => `
  • ${icon} ${escHtml(i.name)}
  • `; const renderFn = renderItem || defaultRender; sec.innerHTML = `
    ${icon}
    ${escHtml(title)}
    ${escHtml(items.length)} ${escHtml(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); } }); }); return sec; }; // ── Build section cards ─────────────────────────────────────────────────── const noTotp = pwItems.filter( (i) => i.plain?.url && !extractTotpSecret(i.plain?.totp_uri), ); // Sections rendered in pairs into a 2-col grid const sectionDefs = [ // HIBP section will be inserted asynchronously — placeholder first null, makeSection( "Weak Passwords", "⚠️", "#fff3cd", weak, "password(s) need strengthening", (i) => { const s = _passwordStrength(i.plain?.password); return `
  • 🔑 ${escHtml(i.name)} ${s ? s.label : "Weak"}
  • `; }, ), makeSection( "Reused Passwords", "♻️", "#e3f2fd", [...new Set(reused.map((i) => i.id))] .map((id) => _items.find((i) => i.id === id)) .filter(Boolean), "password(s) reused across sites", ), makeSection( "Old Passwords", "🕐", "#f3e8ff", old, "password(s) not changed in 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)}
  • `; }, ), makeSection( "No 2FA Saved", "🔓", "#e8f5e9", noTotp, "account(s) may support 2FA but none stored", ), ].filter((s) => s !== null); // Render all non-HIBP sections into grid const grid = document.createElement("div"); grid.className = "sec-sections-grid"; sectionDefs.forEach((s) => grid.appendChild(s)); sectionsEl.appendChild(grid); // ── HaveIBeenPwned breach check ────────────────────────────────────────── const hibpCard = document.createElement("div"); hibpCard.className = "sec-section sec-section-hibp-loading"; hibpCard.innerHTML = `
    🚨
    Checking for known breaches…
    Querying HaveIBeenPwned (k-anonymity — your passwords are never sent).
    `; sectionsEl.insertBefore(hibpCard, grid); let hibpResults; if (_healthCache?.hibpResults) { hibpResults = _healthCache.hibpResults; } else { 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) { hibpCard.innerHTML = `
    No known breaches
    None of your passwords appeared in known data breaches (via HaveIBeenPwned).
    `; } else { hibpCard.innerHTML = `
    🚨
    Breached Passwords
    ${breached.length} password${breached.length !== 1 ? "s" : ""} found in data breaches
    `; hibpCard.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); } }); }); } hibpCard.classList.remove("sec-section-hibp-loading"); } // ── 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) { showToast("Could not load shared inbox: " + err.message, "error"); } } 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 `
  • `; }, ) .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 `
  • ${ !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 { // Always fetch fresh share data from the server so the grantee sees // the owner's latest version, not the ciphertext that was baked into // the button's data-* attributes when the tab was last rendered. const shareId = parseInt(btn.dataset.viewShare); const inboxRes = await apiFetch("/api/sharing/inbox"); if (!inboxRes) return; const inbox = await inboxRes.json(); const freshShare = inbox.find((s) => s.id === shareId); if (!freshShare || !freshShare.owner_public_key) { showToast("Share not found or owner key unavailable.", "error"); return; } const ownerPubKey = await SharingCrypto.importPublicKey( freshShare.owner_public_key, ); const sharedKey = await SharingCrypto.deriveSharedKey( SharingSession.getKey(), ownerPubKey, ); const plain = await SharingCrypto.decryptShare( sharedKey, freshShare.enc_data, freshShare.iv, ); // Decrypt the item name if an encrypted version is available. // Falls back to the non-sensitive label for legacy shares. let displayName = freshShare.item_name; if (freshShare.enc_name && freshShare.iv_name) { const decrypted = await SharingCrypto.decryptName( sharedKey, freshShare.enc_name, freshShare.iv_name, ); if (decrypted) displayName = decrypted; } showSharedItemDetails(displayName, freshShare.item_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) { const DATE_FIELDS = new Set(["password_changed_at"]); container.innerHTML = Object.entries(plain) .filter(([k, v]) => { if (INTERNAL_PLAIN_FIELDS.has(k)) return false; // strip internal metadata if (k === "tags") return Array.isArray(v) && v.length > 0; return !!v; }) .map(([k, v]) => { const label = FIELD_LABELS[k] || k.replace(/_/g, " "); // Tags → badge chips, no copy button if (k === "tags") { const badges = (v || []) .map((t) => `${escHtml(t)}`) .join(" "); return `
    ${escHtml(label)}
    ${badges}
    `; } // Dates → human-readable let val = String(v); if (DATE_FIELDS.has(k)) { try { val = new Date(v).toLocaleString(); } catch { /* keep raw */ } } 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, prepareSharePlain(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", ); } } /** * Whole days left before a pending emergency request unlocks. Mirrors the * server's wait_elapsed calculation in EmergencyAccess.wait_elapsed. */ function _emDaysRemaining(g) { if (!g.request_initiated_at) return g.wait_days; const elapsedMs = Date.now() - new Date(g.request_initiated_at).getTime(); return Math.max(0, Math.ceil(g.wait_days - elapsedMs / 86400000)); } 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") { // The wait period is the only thing standing between a request and // the grantee reading the vault, so make the countdown explicit // rather than showing a bare status word. const waitInfo = g.wait_elapsed ? "⚠ Wait elapsed — access is available now" : `⏳ Unlocks in ${_emDaysRemaining(g)} day(s)`; actions = `${waitInfo} `; } // Retrieval is not blocked after the wait elapses, so the grantor's // signal that it happened is this badge plus their audit log. const retrieved = g.vault_retrieval_count ? `⚠ Vault retrieved ${ g.vault_retrieval_count }× · first on ${new Date( g.vault_retrieved_at, ).toLocaleDateString()} — remove this grant if unexpected` : ""; return `
  • `; }) .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 `
  • `; }) .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 = []; const failedIds = []; for (const item of items) { let plain; try { plain = await Crypto.decryptItem(vaultKey, item.enc_data, item.iv); } catch { // Cannot re-encrypt what we cannot read. Collect and abort below — // rotating the key regardless would orphan this item permanently. failedIds.push(item.id); continue; } 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 }); } // Refuse to rotate unless every item was re-encrypted. Unlike recovery // there is no lockout risk in stopping here — the current password keeps // working — so this fails closed with no partial-completion escape hatch. if (failedIds.length || reEncrypted.length !== items.length) { cpError.textContent = `Password not changed: ${failedIds.length || items.length - reEncrypted.length} of ` + `${items.length} item(s) could not be re-encrypted. Continuing would ` + `permanently lock them. Reload the vault and try again — if this ` + `persists, export your vault before retrying.`; cpError.classList.remove("hidden"); return; } // Re-encrypt sharing private key with new vault key so sharing stays functional. // The private key is stored as AES-GCM ciphertext on the server; rotating the // vault key without re-encrypting it would leave it permanently unreadable. let sharingKeyPayload = {}; try { const sharingRes = await apiFetch("/api/sharing/keys"); if (sharingRes && sharingRes.ok) { const sharingData = await sharingRes.json(); if ( sharingData.keys_setup && sharingData.private_key_enc && sharingData.private_key_iv ) { const b64ToArr = (b64) => Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); const arrToB64 = (arr) => btoa(String.fromCharCode(...new Uint8Array(arr))); const privKeyBytes = await window.crypto.subtle.decrypt( { name: "AES-GCM", iv: b64ToArr(sharingData.private_key_iv), }, vaultKey, b64ToArr(sharingData.private_key_enc), ); const newPrivIv = window.crypto.getRandomValues(new Uint8Array(12)); const reEncPriv = await window.crypto.subtle.encrypt( { name: "AES-GCM", iv: newPrivIv }, newVaultKey, privKeyBytes, ); sharingKeyPayload = { sharing_private_key_enc: arrToB64(reEncPriv), sharing_private_key_iv: arrToB64(newPrivIv), }; } } } catch (err) { console.warn("[PassKeeper] Could not re-encrypt sharing key:", err); // Non-fatal — password change continues; user can regenerate sharing keys } // 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, ...sharingKeyPayload, }), }); 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) { // The server runs the same completeness check and answers 409 if the // payload was short; show its message rather than burying it. cpError.textContent = err.status === 409 ? err.message : "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 && data.recovery_is_legacy) { // Pre-verifier recovery code: its challenge proof is still keyed on // enc_key_salt, which the server discloses at login. Regenerating // rebinds the proof to a value derived from the recovery code alone. statusEl.textContent = "⚠ Your recovery code uses an outdated verification method. " + "Generate a new one to secure it — your current code keeps working until you do."; actionsEl.innerHTML = ''; } else 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; } // Use the user's email as a per-user PBKDF2 salt so precomputed tables // cannot attack multiple users at once. const meRes = await apiFetch("/api/auth/me"); if (!meRes || !meRes.ok) { showToast("Session error. Please reload.", "error"); return; } const meData = await meRes.json(); const userEmail = meData.email; // 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 using the user's email as PBKDF2 salt 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(userEmail), 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); // Derive the recovery verifier — the HMAC key the server uses for the // recovery challenge-response. It comes from the recovery code alone and // is used for nothing else, so it never doubles as key material. // Must stay byte-identical to deriveRecoveryVerifier() in recover.js. const recovery_verifier = await _deriveRecoveryVerifier( recoveryCode, userEmail, ); // Store on server const res = await apiFetch("/api/auth/recovery/setup", { method: "POST", body: JSON.stringify({ recovery_enc_salt, recovery_iv, recovery_verifier, }), }); 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() { const all = [..._items, ..._sharedItems]; if (!_activeFilter) { renderItemList(all); return; } if (_activeFilter.type === "itemType") renderItemList(all.filter((i) => i.item_type === _activeFilter.value)); else if (_activeFilter.type === "folder") // Shared items belong to no folder — exclude them from folder filters. renderItemList(_items.filter((i) => i.folder_id === _activeFilter.value)); else if (_activeFilter.type === "tag") renderItemList( all.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 all = [..._items, ..._sharedItems]; const pool = !_activeFilter ? all : _activeFilter.type === "itemType" ? all.filter((i) => i.item_type === _activeFilter.value) : _activeFilter.type === "folder" ? _items.filter((i) => i.folder_id === _activeFilter.value) : _activeFilter.type === "tag" ? all.filter((i) => (i.plain?.tags || []).includes(_activeFilter.value)) : all; 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"); // Store plaintext passwords in a WeakMap keyed by button element so // they never appear in the DOM (no data-pw attributes to scrape). const _historyPasswords = new WeakMap(); historyPanel.innerHTML = `
    🕐 Previous passwords (${history.length})
    ` + history.map((h, idx) => { const date = h.changed_at ? new Date(h.changed_at).toLocaleDateString() : "Unknown date"; return `
    •••••••• ${escHtml(date)}
    `; }).join(""); // Register passwords in the WeakMap after DOM elements exist. historyPanel.querySelectorAll(".pw-history-row").forEach((row) => { const idx = parseInt(row.dataset.histIdx); const pw = history[idx]?.password || ""; const revealBtn = row.querySelector(".pw-history-reveal"); const restoreBtn = row.querySelector(".pw-history-restore"); _historyPasswords.set(revealBtn, pw); _historyPasswords.set(restoreBtn, pw); revealBtn.addEventListener("click", () => { const masked = row.querySelector(".pw-history-masked"); if (masked.textContent === "••••••••") { masked.textContent = _historyPasswords.get(revealBtn); revealBtn.textContent = "🙈"; } else { masked.textContent = "••••••••"; revealBtn.textContent = "👁"; } }); restoreBtn.addEventListener("click", () => { const pwField = document.getElementById("field-password"); if (pwField) { pwField.value = _historyPasswords.get(restoreBtn); 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 { // Re-encrypt shared copies so recipients always see the latest data. const shared_updates = await buildSharedUpdates(itemId, plainData, name); const res = await apiFetch(`/api/vault/${itemId}`, { method: "PUT", body: JSON.stringify({ ...payload, shared_updates }), }); 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(() => {}); } /** * Derive the recovery verifier: 256 bits of PBKDF2 over the recovery code, * salted with a domain-separated label plus the user's email, returned as 64 * lowercase hex characters. * * The server stores this and keys the recovery challenge HMAC with it. It is * deliberately independent of enc_key_salt — enc_key_salt is the vault-key * PBKDF2 salt and is disclosed to the client at login, so using it as the * proof key let anyone with the master password forge a proof and pull the * whole vault from /recovery/items without a second factor. * * recover.js has a byte-identical copy. Changing the label or iteration count * in one place without the other invalidates every existing recovery code. */ async function _deriveRecoveryVerifier(recoveryCode, email) { const baseKey = await window.crypto.subtle.importKey( "raw", new TextEncoder().encode(recoveryCode), "PBKDF2", false, ["deriveBits"], ); const bits = await window.crypto.subtle.deriveBits( { name: "PBKDF2", salt: new TextEncoder().encode( "passkeeper-recovery-verifier:" + email.toLowerCase(), ), iterations: 200_000, hash: "SHA-256", }, baseKey, 256, ); return Array.from(new Uint8Array(bits)) .map((b) => b.toString(16).padStart(2, "0")) .join(""); } 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"); // Tab panes are siblings of the .panel-tabs container, not descendants of it, // so search in the parent element. const paneRoot = container.parentElement; tabs.forEach((btn) => { btn.addEventListener("click", () => { tabs.forEach((t) => t.classList.remove("active")); btn.classList.add("active"); const target = btn.dataset.tab; paneRoot.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"); applyCurrentFilter(); }); 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"); applyCurrentFilter(); }); 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); applyCurrentFilter(); }); }); 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);