Files
PassKeeper/app/static/js/vault.js
T
2026-05-19 10:35:34 -04:00

4748 lines
170 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* vault.js — Vault UI: all views (vault, security, sharing, emergency, settings)
*
* All sensitive data is decrypted client-side. The server only ever stores
* encrypted blobs (zero-knowledge architecture).
*/
const Vault = (() => {
let _items = [];
let _folders = [];
let _currentView = "vault";
// ── Web-app inactivity / session timeout ──────────────────────────────────
// Mirrors the extension idle lock. Default 15 min; user can change in
// Account Settings. Zero means never. Stored in localStorage so it persists
// across page reloads without a server round-trip.
const WEB_IDLE_KEY = "web_idle_minutes";
const WEB_IDLE_DEFAULT = 15; // minutes; 0 = never
let _webIdleTimer = null;
function _getWebIdleMinutes() {
const v = parseInt(localStorage.getItem(WEB_IDLE_KEY), 10);
return isNaN(v) ? WEB_IDLE_DEFAULT : v;
}
function _resetWebIdleTimer() {
if (_webIdleTimer) clearTimeout(_webIdleTimer);
const mins = _getWebIdleMinutes();
if (mins === 0) return;
_webIdleTimer = setTimeout(() => {
// Only lock if the vault is currently unlocked.
if (!VaultSession.getKey()) return;
console.log("[PassKeeper] Web inactivity lock after", mins, "min");
VaultSession.clear();
showUnlockOverlay();
showToast("Vault locked due to inactivity.", "info");
}, mins * 60_000);
}
function _startWebIdleTracking() {
const events = [
"mousemove",
"mousedown",
"keydown",
"touchstart",
"scroll",
];
const handler = () => _resetWebIdleTimer();
events.forEach((e) =>
document.addEventListener(e, handler, { passive: true }),
);
_resetWebIdleTimer();
// Lock when the tab is hidden for longer than the idle timeout.
// This catches screen-lock, minimize, and long tab switches without
// requiring the user to wait for the inactivity timer to fire after
// returning to the tab.
let _hiddenAt = null;
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") {
_hiddenAt = Date.now();
// Pause the inactivity timer while hidden — user activity is impossible.
if (_webIdleTimer) {
clearTimeout(_webIdleTimer);
_webIdleTimer = null;
}
} else {
// Tab became visible again.
const mins = _getWebIdleMinutes();
if (mins > 0 && _hiddenAt !== null) {
const hiddenMs = Date.now() - _hiddenAt;
if (hiddenMs >= mins * 60_000 && VaultSession.getKey()) {
console.log(
"[PassKeeper] Locking — tab was hidden for",
Math.round(hiddenMs / 1000),
"s (idle timeout:",
mins,
"min)",
);
VaultSession.clear();
showUnlockOverlay();
showToast("Vault locked due to inactivity.", "info");
_hiddenAt = null;
return;
}
}
_hiddenAt = null;
// Resume the inactivity timer now that the user is back.
_resetWebIdleTimer();
}
});
}
let _activeFilter = null;
let _sortOrder = "name-asc";
// ── Bulk selection state ──────────────────────────────────────────────────
let _selectMode = false;
let _selectedIds = new Set();
const FIELD_LABELS = {
url: "Website URL",
username: "Username",
password: "Password",
notes: "Notes",
note_body: "Note",
cardholder_name: "Cardholder Name",
card_number: "Card Number",
expiry_month: "Expiry Month",
expiry_year: "Expiry Year",
cvv: "CVV",
bank_name: "Bank Name",
account_type: "Account Type",
routing_number: "Routing Number",
account_number: "Account Number",
first_name: "First Name",
last_name: "Last Name",
company: "Company",
address_line: "Street Address",
city: "City",
state: "State",
zip: "ZIP",
country: "Country",
phone: "Phone",
email: "Email",
ssn_number: "Social Security Number",
// passkey
rp_id: "RP ID (Domain)",
credential_id: "Credential ID",
user_handle: "User Handle",
device_hint: "Device / Authenticator",
};
const SENSITIVE_FIELDS = new Set([
"password",
"cvv",
"card_number",
"account_number",
"routing_number",
"ssn_number",
"credential_id",
"user_handle",
]);
// ── API helpers ───────────────────────────────────────────────────────────
async function apiFetch(path, options = {}) {
let token = sessionStorage.getItem("access_token");
if (!token) {
redirectToLogin();
return null;
}
const defaults = {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
"X-CSRFToken": csrfToken(),
},
};
const merged = { ...defaults, ...options };
merged.headers = { ...defaults.headers, ...(options.headers || {}) };
let res = await fetch(path, merged);
if (res.status === 401) {
const refreshed = await tryRefreshToken();
if (!refreshed) {
redirectToLogin();
return null;
}
merged.headers["Authorization"] =
`Bearer ${sessionStorage.getItem("access_token")}`;
res = await fetch(path, merged);
}
if (!res.ok) {
const err = await res.json().catch(() => ({}));
const e = new Error(err.error || `HTTP ${res.status}`);
e.status = res.status;
throw e;
}
return res;
}
let _refreshPromise = null;
async function tryRefreshToken() {
// Singleton: if a refresh is already in flight, reuse it so concurrent 401s
// don't each fire a separate rotation (which would blacklist each other's tokens).
if (_refreshPromise) return _refreshPromise;
_refreshPromise = (async () => {
const refreshToken = localStorage.getItem("refresh_token");
if (!refreshToken) return false;
try {
const res = await fetch("/api/auth/refresh", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": csrfToken(),
},
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!res.ok) return false;
const data = await res.json();
sessionStorage.setItem("access_token", data.access_token);
if (data.refresh_token)
localStorage.setItem("refresh_token", data.refresh_token);
return true;
} catch {
return false;
}
})().finally(() => {
_refreshPromise = null;
});
return _refreshPromise;
}
function csrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.content || "";
}
function redirectToLogin() {
VaultSession.clear();
SharingSession.clear();
sessionStorage.removeItem("access_token");
window.dispatchEvent(new CustomEvent("passkeeper:logout"));
window.location.href = "/login";
}
// ── Load ──────────────────────────────────────────────────────────────────
async function loadVault() {
const vaultKey = VaultSession.getKey();
if (!vaultKey) {
showUnlockOverlay();
return;
}
showLoadingState(true);
try {
const [itemsRes, foldersRes] = await Promise.all([
apiFetch("/api/vault"),
apiFetch("/api/folders"),
]);
if (!itemsRes || !foldersRes) {
showUnlockOverlay();
return;
}
const rawItems = await itemsRes.json();
_folders = await foldersRes.json();
_items = await Promise.all(
rawItems.map(async (item) => {
try {
const plain = await Crypto.decryptItem(
vaultKey,
item.enc_data,
item.iv,
);
// Decrypt the item name if an encrypted version exists.
// Fall back to the server-stored plaintext name for legacy items.
let displayName = item.name;
if (item.enc_name && item.iv_name) {
const decrypted = await Crypto.decryptName(
vaultKey,
item.enc_name,
item.iv_name,
);
if (decrypted) displayName = decrypted;
}
return { ...item, name: displayName, plain };
} catch {
return { ...item, plain: null, decryptError: true };
}
}),
);
renderFolderList();
renderTagList();
applyCurrentFilter();
// Reset banner dismissed flag so fresh health results are visible.
const banner = document.getElementById("health-banner");
if (banner) banner.dataset.dismissed = "0";
// Run health checks in the background — updates badge + banner without
// blocking the vault render. Results are cached so opening the Security
// tab doesn't re-run HIBP checks.
runBackgroundHealthCheck();
} catch (err) {
showToast("Failed to load vault: " + err.message, "error");
} finally {
showLoadingState(false);
}
}
async function loadFolders() {
try {
const res = await apiFetch("/api/folders");
if (!res) return;
_folders = await res.json();
renderFolderList();
} catch (err) {
showToast("Could not refresh folders: " + err.message, "error");
}
}
// ── View switching ────────────────────────────────────────────────────────
function switchView(view, { pushState = true } = {}) {
// Exit bulk-select mode when navigating away from the vault view.
if (view !== "vault" && _selectMode) _exitSelectMode();
_currentView = view;
["vault", "security", "sharing", "emergency", "import-export"].forEach(
(v) => {
document
.getElementById(`view-${v}`)
?.classList.toggle("hidden", v !== view);
},
);
document.querySelectorAll(".sidebar-item[data-view]").forEach((el) => {
el.classList.toggle("active", el.dataset.view === view);
});
if (view !== "vault") {
document
.querySelectorAll(
".sidebar-item[data-type-filter], .sidebar-item[data-folder-id]",
)
.forEach((el) => el.classList.remove("active"));
}
// Push to browser history so the back button navigates between views.
if (pushState) {
const hash = view === "vault" ? "" : `#${view}`;
history.pushState({ view }, "", hash || window.location.pathname);
}
if (view === "security") renderSecurityDashboard();
if (view === "sharing") loadSharingView();
if (view === "emergency") loadEmergencyView();
if (view === "import-export") loadImportExportView();
}
// ── Vault render ──────────────────────────────────────────────────────────
function renderFolderList() {
const ul = document.getElementById("sidebar-folders");
if (!ul) return;
ul.innerHTML = "";
_folders.forEach((folder) => {
const li = document.createElement("li");
li.className = "sidebar-item folder-item";
li.dataset.folderId = folder.id;
const label = document.createElement("span");
label.className = "folder-label";
label.innerHTML = `<span class="sidebar-icon">📁</span> <span class="sidebar-label">${escHtml(folder.name)}</span>`;
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 = '<li class="sidebar-tag-empty">No tags yet</li>';
return;
}
tags.forEach((tag) => {
const li = document.createElement("li");
li.className = "sidebar-item sidebar-tag-item";
li.dataset.tag = tag;
li.innerHTML = `<span class="sidebar-icon">🏷️</span><span class="sidebar-label">${escHtml(tag)}</span>`;
li.addEventListener("click", () => {
document
.querySelectorAll(".sidebar-item")
.forEach((el) => el.classList.remove("active"));
li.classList.add("active");
_activeFilter = { type: "tag", value: tag };
switchView("vault");
updateVaultTitle("#" + tag);
renderItemList(
_items.filter((i) => (i.plain?.tags || []).includes(tag)),
);
});
ul.appendChild(li);
});
}
// Persists which folder groups are collapsed across re-renders.
const _collapsedGroups = new Set();
function renderItemList(items) {
const list = document.getElementById("vault-list");
if (!list) return;
list.innerHTML = "";
// Apply or remove select-mode CSS class on the parent wrapper.
const wrapper = list.closest(".vault-list");
if (wrapper) wrapper.classList.toggle("select-mode", _selectMode);
if (items.length === 0) {
list.innerHTML =
'<li class="vault-empty">No items found. Click + to add one.</li>';
return;
}
const sorted = getSortedItems(items);
const groups = {};
sorted.forEach((item) => {
const key = folderName(item.folder_id);
if (!groups[key]) groups[key] = [];
groups[key].push(item);
});
const keys =
_sortOrder === "folder"
? Object.keys(groups).sort()
: ["(No folder)", ..._folders.map((f) => f.name)].filter(
(k) => groups[k],
);
Object.keys(groups).forEach((k) => {
if (!keys.includes(k)) keys.push(k);
});
keys.forEach((groupName) => {
if (!groups[groupName]) return;
const isCollapsed = _collapsedGroups.has(groupName);
const count = groups[groupName].length;
// ── Collapsible group header ────────────────────────────────────────────
const header = document.createElement("li");
header.className =
"vault-group-header" + (isCollapsed ? " collapsed" : "");
header.innerHTML =
`<span class="group-name">${escHtml(groupName)}</span>` +
`<span class="group-meta">` +
`<span class="group-count">${count}</span>` +
`<span class="group-chevron">${isCollapsed ? "▶" : "▼"}</span>` +
`</span>`;
header.addEventListener("click", () => {
if (_collapsedGroups.has(groupName)) {
_collapsedGroups.delete(groupName);
} else {
_collapsedGroups.add(groupName);
}
renderItemList(items); // re-render preserving collapse state
});
list.appendChild(header);
// ── Items (hidden when collapsed) ───────────────────────────────────────
if (!isCollapsed) {
groups[groupName].forEach((item) =>
list.appendChild(createItemElement(item)),
);
}
});
}
function createItemElement(item) {
const li = document.createElement("li");
li.className = "vault-item";
li.dataset.id = item.id;
const iconMap = {
password: "🔑",
note: "📝",
card: "💳",
bank: "🏦",
address: "🏠",
ssn: "🪪",
passkey: "🔐",
};
const icon = iconMap[item.item_type] || "🔑";
const itemTags = item.plain?.tags || [];
let subText = "";
if (item.plain) {
switch (item.item_type) {
case "password":
subText = item.plain.username || item.plain.url || "";
break;
case "note":
subText = (item.plain.note_body || "").slice(0, 60);
break;
case "card":
subText = item.plain.card_number
? "•••• " +
String(item.plain.card_number).replace(/\s/g, "").slice(-4)
: "";
break;
case "bank":
subText = item.plain.bank_name || "";
break;
case "address":
subText = [item.plain.first_name, item.plain.last_name]
.filter(Boolean)
.join(" ");
break;
case "ssn":
subText = "•••-••-••••";
break;
case "passkey":
subText = [
item.plain.username,
item.plain.rp_id ? `@ ${item.plain.rp_id}` : "",
]
.filter(Boolean)
.join(" ");
break;
}
}
const showCopyUser = item.item_type === "password";
const showCopyPass = ["password", "card", "bank", "ssn"].includes(
item.item_type,
);
const showLaunch = item.item_type === "password" && item.plain?.url;
const showTotp =
item.item_type === "password" &&
!!extractTotpSecret(item.plain?.totp_uri);
const isFavorite = itemTags.includes("favorite");
const visibleTags = itemTags.filter((t) => t !== "favorite");
const tagBadges = visibleTags
.map((t) => `<span class="item-tag">${escHtml(t)}</span>`)
.join("");
li.innerHTML = `
<input type="checkbox" class="item-checkbox" data-id="${item.id}" ${_selectedIds.has(item.id) ? "checked" : ""} aria-label="Select ${escHtml(item.name)}"/>
<div class="item-icon">${icon}</div>
<div class="item-info">
<span class="item-name">${escHtml(item.name)}</span>
<span class="item-sub">${escHtml(subText)}</span>
${tagBadges ? `<div class="item-tags">${tagBadges}</div>` : ""}
${showTotp ? `<span class="item-totp" id="totp-display-${item.id}"><span class="totp-code">······</span><span class="totp-timer"></span></span>` : ""}
</div>
<div class="item-actions">
${showLaunch ? `<button class="btn-icon" title="Open URL" data-action="launch">🌐</button>` : ""}
${showCopyUser ? `<button class="btn-icon" title="Copy username" data-action="copy-user">👤</button>` : ""}
${showCopyPass ? `<button class="btn-icon" title="Copy secret" data-action="copy-pass">📋</button>` : ""}
${showTotp ? `<button class="btn-icon" title="Copy 2FA code" data-action="copy-totp">🔐</button>` : ""}
<button class="btn-icon btn-favorite${isFavorite ? " is-favorite" : ""}" title="${isFavorite ? "Remove from favorites" : "Add to favorites"}" data-action="favorite">${isFavorite ? "★" : "☆"}</button>
<button class="btn-icon" title="Edit" data-action="edit">✏️</button>
<button class="btn-icon" title="Delete" data-action="delete">🗑️</button>
</div>`;
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 =
'<option value="">— No folder —</option>' +
_folders.map((f) => `<option value="${f.id}">${escHtml(f.name)}</option>`).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(`<strong>${breached}</strong> breached password${breached !== 1 ? "s" : ""}`);
if ((weak || 0) > 0)
parts.push(`<strong>${weak}</strong> weak password${weak !== 1 ? "s" : ""}`);
if ((reused || 0) > 0)
parts.push(`<strong>${reused}</strong> reused password${reused !== 1 ? "s" : ""}`);
const severity = hasBreaches ? "danger" : "warn";
const icon = hasBreaches ? "🚨" : "⚠️";
banner.className = `health-banner banner-${severity}`;
banner.innerHTML = `
<span class="health-banner-icon">${icon}</span>
<span class="health-banner-text">${parts.join(" · ")}</span>
<button class="health-banner-link" id="btn-health-banner-view">View report</button>
<button class="health-banner-dismiss" id="btn-health-banner-dismiss" title="Dismiss">×</button>`;
banner.classList.remove("hidden");
document.getElementById("btn-health-banner-view")?.addEventListener("click", () => {
switchView("security");
});
document.getElementById("btn-health-banner-dismiss")?.addEventListener("click", () => {
banner.classList.add("hidden");
banner.dataset.dismissed = "1";
});
}
async function renderSecurityDashboard() {
const summaryEl = document.getElementById("security-summary");
const sectionsEl = document.getElementById("security-sections");
if (!summaryEl || !sectionsEl) return;
// If vault hasn't loaded yet, wait for it then re-render
if (!_items.length && VaultSession.getKey()) {
summaryEl.innerHTML = '<div class="sec-notice">Loading…</div>';
sectionsEl.innerHTML = "";
loadVault().then(() => renderSecurityDashboard());
return;
}
const pwItems = _items.filter(
(i) => i.item_type === "password" && i.plain?.password,
);
if (!pwItems.length) {
summaryEl.innerHTML =
'<div class="sec-notice">No passwords to analyze.</div>';
sectionsEl.innerHTML = "";
return;
}
const weak = pwItems.filter((i) => {
const p = i.plain.password;
if (p.length < 10) return true;
return (
[/[A-Z]/, /[a-z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((r) => r.test(p))
.length < 2
);
});
const passCounts = {};
pwItems.forEach((i) => {
const p = i.plain.password;
(passCounts[p] = passCounts[p] || []).push(i);
});
const reused = Object.values(passCounts)
.filter((a) => a.length > 1)
.flat();
const cutoff = Date.now() - 180 * 86400000;
// "Old" only penalises passwords that are ALSO weak or reused.
// Uses plain.password_changed_at when available (set on create/edit),
// falling back to created_at for items saved before this feature.
const weakOrReusedIds = new Set([
...weak.map((i) => i.id),
...reused.map((i) => i.id),
]);
const old = pwItems.filter((i) => {
const ageRef = i.plain?.password_changed_at
? new Date(i.plain.password_changed_at).getTime()
: new Date(i.created_at).getTime();
return ageRef < cutoff && weakOrReusedIds.has(i.id);
});
const total = pwItems.length;
const score = Math.max(
0,
Math.round(
100 -
(weak.length / total) * 40 -
(reused.length / total) * 30 -
(old.length / total) * 15,
),
);
const cls =
score >= 80 ? "score-good" : score >= 50 ? "score-fair" : "score-poor";
const label =
score >= 80 ? "Good" : score >= 50 ? "Fair" : "Needs attention";
summaryEl.innerHTML = `
<div class="sec-score-card">
<div class="sec-score ${cls}">${score}</div>
<div class="sec-score-label">${label}</div>
<div class="sec-score-desc">Based on ${total} password${total !== 1 ? "s" : ""}</div>
</div>
<div class="sec-stats">
<div class="sec-stat ${weak.length ? "sec-stat-warn" : "sec-stat-ok"}"><span class="sec-stat-num">${weak.length}</span><span class="sec-stat-label">Weak</span></div>
<div class="sec-stat ${reused.length ? "sec-stat-warn" : "sec-stat-ok"}"><span class="sec-stat-num">${reused.length}</span><span class="sec-stat-label">Reused</span></div>
<div class="sec-stat ${old.length ? "sec-stat-info" : "sec-stat-ok"}"><span class="sec-stat-num">${old.length}</span><span class="sec-stat-label">Old &amp; Weak</span></div>
</div>`;
sectionsEl.innerHTML = "";
const makeSection = (title, icon, items, desc, renderItem) => {
if (!items.length) return;
const sec = document.createElement("div");
sec.className = "sec-section";
const defaultRender = (i) => `<li class="sec-item">
<span class="sec-item-name">${escHtml(i.name)}</span>
<span class="sec-item-sub">${escHtml(i.plain?.username || "")}</span>
<button class="btn-secondary btn-sm" data-sec-edit="${i.id}">Edit</button>
</li>`;
const renderFn = renderItem || defaultRender;
sec.innerHTML = `
<div class="sec-section-header"><span class="sec-section-icon">${icon}</span>
<div><div class="sec-section-title">${title} (${items.length})</div><div class="sec-section-desc">${desc}</div></div>
</div>
<ul class="sec-item-list">
${items.map(renderFn).join("")}
</ul>`;
sec.querySelectorAll("[data-sec-edit]").forEach((btn) => {
btn.addEventListener("click", () => {
const item = _items.find(
(it) => it.id === parseInt(btn.dataset.secEdit),
);
if (item) {
switchView("vault");
openModal("edit", item);
}
});
});
sectionsEl.appendChild(sec);
};
makeSection(
"Weak Passwords",
"⚠️",
weak,
"Short or low-complexity passwords.",
);
makeSection(
"Reused Passwords",
"♻️",
[...new Set(reused.map((i) => i.id))]
.map((id) => _items.find((i) => i.id === id))
.filter(Boolean),
"Same password used on multiple sites.",
);
makeSection(
"Old Passwords",
"🕐",
old,
"Weak or reused passwords not changed in over 180 days.",
(i) => {
const ref = i.plain?.password_changed_at || i.created_at;
const daysAgo = ref
? Math.floor((Date.now() - new Date(ref).getTime()) / 86400000)
: null;
const ageLabel = daysAgo !== null
? `Last changed ${daysAgo} day${daysAgo !== 1 ? "s" : ""} ago`
: "Age unknown";
return `<li class="sec-item">
<span class="sec-item-name">${escHtml(i.name)}</span>
<span class="sec-item-sub">${escHtml(ageLabel)}</span>
<button class="btn-secondary btn-sm" data-sec-edit="${i.id}">Edit</button>
</li>`;
},
);
// ── Missing 2FA warning ──────────────────────────────────────────────────
// Flag password items that have a URL but no TOTP URI saved.
// These accounts likely support 2FA but the user hasn't stored it.
const noTotp = pwItems.filter(
(i) => i.plain?.url && !extractTotpSecret(i.plain?.totp_uri),
);
makeSection(
"No 2FA Saved",
"🔓",
noTotp,
"These accounts may support two-factor authentication but have no TOTP code stored.",
);
// ── HaveIBeenPwned breach check ──────────────────────────────────────────
// Use cached results from the background check when available — avoids
// re-querying HIBP every time the user opens the Security tab.
const hibpSection = document.createElement("div");
hibpSection.className = "sec-section";
hibpSection.innerHTML = `
<div class="sec-section-header">
<span class="sec-section-icon">🔓</span>
<div>
<div class="sec-section-title">Checking for known breaches…</div>
<div class="sec-section-desc">Querying HaveIBeenPwned (k-anonymity — your passwords are never sent).</div>
</div>
</div>`;
sectionsEl.appendChild(hibpSection);
// Use cached results if available, otherwise run fresh checks.
let hibpResults;
if (_healthCache?.hibpResults) {
hibpResults = _healthCache.hibpResults;
} else {
// Run all HIBP checks in parallel — k-anonymity: only 5-char SHA-1 prefix sent.
hibpResults = await Promise.all(
pwItems.map(async (item) => ({
item,
count: await checkHibp(item.plain.password),
})),
);
}
const breached = hibpResults.filter((r) => r.count > 0).map((r) => r.item);
if (!breached.length) {
hibpSection.innerHTML = `
<div class="sec-section-header">
<span class="sec-section-icon">✅</span>
<div>
<div class="sec-section-title">No known breaches</div>
<div class="sec-section-desc">None of your passwords appeared in known data breaches (via HaveIBeenPwned).</div>
</div>
</div>`;
} else {
hibpSection.innerHTML = `
<div class="sec-section-header">
<span class="sec-section-icon">🔓</span>
<div>
<div class="sec-section-title">Breached Passwords (${breached.length})</div>
<div class="sec-section-desc">These passwords appeared in known data breaches. Change them immediately.</div>
</div>
</div>
<ul class="sec-item-list">
${breached
.map((i) => {
const count =
hibpResults.find((r) => r.item.id === i.id)?.count || 0;
return `<li class="sec-item">
<span class="sec-item-name">${escHtml(i.name)}</span>
<span class="sec-item-sub">${escHtml(i.plain?.username || "")} — seen ${count.toLocaleString()} time${count !== 1 ? "s" : ""} in breaches</span>
<button class="btn-secondary btn-sm" data-sec-edit="${i.id}">Change</button>
</li>`;
})
.join("")}
</ul>`;
hibpSection.querySelectorAll("[data-sec-edit]").forEach((btn) => {
btn.addEventListener("click", () => {
const item = _items.find(
(it) => it.id === parseInt(btn.dataset.secEdit),
);
if (item) {
switchView("vault");
openModal("edit", item);
}
});
});
}
}
// ── Import / Export View ─────────────────────────────────────────────────
// Holds parsed rows from a chosen file, ready for import.
let _importRows = [];
/**
* Parse a Chrome/Bitwarden/1Password CSV into a normalised array of plain objects.
* Supported column sets:
* Chrome: name, url, username, password
* Bitwarden: name, login_uri, login_username, login_password, notes, type
* 1Password: Title, Url, Username, Password, Notes
*/
function _parseCsvImport(text) {
const lines = text.split(/\r?\n/);
if (lines.length < 2) return [];
const headers = lines[0]
.split(",")
.map((h) => h.trim().replace(/^"|"$/g, "").toLowerCase());
// Detect format by inspecting header names.
const col = (candidates) => {
for (const c of candidates) {
const idx = headers.indexOf(c);
if (idx !== -1) return idx;
}
return -1;
};
const iName = col(["name", "title"]);
const iUrl = col(["url", "login_uri"]);
const iUser = col(["username", "login_username"]);
const iPass = col(["password", "login_password"]);
const iNotes = col(["notes", "note"]);
const rows = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
// RFC 4180-compliant CSV split — handles quoted fields containing commas
// and embedded double-quotes escaped as "".
const cells = [];
let cur = "",
inQuote = false;
const src = line + ",";
for (let ci = 0; ci < src.length; ci++) {
const ch = src[ci];
if (ch === '"') {
if (inQuote && src[ci + 1] === '"') {
// Escaped quote inside a quoted field: "" → "
cur += '"';
ci++; // skip the second quote
} else {
inQuote = !inQuote;
}
} else if (ch === "," && !inQuote) {
cells.push(cur.trim());
cur = "";
} else {
cur += ch;
}
}
const get = (idx) =>
idx !== -1 && cells[idx] != null
? cells[idx].replace(/^"|"$/g, "")
: "";
const name = get(iName);
const password = get(iPass);
if (!name || !password) continue;
rows.push({
name,
url: get(iUrl),
username: get(iUser),
password,
notes: get(iNotes),
});
}
return rows;
}
let _importViewInitialised = false;
function _resetImportView() {
_importRows = [];
const fileInput = document.getElementById("import-file-input");
const fileNameEl = document.getElementById("import-file-name");
const previewEl = document.getElementById("import-preview");
const confirmBtn = document.getElementById("btn-import-confirm");
const resultEl = document.getElementById("import-result");
if (fileInput) fileInput.value = "";
if (fileNameEl) fileNameEl.textContent = "No file chosen";
if (previewEl) {
previewEl.innerHTML = "";
previewEl.classList.add("hidden");
}
if (confirmBtn) {
confirmBtn.disabled = true;
confirmBtn.textContent = "Import items";
delete confirmBtn.dataset.mode;
}
if (resultEl) {
resultEl.innerHTML = "";
resultEl.classList.add("hidden");
}
}
function loadImportExportView() {
// Reset import panel state every time the view is entered so a previous
// import result or file preview is never shown stale on re-entry.
_resetImportView();
if (_importViewInitialised) return;
_importViewInitialised = true;
// ── Export ───────────────────────────────────────────────────────────────
document
.getElementById("btn-export-json")
?.addEventListener("click", async () => {
try {
const res = await apiFetch("/api/vault");
if (!res) return;
const items = await res.json();
const payload = JSON.stringify(
{ version: 1, exported_at: new Date().toISOString(), items },
null,
2,
);
const blob = new Blob([payload], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `passkeeper-export-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
showToast("Encrypted vault exported.");
console.log("[PassKeeper] Vault exported:", items.length, "items");
} catch (err) {
showToast("Export failed: " + err.message, "error");
}
});
document
.getElementById("btn-export-csv")
?.addEventListener("click", async () => {
const vaultKey = VaultSession.getKey();
if (!vaultKey) {
showUnlockOverlay();
return;
}
// Warn the user that this export contains plaintext passwords before proceeding.
const confirmed = await new Promise((resolve) => {
const overlay = document.createElement("div");
overlay.style.cssText = [
"position:fixed",
"inset:0",
"background:rgba(0,0,0,0.55)",
"z-index:9999",
"display:flex",
"align-items:center",
"justify-content:center",
].join(";");
overlay.innerHTML = `
<div style="background:#fff;border-radius:12px;padding:28px 24px;max-width:380px;width:90%;box-shadow:0 8px 32px rgba(0,0,0,0.22);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px;">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" stroke="#d97706" stroke-width="1.8" fill="none"/>
<line x1="12" y1="9" x2="12" y2="13" stroke="#d97706" stroke-width="1.8" stroke-linecap="round"/>
<line x1="12" y1="17" x2="12.01" y2="17" stroke="#d97706" stroke-width="2" stroke-linecap="round"/>
</svg>
<strong style="font-size:15px;color:#111827;">Export plaintext passwords?</strong>
</div>
<p style="font-size:13px;color:#374151;margin-bottom:6px;">
The CSV file will contain <strong>all your passwords in plaintext</strong>.
Anyone with access to the file can read them.
</p>
<p style="font-size:13px;color:#374151;margin-bottom:20px;">
Store the file in a secure location and delete it when you no longer need it.
</p>
<div style="display:flex;gap:10px;justify-content:flex-end;">
<button id="_csv_cancel" style="padding:8px 18px;border:1px solid #d1d5db;border-radius:7px;background:transparent;cursor:pointer;font-size:13px;color:#374151;">Cancel</button>
<button id="_csv_confirm" style="padding:8px 18px;border:none;border-radius:7px;background:#c0392b;color:#fff;cursor:pointer;font-size:13px;font-weight:600;">Export anyway</button>
</div>
</div>`;
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 = `<p>Found <strong>${items.length}</strong> encrypted item(s) ready to import.</p>
<p class="import-note">These are already encrypted with your vault key — they will be imported as-is.</p>`;
} 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 = `<p>Found <strong>${rows.length}</strong> password(s) to import.</p>
<p class="import-note">Preview (first 5):</p>
<ul class="import-preview-list">${rows
.slice(0, 5)
.map(
(r) =>
`<li><strong>${escHtml(r.name)}</strong> — ${escHtml(r.username || "(no username)")}</li>`,
)
.join("")}</ul>
${rows.length > 5 ? `<p class="import-note">…and ${rows.length - 5} more.</p>` : ""}`;
}
previewEl.classList.remove("hidden");
confirmBtn.disabled = false;
confirmBtn.dataset.mode = isJson ? "json" : "csv";
} catch (err) {
previewEl.innerHTML = `<p class="import-error">⚠️ ${escHtml(err.message)}</p>`;
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 <strong>${data.imported}</strong> item(s)${data.skipped ? `, skipped ${data.skipped} malformed row(s)` : ""}.`;
resultEl.className = "import-result import-result-ok";
resultEl.classList.remove("hidden");
_importRows = [];
confirmBtn.textContent = "Import items";
fileInput.value = "";
fileNameEl.textContent = "No file chosen";
previewEl.classList.add("hidden");
console.log(
"[PassKeeper] Import complete:",
data.imported,
"imported,",
data.skipped,
"skipped",
);
await loadVault();
} catch (err) {
resultEl.innerHTML = `⚠️ ${escHtml(err.message)}`;
resultEl.className = "import-result import-result-err";
resultEl.classList.remove("hidden");
confirmBtn.disabled = false;
confirmBtn.textContent = "Import items";
}
});
}
// ── Sharing View ──────────────────────────────────────────────────────────
async function loadSharingView() {
try {
const keysRes = await apiFetch("/api/sharing/keys");
if (!keysRes) return;
const keysData = await keysRes.json();
const notice = document.getElementById("sharing-keys-notice");
if (!keysData.keys_setup) {
notice.classList.remove("hidden");
} else {
notice.classList.add("hidden");
// Load sharing private key into SharingSession if vault key available
const vaultKey = VaultSession.getKey();
if (vaultKey && !SharingSession.isReady()) {
try {
const privKey = await SharingCrypto.decryptPrivateKey(
vaultKey,
keysData.private_key_enc,
keysData.private_key_iv,
);
SharingSession.setKey(privKey);
} catch (err) {
console.warn(
"[PassKeeper] Could not load sharing private key:",
err,
);
}
}
}
await Promise.all([loadSharedOut(), loadSharedIn()]);
} catch (err) {
showToast("Could not load sharing data: " + err.message, "error");
}
}
async function loadSharedOut() {
try {
const res = await apiFetch("/api/sharing");
if (!res) return;
const shares = await res.json();
renderSharedOut(shares);
} catch (err) {
console.error("loadSharedOut:", err);
}
}
async function loadSharedIn() {
try {
const res = await apiFetch("/api/sharing/inbox");
if (!res) return;
const shares = await res.json();
renderSharedIn(shares);
} catch (err) {
console.error("loadSharedIn:", err);
}
}
function renderSharedOut(shares) {
const ul = document.getElementById("shared-out-list");
if (!ul) return;
if (!shares.length) {
ul.innerHTML =
'<li class="vault-empty">You haven\'t shared any items yet.</li>';
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 " · <span class=\"badge badge-danger\">Expired</span>";
if (daysLeft <= 3) return ` · <span class="badge badge-warn">Expires in ${daysLeft}d</span>`;
return ` · Expires ${new Date(s.expires_at).toLocaleDateString()}`;
})();
return `
<li class="share-item">
<div class="share-icon">${itemIcon(s.item_type)}</div>
<div class="share-info">
<span class="share-name">${escHtml(displayName)}</span>
<span class="share-meta">Shared with ${escHtml(s.recipient_email)} · ${s.accepted ? "✅ Accepted" : "⏳ Pending"}${expiryLabel}</span>
</div>
<button class="btn-icon" title="Revoke share" data-revoke="${s.id}">🗑️</button>
</li>`;
},
)
.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 =
'<li class="vault-empty">No items have been shared with you.</li>';
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 ` · <span class="badge badge-warn">Expires in ${daysLeft}d</span>`;
return ` · Expires ${new Date(s.expires_at).toLocaleDateString()}`;
})();
return `
<li class="share-item">
<div class="share-icon">${itemIcon(s.item_type)}</div>
<div class="share-info">
<span class="share-name">${escHtml(s.item_name)}</span>
<span class="share-meta">From ${escHtml(s.owner_email)}${expiryLabel}</span>
</div>
${
!s.accepted
? `<button class="btn-primary btn-sm" data-accept="${s.id}">Accept</button>`
: `<button class="btn-secondary btn-sm"
data-view-share="${s.id}"
data-owner-key="${escHtml(s.owner_public_key || "")}"
data-enc="${escHtml(s.enc_data)}"
data-iv="${escHtml(s.iv)}"
data-enc-name="${escHtml(s.enc_name || "")}"
data-iv-name="${escHtml(s.iv_name || "")}"
data-name="${escHtml(s.item_name)}"
data-type="${escHtml(s.item_type || "")}">View</button>`
}
</li>`;
},
)
.join("");
ul.querySelectorAll("[data-accept]").forEach((btn) => {
btn.addEventListener("click", async () => {
try {
await apiFetch(`/api/sharing/inbox/${btn.dataset.accept}/accept`, {
method: "POST",
});
showToast("Share accepted");
loadSharedIn();
} catch (err) {
showToast(err.message, "error");
}
});
});
ul.querySelectorAll("[data-view-share]").forEach((btn) => {
btn.addEventListener("click", async () => {
if (!SharingSession.isReady()) {
showToast("Sharing keys not loaded. Check Settings.", "error");
return;
}
try {
const ownerPubKey = await SharingCrypto.importPublicKey(
btn.dataset.ownerKey,
);
const sharedKey = await SharingCrypto.deriveSharedKey(
SharingSession.getKey(),
ownerPubKey,
);
const plain = await SharingCrypto.decryptShare(
sharedKey,
btn.dataset.enc,
btn.dataset.iv,
);
// Decrypt the item name if an encrypted version is available.
// Falls back to the non-sensitive label for legacy shares.
let displayName = btn.dataset.name;
if (btn.dataset.encName && btn.dataset.ivName) {
const decrypted = await SharingCrypto.decryptName(
sharedKey,
btn.dataset.encName,
btn.dataset.ivName,
);
if (decrypted) displayName = decrypted;
}
showSharedItemDetails(displayName, btn.dataset.type, plain);
} catch (err) {
showToast("Could not decrypt: " + err.message, "error");
}
});
});
}
function showSharedItemDetails(name, itemType, plain) {
document.getElementById("detail-modal-title").textContent =
`${itemIcon(itemType || "password")} ${name}`;
renderDetailFields(document.getElementById("detail-modal-body"), plain);
document.getElementById("detail-modal").classList.add("open");
}
function renderDetailFields(container, plain) {
container.innerHTML =
Object.entries(plain)
.filter(([, v]) => v)
.map(([k, v]) => {
const label = FIELD_LABELS[k] || k.replace(/_/g, " ");
const val = String(v);
const sensitive = SENSITIVE_FIELDS.has(k);
const valHtml = sensitive
? `<span class="detail-sensitive">
<span class="detail-masked">••••••••</span>
<span class="detail-actual hidden">${escHtml(val)}</span>
</span>
<button type="button" class="btn-icon btn-reveal" title="Show/hide">👁</button>`
: `<span class="detail-val">${escHtml(val)}</span>`;
return `<div class="detail-field">
<span class="detail-label">${escHtml(label)}</span>
<div class="detail-val-row">
${valHtml}
<button type="button" class="btn-icon btn-copy-field" data-copy="${escHtml(val)}" title="Copy">📋</button>
</div>
</div>`;
})
.join("") || '<p class="vault-empty">No fields to display.</p>';
container.querySelectorAll(".btn-reveal").forEach((btn) => {
btn.addEventListener("click", () => {
const span = btn.previousElementSibling;
const revealed = span.dataset.revealed === "true";
span.dataset.revealed = String(!revealed);
span
.querySelector(".detail-masked")
.classList.toggle("hidden", !revealed);
span
.querySelector(".detail-actual")
.classList.toggle("hidden", revealed);
});
});
container.querySelectorAll(".btn-copy-field").forEach((btn) => {
btn.addEventListener("click", () => {
navigator.clipboard
.writeText(btn.dataset.copy)
.then(() => showToast("Copied!"));
});
});
}
async function handleShareFormSubmit(e) {
e.preventDefault();
const errEl = document.getElementById("share-error");
errEl.classList.add("hidden");
if (!SharingSession.isReady()) {
showToast("Set up sharing keys first (Settings)", "error");
return;
}
const itemId = parseInt(document.getElementById("share-item-select").value);
const recipientEmail = document
.getElementById("share-recipient-email")
.value.trim()
.toLowerCase();
if (!itemId || !recipientEmail) {
errEl.textContent = "Please select an item and enter a recipient email.";
errEl.classList.remove("hidden");
return;
}
try {
// Fetch recipient's public key
const pkRes = await apiFetch(
`/api/sharing/public-key?email=${encodeURIComponent(recipientEmail)}`,
);
if (!pkRes) return;
const pkData = await pkRes.json();
const recipientPubKey = await SharingCrypto.importPublicKey(
pkData.public_key,
);
const sharedKey = await SharingCrypto.deriveSharedKey(
SharingSession.getKey(),
recipientPubKey,
);
// Find item plaintext
const item = _items.find((i) => i.id === itemId);
if (!item?.plain) {
showToast("Item not found or not decrypted", "error");
return;
}
const { enc_data, iv } = await SharingCrypto.encryptForShare(
sharedKey,
item.plain,
);
// Encrypt the display name with the same ECDH shared key so the server
// never sees the plaintext name. item_name is set to item_type as a
// non-sensitive fallback label for legacy-compat (server requires the field).
const { enc_name, iv_name } = await SharingCrypto.encryptName(
sharedKey,
item.name,
);
const res = await apiFetch("/api/sharing", {
method: "POST",
body: JSON.stringify({
item_id: itemId,
recipient_email: recipientEmail,
enc_data,
iv,
item_name: item.item_type, // non-sensitive label; real name is in enc_name
item_type: item.item_type,
enc_name,
iv_name,
expires_days: (() => {
const sel = document.getElementById("share-expiry-select");
const v = sel ? parseInt(sel.value) : NaN;
return isNaN(v) || v === 0 ? null : v;
})(),
}),
});
if (!res) return;
closeModal("share-modal");
showToast("Item shared successfully");
loadSharedOut();
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove("hidden");
}
}
function openShareModal() {
const sel = document.getElementById("share-item-select");
sel.innerHTML = _items
.map(
(i) =>
`<option value="${i.id}">${escHtml(i.name)} (${i.item_type})</option>`,
)
.join("");
document.getElementById("share-recipient-email").value = "";
document.getElementById("share-error").classList.add("hidden");
document.getElementById("share-modal").classList.add("open");
}
// ── Emergency Access View ─────────────────────────────────────────────────
async function loadEmergencyView() {
try {
const res = await apiFetch("/api/emergency");
if (!res) return;
const data = await res.json();
renderEmergencyGrants(data.grants);
renderEmergencyAccess(data.access);
} catch (err) {
showToast(
"Could not load emergency access data: " + err.message,
"error",
);
}
}
function renderEmergencyGrants(grants) {
const ul = document.getElementById("em-grants-list");
if (!ul) return;
if (!grants.length) {
ul.innerHTML =
'<li class="vault-empty">No trusted contacts set up yet.</li>';
return;
}
ul.innerHTML = grants
.map((g) => {
let actions = "";
if (g.status === "accepted") {
actions = `<button class="btn-primary btn-sm" data-provide="${g.id}">Provide Recovery Data</button>`;
}
if (g.status === "ready") {
// Check if the stored snapshot is in the old format (plaintext names).
if (g.enc_vault_is_legacy) {
actions = `
<span class="badge badge-warn" title="Recovery snapshot contains plaintext item names. Re-provision to encrypt them.">⚠ Outdated snapshot</span>
<button class="btn-primary btn-sm" data-provide="${g.id}">Re-provision</button>`;
} else {
actions = `<button class="btn-secondary btn-sm" data-provide="${g.id}">Update Recovery Data</button>`;
}
}
if (g.status === "pending") {
const waitInfo = g.wait_elapsed
? "Wait period elapsed"
: "Access requested";
actions = `<span class="badge badge-warn">${waitInfo}</span>
<button class="btn-secondary btn-sm" data-deny="${g.id}">Deny</button>`;
}
return `<li class="share-item">
<div class="share-icon">🚨</div>
<div class="share-info">
<span class="share-name">${escHtml(g.grantee_email)}</span>
<span class="share-meta">Status: ${escHtml(g.status)} · Wait: ${g.wait_days} day(s)</span>
</div>
<div class="share-actions">
${actions}
<button class="btn-icon" title="Remove" data-remove-ea="${g.id}">🗑️</button>
</div>
</li>`;
})
.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 =
'<li class="vault-empty">No one has designated you as an emergency contact.</li>';
return;
}
ul.innerHTML = access
.map((a) => {
let actions = "";
if (a.status === "invited") {
actions = `<button class="btn-primary btn-sm" data-accept-ea="${a.id}">Accept</button>`;
} else if (a.status === "ready") {
actions = `<button class="btn-primary btn-sm" data-request-ea="${a.id}">Request Access</button>`;
} 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
? `<button class="btn-primary btn-sm" data-get-vault="${a.id}">View Vault</button>`
: `<span class="badge badge-info">Waiting (~${daysLeft} day${daysLeft !== 1 ? "s" : ""} left)</span>`;
}
return `<li class="share-item">
<div class="share-icon">🔐</div>
<div class="share-info">
<span class="share-name">${escHtml(a.grantor_email || "Unknown")}</span>
<span class="share-meta">Status: ${escHtml(a.status)} · Wait: ${a.wait_days} day(s)</span>
</div>
<div class="share-actions">${actions}</div>
</li>`;
})
.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 =
'<li class="vault-empty">No items in emergency vault.</li>';
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 `<li class="em-vault-item">
<button type="button" class="em-vault-item-header" data-em-idx="${idx}" aria-expanded="false">
<span class="em-vault-icon">${icon}</span>
<span class="em-vault-name">${escHtml(item.name)}</span>
<span class="em-vault-type">${escHtml(item.item_type)}</span>
<span class="em-vault-chevron">▸</span>
</button>
<div class="em-vault-item-body hidden" id="em-vault-body-${idx}">
${
hasFields
? '<div class="detail-body em-detail-body"></div>'
: '<p class="vault-empty">Could not decrypt this item.</p>'
}
</div>
</li>`;
})
.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 = `
<div class="modal gen-modal-inner">
<div class="modal-header">
<h3>⚡ Password Generator</h3>
<button class="btn-icon btn-close" id="btn-close-gen-modal" aria-label="Close">✕</button>
</div>
<div class="form-group gen-form-group">
<label>Length: <strong id="gen-length-display">20</strong></label>
<input type="range" id="gen-length" min="8" max="64" value="20">
</div>
<div class="gen-options">
<label class="gen-opt"><input type="checkbox" id="gen-upper" checked> Uppercase</label>
<label class="gen-opt"><input type="checkbox" id="gen-lower" checked> Lowercase</label>
<label class="gen-opt"><input type="checkbox" id="gen-digits" checked> Numbers</label>
<label class="gen-opt"><input type="checkbox" id="gen-symbols" checked> Symbols</label>
</div>
<div class="form-group">
<label>Generated Password</label>
<div class="input-with-toggle gen-input-wrap">
<input type="text" id="gen-output" readonly>
<button type="button" class="btn-show-pass" id="btn-gen-copy" title="Copy">📋</button>
</div>
<div id="gen-strength" class="password-strength"></div>
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" id="btn-close-gen-modal2">Close</button>
<button type="button" class="btn-primary" id="btn-regenerate">↺ Regenerate</button>
</div>
</div>`;
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 = '<p class="settings-desc">No passkeys registered yet.</p>';
} 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 `<div class="passkey-item" data-cred-id="${c.id}">
<div class="passkey-info">
<span class="passkey-name">${escHtml(c.name)}</span>
<span class="passkey-meta">${typeLabel} · ${lastUsed} · Added ${escHtml(created)}</span>
</div>
<div class="passkey-actions">
<button class="btn-text btn-sm btn-rename-passkey" data-id="${c.id}" data-name="${escHtml(c.name)}">Rename</button>
<button class="btn-danger-text btn-sm btn-delete-passkey" data-id="${c.id}" data-name="${escHtml(c.name)}">Remove</button>
</div>
</div>`;
})
.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 = '<p class="settings-desc">Could not load passkeys.</p>';
}
// 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 =
'<p style="color:#6b7280;font-size:12px;">No activity recorded yet.</p>';
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 = `
<span style="font-size:12px;${isAlert ? "color:#b91c1c;font-weight:600;" : "color:#111827;"}">${label}</span>
<span style="font-size:11px;color:#9ca3af;white-space:nowrap;text-align:right;">${date}<br>${e.ip_address || ""}</span>`;
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. <span style="font-size:12px;color:#6b7280;">(${remaining} backup code${remaining !== 1 ? "s" : ""} remaining)</span>`;
actions.innerHTML =
'<button class="btn-secondary" id="btn-mfa-disable">Disable MFA</button>' +
' <button class="btn-secondary" id="btn-regen-backup-codes">Regenerate backup codes</button>';
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 =
'<button class="btn-primary" id="btn-mfa-setup">Set up MFA</button>';
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) =>
`<code style="display:inline-block;background:#f3f4f6;border-radius:4px;padding:3px 8px;font-family:monospace;font-size:13px;letter-spacing:0.05em;">${c}</code>`,
)
.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 = `
<div style="background:#fff;border-radius:12px;padding:28px 24px;max-width:420px;width:90%;box-shadow:0 8px 32px rgba(0,0,0,0.22);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<h3 style="margin:0 0 8px;font-size:16px;color:#111827;">
${isFirstTime ? "🔐 MFA enabled — save your backup codes" : "🔐 New backup codes"}
</h3>
${
isFirstTime
? `<p style="font-size:13px;color:#374151;margin-bottom:12px;">
These codes let you sign in if you lose access to your authenticator app.
<strong>Save them now — they will not be shown again.</strong>
</p>`
: `<p style="font-size:13px;color:#374151;margin-bottom:12px;">
Your previous codes have been invalidated. Save these new codes securely.
</p>`
}
<div style="background:#f9fafb;border:1px solid #e5e7eb;border-radius:8px;padding:12px;display:flex;flex-wrap:wrap;gap:6px;margin-bottom:16px;">
${codesHtml}
</div>
<div style="display:flex;gap:10px;justify-content:flex-end;">
<button id="__pk_copy_codes__" style="padding:8px 16px;border:1px solid #d1d5db;border-radius:7px;background:#fff;cursor:pointer;font-size:13px;">Copy all</button>
<button id="__pk_close_backup__" style="padding:8px 16px;border:none;border-radius:7px;background:#c0392b;color:#fff;cursor:pointer;font-size:13px;font-weight:600;">I've saved them</button>
</div>
</div>`;
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 =
'<button class="btn-secondary" id="btn-regen-keys">Regenerate keys</button>';
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 =
'<button class="btn-primary" id="btn-gen-keys">Generate sharing keys</button>';
document
.getElementById("btn-gen-keys")
?.addEventListener("click", handleGenerateKeys);
}
} catch (err) {
console.error("loadSharingKeysStatus:", err);
}
}
async function handleGenerateKeys() {
const vaultKey = VaultSession.getKey();
if (!vaultKey) {
showToast("Vault is locked. Unlock first.", "error");
return;
}
try {
const keyPair = await SharingCrypto.generateKeyPair();
const public_key = await SharingCrypto.exportPublicKey(keyPair.publicKey);
const { private_key_enc, private_key_iv } =
await SharingCrypto.encryptPrivateKey(vaultKey, keyPair.privateKey);
const res = await apiFetch("/api/sharing/keys", {
method: "POST",
body: JSON.stringify({ public_key, private_key_enc, private_key_iv }),
});
if (!res) return;
// Cache private key in session
SharingSession.setKey(keyPair.privateKey);
showToast("Sharing keys generated");
await loadSharingKeysStatus();
} catch (err) {
showToast("Key generation failed: " + err.message, "error");
}
}
// ── Change Password ───────────────────────────────────────────────────────
async function handleChangePassword() {
const cpError = document.getElementById("cp-error");
cpError.classList.add("hidden");
const currentPass = document.getElementById("cp-current").value;
const newPass = document.getElementById("cp-new").value;
const confirmPass = document.getElementById("cp-confirm").value;
if (!currentPass || !newPass || !confirmPass) {
cpError.textContent = "All fields are required.";
cpError.classList.remove("hidden");
return;
}
if (newPass !== confirmPass) {
cpError.textContent = "New passwords do not match.";
cpError.classList.remove("hidden");
return;
}
if (newPass.length < 12) {
cpError.textContent = "New password must be at least 12 characters.";
cpError.classList.remove("hidden");
return;
}
if (currentPass === newPass) {
cpError.textContent =
"New password must be different from the current password.";
cpError.classList.remove("hidden");
return;
}
const btn = document.getElementById("btn-change-password");
const originalText = btn.textContent;
btn.disabled = true;
btn.textContent = "Re-encrypting vault…";
try {
const vaultKey = VaultSession.getKey();
if (!vaultKey) {
showToast("Vault is locked. Reload and unlock first.", "error");
return;
}
// Fetch current user email from session storage
const encKeySalt = sessionStorage.getItem("enc_key_salt");
// We need the email to derive auth hashes — fetch it from the profile
const profileRes = await apiFetch("/api/auth/me");
if (!profileRes) return;
const profile = await profileRes.json();
const email = profile.email;
// Derive both auth hashes
const currentAuthHash = await Crypto.deriveAuthHash(currentPass, email);
const newAuthHash = await Crypto.deriveAuthHash(newPass, email);
const newEncKeySalt = Crypto.generateSalt(16);
const newVaultKey = await Crypto.deriveVaultKey(newPass, newEncKeySalt);
// Fetch all vault items and re-encrypt
const itemsRes = await apiFetch("/api/vault");
if (!itemsRes) return;
const items = await itemsRes.json();
btn.textContent = `Re-encrypting ${items.length} item(s)…`;
const reEncrypted = [];
for (const item of items) {
const plain = await Crypto.decryptItem(
vaultKey,
item.enc_data,
item.iv,
);
const { enc_data, iv } = await Crypto.encryptItem(newVaultKey, plain);
// Re-encrypt the name if it was previously encrypted.
let encNamePayload = {};
if (item.enc_name && item.iv_name) {
const plainName = await Crypto.decryptName(
vaultKey,
item.enc_name,
item.iv_name,
);
if (plainName) {
const { enc_name, iv_name } = await Crypto.encryptName(
newVaultKey,
plainName,
);
encNamePayload = { enc_name, iv_name };
}
}
reEncrypted.push({ id: item.id, enc_data, iv, ...encNamePayload });
}
// Submit atomic password change
const res = await apiFetch("/api/auth/change-password", {
method: "POST",
body: JSON.stringify({
current_auth_hash: currentAuthHash,
new_auth_hash: newAuthHash,
new_enc_key_salt: newEncKeySalt,
items: reEncrypted,
}),
});
if (!res) return;
const data = await res.json();
if (!res.ok) {
cpError.textContent = data.error || "Password change failed.";
cpError.classList.remove("hidden");
return;
}
showToast("Password changed. Please log in again.");
setTimeout(() => redirectToLogin(), 1500);
} catch (err) {
cpError.textContent = "An error occurred: " + err.message;
cpError.classList.remove("hidden");
console.error(err);
} finally {
btn.disabled = false;
btn.textContent = originalText;
}
}
// ── Account Recovery Setup ────────────────────────────────────────────────
async function loadRecoveryStatus() {
try {
const res = await apiFetch("/api/auth/recovery/status");
if (!res) return;
const data = await res.json();
const statusEl = document.getElementById("recovery-status-text");
const actionsEl = document.getElementById("recovery-actions");
if (data.recovery_configured) {
statusEl.textContent =
"✅ A recovery code is configured for your account.";
actionsEl.innerHTML =
'<button class="btn-secondary" id="btn-regen-recovery">Generate new recovery code</button>';
} else {
statusEl.textContent =
"No recovery code set up. If you forget your master password, your vault cannot be recovered.";
actionsEl.innerHTML =
'<button class="btn-primary" id="btn-gen-recovery">Generate recovery code</button>';
}
document
.getElementById("btn-gen-recovery")
?.addEventListener("click", handleSetupRecovery);
document
.getElementById("btn-regen-recovery")
?.addEventListener("click", handleSetupRecovery);
} catch (err) {
console.error("loadRecoveryStatus:", err);
}
}
async function handleSetupRecovery() {
const vaultKey = VaultSession.getKey();
if (!vaultKey) {
showToast("Vault is locked. Unlock first.", "error");
return;
}
try {
const encKeySalt = sessionStorage.getItem("enc_key_salt");
if (!encKeySalt) {
showToast("Session error. Please reload.", "error");
return;
}
// Generate a random 128-bit (16-byte) recovery code displayed as hex
const rawBytes = window.crypto.getRandomValues(new Uint8Array(16));
const recoveryCode = Array.from(rawBytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
// Derive recovery key from the code
const recoveryKeyMaterial = await window.crypto.subtle.importKey(
"raw",
new TextEncoder().encode(recoveryCode),
"PBKDF2",
false,
["deriveKey"],
);
const recoveryKey = await window.crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: new TextEncoder().encode("passkeeper-recovery"),
iterations: 200_000,
hash: "SHA-256",
},
recoveryKeyMaterial,
{ name: "AES-GCM", length: 256 },
false,
["encrypt"],
);
// Encrypt enc_key_salt with recovery key
const iv = window.crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await window.crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
recoveryKey,
new TextEncoder().encode(encKeySalt),
);
function bytesToBase64(bytes) {
let bin = "";
new Uint8Array(bytes).forEach((b) => (bin += String.fromCharCode(b)));
return btoa(bin);
}
const recovery_enc_salt = bytesToBase64(ciphertext);
const recovery_iv = bytesToBase64(iv);
// Store on server
const res = await apiFetch("/api/auth/recovery/setup", {
method: "POST",
body: JSON.stringify({ recovery_enc_salt, recovery_iv }),
});
if (!res) return;
// Display the code to the user — formatted in groups of 4
const formatted = recoveryCode.match(/.{1,4}/g).join("-");
document.getElementById("recovery-code-value").textContent = formatted;
document
.getElementById("recovery-code-display")
.classList.remove("hidden");
document.getElementById("recovery-actions").innerHTML = "";
document.getElementById("recovery-status-text").textContent =
"Your new recovery code is shown below.";
} catch (err) {
showToast("Recovery code generation failed: " + err.message, "error");
console.error(err);
}
}
// ── Delete Account ────────────────────────────────────────────────────────
async function handleDeleteAccount() {
const deleteError = document.getElementById("delete-error");
deleteError.classList.add("hidden");
const password = document.getElementById("delete-password").value;
if (!password) {
deleteError.textContent = "Please enter your master password.";
deleteError.classList.remove("hidden");
return;
}
const btn = document.getElementById("btn-delete-confirm");
const originalText = btn.textContent;
btn.disabled = true;
btn.textContent = "Deleting…";
try {
const profileRes = await apiFetch("/api/auth/me");
if (!profileRes) return;
const profile = await profileRes.json();
const authHash = await Crypto.deriveAuthHash(password, profile.email);
const res = await apiFetch("/api/auth/account", {
method: "DELETE",
body: JSON.stringify({ auth_hash: authHash }),
});
if (!res) return;
const data = await res.json();
if (!res.ok) {
deleteError.textContent = data.error || "Deletion failed.";
deleteError.classList.remove("hidden");
return;
}
// Clear all local state and redirect
sessionStorage.clear();
localStorage.clear();
window.location.href = "/login";
} catch (err) {
deleteError.textContent = "An error occurred: " + err.message;
deleteError.classList.remove("hidden");
console.error(err);
} finally {
btn.disabled = false;
btn.textContent = originalText;
}
}
// ── Folder CRUD ───────────────────────────────────────────────────────────
function showNewFolderRow() {
document.getElementById("new-folder-row").classList.remove("hidden");
document.getElementById("new-folder-input").focus();
}
function hideNewFolderRow() {
document.getElementById("new-folder-row").classList.add("hidden");
document.getElementById("new-folder-input").value = "";
}
async function handleNewFolder(e) {
e.preventDefault();
const name = document.getElementById("new-folder-input").value.trim();
if (!name) return;
try {
const res = await apiFetch("/api/folders", {
method: "POST",
body: JSON.stringify({ name }),
});
if (!res) return;
hideNewFolderRow();
await loadFolders();
showToast("Folder created");
} catch (err) {
showToast("Could not create folder: " + err.message, "error");
}
}
async function confirmDeleteFolder(folder) {
if (
!confirm(
`Delete folder "${folder.name}"?\nItems will be moved to No Folder.`,
)
)
return;
try {
const res = await apiFetch(`/api/folders/${folder.id}`, {
method: "DELETE",
});
if (!res) return;
_items = _items.map((i) =>
i.folder_id === folder.id ? { ...i, folder_id: null } : i,
);
if (
_activeFilter?.type === "folder" &&
_activeFilter.value === folder.id
) {
_activeFilter = null;
updateVaultTitle("All Items");
}
await loadFolders();
applyCurrentFilter();
showToast("Folder deleted");
} catch (err) {
showToast("Could not delete folder: " + err.message, "error");
}
}
// ── Filtering / Search ────────────────────────────────────────────────────
function applyCurrentFilter() {
if (!_activeFilter) {
renderItemList(_items);
return;
}
if (_activeFilter.type === "itemType")
renderItemList(_items.filter((i) => i.item_type === _activeFilter.value));
else if (_activeFilter.type === "folder")
renderItemList(_items.filter((i) => i.folder_id === _activeFilter.value));
else if (_activeFilter.type === "tag")
renderItemList(
_items.filter((i) =>
(i.plain?.tags || []).includes(_activeFilter.value),
),
);
}
function filterByFolder(folderId, name) {
_activeFilter = { type: "folder", value: folderId };
switchView("vault");
document
.querySelectorAll(".sidebar-item")
.forEach((el) => el.classList.remove("active"));
document
.querySelector(`[data-folder-id="${folderId}"]`)
?.classList.add("active");
updateVaultTitle(name);
renderItemList(_items.filter((i) => i.folder_id === folderId));
}
function handleSearch(e) {
const q = e.target.value.trim().toLowerCase();
if (!q) {
applyCurrentFilter();
return;
}
const pool = !_activeFilter
? _items
: _activeFilter.type === "itemType"
? _items.filter((i) => i.item_type === _activeFilter.value)
: _activeFilter.type === "folder"
? _items.filter((i) => i.folder_id === _activeFilter.value)
: _activeFilter.type === "tag"
? _items.filter((i) =>
(i.plain?.tags || []).includes(_activeFilter.value),
)
: _items;
renderItemList(
pool.filter((item) => {
const p = item.plain || {};
return (
item.name.toLowerCase().includes(q) ||
(p.username || "").toLowerCase().includes(q) ||
(p.url || "").toLowerCase().includes(q) ||
(p.note_body || "").toLowerCase().includes(q) ||
(p.rp_id || "").toLowerCase().includes(q) ||
(p.notes || "").toLowerCase().includes(q) ||
(p.cardholder_name || "").toLowerCase().includes(q) ||
(p.email || "").toLowerCase().includes(q) ||
(p.tags || []).some((t) => t.toLowerCase().includes(q))
);
}),
);
}
function updateVaultTitle(text) {
const el = document.getElementById("vault-title");
if (el) el.textContent = text;
}
// ── Item Modal ────────────────────────────────────────────────────────────
const TYPE_LABELS = {
password: "🔑 Password",
note: "📝 Secure Note",
card: "💳 Payment Card",
bank: "🏦 Bank Account",
address: "🏠 Address",
ssn: "🪪 Identity (SSN)",
passkey: "🔐 Passkey",
};
function switchModalType(type) {
document.querySelectorAll(".type-fields").forEach((el) => {
el.classList.toggle(
"hidden",
!el.dataset.forTypes.split(" ").includes(type),
);
});
}
function openModal(mode, item = null) {
const form = document.getElementById("item-form");
form.reset();
form.dataset.mode = mode;
form.dataset.itemId = item ? item.id : "";
// Always start Advanced Settings collapsed when the modal opens.
const advBody = document.getElementById("adv-settings-body");
const advToggle = document.getElementById("adv-settings-toggle");
if (advBody) advBody.classList.add("hidden");
if (advToggle) {
advToggle.setAttribute("aria-expanded", "false");
const chevron = advToggle.querySelector(".adv-section-chevron");
if (chevron) chevron.textContent = "\u2964";
}
const folderSel = document.getElementById("field-folder");
folderSel.innerHTML = '<option value="">— No folder —</option>';
_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 =
`<div class="pw-history-title">🕐 Previous passwords (${history.length})</div>` +
history.map((h, idx) => {
const date = h.changed_at
? new Date(h.changed_at).toLocaleDateString()
: "Unknown date";
return `<div class="pw-history-row" data-hist-idx="${idx}">
<span class="pw-history-masked">••••••••</span>
<span class="pw-history-date">${escHtml(date)}</span>
<button class="btn-text btn-sm pw-history-reveal" title="Reveal">👁</button>
<button class="btn-text btn-sm pw-history-restore" title="Restore this password">↩ Restore</button>
</div>`;
}).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 {
const res = await apiFetch(`/api/vault/${itemId}`, {
method: "PUT",
body: JSON.stringify(payload),
});
if (!res) return;
showToast("Item updated");
}
closeModal();
await loadVault();
} catch (err) {
showToast("Save failed: " + err.message, "error");
}
}
async function confirmDeleteItem(item) {
if (!confirm(`Delete "${item.name}"? This cannot be undone.`)) return;
try {
const res = await apiFetch(`/api/vault/${item.id}`, { method: "DELETE" });
if (!res) return;
showToast("Item deleted");
await loadVault();
} catch (err) {
showToast("Delete failed: " + err.message, "error");
}
}
/**
* Toggle the "favorite" tag on a vault item.
* Re-encrypts and PUTs the item in-place, then patches _items and
* re-renders so the star flips instantly without a full vault reload.
*/
async function toggleFavorite(item) {
const vaultKey = VaultSession.getKey();
if (!vaultKey) {
showUnlockOverlay();
return;
}
if (!item.plain) {
showToast("Item could not be decrypted", "error");
return;
}
const currentTags = item.plain.tags || [];
const isFavorite = currentTags.includes("favorite");
const newTags = isFavorite
? currentTags.filter((t) => t !== "favorite")
: [...currentTags, "favorite"].sort();
const updatedPlain = { ...item.plain, tags: newTags };
try {
const { enc_data, iv } = await Crypto.encryptItem(vaultKey, updatedPlain);
const { enc_name, iv_name } = await Crypto.encryptName(vaultKey, item.name);
const payload = {
name: item.item_type,
item_type: item.item_type,
folder_id: item.folder_id || null,
enc_data,
iv,
enc_name,
iv_name,
};
const res = await apiFetch(`/api/vault/${item.id}`, {
method: "PUT",
body: JSON.stringify(payload),
});
if (!res) return;
// Patch the in-memory item so the re-render is instant.
const idx = _items.findIndex((i) => i.id === item.id);
if (idx !== -1) {
_items[idx] = { ..._items[idx], plain: updatedPlain };
}
showToast(isFavorite ? "Removed from favorites" : "Added to favorites ★");
// Refresh the sidebar tag list ("favorite" tag may appear/disappear).
renderTagList();
// Re-render the current view preserving the active filter.
applyCurrentFilter();
} catch (err) {
showToast("Could not update favorite: " + err.message, "error");
}
}
// ── Logout ────────────────────────────────────────────────────────────────
async function handleLogout() {
const refreshToken = localStorage.getItem("refresh_token");
await fetch("/api/auth/logout", {
method: "POST",
headers: {
Authorization: `Bearer ${sessionStorage.getItem("access_token") || ""}`,
"Content-Type": "application/json",
"X-CSRFToken": csrfToken(),
},
body: JSON.stringify({ refresh_token: refreshToken }),
}).catch(() => {});
VaultSession.clear();
SharingSession.clear();
sessionStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
window.dispatchEvent(new CustomEvent("passkeeper:logout"));
window.location.href = "/login";
}
// ── Utilities ─────────────────────────────────────────────────────────────
// ── TOTP (RFC 6238) ── pure Web Crypto, no external library ──────────────
/**
* Extract the base32 secret from either a plain base32 string or an
* otpauth://totp/... URI (both are common when users scan a QR code).
*/
function extractTotpSecret(uri) {
if (!uri) return null;
uri = uri.trim();
if (uri.startsWith("otpauth://")) {
try {
const secret = new URL(uri).searchParams.get("secret");
return secret ? secret.toUpperCase().replace(/\s+/g, "") : null;
} catch (e) {
return null;
}
}
// Plain base32 secret (spaces and hyphens stripped for convenience).
return uri.toUpperCase().replace(/[\s-]/g, "") || null;
}
/** Decode a base32 string to a Uint8Array. */
function base32ToBytes(b32) {
const CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
b32 = b32.replace(/=+$/, "");
let bits = 0,
val = 0;
const out = [];
for (const c of b32) {
const idx = CHARS.indexOf(c);
if (idx === -1) continue;
val = (val << 5) | idx;
bits += 5;
if (bits >= 8) {
bits -= 8;
out.push((val >> bits) & 0xff);
}
}
return new Uint8Array(out);
}
/**
* Compute the current TOTP code (6 digits, 30-second window).
* Returns a Promise<string>.
*/
async function getTotpCode(secret) {
const keyBytes = base32ToBytes(secret);
if (!keyBytes.length) return null;
const counter = Math.floor(Date.now() / 1000 / 30);
const msg = new Uint8Array(8);
// Write counter as big-endian 64-bit integer.
let c = counter;
for (let i = 7; i >= 0; i--) {
msg[i] = c & 0xff;
c = Math.floor(c / 256);
}
const cryptoKey = await crypto.subtle.importKey(
"raw",
keyBytes,
{ name: "HMAC", hash: "SHA-1" },
false,
["sign"],
);
const sig = new Uint8Array(
await crypto.subtle.sign("HMAC", cryptoKey, msg),
);
const offset = sig[19] & 0x0f;
const code =
(((sig[offset] & 0x7f) << 24) |
(sig[offset + 1] << 16) |
(sig[offset + 2] << 8) |
sig[offset + 3]) %
1_000_000;
return String(code).padStart(6, "0");
}
/** Seconds remaining in the current 30-second TOTP window. */
function totpSecondsLeft() {
return 30 - (Math.floor(Date.now() / 1000) % 30);
}
// Track the clipboard clear timer so multiple rapid copies don't stack.
let _clipboardClearTimer = null;
function copyToClipboard(text, msg) {
navigator.clipboard
.writeText(text)
.then(() => {
showToast(msg);
// Auto-clear clipboard after 30 seconds — industry-standard hygiene.
if (_clipboardClearTimer) clearTimeout(_clipboardClearTimer);
_clipboardClearTimer = setTimeout(() => {
navigator.clipboard.writeText("").catch(() => {});
_clipboardClearTimer = null;
}, 30_000);
})
.catch(() => {});
}
function escHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function itemIcon(type) {
return (
{
password: "🔑",
note: "📝",
card: "💳",
bank: "🏦",
address: "🏠",
ssn: "🪪",
passkey: "🔐",
}[type] || "🔑"
);
}
function showToast(message, type = "success") {
const toast = document.getElementById("toast");
if (!toast) return;
toast.textContent = message;
toast.className = `toast toast-${type} show`;
clearTimeout(toast._timer);
toast._timer = setTimeout(() => toast.classList.remove("show"), 2500);
}
function showLoadingState(loading) {
document
.getElementById("vault-spinner")
?.classList.toggle("hidden", !loading);
}
// ── Unlock overlay ────────────────────────────────────────────────────────
function showUnlockOverlay() {
document.getElementById("unlock-overlay").classList.add("open");
document.getElementById("unlock-password").focus();
}
async function handleUnlock(e) {
e.preventDefault();
const password = document.getElementById("unlock-password").value;
const enc_key_salt = sessionStorage.getItem("enc_key_salt");
const btn = e.target.querySelector('[type="submit"]');
const errEl = document.getElementById("unlock-error");
if (!enc_key_salt) {
redirectToLogin();
return;
}
btn.disabled = true;
btn.textContent = "Unlocking…";
errEl.classList.add("hidden");
try {
const vaultKey = await Crypto.deriveVaultKey(password, enc_key_salt);
VaultSession.setKey(vaultKey);
document.getElementById("unlock-overlay").classList.remove("open");
await loadFolders();
await loadVault();
} catch {
errEl.classList.remove("hidden");
} finally {
btn.disabled = false;
btn.textContent = "Unlock";
document.getElementById("unlock-password").value = "";
}
}
// ── Tabs ──────────────────────────────────────────────────────────────────
function initTabs(container) {
const tabs = container.querySelectorAll(".tab-btn");
tabs.forEach((btn) => {
btn.addEventListener("click", () => {
tabs.forEach((t) => t.classList.remove("active"));
btn.classList.add("active");
const target = btn.dataset.tab;
container.querySelectorAll(".tab-pane").forEach((pane) => {
pane.classList.toggle("hidden", !pane.id.endsWith(target));
});
});
});
}
// ── Init ──────────────────────────────────────────────────────────────────
async function init() {
if (!sessionStorage.getItem("access_token")) {
// Give the extension bridge up to 800 ms to inject a session before redirecting
await new Promise((resolve) => {
const timer = setTimeout(resolve, 800);
window.addEventListener(
"passkeeper:ext-login",
() => {
clearTimeout(timer);
resolve();
},
{ once: true },
);
});
// Still no token — try a silent refresh using the persisted refresh_token
// before giving up and redirecting. This handles hard-refresh / new-tab scenarios.
if (!sessionStorage.getItem("access_token")) {
const refreshed = await tryRefreshToken();
if (!refreshed) {
window.location.href = "/login";
return;
}
}
}
// Vault
document
.getElementById("unlock-form")
?.addEventListener("submit", handleUnlock);
document
.getElementById("btn-new-folder")
?.addEventListener("click", showNewFolderRow);
document
.getElementById("btn-cancel-folder")
?.addEventListener("click", hideNewFolderRow);
document
.getElementById("new-folder-form")
?.addEventListener("submit", handleNewFolder);
document
.getElementById("search-input")
?.addEventListener("input", handleSearch);
document
.getElementById("btn-add-item")
?.addEventListener("click", () => openModal("add"));
document
.getElementById("btn-close-modal")
?.addEventListener("click", () => closeModal());
document
.getElementById("btn-cancel-modal")
?.addEventListener("click", () => closeModal());
document
.getElementById("item-form")
?.addEventListener("submit", handleFormSubmit);
document
.getElementById("btn-logout")
?.addEventListener("click", handleLogout);
document.getElementById("sort-select")?.addEventListener("change", (e) => {
_sortOrder = e.target.value;
applyCurrentFilter();
});
document.getElementById("field-type")?.addEventListener("change", (e) => {
switchModalType(e.target.value);
if (e.target.value === "password") initPasswordFieldEnhancements();
});
document
.getElementById("btn-toggle-pass")
?.addEventListener("click", () => {
const el = document.getElementById("field-password");
el.type = el.type === "password" ? "text" : "password";
});
document.getElementById("btn-toggle-ssn")?.addEventListener("click", () => {
const el = document.getElementById("field-ssn-number");
el.type = el.type === "password" ? "text" : "password";
});
// Live tag preview in the item modal.
document.getElementById("field-tags")?.addEventListener("input", (e) => {
const preview = document.getElementById("field-tags-preview");
if (!preview) return;
const tags = _parseTags(e.target.value);
preview.innerHTML = tags
.map((t) => `<span class="item-tag">${escHtml(t)}</span>`)
.join("");
});
// Advanced Settings collapsible toggle
document
.getElementById("adv-settings-toggle")
?.addEventListener("click", () => {
const body = document.getElementById("adv-settings-body");
const btn = document.getElementById("adv-settings-toggle");
const chevron = btn.querySelector(".adv-section-chevron");
const isOpen = !body.classList.contains("hidden");
body.classList.toggle("hidden", isOpen);
btn.setAttribute("aria-expanded", String(!isOpen));
chevron.textContent = isOpen ? "\u2964" : "\u2963";
});
// Sidebar navigation
document.getElementById("sidebar-all")?.addEventListener("click", () => {
_activeFilter = null;
switchView("vault");
document
.querySelectorAll(".sidebar-item")
.forEach((el) => el.classList.remove("active"));
document.getElementById("sidebar-all").classList.add("active");
updateVaultTitle("All Items");
renderItemList(_items);
});
document.getElementById("sidebar-favorites")?.addEventListener("click", () => {
_activeFilter = { type: "tag", value: "favorite" };
switchView("vault");
document
.querySelectorAll(".sidebar-item")
.forEach((el) => el.classList.remove("active"));
document.getElementById("sidebar-favorites").classList.add("active");
updateVaultTitle("⭐ Favorites");
renderItemList(_items.filter((i) => (i.plain?.tags || []).includes("favorite")));
});
document
.getElementById("sidebar-security")
?.addEventListener("click", () => switchView("security"));
document
.getElementById("sidebar-sharing")
?.addEventListener("click", () => switchView("sharing"));
document
.getElementById("sidebar-emergency")
?.addEventListener("click", () => switchView("emergency"));
document
.getElementById("sidebar-import-export")
?.addEventListener("click", () => switchView("import-export"));
document
.getElementById("sidebar-generator")
?.addEventListener("click", () => openPasswordGeneratorModal());
document.querySelectorAll("[data-type-filter]").forEach((el) => {
el.addEventListener("click", () => {
const type = el.dataset.typeFilter;
const labels = {
password: "Passwords",
note: "Secure Notes",
card: "Payment Cards",
bank: "Bank Accounts",
address: "Addresses",
ssn: "Identities",
passkey: "Passkeys",
};
_activeFilter = { type: "itemType", value: type };
switchView("vault");
document
.querySelectorAll(".sidebar-item")
.forEach((s) => s.classList.remove("active"));
el.classList.add("active");
updateVaultTitle(labels[type] || type);
renderItemList(_items.filter((i) => i.item_type === type));
});
});
document.getElementById("item-modal")?.addEventListener("click", (e) => {
/* backdrop click intentionally disabled — modal closes only via Cancel or Save */
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
closeModal("share-modal");
closeModal("emergency-modal");
closeModal("settings-modal");
closeModal("detail-modal");
closeModal("gen-modal");
}
});
// Detail modal (shared item view)
const closeDetailModal = () => closeModal("detail-modal");
document
.getElementById("btn-close-detail-modal")
?.addEventListener("click", closeDetailModal);
document
.getElementById("btn-detail-done")
?.addEventListener("click", closeDetailModal);
document.getElementById("detail-modal")?.addEventListener("click", (e) => {
if (e.target.id === "detail-modal") closeDetailModal();
});
document
.getElementById("field-card-number")
?.addEventListener("input", (e) => {
let v = e.target.value.replace(/\D/g, "").slice(0, 16);
e.target.value = v.replace(/(.{4})/g, "$1 ").trim();
});
// Settings modal
document
.getElementById("btn-settings")
?.addEventListener("click", openSettingsModal);
document
.getElementById("btn-close-settings")
?.addEventListener("click", () => closeModal("settings-modal"));
// Web-app session timeout setting.
document
.getElementById("web-idle-select")
?.addEventListener("change", (e) => {
const mins = parseInt(e.target.value, 10);
localStorage.setItem(WEB_IDLE_KEY, mins);
_resetWebIdleTimer();
const label = mins === 0 ? "never" : mins + " min";
showToast("Auto-lock set to " + label + ".");
console.log("[PassKeeper] Web idle timeout set to", label);
});
document
.getElementById("settings-modal")
?.addEventListener("click", (e) => {
if (e.target.id === "settings-modal") closeModal("settings-modal");
});
document
.getElementById("btn-mfa-confirm")
?.addEventListener("click", handleMfaConfirm);
document
.getElementById("btn-mfa-cancel-setup")
?.addEventListener("click", () => {
document.getElementById("mfa-setup-area").classList.add("hidden");
loadMfaStatus();
});
document
.getElementById("btn-mfa-disable-confirm")
?.addEventListener("click", handleMfaDisableConfirm);
document
.getElementById("btn-mfa-disable-cancel")
?.addEventListener("click", () => {
document.getElementById("mfa-disable-area").classList.add("hidden");
loadMfaStatus();
});
document
.getElementById("btn-change-password")
?.addEventListener("click", handleChangePassword);
document
.getElementById("btn-delete-account")
?.addEventListener("click", () => {
document.getElementById("btn-delete-account").classList.add("hidden");
document
.getElementById("delete-confirm-area")
.classList.remove("hidden");
document.getElementById("delete-password").focus();
});
document
.getElementById("btn-delete-cancel")
?.addEventListener("click", () => {
document.getElementById("delete-confirm-area").classList.add("hidden");
document
.getElementById("btn-delete-account")
.classList.remove("hidden");
document.getElementById("delete-password").value = "";
document.getElementById("delete-error").classList.add("hidden");
});
document
.getElementById("btn-delete-confirm")
?.addEventListener("click", handleDeleteAccount);
document
.getElementById("btn-copy-recovery-code")
?.addEventListener("click", () => {
const code = document.getElementById("recovery-code-value").textContent;
navigator.clipboard
.writeText(code)
.then(() => showToast("Recovery code copied"));
});
document
.getElementById("btn-recovery-done")
?.addEventListener("click", () => {
document
.getElementById("recovery-code-display")
.classList.add("hidden");
loadRecoveryStatus();
});
// Share modal
document
.getElementById("btn-share-item")
?.addEventListener("click", openShareModal);
document
.getElementById("btn-close-share-modal")
?.addEventListener("click", () => closeModal("share-modal"));
document
.getElementById("btn-cancel-share")
?.addEventListener("click", () => closeModal("share-modal"));
document.getElementById("share-modal")?.addEventListener("click", (e) => {
if (e.target.id === "share-modal") closeModal("share-modal");
});
document
.getElementById("share-form")
?.addEventListener("submit", handleShareFormSubmit);
document
.getElementById("btn-setup-keys-from-sharing")
?.addEventListener("click", () => {
closeModal("share-modal");
openSettingsModal();
});
// Emergency modal
document
.getElementById("btn-add-emergency")
?.addEventListener("click", () => {
document.getElementById("emergency-error").classList.add("hidden");
document.getElementById("emergency-modal").classList.add("open");
});
document
.getElementById("btn-close-emergency-modal")
?.addEventListener("click", () => closeModal("emergency-modal"));
document
.getElementById("btn-cancel-emergency")
?.addEventListener("click", () => closeModal("emergency-modal"));
document
.getElementById("emergency-modal")
?.addEventListener("click", (e) => {
if (e.target.id === "emergency-modal") closeModal("emergency-modal");
});
document
.getElementById("emergency-form")
?.addEventListener("submit", handleEmergencyFormSubmit);
// Tabs
document.querySelectorAll(".panel-tabs").forEach((c) => initTabs(c));
// Sidebar collapse toggle — desktop only (mobile uses hamburger instead)
const sidebar = document.getElementById("sidebar");
const toggleBtn = document.getElementById("btn-sidebar-toggle");
if (
sidebar &&
toggleBtn &&
document.body.classList.contains("is-desktop")
) {
const STORAGE_KEY = "sidebar_collapsed";
if (localStorage.getItem(STORAGE_KEY) === "1") {
sidebar.classList.add("collapsed");
toggleBtn.setAttribute("aria-label", "Expand sidebar");
toggleBtn.setAttribute("title", "Expand sidebar");
}
toggleBtn.addEventListener("click", () => {
const isCollapsed = sidebar.classList.toggle("collapsed");
localStorage.setItem(STORAGE_KEY, isCollapsed ? "1" : "0");
toggleBtn.setAttribute(
"aria-label",
isCollapsed ? "Expand sidebar" : "Collapse sidebar",
);
toggleBtn.setAttribute(
"title",
isCollapsed ? "Expand sidebar" : "Collapse sidebar",
);
});
}
// Mobile sidebar: hamburger opens overlay, backdrop/item-click closes it
const backdrop = document.getElementById("sidebar-backdrop");
const mobileMenuBtn = document.getElementById("btn-mobile-menu");
function openMobileSidebar() {
sidebar?.classList.add("mobile-open");
backdrop?.classList.add("open");
document.documentElement.classList.add("sidebar-open");
}
function closeMobileSidebar() {
sidebar?.classList.remove("mobile-open");
backdrop?.classList.remove("open");
document.documentElement.classList.remove("sidebar-open");
}
mobileMenuBtn?.addEventListener("click", openMobileSidebar);
backdrop?.addEventListener("click", closeMobileSidebar);
backdrop?.addEventListener("touchstart", closeMobileSidebar, {
passive: true,
});
// Close mobile sidebar when a nav item is tapped (mobile only)
document.querySelectorAll(".sidebar-item").forEach((item) => {
item.addEventListener("click", () => {
if (document.body.classList.contains("is-mobile")) closeMobileSidebar();
});
});
// Escape key closes the sidebar
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && sidebar?.classList.contains("mobile-open"))
closeMobileSidebar();
});
// Mobile FAB mirrors the desktop add-item button
document
.getElementById("btn-add-item-mobile")
?.addEventListener("click", () => {
document.getElementById("btn-add-item")?.click();
});
// ── Browser history / back-button support ─────────────────────────────────
const _hashToView = (hash) => {
const v = (hash || "").replace("#", "").trim();
const valid = [
"vault",
"security",
"sharing",
"emergency",
"import-export",
];
return valid.includes(v) ? v : "vault";
};
const _initialView = _hashToView(window.location.hash);
history.replaceState({ view: _initialView }, "", window.location.href);
window.addEventListener("popstate", (e) => {
const view = e.state?.view || _hashToView(window.location.hash);
switchView(view, { pushState: false });
});
// Start web-app inactivity tracking.
_startWebIdleTracking();
_initBulkToolbar();
if (!VaultSession.getKey()) {
showUnlockOverlay();
} else {
loadVault();
// Restore view from hash after vault loads (avoids rendering before decrypt).
if (_initialView !== "vault")
switchView(_initialView, { pushState: false });
}
}
async function handleEmergencyFormSubmit(e) {
e.preventDefault();
const errEl = document.getElementById("emergency-error");
errEl.classList.add("hidden");
const email = document
.getElementById("emergency-email")
.value.trim()
.toLowerCase();
const waitDays = document.getElementById("emergency-wait-days").value;
if (!email) {
errEl.textContent = "Email is required.";
errEl.classList.remove("hidden");
return;
}
try {
const res = await apiFetch("/api/emergency", {
method: "POST",
body: JSON.stringify({
grantee_email: email,
wait_days: parseInt(waitDays),
}),
});
if (!res) return;
closeModal("emergency-modal");
showToast("Invitation sent");
loadEmergencyView();
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove("hidden");
}
}
return {
init,
// Exposed for inline onclick on the sidebar li — ensures the click reaches
// switchView regardless of addEventListener binding order or timing.
switchToImportExport: () => switchView("import-export"),
};
})();
/**
* Mobile/desktop mode detection.
*
* Sets body.is-mobile or body.is-desktop BEFORE first paint so CSS
* scoped to those classes takes effect without any flash or transition.
* Uses matchMedia (same source of truth as CSS media queries) rather
* than window.innerWidth (unreliable on some Android Chrome builds).
*
* MOBILE_BP must match the CSS breakpoint. 768px covers all phones and
* iPad Mini portrait (744px). iPad Air portrait (820px) and larger get
* the desktop sidebar layout.
*/
(function initLayout() {
const MOBILE_BP = 768;
const mq = window.matchMedia(`(max-width: ${MOBILE_BP}px)`);
function applyMode(isMobile) {
document.body.classList.toggle("is-mobile", isMobile);
document.body.classList.toggle("is-desktop", !isMobile);
}
// Set immediately — body class is applied before CSS is rendered
applyMode(mq.matches);
// Re-apply on orientation change / window resize (e.g. split-screen, rotation)
mq.addEventListener("change", (e) => {
applyMode(e.matches);
// If switching to desktop, ensure mobile sidebar is closed
if (!e.matches) {
document.getElementById("sidebar")?.classList.remove("mobile-open");
document.getElementById("sidebar-backdrop")?.classList.remove("open");
document.documentElement.classList.remove("sidebar-open");
}
});
})();
/**
* Viewport height fix for Chrome on Android.
*
* Chrome's 100vh includes the address bar height; window.innerHeight does not.
* Setting --vh from innerHeight and using calc(var(--vh,1vh)*100) in CSS
* gives the true usable viewport height on all browsers.
*/
(function setViewportHeight() {
function update() {
document.documentElement.style.setProperty(
"--vh",
`${window.innerHeight * 0.01}px`,
);
}
update();
window.addEventListener("resize", update, { passive: true });
window.addEventListener("orientationchange", () => setTimeout(update, 100), {
passive: true,
});
if (window.ResizeObserver) {
new ResizeObserver(update).observe(document.documentElement);
}
})();
document.addEventListener("DOMContentLoaded", Vault.init);