Files
PassKeeper/app/static/js/vault.js
T

2691 lines
90 KiB
JavaScript

/**
* 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";
let _activeFilter = null;
let _sortOrder = "name-asc";
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",
};
const SENSITIVE_FIELDS = new Set([
"password",
"cvv",
"card_number",
"account_number",
"routing_number",
"ssn_number",
]);
// ── 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 && res.status !== 404) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${res.status}`);
}
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,
);
return { ...item, plain };
} catch {
return { ...item, plain: null, decryptError: true };
}
}),
);
renderFolderList();
applyCurrentFilter();
} catch (err) {
showToast("Failed to load vault: " + err.message, "error");
} finally {
showLoadingState(false);
}
}
async function loadFolders() {
try {
const res = await apiFetch("/api/folders");
if (!res) return;
_folders = await res.json();
renderFolderList();
} catch (err) {
showToast("Could not refresh folders: " + err.message, "error");
}
}
// ── View switching ────────────────────────────────────────────────────────
function switchView(view) {
_currentView = view;
["vault", "security", "sharing", "emergency"].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"));
}
if (view === "security") renderSecurityDashboard();
if (view === "sharing") loadSharingView();
if (view === "emergency") loadEmergencyView();
}
// ── Vault render ──────────────────────────────────────────────────────────
function renderFolderList() {
const ul = document.getElementById("sidebar-folders");
if (!ul) return;
ul.innerHTML = "";
_folders.forEach((folder) => {
const li = document.createElement("li");
li.className = "sidebar-item folder-item";
li.dataset.folderId = folder.id;
const label = document.createElement("span");
label.className = "folder-label";
label.innerHTML = `<span class="sidebar-icon">📁</span> <span class="sidebar-label">${escHtml(folder.name)}</span>`;
label.addEventListener("click", () =>
filterByFolder(folder.id, folder.name),
);
const delBtn = document.createElement("button");
delBtn.className = "btn-delete-folder";
delBtn.title = "Delete folder";
delBtn.textContent = "🗑";
delBtn.addEventListener("click", (e) => {
e.stopPropagation();
confirmDeleteFolder(folder);
});
li.appendChild(label);
li.appendChild(delBtn);
ul.appendChild(li);
});
}
function getSortedItems(items) {
const sorted = [...items];
switch (_sortOrder) {
case "name-asc":
sorted.sort((a, b) => a.name.localeCompare(b.name));
break;
case "name-desc":
sorted.sort((a, b) => b.name.localeCompare(a.name));
break;
case "date-desc":
sorted.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
break;
case "date-asc":
sorted.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
break;
case "folder":
sorted.sort(
(a, b) =>
folderName(a.folder_id).localeCompare(folderName(b.folder_id)) ||
a.name.localeCompare(b.name),
);
break;
}
return sorted;
}
function folderName(id) {
if (!id) return "(No folder)";
return _folders.find((f) => f.id === id)?.name || "Unknown";
}
function renderItemList(items) {
const list = document.getElementById("vault-list");
if (!list) return;
list.innerHTML = "";
if (items.length === 0) {
list.innerHTML =
'<li class="vault-empty">No items found. Click + to add one.</li>';
return;
}
const sorted = getSortedItems(items);
const groups = {};
sorted.forEach((item) => {
const key = folderName(item.folder_id);
if (!groups[key]) groups[key] = [];
groups[key].push(item);
});
const keys =
_sortOrder === "folder"
? Object.keys(groups).sort()
: ["(No folder)", ..._folders.map((f) => f.name)].filter(
(k) => groups[k],
);
Object.keys(groups).forEach((k) => {
if (!keys.includes(k)) keys.push(k);
});
keys.forEach((groupName) => {
if (!groups[groupName]) return;
const header = document.createElement("li");
header.className = "vault-group-header";
header.textContent = groupName;
list.appendChild(header);
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] || "🔑";
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;
}
}
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;
li.innerHTML = `
<div class="item-icon">${icon}</div>
<div class="item-info">
<span class="item-name">${escHtml(item.name)}</span>
<span class="item-sub">${escHtml(subText)}</span>
</div>
<div class="item-actions">
${showLaunch ? `<button class="btn-icon" title="Open URL" data-action="launch">🌐</button>` : ""}
${showCopyUser ? `<button class="btn-icon" title="Copy username" data-action="copy-user">👤</button>` : ""}
${showCopyPass ? `<button class="btn-icon" title="Copy secret" data-action="copy-pass">📋</button>` : ""}
<button class="btn-icon" title="Edit" data-action="edit">✏️</button>
<button class="btn-icon" title="Delete" data-action="delete">🗑️</button>
</div>`;
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");
},
);
}
li.querySelector('[data-action="edit"]').addEventListener("click", (e) => {
e.stopPropagation();
openModal("edit", item);
});
li.querySelector('[data-action="delete"]').addEventListener(
"click",
(e) => {
e.stopPropagation();
confirmDeleteItem(item);
},
);
li.addEventListener("click", () => openModal("edit", item));
return li;
}
// ── Security Dashboard ────────────────────────────────────────────────────
function renderSecurityDashboard() {
const summaryEl = document.getElementById("security-summary");
const sectionsEl = document.getElementById("security-sections");
if (!summaryEl || !sectionsEl) return;
// If vault hasn't loaded yet, wait for it then re-render
if (!_items.length && VaultSession.getKey()) {
summaryEl.innerHTML = '<div class="sec-notice">Loading…</div>';
sectionsEl.innerHTML = "";
loadVault().then(() => renderSecurityDashboard());
return;
}
const pwItems = _items.filter(
(i) => i.item_type === "password" && i.plain?.password,
);
if (!pwItems.length) {
summaryEl.innerHTML =
'<div class="sec-notice">No passwords to analyze.</div>';
sectionsEl.innerHTML = "";
return;
}
const weak = pwItems.filter((i) => {
const p = i.plain.password;
if (p.length < 10) return true;
return (
[/[A-Z]/, /[a-z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((r) => r.test(p))
.length < 2
);
});
const passCounts = {};
pwItems.forEach((i) => {
const p = i.plain.password;
(passCounts[p] = passCounts[p] || []).push(i);
});
const reused = Object.values(passCounts)
.filter((a) => a.length > 1)
.flat();
const cutoff = Date.now() - 180 * 86400000;
const old = pwItems.filter(
(i) => new Date(i.created_at).getTime() < cutoff,
);
const total = pwItems.length;
const score = Math.max(
0,
Math.round(
100 -
(weak.length / total) * 40 -
(reused.length / total) * 30 -
(old.length / total) * 15,
),
);
const cls =
score >= 80 ? "score-good" : score >= 50 ? "score-fair" : "score-poor";
const label =
score >= 80 ? "Good" : score >= 50 ? "Fair" : "Needs attention";
summaryEl.innerHTML = `
<div class="sec-score-card">
<div class="sec-score ${cls}">${score}</div>
<div class="sec-score-label">${label}</div>
<div class="sec-score-desc">Based on ${total} password${total !== 1 ? "s" : ""}</div>
</div>
<div class="sec-stats">
<div class="sec-stat ${weak.length ? "sec-stat-warn" : "sec-stat-ok"}"><span class="sec-stat-num">${weak.length}</span><span class="sec-stat-label">Weak</span></div>
<div class="sec-stat ${reused.length ? "sec-stat-warn" : "sec-stat-ok"}"><span class="sec-stat-num">${reused.length}</span><span class="sec-stat-label">Reused</span></div>
<div class="sec-stat ${old.length ? "sec-stat-info" : "sec-stat-ok"}"><span class="sec-stat-num">${old.length}</span><span class="sec-stat-label">Old (&gt;180d)</span></div>
</div>`;
sectionsEl.innerHTML = "";
const makeSection = (title, icon, items, desc) => {
if (!items.length) return;
const sec = document.createElement("div");
sec.className = "sec-section";
sec.innerHTML = `
<div class="sec-section-header"><span class="sec-section-icon">${icon}</span>
<div><div class="sec-section-title">${title} (${items.length})</div><div class="sec-section-desc">${desc}</div></div>
</div>
<ul class="sec-item-list">
${items
.map(
(i) => `<li class="sec-item">
<span class="sec-item-name">${escHtml(i.name)}</span>
<span class="sec-item-sub">${escHtml(i.plain?.username || "")}</span>
<button class="btn-secondary btn-sm" data-sec-edit="${i.id}">Edit</button>
</li>`,
)
.join("")}
</ul>`;
sec.querySelectorAll("[data-sec-edit]").forEach((btn) => {
btn.addEventListener("click", () => {
const item = _items.find(
(it) => it.id === parseInt(btn.dataset.secEdit),
);
if (item) {
switchView("vault");
openModal("edit", item);
}
});
});
sectionsEl.appendChild(sec);
};
makeSection(
"Weak Passwords",
"⚠️",
weak,
"Short or low-complexity passwords.",
);
makeSection(
"Reused Passwords",
"♻️",
[...new Set(reused.map((i) => i.id))]
.map((id) => _items.find((i) => i.id === id))
.filter(Boolean),
"Same password used on multiple sites.",
);
makeSection("Old Passwords", "🕐", old, "Not changed in over 180 days.");
}
// ── Sharing View ──────────────────────────────────────────────────────────
async function loadSharingView() {
try {
const keysRes = await apiFetch("/api/sharing/keys");
if (!keysRes) return;
const keysData = await keysRes.json();
const notice = document.getElementById("sharing-keys-notice");
if (!keysData.keys_setup) {
notice.classList.remove("hidden");
} else {
notice.classList.add("hidden");
// Load sharing private key into SharingSession if vault key available
const vaultKey = VaultSession.getKey();
if (vaultKey && !SharingSession.isReady()) {
try {
const privKey = await SharingCrypto.decryptPrivateKey(
vaultKey,
keysData.private_key_enc,
keysData.private_key_iv,
);
SharingSession.setKey(privKey);
} catch (err) {
console.warn(
"[PassKeeper] Could not load sharing private key:",
err,
);
}
}
}
await Promise.all([loadSharedOut(), loadSharedIn()]);
} catch (err) {
showToast("Could not load sharing data: " + err.message, "error");
}
}
async function loadSharedOut() {
try {
const res = await apiFetch("/api/sharing");
if (!res) return;
const shares = await res.json();
renderSharedOut(shares);
} catch (err) {
console.error("loadSharedOut:", err);
}
}
async function loadSharedIn() {
try {
const res = await apiFetch("/api/sharing/inbox");
if (!res) return;
const shares = await res.json();
renderSharedIn(shares);
} catch (err) {
console.error("loadSharedIn:", err);
}
}
function renderSharedOut(shares) {
const ul = document.getElementById("shared-out-list");
if (!ul) return;
if (!shares.length) {
ul.innerHTML =
'<li class="vault-empty">You haven\'t shared any items yet.</li>';
return;
}
ul.innerHTML = shares
.map(
(s) => `
<li class="share-item">
<div class="share-icon">${itemIcon(s.item_type)}</div>
<div class="share-info">
<span class="share-name">${escHtml(s.item_name)}</span>
<span class="share-meta">Shared with ${escHtml(s.recipient_email)} · ${s.accepted ? "✅ Accepted" : "⏳ Pending"}</span>
</div>
<button class="btn-icon" title="Revoke share" data-revoke="${s.id}">🗑️</button>
</li>`,
)
.join("");
ul.querySelectorAll("[data-revoke]").forEach((btn) => {
btn.addEventListener("click", async () => {
if (!confirm("Remove this share?")) return;
try {
await apiFetch(`/api/sharing/${btn.dataset.revoke}`, {
method: "DELETE",
});
showToast("Share removed");
loadSharedOut();
} catch (err) {
showToast(err.message, "error");
}
});
});
}
function renderSharedIn(shares) {
const ul = document.getElementById("shared-in-list");
if (!ul) return;
if (!shares.length) {
ul.innerHTML =
'<li class="vault-empty">No items have been shared with you.</li>';
return;
}
ul.innerHTML = shares
.map(
(s) => `
<li class="share-item">
<div class="share-icon">${itemIcon(s.item_type)}</div>
<div class="share-info">
<span class="share-name">${escHtml(s.item_name)}</span>
<span class="share-meta">From ${escHtml(s.owner_email)}</span>
</div>
${
!s.accepted
? `<button class="btn-primary btn-sm" data-accept="${s.id}">Accept</button>`
: `<button class="btn-secondary btn-sm" data-view-share="${s.id}" data-owner-key="${escHtml(s.owner_public_key || "")}" data-enc="${escHtml(s.enc_data)}" data-iv="${escHtml(s.iv)}" data-name="${escHtml(s.item_name)}" data-type="${escHtml(s.item_type || "")}">View</button>`
}
</li>`,
)
.join("");
ul.querySelectorAll("[data-accept]").forEach((btn) => {
btn.addEventListener("click", async () => {
try {
await apiFetch(`/api/sharing/inbox/${btn.dataset.accept}/accept`, {
method: "POST",
});
showToast("Share accepted");
loadSharedIn();
} catch (err) {
showToast(err.message, "error");
}
});
});
ul.querySelectorAll("[data-view-share]").forEach((btn) => {
btn.addEventListener("click", async () => {
if (!SharingSession.isReady()) {
showToast("Sharing keys not loaded. Check Settings.", "error");
return;
}
try {
const ownerPubKey = await SharingCrypto.importPublicKey(
btn.dataset.ownerKey,
);
const sharedKey = await SharingCrypto.deriveSharedKey(
SharingSession.getKey(),
ownerPubKey,
);
const plain = await SharingCrypto.decryptShare(
sharedKey,
btn.dataset.enc,
btn.dataset.iv,
);
showSharedItemDetails(btn.dataset.name, btn.dataset.type, plain);
} catch (err) {
showToast("Could not decrypt: " + err.message, "error");
}
});
});
}
function showSharedItemDetails(name, itemType, plain) {
document.getElementById("detail-modal-title").textContent =
`${itemIcon(itemType || "password")} ${name}`;
renderDetailFields(document.getElementById("detail-modal-body"), plain);
document.getElementById("detail-modal").classList.add("open");
}
function renderDetailFields(container, plain) {
container.innerHTML =
Object.entries(plain)
.filter(([, v]) => v)
.map(([k, v]) => {
const label = FIELD_LABELS[k] || k.replace(/_/g, " ");
const val = String(v);
const sensitive = SENSITIVE_FIELDS.has(k);
const valHtml = sensitive
? `<span class="detail-sensitive">
<span class="detail-masked">••••••••</span>
<span class="detail-actual hidden">${escHtml(val)}</span>
</span>
<button type="button" class="btn-icon btn-reveal" title="Show/hide">👁</button>`
: `<span class="detail-val">${escHtml(val)}</span>`;
return `<div class="detail-field">
<span class="detail-label">${escHtml(label)}</span>
<div class="detail-val-row">
${valHtml}
<button type="button" class="btn-icon btn-copy-field" data-copy="${escHtml(val)}" title="Copy">📋</button>
</div>
</div>`;
})
.join("") || '<p class="vault-empty">No fields to display.</p>';
container.querySelectorAll(".btn-reveal").forEach((btn) => {
btn.addEventListener("click", () => {
const span = btn.previousElementSibling;
const revealed = span.dataset.revealed === "true";
span.dataset.revealed = String(!revealed);
span
.querySelector(".detail-masked")
.classList.toggle("hidden", !revealed);
span
.querySelector(".detail-actual")
.classList.toggle("hidden", revealed);
});
});
container.querySelectorAll(".btn-copy-field").forEach((btn) => {
btn.addEventListener("click", () => {
navigator.clipboard
.writeText(btn.dataset.copy)
.then(() => showToast("Copied!"));
});
});
}
async function handleShareFormSubmit(e) {
e.preventDefault();
const errEl = document.getElementById("share-error");
errEl.classList.add("hidden");
if (!SharingSession.isReady()) {
showToast("Set up sharing keys first (Settings)", "error");
return;
}
const itemId = parseInt(document.getElementById("share-item-select").value);
const recipientEmail = document
.getElementById("share-recipient-email")
.value.trim()
.toLowerCase();
if (!itemId || !recipientEmail) {
errEl.textContent = "Please select an item and enter a recipient email.";
errEl.classList.remove("hidden");
return;
}
try {
// Fetch recipient's public key
const pkRes = await apiFetch(
`/api/sharing/public-key?email=${encodeURIComponent(recipientEmail)}`,
);
if (!pkRes) return;
const pkData = await pkRes.json();
const recipientPubKey = await SharingCrypto.importPublicKey(
pkData.public_key,
);
const sharedKey = await SharingCrypto.deriveSharedKey(
SharingSession.getKey(),
recipientPubKey,
);
// Find item plaintext
const item = _items.find((i) => i.id === itemId);
if (!item?.plain) {
showToast("Item not found or not decrypted", "error");
return;
}
const { enc_data, iv } = await SharingCrypto.encryptForShare(
sharedKey,
item.plain,
);
const res = await apiFetch("/api/sharing", {
method: "POST",
body: JSON.stringify({
item_id: itemId,
recipient_email: recipientEmail,
enc_data,
iv,
item_name: item.name,
item_type: item.item_type,
}),
});
if (!res) return;
closeModal("share-modal");
showToast("Item shared successfully");
loadSharedOut();
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove("hidden");
}
}
function openShareModal() {
const sel = document.getElementById("share-item-select");
sel.innerHTML = _items
.map(
(i) =>
`<option value="${i.id}">${escHtml(i.name)} (${i.item_type})</option>`,
)
.join("");
document.getElementById("share-recipient-email").value = "";
document.getElementById("share-error").classList.add("hidden");
document.getElementById("share-modal").classList.add("open");
}
// ── Emergency Access View ─────────────────────────────────────────────────
async function loadEmergencyView() {
try {
const res = await apiFetch("/api/emergency");
if (!res) return;
const data = await res.json();
renderEmergencyGrants(data.grants);
renderEmergencyAccess(data.access);
} catch (err) {
showToast(
"Could not load emergency access data: " + err.message,
"error",
);
}
}
function renderEmergencyGrants(grants) {
const ul = document.getElementById("em-grants-list");
if (!ul) return;
if (!grants.length) {
ul.innerHTML =
'<li class="vault-empty">No trusted contacts set up yet.</li>';
return;
}
ul.innerHTML = grants
.map((g) => {
let actions = "";
if (g.status === "accepted") {
actions = `<button class="btn-primary btn-sm" data-provide="${g.id}">Provide Recovery Data</button>`;
}
if (g.status === "pending") {
const waitInfo = g.wait_elapsed
? "Wait period elapsed"
: "Access requested";
actions = `<span class="badge badge-warn">${waitInfo}</span>
<button class="btn-secondary btn-sm" data-deny="${g.id}">Deny</button>`;
}
return `<li class="share-item">
<div class="share-icon">🚨</div>
<div class="share-info">
<span class="share-name">${escHtml(g.grantee_email)}</span>
<span class="share-meta">Status: ${escHtml(g.status)} · Wait: ${g.wait_days} day(s)</span>
</div>
<div class="share-actions">
${actions}
<button class="btn-icon" title="Remove" data-remove-ea="${g.id}">🗑️</button>
</div>
</li>`;
})
.join("");
ul.querySelectorAll("[data-provide]").forEach((btn) => {
btn.addEventListener("click", () =>
handleProvideEmergencyData(parseInt(btn.dataset.provide)),
);
});
ul.querySelectorAll("[data-deny]").forEach((btn) => {
btn.addEventListener("click", async () => {
if (!confirm("Deny this access request?")) return;
try {
await apiFetch(`/api/emergency/${btn.dataset.deny}/deny`, {
method: "POST",
});
showToast("Access request denied");
loadEmergencyView();
} catch (err) {
showToast(err.message, "error");
}
});
});
ul.querySelectorAll("[data-remove-ea]").forEach((btn) => {
btn.addEventListener("click", async () => {
if (!confirm("Remove this emergency access grant?")) return;
try {
await apiFetch(`/api/emergency/${btn.dataset.removeEa}`, {
method: "DELETE",
});
showToast("Emergency access removed");
loadEmergencyView();
} catch (err) {
showToast(err.message, "error");
}
});
});
}
async function handleProvideEmergencyData(eaId) {
if (!SharingSession.isReady()) {
showToast("Set up sharing keys first (Settings → Sharing Keys)", "error");
return;
}
if (
!confirm(
"This will create an encrypted snapshot of all your vault items for your trusted contact. Continue?",
)
)
return;
try {
// Get grantee's public key from the emergency record
const listRes = await apiFetch("/api/emergency");
const listData = await listRes.json();
const ea = listData.grants.find((g) => g.id === eaId);
if (!ea) {
showToast("Emergency access record not found", "error");
return;
}
// Fetch grantee's public key
const pkRes = await apiFetch(
`/api/sharing/public-key?email=${encodeURIComponent(ea.grantee_email)}`,
);
if (!pkRes) return;
const pkData = await pkRes.json();
const granteePubKey = await SharingCrypto.importPublicKey(
pkData.public_key,
);
const sharedKey = await SharingCrypto.deriveSharedKey(
SharingSession.getKey(),
granteePubKey,
);
// Re-encrypt all decrypted vault items
const encItems = await Promise.all(
_items
.filter((i) => i.plain)
.map(async (i) => {
const { enc_data, iv } = await SharingCrypto.encryptForShare(
sharedKey,
i.plain,
);
return {
id: i.id,
name: i.name,
item_type: i.item_type,
enc_data,
iv,
};
}),
);
await apiFetch(`/api/emergency/${eaId}/provide`, {
method: "POST",
body: JSON.stringify({ enc_vault: JSON.stringify(encItems) }),
});
showToast("Recovery data provided");
loadEmergencyView();
} catch (err) {
showToast("Failed to provide recovery data: " + err.message, "error");
}
}
function renderEmergencyAccess(access) {
const ul = document.getElementById("em-access-list");
if (!ul) return;
if (!access.length) {
ul.innerHTML =
'<li class="vault-empty">No one has designated you as an emergency contact.</li>';
return;
}
ul.innerHTML = access
.map((a) => {
let actions = "";
if (a.status === "invited") {
actions = `<button class="btn-primary btn-sm" data-accept-ea="${a.id}">Accept</button>`;
} else if (a.status === "ready") {
actions = `<button class="btn-primary btn-sm" data-request-ea="${a.id}">Request Access</button>`;
} else if (a.status === "pending") {
let daysLeft = a.wait_days;
if (a.request_initiated_at) {
const elapsed =
(Date.now() - new Date(a.request_initiated_at).getTime()) /
86400000;
daysLeft = Math.max(0, Math.ceil(a.wait_days - elapsed));
}
actions = a.wait_elapsed
? `<button class="btn-primary btn-sm" data-get-vault="${a.id}">View Vault</button>`
: `<span class="badge badge-info">Waiting (~${daysLeft} day${daysLeft !== 1 ? "s" : ""} left)</span>`;
}
return `<li class="share-item">
<div class="share-icon">🔐</div>
<div class="share-info">
<span class="share-name">${escHtml(a.grantor_email || "Unknown")}</span>
<span class="share-meta">Status: ${escHtml(a.status)} · Wait: ${a.wait_days} day(s)</span>
</div>
<div class="share-actions">${actions}</div>
</li>`;
})
.join("");
ul.querySelectorAll("[data-accept-ea]").forEach((btn) => {
btn.addEventListener("click", async () => {
try {
await apiFetch(`/api/emergency/${btn.dataset.acceptEa}/accept`, {
method: "POST",
});
showToast("Emergency access accepted");
loadEmergencyView();
} catch (err) {
showToast(err.message, "error");
}
});
});
ul.querySelectorAll("[data-request-ea]").forEach((btn) => {
btn.addEventListener("click", async () => {
if (
!confirm(
"Request emergency access? This will start the waiting period. The vault owner will be notified.",
)
)
return;
try {
await apiFetch(`/api/emergency/${btn.dataset.requestEa}/request`, {
method: "POST",
});
showToast("Access request sent — wait period has started");
loadEmergencyView();
} catch (err) {
showToast(err.message, "error");
}
});
});
ul.querySelectorAll("[data-get-vault]").forEach((btn) => {
btn.addEventListener("click", () =>
handleGetEmergencyVault(parseInt(btn.dataset.getVault)),
);
});
}
async function handleGetEmergencyVault(eaId) {
if (!SharingSession.isReady()) {
showToast("Set up sharing keys first (Settings → Sharing Keys)", "error");
return;
}
try {
const res = await apiFetch(`/api/emergency/${eaId}/vault`);
if (!res) return;
const data = await res.json();
const grantorPubKey = await SharingCrypto.importPublicKey(
data.grantor_public_key,
);
const sharedKey = await SharingCrypto.deriveSharedKey(
SharingSession.getKey(),
grantorPubKey,
);
const encItems = JSON.parse(data.enc_vault);
const decItems = await Promise.all(
encItems.map(async (i) => {
try {
const plain = await SharingCrypto.decryptShare(
sharedKey,
i.enc_data,
i.iv,
);
return { ...i, plain };
} catch {
return { ...i, plain: null };
}
}),
);
renderEmergencyVaultPanel(decItems);
} catch (err) {
showToast("Could not retrieve vault: " + err.message, "error");
}
}
function renderEmergencyVaultPanel(items) {
const list = document.getElementById("em-access-list");
const panel = document.getElementById("em-vault-panel");
const vaultList = document.getElementById("em-vault-list");
if (!panel || !vaultList) return;
list.classList.add("hidden");
panel.classList.remove("hidden");
if (!items.length) {
vaultList.innerHTML =
'<li class="vault-empty">No items in emergency vault.</li>';
return;
}
vaultList.innerHTML = items
.map((item, idx) => {
const icon = itemIcon(item.item_type);
const hasFields =
item.plain && Object.values(item.plain).some((v) => v);
return `<li class="em-vault-item">
<button type="button" class="em-vault-item-header" data-em-idx="${idx}" aria-expanded="false">
<span class="em-vault-icon">${icon}</span>
<span class="em-vault-name">${escHtml(item.name)}</span>
<span class="em-vault-type">${escHtml(item.item_type)}</span>
<span class="em-vault-chevron">▸</span>
</button>
<div class="em-vault-item-body hidden" id="em-vault-body-${idx}">
${
hasFields
? '<div class="detail-body em-detail-body"></div>'
: '<p class="vault-empty">Could not decrypt this item.</p>'
}
</div>
</li>`;
})
.join("");
// Populate detail fields and wire expand/collapse
items.forEach((item, idx) => {
const header = vaultList.querySelector(`[data-em-idx="${idx}"]`);
const body = document.getElementById(`em-vault-body-${idx}`);
const detailBody = body?.querySelector(".em-detail-body");
if (detailBody && item.plain) renderDetailFields(detailBody, item.plain);
header?.addEventListener("click", () => {
const expanded = header.getAttribute("aria-expanded") === "true";
header.setAttribute("aria-expanded", String(!expanded));
header.querySelector(".em-vault-chevron").textContent = expanded
? "▸"
: "▾";
body?.classList.toggle("hidden", expanded);
});
});
document
.getElementById("btn-em-vault-back")
?.addEventListener("click", () => {
panel.classList.add("hidden");
list.classList.remove("hidden");
});
}
// ── Password Generator ────────────────────────────────────────────────
const GEN_CHARSETS = {
upper: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
lower: "abcdefghijklmnopqrstuvwxyz",
digits: "0123456789",
symbols: "!@#$%^&*()-_=+[]{}|;:,.<>?",
};
function generatePassword(
length = 20,
opts = { upper: true, lower: true, digits: true, symbols: true },
) {
let pool = "";
const required = [];
if (opts.upper) {
pool += GEN_CHARSETS.upper;
required.push(GEN_CHARSETS.upper);
}
if (opts.lower) {
pool += GEN_CHARSETS.lower;
required.push(GEN_CHARSETS.lower);
}
if (opts.digits) {
pool += GEN_CHARSETS.digits;
required.push(GEN_CHARSETS.digits);
}
if (opts.symbols) {
pool += GEN_CHARSETS.symbols;
required.push(GEN_CHARSETS.symbols);
}
if (!pool) pool = GEN_CHARSETS.lower;
const arr = new Uint32Array(length + required.length);
window.crypto.getRandomValues(arr);
// Guarantee at least one char from each required charset
const chars = required.map((cs, i) => cs[arr[i] % cs.length]);
for (let i = required.length; i < length + required.length; i++) {
chars.push(pool[arr[i] % pool.length]);
}
// Fisher-Yates shuffle using crypto random
const shuffle = new Uint32Array(chars.length);
window.crypto.getRandomValues(shuffle);
for (let i = chars.length - 1; i > 0; i--) {
const j = shuffle[i] % (i + 1);
[chars[i], chars[j]] = [chars[j], chars[i]];
}
return chars.slice(0, length).join("");
}
function scorePassword(pw) {
if (!pw) return { score: 0, label: "", cls: "" };
let score = 0;
if (pw.length >= 8) score++;
if (pw.length >= 12) score++;
if (pw.length >= 16) score++;
if (/[A-Z]/.test(pw)) score++;
if (/[a-z]/.test(pw)) score++;
if (/[0-9]/.test(pw)) score++;
if (/[^A-Za-z0-9]/.test(pw)) score++;
// clamp to 5 bands
const band = Math.min(5, Math.ceil((score / 7) * 5));
const labels = ["", "Very Weak", "Weak", "Fair", "Strong", "Very Strong"];
const clses = [
"",
"strength-1",
"strength-2",
"strength-3",
"strength-4",
"strength-5",
];
return { score: band, label: labels[band], cls: clses[band] };
}
// Attach strength meter + generate button to the password field in the item modal
function initPasswordFieldEnhancements() {
const pwInput = document.getElementById("field-password");
if (!pwInput) return;
// Strength bar below the password field
if (!document.getElementById("field-password-strength")) {
const bar = document.createElement("div");
bar.id = "field-password-strength";
bar.className = "password-strength";
pwInput
.closest(".input-with-toggle")
.insertAdjacentElement("afterend", bar);
}
pwInput.addEventListener("input", () => {
const { label, cls } = scorePassword(pwInput.value);
const bar = document.getElementById("field-password-strength");
bar.textContent = pwInput.value ? label : "";
bar.className = "password-strength " + (pwInput.value ? cls : "");
});
// Generate button — add only once
if (!document.getElementById("btn-generate-pass")) {
const btn = document.createElement("button");
btn.type = "button";
btn.id = "btn-generate-pass";
btn.className = "btn-secondary btn-sm";
btn.textContent = "⚡ Generate";
btn.style.marginTop = "6px";
pwInput.closest(".form-group").appendChild(btn);
}
document.getElementById("btn-generate-pass").onclick = () => {
const pw = generatePassword(20);
pwInput.value = pw;
pwInput.type = "text"; // reveal so user can see what was generated
pwInput.dispatchEvent(new Event("input")); // trigger strength bar
};
}
function openPasswordGeneratorModal() {
// Build modal content dynamically — keeps the HTML lean
let modal = document.getElementById("gen-modal");
if (!modal) {
modal = document.createElement("div");
modal.id = "gen-modal";
modal.className = "modal-overlay";
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.innerHTML = `
<div class="modal" style="max-width:420px">
<div class="modal-header">
<h3>⚡ Password Generator</h3>
<button class="btn-icon btn-close" id="btn-close-gen-modal" aria-label="Close">✕</button>
</div>
<div class="form-group" style="margin-bottom:8px">
<label>Length: <strong id="gen-length-display">20</strong></label>
<input type="range" id="gen-length" min="8" max="64" value="20" style="width:100%;margin-top:4px">
</div>
<div class="gen-options" style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:16px">
<label class="gen-opt"><input type="checkbox" id="gen-upper" checked> Uppercase</label>
<label class="gen-opt"><input type="checkbox" id="gen-lower" checked> Lowercase</label>
<label class="gen-opt"><input type="checkbox" id="gen-digits" checked> Numbers</label>
<label class="gen-opt"><input type="checkbox" id="gen-symbols" checked> Symbols</label>
</div>
<div class="form-group">
<label>Generated Password</label>
<div class="input-with-toggle" style="margin-bottom:4px">
<input type="text" id="gen-output" readonly style="font-family:monospace;letter-spacing:.5px;padding-right:40px">
<button type="button" class="btn-show-pass" id="btn-gen-copy" title="Copy" style="font-size:14px">📋</button>
</div>
<div id="gen-strength" class="password-strength"></div>
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" id="btn-close-gen-modal2">Close</button>
<button type="button" class="btn-primary" id="btn-regenerate">↺ Regenerate</button>
</div>
</div>`;
document.body.appendChild(modal);
const lengthSlider = document.getElementById("gen-length");
const lengthDisplay = document.getElementById("gen-length-display");
const output = document.getElementById("gen-output");
const strengthEl = document.getElementById("gen-strength");
function refreshGen() {
const pw = generatePassword(parseInt(lengthSlider.value), {
upper: document.getElementById("gen-upper").checked,
lower: document.getElementById("gen-lower").checked,
digits: document.getElementById("gen-digits").checked,
symbols: document.getElementById("gen-symbols").checked,
});
output.value = pw;
const { label, cls } = scorePassword(pw);
strengthEl.textContent = label;
strengthEl.className = "password-strength " + cls;
}
lengthSlider.addEventListener("input", () => {
lengthDisplay.textContent = lengthSlider.value;
refreshGen();
});
["gen-upper", "gen-lower", "gen-digits", "gen-symbols"].forEach((id) => {
document.getElementById(id).addEventListener("change", refreshGen);
});
document
.getElementById("btn-regenerate")
.addEventListener("click", refreshGen);
document.getElementById("btn-gen-copy").addEventListener("click", () => {
navigator.clipboard
.writeText(output.value)
.then(() => showToast("Password copied"));
});
const closeGen = () => modal.classList.remove("open");
document
.getElementById("btn-close-gen-modal")
.addEventListener("click", closeGen);
document
.getElementById("btn-close-gen-modal2")
.addEventListener("click", closeGen);
modal.addEventListener("click", (e) => {
if (e.target === modal) closeGen();
});
refreshGen();
}
modal.classList.add("open");
// Regenerate fresh password each time modal opens
const lengthSlider = document.getElementById("gen-length");
document.getElementById("gen-length-display").textContent =
lengthSlider.value;
const pw = generatePassword(parseInt(lengthSlider.value), {
upper: document.getElementById("gen-upper").checked,
lower: document.getElementById("gen-lower").checked,
digits: document.getElementById("gen-digits").checked,
symbols: document.getElementById("gen-symbols").checked,
});
document.getElementById("gen-output").value = pw;
const { label, cls } = scorePassword(pw);
const strengthEl = document.getElementById("gen-strength");
strengthEl.textContent = label;
strengthEl.className = "password-strength " + cls;
}
// ── Account Settings ──────────────────────────────────────────────────────
let _pendingMfaSecret = null;
async function openSettingsModal() {
document.getElementById("settings-modal").classList.add("open");
// 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(),
]);
}
async function loadMfaStatus() {
try {
const res = await apiFetch("/api/auth/mfa/status");
if (!res) return;
const data = await res.json();
renderMfaStatus(data.totp_enabled);
} catch (err) {
console.error("loadMfaStatus:", err);
}
}
function renderMfaStatus(enabled) {
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) {
statusText.textContent = "✅ Two-factor authentication is enabled.";
actions.innerHTML =
'<button class="btn-secondary" id="btn-mfa-disable">Disable MFA</button>';
document
.getElementById("btn-mfa-disable")
?.addEventListener("click", () => {
document
.getElementById("mfa-disable-area")
.classList.remove("hidden");
document.getElementById("mfa-actions").innerHTML = "";
document.getElementById("mfa-disable-code").focus();
});
} else {
statusText.textContent = "Two-factor authentication is not enabled.";
actions.innerHTML =
'<button class="btn-primary" id="btn-mfa-setup">Set up MFA</button>';
document
.getElementById("btn-mfa-setup")
?.addEventListener("click", handleMfaSetup);
}
}
async function handleMfaSetup() {
try {
const res = await apiFetch("/api/auth/mfa/setup");
if (!res) return;
const data = await res.json();
_pendingMfaSecret = data.secret;
document.getElementById("mfa-qr-img").src = data.qr_code;
document.getElementById("mfa-secret-display").textContent = data.secret;
document.getElementById("mfa-setup-area").classList.remove("hidden");
document.getElementById("mfa-actions").innerHTML = "";
document.getElementById("mfa-status-text").textContent =
"Scan the QR code below with your authenticator app.";
document.getElementById("mfa-verify-code").value = "";
document.getElementById("mfa-verify-code").focus();
} catch (err) {
showToast("Could not start MFA setup: " + err.message, "error");
}
}
async function handleMfaConfirm() {
const code = document.getElementById("mfa-verify-code").value.trim();
if (!code || !_pendingMfaSecret) return;
try {
const res = await apiFetch("/api/auth/mfa/enable", {
method: "POST",
body: JSON.stringify({ secret: _pendingMfaSecret, totp_code: code }),
});
if (!res) return;
_pendingMfaSecret = null;
showToast("MFA enabled successfully");
renderMfaStatus(true);
} catch (err) {
showToast("Failed to enable MFA: " + err.message, "error");
}
}
async function handleMfaDisableConfirm() {
const code = document.getElementById("mfa-disable-code").value.trim();
if (!code) return;
try {
const res = await apiFetch("/api/auth/mfa/disable", {
method: "POST",
body: JSON.stringify({ totp_code: code }),
});
if (!res) return;
showToast("MFA disabled");
renderMfaStatus(false);
} catch (err) {
showToast("Failed to disable MFA: " + err.message, "error");
}
}
async function loadSharingKeysStatus() {
try {
const res = await apiFetch("/api/sharing/keys");
if (!res) return;
const data = await res.json();
const statusEl = document.getElementById("sharing-keys-status");
const actionsEl = document.getElementById("sharing-keys-actions");
if (data.keys_setup) {
statusEl.textContent =
"✅ Sharing keys are set up. You can share items and use emergency access.";
actionsEl.innerHTML =
'<button class="btn-secondary" id="btn-regen-keys">Regenerate keys</button>';
document
.getElementById("btn-regen-keys")
?.addEventListener("click", handleGenerateKeys);
} else {
statusEl.textContent =
"No sharing keys set up. Generate keys to enable item sharing and emergency access.";
actionsEl.innerHTML =
'<button class="btn-primary" id="btn-gen-keys">Generate sharing keys</button>';
document
.getElementById("btn-gen-keys")
?.addEventListener("click", handleGenerateKeys);
}
} catch (err) {
console.error("loadSharingKeysStatus:", err);
}
}
async function handleGenerateKeys() {
const vaultKey = VaultSession.getKey();
if (!vaultKey) {
showToast("Vault is locked. Unlock first.", "error");
return;
}
try {
const keyPair = await SharingCrypto.generateKeyPair();
const public_key = await SharingCrypto.exportPublicKey(keyPair.publicKey);
const { private_key_enc, private_key_iv } =
await SharingCrypto.encryptPrivateKey(vaultKey, keyPair.privateKey);
const res = await apiFetch("/api/sharing/keys", {
method: "POST",
body: JSON.stringify({ public_key, private_key_enc, private_key_iv }),
});
if (!res) return;
// Cache private key in session
SharingSession.setKey(keyPair.privateKey);
showToast("Sharing keys generated");
await loadSharingKeysStatus();
} catch (err) {
showToast("Key generation failed: " + err.message, "error");
}
}
// ── Change Password ───────────────────────────────────────────────────────
async function handleChangePassword() {
const cpError = document.getElementById("cp-error");
cpError.classList.add("hidden");
const currentPass = document.getElementById("cp-current").value;
const newPass = document.getElementById("cp-new").value;
const confirmPass = document.getElementById("cp-confirm").value;
if (!currentPass || !newPass || !confirmPass) {
cpError.textContent = "All fields are required.";
cpError.classList.remove("hidden");
return;
}
if (newPass !== confirmPass) {
cpError.textContent = "New passwords do not match.";
cpError.classList.remove("hidden");
return;
}
if (newPass.length < 12) {
cpError.textContent = "New password must be at least 12 characters.";
cpError.classList.remove("hidden");
return;
}
if (currentPass === newPass) {
cpError.textContent =
"New password must be different from the current password.";
cpError.classList.remove("hidden");
return;
}
const btn = document.getElementById("btn-change-password");
const originalText = btn.textContent;
btn.disabled = true;
btn.textContent = "Re-encrypting vault…";
try {
const vaultKey = VaultSession.getKey();
if (!vaultKey) {
showToast("Vault is locked. Reload and unlock first.", "error");
return;
}
// Fetch current user email from session storage
const encKeySalt = sessionStorage.getItem("enc_key_salt");
// We need the email to derive auth hashes — fetch it from the profile
const profileRes = await apiFetch("/api/auth/me");
if (!profileRes) return;
const profile = await profileRes.json();
const email = profile.email;
// Derive both auth hashes
const currentAuthHash = await Crypto.deriveAuthHash(currentPass, email);
const newAuthHash = await Crypto.deriveAuthHash(newPass, email);
const newEncKeySalt = Crypto.generateSalt(16);
const newVaultKey = await Crypto.deriveVaultKey(newPass, newEncKeySalt);
// Fetch all vault items and re-encrypt
const itemsRes = await apiFetch("/api/vault");
if (!itemsRes) return;
const items = await itemsRes.json();
btn.textContent = `Re-encrypting ${items.length} item(s)…`;
const reEncrypted = [];
for (const item of items) {
const plain = await Crypto.decryptItem(
vaultKey,
item.enc_data,
item.iv,
);
const { enc_data, iv } = await Crypto.encryptItem(newVaultKey, plain);
reEncrypted.push({ id: item.id, enc_data, iv });
}
// Submit atomic password change
const res = await apiFetch("/api/auth/change-password", {
method: "POST",
body: JSON.stringify({
current_auth_hash: currentAuthHash,
new_auth_hash: newAuthHash,
new_enc_key_salt: newEncKeySalt,
items: reEncrypted,
}),
});
if (!res) return;
const data = await res.json();
if (!res.ok) {
cpError.textContent = data.error || "Password change failed.";
cpError.classList.remove("hidden");
return;
}
showToast("Password changed. Please log in again.");
setTimeout(() => redirectToLogin(), 1500);
} catch (err) {
cpError.textContent = "An error occurred: " + err.message;
cpError.classList.remove("hidden");
console.error(err);
} finally {
btn.disabled = false;
btn.textContent = originalText;
}
}
// ── Account Recovery Setup ────────────────────────────────────────────────
async function loadRecoveryStatus() {
try {
const res = await apiFetch("/api/auth/recovery/status");
if (!res) return;
const data = await res.json();
const statusEl = document.getElementById("recovery-status-text");
const actionsEl = document.getElementById("recovery-actions");
if (data.recovery_configured) {
statusEl.textContent =
"✅ A recovery code is configured for your account.";
actionsEl.innerHTML =
'<button class="btn-secondary" id="btn-regen-recovery">Generate new recovery code</button>';
} else {
statusEl.textContent =
"No recovery code set up. If you forget your master password, your vault cannot be recovered.";
actionsEl.innerHTML =
'<button class="btn-primary" id="btn-gen-recovery">Generate recovery code</button>';
}
document
.getElementById("btn-gen-recovery")
?.addEventListener("click", handleSetupRecovery);
document
.getElementById("btn-regen-recovery")
?.addEventListener("click", handleSetupRecovery);
} catch (err) {
console.error("loadRecoveryStatus:", err);
}
}
async function handleSetupRecovery() {
const vaultKey = VaultSession.getKey();
if (!vaultKey) {
showToast("Vault is locked. Unlock first.", "error");
return;
}
try {
const encKeySalt = sessionStorage.getItem("enc_key_salt");
if (!encKeySalt) {
showToast("Session error. Please reload.", "error");
return;
}
// Generate a random 128-bit (16-byte) recovery code displayed as hex
const rawBytes = window.crypto.getRandomValues(new Uint8Array(16));
const recoveryCode = Array.from(rawBytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
// Derive recovery key from the code
const recoveryKeyMaterial = await window.crypto.subtle.importKey(
"raw",
new TextEncoder().encode(recoveryCode),
"PBKDF2",
false,
["deriveKey"],
);
const recoveryKey = await window.crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: new TextEncoder().encode("passkeeper-recovery"),
iterations: 200_000,
hash: "SHA-256",
},
recoveryKeyMaterial,
{ name: "AES-GCM", length: 256 },
false,
["encrypt"],
);
// Encrypt enc_key_salt with recovery key
const iv = window.crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await window.crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
recoveryKey,
new TextEncoder().encode(encKeySalt),
);
function bytesToBase64(bytes) {
let bin = "";
new Uint8Array(bytes).forEach((b) => (bin += String.fromCharCode(b)));
return btoa(bin);
}
const recovery_enc_salt = bytesToBase64(ciphertext);
const recovery_iv = bytesToBase64(iv);
// Store on server
const res = await apiFetch("/api/auth/recovery/setup", {
method: "POST",
body: JSON.stringify({ recovery_enc_salt, recovery_iv }),
});
if (!res) return;
// Display the code to the user — formatted in groups of 4
const formatted = recoveryCode.match(/.{1,4}/g).join("-");
document.getElementById("recovery-code-value").textContent = formatted;
document
.getElementById("recovery-code-display")
.classList.remove("hidden");
document.getElementById("recovery-actions").innerHTML = "";
document.getElementById("recovery-status-text").textContent =
"Your new recovery code is shown below.";
} catch (err) {
showToast("Recovery code generation failed: " + err.message, "error");
console.error(err);
}
}
// ── Delete Account ────────────────────────────────────────────────────────
async function handleDeleteAccount() {
const deleteError = document.getElementById("delete-error");
deleteError.classList.add("hidden");
const password = document.getElementById("delete-password").value;
if (!password) {
deleteError.textContent = "Please enter your master password.";
deleteError.classList.remove("hidden");
return;
}
const btn = document.getElementById("btn-delete-confirm");
const originalText = btn.textContent;
btn.disabled = true;
btn.textContent = "Deleting…";
try {
const profileRes = await apiFetch("/api/auth/me");
if (!profileRes) return;
const profile = await profileRes.json();
const authHash = await Crypto.deriveAuthHash(password, profile.email);
const res = await apiFetch("/api/auth/account", {
method: "DELETE",
body: JSON.stringify({ auth_hash: authHash }),
});
if (!res) return;
const data = await res.json();
if (!res.ok) {
deleteError.textContent = data.error || "Deletion failed.";
deleteError.classList.remove("hidden");
return;
}
// Clear all local state and redirect
sessionStorage.clear();
localStorage.clear();
window.location.href = "/login";
} catch (err) {
deleteError.textContent = "An error occurred: " + err.message;
deleteError.classList.remove("hidden");
console.error(err);
} finally {
btn.disabled = false;
btn.textContent = originalText;
}
}
// ── Folder CRUD ───────────────────────────────────────────────────────────
function showNewFolderRow() {
document.getElementById("new-folder-row").classList.remove("hidden");
document.getElementById("new-folder-input").focus();
}
function hideNewFolderRow() {
document.getElementById("new-folder-row").classList.add("hidden");
document.getElementById("new-folder-input").value = "";
}
async function handleNewFolder(e) {
e.preventDefault();
const name = document.getElementById("new-folder-input").value.trim();
if (!name) return;
try {
const res = await apiFetch("/api/folders", {
method: "POST",
body: JSON.stringify({ name }),
});
if (!res) return;
hideNewFolderRow();
await loadFolders();
showToast("Folder created");
} catch (err) {
showToast("Could not create folder: " + err.message, "error");
}
}
async function confirmDeleteFolder(folder) {
if (
!confirm(
`Delete folder "${folder.name}"?\nItems will be moved to No Folder.`,
)
)
return;
try {
const res = await apiFetch(`/api/folders/${folder.id}`, {
method: "DELETE",
});
if (!res) return;
_items = _items.map((i) =>
i.folder_id === folder.id ? { ...i, folder_id: null } : i,
);
if (
_activeFilter?.type === "folder" &&
_activeFilter.value === folder.id
) {
_activeFilter = null;
updateVaultTitle("All Items");
}
await loadFolders();
applyCurrentFilter();
showToast("Folder deleted");
} catch (err) {
showToast("Could not delete folder: " + err.message, "error");
}
}
// ── Filtering / Search ────────────────────────────────────────────────────
function applyCurrentFilter() {
if (!_activeFilter) {
renderItemList(_items);
return;
}
if (_activeFilter.type === "itemType")
renderItemList(_items.filter((i) => i.item_type === _activeFilter.value));
else if (_activeFilter.type === "folder")
renderItemList(_items.filter((i) => i.folder_id === _activeFilter.value));
}
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
? _activeFilter.type === "itemType"
? _items.filter((i) => i.item_type === _activeFilter.value)
: _items.filter((i) => i.folder_id === _activeFilter.value)
: _items;
renderItemList(
pool.filter(
(item) =>
item.name.toLowerCase().includes(q) ||
(item.plain?.username || "").toLowerCase().includes(q) ||
(item.plain?.url || "").toLowerCase().includes(q) ||
(item.plain?.note_body || "").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)",
};
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 : "";
const folderSel = document.getElementById("field-folder");
folderSel.innerHTML = '<option value="">— No folder —</option>';
_folders.forEach((f) => {
const opt = document.createElement("option");
opt.value = f.id;
opt.textContent = f.name;
folderSel.appendChild(opt);
});
if (mode === "add") {
document.getElementById("type-selector-group").classList.remove("hidden");
document.getElementById("modal-title").textContent = "Add Item";
switchModalType(
document.getElementById("field-type").value || "password",
);
} else {
document.getElementById("type-selector-group").classList.add("hidden");
const t = item.item_type || "password";
document.getElementById("modal-title").textContent =
`Edit ${TYPE_LABELS[t] || "Item"}`;
switchModalType(t);
form.dataset.itemType = t;
setVal("field-name", item.name);
setVal("field-folder", item.folder_id || "");
if (item.plain) {
const p = item.plain;
switch (t) {
case "password":
setVal("field-url", p.url);
setVal("field-username", p.username);
setVal("field-password", p.password);
setVal("field-notes", p.notes);
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;
}
}
}
document.getElementById("item-modal").classList.add("open");
document.getElementById("field-name").focus();
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"),
notes: getVal("field-notes").trim(),
};
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(),
};
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 { enc_data, iv } = await Crypto.encryptItem(
vaultKey,
buildPlainData(itemType),
);
const folderVal = getVal("field-folder");
const payload = {
name,
item_type: itemType,
folder_id: folderVal ? parseInt(folderVal) : null,
enc_data,
iv,
};
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");
}
}
// ── 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 ─────────────────────────────────────────────────────────────
function copyToClipboard(text, msg = "Copied") {
navigator.clipboard
.writeText(text)
.then(() => showToast(msg))
.catch(() => {});
}
function escHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function itemIcon(type) {
return (
{
password: "🔑",
note: "📝",
card: "💳",
bank: "🏦",
address: "🏠",
ssn: "🪪",
}[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";
});
// 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-security")
?.addEventListener("click", () => switchView("security"));
document
.getElementById("sidebar-sharing")
?.addEventListener("click", () => switchView("sharing"));
document
.getElementById("sidebar-emergency")
?.addEventListener("click", () => switchView("emergency"));
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",
};
_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"));
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 (persisted in localStorage)
const sidebar = document.getElementById("sidebar");
const toggleBtn = document.getElementById("btn-sidebar-toggle");
if (sidebar && toggleBtn) {
const STORAGE_KEY = "sidebar_collapsed";
// Restore saved state immediately (before paint)
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",
);
});
}
if (!VaultSession.getKey()) {
showUnlockOverlay();
} else {
loadVault();
}
}
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 };
})();
document.addEventListener("DOMContentLoaded", Vault.init);