/**
* 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 = ` `;
label.addEventListener("click", () =>
filterByFolder(folder.id, folder.name),
);
const delBtn = document.createElement("button");
delBtn.className = "btn-delete-folder";
delBtn.title = "Delete folder";
delBtn.textContent = "🗑";
delBtn.addEventListener("click", (e) => {
e.stopPropagation();
confirmDeleteFolder(folder);
});
li.appendChild(label);
li.appendChild(delBtn);
ul.appendChild(li);
});
}
function getSortedItems(items) {
const sorted = [...items];
switch (_sortOrder) {
case "name-asc":
sorted.sort((a, b) => a.name.localeCompare(b.name));
break;
case "name-desc":
sorted.sort((a, b) => b.name.localeCompare(a.name));
break;
case "date-desc":
sorted.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
break;
case "date-asc":
sorted.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
break;
case "folder":
sorted.sort(
(a, b) =>
folderName(a.folder_id).localeCompare(folderName(b.folder_id)) ||
a.name.localeCompare(b.name),
);
break;
}
return sorted;
}
function folderName(id) {
if (!id) return "(No folder)";
return _folders.find((f) => f.id === id)?.name || "Unknown";
}
/** Parse a comma-separated tag string into a sorted, deduped lowercase array. */
function _parseTags(str) {
return [
...new Set(
(str || "")
.split(",")
.map((t) => t.trim().toLowerCase())
.filter(Boolean),
),
].sort();
}
/** Collect all unique tags across all loaded items. */
function _allTags() {
const set = new Set();
_items.forEach((i) => (i.plain?.tags || []).forEach((t) => set.add(t)));
return [...set].sort();
}
/** Render the tags sidebar list. */
function renderTagList() {
const ul = document.getElementById("sidebar-tags");
if (!ul) return;
ul.innerHTML = "";
const tags = _allTags();
if (!tags.length) {
ul.innerHTML = '
';
return;
}
tags.forEach((tag) => {
const li = document.createElement("li");
li.className = "sidebar-item sidebar-tag-item";
li.dataset.tag = tag;
li.innerHTML = ``;
li.addEventListener("click", () => {
document
.querySelectorAll(".sidebar-item")
.forEach((el) => el.classList.remove("active"));
li.classList.add("active");
_activeFilter = { type: "tag", value: tag };
switchView("vault");
updateVaultTitle("#" + tag);
renderItemList(
_items.filter((i) => (i.plain?.tags || []).includes(tag)),
);
});
ul.appendChild(li);
});
}
// Persists which folder groups are collapsed across re-renders.
const _collapsedGroups = new Set();
function renderItemList(items) {
const list = document.getElementById("vault-list");
if (!list) return;
list.innerHTML = "";
// Apply or remove select-mode CSS class on the parent wrapper.
const wrapper = list.closest(".vault-list");
if (wrapper) wrapper.classList.toggle("select-mode", _selectMode);
if (items.length === 0) {
list.innerHTML =
'No items found. Click + to add one. ';
return;
}
const sorted = getSortedItems(items);
const groups = {};
sorted.forEach((item) => {
const key = folderName(item.folder_id);
if (!groups[key]) groups[key] = [];
groups[key].push(item);
});
const keys =
_sortOrder === "folder"
? Object.keys(groups).sort()
: ["(No folder)", ..._folders.map((f) => f.name)].filter(
(k) => groups[k],
);
Object.keys(groups).forEach((k) => {
if (!keys.includes(k)) keys.push(k);
});
keys.forEach((groupName) => {
if (!groups[groupName]) return;
const isCollapsed = _collapsedGroups.has(groupName);
const count = groups[groupName].length;
// ── Collapsible group header ────────────────────────────────────────────
const header = document.createElement("li");
header.className =
"vault-group-header" + (isCollapsed ? " collapsed" : "");
header.innerHTML =
`${escHtml(groupName)} ` +
`` +
`${count} ` +
`${isCollapsed ? "▶" : "▼"} ` +
` `;
header.addEventListener("click", () => {
if (_collapsedGroups.has(groupName)) {
_collapsedGroups.delete(groupName);
} else {
_collapsedGroups.add(groupName);
}
renderItemList(items); // re-render preserving collapse state
});
list.appendChild(header);
// ── Items (hidden when collapsed) ───────────────────────────────────────
if (!isCollapsed) {
groups[groupName].forEach((item) =>
list.appendChild(createItemElement(item)),
);
}
});
}
function createItemElement(item) {
const li = document.createElement("li");
li.className = "vault-item";
li.dataset.id = item.id;
const iconMap = {
password: "🔑",
note: "📝",
card: "💳",
bank: "🏦",
address: "🏠",
ssn: "🪪",
passkey: "🔐",
};
const icon = iconMap[item.item_type] || "🔑";
const itemTags = item.plain?.tags || [];
let subText = "";
if (item.plain) {
switch (item.item_type) {
case "password":
subText = item.plain.username || item.plain.url || "";
break;
case "note":
subText = (item.plain.note_body || "").slice(0, 60);
break;
case "card":
subText = item.plain.card_number
? "•••• " +
String(item.plain.card_number).replace(/\s/g, "").slice(-4)
: "";
break;
case "bank":
subText = item.plain.bank_name || "";
break;
case "address":
subText = [item.plain.first_name, item.plain.last_name]
.filter(Boolean)
.join(" ");
break;
case "ssn":
subText = "•••-••-••••";
break;
case "passkey":
subText = [
item.plain.username,
item.plain.rp_id ? `@ ${item.plain.rp_id}` : "",
]
.filter(Boolean)
.join(" ");
break;
}
}
const showCopyUser = item.item_type === "password";
const showCopyPass = ["password", "card", "bank", "ssn"].includes(
item.item_type,
);
const showLaunch = item.item_type === "password" && item.plain?.url;
const showTotp =
item.item_type === "password" &&
!!extractTotpSecret(item.plain?.totp_uri);
const isFavorite = itemTags.includes("favorite");
const visibleTags = itemTags.filter((t) => t !== "favorite");
const tagBadges = visibleTags
.map((t) => `${escHtml(t)} `)
.join("");
li.innerHTML = `
${icon}
${escHtml(item.name)}
${escHtml(subText)}
${tagBadges ? `
${tagBadges}
` : ""}
${showTotp ? `
······ ` : ""}
${showLaunch ? `🌐 ` : ""}
${showCopyUser ? `👤 ` : ""}
${showCopyPass ? `📋 ` : ""}
${showTotp ? `🔐 ` : ""}
${isFavorite ? "★" : "☆"}
✏️
🗑️
`;
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 =
'— No folder — ' +
_folders.map((f) => `${escHtml(f.name)} `).join("");
sel.addEventListener("change", async () => {
const folderId = sel.value ? parseInt(sel.value) : null;
sel.remove();
try {
await Promise.all(
[..._selectedIds].map((id) =>
apiFetch(`/api/vault/${id}`, {
method: "PUT",
body: JSON.stringify({ folder_id: folderId }),
}),
),
);
const dest = folderId
? (_folders.find((f) => f.id === folderId)?.name || "folder")
: "root";
showToast(`${_selectedIds.size} item${_selectedIds.size !== 1 ? "s" : ""} moved to ${dest}`);
_exitSelectMode();
await loadVault();
} catch (err) {
showToast("Move failed: " + err.message, "error");
}
});
// Insert after the Move button.
const moveBtn = document.getElementById("btn-bulk-move");
moveBtn?.after(sel);
sel.focus();
}
async function _handleBulkExport() {
const ids = new Set(_selectedIds);
if (!ids.size) return;
const vaultKey = VaultSession.getKey();
if (!vaultKey) { showUnlockOverlay(); return; }
const selected = _items.filter((i) => ids.has(i.id));
const envelope = {
version: 1,
exported_at: new Date().toISOString(),
items: selected.map((i) => ({
id: i.id,
name: i.name,
item_type: i.item_type,
folder_id: i.folder_id,
enc_data: i.enc_data,
iv: i.iv,
enc_name: i.enc_name || null,
iv_name: i.iv_name || null,
})),
};
const blob = new Blob([JSON.stringify(envelope, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `passkeeper-selection-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast(`${selected.length} item${selected.length !== 1 ? "s" : ""} exported`);
// Log the export server-side for the audit trail.
apiFetch("/api/vault/audit-export", {
method: "POST",
body: JSON.stringify({ item_ids: selected.map((i) => i.id) }),
}).catch(() => {}); // best-effort — don't block on this
_exitSelectMode();
}
function _initBulkToolbar() {
document.getElementById("btn-select-mode")?.addEventListener("click", () => {
if (_selectMode) _exitSelectMode();
else _enterSelectMode();
});
document.getElementById("btn-bulk-cancel")?.addEventListener("click", _exitSelectMode);
document.getElementById("btn-bulk-delete")?.addEventListener("click", _handleBulkDelete);
document.getElementById("btn-bulk-move")?.addEventListener("click", _handleBulkMove);
document.getElementById("btn-bulk-export")?.addEventListener("click", _handleBulkExport);
document.getElementById("btn-bulk-select-all")?.addEventListener("click", () => {
// Select all currently visible items (from the rendered list).
const list = document.getElementById("vault-list");
if (!list) return;
const checkboxes = list.querySelectorAll(".item-checkbox");
const allChecked = [...checkboxes].every((cb) => cb.checked);
checkboxes.forEach((cb) => {
cb.checked = !allChecked;
const id = parseInt(cb.dataset.id);
if (!allChecked) {
_selectedIds.add(id);
cb.closest(".vault-item")?.classList.add("item-selected");
} else {
_selectedIds.delete(id);
cb.closest(".vault-item")?.classList.remove("item-selected");
}
});
_updateBulkToolbar();
});
}
// ── Background vault health checks ──────────────────────────────────────────
//
// Runs after every vault load. Computes weak/reused counts synchronously,
// then fires HIBP checks in parallel. Updates the Security sidebar badge
// and a dismissible top banner without requiring the user to open the
// Security tab. Results are cached so re-opening the tab skips re-checking.
let _healthCache = null; // { weak, reused, breached } — populated after first run
let _hibpRunning = false; // prevents concurrent HIBP runs
async function runBackgroundHealthCheck() {
if (_hibpRunning) return;
_hibpRunning = true;
_healthCache = null;
try {
const pwItems = _items.filter(
(i) => i.item_type === "password" && i.plain?.password,
);
if (!pwItems.length) {
_updateHealthUI({ weak: 0, reused: 0, breached: 0 });
return;
}
// Synchronous metrics — instant.
const weak = pwItems.filter((i) => {
const p = i.plain.password;
if (p.length < 10) return true;
return (
[/[A-Z]/, /[a-z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((r) => r.test(p))
.length < 2
);
});
const passCounts = {};
pwItems.forEach((i) => {
const p = i.plain.password;
(passCounts[p] = passCounts[p] || []).push(i);
});
const reused = Object.values(passCounts)
.filter((a) => a.length > 1)
.flat();
// Update badge immediately with sync results — HIBP will update again.
_updateHealthUI({ weak: weak.length, reused: reused.length, breached: null });
// HIBP — k-anonymity, runs in parallel.
const hibpResults = await Promise.all(
pwItems.map(async (item) => ({
item,
count: await checkHibp(item.plain.password),
})),
);
const breachedItems = hibpResults.filter((r) => r.count > 0).map((r) => r.item);
_healthCache = {
weak: weak.length,
reused: reused.length,
breached: breachedItems.length,
breachedItems,
hibpResults,
};
_updateHealthUI({
weak: weak.length,
reused: reused.length,
breached: breachedItems.length,
});
} catch (err) {
console.error("[PassKeeper] Background health check failed:", err);
} finally {
_hibpRunning = false;
}
}
function _updateHealthUI({ weak, reused, breached }) {
const badge = document.getElementById("security-badge");
const banner = document.getElementById("health-banner");
if (!badge || !banner) return;
const knownBreached = breached !== null;
const issueCount =
(weak || 0) + (reused || 0) + (knownBreached ? (breached || 0) : 0);
const hasBreaches = knownBreached && breached > 0;
const hasSyncIssues = (weak || 0) + (reused || 0) > 0;
// ── Sidebar badge ──────────────────────────────────────────────────────
if (issueCount > 0) {
badge.textContent = issueCount > 99 ? "99+" : String(issueCount);
badge.classList.remove("hidden", "badge-warn");
if (hasBreaches) {
badge.classList.remove("badge-warn"); // red (default)
} else {
badge.classList.add("badge-warn"); // amber
}
} else {
badge.classList.add("hidden");
}
// ── Banner ─────────────────────────────────────────────────────────────
// Don't re-render if user already dismissed it this session.
if (banner.dataset.dismissed === "1") return;
if (issueCount === 0) {
banner.classList.add("hidden");
return;
}
const parts = [];
if (hasBreaches)
parts.push(`${breached} breached password${breached !== 1 ? "s" : ""}`);
if ((weak || 0) > 0)
parts.push(`${weak} weak password${weak !== 1 ? "s" : ""}`);
if ((reused || 0) > 0)
parts.push(`${reused} reused password${reused !== 1 ? "s" : ""}`);
const severity = hasBreaches ? "danger" : "warn";
const icon = hasBreaches ? "🚨" : "⚠️";
banner.className = `health-banner banner-${severity}`;
banner.innerHTML = `
${icon}
${parts.join(" · ")}
View report
× `;
banner.classList.remove("hidden");
document.getElementById("btn-health-banner-view")?.addEventListener("click", () => {
switchView("security");
});
document.getElementById("btn-health-banner-dismiss")?.addEventListener("click", () => {
banner.classList.add("hidden");
banner.dataset.dismissed = "1";
});
}
async function renderSecurityDashboard() {
const summaryEl = document.getElementById("security-summary");
const sectionsEl = document.getElementById("security-sections");
if (!summaryEl || !sectionsEl) return;
// If vault hasn't loaded yet, wait for it then re-render
if (!_items.length && VaultSession.getKey()) {
summaryEl.innerHTML = 'Loading…
';
sectionsEl.innerHTML = "";
loadVault().then(() => renderSecurityDashboard());
return;
}
const pwItems = _items.filter(
(i) => i.item_type === "password" && i.plain?.password,
);
if (!pwItems.length) {
summaryEl.innerHTML =
'No passwords to analyze.
';
sectionsEl.innerHTML = "";
return;
}
const weak = pwItems.filter((i) => {
const p = i.plain.password;
if (p.length < 10) return true;
return (
[/[A-Z]/, /[a-z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((r) => r.test(p))
.length < 2
);
});
const passCounts = {};
pwItems.forEach((i) => {
const p = i.plain.password;
(passCounts[p] = passCounts[p] || []).push(i);
});
const reused = Object.values(passCounts)
.filter((a) => a.length > 1)
.flat();
const cutoff = Date.now() - 180 * 86400000;
// "Old" only penalises passwords that are ALSO weak or reused.
// Uses plain.password_changed_at when available (set on create/edit),
// falling back to created_at for items saved before this feature.
const weakOrReusedIds = new Set([
...weak.map((i) => i.id),
...reused.map((i) => i.id),
]);
const old = pwItems.filter((i) => {
const ageRef = i.plain?.password_changed_at
? new Date(i.plain.password_changed_at).getTime()
: new Date(i.created_at).getTime();
return ageRef < cutoff && weakOrReusedIds.has(i.id);
});
const total = pwItems.length;
const score = Math.max(
0,
Math.round(
100 -
(weak.length / total) * 40 -
(reused.length / total) * 30 -
(old.length / total) * 15,
),
);
const cls =
score >= 80 ? "score-good" : score >= 50 ? "score-fair" : "score-poor";
const label =
score >= 80 ? "Good" : score >= 50 ? "Fair" : "Needs attention";
summaryEl.innerHTML = `
${score}
${label}
Based on ${total} password${total !== 1 ? "s" : ""}
${weak.length} Weak
${reused.length} Reused
${old.length} Old & Weak
`;
sectionsEl.innerHTML = "";
const makeSection = (title, icon, items, desc, renderItem) => {
if (!items.length) return;
const sec = document.createElement("div");
sec.className = "sec-section";
const defaultRender = (i) => `
${escHtml(i.name)}
${escHtml(i.plain?.username || "")}
Edit
`;
const renderFn = renderItem || defaultRender;
sec.innerHTML = `
${items.map(renderFn).join("")}
`;
sec.querySelectorAll("[data-sec-edit]").forEach((btn) => {
btn.addEventListener("click", () => {
const item = _items.find(
(it) => it.id === parseInt(btn.dataset.secEdit),
);
if (item) {
switchView("vault");
openModal("edit", item);
}
});
});
sectionsEl.appendChild(sec);
};
makeSection(
"Weak Passwords",
"⚠️",
weak,
"Short or low-complexity passwords.",
);
makeSection(
"Reused Passwords",
"♻️",
[...new Set(reused.map((i) => i.id))]
.map((id) => _items.find((i) => i.id === id))
.filter(Boolean),
"Same password used on multiple sites.",
);
makeSection(
"Old Passwords",
"🕐",
old,
"Weak or reused passwords not changed in over 180 days.",
(i) => {
const ref = i.plain?.password_changed_at || i.created_at;
const daysAgo = ref
? Math.floor((Date.now() - new Date(ref).getTime()) / 86400000)
: null;
const ageLabel = daysAgo !== null
? `Last changed ${daysAgo} day${daysAgo !== 1 ? "s" : ""} ago`
: "Age unknown";
return `
${escHtml(i.name)}
${escHtml(ageLabel)}
Edit
`;
},
);
// ── 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 = `
`;
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 = `
`;
} else {
hibpSection.innerHTML = `
${breached
.map((i) => {
const count =
hibpResults.find((r) => r.item.id === i.id)?.count || 0;
return `
${escHtml(i.name)}
${escHtml(i.plain?.username || "")} — seen ${count.toLocaleString()} time${count !== 1 ? "s" : ""} in breaches
Change
`;
})
.join("")}
`;
hibpSection.querySelectorAll("[data-sec-edit]").forEach((btn) => {
btn.addEventListener("click", () => {
const item = _items.find(
(it) => it.id === parseInt(btn.dataset.secEdit),
);
if (item) {
switchView("vault");
openModal("edit", item);
}
});
});
}
}
// ── Import / Export View ─────────────────────────────────────────────────
// Holds parsed rows from a chosen file, ready for import.
let _importRows = [];
/**
* Parse a Chrome/Bitwarden/1Password CSV into a normalised array of plain objects.
* Supported column sets:
* Chrome: name, url, username, password
* Bitwarden: name, login_uri, login_username, login_password, notes, type
* 1Password: Title, Url, Username, Password, Notes
*/
function _parseCsvImport(text) {
const lines = text.split(/\r?\n/);
if (lines.length < 2) return [];
const headers = lines[0]
.split(",")
.map((h) => h.trim().replace(/^"|"$/g, "").toLowerCase());
// Detect format by inspecting header names.
const col = (candidates) => {
for (const c of candidates) {
const idx = headers.indexOf(c);
if (idx !== -1) return idx;
}
return -1;
};
const iName = col(["name", "title"]);
const iUrl = col(["url", "login_uri"]);
const iUser = col(["username", "login_username"]);
const iPass = col(["password", "login_password"]);
const iNotes = col(["notes", "note"]);
const rows = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
// RFC 4180-compliant CSV split — handles quoted fields containing commas
// and embedded double-quotes escaped as "".
const cells = [];
let cur = "",
inQuote = false;
const src = line + ",";
for (let ci = 0; ci < src.length; ci++) {
const ch = src[ci];
if (ch === '"') {
if (inQuote && src[ci + 1] === '"') {
// Escaped quote inside a quoted field: "" → "
cur += '"';
ci++; // skip the second quote
} else {
inQuote = !inQuote;
}
} else if (ch === "," && !inQuote) {
cells.push(cur.trim());
cur = "";
} else {
cur += ch;
}
}
const get = (idx) =>
idx !== -1 && cells[idx] != null
? cells[idx].replace(/^"|"$/g, "")
: "";
const name = get(iName);
const password = get(iPass);
if (!name || !password) continue;
rows.push({
name,
url: get(iUrl),
username: get(iUser),
password,
notes: get(iNotes),
});
}
return rows;
}
let _importViewInitialised = false;
function _resetImportView() {
_importRows = [];
const fileInput = document.getElementById("import-file-input");
const fileNameEl = document.getElementById("import-file-name");
const previewEl = document.getElementById("import-preview");
const confirmBtn = document.getElementById("btn-import-confirm");
const resultEl = document.getElementById("import-result");
if (fileInput) fileInput.value = "";
if (fileNameEl) fileNameEl.textContent = "No file chosen";
if (previewEl) {
previewEl.innerHTML = "";
previewEl.classList.add("hidden");
}
if (confirmBtn) {
confirmBtn.disabled = true;
confirmBtn.textContent = "Import items";
delete confirmBtn.dataset.mode;
}
if (resultEl) {
resultEl.innerHTML = "";
resultEl.classList.add("hidden");
}
}
function loadImportExportView() {
// Reset import panel state every time the view is entered so a previous
// import result or file preview is never shown stale on re-entry.
_resetImportView();
if (_importViewInitialised) return;
_importViewInitialised = true;
// ── Export ───────────────────────────────────────────────────────────────
document
.getElementById("btn-export-json")
?.addEventListener("click", async () => {
try {
const res = await apiFetch("/api/vault");
if (!res) return;
const items = await res.json();
const payload = JSON.stringify(
{ version: 1, exported_at: new Date().toISOString(), items },
null,
2,
);
const blob = new Blob([payload], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `passkeeper-export-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
showToast("Encrypted vault exported.");
console.log("[PassKeeper] Vault exported:", items.length, "items");
} catch (err) {
showToast("Export failed: " + err.message, "error");
}
});
document
.getElementById("btn-export-csv")
?.addEventListener("click", async () => {
const vaultKey = VaultSession.getKey();
if (!vaultKey) {
showUnlockOverlay();
return;
}
// Warn the user that this export contains plaintext passwords before proceeding.
const confirmed = await new Promise((resolve) => {
const overlay = document.createElement("div");
overlay.style.cssText = [
"position:fixed",
"inset:0",
"background:rgba(0,0,0,0.55)",
"z-index:9999",
"display:flex",
"align-items:center",
"justify-content:center",
].join(";");
overlay.innerHTML = `
Export plaintext passwords?
The CSV file will contain all your passwords in plaintext .
Anyone with access to the file can read them.
Store the file in a secure location and delete it when you no longer need it.
Cancel
Export anyway
`;
document.body.appendChild(overlay);
overlay
.querySelector("#_csv_cancel")
.addEventListener("click", () => {
overlay.remove();
resolve(false);
});
overlay
.querySelector("#_csv_confirm")
.addEventListener("click", () => {
overlay.remove();
resolve(true);
});
overlay.addEventListener("click", (e) => {
if (e.target === overlay) {
overlay.remove();
resolve(false);
}
});
});
if (!confirmed) return;
try {
const res = await apiFetch("/api/vault");
if (!res) return;
const raw = await res.json();
const decrypted = await Promise.all(
raw.map(async (item) => {
try {
const plain = await Crypto.decryptItem(
vaultKey,
item.enc_data,
item.iv,
);
let displayName = item.name;
if (item.enc_name && item.iv_name) {
const n = await Crypto.decryptName(
vaultKey,
item.enc_name,
item.iv_name,
);
if (n) displayName = n;
}
return { name: displayName, ...plain };
} catch {
return null;
}
}),
);
const csvRows = [["name", "url", "username", "password", "notes"]];
decrypted.filter(Boolean).forEach((r) => {
if (r.password) {
const esc = (v) => `"${String(v ?? "").replace(/"/g, '""')}"`;
csvRows.push(
[r.name, r.url, r.username, r.password, r.notes].map(esc),
);
}
});
const csv = csvRows.map((r) => r.join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `passkeeper-export-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
showToast("CSV exported — store it securely.");
console.log(
"[PassKeeper] CSV export:",
decrypted.filter(Boolean).length,
"items",
);
} catch (err) {
showToast("CSV export failed: " + err.message, "error");
}
});
// ── Import ───────────────────────────────────────────────────────────────
const fileInput = document.getElementById("import-file-input");
const fileNameEl = document.getElementById("import-file-name");
const previewEl = document.getElementById("import-preview");
const confirmBtn = document.getElementById("btn-import-confirm");
const resultEl = document.getElementById("import-result");
fileInput?.addEventListener("change", async () => {
const file = fileInput.files[0];
if (!file) return;
fileNameEl.textContent = file.name;
previewEl.classList.add("hidden");
resultEl.classList.add("hidden");
confirmBtn.disabled = true;
_importRows = [];
const text = await file.text();
const isJson = file.name.endsWith(".json");
try {
if (isJson) {
// PassKeeper encrypted JSON export.
const parsed = JSON.parse(text);
const items = parsed.items || (Array.isArray(parsed) ? parsed : []);
if (!items.length) throw new Error("No items found in JSON file.");
_importRows = items;
previewEl.innerHTML = `Found ${items.length} encrypted item(s) ready to import.
These are already encrypted with your vault key — they will be imported as-is.
`;
} else {
// CSV — decrypt and re-encrypt with current vault key.
const vaultKey = VaultSession.getKey();
if (!vaultKey) {
showUnlockOverlay();
return;
}
const rows = _parseCsvImport(text);
if (!rows.length)
throw new Error("No valid rows found. Check the CSV format.");
_importRows = rows; // stored as plaintext — encrypted on confirm
previewEl.innerHTML = `Found ${rows.length} password(s) to import.
Preview (first 5):
${rows
.slice(0, 5)
.map(
(r) =>
`${escHtml(r.name)} — ${escHtml(r.username || "(no username)")} `,
)
.join("")}
${rows.length > 5 ? `…and ${rows.length - 5} more.
` : ""}`;
}
previewEl.classList.remove("hidden");
confirmBtn.disabled = false;
confirmBtn.dataset.mode = isJson ? "json" : "csv";
} catch (err) {
previewEl.innerHTML = `⚠️ ${escHtml(err.message)}
`;
previewEl.classList.remove("hidden");
}
});
confirmBtn?.addEventListener("click", async () => {
const vaultKey = VaultSession.getKey();
if (!vaultKey) {
showUnlockOverlay();
return;
}
confirmBtn.disabled = true;
confirmBtn.textContent = "Importing…";
resultEl.classList.add("hidden");
try {
let payload;
if (confirmBtn.dataset.mode === "json") {
// Already-encrypted items — send directly.
payload = _importRows;
} else {
// Plaintext CSV rows — encrypt each one now.
// Use the import timestamp as password_changed_at — best available
// approximation since CSV exports don't carry a change date.
const importedAt = new Date().toISOString();
payload = await Promise.all(
_importRows.map(async (row) => {
const plain = {
url: row.url || "",
username: row.username || "",
password: row.password,
notes: row.notes || "",
password_changed_at: importedAt,
};
const { enc_data, iv } = await Crypto.encryptItem(
vaultKey,
plain,
);
const { enc_name, iv_name } = await Crypto.encryptName(
vaultKey,
row.name,
);
return {
name: "password",
item_type: "password",
enc_data,
iv,
enc_name,
iv_name,
};
}),
);
}
const res = await apiFetch("/api/vault/import", {
method: "POST",
body: JSON.stringify(payload),
});
if (!res) return;
const data = await res.json();
resultEl.innerHTML = `✅ Imported ${data.imported} item(s)${data.skipped ? `, skipped ${data.skipped} malformed row(s)` : ""}.`;
resultEl.className = "import-result import-result-ok";
resultEl.classList.remove("hidden");
_importRows = [];
confirmBtn.textContent = "Import items";
fileInput.value = "";
fileNameEl.textContent = "No file chosen";
previewEl.classList.add("hidden");
console.log(
"[PassKeeper] Import complete:",
data.imported,
"imported,",
data.skipped,
"skipped",
);
await loadVault();
} catch (err) {
resultEl.innerHTML = `⚠️ ${escHtml(err.message)}`;
resultEl.className = "import-result import-result-err";
resultEl.classList.remove("hidden");
confirmBtn.disabled = false;
confirmBtn.textContent = "Import items";
}
});
}
// ── Sharing View ──────────────────────────────────────────────────────────
async function loadSharingView() {
try {
const keysRes = await apiFetch("/api/sharing/keys");
if (!keysRes) return;
const keysData = await keysRes.json();
const notice = document.getElementById("sharing-keys-notice");
if (!keysData.keys_setup) {
notice.classList.remove("hidden");
} else {
notice.classList.add("hidden");
// Load sharing private key into SharingSession if vault key available
const vaultKey = VaultSession.getKey();
if (vaultKey && !SharingSession.isReady()) {
try {
const privKey = await SharingCrypto.decryptPrivateKey(
vaultKey,
keysData.private_key_enc,
keysData.private_key_iv,
);
SharingSession.setKey(privKey);
} catch (err) {
console.warn(
"[PassKeeper] Could not load sharing private key:",
err,
);
}
}
}
await Promise.all([loadSharedOut(), loadSharedIn()]);
} catch (err) {
showToast("Could not load sharing data: " + err.message, "error");
}
}
async function loadSharedOut() {
try {
const res = await apiFetch("/api/sharing");
if (!res) return;
const shares = await res.json();
renderSharedOut(shares);
} catch (err) {
console.error("loadSharedOut:", err);
}
}
async function loadSharedIn() {
try {
const res = await apiFetch("/api/sharing/inbox");
if (!res) return;
const shares = await res.json();
renderSharedIn(shares);
} catch (err) {
console.error("loadSharedIn:", err);
}
}
function renderSharedOut(shares) {
const ul = document.getElementById("shared-out-list");
if (!ul) return;
if (!shares.length) {
ul.innerHTML =
'You haven\'t shared any items yet. ';
return;
}
ul.innerHTML = shares
.map(
(s) => {
// Resolve the display name: prefer the decrypted name from _items
// (owner's vault is already in memory), fall back to item_name which
// is now the item_type label for new shares, or a legacy plaintext
// name for shares created before the enc_name migration.
const vaultItem = _items.find((i) => i.id === s.item_id);
const displayName = vaultItem?.name || s.item_name;
const expiryLabel = (() => {
if (!s.expires_at) return "";
const daysLeft = Math.ceil(
(new Date(s.expires_at).getTime() - Date.now()) / 86400000,
);
if (daysLeft <= 0) return " · Expired ";
if (daysLeft <= 3) return ` · Expires in ${daysLeft}d `;
return ` · Expires ${new Date(s.expires_at).toLocaleDateString()}`;
})();
return `
${itemIcon(s.item_type)}
${escHtml(displayName)}
Shared with ${escHtml(s.recipient_email)} · ${s.accepted ? "✅ Accepted" : "⏳ Pending"}${expiryLabel}
🗑️
`;
},
)
.join("");
ul.querySelectorAll("[data-revoke]").forEach((btn) => {
btn.addEventListener("click", async () => {
if (!confirm("Remove this share?")) return;
try {
await apiFetch(`/api/sharing/${btn.dataset.revoke}`, {
method: "DELETE",
});
showToast("Share removed");
loadSharedOut();
} catch (err) {
showToast(err.message, "error");
}
});
});
}
function renderSharedIn(shares) {
const ul = document.getElementById("shared-in-list");
if (!ul) return;
if (!shares.length) {
ul.innerHTML =
'No items have been shared with you. ';
return;
}
ul.innerHTML = shares
.map(
(s) => {
// Expiry label for pending (unaccepted) items.
const expiryLabel = (() => {
if (!s.expires_at || s.accepted) return "";
const daysLeft = Math.ceil(
(new Date(s.expires_at).getTime() - Date.now()) / 86400000,
);
if (daysLeft <= 3)
return ` · Expires in ${daysLeft}d `;
return ` · Expires ${new Date(s.expires_at).toLocaleDateString()}`;
})();
return `
${itemIcon(s.item_type)}
${escHtml(s.item_name)}
From ${escHtml(s.owner_email)}${expiryLabel}
${
!s.accepted
? `Accept `
: `View `
}
`;
},
)
.join("");
ul.querySelectorAll("[data-accept]").forEach((btn) => {
btn.addEventListener("click", async () => {
try {
await apiFetch(`/api/sharing/inbox/${btn.dataset.accept}/accept`, {
method: "POST",
});
showToast("Share accepted");
loadSharedIn();
} catch (err) {
showToast(err.message, "error");
}
});
});
ul.querySelectorAll("[data-view-share]").forEach((btn) => {
btn.addEventListener("click", async () => {
if (!SharingSession.isReady()) {
showToast("Sharing keys not loaded. Check Settings.", "error");
return;
}
try {
const ownerPubKey = await SharingCrypto.importPublicKey(
btn.dataset.ownerKey,
);
const sharedKey = await SharingCrypto.deriveSharedKey(
SharingSession.getKey(),
ownerPubKey,
);
const plain = await SharingCrypto.decryptShare(
sharedKey,
btn.dataset.enc,
btn.dataset.iv,
);
// Decrypt the item name if an encrypted version is available.
// Falls back to the non-sensitive label for legacy shares.
let displayName = btn.dataset.name;
if (btn.dataset.encName && btn.dataset.ivName) {
const decrypted = await SharingCrypto.decryptName(
sharedKey,
btn.dataset.encName,
btn.dataset.ivName,
);
if (decrypted) displayName = decrypted;
}
showSharedItemDetails(displayName, btn.dataset.type, plain);
} catch (err) {
showToast("Could not decrypt: " + err.message, "error");
}
});
});
}
function showSharedItemDetails(name, itemType, plain) {
document.getElementById("detail-modal-title").textContent =
`${itemIcon(itemType || "password")} ${name}`;
renderDetailFields(document.getElementById("detail-modal-body"), plain);
document.getElementById("detail-modal").classList.add("open");
}
function renderDetailFields(container, plain) {
container.innerHTML =
Object.entries(plain)
.filter(([, v]) => v)
.map(([k, v]) => {
const label = FIELD_LABELS[k] || k.replace(/_/g, " ");
const val = String(v);
const sensitive = SENSITIVE_FIELDS.has(k);
const valHtml = sensitive
? `
••••••••
${escHtml(val)}
👁 `
: `${escHtml(val)} `;
return `
${escHtml(label)}
${valHtml}
📋
`;
})
.join("") || 'No fields to display.
';
container.querySelectorAll(".btn-reveal").forEach((btn) => {
btn.addEventListener("click", () => {
const span = btn.previousElementSibling;
const revealed = span.dataset.revealed === "true";
span.dataset.revealed = String(!revealed);
span
.querySelector(".detail-masked")
.classList.toggle("hidden", !revealed);
span
.querySelector(".detail-actual")
.classList.toggle("hidden", revealed);
});
});
container.querySelectorAll(".btn-copy-field").forEach((btn) => {
btn.addEventListener("click", () => {
navigator.clipboard
.writeText(btn.dataset.copy)
.then(() => showToast("Copied!"));
});
});
}
async function handleShareFormSubmit(e) {
e.preventDefault();
const errEl = document.getElementById("share-error");
errEl.classList.add("hidden");
if (!SharingSession.isReady()) {
showToast("Set up sharing keys first (Settings)", "error");
return;
}
const itemId = parseInt(document.getElementById("share-item-select").value);
const recipientEmail = document
.getElementById("share-recipient-email")
.value.trim()
.toLowerCase();
if (!itemId || !recipientEmail) {
errEl.textContent = "Please select an item and enter a recipient email.";
errEl.classList.remove("hidden");
return;
}
try {
// Fetch recipient's public key
const pkRes = await apiFetch(
`/api/sharing/public-key?email=${encodeURIComponent(recipientEmail)}`,
);
if (!pkRes) return;
const pkData = await pkRes.json();
const recipientPubKey = await SharingCrypto.importPublicKey(
pkData.public_key,
);
const sharedKey = await SharingCrypto.deriveSharedKey(
SharingSession.getKey(),
recipientPubKey,
);
// Find item plaintext
const item = _items.find((i) => i.id === itemId);
if (!item?.plain) {
showToast("Item not found or not decrypted", "error");
return;
}
const { enc_data, iv } = await SharingCrypto.encryptForShare(
sharedKey,
item.plain,
);
// Encrypt the display name with the same ECDH shared key so the server
// never sees the plaintext name. item_name is set to item_type as a
// non-sensitive fallback label for legacy-compat (server requires the field).
const { enc_name, iv_name } = await SharingCrypto.encryptName(
sharedKey,
item.name,
);
const res = await apiFetch("/api/sharing", {
method: "POST",
body: JSON.stringify({
item_id: itemId,
recipient_email: recipientEmail,
enc_data,
iv,
item_name: item.item_type, // non-sensitive label; real name is in enc_name
item_type: item.item_type,
enc_name,
iv_name,
expires_days: (() => {
const sel = document.getElementById("share-expiry-select");
const v = sel ? parseInt(sel.value) : NaN;
return isNaN(v) || v === 0 ? null : v;
})(),
}),
});
if (!res) return;
closeModal("share-modal");
showToast("Item shared successfully");
loadSharedOut();
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove("hidden");
}
}
function openShareModal() {
const sel = document.getElementById("share-item-select");
sel.innerHTML = _items
.map(
(i) =>
`${escHtml(i.name)} (${i.item_type}) `,
)
.join("");
document.getElementById("share-recipient-email").value = "";
document.getElementById("share-error").classList.add("hidden");
document.getElementById("share-modal").classList.add("open");
}
// ── Emergency Access View ─────────────────────────────────────────────────
async function loadEmergencyView() {
try {
const res = await apiFetch("/api/emergency");
if (!res) return;
const data = await res.json();
renderEmergencyGrants(data.grants);
renderEmergencyAccess(data.access);
} catch (err) {
showToast(
"Could not load emergency access data: " + err.message,
"error",
);
}
}
function renderEmergencyGrants(grants) {
const ul = document.getElementById("em-grants-list");
if (!ul) return;
if (!grants.length) {
ul.innerHTML =
'No trusted contacts set up yet. ';
return;
}
ul.innerHTML = grants
.map((g) => {
let actions = "";
if (g.status === "accepted") {
actions = `Provide Recovery Data `;
}
if (g.status === "ready") {
// Check if the stored snapshot is in the old format (plaintext names).
if (g.enc_vault_is_legacy) {
actions = `
⚠ Outdated snapshot
Re-provision `;
} else {
actions = `Update Recovery Data `;
}
}
if (g.status === "pending") {
const waitInfo = g.wait_elapsed
? "Wait period elapsed"
: "Access requested";
actions = `${waitInfo}
Deny `;
}
return `
🚨
${escHtml(g.grantee_email)}
Status: ${escHtml(g.status)} · Wait: ${g.wait_days} day(s)
${actions}
🗑️
`;
})
.join("");
ul.querySelectorAll("[data-provide]").forEach((btn) => {
btn.addEventListener("click", () =>
handleProvideEmergencyData(parseInt(btn.dataset.provide)),
);
});
ul.querySelectorAll("[data-deny]").forEach((btn) => {
btn.addEventListener("click", async () => {
if (!confirm("Deny this access request?")) return;
try {
await apiFetch(`/api/emergency/${btn.dataset.deny}/deny`, {
method: "POST",
});
showToast("Access request denied");
loadEmergencyView();
} catch (err) {
showToast(err.message, "error");
}
});
});
ul.querySelectorAll("[data-remove-ea]").forEach((btn) => {
btn.addEventListener("click", async () => {
if (!confirm("Remove this emergency access grant?")) return;
try {
await apiFetch(`/api/emergency/${btn.dataset.removeEa}`, {
method: "DELETE",
});
showToast("Emergency access removed");
loadEmergencyView();
} catch (err) {
showToast(err.message, "error");
}
});
});
}
async function handleProvideEmergencyData(eaId) {
if (!SharingSession.isReady()) {
showToast("Set up sharing keys first (Settings → Sharing Keys)", "error");
return;
}
if (
!confirm(
"This will create an encrypted snapshot of all your vault items for your trusted contact. Continue?",
)
)
return;
try {
// Get grantee's public key from the emergency record
const listRes = await apiFetch("/api/emergency");
const listData = await listRes.json();
const ea = listData.grants.find((g) => g.id === eaId);
if (!ea) {
showToast("Emergency access record not found", "error");
return;
}
// Fetch grantee's public key
const pkRes = await apiFetch(
`/api/sharing/public-key?email=${encodeURIComponent(ea.grantee_email)}`,
);
if (!pkRes) return;
const pkData = await pkRes.json();
const granteePubKey = await SharingCrypto.importPublicKey(
pkData.public_key,
);
const sharedKey = await SharingCrypto.deriveSharedKey(
SharingSession.getKey(),
granteePubKey,
);
// Re-encrypt all decrypted vault items.
// Item name is encrypted with the same ECDH shared key so the server
// never sees plaintext names inside enc_vault (zero-knowledge).
const encItems = await Promise.all(
_items
.filter((i) => i.plain)
.map(async (i) => {
const { enc_data, iv } = await SharingCrypto.encryptForShare(
sharedKey,
i.plain,
);
const { enc_name, iv_name } = await SharingCrypto.encryptName(
sharedKey,
i.name,
);
return {
id: i.id,
item_type: i.item_type,
enc_data,
iv,
enc_name,
iv_name,
};
}),
);
await apiFetch(`/api/emergency/${eaId}/provide`, {
method: "POST",
body: JSON.stringify({ enc_vault: JSON.stringify(encItems) }),
});
showToast("Recovery data provided");
loadEmergencyView();
} catch (err) {
showToast("Failed to provide recovery data: " + err.message, "error");
}
}
function renderEmergencyAccess(access) {
const ul = document.getElementById("em-access-list");
if (!ul) return;
if (!access.length) {
ul.innerHTML =
'No one has designated you as an emergency contact. ';
return;
}
ul.innerHTML = access
.map((a) => {
let actions = "";
if (a.status === "invited") {
actions = `Accept `;
} else if (a.status === "ready") {
actions = `Request Access `;
} 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
? `View Vault `
: `Waiting (~${daysLeft} day${daysLeft !== 1 ? "s" : ""} left) `;
}
return `
🔐
${escHtml(a.grantor_email || "Unknown")}
Status: ${escHtml(a.status)} · Wait: ${a.wait_days} day(s)
${actions}
`;
})
.join("");
ul.querySelectorAll("[data-accept-ea]").forEach((btn) => {
btn.addEventListener("click", async () => {
try {
await apiFetch(`/api/emergency/${btn.dataset.acceptEa}/accept`, {
method: "POST",
});
showToast("Emergency access accepted");
loadEmergencyView();
} catch (err) {
showToast(err.message, "error");
}
});
});
ul.querySelectorAll("[data-request-ea]").forEach((btn) => {
btn.addEventListener("click", async () => {
if (
!confirm(
"Request emergency access? This will start the waiting period. The vault owner will be notified.",
)
)
return;
try {
await apiFetch(`/api/emergency/${btn.dataset.requestEa}/request`, {
method: "POST",
});
showToast("Access request sent — wait period has started");
loadEmergencyView();
} catch (err) {
showToast(err.message, "error");
}
});
});
ul.querySelectorAll("[data-get-vault]").forEach((btn) => {
btn.addEventListener("click", () =>
handleGetEmergencyVault(parseInt(btn.dataset.getVault)),
);
});
}
async function handleGetEmergencyVault(eaId) {
if (!SharingSession.isReady()) {
showToast("Set up sharing keys first (Settings → Sharing Keys)", "error");
return;
}
try {
const res = await apiFetch(`/api/emergency/${eaId}/vault`);
if (!res) return;
const data = await res.json();
const grantorPubKey = await SharingCrypto.importPublicKey(
data.grantor_public_key,
);
const sharedKey = await SharingCrypto.deriveSharedKey(
SharingSession.getKey(),
grantorPubKey,
);
const encItems = JSON.parse(data.enc_vault);
const decItems = await Promise.all(
encItems.map(async (i) => {
try {
const plain = await SharingCrypto.decryptShare(
sharedKey,
i.enc_data,
i.iv,
);
// Decrypt the item display name if available (new format).
// Fall back to item_type label for legacy enc_vault snapshots.
let displayName = i.item_type || "password";
if (i.enc_name && i.iv_name) {
const decName = await SharingCrypto.decryptName(
sharedKey,
i.enc_name,
i.iv_name,
);
if (decName) displayName = decName;
}
return { ...i, name: displayName, plain };
} catch {
return { ...i, name: i.item_type || "password", plain: null };
}
}),
);
renderEmergencyVaultPanel(decItems);
} catch (err) {
showToast("Could not retrieve vault: " + err.message, "error");
}
}
function renderEmergencyVaultPanel(items) {
const list = document.getElementById("em-access-list");
const panel = document.getElementById("em-vault-panel");
const vaultList = document.getElementById("em-vault-list");
if (!panel || !vaultList) return;
list.classList.add("hidden");
panel.classList.remove("hidden");
if (!items.length) {
vaultList.innerHTML =
'No items in emergency vault. ';
return;
}
vaultList.innerHTML = items
.map((item, idx) => {
const icon = itemIcon(item.item_type);
const hasFields =
item.plain && Object.values(item.plain).some((v) => v);
return `
${
hasFields
? '
'
: '
Could not decrypt this item.
'
}
`;
})
.join("");
// Populate detail fields and wire expand/collapse
items.forEach((item, idx) => {
const header = vaultList.querySelector(`[data-em-idx="${idx}"]`);
const body = document.getElementById(`em-vault-body-${idx}`);
const detailBody = body?.querySelector(".em-detail-body");
if (detailBody && item.plain) renderDetailFields(detailBody, item.plain);
header?.addEventListener("click", () => {
const expanded = header.getAttribute("aria-expanded") === "true";
header.setAttribute("aria-expanded", String(!expanded));
header.querySelector(".em-vault-chevron").textContent = expanded
? "▸"
: "▾";
body?.classList.toggle("hidden", expanded);
});
});
document
.getElementById("btn-em-vault-back")
?.addEventListener("click", () => {
panel.classList.add("hidden");
list.classList.remove("hidden");
});
}
// ── Password Generator ────────────────────────────────────────────────
const GEN_CHARSETS = {
upper: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
lower: "abcdefghijklmnopqrstuvwxyz",
digits: "0123456789",
symbols: "!@#$%^&*()-_=+[]{}|;:,.<>?",
};
function generatePassword(
length = 20,
opts = { upper: true, lower: true, digits: true, symbols: true },
) {
let pool = "";
const required = [];
if (opts.upper) {
pool += GEN_CHARSETS.upper;
required.push(GEN_CHARSETS.upper);
}
if (opts.lower) {
pool += GEN_CHARSETS.lower;
required.push(GEN_CHARSETS.lower);
}
if (opts.digits) {
pool += GEN_CHARSETS.digits;
required.push(GEN_CHARSETS.digits);
}
if (opts.symbols) {
pool += GEN_CHARSETS.symbols;
required.push(GEN_CHARSETS.symbols);
}
if (!pool) pool = GEN_CHARSETS.lower;
const arr = new Uint32Array(length + required.length);
window.crypto.getRandomValues(arr);
// Guarantee at least one char from each required charset
const chars = required.map((cs, i) => cs[arr[i] % cs.length]);
for (let i = required.length; i < length + required.length; i++) {
chars.push(pool[arr[i] % pool.length]);
}
// Fisher-Yates shuffle using crypto random
const shuffle = new Uint32Array(chars.length);
window.crypto.getRandomValues(shuffle);
for (let i = chars.length - 1; i > 0; i--) {
const j = shuffle[i] % (i + 1);
[chars[i], chars[j]] = [chars[j], chars[i]];
}
return chars.slice(0, length).join("");
}
function scorePassword(pw) {
if (!pw) return { score: 0, label: "", cls: "" };
let score = 0;
if (pw.length >= 8) score++;
if (pw.length >= 12) score++;
if (pw.length >= 16) score++;
if (/[A-Z]/.test(pw)) score++;
if (/[a-z]/.test(pw)) score++;
if (/[0-9]/.test(pw)) score++;
if (/[^A-Za-z0-9]/.test(pw)) score++;
// clamp to 5 bands
const band = Math.min(5, Math.ceil((score / 7) * 5));
const labels = ["", "Very Weak", "Weak", "Fair", "Strong", "Very Strong"];
const clses = [
"",
"strength-1",
"strength-2",
"strength-3",
"strength-4",
"strength-5",
];
return { score: band, label: labels[band], cls: clses[band] };
}
// Attach strength meter + generate button to the password field in the item modal
function initPasswordFieldEnhancements() {
const pwInput = document.getElementById("field-password");
if (!pwInput) return;
// Strength bar below the password field
if (!document.getElementById("field-password-strength")) {
const bar = document.createElement("div");
bar.id = "field-password-strength";
bar.className = "password-strength";
pwInput
.closest(".input-with-toggle")
.insertAdjacentElement("afterend", bar);
}
pwInput.addEventListener("input", () => {
const { label, cls } = scorePassword(pwInput.value);
const bar = document.getElementById("field-password-strength");
bar.textContent = pwInput.value ? label : "";
bar.className = "password-strength " + (pwInput.value ? cls : "");
});
// Generate button — add only once
if (!document.getElementById("btn-generate-pass")) {
const btn = document.createElement("button");
btn.type = "button";
btn.id = "btn-generate-pass";
btn.className = "btn-secondary btn-sm btn-generate";
btn.textContent = "⚡ Generate";
pwInput.closest(".form-group").appendChild(btn);
}
document.getElementById("btn-generate-pass").onclick = () => {
const pw = generatePassword(20);
pwInput.value = pw;
pwInput.type = "text"; // reveal so user can see what was generated
pwInput.dispatchEvent(new Event("input")); // trigger strength bar
};
}
function openPasswordGeneratorModal() {
// Build modal content dynamically — keeps the HTML lean
let modal = document.getElementById("gen-modal");
if (!modal) {
modal = document.createElement("div");
modal.id = "gen-modal";
modal.className = "modal-overlay";
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.innerHTML = `
`;
document.body.appendChild(modal);
const lengthSlider = document.getElementById("gen-length");
const lengthDisplay = document.getElementById("gen-length-display");
const output = document.getElementById("gen-output");
const strengthEl = document.getElementById("gen-strength");
function refreshGen() {
const pw = generatePassword(parseInt(lengthSlider.value), {
upper: document.getElementById("gen-upper").checked,
lower: document.getElementById("gen-lower").checked,
digits: document.getElementById("gen-digits").checked,
symbols: document.getElementById("gen-symbols").checked,
});
output.value = pw;
const { label, cls } = scorePassword(pw);
strengthEl.textContent = label;
strengthEl.className = "password-strength " + cls;
}
lengthSlider.addEventListener("input", () => {
lengthDisplay.textContent = lengthSlider.value;
refreshGen();
});
["gen-upper", "gen-lower", "gen-digits", "gen-symbols"].forEach((id) => {
document.getElementById(id).addEventListener("change", refreshGen);
});
document
.getElementById("btn-regenerate")
.addEventListener("click", refreshGen);
document.getElementById("btn-gen-copy").addEventListener("click", () => {
navigator.clipboard
.writeText(output.value)
.then(() => showToast("Password copied"));
});
const closeGen = () => modal.classList.remove("open");
document
.getElementById("btn-close-gen-modal")
.addEventListener("click", closeGen);
document
.getElementById("btn-close-gen-modal2")
.addEventListener("click", closeGen);
modal.addEventListener("click", (e) => {
if (e.target === modal) closeGen();
});
refreshGen();
}
modal.classList.add("open");
// Regenerate fresh password each time modal opens
const lengthSlider = document.getElementById("gen-length");
document.getElementById("gen-length-display").textContent =
lengthSlider.value;
const pw = generatePassword(parseInt(lengthSlider.value), {
upper: document.getElementById("gen-upper").checked,
lower: document.getElementById("gen-lower").checked,
digits: document.getElementById("gen-digits").checked,
symbols: document.getElementById("gen-symbols").checked,
});
document.getElementById("gen-output").value = pw;
const { label, cls } = scorePassword(pw);
const strengthEl = document.getElementById("gen-strength");
strengthEl.textContent = label;
strengthEl.className = "password-strength " + cls;
}
// ── Account Settings ──────────────────────────────────────────────────────
let _pendingMfaSecret = null;
async function openSettingsModal() {
document.getElementById("settings-modal").classList.add("open");
// Populate web-app session timeout select with saved value.
const idleSel = document.getElementById("web-idle-select");
if (idleSel) idleSel.value = String(_getWebIdleMinutes());
// Reset change password fields
["cp-current", "cp-new", "cp-confirm"].forEach((id) => {
const el = document.getElementById(id);
if (el) el.value = "";
});
document.getElementById("cp-error")?.classList.add("hidden");
// Reset delete account area
document.getElementById("delete-confirm-area")?.classList.add("hidden");
document.getElementById("btn-delete-account")?.classList.remove("hidden");
document.getElementById("delete-password").value = "";
document.getElementById("delete-error")?.classList.add("hidden");
// Reset recovery display
document.getElementById("recovery-code-display")?.classList.add("hidden");
await Promise.all([
loadMfaStatus(),
loadSharingKeysStatus(),
loadRecoveryStatus(),
loadAuditLog(0, true),
loadPasskeys(),
]);
}
async function loadMfaStatus() {
try {
const res = await apiFetch("/api/auth/mfa/status");
if (!res) return;
const data = await res.json();
renderMfaStatus(data.totp_enabled, data.backup_codes_remaining);
} catch (err) {
console.error("loadMfaStatus:", err);
}
}
const _auditPageSize = 20;
let _auditOffset = 0;
let _auditTotal = 0;
// ── Passkey (WebAuthn) management ────────────────────────────────────────────
async function loadPasskeys() {
const listEl = document.getElementById("passkeys-list");
const errEl = document.getElementById("passkeys-error");
if (!listEl) return;
errEl?.classList.add("hidden");
// Hide the section if WebAuthn is not supported in this browser.
if (!window.PublicKeyCredential) {
document.getElementById("passkeys-section")?.classList.add("hidden");
return;
}
try {
const res = await apiFetch("/api/webauthn/credentials");
const creds = await res.json();
if (!Array.isArray(creds) || !creds.length) {
listEl.innerHTML = 'No passkeys registered yet.
';
} else {
listEl.innerHTML = creds
.map((c) => {
const created = c.created_at
? new Date(c.created_at).toLocaleDateString()
: "";
const lastUsed = c.last_used_at
? `Last used ${new Date(c.last_used_at).toLocaleDateString()}`
: "Never used";
const transports = c.transports || [];
const typeLabel = transports.includes("internal")
? "📱 Device"
: transports.some((t) => ["usb", "nfc", "ble", "smart-card"].includes(t))
? "🔑 Security key"
: "🔑 Passkey";
return `
${escHtml(c.name)}
${typeLabel} · ${lastUsed} · Added ${escHtml(created)}
Rename
Remove
`;
})
.join("");
// Wire rename buttons.
listEl.querySelectorAll(".btn-rename-passkey").forEach((btn) => {
btn.addEventListener("click", async () => {
const newName = prompt("New passkey name:", btn.dataset.name);
if (!newName?.trim()) return;
try {
await apiFetch(`/api/webauthn/credentials/${btn.dataset.id}`, {
method: "PATCH",
body: JSON.stringify({ name: newName.trim() }),
});
await loadPasskeys();
} catch (e) {
showToast("Failed to rename passkey", "error");
}
});
});
// Wire delete buttons.
listEl.querySelectorAll(".btn-delete-passkey").forEach((btn) => {
btn.addEventListener("click", async () => {
if (!confirm(`Remove passkey "${btn.dataset.name}"?`)) return;
try {
await apiFetch(`/api/webauthn/credentials/${btn.dataset.id}`, {
method: "DELETE",
});
showToast("Passkey removed");
await loadPasskeys();
} catch (e) {
showToast("Failed to remove passkey", "error");
}
});
});
}
} catch (e) {
listEl.innerHTML = 'Could not load passkeys.
';
}
// Wire register button (only bind once).
const registerBtn = document.getElementById("btn-register-passkey");
if (registerBtn && !registerBtn.dataset.bound) {
registerBtn.dataset.bound = "1";
registerBtn.addEventListener("click", async () => {
const nameInput = document.getElementById("passkey-name-input");
const attachSel = document.getElementById("passkey-attachment-select");
const name = (nameInput?.value || "").trim() || "Passkey";
const attachment = attachSel?.value || "platform";
const errEl = document.getElementById("passkeys-error");
errEl?.classList.add("hidden");
registerBtn.disabled = true;
registerBtn.textContent = "Waiting…";
const result = await PasskeyAuth.registerPasskey(name, attachment);
registerBtn.disabled = false;
registerBtn.textContent = "+ Add passkey";
if (result.error) {
if (errEl) {
errEl.textContent = result.error;
errEl.classList.remove("hidden");
}
return;
}
if (nameInput) nameInput.value = "";
showToast(`Passkey "${escHtml(result.credential.name)}" registered`);
await loadPasskeys();
});
}
}
async function loadAuditLog(offset = 0, reset = false) {
try {
const res = await apiFetch(
`/api/auth/audit-log?limit=${_auditPageSize}&offset=${offset}`,
);
if (!res) return;
const data = await res.json();
_auditTotal = data.total || 0;
_auditOffset = offset + (data.entries || []).length;
const listEl = document.getElementById("audit-log-list");
if (!listEl) return;
if (reset) listEl.innerHTML = "";
if (!data.entries || !data.entries.length) {
if (reset)
listEl.innerHTML =
'No activity recorded yet.
';
document.getElementById("btn-load-more-audit").style.display = "none";
return;
}
// Action label map for friendly display
const ACTION_LABELS = {
"auth.login": "Signed in",
"auth.login_failed": "Failed sign-in attempt",
"auth.login_blocked": "Sign-in blocked (account locked)",
"auth.account_locked": "Account locked",
"auth.logout": "Signed out",
"auth.mfa_verify": "MFA verified",
"auth.mfa_backup_code_used": "MFA backup code used",
"auth.mfa_enable": "MFA enabled",
"auth.mfa_disable": "MFA disabled",
"auth.mfa_backup_codes_regenerated": "Backup codes regenerated",
"auth.change_password": "Password changed",
"auth.recovery_setup": "Recovery code configured",
"auth.recovery_success": "Account recovered",
"auth.recovery_failed": "Failed recovery attempt",
"vault.create": "Vault item created",
"vault.update": "Vault item updated",
"vault.delete": "Vault item deleted",
};
data.entries.forEach((e) => {
const label = ACTION_LABELS[e.action] || e.action;
const date = new Date(e.created_at).toLocaleString();
const isAlert =
e.action.includes("failed") ||
e.action.includes("blocked") ||
e.action.includes("locked");
const row = document.createElement("div");
row.style.cssText = `display:flex;justify-content:space-between;align-items:flex-start;padding:7px 0;border-bottom:1px solid #f3f4f6;gap:8px;`;
row.innerHTML = `
${label}
${date} ${e.ip_address || ""} `;
listEl.appendChild(row);
});
const loadMoreBtn = document.getElementById("btn-load-more-audit");
if (_auditOffset < _auditTotal) {
loadMoreBtn.style.display = "inline-block";
loadMoreBtn.onclick = () => loadAuditLog(_auditOffset, false);
} else {
loadMoreBtn.style.display = "none";
}
} catch (err) {
console.error("loadAuditLog:", err);
}
}
function renderMfaStatus(enabled, backupCodesRemaining) {
const statusText = document.getElementById("mfa-status-text");
const actions = document.getElementById("mfa-actions");
document.getElementById("mfa-setup-area").classList.add("hidden");
document.getElementById("mfa-disable-area").classList.add("hidden");
if (enabled) {
const remaining =
backupCodesRemaining != null ? backupCodesRemaining : "?";
statusText.innerHTML = `✅ Two-factor authentication is enabled. (${remaining} backup code${remaining !== 1 ? "s" : ""} remaining) `;
actions.innerHTML =
'Disable MFA ' +
' Regenerate backup codes ';
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 =
'Set up MFA ';
document
.getElementById("btn-mfa-setup")
?.addEventListener("click", handleMfaSetup);
}
}
async function handleMfaSetup() {
try {
const res = await apiFetch("/api/auth/mfa/setup");
if (!res) return;
const data = await res.json();
_pendingMfaSecret = data.secret;
document.getElementById("mfa-qr-img").src = data.qr_code;
document.getElementById("mfa-secret-display").textContent = data.secret;
document.getElementById("mfa-setup-area").classList.remove("hidden");
document.getElementById("mfa-actions").innerHTML = "";
document.getElementById("mfa-status-text").textContent =
"Scan the QR code below with your authenticator app.";
document.getElementById("mfa-verify-code").value = "";
document.getElementById("mfa-verify-code").focus();
} catch (err) {
showToast("Could not start MFA setup: " + err.message, "error");
}
}
async function handleMfaConfirm() {
const code = document.getElementById("mfa-verify-code").value.trim();
if (!code || !_pendingMfaSecret) return;
try {
const res = await apiFetch("/api/auth/mfa/enable", {
method: "POST",
body: JSON.stringify({ secret: _pendingMfaSecret, totp_code: code }),
});
if (!res) return;
const data = await res.json();
if (!res.ok) {
showToast(data.error || "Failed to enable MFA", "error");
return;
}
_pendingMfaSecret = null;
document.getElementById("mfa-setup-area").classList.add("hidden");
// Display one-time backup codes immediately after enabling
if (data.backup_codes && data.backup_codes.length) {
showBackupCodesModal(data.backup_codes, true);
} else {
showToast("MFA enabled successfully");
renderMfaStatus(true);
}
} catch (err) {
showToast("Failed to enable MFA: " + err.message, "error");
}
}
/**
* Show backup codes in a modal. When `isFirstTime` is true the modal
* emphasises that these will not be shown again.
*/
function showBackupCodesModal(codes, isFirstTime = false) {
const existing = document.getElementById("__pk_backup_modal__");
if (existing) existing.remove();
const codesHtml = codes
.map(
(c) =>
`${c}`,
)
.join(" ");
const overlay = document.createElement("div");
overlay.id = "__pk_backup_modal__";
overlay.style.cssText =
"position:fixed;inset:0;background:rgba(0,0,0,0.55);z-index:9999;display:flex;align-items:center;justify-content:center;";
overlay.innerHTML = `
${isFirstTime ? "🔐 MFA enabled — save your backup codes" : "🔐 New backup codes"}
${
isFirstTime
? `
These codes let you sign in if you lose access to your authenticator app.
Save them now — they will not be shown again.
`
: `
Your previous codes have been invalidated. Save these new codes securely.
`
}
${codesHtml}
Copy all
I've saved them
`;
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 =
'Regenerate keys ';
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 =
'Generate sharing keys ';
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 =
'Generate new recovery code ';
} else {
statusEl.textContent =
"No recovery code set up. If you forget your master password, your vault cannot be recovered.";
actionsEl.innerHTML =
'Generate recovery code ';
}
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 = '— No folder — ';
_folders.forEach((f) => {
const opt = document.createElement("option");
opt.value = f.id;
opt.textContent = f.name;
folderSel.appendChild(opt);
});
if (mode === "add") {
document.getElementById("type-selector-group").classList.remove("hidden");
document.getElementById("modal-title").textContent = "Add Item";
switchModalType(
document.getElementById("field-type").value || "password",
);
} else {
document.getElementById("type-selector-group").classList.add("hidden");
const t = item.item_type || "password";
document.getElementById("modal-title").textContent =
`Edit ${TYPE_LABELS[t] || "Item"}`;
switchModalType(t);
form.dataset.itemType = t;
setVal("field-name", item.name);
setVal("field-folder", item.folder_id || "");
if (item.plain) {
const p = item.plain;
switch (t) {
case "password":
setVal("field-url", p.url);
setVal("field-username", p.username);
setVal("field-password", p.password);
setVal("field-totp-uri", p.totp_uri || "");
setVal("field-notes", p.notes);
setVal("field-tags", (p.tags || []).join(", "));
// Advanced Settings
document.getElementById("field-autofill").checked =
p.autofill !== false; // default true
document.getElementById("field-autologin").checked =
!!p.autologin;
document.getElementById("field-reprompt").checked =
!!p.reprompt;
break;
case "note":
setVal("field-note-body", p.note_body);
break;
case "card":
setVal("field-cardholder", p.cardholder_name);
setVal("field-card-number", p.card_number);
setVal("field-expiry-month", p.expiry_month);
setVal("field-expiry-year", p.expiry_year);
setVal("field-cvv", p.cvv);
setVal("field-notes", p.notes);
break;
case "bank":
setVal("field-bank-name", p.bank_name);
setVal("field-account-type", p.account_type);
setVal("field-routing", p.routing_number);
setVal("field-account-number", p.account_number);
setVal("field-notes", p.notes);
break;
case "address":
[
"first_name",
"last_name",
"company",
"address_line",
"city",
"state",
"zip",
"country",
"phone",
"email",
].forEach((k) => setVal("field-" + k.replace(/_/g, "-"), p[k]));
setVal("field-notes", p.notes);
break;
case "ssn":
setVal("field-ssn-number", p.ssn_number);
setVal("field-notes", p.notes);
break;
case "passkey":
setVal("field-pk-rp-id", p.rp_id);
setVal("field-pk-username", p.username);
setVal("field-pk-credential-id", p.credential_id);
setVal("field-pk-user-handle", p.user_handle);
setVal("field-pk-device-hint", p.device_hint);
setVal("field-pk-notes", p.notes);
setVal("field-tags", (p.tags || []).join(", "));
break;
}
}
}
document.getElementById("item-modal").classList.add("open");
document.getElementById("field-name").focus();
// Render password history panel for password items in edit mode.
const historyPanel = document.getElementById("password-history-panel");
if (historyPanel) {
if (mode === "edit" && item?.item_type === "password") {
const history = item.plain?.password_history || [];
if (history.length) {
historyPanel.classList.remove("hidden");
// Store plaintext passwords in a WeakMap keyed by button element so
// they never appear in the DOM (no data-pw attributes to scrape).
const _historyPasswords = new WeakMap();
historyPanel.innerHTML =
`🕐 Previous passwords (${history.length})
` +
history.map((h, idx) => {
const date = h.changed_at
? new Date(h.changed_at).toLocaleDateString()
: "Unknown date";
return `
••••••••
${escHtml(date)}
👁
↩ Restore
`;
}).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.
*/
async function getTotpCode(secret) {
const keyBytes = base32ToBytes(secret);
if (!keyBytes.length) return null;
const counter = Math.floor(Date.now() / 1000 / 30);
const msg = new Uint8Array(8);
// Write counter as big-endian 64-bit integer.
let c = counter;
for (let i = 7; i >= 0; i--) {
msg[i] = c & 0xff;
c = Math.floor(c / 256);
}
const cryptoKey = await crypto.subtle.importKey(
"raw",
keyBytes,
{ name: "HMAC", hash: "SHA-1" },
false,
["sign"],
);
const sig = new Uint8Array(
await crypto.subtle.sign("HMAC", cryptoKey, msg),
);
const offset = sig[19] & 0x0f;
const code =
(((sig[offset] & 0x7f) << 24) |
(sig[offset + 1] << 16) |
(sig[offset + 2] << 8) |
sig[offset + 3]) %
1_000_000;
return String(code).padStart(6, "0");
}
/** Seconds remaining in the current 30-second TOTP window. */
function totpSecondsLeft() {
return 30 - (Math.floor(Date.now() / 1000) % 30);
}
// Track the clipboard clear timer so multiple rapid copies don't stack.
let _clipboardClearTimer = null;
function copyToClipboard(text, msg) {
navigator.clipboard
.writeText(text)
.then(() => {
showToast(msg);
// Auto-clear clipboard after 30 seconds — industry-standard hygiene.
if (_clipboardClearTimer) clearTimeout(_clipboardClearTimer);
_clipboardClearTimer = setTimeout(() => {
navigator.clipboard.writeText("").catch(() => {});
_clipboardClearTimer = null;
}, 30_000);
})
.catch(() => {});
}
function escHtml(str) {
return String(str)
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """);
}
function itemIcon(type) {
return (
{
password: "🔑",
note: "📝",
card: "💳",
bank: "🏦",
address: "🏠",
ssn: "🪪",
passkey: "🔐",
}[type] || "🔑"
);
}
function showToast(message, type = "success") {
const toast = document.getElementById("toast");
if (!toast) return;
toast.textContent = message;
toast.className = `toast toast-${type} show`;
clearTimeout(toast._timer);
toast._timer = setTimeout(() => toast.classList.remove("show"), 2500);
}
function showLoadingState(loading) {
document
.getElementById("vault-spinner")
?.classList.toggle("hidden", !loading);
}
// ── Unlock overlay ────────────────────────────────────────────────────────
function showUnlockOverlay() {
document.getElementById("unlock-overlay").classList.add("open");
document.getElementById("unlock-password").focus();
}
async function handleUnlock(e) {
e.preventDefault();
const password = document.getElementById("unlock-password").value;
const enc_key_salt = sessionStorage.getItem("enc_key_salt");
const btn = e.target.querySelector('[type="submit"]');
const errEl = document.getElementById("unlock-error");
if (!enc_key_salt) {
redirectToLogin();
return;
}
btn.disabled = true;
btn.textContent = "Unlocking…";
errEl.classList.add("hidden");
try {
const vaultKey = await Crypto.deriveVaultKey(password, enc_key_salt);
VaultSession.setKey(vaultKey);
document.getElementById("unlock-overlay").classList.remove("open");
await loadFolders();
await loadVault();
} catch {
errEl.classList.remove("hidden");
} finally {
btn.disabled = false;
btn.textContent = "Unlock";
document.getElementById("unlock-password").value = "";
}
}
// ── Tabs ──────────────────────────────────────────────────────────────────
function initTabs(container) {
const tabs = container.querySelectorAll(".tab-btn");
tabs.forEach((btn) => {
btn.addEventListener("click", () => {
tabs.forEach((t) => t.classList.remove("active"));
btn.classList.add("active");
const target = btn.dataset.tab;
container.querySelectorAll(".tab-pane").forEach((pane) => {
pane.classList.toggle("hidden", !pane.id.endsWith(target));
});
});
});
}
// ── Init ──────────────────────────────────────────────────────────────────
async function init() {
if (!sessionStorage.getItem("access_token")) {
// Give the extension bridge up to 800 ms to inject a session before redirecting
await new Promise((resolve) => {
const timer = setTimeout(resolve, 800);
window.addEventListener(
"passkeeper:ext-login",
() => {
clearTimeout(timer);
resolve();
},
{ once: true },
);
});
// Still no token — try a silent refresh using the persisted refresh_token
// before giving up and redirecting. This handles hard-refresh / new-tab scenarios.
if (!sessionStorage.getItem("access_token")) {
const refreshed = await tryRefreshToken();
if (!refreshed) {
window.location.href = "/login";
return;
}
}
}
// Vault
document
.getElementById("unlock-form")
?.addEventListener("submit", handleUnlock);
document
.getElementById("btn-new-folder")
?.addEventListener("click", showNewFolderRow);
document
.getElementById("btn-cancel-folder")
?.addEventListener("click", hideNewFolderRow);
document
.getElementById("new-folder-form")
?.addEventListener("submit", handleNewFolder);
document
.getElementById("search-input")
?.addEventListener("input", handleSearch);
document
.getElementById("btn-add-item")
?.addEventListener("click", () => openModal("add"));
document
.getElementById("btn-close-modal")
?.addEventListener("click", () => closeModal());
document
.getElementById("btn-cancel-modal")
?.addEventListener("click", () => closeModal());
document
.getElementById("item-form")
?.addEventListener("submit", handleFormSubmit);
document
.getElementById("btn-logout")
?.addEventListener("click", handleLogout);
document.getElementById("sort-select")?.addEventListener("change", (e) => {
_sortOrder = e.target.value;
applyCurrentFilter();
});
document.getElementById("field-type")?.addEventListener("change", (e) => {
switchModalType(e.target.value);
if (e.target.value === "password") initPasswordFieldEnhancements();
});
document
.getElementById("btn-toggle-pass")
?.addEventListener("click", () => {
const el = document.getElementById("field-password");
el.type = el.type === "password" ? "text" : "password";
});
document.getElementById("btn-toggle-ssn")?.addEventListener("click", () => {
const el = document.getElementById("field-ssn-number");
el.type = el.type === "password" ? "text" : "password";
});
// Live tag preview in the item modal.
document.getElementById("field-tags")?.addEventListener("input", (e) => {
const preview = document.getElementById("field-tags-preview");
if (!preview) return;
const tags = _parseTags(e.target.value);
preview.innerHTML = tags
.map((t) => `${escHtml(t)} `)
.join("");
});
// Advanced Settings collapsible toggle
document
.getElementById("adv-settings-toggle")
?.addEventListener("click", () => {
const body = document.getElementById("adv-settings-body");
const btn = document.getElementById("adv-settings-toggle");
const chevron = btn.querySelector(".adv-section-chevron");
const isOpen = !body.classList.contains("hidden");
body.classList.toggle("hidden", isOpen);
btn.setAttribute("aria-expanded", String(!isOpen));
chevron.textContent = isOpen ? "\u2964" : "\u2963";
});
// Sidebar navigation
document.getElementById("sidebar-all")?.addEventListener("click", () => {
_activeFilter = null;
switchView("vault");
document
.querySelectorAll(".sidebar-item")
.forEach((el) => el.classList.remove("active"));
document.getElementById("sidebar-all").classList.add("active");
updateVaultTitle("All Items");
renderItemList(_items);
});
document.getElementById("sidebar-favorites")?.addEventListener("click", () => {
_activeFilter = { type: "tag", value: "favorite" };
switchView("vault");
document
.querySelectorAll(".sidebar-item")
.forEach((el) => el.classList.remove("active"));
document.getElementById("sidebar-favorites").classList.add("active");
updateVaultTitle("⭐ Favorites");
renderItemList(_items.filter((i) => (i.plain?.tags || []).includes("favorite")));
});
document
.getElementById("sidebar-security")
?.addEventListener("click", () => switchView("security"));
document
.getElementById("sidebar-sharing")
?.addEventListener("click", () => switchView("sharing"));
document
.getElementById("sidebar-emergency")
?.addEventListener("click", () => switchView("emergency"));
document
.getElementById("sidebar-import-export")
?.addEventListener("click", () => switchView("import-export"));
document
.getElementById("sidebar-generator")
?.addEventListener("click", () => openPasswordGeneratorModal());
document.querySelectorAll("[data-type-filter]").forEach((el) => {
el.addEventListener("click", () => {
const type = el.dataset.typeFilter;
const labels = {
password: "Passwords",
note: "Secure Notes",
card: "Payment Cards",
bank: "Bank Accounts",
address: "Addresses",
ssn: "Identities",
passkey: "Passkeys",
};
_activeFilter = { type: "itemType", value: type };
switchView("vault");
document
.querySelectorAll(".sidebar-item")
.forEach((s) => s.classList.remove("active"));
el.classList.add("active");
updateVaultTitle(labels[type] || type);
renderItemList(_items.filter((i) => i.item_type === type));
});
});
document.getElementById("item-modal")?.addEventListener("click", (e) => {
/* backdrop click intentionally disabled — modal closes only via Cancel or Save */
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
closeModal("share-modal");
closeModal("emergency-modal");
closeModal("settings-modal");
closeModal("detail-modal");
closeModal("gen-modal");
}
});
// Detail modal (shared item view)
const closeDetailModal = () => closeModal("detail-modal");
document
.getElementById("btn-close-detail-modal")
?.addEventListener("click", closeDetailModal);
document
.getElementById("btn-detail-done")
?.addEventListener("click", closeDetailModal);
document.getElementById("detail-modal")?.addEventListener("click", (e) => {
if (e.target.id === "detail-modal") closeDetailModal();
});
document
.getElementById("field-card-number")
?.addEventListener("input", (e) => {
let v = e.target.value.replace(/\D/g, "").slice(0, 16);
e.target.value = v.replace(/(.{4})/g, "$1 ").trim();
});
// Settings modal
document
.getElementById("btn-settings")
?.addEventListener("click", openSettingsModal);
document
.getElementById("btn-close-settings")
?.addEventListener("click", () => closeModal("settings-modal"));
// Web-app session timeout setting.
document
.getElementById("web-idle-select")
?.addEventListener("change", (e) => {
const mins = parseInt(e.target.value, 10);
localStorage.setItem(WEB_IDLE_KEY, mins);
_resetWebIdleTimer();
const label = mins === 0 ? "never" : mins + " min";
showToast("Auto-lock set to " + label + ".");
console.log("[PassKeeper] Web idle timeout set to", label);
});
document
.getElementById("settings-modal")
?.addEventListener("click", (e) => {
if (e.target.id === "settings-modal") closeModal("settings-modal");
});
document
.getElementById("btn-mfa-confirm")
?.addEventListener("click", handleMfaConfirm);
document
.getElementById("btn-mfa-cancel-setup")
?.addEventListener("click", () => {
document.getElementById("mfa-setup-area").classList.add("hidden");
loadMfaStatus();
});
document
.getElementById("btn-mfa-disable-confirm")
?.addEventListener("click", handleMfaDisableConfirm);
document
.getElementById("btn-mfa-disable-cancel")
?.addEventListener("click", () => {
document.getElementById("mfa-disable-area").classList.add("hidden");
loadMfaStatus();
});
document
.getElementById("btn-change-password")
?.addEventListener("click", handleChangePassword);
document
.getElementById("btn-delete-account")
?.addEventListener("click", () => {
document.getElementById("btn-delete-account").classList.add("hidden");
document
.getElementById("delete-confirm-area")
.classList.remove("hidden");
document.getElementById("delete-password").focus();
});
document
.getElementById("btn-delete-cancel")
?.addEventListener("click", () => {
document.getElementById("delete-confirm-area").classList.add("hidden");
document
.getElementById("btn-delete-account")
.classList.remove("hidden");
document.getElementById("delete-password").value = "";
document.getElementById("delete-error").classList.add("hidden");
});
document
.getElementById("btn-delete-confirm")
?.addEventListener("click", handleDeleteAccount);
document
.getElementById("btn-copy-recovery-code")
?.addEventListener("click", () => {
const code = document.getElementById("recovery-code-value").textContent;
navigator.clipboard
.writeText(code)
.then(() => showToast("Recovery code copied"));
});
document
.getElementById("btn-recovery-done")
?.addEventListener("click", () => {
document
.getElementById("recovery-code-display")
.classList.add("hidden");
loadRecoveryStatus();
});
// Share modal
document
.getElementById("btn-share-item")
?.addEventListener("click", openShareModal);
document
.getElementById("btn-close-share-modal")
?.addEventListener("click", () => closeModal("share-modal"));
document
.getElementById("btn-cancel-share")
?.addEventListener("click", () => closeModal("share-modal"));
document.getElementById("share-modal")?.addEventListener("click", (e) => {
if (e.target.id === "share-modal") closeModal("share-modal");
});
document
.getElementById("share-form")
?.addEventListener("submit", handleShareFormSubmit);
document
.getElementById("btn-setup-keys-from-sharing")
?.addEventListener("click", () => {
closeModal("share-modal");
openSettingsModal();
});
// Emergency modal
document
.getElementById("btn-add-emergency")
?.addEventListener("click", () => {
document.getElementById("emergency-error").classList.add("hidden");
document.getElementById("emergency-modal").classList.add("open");
});
document
.getElementById("btn-close-emergency-modal")
?.addEventListener("click", () => closeModal("emergency-modal"));
document
.getElementById("btn-cancel-emergency")
?.addEventListener("click", () => closeModal("emergency-modal"));
document
.getElementById("emergency-modal")
?.addEventListener("click", (e) => {
if (e.target.id === "emergency-modal") closeModal("emergency-modal");
});
document
.getElementById("emergency-form")
?.addEventListener("submit", handleEmergencyFormSubmit);
// Tabs
document.querySelectorAll(".panel-tabs").forEach((c) => initTabs(c));
// Sidebar collapse toggle — desktop only (mobile uses hamburger instead)
const sidebar = document.getElementById("sidebar");
const toggleBtn = document.getElementById("btn-sidebar-toggle");
if (
sidebar &&
toggleBtn &&
document.body.classList.contains("is-desktop")
) {
const STORAGE_KEY = "sidebar_collapsed";
if (localStorage.getItem(STORAGE_KEY) === "1") {
sidebar.classList.add("collapsed");
toggleBtn.setAttribute("aria-label", "Expand sidebar");
toggleBtn.setAttribute("title", "Expand sidebar");
}
toggleBtn.addEventListener("click", () => {
const isCollapsed = sidebar.classList.toggle("collapsed");
localStorage.setItem(STORAGE_KEY, isCollapsed ? "1" : "0");
toggleBtn.setAttribute(
"aria-label",
isCollapsed ? "Expand sidebar" : "Collapse sidebar",
);
toggleBtn.setAttribute(
"title",
isCollapsed ? "Expand sidebar" : "Collapse sidebar",
);
});
}
// Mobile sidebar: hamburger opens overlay, backdrop/item-click closes it
const backdrop = document.getElementById("sidebar-backdrop");
const mobileMenuBtn = document.getElementById("btn-mobile-menu");
function openMobileSidebar() {
sidebar?.classList.add("mobile-open");
backdrop?.classList.add("open");
document.documentElement.classList.add("sidebar-open");
}
function closeMobileSidebar() {
sidebar?.classList.remove("mobile-open");
backdrop?.classList.remove("open");
document.documentElement.classList.remove("sidebar-open");
}
mobileMenuBtn?.addEventListener("click", openMobileSidebar);
backdrop?.addEventListener("click", closeMobileSidebar);
backdrop?.addEventListener("touchstart", closeMobileSidebar, {
passive: true,
});
// Close mobile sidebar when a nav item is tapped (mobile only)
document.querySelectorAll(".sidebar-item").forEach((item) => {
item.addEventListener("click", () => {
if (document.body.classList.contains("is-mobile")) closeMobileSidebar();
});
});
// Escape key closes the sidebar
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && sidebar?.classList.contains("mobile-open"))
closeMobileSidebar();
});
// Mobile FAB mirrors the desktop add-item button
document
.getElementById("btn-add-item-mobile")
?.addEventListener("click", () => {
document.getElementById("btn-add-item")?.click();
});
// ── Browser history / back-button support ─────────────────────────────────
const _hashToView = (hash) => {
const v = (hash || "").replace("#", "").trim();
const valid = [
"vault",
"security",
"sharing",
"emergency",
"import-export",
];
return valid.includes(v) ? v : "vault";
};
const _initialView = _hashToView(window.location.hash);
history.replaceState({ view: _initialView }, "", window.location.href);
window.addEventListener("popstate", (e) => {
const view = e.state?.view || _hashToView(window.location.hash);
switchView(view, { pushState: false });
});
// Start web-app inactivity tracking.
_startWebIdleTracking();
_initBulkToolbar();
if (!VaultSession.getKey()) {
showUnlockOverlay();
} else {
loadVault();
// Restore view from hash after vault loads (avoids rendering before decrypt).
if (_initialView !== "vault")
switchView(_initialView, { pushState: false });
}
}
async function handleEmergencyFormSubmit(e) {
e.preventDefault();
const errEl = document.getElementById("emergency-error");
errEl.classList.add("hidden");
const email = document
.getElementById("emergency-email")
.value.trim()
.toLowerCase();
const waitDays = document.getElementById("emergency-wait-days").value;
if (!email) {
errEl.textContent = "Email is required.";
errEl.classList.remove("hidden");
return;
}
try {
const res = await apiFetch("/api/emergency", {
method: "POST",
body: JSON.stringify({
grantee_email: email,
wait_days: parseInt(waitDays),
}),
});
if (!res) return;
closeModal("emergency-modal");
showToast("Invitation sent");
loadEmergencyView();
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove("hidden");
}
}
return {
init,
// Exposed for inline onclick on the sidebar li — ensures the click reaches
// switchView regardless of addEventListener binding order or timing.
switchToImportExport: () => switchView("import-export"),
};
})();
/**
* Mobile/desktop mode detection.
*
* Sets body.is-mobile or body.is-desktop BEFORE first paint so CSS
* scoped to those classes takes effect without any flash or transition.
* Uses matchMedia (same source of truth as CSS media queries) rather
* than window.innerWidth (unreliable on some Android Chrome builds).
*
* MOBILE_BP must match the CSS breakpoint. 768px covers all phones and
* iPad Mini portrait (744px). iPad Air portrait (820px) and larger get
* the desktop sidebar layout.
*/
(function initLayout() {
const MOBILE_BP = 768;
const mq = window.matchMedia(`(max-width: ${MOBILE_BP}px)`);
function applyMode(isMobile) {
document.body.classList.toggle("is-mobile", isMobile);
document.body.classList.toggle("is-desktop", !isMobile);
}
// Set immediately — body class is applied before CSS is rendered
applyMode(mq.matches);
// Re-apply on orientation change / window resize (e.g. split-screen, rotation)
mq.addEventListener("change", (e) => {
applyMode(e.matches);
// If switching to desktop, ensure mobile sidebar is closed
if (!e.matches) {
document.getElementById("sidebar")?.classList.remove("mobile-open");
document.getElementById("sidebar-backdrop")?.classList.remove("open");
document.documentElement.classList.remove("sidebar-open");
}
});
})();
/**
* Viewport height fix for Chrome on Android.
*
* Chrome's 100vh includes the address bar height; window.innerHeight does not.
* Setting --vh from innerHeight and using calc(var(--vh,1vh)*100) in CSS
* gives the true usable viewport height on all browsers.
*/
(function setViewportHeight() {
function update() {
document.documentElement.style.setProperty(
"--vh",
`${window.innerHeight * 0.01}px`,
);
}
update();
window.addEventListener("resize", update, { passive: true });
window.addEventListener("orientationchange", () => setTimeout(update, 100), {
passive: true,
});
if (window.ResizeObserver) {
new ResizeObserver(update).observe(document.documentElement);
}
})();
document.addEventListener("DOMContentLoaded", Vault.init);