4019 lines
140 KiB
JavaScript
4019 lines
140 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";
|
|
|
|
// ── 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();
|
|
}
|
|
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",
|
|
// 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();
|
|
} 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 } = {}) {
|
|
_currentView = view;
|
|
["vault", "security", "sharing", "emergency", "import-export"].forEach(
|
|
(v) => {
|
|
document
|
|
.getElementById(`view-${v}`)
|
|
?.classList.toggle("hidden", v !== view);
|
|
},
|
|
);
|
|
document.querySelectorAll(".sidebar-item[data-view]").forEach((el) => {
|
|
el.classList.toggle("active", el.dataset.view === view);
|
|
});
|
|
if (view !== "vault") {
|
|
document
|
|
.querySelectorAll(
|
|
".sidebar-item[data-type-filter], .sidebar-item[data-folder-id]",
|
|
)
|
|
.forEach((el) => el.classList.remove("active"));
|
|
}
|
|
|
|
// Push to browser history so the back button navigates between views.
|
|
if (pushState) {
|
|
const hash = view === "vault" ? "" : `#${view}`;
|
|
history.pushState({ view }, "", hash || window.location.pathname);
|
|
}
|
|
|
|
if (view === "security") renderSecurityDashboard();
|
|
if (view === "sharing") loadSharingView();
|
|
if (view === "emergency") loadEmergencyView();
|
|
if (view === "import-export") loadImportExportView();
|
|
}
|
|
|
|
// ── Vault render ──────────────────────────────────────────────────────────
|
|
|
|
function renderFolderList() {
|
|
const ul = document.getElementById("sidebar-folders");
|
|
if (!ul) return;
|
|
ul.innerHTML = "";
|
|
_folders.forEach((folder) => {
|
|
const li = document.createElement("li");
|
|
li.className = "sidebar-item folder-item";
|
|
li.dataset.folderId = folder.id;
|
|
|
|
const label = document.createElement("span");
|
|
label.className = "folder-label";
|
|
label.innerHTML = `<span class="sidebar-icon">📁</span> <span class="sidebar-label">${escHtml(folder.name)}</span>`;
|
|
label.addEventListener("click", () =>
|
|
filterByFolder(folder.id, folder.name),
|
|
);
|
|
|
|
const delBtn = document.createElement("button");
|
|
delBtn.className = "btn-delete-folder";
|
|
delBtn.title = "Delete folder";
|
|
delBtn.textContent = "🗑";
|
|
delBtn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
confirmDeleteFolder(folder);
|
|
});
|
|
|
|
li.appendChild(label);
|
|
li.appendChild(delBtn);
|
|
ul.appendChild(li);
|
|
});
|
|
}
|
|
|
|
function getSortedItems(items) {
|
|
const sorted = [...items];
|
|
switch (_sortOrder) {
|
|
case "name-asc":
|
|
sorted.sort((a, b) => a.name.localeCompare(b.name));
|
|
break;
|
|
case "name-desc":
|
|
sorted.sort((a, b) => b.name.localeCompare(a.name));
|
|
break;
|
|
case "date-desc":
|
|
sorted.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
|
break;
|
|
case "date-asc":
|
|
sorted.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
|
break;
|
|
case "folder":
|
|
sorted.sort(
|
|
(a, b) =>
|
|
folderName(a.folder_id).localeCompare(folderName(b.folder_id)) ||
|
|
a.name.localeCompare(b.name),
|
|
);
|
|
break;
|
|
}
|
|
return sorted;
|
|
}
|
|
|
|
function folderName(id) {
|
|
if (!id) return "(No folder)";
|
|
return _folders.find((f) => f.id === id)?.name || "Unknown";
|
|
}
|
|
|
|
/** Parse a comma-separated tag string into a sorted, deduped lowercase array. */
|
|
function _parseTags(str) {
|
|
return [
|
|
...new Set(
|
|
(str || "")
|
|
.split(",")
|
|
.map((t) => t.trim().toLowerCase())
|
|
.filter(Boolean),
|
|
),
|
|
].sort();
|
|
}
|
|
|
|
/** Collect all unique tags across all loaded items. */
|
|
function _allTags() {
|
|
const set = new Set();
|
|
_items.forEach((i) => (i.plain?.tags || []).forEach((t) => set.add(t)));
|
|
return [...set].sort();
|
|
}
|
|
|
|
/** Render the tags sidebar list. */
|
|
function renderTagList() {
|
|
const ul = document.getElementById("sidebar-tags");
|
|
if (!ul) return;
|
|
ul.innerHTML = "";
|
|
const tags = _allTags();
|
|
if (!tags.length) {
|
|
ul.innerHTML = '<li class="sidebar-tag-empty">No tags yet</li>';
|
|
return;
|
|
}
|
|
tags.forEach((tag) => {
|
|
const li = document.createElement("li");
|
|
li.className = "sidebar-item sidebar-tag-item";
|
|
li.dataset.tag = tag;
|
|
li.innerHTML = `<span class="sidebar-icon">🏷️</span><span class="sidebar-label">${escHtml(tag)}</span>`;
|
|
li.addEventListener("click", () => {
|
|
document
|
|
.querySelectorAll(".sidebar-item")
|
|
.forEach((el) => el.classList.remove("active"));
|
|
li.classList.add("active");
|
|
_activeFilter = { type: "tag", value: tag };
|
|
switchView("vault");
|
|
updateVaultTitle("#" + tag);
|
|
renderItemList(
|
|
_items.filter((i) => (i.plain?.tags || []).includes(tag)),
|
|
);
|
|
});
|
|
ul.appendChild(li);
|
|
});
|
|
}
|
|
|
|
// Persists which folder groups are collapsed across re-renders.
|
|
const _collapsedGroups = new Set();
|
|
|
|
function renderItemList(items) {
|
|
const list = document.getElementById("vault-list");
|
|
if (!list) return;
|
|
list.innerHTML = "";
|
|
if (items.length === 0) {
|
|
list.innerHTML =
|
|
'<li class="vault-empty">No items found. Click + to add one.</li>';
|
|
return;
|
|
}
|
|
|
|
const sorted = getSortedItems(items);
|
|
const groups = {};
|
|
sorted.forEach((item) => {
|
|
const key = folderName(item.folder_id);
|
|
if (!groups[key]) groups[key] = [];
|
|
groups[key].push(item);
|
|
});
|
|
|
|
const keys =
|
|
_sortOrder === "folder"
|
|
? Object.keys(groups).sort()
|
|
: ["(No folder)", ..._folders.map((f) => f.name)].filter(
|
|
(k) => groups[k],
|
|
);
|
|
Object.keys(groups).forEach((k) => {
|
|
if (!keys.includes(k)) keys.push(k);
|
|
});
|
|
|
|
keys.forEach((groupName) => {
|
|
if (!groups[groupName]) return;
|
|
const isCollapsed = _collapsedGroups.has(groupName);
|
|
const count = groups[groupName].length;
|
|
|
|
// ── Collapsible group header ────────────────────────────────────────────
|
|
const header = document.createElement("li");
|
|
header.className =
|
|
"vault-group-header" + (isCollapsed ? " collapsed" : "");
|
|
header.innerHTML =
|
|
`<span class="group-name">${escHtml(groupName)}</span>` +
|
|
`<span class="group-meta">` +
|
|
`<span class="group-count">${count}</span>` +
|
|
`<span class="group-chevron">${isCollapsed ? "▶" : "▼"}</span>` +
|
|
`</span>`;
|
|
header.addEventListener("click", () => {
|
|
if (_collapsedGroups.has(groupName)) {
|
|
_collapsedGroups.delete(groupName);
|
|
} else {
|
|
_collapsedGroups.add(groupName);
|
|
}
|
|
renderItemList(items); // re-render preserving collapse state
|
|
});
|
|
list.appendChild(header);
|
|
|
|
// ── Items (hidden when collapsed) ───────────────────────────────────────
|
|
if (!isCollapsed) {
|
|
groups[groupName].forEach((item) =>
|
|
list.appendChild(createItemElement(item)),
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
function createItemElement(item) {
|
|
const li = document.createElement("li");
|
|
li.className = "vault-item";
|
|
li.dataset.id = item.id;
|
|
|
|
const iconMap = {
|
|
password: "🔑",
|
|
note: "📝",
|
|
card: "💳",
|
|
bank: "🏦",
|
|
address: "🏠",
|
|
ssn: "🪪",
|
|
passkey: "🔐",
|
|
};
|
|
const icon = iconMap[item.item_type] || "🔑";
|
|
|
|
const itemTags = item.plain?.tags || [];
|
|
let subText = "";
|
|
if (item.plain) {
|
|
switch (item.item_type) {
|
|
case "password":
|
|
subText = item.plain.username || item.plain.url || "";
|
|
break;
|
|
case "note":
|
|
subText = (item.plain.note_body || "").slice(0, 60);
|
|
break;
|
|
case "card":
|
|
subText = item.plain.card_number
|
|
? "•••• " +
|
|
String(item.plain.card_number).replace(/\s/g, "").slice(-4)
|
|
: "";
|
|
break;
|
|
case "bank":
|
|
subText = item.plain.bank_name || "";
|
|
break;
|
|
case "address":
|
|
subText = [item.plain.first_name, item.plain.last_name]
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
break;
|
|
case "ssn":
|
|
subText = "•••-••-••••";
|
|
break;
|
|
case "passkey":
|
|
subText = [
|
|
item.plain.username,
|
|
item.plain.rp_id ? `@ ${item.plain.rp_id}` : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
break;
|
|
}
|
|
}
|
|
|
|
const showCopyUser = item.item_type === "password";
|
|
const showCopyPass = ["password", "card", "bank", "ssn"].includes(
|
|
item.item_type,
|
|
);
|
|
const showLaunch = item.item_type === "password" && item.plain?.url;
|
|
const showTotp =
|
|
item.item_type === "password" &&
|
|
!!extractTotpSecret(item.plain?.totp_uri);
|
|
|
|
const isFavorite = itemTags.includes("favorite");
|
|
const visibleTags = itemTags.filter((t) => t !== "favorite");
|
|
const tagBadges = visibleTags
|
|
.map((t) => `<span class="item-tag">${escHtml(t)}</span>`)
|
|
.join("");
|
|
|
|
li.innerHTML = `
|
|
<div class="item-icon">${icon}</div>
|
|
<div class="item-info">
|
|
<span class="item-name">${escHtml(item.name)}</span>
|
|
<span class="item-sub">${escHtml(subText)}</span>
|
|
${tagBadges ? `<div class="item-tags">${tagBadges}</div>` : ""}
|
|
${showTotp ? `<span class="item-totp" id="totp-display-${item.id}"><span class="totp-code">······</span><span class="totp-timer"></span></span>` : ""}
|
|
</div>
|
|
<div class="item-actions">
|
|
${showLaunch ? `<button class="btn-icon" title="Open URL" data-action="launch">🌐</button>` : ""}
|
|
${showCopyUser ? `<button class="btn-icon" title="Copy username" data-action="copy-user">👤</button>` : ""}
|
|
${showCopyPass ? `<button class="btn-icon" title="Copy secret" data-action="copy-pass">📋</button>` : ""}
|
|
${showTotp ? `<button class="btn-icon" title="Copy 2FA code" data-action="copy-totp">🔐</button>` : ""}
|
|
<button class="btn-icon btn-favorite${isFavorite ? " is-favorite" : ""}" title="${isFavorite ? "Remove from favorites" : "Add to favorites"}" data-action="favorite">${isFavorite ? "★" : "☆"}</button>
|
|
<button class="btn-icon" title="Edit" data-action="edit">✏️</button>
|
|
<button class="btn-icon" title="Delete" data-action="delete">🗑️</button>
|
|
</div>`;
|
|
|
|
if (showTotp) {
|
|
// Start a live TOTP ticker for this item.
|
|
const secret = extractTotpSecret(item.plain.totp_uri);
|
|
const displayEl = li.querySelector(`#totp-display-${item.id}`);
|
|
const codeEl = displayEl.querySelector(".totp-code");
|
|
const timerEl = displayEl.querySelector(".totp-timer");
|
|
|
|
async function refreshTotp() {
|
|
const code = await getTotpCode(secret).catch(() => null);
|
|
if (!code) return;
|
|
// Insert a space in the middle for readability: 123 456
|
|
codeEl.textContent = code.slice(0, 3) + " " + code.slice(3);
|
|
const secs = totpSecondsLeft();
|
|
timerEl.textContent = " (" + secs + "s)";
|
|
// Colour the timer red when it's about to expire.
|
|
timerEl.classList.toggle("totp-timer--expiring", secs <= 5);
|
|
}
|
|
refreshTotp();
|
|
// Refresh every second. Store interval id on the element for cleanup.
|
|
const intervalId = setInterval(refreshTotp, 1000);
|
|
li.dataset.totpInterval = intervalId;
|
|
}
|
|
|
|
if (showLaunch) {
|
|
li.querySelector('[data-action="launch"]').addEventListener(
|
|
"click",
|
|
(e) => {
|
|
e.stopPropagation();
|
|
let url = item.plain?.url || "";
|
|
if (url && !/^https?:\/\//i.test(url)) url = "https://" + url;
|
|
window.open(url, "_blank", "noopener,noreferrer");
|
|
},
|
|
);
|
|
}
|
|
if (showCopyUser) {
|
|
li.querySelector('[data-action="copy-user"]').addEventListener(
|
|
"click",
|
|
(e) => {
|
|
e.stopPropagation();
|
|
copyToClipboard(item.plain?.username || "", "Username copied");
|
|
},
|
|
);
|
|
}
|
|
if (showCopyPass) {
|
|
li.querySelector('[data-action="copy-pass"]').addEventListener(
|
|
"click",
|
|
(e) => {
|
|
e.stopPropagation();
|
|
const s =
|
|
item.item_type === "password"
|
|
? item.plain?.password
|
|
: item.item_type === "card"
|
|
? item.plain?.cvv
|
|
: item.item_type === "bank"
|
|
? item.plain?.account_number
|
|
: item.plain?.ssn_number;
|
|
copyToClipboard(s || "", "Copied to clipboard");
|
|
},
|
|
);
|
|
}
|
|
if (showTotp) {
|
|
li.querySelector('[data-action="copy-totp"]').addEventListener(
|
|
"click",
|
|
async (e) => {
|
|
e.stopPropagation();
|
|
const secret = extractTotpSecret(item.plain?.totp_uri);
|
|
const code = await getTotpCode(secret).catch(() => null);
|
|
if (code) copyToClipboard(code, "2FA code copied");
|
|
},
|
|
);
|
|
}
|
|
li.querySelector('[data-action="favorite"]').addEventListener(
|
|
"click",
|
|
(e) => {
|
|
e.stopPropagation();
|
|
toggleFavorite(item);
|
|
},
|
|
);
|
|
li.querySelector('[data-action="edit"]').addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
openModal("edit", item);
|
|
});
|
|
li.querySelector('[data-action="delete"]').addEventListener(
|
|
"click",
|
|
(e) => {
|
|
e.stopPropagation();
|
|
confirmDeleteItem(item);
|
|
},
|
|
);
|
|
li.addEventListener("click", () => openModal("edit", item));
|
|
return li;
|
|
}
|
|
|
|
// ── Security Dashboard ────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Check a password against the HaveIBeenPwned Pwned Passwords API
|
|
* using k-anonymity (only the first 5 hex chars of SHA-1 are sent).
|
|
* Returns the breach count (0 = not found in any known breach).
|
|
*/
|
|
async function checkHibp(password) {
|
|
try {
|
|
const msgBuffer = new TextEncoder().encode(password);
|
|
const hashBuffer = await crypto.subtle.digest("SHA-1", msgBuffer);
|
|
const hashHex = Array.from(new Uint8Array(hashBuffer))
|
|
.map((b) => b.toString(16).padStart(2, "0"))
|
|
.join("")
|
|
.toUpperCase();
|
|
const prefix = hashHex.slice(0, 5);
|
|
const suffix = hashHex.slice(5);
|
|
const res = await fetch(
|
|
`https://api.pwnedpasswords.com/range/${prefix}`,
|
|
{
|
|
headers: { "Add-Padding": "true" },
|
|
},
|
|
);
|
|
if (!res.ok) return 0;
|
|
const text = await res.text();
|
|
const line = text.split("\n").find((l) => l.startsWith(suffix));
|
|
if (!line) return 0;
|
|
return parseInt(line.split(":")[1], 10) || 0;
|
|
} catch {
|
|
return 0; // network error or API unavailable — fail safe (do not block UI)
|
|
}
|
|
}
|
|
|
|
async function renderSecurityDashboard() {
|
|
const summaryEl = document.getElementById("security-summary");
|
|
const sectionsEl = document.getElementById("security-sections");
|
|
if (!summaryEl || !sectionsEl) return;
|
|
|
|
// If vault hasn't loaded yet, wait for it then re-render
|
|
if (!_items.length && VaultSession.getKey()) {
|
|
summaryEl.innerHTML = '<div class="sec-notice">Loading…</div>';
|
|
sectionsEl.innerHTML = "";
|
|
loadVault().then(() => renderSecurityDashboard());
|
|
return;
|
|
}
|
|
|
|
const pwItems = _items.filter(
|
|
(i) => i.item_type === "password" && i.plain?.password,
|
|
);
|
|
if (!pwItems.length) {
|
|
summaryEl.innerHTML =
|
|
'<div class="sec-notice">No passwords to analyze.</div>';
|
|
sectionsEl.innerHTML = "";
|
|
return;
|
|
}
|
|
|
|
const weak = pwItems.filter((i) => {
|
|
const p = i.plain.password;
|
|
if (p.length < 10) return true;
|
|
return (
|
|
[/[A-Z]/, /[a-z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((r) => r.test(p))
|
|
.length < 2
|
|
);
|
|
});
|
|
|
|
const passCounts = {};
|
|
pwItems.forEach((i) => {
|
|
const p = i.plain.password;
|
|
(passCounts[p] = passCounts[p] || []).push(i);
|
|
});
|
|
const reused = Object.values(passCounts)
|
|
.filter((a) => a.length > 1)
|
|
.flat();
|
|
|
|
const cutoff = Date.now() - 180 * 86400000;
|
|
// "Old" only penalises passwords that are ALSO weak or reused.
|
|
// A strong, unique password that hasn't changed in 200 days is fine —
|
|
// penalising it discourages good password hygiene.
|
|
const weakOrReusedIds = new Set([
|
|
...weak.map((i) => i.id),
|
|
...reused.map((i) => i.id),
|
|
]);
|
|
const old = pwItems.filter(
|
|
(i) =>
|
|
new Date(i.created_at).getTime() < cutoff && weakOrReusedIds.has(i.id),
|
|
);
|
|
|
|
const total = pwItems.length;
|
|
const score = Math.max(
|
|
0,
|
|
Math.round(
|
|
100 -
|
|
(weak.length / total) * 40 -
|
|
(reused.length / total) * 30 -
|
|
(old.length / total) * 15,
|
|
),
|
|
);
|
|
const cls =
|
|
score >= 80 ? "score-good" : score >= 50 ? "score-fair" : "score-poor";
|
|
const label =
|
|
score >= 80 ? "Good" : score >= 50 ? "Fair" : "Needs attention";
|
|
|
|
summaryEl.innerHTML = `
|
|
<div class="sec-score-card">
|
|
<div class="sec-score ${cls}">${score}</div>
|
|
<div class="sec-score-label">${label}</div>
|
|
<div class="sec-score-desc">Based on ${total} password${total !== 1 ? "s" : ""}</div>
|
|
</div>
|
|
<div class="sec-stats">
|
|
<div class="sec-stat ${weak.length ? "sec-stat-warn" : "sec-stat-ok"}"><span class="sec-stat-num">${weak.length}</span><span class="sec-stat-label">Weak</span></div>
|
|
<div class="sec-stat ${reused.length ? "sec-stat-warn" : "sec-stat-ok"}"><span class="sec-stat-num">${reused.length}</span><span class="sec-stat-label">Reused</span></div>
|
|
<div class="sec-stat ${old.length ? "sec-stat-info" : "sec-stat-ok"}"><span class="sec-stat-num">${old.length}</span><span class="sec-stat-label">Old & Weak</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,
|
|
"Weak or reused passwords not changed in over 180 days.",
|
|
);
|
|
|
|
// ── 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 ──────────────────────────────────────────
|
|
// Run after the synchronous sections are rendered so the UI is immediately
|
|
// useful. The HIBP API is queried in parallel for all passwords.
|
|
const hibpSection = document.createElement("div");
|
|
hibpSection.className = "sec-section";
|
|
hibpSection.innerHTML = `
|
|
<div class="sec-section-header">
|
|
<span class="sec-section-icon">🔓</span>
|
|
<div>
|
|
<div class="sec-section-title">Checking for known breaches…</div>
|
|
<div class="sec-section-desc">Querying HaveIBeenPwned (k-anonymity — your passwords are never sent).</div>
|
|
</div>
|
|
</div>`;
|
|
sectionsEl.appendChild(hibpSection);
|
|
|
|
// Run all HIBP checks in parallel — k-anonymity: only 5-char SHA-1 prefix sent.
|
|
const hibpResults = await Promise.all(
|
|
pwItems.map(async (item) => ({
|
|
item,
|
|
count: await checkHibp(item.plain.password),
|
|
})),
|
|
);
|
|
const breached = hibpResults.filter((r) => r.count > 0).map((r) => r.item);
|
|
|
|
if (!breached.length) {
|
|
hibpSection.innerHTML = `
|
|
<div class="sec-section-header">
|
|
<span class="sec-section-icon">✅</span>
|
|
<div>
|
|
<div class="sec-section-title">No known breaches</div>
|
|
<div class="sec-section-desc">None of your passwords appeared in known data breaches (via HaveIBeenPwned).</div>
|
|
</div>
|
|
</div>`;
|
|
} else {
|
|
hibpSection.innerHTML = `
|
|
<div class="sec-section-header">
|
|
<span class="sec-section-icon">🔓</span>
|
|
<div>
|
|
<div class="sec-section-title">Breached Passwords (${breached.length})</div>
|
|
<div class="sec-section-desc">These passwords appeared in known data breaches. Change them immediately.</div>
|
|
</div>
|
|
</div>
|
|
<ul class="sec-item-list">
|
|
${breached
|
|
.map((i) => {
|
|
const count =
|
|
hibpResults.find((r) => r.item.id === i.id)?.count || 0;
|
|
return `<li class="sec-item">
|
|
<span class="sec-item-name">${escHtml(i.name)}</span>
|
|
<span class="sec-item-sub">${escHtml(i.plain?.username || "")} — seen ${count.toLocaleString()} time${count !== 1 ? "s" : ""} in breaches</span>
|
|
<button class="btn-secondary btn-sm" data-sec-edit="${i.id}">Change</button>
|
|
</li>`;
|
|
})
|
|
.join("")}
|
|
</ul>`;
|
|
hibpSection.querySelectorAll("[data-sec-edit]").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const item = _items.find(
|
|
(it) => it.id === parseInt(btn.dataset.secEdit),
|
|
);
|
|
if (item) {
|
|
switchView("vault");
|
|
openModal("edit", item);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── Import / Export View ─────────────────────────────────────────────────
|
|
|
|
// Holds parsed rows from a chosen file, ready for import.
|
|
let _importRows = [];
|
|
|
|
/**
|
|
* Parse a Chrome/Bitwarden/1Password CSV into a normalised array of plain objects.
|
|
* Supported column sets:
|
|
* Chrome: name, url, username, password
|
|
* Bitwarden: name, login_uri, login_username, login_password, notes, type
|
|
* 1Password: Title, Url, Username, Password, Notes
|
|
*/
|
|
function _parseCsvImport(text) {
|
|
const lines = text.split(/\r?\n/);
|
|
if (lines.length < 2) return [];
|
|
const headers = lines[0]
|
|
.split(",")
|
|
.map((h) => h.trim().replace(/^"|"$/g, "").toLowerCase());
|
|
|
|
// Detect format by inspecting header names.
|
|
const col = (candidates) => {
|
|
for (const c of candidates) {
|
|
const idx = headers.indexOf(c);
|
|
if (idx !== -1) return idx;
|
|
}
|
|
return -1;
|
|
};
|
|
const iName = col(["name", "title"]);
|
|
const iUrl = col(["url", "login_uri"]);
|
|
const iUser = col(["username", "login_username"]);
|
|
const iPass = col(["password", "login_password"]);
|
|
const iNotes = col(["notes", "note"]);
|
|
|
|
const rows = [];
|
|
for (let i = 1; i < lines.length; i++) {
|
|
const line = lines[i].trim();
|
|
if (!line) continue;
|
|
// RFC 4180-compliant CSV split — handles quoted fields containing commas
|
|
// and embedded double-quotes escaped as "".
|
|
const cells = [];
|
|
let cur = "",
|
|
inQuote = false;
|
|
const src = line + ",";
|
|
for (let ci = 0; ci < src.length; ci++) {
|
|
const ch = src[ci];
|
|
if (ch === '"') {
|
|
if (inQuote && src[ci + 1] === '"') {
|
|
// Escaped quote inside a quoted field: "" → "
|
|
cur += '"';
|
|
ci++; // skip the second quote
|
|
} else {
|
|
inQuote = !inQuote;
|
|
}
|
|
} else if (ch === "," && !inQuote) {
|
|
cells.push(cur.trim());
|
|
cur = "";
|
|
} else {
|
|
cur += ch;
|
|
}
|
|
}
|
|
const get = (idx) =>
|
|
idx !== -1 && cells[idx] != null
|
|
? cells[idx].replace(/^"|"$/g, "")
|
|
: "";
|
|
const name = get(iName);
|
|
const password = get(iPass);
|
|
if (!name || !password) continue;
|
|
rows.push({
|
|
name,
|
|
url: get(iUrl),
|
|
username: get(iUser),
|
|
password,
|
|
notes: get(iNotes),
|
|
});
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
let _importViewInitialised = false;
|
|
|
|
function _resetImportView() {
|
|
_importRows = [];
|
|
const fileInput = document.getElementById("import-file-input");
|
|
const fileNameEl = document.getElementById("import-file-name");
|
|
const previewEl = document.getElementById("import-preview");
|
|
const confirmBtn = document.getElementById("btn-import-confirm");
|
|
const resultEl = document.getElementById("import-result");
|
|
if (fileInput) fileInput.value = "";
|
|
if (fileNameEl) fileNameEl.textContent = "No file chosen";
|
|
if (previewEl) {
|
|
previewEl.innerHTML = "";
|
|
previewEl.classList.add("hidden");
|
|
}
|
|
if (confirmBtn) {
|
|
confirmBtn.disabled = true;
|
|
confirmBtn.textContent = "Import items";
|
|
delete confirmBtn.dataset.mode;
|
|
}
|
|
if (resultEl) {
|
|
resultEl.innerHTML = "";
|
|
resultEl.classList.add("hidden");
|
|
}
|
|
}
|
|
|
|
function loadImportExportView() {
|
|
// Reset import panel state every time the view is entered so a previous
|
|
// import result or file preview is never shown stale on re-entry.
|
|
_resetImportView();
|
|
|
|
if (_importViewInitialised) return;
|
|
_importViewInitialised = true;
|
|
|
|
// ── Export ───────────────────────────────────────────────────────────────
|
|
|
|
document
|
|
.getElementById("btn-export-json")
|
|
?.addEventListener("click", async () => {
|
|
try {
|
|
const res = await apiFetch("/api/vault");
|
|
if (!res) return;
|
|
const items = await res.json();
|
|
const payload = JSON.stringify(
|
|
{ version: 1, exported_at: new Date().toISOString(), items },
|
|
null,
|
|
2,
|
|
);
|
|
const blob = new Blob([payload], { type: "application/json" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = `passkeeper-export-${new Date().toISOString().slice(0, 10)}.json`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
showToast("Encrypted vault exported.");
|
|
console.log("[PassKeeper] Vault exported:", items.length, "items");
|
|
} catch (err) {
|
|
showToast("Export failed: " + err.message, "error");
|
|
}
|
|
});
|
|
|
|
document
|
|
.getElementById("btn-export-csv")
|
|
?.addEventListener("click", async () => {
|
|
const vaultKey = VaultSession.getKey();
|
|
if (!vaultKey) {
|
|
showUnlockOverlay();
|
|
return;
|
|
}
|
|
|
|
// Warn the user that this export contains plaintext passwords before proceeding.
|
|
const confirmed = await new Promise((resolve) => {
|
|
const overlay = document.createElement("div");
|
|
overlay.style.cssText = [
|
|
"position:fixed",
|
|
"inset:0",
|
|
"background:rgba(0,0,0,0.55)",
|
|
"z-index:9999",
|
|
"display:flex",
|
|
"align-items:center",
|
|
"justify-content:center",
|
|
].join(";");
|
|
overlay.innerHTML = `
|
|
<div style="background:#fff;border-radius:12px;padding:28px 24px;max-width:380px;width:90%;box-shadow:0 8px 32px rgba(0,0,0,0.22);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
|
<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px;">
|
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
|
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" stroke="#d97706" stroke-width="1.8" fill="none"/>
|
|
<line x1="12" y1="9" x2="12" y2="13" stroke="#d97706" stroke-width="1.8" stroke-linecap="round"/>
|
|
<line x1="12" y1="17" x2="12.01" y2="17" stroke="#d97706" stroke-width="2" stroke-linecap="round"/>
|
|
</svg>
|
|
<strong style="font-size:15px;color:#111827;">Export plaintext passwords?</strong>
|
|
</div>
|
|
<p style="font-size:13px;color:#374151;margin-bottom:6px;">
|
|
The CSV file will contain <strong>all your passwords in plaintext</strong>.
|
|
Anyone with access to the file can read them.
|
|
</p>
|
|
<p style="font-size:13px;color:#374151;margin-bottom:20px;">
|
|
Store the file in a secure location and delete it when you no longer need it.
|
|
</p>
|
|
<div style="display:flex;gap:10px;justify-content:flex-end;">
|
|
<button id="_csv_cancel" style="padding:8px 18px;border:1px solid #d1d5db;border-radius:7px;background:transparent;cursor:pointer;font-size:13px;color:#374151;">Cancel</button>
|
|
<button id="_csv_confirm" style="padding:8px 18px;border:none;border-radius:7px;background:#c0392b;color:#fff;cursor:pointer;font-size:13px;font-weight:600;">Export anyway</button>
|
|
</div>
|
|
</div>`;
|
|
document.body.appendChild(overlay);
|
|
overlay
|
|
.querySelector("#_csv_cancel")
|
|
.addEventListener("click", () => {
|
|
overlay.remove();
|
|
resolve(false);
|
|
});
|
|
overlay
|
|
.querySelector("#_csv_confirm")
|
|
.addEventListener("click", () => {
|
|
overlay.remove();
|
|
resolve(true);
|
|
});
|
|
overlay.addEventListener("click", (e) => {
|
|
if (e.target === overlay) {
|
|
overlay.remove();
|
|
resolve(false);
|
|
}
|
|
});
|
|
});
|
|
|
|
if (!confirmed) return;
|
|
|
|
try {
|
|
const res = await apiFetch("/api/vault");
|
|
if (!res) return;
|
|
const raw = await res.json();
|
|
const decrypted = await Promise.all(
|
|
raw.map(async (item) => {
|
|
try {
|
|
const plain = await Crypto.decryptItem(
|
|
vaultKey,
|
|
item.enc_data,
|
|
item.iv,
|
|
);
|
|
let displayName = item.name;
|
|
if (item.enc_name && item.iv_name) {
|
|
const n = await Crypto.decryptName(
|
|
vaultKey,
|
|
item.enc_name,
|
|
item.iv_name,
|
|
);
|
|
if (n) displayName = n;
|
|
}
|
|
return { name: displayName, ...plain };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}),
|
|
);
|
|
const csvRows = [["name", "url", "username", "password", "notes"]];
|
|
decrypted.filter(Boolean).forEach((r) => {
|
|
if (r.password) {
|
|
const esc = (v) => `"${String(v ?? "").replace(/"/g, '""')}"`;
|
|
csvRows.push(
|
|
[r.name, r.url, r.username, r.password, r.notes].map(esc),
|
|
);
|
|
}
|
|
});
|
|
const csv = csvRows.map((r) => r.join(",")).join("\n");
|
|
const blob = new Blob([csv], { type: "text/csv" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = `passkeeper-export-${new Date().toISOString().slice(0, 10)}.csv`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
showToast("CSV exported — store it securely.");
|
|
console.log(
|
|
"[PassKeeper] CSV export:",
|
|
decrypted.filter(Boolean).length,
|
|
"items",
|
|
);
|
|
} catch (err) {
|
|
showToast("CSV export failed: " + err.message, "error");
|
|
}
|
|
});
|
|
|
|
// ── Import ───────────────────────────────────────────────────────────────
|
|
|
|
const fileInput = document.getElementById("import-file-input");
|
|
const fileNameEl = document.getElementById("import-file-name");
|
|
const previewEl = document.getElementById("import-preview");
|
|
const confirmBtn = document.getElementById("btn-import-confirm");
|
|
const resultEl = document.getElementById("import-result");
|
|
|
|
fileInput?.addEventListener("change", async () => {
|
|
const file = fileInput.files[0];
|
|
if (!file) return;
|
|
fileNameEl.textContent = file.name;
|
|
previewEl.classList.add("hidden");
|
|
resultEl.classList.add("hidden");
|
|
confirmBtn.disabled = true;
|
|
_importRows = [];
|
|
|
|
const text = await file.text();
|
|
const isJson = file.name.endsWith(".json");
|
|
|
|
try {
|
|
if (isJson) {
|
|
// PassKeeper encrypted JSON export.
|
|
const parsed = JSON.parse(text);
|
|
const items = parsed.items || (Array.isArray(parsed) ? parsed : []);
|
|
if (!items.length) throw new Error("No items found in JSON file.");
|
|
_importRows = items;
|
|
previewEl.innerHTML = `<p>Found <strong>${items.length}</strong> encrypted item(s) ready to import.</p>
|
|
<p class="import-note">These are already encrypted with your vault key — they will be imported as-is.</p>`;
|
|
} else {
|
|
// CSV — decrypt and re-encrypt with current vault key.
|
|
const vaultKey = VaultSession.getKey();
|
|
if (!vaultKey) {
|
|
showUnlockOverlay();
|
|
return;
|
|
}
|
|
const rows = _parseCsvImport(text);
|
|
if (!rows.length)
|
|
throw new Error("No valid rows found. Check the CSV format.");
|
|
_importRows = rows; // stored as plaintext — encrypted on confirm
|
|
previewEl.innerHTML = `<p>Found <strong>${rows.length}</strong> password(s) to import.</p>
|
|
<p class="import-note">Preview (first 5):</p>
|
|
<ul class="import-preview-list">${rows
|
|
.slice(0, 5)
|
|
.map(
|
|
(r) =>
|
|
`<li><strong>${escHtml(r.name)}</strong> — ${escHtml(r.username || "(no username)")}</li>`,
|
|
)
|
|
.join("")}</ul>
|
|
${rows.length > 5 ? `<p class="import-note">…and ${rows.length - 5} more.</p>` : ""}`;
|
|
}
|
|
previewEl.classList.remove("hidden");
|
|
confirmBtn.disabled = false;
|
|
confirmBtn.dataset.mode = isJson ? "json" : "csv";
|
|
} catch (err) {
|
|
previewEl.innerHTML = `<p class="import-error">⚠️ ${escHtml(err.message)}</p>`;
|
|
previewEl.classList.remove("hidden");
|
|
}
|
|
});
|
|
|
|
confirmBtn?.addEventListener("click", async () => {
|
|
const vaultKey = VaultSession.getKey();
|
|
if (!vaultKey) {
|
|
showUnlockOverlay();
|
|
return;
|
|
}
|
|
confirmBtn.disabled = true;
|
|
confirmBtn.textContent = "Importing…";
|
|
resultEl.classList.add("hidden");
|
|
|
|
try {
|
|
let payload;
|
|
if (confirmBtn.dataset.mode === "json") {
|
|
// Already-encrypted items — send directly.
|
|
payload = _importRows;
|
|
} else {
|
|
// Plaintext CSV rows — encrypt each one now.
|
|
payload = await Promise.all(
|
|
_importRows.map(async (row) => {
|
|
const plain = {
|
|
url: row.url || "",
|
|
username: row.username || "",
|
|
password: row.password,
|
|
notes: row.notes || "",
|
|
};
|
|
const { enc_data, iv } = await Crypto.encryptItem(
|
|
vaultKey,
|
|
plain,
|
|
);
|
|
const { enc_name, iv_name } = await Crypto.encryptName(
|
|
vaultKey,
|
|
row.name,
|
|
);
|
|
return {
|
|
name: "password",
|
|
item_type: "password",
|
|
enc_data,
|
|
iv,
|
|
enc_name,
|
|
iv_name,
|
|
};
|
|
}),
|
|
);
|
|
}
|
|
|
|
const res = await apiFetch("/api/vault/import", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!res) return;
|
|
const data = await res.json();
|
|
|
|
resultEl.innerHTML = `✅ Imported <strong>${data.imported}</strong> item(s)${data.skipped ? `, skipped ${data.skipped} malformed row(s)` : ""}.`;
|
|
resultEl.className = "import-result import-result-ok";
|
|
resultEl.classList.remove("hidden");
|
|
_importRows = [];
|
|
confirmBtn.textContent = "Import items";
|
|
fileInput.value = "";
|
|
fileNameEl.textContent = "No file chosen";
|
|
previewEl.classList.add("hidden");
|
|
console.log(
|
|
"[PassKeeper] Import complete:",
|
|
data.imported,
|
|
"imported,",
|
|
data.skipped,
|
|
"skipped",
|
|
);
|
|
await loadVault();
|
|
} catch (err) {
|
|
resultEl.innerHTML = `⚠️ ${escHtml(err.message)}`;
|
|
resultEl.className = "import-result import-result-err";
|
|
resultEl.classList.remove("hidden");
|
|
confirmBtn.disabled = false;
|
|
confirmBtn.textContent = "Import items";
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Sharing View ──────────────────────────────────────────────────────────
|
|
|
|
async function loadSharingView() {
|
|
try {
|
|
const keysRes = await apiFetch("/api/sharing/keys");
|
|
if (!keysRes) return;
|
|
const keysData = await keysRes.json();
|
|
|
|
const notice = document.getElementById("sharing-keys-notice");
|
|
if (!keysData.keys_setup) {
|
|
notice.classList.remove("hidden");
|
|
} else {
|
|
notice.classList.add("hidden");
|
|
// Load sharing private key into SharingSession if vault key available
|
|
const vaultKey = VaultSession.getKey();
|
|
if (vaultKey && !SharingSession.isReady()) {
|
|
try {
|
|
const privKey = await SharingCrypto.decryptPrivateKey(
|
|
vaultKey,
|
|
keysData.private_key_enc,
|
|
keysData.private_key_iv,
|
|
);
|
|
SharingSession.setKey(privKey);
|
|
} catch (err) {
|
|
console.warn(
|
|
"[PassKeeper] Could not load sharing private key:",
|
|
err,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
await Promise.all([loadSharedOut(), loadSharedIn()]);
|
|
} catch (err) {
|
|
showToast("Could not load sharing data: " + err.message, "error");
|
|
}
|
|
}
|
|
|
|
async function loadSharedOut() {
|
|
try {
|
|
const res = await apiFetch("/api/sharing");
|
|
if (!res) return;
|
|
const shares = await res.json();
|
|
renderSharedOut(shares);
|
|
} catch (err) {
|
|
console.error("loadSharedOut:", err);
|
|
}
|
|
}
|
|
|
|
async function loadSharedIn() {
|
|
try {
|
|
const res = await apiFetch("/api/sharing/inbox");
|
|
if (!res) return;
|
|
const shares = await res.json();
|
|
renderSharedIn(shares);
|
|
} catch (err) {
|
|
console.error("loadSharedIn:", err);
|
|
}
|
|
}
|
|
|
|
function renderSharedOut(shares) {
|
|
const ul = document.getElementById("shared-out-list");
|
|
if (!ul) return;
|
|
if (!shares.length) {
|
|
ul.innerHTML =
|
|
'<li class="vault-empty">You haven\'t shared any items yet.</li>';
|
|
return;
|
|
}
|
|
ul.innerHTML = shares
|
|
.map(
|
|
(s) => `
|
|
<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-generate";
|
|
btn.textContent = "⚡ Generate";
|
|
pwInput.closest(".form-group").appendChild(btn);
|
|
}
|
|
|
|
document.getElementById("btn-generate-pass").onclick = () => {
|
|
const pw = generatePassword(20);
|
|
pwInput.value = pw;
|
|
pwInput.type = "text"; // reveal so user can see what was generated
|
|
pwInput.dispatchEvent(new Event("input")); // trigger strength bar
|
|
};
|
|
}
|
|
|
|
function openPasswordGeneratorModal() {
|
|
// Build modal content dynamically — keeps the HTML lean
|
|
let modal = document.getElementById("gen-modal");
|
|
if (!modal) {
|
|
modal = document.createElement("div");
|
|
modal.id = "gen-modal";
|
|
modal.className = "modal-overlay";
|
|
modal.setAttribute("role", "dialog");
|
|
modal.setAttribute("aria-modal", "true");
|
|
modal.innerHTML = `
|
|
<div class="modal gen-modal-inner">
|
|
<div class="modal-header">
|
|
<h3>⚡ Password Generator</h3>
|
|
<button class="btn-icon btn-close" id="btn-close-gen-modal" aria-label="Close">✕</button>
|
|
</div>
|
|
<div class="form-group gen-form-group">
|
|
<label>Length: <strong id="gen-length-display">20</strong></label>
|
|
<input type="range" id="gen-length" min="8" max="64" value="20">
|
|
</div>
|
|
<div class="gen-options">
|
|
<label class="gen-opt"><input type="checkbox" id="gen-upper" checked> Uppercase</label>
|
|
<label class="gen-opt"><input type="checkbox" id="gen-lower" checked> Lowercase</label>
|
|
<label class="gen-opt"><input type="checkbox" id="gen-digits" checked> Numbers</label>
|
|
<label class="gen-opt"><input type="checkbox" id="gen-symbols" checked> Symbols</label>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Generated Password</label>
|
|
<div class="input-with-toggle gen-input-wrap">
|
|
<input type="text" id="gen-output" readonly>
|
|
<button type="button" class="btn-show-pass" id="btn-gen-copy" title="Copy">📋</button>
|
|
</div>
|
|
<div id="gen-strength" class="password-strength"></div>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button type="button" class="btn-secondary" id="btn-close-gen-modal2">Close</button>
|
|
<button type="button" class="btn-primary" id="btn-regenerate">↺ Regenerate</button>
|
|
</div>
|
|
</div>`;
|
|
document.body.appendChild(modal);
|
|
|
|
const lengthSlider = document.getElementById("gen-length");
|
|
const lengthDisplay = document.getElementById("gen-length-display");
|
|
const output = document.getElementById("gen-output");
|
|
const strengthEl = document.getElementById("gen-strength");
|
|
|
|
function refreshGen() {
|
|
const pw = generatePassword(parseInt(lengthSlider.value), {
|
|
upper: document.getElementById("gen-upper").checked,
|
|
lower: document.getElementById("gen-lower").checked,
|
|
digits: document.getElementById("gen-digits").checked,
|
|
symbols: document.getElementById("gen-symbols").checked,
|
|
});
|
|
output.value = pw;
|
|
const { label, cls } = scorePassword(pw);
|
|
strengthEl.textContent = label;
|
|
strengthEl.className = "password-strength " + cls;
|
|
}
|
|
|
|
lengthSlider.addEventListener("input", () => {
|
|
lengthDisplay.textContent = lengthSlider.value;
|
|
refreshGen();
|
|
});
|
|
["gen-upper", "gen-lower", "gen-digits", "gen-symbols"].forEach((id) => {
|
|
document.getElementById(id).addEventListener("change", refreshGen);
|
|
});
|
|
document
|
|
.getElementById("btn-regenerate")
|
|
.addEventListener("click", refreshGen);
|
|
document.getElementById("btn-gen-copy").addEventListener("click", () => {
|
|
navigator.clipboard
|
|
.writeText(output.value)
|
|
.then(() => showToast("Password copied"));
|
|
});
|
|
const closeGen = () => modal.classList.remove("open");
|
|
document
|
|
.getElementById("btn-close-gen-modal")
|
|
.addEventListener("click", closeGen);
|
|
document
|
|
.getElementById("btn-close-gen-modal2")
|
|
.addEventListener("click", closeGen);
|
|
modal.addEventListener("click", (e) => {
|
|
if (e.target === modal) closeGen();
|
|
});
|
|
|
|
refreshGen();
|
|
}
|
|
modal.classList.add("open");
|
|
// Regenerate fresh password each time modal opens
|
|
const lengthSlider = document.getElementById("gen-length");
|
|
document.getElementById("gen-length-display").textContent =
|
|
lengthSlider.value;
|
|
const pw = generatePassword(parseInt(lengthSlider.value), {
|
|
upper: document.getElementById("gen-upper").checked,
|
|
lower: document.getElementById("gen-lower").checked,
|
|
digits: document.getElementById("gen-digits").checked,
|
|
symbols: document.getElementById("gen-symbols").checked,
|
|
});
|
|
document.getElementById("gen-output").value = pw;
|
|
const { label, cls } = scorePassword(pw);
|
|
const strengthEl = document.getElementById("gen-strength");
|
|
strengthEl.textContent = label;
|
|
strengthEl.className = "password-strength " + cls;
|
|
}
|
|
|
|
// ── Account Settings ──────────────────────────────────────────────────────
|
|
|
|
let _pendingMfaSecret = null;
|
|
|
|
async function openSettingsModal() {
|
|
document.getElementById("settings-modal").classList.add("open");
|
|
// Populate web-app session timeout select with saved value.
|
|
const idleSel = document.getElementById("web-idle-select");
|
|
if (idleSel) idleSel.value = String(_getWebIdleMinutes());
|
|
// Reset change password fields
|
|
["cp-current", "cp-new", "cp-confirm"].forEach((id) => {
|
|
const el = document.getElementById(id);
|
|
if (el) el.value = "";
|
|
});
|
|
document.getElementById("cp-error")?.classList.add("hidden");
|
|
// Reset delete account area
|
|
document.getElementById("delete-confirm-area")?.classList.add("hidden");
|
|
document.getElementById("btn-delete-account")?.classList.remove("hidden");
|
|
document.getElementById("delete-password").value = "";
|
|
document.getElementById("delete-error")?.classList.add("hidden");
|
|
// Reset recovery display
|
|
document.getElementById("recovery-code-display")?.classList.add("hidden");
|
|
await Promise.all([
|
|
loadMfaStatus(),
|
|
loadSharingKeysStatus(),
|
|
loadRecoveryStatus(),
|
|
loadAuditLog(0, true),
|
|
]);
|
|
}
|
|
|
|
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;
|
|
|
|
async function loadAuditLog(offset = 0, reset = false) {
|
|
try {
|
|
const res = await apiFetch(
|
|
`/api/auth/audit-log?limit=${_auditPageSize}&offset=${offset}`,
|
|
);
|
|
if (!res) return;
|
|
const data = await res.json();
|
|
_auditTotal = data.total || 0;
|
|
_auditOffset = offset + (data.entries || []).length;
|
|
|
|
const listEl = document.getElementById("audit-log-list");
|
|
if (!listEl) return;
|
|
|
|
if (reset) listEl.innerHTML = "";
|
|
|
|
if (!data.entries || !data.entries.length) {
|
|
if (reset)
|
|
listEl.innerHTML =
|
|
'<p style="color:#6b7280;font-size:12px;">No activity recorded yet.</p>';
|
|
document.getElementById("btn-load-more-audit").style.display = "none";
|
|
return;
|
|
}
|
|
|
|
// Action label map for friendly display
|
|
const ACTION_LABELS = {
|
|
"auth.login": "Signed in",
|
|
"auth.login_failed": "Failed sign-in attempt",
|
|
"auth.login_blocked": "Sign-in blocked (account locked)",
|
|
"auth.account_locked": "Account locked",
|
|
"auth.logout": "Signed out",
|
|
"auth.mfa_verify": "MFA verified",
|
|
"auth.mfa_backup_code_used": "MFA backup code used",
|
|
"auth.mfa_enable": "MFA enabled",
|
|
"auth.mfa_disable": "MFA disabled",
|
|
"auth.mfa_backup_codes_regenerated": "Backup codes regenerated",
|
|
"auth.change_password": "Password changed",
|
|
"auth.recovery_setup": "Recovery code configured",
|
|
"auth.recovery_success": "Account recovered",
|
|
"auth.recovery_failed": "Failed recovery attempt",
|
|
"vault.create": "Vault item created",
|
|
"vault.update": "Vault item updated",
|
|
"vault.delete": "Vault item deleted",
|
|
};
|
|
|
|
data.entries.forEach((e) => {
|
|
const label = ACTION_LABELS[e.action] || e.action;
|
|
const date = new Date(e.created_at).toLocaleString();
|
|
const isAlert =
|
|
e.action.includes("failed") ||
|
|
e.action.includes("blocked") ||
|
|
e.action.includes("locked");
|
|
const row = document.createElement("div");
|
|
row.style.cssText = `display:flex;justify-content:space-between;align-items:flex-start;padding:7px 0;border-bottom:1px solid #f3f4f6;gap:8px;`;
|
|
row.innerHTML = `
|
|
<span style="font-size:12px;${isAlert ? "color:#b91c1c;font-weight:600;" : "color:#111827;"}">${label}</span>
|
|
<span style="font-size:11px;color:#9ca3af;white-space:nowrap;text-align:right;">${date}<br>${e.ip_address || ""}</span>`;
|
|
listEl.appendChild(row);
|
|
});
|
|
|
|
const loadMoreBtn = document.getElementById("btn-load-more-audit");
|
|
if (_auditOffset < _auditTotal) {
|
|
loadMoreBtn.style.display = "inline-block";
|
|
loadMoreBtn.onclick = () => loadAuditLog(_auditOffset, false);
|
|
} else {
|
|
loadMoreBtn.style.display = "none";
|
|
}
|
|
} catch (err) {
|
|
console.error("loadAuditLog:", err);
|
|
}
|
|
}
|
|
|
|
function renderMfaStatus(enabled, backupCodesRemaining) {
|
|
const statusText = document.getElementById("mfa-status-text");
|
|
const actions = document.getElementById("mfa-actions");
|
|
document.getElementById("mfa-setup-area").classList.add("hidden");
|
|
document.getElementById("mfa-disable-area").classList.add("hidden");
|
|
|
|
if (enabled) {
|
|
const remaining =
|
|
backupCodesRemaining != null ? backupCodesRemaining : "?";
|
|
statusText.innerHTML = `✅ Two-factor authentication is enabled. <span style="font-size:12px;color:#6b7280;">(${remaining} backup code${remaining !== 1 ? "s" : ""} remaining)</span>`;
|
|
actions.innerHTML =
|
|
'<button class="btn-secondary" id="btn-mfa-disable">Disable MFA</button>' +
|
|
' <button class="btn-secondary" id="btn-regen-backup-codes">Regenerate backup codes</button>';
|
|
document
|
|
.getElementById("btn-mfa-disable")
|
|
?.addEventListener("click", () => {
|
|
document
|
|
.getElementById("mfa-disable-area")
|
|
.classList.remove("hidden");
|
|
document.getElementById("mfa-actions").innerHTML = "";
|
|
document.getElementById("mfa-disable-code").focus();
|
|
});
|
|
document
|
|
.getElementById("btn-regen-backup-codes")
|
|
?.addEventListener("click", handleRegenerateBackupCodes);
|
|
} else {
|
|
statusText.textContent = "Two-factor authentication is not enabled.";
|
|
actions.innerHTML =
|
|
'<button class="btn-primary" id="btn-mfa-setup">Set up MFA</button>';
|
|
document
|
|
.getElementById("btn-mfa-setup")
|
|
?.addEventListener("click", handleMfaSetup);
|
|
}
|
|
}
|
|
|
|
async function handleMfaSetup() {
|
|
try {
|
|
const res = await apiFetch("/api/auth/mfa/setup");
|
|
if (!res) return;
|
|
const data = await res.json();
|
|
_pendingMfaSecret = data.secret;
|
|
|
|
document.getElementById("mfa-qr-img").src = data.qr_code;
|
|
document.getElementById("mfa-secret-display").textContent = data.secret;
|
|
document.getElementById("mfa-setup-area").classList.remove("hidden");
|
|
document.getElementById("mfa-actions").innerHTML = "";
|
|
document.getElementById("mfa-status-text").textContent =
|
|
"Scan the QR code below with your authenticator app.";
|
|
document.getElementById("mfa-verify-code").value = "";
|
|
document.getElementById("mfa-verify-code").focus();
|
|
} catch (err) {
|
|
showToast("Could not start MFA setup: " + err.message, "error");
|
|
}
|
|
}
|
|
|
|
async function handleMfaConfirm() {
|
|
const code = document.getElementById("mfa-verify-code").value.trim();
|
|
if (!code || !_pendingMfaSecret) return;
|
|
try {
|
|
const res = await apiFetch("/api/auth/mfa/enable", {
|
|
method: "POST",
|
|
body: JSON.stringify({ secret: _pendingMfaSecret, totp_code: code }),
|
|
});
|
|
if (!res) return;
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
showToast(data.error || "Failed to enable MFA", "error");
|
|
return;
|
|
}
|
|
_pendingMfaSecret = null;
|
|
document.getElementById("mfa-setup-area").classList.add("hidden");
|
|
|
|
// Display one-time backup codes immediately after enabling
|
|
if (data.backup_codes && data.backup_codes.length) {
|
|
showBackupCodesModal(data.backup_codes, true);
|
|
} else {
|
|
showToast("MFA enabled successfully");
|
|
renderMfaStatus(true);
|
|
}
|
|
} catch (err) {
|
|
showToast("Failed to enable MFA: " + err.message, "error");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show backup codes in a modal. When `isFirstTime` is true the modal
|
|
* emphasises that these will not be shown again.
|
|
*/
|
|
function showBackupCodesModal(codes, isFirstTime = false) {
|
|
const existing = document.getElementById("__pk_backup_modal__");
|
|
if (existing) existing.remove();
|
|
|
|
const codesHtml = codes
|
|
.map(
|
|
(c) =>
|
|
`<code style="display:inline-block;background:#f3f4f6;border-radius:4px;padding:3px 8px;font-family:monospace;font-size:13px;letter-spacing:0.05em;">${c}</code>`,
|
|
)
|
|
.join(" ");
|
|
|
|
const overlay = document.createElement("div");
|
|
overlay.id = "__pk_backup_modal__";
|
|
overlay.style.cssText =
|
|
"position:fixed;inset:0;background:rgba(0,0,0,0.55);z-index:9999;display:flex;align-items:center;justify-content:center;";
|
|
overlay.innerHTML = `
|
|
<div style="background:#fff;border-radius:12px;padding:28px 24px;max-width:420px;width:90%;box-shadow:0 8px 32px rgba(0,0,0,0.22);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
|
<h3 style="margin:0 0 8px;font-size:16px;color:#111827;">
|
|
${isFirstTime ? "🔐 MFA enabled — save your backup codes" : "🔐 New backup codes"}
|
|
</h3>
|
|
${
|
|
isFirstTime
|
|
? `<p style="font-size:13px;color:#374151;margin-bottom:12px;">
|
|
These codes let you sign in if you lose access to your authenticator app.
|
|
<strong>Save them now — they will not be shown again.</strong>
|
|
</p>`
|
|
: `<p style="font-size:13px;color:#374151;margin-bottom:12px;">
|
|
Your previous codes have been invalidated. Save these new codes securely.
|
|
</p>`
|
|
}
|
|
<div style="background:#f9fafb;border:1px solid #e5e7eb;border-radius:8px;padding:12px;display:flex;flex-wrap:wrap;gap:6px;margin-bottom:16px;">
|
|
${codesHtml}
|
|
</div>
|
|
<div style="display:flex;gap:10px;justify-content:flex-end;">
|
|
<button id="__pk_copy_codes__" style="padding:8px 16px;border:1px solid #d1d5db;border-radius:7px;background:#fff;cursor:pointer;font-size:13px;">Copy all</button>
|
|
<button id="__pk_close_backup__" style="padding:8px 16px;border:none;border-radius:7px;background:#c0392b;color:#fff;cursor:pointer;font-size:13px;font-weight:600;">I've saved them</button>
|
|
</div>
|
|
</div>`;
|
|
document.body.appendChild(overlay);
|
|
|
|
document
|
|
.getElementById("__pk_copy_codes__")
|
|
.addEventListener("click", () => {
|
|
navigator.clipboard
|
|
.writeText(codes.join("\n"))
|
|
.then(() => showToast("Backup codes copied"));
|
|
});
|
|
document
|
|
.getElementById("__pk_close_backup__")
|
|
.addEventListener("click", () => {
|
|
overlay.remove();
|
|
renderMfaStatus(true);
|
|
});
|
|
}
|
|
|
|
async function handleRegenerateBackupCodes() {
|
|
const code = prompt(
|
|
"Enter your current authenticator code to regenerate backup codes:",
|
|
);
|
|
if (!code) return;
|
|
try {
|
|
const res = await apiFetch("/api/auth/mfa/backup-codes/regenerate", {
|
|
method: "POST",
|
|
body: JSON.stringify({ totp_code: code.trim() }),
|
|
});
|
|
if (!res) return;
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
showToast(data.error || "Failed to regenerate", "error");
|
|
return;
|
|
}
|
|
showBackupCodesModal(data.backup_codes, false);
|
|
console.log("[PassKeeper] MFA backup codes regenerated");
|
|
} catch (err) {
|
|
showToast("Failed to regenerate backup codes: " + err.message, "error");
|
|
}
|
|
}
|
|
|
|
async function handleMfaDisableConfirm() {
|
|
const code = document.getElementById("mfa-disable-code").value.trim();
|
|
if (!code) return;
|
|
try {
|
|
const res = await apiFetch("/api/auth/mfa/disable", {
|
|
method: "POST",
|
|
body: JSON.stringify({ totp_code: code }),
|
|
});
|
|
if (!res) return;
|
|
showToast("MFA disabled");
|
|
renderMfaStatus(false);
|
|
} catch (err) {
|
|
showToast("Failed to disable MFA: " + err.message, "error");
|
|
}
|
|
}
|
|
|
|
async function loadSharingKeysStatus() {
|
|
try {
|
|
const res = await apiFetch("/api/sharing/keys");
|
|
if (!res) return;
|
|
const data = await res.json();
|
|
const statusEl = document.getElementById("sharing-keys-status");
|
|
const actionsEl = document.getElementById("sharing-keys-actions");
|
|
|
|
if (data.keys_setup) {
|
|
statusEl.textContent =
|
|
"✅ Sharing keys are set up. You can share items and use emergency access.";
|
|
actionsEl.innerHTML =
|
|
'<button class="btn-secondary" id="btn-regen-keys">Regenerate keys</button>';
|
|
document
|
|
.getElementById("btn-regen-keys")
|
|
?.addEventListener("click", handleGenerateKeys);
|
|
} else {
|
|
statusEl.textContent =
|
|
"No sharing keys set up. Generate keys to enable item sharing and emergency access.";
|
|
actionsEl.innerHTML =
|
|
'<button class="btn-primary" id="btn-gen-keys">Generate sharing keys</button>';
|
|
document
|
|
.getElementById("btn-gen-keys")
|
|
?.addEventListener("click", handleGenerateKeys);
|
|
}
|
|
} catch (err) {
|
|
console.error("loadSharingKeysStatus:", err);
|
|
}
|
|
}
|
|
|
|
async function handleGenerateKeys() {
|
|
const vaultKey = VaultSession.getKey();
|
|
if (!vaultKey) {
|
|
showToast("Vault is locked. Unlock first.", "error");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const keyPair = await SharingCrypto.generateKeyPair();
|
|
const public_key = await SharingCrypto.exportPublicKey(keyPair.publicKey);
|
|
const { private_key_enc, private_key_iv } =
|
|
await SharingCrypto.encryptPrivateKey(vaultKey, keyPair.privateKey);
|
|
|
|
const res = await apiFetch("/api/sharing/keys", {
|
|
method: "POST",
|
|
body: JSON.stringify({ public_key, private_key_enc, private_key_iv }),
|
|
});
|
|
if (!res) return;
|
|
|
|
// Cache private key in session
|
|
SharingSession.setKey(keyPair.privateKey);
|
|
|
|
showToast("Sharing keys generated");
|
|
await loadSharingKeysStatus();
|
|
} catch (err) {
|
|
showToast("Key generation failed: " + err.message, "error");
|
|
}
|
|
}
|
|
|
|
// ── Change Password ───────────────────────────────────────────────────────
|
|
|
|
async function handleChangePassword() {
|
|
const cpError = document.getElementById("cp-error");
|
|
cpError.classList.add("hidden");
|
|
|
|
const currentPass = document.getElementById("cp-current").value;
|
|
const newPass = document.getElementById("cp-new").value;
|
|
const confirmPass = document.getElementById("cp-confirm").value;
|
|
|
|
if (!currentPass || !newPass || !confirmPass) {
|
|
cpError.textContent = "All fields are required.";
|
|
cpError.classList.remove("hidden");
|
|
return;
|
|
}
|
|
if (newPass !== confirmPass) {
|
|
cpError.textContent = "New passwords do not match.";
|
|
cpError.classList.remove("hidden");
|
|
return;
|
|
}
|
|
if (newPass.length < 12) {
|
|
cpError.textContent = "New password must be at least 12 characters.";
|
|
cpError.classList.remove("hidden");
|
|
return;
|
|
}
|
|
if (currentPass === newPass) {
|
|
cpError.textContent =
|
|
"New password must be different from the current password.";
|
|
cpError.classList.remove("hidden");
|
|
return;
|
|
}
|
|
|
|
const btn = document.getElementById("btn-change-password");
|
|
const originalText = btn.textContent;
|
|
btn.disabled = true;
|
|
btn.textContent = "Re-encrypting vault…";
|
|
|
|
try {
|
|
const vaultKey = VaultSession.getKey();
|
|
if (!vaultKey) {
|
|
showToast("Vault is locked. Reload and unlock first.", "error");
|
|
return;
|
|
}
|
|
|
|
// Fetch current user email from session storage
|
|
const encKeySalt = sessionStorage.getItem("enc_key_salt");
|
|
|
|
// We need the email to derive auth hashes — fetch it from the profile
|
|
const profileRes = await apiFetch("/api/auth/me");
|
|
if (!profileRes) return;
|
|
const profile = await profileRes.json();
|
|
const email = profile.email;
|
|
|
|
// Derive both auth hashes
|
|
const currentAuthHash = await Crypto.deriveAuthHash(currentPass, email);
|
|
const newAuthHash = await Crypto.deriveAuthHash(newPass, email);
|
|
const newEncKeySalt = Crypto.generateSalt(16);
|
|
const newVaultKey = await Crypto.deriveVaultKey(newPass, newEncKeySalt);
|
|
|
|
// Fetch all vault items and re-encrypt
|
|
const itemsRes = await apiFetch("/api/vault");
|
|
if (!itemsRes) return;
|
|
const items = await itemsRes.json();
|
|
|
|
btn.textContent = `Re-encrypting ${items.length} item(s)…`;
|
|
|
|
const reEncrypted = [];
|
|
for (const item of items) {
|
|
const plain = await Crypto.decryptItem(
|
|
vaultKey,
|
|
item.enc_data,
|
|
item.iv,
|
|
);
|
|
const { enc_data, iv } = await Crypto.encryptItem(newVaultKey, plain);
|
|
// Re-encrypt the name if it was previously encrypted.
|
|
let encNamePayload = {};
|
|
if (item.enc_name && item.iv_name) {
|
|
const plainName = await Crypto.decryptName(
|
|
vaultKey,
|
|
item.enc_name,
|
|
item.iv_name,
|
|
);
|
|
if (plainName) {
|
|
const { enc_name, iv_name } = await Crypto.encryptName(
|
|
newVaultKey,
|
|
plainName,
|
|
);
|
|
encNamePayload = { enc_name, iv_name };
|
|
}
|
|
}
|
|
reEncrypted.push({ id: item.id, enc_data, iv, ...encNamePayload });
|
|
}
|
|
|
|
// Submit atomic password change
|
|
const res = await apiFetch("/api/auth/change-password", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
current_auth_hash: currentAuthHash,
|
|
new_auth_hash: newAuthHash,
|
|
new_enc_key_salt: newEncKeySalt,
|
|
items: reEncrypted,
|
|
}),
|
|
});
|
|
if (!res) return;
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
cpError.textContent = data.error || "Password change failed.";
|
|
cpError.classList.remove("hidden");
|
|
return;
|
|
}
|
|
|
|
showToast("Password changed. Please log in again.");
|
|
setTimeout(() => redirectToLogin(), 1500);
|
|
} catch (err) {
|
|
cpError.textContent = "An error occurred: " + err.message;
|
|
cpError.classList.remove("hidden");
|
|
console.error(err);
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = originalText;
|
|
}
|
|
}
|
|
|
|
// ── Account Recovery Setup ────────────────────────────────────────────────
|
|
|
|
async function loadRecoveryStatus() {
|
|
try {
|
|
const res = await apiFetch("/api/auth/recovery/status");
|
|
if (!res) return;
|
|
const data = await res.json();
|
|
const statusEl = document.getElementById("recovery-status-text");
|
|
const actionsEl = document.getElementById("recovery-actions");
|
|
|
|
if (data.recovery_configured) {
|
|
statusEl.textContent =
|
|
"✅ A recovery code is configured for your account.";
|
|
actionsEl.innerHTML =
|
|
'<button class="btn-secondary" id="btn-regen-recovery">Generate new recovery code</button>';
|
|
} else {
|
|
statusEl.textContent =
|
|
"No recovery code set up. If you forget your master password, your vault cannot be recovered.";
|
|
actionsEl.innerHTML =
|
|
'<button class="btn-primary" id="btn-gen-recovery">Generate recovery code</button>';
|
|
}
|
|
document
|
|
.getElementById("btn-gen-recovery")
|
|
?.addEventListener("click", handleSetupRecovery);
|
|
document
|
|
.getElementById("btn-regen-recovery")
|
|
?.addEventListener("click", handleSetupRecovery);
|
|
} catch (err) {
|
|
console.error("loadRecoveryStatus:", err);
|
|
}
|
|
}
|
|
|
|
async function handleSetupRecovery() {
|
|
const vaultKey = VaultSession.getKey();
|
|
if (!vaultKey) {
|
|
showToast("Vault is locked. Unlock first.", "error");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const encKeySalt = sessionStorage.getItem("enc_key_salt");
|
|
if (!encKeySalt) {
|
|
showToast("Session error. Please reload.", "error");
|
|
return;
|
|
}
|
|
|
|
// Generate a random 128-bit (16-byte) recovery code displayed as hex
|
|
const rawBytes = window.crypto.getRandomValues(new Uint8Array(16));
|
|
const recoveryCode = Array.from(rawBytes)
|
|
.map((b) => b.toString(16).padStart(2, "0"))
|
|
.join("");
|
|
|
|
// Derive recovery key from the code
|
|
const recoveryKeyMaterial = await window.crypto.subtle.importKey(
|
|
"raw",
|
|
new TextEncoder().encode(recoveryCode),
|
|
"PBKDF2",
|
|
false,
|
|
["deriveKey"],
|
|
);
|
|
const recoveryKey = await window.crypto.subtle.deriveKey(
|
|
{
|
|
name: "PBKDF2",
|
|
salt: new TextEncoder().encode("passkeeper-recovery"),
|
|
iterations: 200_000,
|
|
hash: "SHA-256",
|
|
},
|
|
recoveryKeyMaterial,
|
|
{ name: "AES-GCM", length: 256 },
|
|
false,
|
|
["encrypt"],
|
|
);
|
|
|
|
// Encrypt enc_key_salt with recovery key
|
|
const iv = window.crypto.getRandomValues(new Uint8Array(12));
|
|
const ciphertext = await window.crypto.subtle.encrypt(
|
|
{ name: "AES-GCM", iv },
|
|
recoveryKey,
|
|
new TextEncoder().encode(encKeySalt),
|
|
);
|
|
|
|
function bytesToBase64(bytes) {
|
|
let bin = "";
|
|
new Uint8Array(bytes).forEach((b) => (bin += String.fromCharCode(b)));
|
|
return btoa(bin);
|
|
}
|
|
|
|
const recovery_enc_salt = bytesToBase64(ciphertext);
|
|
const recovery_iv = bytesToBase64(iv);
|
|
|
|
// Store on server
|
|
const res = await apiFetch("/api/auth/recovery/setup", {
|
|
method: "POST",
|
|
body: JSON.stringify({ recovery_enc_salt, recovery_iv }),
|
|
});
|
|
if (!res) return;
|
|
|
|
// Display the code to the user — formatted in groups of 4
|
|
const formatted = recoveryCode.match(/.{1,4}/g).join("-");
|
|
document.getElementById("recovery-code-value").textContent = formatted;
|
|
document
|
|
.getElementById("recovery-code-display")
|
|
.classList.remove("hidden");
|
|
document.getElementById("recovery-actions").innerHTML = "";
|
|
document.getElementById("recovery-status-text").textContent =
|
|
"Your new recovery code is shown below.";
|
|
} catch (err) {
|
|
showToast("Recovery code generation failed: " + err.message, "error");
|
|
console.error(err);
|
|
}
|
|
}
|
|
|
|
// ── Delete Account ────────────────────────────────────────────────────────
|
|
|
|
async function handleDeleteAccount() {
|
|
const deleteError = document.getElementById("delete-error");
|
|
deleteError.classList.add("hidden");
|
|
const password = document.getElementById("delete-password").value;
|
|
if (!password) {
|
|
deleteError.textContent = "Please enter your master password.";
|
|
deleteError.classList.remove("hidden");
|
|
return;
|
|
}
|
|
|
|
const btn = document.getElementById("btn-delete-confirm");
|
|
const originalText = btn.textContent;
|
|
btn.disabled = true;
|
|
btn.textContent = "Deleting…";
|
|
|
|
try {
|
|
const profileRes = await apiFetch("/api/auth/me");
|
|
if (!profileRes) return;
|
|
const profile = await profileRes.json();
|
|
const authHash = await Crypto.deriveAuthHash(password, profile.email);
|
|
|
|
const res = await apiFetch("/api/auth/account", {
|
|
method: "DELETE",
|
|
body: JSON.stringify({ auth_hash: authHash }),
|
|
});
|
|
if (!res) return;
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
deleteError.textContent = data.error || "Deletion failed.";
|
|
deleteError.classList.remove("hidden");
|
|
return;
|
|
}
|
|
|
|
// Clear all local state and redirect
|
|
sessionStorage.clear();
|
|
localStorage.clear();
|
|
window.location.href = "/login";
|
|
} catch (err) {
|
|
deleteError.textContent = "An error occurred: " + err.message;
|
|
deleteError.classList.remove("hidden");
|
|
console.error(err);
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = originalText;
|
|
}
|
|
}
|
|
|
|
// ── Folder CRUD ───────────────────────────────────────────────────────────
|
|
|
|
function showNewFolderRow() {
|
|
document.getElementById("new-folder-row").classList.remove("hidden");
|
|
document.getElementById("new-folder-input").focus();
|
|
}
|
|
|
|
function hideNewFolderRow() {
|
|
document.getElementById("new-folder-row").classList.add("hidden");
|
|
document.getElementById("new-folder-input").value = "";
|
|
}
|
|
|
|
async function handleNewFolder(e) {
|
|
e.preventDefault();
|
|
const name = document.getElementById("new-folder-input").value.trim();
|
|
if (!name) return;
|
|
try {
|
|
const res = await apiFetch("/api/folders", {
|
|
method: "POST",
|
|
body: JSON.stringify({ name }),
|
|
});
|
|
if (!res) return;
|
|
hideNewFolderRow();
|
|
await loadFolders();
|
|
showToast("Folder created");
|
|
} catch (err) {
|
|
showToast("Could not create folder: " + err.message, "error");
|
|
}
|
|
}
|
|
|
|
async function confirmDeleteFolder(folder) {
|
|
if (
|
|
!confirm(
|
|
`Delete folder "${folder.name}"?\nItems will be moved to No Folder.`,
|
|
)
|
|
)
|
|
return;
|
|
try {
|
|
const res = await apiFetch(`/api/folders/${folder.id}`, {
|
|
method: "DELETE",
|
|
});
|
|
if (!res) return;
|
|
_items = _items.map((i) =>
|
|
i.folder_id === folder.id ? { ...i, folder_id: null } : i,
|
|
);
|
|
if (
|
|
_activeFilter?.type === "folder" &&
|
|
_activeFilter.value === folder.id
|
|
) {
|
|
_activeFilter = null;
|
|
updateVaultTitle("All Items");
|
|
}
|
|
await loadFolders();
|
|
applyCurrentFilter();
|
|
showToast("Folder deleted");
|
|
} catch (err) {
|
|
showToast("Could not delete folder: " + err.message, "error");
|
|
}
|
|
}
|
|
|
|
// ── Filtering / Search ────────────────────────────────────────────────────
|
|
|
|
function applyCurrentFilter() {
|
|
if (!_activeFilter) {
|
|
renderItemList(_items);
|
|
return;
|
|
}
|
|
if (_activeFilter.type === "itemType")
|
|
renderItemList(_items.filter((i) => i.item_type === _activeFilter.value));
|
|
else if (_activeFilter.type === "folder")
|
|
renderItemList(_items.filter((i) => i.folder_id === _activeFilter.value));
|
|
else if (_activeFilter.type === "tag")
|
|
renderItemList(
|
|
_items.filter((i) =>
|
|
(i.plain?.tags || []).includes(_activeFilter.value),
|
|
),
|
|
);
|
|
}
|
|
|
|
function filterByFolder(folderId, name) {
|
|
_activeFilter = { type: "folder", value: folderId };
|
|
switchView("vault");
|
|
document
|
|
.querySelectorAll(".sidebar-item")
|
|
.forEach((el) => el.classList.remove("active"));
|
|
document
|
|
.querySelector(`[data-folder-id="${folderId}"]`)
|
|
?.classList.add("active");
|
|
updateVaultTitle(name);
|
|
renderItemList(_items.filter((i) => i.folder_id === folderId));
|
|
}
|
|
|
|
function handleSearch(e) {
|
|
const q = e.target.value.trim().toLowerCase();
|
|
if (!q) {
|
|
applyCurrentFilter();
|
|
return;
|
|
}
|
|
const pool = !_activeFilter
|
|
? _items
|
|
: _activeFilter.type === "itemType"
|
|
? _items.filter((i) => i.item_type === _activeFilter.value)
|
|
: _activeFilter.type === "folder"
|
|
? _items.filter((i) => i.folder_id === _activeFilter.value)
|
|
: _activeFilter.type === "tag"
|
|
? _items.filter((i) =>
|
|
(i.plain?.tags || []).includes(_activeFilter.value),
|
|
)
|
|
: _items;
|
|
renderItemList(
|
|
pool.filter(
|
|
(item) =>
|
|
item.name.toLowerCase().includes(q) ||
|
|
(item.plain?.username || "").toLowerCase().includes(q) ||
|
|
(item.plain?.url || "").toLowerCase().includes(q) ||
|
|
(item.plain?.note_body || "").toLowerCase().includes(q) ||
|
|
(item.plain?.rp_id || "").toLowerCase().includes(q),
|
|
),
|
|
);
|
|
}
|
|
|
|
function updateVaultTitle(text) {
|
|
const el = document.getElementById("vault-title");
|
|
if (el) el.textContent = text;
|
|
}
|
|
|
|
// ── Item Modal ────────────────────────────────────────────────────────────
|
|
|
|
const TYPE_LABELS = {
|
|
password: "🔑 Password",
|
|
note: "📝 Secure Note",
|
|
card: "💳 Payment Card",
|
|
bank: "🏦 Bank Account",
|
|
address: "🏠 Address",
|
|
ssn: "🪪 Identity (SSN)",
|
|
passkey: "🔐 Passkey",
|
|
};
|
|
|
|
function switchModalType(type) {
|
|
document.querySelectorAll(".type-fields").forEach((el) => {
|
|
el.classList.toggle(
|
|
"hidden",
|
|
!el.dataset.forTypes.split(" ").includes(type),
|
|
);
|
|
});
|
|
}
|
|
|
|
function openModal(mode, item = null) {
|
|
const form = document.getElementById("item-form");
|
|
form.reset();
|
|
form.dataset.mode = mode;
|
|
form.dataset.itemId = item ? item.id : "";
|
|
|
|
// Always start Advanced Settings collapsed when the modal opens.
|
|
const advBody = document.getElementById("adv-settings-body");
|
|
const advToggle = document.getElementById("adv-settings-toggle");
|
|
if (advBody) advBody.classList.add("hidden");
|
|
if (advToggle) {
|
|
advToggle.setAttribute("aria-expanded", "false");
|
|
const chevron = advToggle.querySelector(".adv-section-chevron");
|
|
if (chevron) chevron.textContent = "\u2964";
|
|
}
|
|
|
|
const folderSel = document.getElementById("field-folder");
|
|
folderSel.innerHTML = '<option value="">— No folder —</option>';
|
|
_folders.forEach((f) => {
|
|
const opt = document.createElement("option");
|
|
opt.value = f.id;
|
|
opt.textContent = f.name;
|
|
folderSel.appendChild(opt);
|
|
});
|
|
|
|
if (mode === "add") {
|
|
document.getElementById("type-selector-group").classList.remove("hidden");
|
|
document.getElementById("modal-title").textContent = "Add Item";
|
|
switchModalType(
|
|
document.getElementById("field-type").value || "password",
|
|
);
|
|
} else {
|
|
document.getElementById("type-selector-group").classList.add("hidden");
|
|
const t = item.item_type || "password";
|
|
document.getElementById("modal-title").textContent =
|
|
`Edit ${TYPE_LABELS[t] || "Item"}`;
|
|
switchModalType(t);
|
|
form.dataset.itemType = t;
|
|
setVal("field-name", item.name);
|
|
setVal("field-folder", item.folder_id || "");
|
|
if (item.plain) {
|
|
const p = item.plain;
|
|
switch (t) {
|
|
case "password":
|
|
setVal("field-url", p.url);
|
|
setVal("field-username", p.username);
|
|
setVal("field-password", p.password);
|
|
setVal("field-totp-uri", p.totp_uri || "");
|
|
setVal("field-notes", p.notes);
|
|
setVal("field-tags", (p.tags || []).join(", "));
|
|
// Advanced Settings
|
|
document.getElementById("field-autofill").checked =
|
|
p.autofill !== false; // default true
|
|
document.getElementById("field-autologin").checked =
|
|
!!p.autologin;
|
|
document.getElementById("field-reprompt").checked =
|
|
!!p.reprompt;
|
|
break;
|
|
case "note":
|
|
setVal("field-note-body", p.note_body);
|
|
break;
|
|
case "card":
|
|
setVal("field-cardholder", p.cardholder_name);
|
|
setVal("field-card-number", p.card_number);
|
|
setVal("field-expiry-month", p.expiry_month);
|
|
setVal("field-expiry-year", p.expiry_year);
|
|
setVal("field-cvv", p.cvv);
|
|
setVal("field-notes", p.notes);
|
|
break;
|
|
case "bank":
|
|
setVal("field-bank-name", p.bank_name);
|
|
setVal("field-account-type", p.account_type);
|
|
setVal("field-routing", p.routing_number);
|
|
setVal("field-account-number", p.account_number);
|
|
setVal("field-notes", p.notes);
|
|
break;
|
|
case "address":
|
|
[
|
|
"first_name",
|
|
"last_name",
|
|
"company",
|
|
"address_line",
|
|
"city",
|
|
"state",
|
|
"zip",
|
|
"country",
|
|
"phone",
|
|
"email",
|
|
].forEach((k) => setVal("field-" + k.replace(/_/g, "-"), p[k]));
|
|
setVal("field-notes", p.notes);
|
|
break;
|
|
case "ssn":
|
|
setVal("field-ssn-number", p.ssn_number);
|
|
setVal("field-notes", p.notes);
|
|
break;
|
|
case "passkey":
|
|
setVal("field-pk-rp-id", p.rp_id);
|
|
setVal("field-pk-username", p.username);
|
|
setVal("field-pk-credential-id", p.credential_id);
|
|
setVal("field-pk-user-handle", p.user_handle);
|
|
setVal("field-pk-device-hint", p.device_hint);
|
|
setVal("field-pk-notes", p.notes);
|
|
setVal("field-tags", (p.tags || []).join(", "));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
document.getElementById("item-modal").classList.add("open");
|
|
document.getElementById("field-name").focus();
|
|
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 { enc_data, iv } = await Crypto.encryptItem(
|
|
vaultKey,
|
|
buildPlainData(itemType),
|
|
);
|
|
// Encrypt the item name client-side so it is never stored in plaintext.
|
|
const { enc_name, iv_name } = await Crypto.encryptName(vaultKey, name);
|
|
const folderVal = getVal("field-folder");
|
|
const payload = {
|
|
name: itemType, // non-sensitive server-side label — real name is in enc_name
|
|
item_type: itemType,
|
|
folder_id: folderVal ? parseInt(folderVal) : null,
|
|
enc_data,
|
|
iv,
|
|
enc_name,
|
|
iv_name,
|
|
};
|
|
|
|
if (mode === "add") {
|
|
const res = await apiFetch("/api/vault", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!res) return;
|
|
showToast("Item added");
|
|
} else {
|
|
const res = await apiFetch(`/api/vault/${itemId}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!res) return;
|
|
showToast("Item updated");
|
|
}
|
|
closeModal();
|
|
await loadVault();
|
|
} catch (err) {
|
|
showToast("Save failed: " + err.message, "error");
|
|
}
|
|
}
|
|
|
|
async function confirmDeleteItem(item) {
|
|
if (!confirm(`Delete "${item.name}"? This cannot be undone.`)) return;
|
|
try {
|
|
const res = await apiFetch(`/api/vault/${item.id}`, { method: "DELETE" });
|
|
if (!res) return;
|
|
showToast("Item deleted");
|
|
await loadVault();
|
|
} catch (err) {
|
|
showToast("Delete failed: " + err.message, "error");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Toggle the "favorite" tag on a vault item.
|
|
* Re-encrypts and PUTs the item in-place, then patches _items and
|
|
* re-renders so the star flips instantly without a full vault reload.
|
|
*/
|
|
async function toggleFavorite(item) {
|
|
const vaultKey = VaultSession.getKey();
|
|
if (!vaultKey) {
|
|
showUnlockOverlay();
|
|
return;
|
|
}
|
|
if (!item.plain) {
|
|
showToast("Item could not be decrypted", "error");
|
|
return;
|
|
}
|
|
|
|
const currentTags = item.plain.tags || [];
|
|
const isFavorite = currentTags.includes("favorite");
|
|
const newTags = isFavorite
|
|
? currentTags.filter((t) => t !== "favorite")
|
|
: [...currentTags, "favorite"].sort();
|
|
|
|
const updatedPlain = { ...item.plain, tags: newTags };
|
|
|
|
try {
|
|
const { enc_data, iv } = await Crypto.encryptItem(vaultKey, updatedPlain);
|
|
const { enc_name, iv_name } = await Crypto.encryptName(vaultKey, item.name);
|
|
|
|
const payload = {
|
|
name: item.item_type,
|
|
item_type: item.item_type,
|
|
folder_id: item.folder_id || null,
|
|
enc_data,
|
|
iv,
|
|
enc_name,
|
|
iv_name,
|
|
};
|
|
|
|
const res = await apiFetch(`/api/vault/${item.id}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!res) return;
|
|
|
|
// Patch the in-memory item so the re-render is instant.
|
|
const idx = _items.findIndex((i) => i.id === item.id);
|
|
if (idx !== -1) {
|
|
_items[idx] = { ..._items[idx], plain: updatedPlain };
|
|
}
|
|
|
|
showToast(isFavorite ? "Removed from favorites" : "Added to favorites ★");
|
|
|
|
// Refresh the sidebar tag list ("favorite" tag may appear/disappear).
|
|
renderTagList();
|
|
// Re-render the current view preserving the active filter.
|
|
applyCurrentFilter();
|
|
} catch (err) {
|
|
showToast("Could not update favorite: " + err.message, "error");
|
|
}
|
|
}
|
|
|
|
// ── Logout ────────────────────────────────────────────────────────────────
|
|
|
|
async function handleLogout() {
|
|
const refreshToken = localStorage.getItem("refresh_token");
|
|
await fetch("/api/auth/logout", {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${sessionStorage.getItem("access_token") || ""}`,
|
|
"Content-Type": "application/json",
|
|
"X-CSRFToken": csrfToken(),
|
|
},
|
|
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
}).catch(() => {});
|
|
VaultSession.clear();
|
|
SharingSession.clear();
|
|
sessionStorage.removeItem("access_token");
|
|
localStorage.removeItem("refresh_token");
|
|
window.dispatchEvent(new CustomEvent("passkeeper:logout"));
|
|
window.location.href = "/login";
|
|
}
|
|
|
|
// ── Utilities ─────────────────────────────────────────────────────────────
|
|
|
|
// ── TOTP (RFC 6238) ── pure Web Crypto, no external library ──────────────
|
|
|
|
/**
|
|
* Extract the base32 secret from either a plain base32 string or an
|
|
* otpauth://totp/... URI (both are common when users scan a QR code).
|
|
*/
|
|
function extractTotpSecret(uri) {
|
|
if (!uri) return null;
|
|
uri = uri.trim();
|
|
if (uri.startsWith("otpauth://")) {
|
|
try {
|
|
const secret = new URL(uri).searchParams.get("secret");
|
|
return secret ? secret.toUpperCase().replace(/\s+/g, "") : null;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
// Plain base32 secret (spaces and hyphens stripped for convenience).
|
|
return uri.toUpperCase().replace(/[\s-]/g, "") || null;
|
|
}
|
|
|
|
/** Decode a base32 string to a Uint8Array. */
|
|
function base32ToBytes(b32) {
|
|
const CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
b32 = b32.replace(/=+$/, "");
|
|
let bits = 0,
|
|
val = 0;
|
|
const out = [];
|
|
for (const c of b32) {
|
|
const idx = CHARS.indexOf(c);
|
|
if (idx === -1) continue;
|
|
val = (val << 5) | idx;
|
|
bits += 5;
|
|
if (bits >= 8) {
|
|
bits -= 8;
|
|
out.push((val >> bits) & 0xff);
|
|
}
|
|
}
|
|
return new Uint8Array(out);
|
|
}
|
|
|
|
/**
|
|
* Compute the current TOTP code (6 digits, 30-second window).
|
|
* Returns a Promise<string>.
|
|
*/
|
|
async function getTotpCode(secret) {
|
|
const keyBytes = base32ToBytes(secret);
|
|
if (!keyBytes.length) return null;
|
|
|
|
const counter = Math.floor(Date.now() / 1000 / 30);
|
|
const msg = new Uint8Array(8);
|
|
// Write counter as big-endian 64-bit integer.
|
|
let c = counter;
|
|
for (let i = 7; i >= 0; i--) {
|
|
msg[i] = c & 0xff;
|
|
c = Math.floor(c / 256);
|
|
}
|
|
|
|
const cryptoKey = await crypto.subtle.importKey(
|
|
"raw",
|
|
keyBytes,
|
|
{ name: "HMAC", hash: "SHA-1" },
|
|
false,
|
|
["sign"],
|
|
);
|
|
const sig = new Uint8Array(
|
|
await crypto.subtle.sign("HMAC", cryptoKey, msg),
|
|
);
|
|
const offset = sig[19] & 0x0f;
|
|
const code =
|
|
(((sig[offset] & 0x7f) << 24) |
|
|
(sig[offset + 1] << 16) |
|
|
(sig[offset + 2] << 8) |
|
|
sig[offset + 3]) %
|
|
1_000_000;
|
|
return String(code).padStart(6, "0");
|
|
}
|
|
|
|
/** Seconds remaining in the current 30-second TOTP window. */
|
|
function totpSecondsLeft() {
|
|
return 30 - (Math.floor(Date.now() / 1000) % 30);
|
|
}
|
|
|
|
// Track the clipboard clear timer so multiple rapid copies don't stack.
|
|
let _clipboardClearTimer = null;
|
|
|
|
function copyToClipboard(text, msg) {
|
|
navigator.clipboard
|
|
.writeText(text)
|
|
.then(() => {
|
|
showToast(msg);
|
|
// Auto-clear clipboard after 30 seconds — industry-standard hygiene.
|
|
if (_clipboardClearTimer) clearTimeout(_clipboardClearTimer);
|
|
_clipboardClearTimer = setTimeout(() => {
|
|
navigator.clipboard.writeText("").catch(() => {});
|
|
_clipboardClearTimer = null;
|
|
}, 30_000);
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
function escHtml(str) {
|
|
return String(str)
|
|
.replace(/&/g, "&")
|
|
.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) => `<span class="item-tag">${escHtml(t)}</span>`)
|
|
.join("");
|
|
});
|
|
|
|
// Advanced Settings collapsible toggle
|
|
document
|
|
.getElementById("adv-settings-toggle")
|
|
?.addEventListener("click", () => {
|
|
const body = document.getElementById("adv-settings-body");
|
|
const btn = document.getElementById("adv-settings-toggle");
|
|
const chevron = btn.querySelector(".adv-section-chevron");
|
|
const isOpen = !body.classList.contains("hidden");
|
|
body.classList.toggle("hidden", isOpen);
|
|
btn.setAttribute("aria-expanded", String(!isOpen));
|
|
chevron.textContent = isOpen ? "\u2964" : "\u2963";
|
|
});
|
|
|
|
// Sidebar navigation
|
|
document.getElementById("sidebar-all")?.addEventListener("click", () => {
|
|
_activeFilter = null;
|
|
switchView("vault");
|
|
document
|
|
.querySelectorAll(".sidebar-item")
|
|
.forEach((el) => el.classList.remove("active"));
|
|
document.getElementById("sidebar-all").classList.add("active");
|
|
updateVaultTitle("All Items");
|
|
renderItemList(_items);
|
|
});
|
|
document.getElementById("sidebar-favorites")?.addEventListener("click", () => {
|
|
_activeFilter = { type: "tag", value: "favorite" };
|
|
switchView("vault");
|
|
document
|
|
.querySelectorAll(".sidebar-item")
|
|
.forEach((el) => el.classList.remove("active"));
|
|
document.getElementById("sidebar-favorites").classList.add("active");
|
|
updateVaultTitle("⭐ Favorites");
|
|
renderItemList(_items.filter((i) => (i.plain?.tags || []).includes("favorite")));
|
|
});
|
|
document
|
|
.getElementById("sidebar-security")
|
|
?.addEventListener("click", () => switchView("security"));
|
|
document
|
|
.getElementById("sidebar-sharing")
|
|
?.addEventListener("click", () => switchView("sharing"));
|
|
document
|
|
.getElementById("sidebar-emergency")
|
|
?.addEventListener("click", () => switchView("emergency"));
|
|
document
|
|
.getElementById("sidebar-import-export")
|
|
?.addEventListener("click", () => switchView("import-export"));
|
|
document
|
|
.getElementById("sidebar-generator")
|
|
?.addEventListener("click", () => openPasswordGeneratorModal());
|
|
|
|
document.querySelectorAll("[data-type-filter]").forEach((el) => {
|
|
el.addEventListener("click", () => {
|
|
const type = el.dataset.typeFilter;
|
|
const labels = {
|
|
password: "Passwords",
|
|
note: "Secure Notes",
|
|
card: "Payment Cards",
|
|
bank: "Bank Accounts",
|
|
address: "Addresses",
|
|
ssn: "Identities",
|
|
passkey: "Passkeys",
|
|
};
|
|
_activeFilter = { type: "itemType", value: type };
|
|
switchView("vault");
|
|
document
|
|
.querySelectorAll(".sidebar-item")
|
|
.forEach((s) => s.classList.remove("active"));
|
|
el.classList.add("active");
|
|
updateVaultTitle(labels[type] || type);
|
|
renderItemList(_items.filter((i) => i.item_type === type));
|
|
});
|
|
});
|
|
|
|
document.getElementById("item-modal")?.addEventListener("click", (e) => {
|
|
/* backdrop click intentionally disabled — modal closes only via Cancel or Save */
|
|
});
|
|
document.addEventListener("keydown", (e) => {
|
|
if (e.key === "Escape") {
|
|
closeModal("share-modal");
|
|
closeModal("emergency-modal");
|
|
closeModal("settings-modal");
|
|
closeModal("detail-modal");
|
|
closeModal("gen-modal");
|
|
}
|
|
});
|
|
|
|
// Detail modal (shared item view)
|
|
const closeDetailModal = () => closeModal("detail-modal");
|
|
document
|
|
.getElementById("btn-close-detail-modal")
|
|
?.addEventListener("click", closeDetailModal);
|
|
document
|
|
.getElementById("btn-detail-done")
|
|
?.addEventListener("click", closeDetailModal);
|
|
document.getElementById("detail-modal")?.addEventListener("click", (e) => {
|
|
if (e.target.id === "detail-modal") closeDetailModal();
|
|
});
|
|
|
|
document
|
|
.getElementById("field-card-number")
|
|
?.addEventListener("input", (e) => {
|
|
let v = e.target.value.replace(/\D/g, "").slice(0, 16);
|
|
e.target.value = v.replace(/(.{4})/g, "$1 ").trim();
|
|
});
|
|
|
|
// Settings modal
|
|
document
|
|
.getElementById("btn-settings")
|
|
?.addEventListener("click", openSettingsModal);
|
|
document
|
|
.getElementById("btn-close-settings")
|
|
?.addEventListener("click", () => closeModal("settings-modal"));
|
|
|
|
// Web-app session timeout setting.
|
|
document
|
|
.getElementById("web-idle-select")
|
|
?.addEventListener("change", (e) => {
|
|
const mins = parseInt(e.target.value, 10);
|
|
localStorage.setItem(WEB_IDLE_KEY, mins);
|
|
_resetWebIdleTimer();
|
|
const label = mins === 0 ? "never" : mins + " min";
|
|
showToast("Auto-lock set to " + label + ".");
|
|
console.log("[PassKeeper] Web idle timeout set to", label);
|
|
});
|
|
document
|
|
.getElementById("settings-modal")
|
|
?.addEventListener("click", (e) => {
|
|
if (e.target.id === "settings-modal") closeModal("settings-modal");
|
|
});
|
|
document
|
|
.getElementById("btn-mfa-confirm")
|
|
?.addEventListener("click", handleMfaConfirm);
|
|
document
|
|
.getElementById("btn-mfa-cancel-setup")
|
|
?.addEventListener("click", () => {
|
|
document.getElementById("mfa-setup-area").classList.add("hidden");
|
|
loadMfaStatus();
|
|
});
|
|
document
|
|
.getElementById("btn-mfa-disable-confirm")
|
|
?.addEventListener("click", handleMfaDisableConfirm);
|
|
document
|
|
.getElementById("btn-mfa-disable-cancel")
|
|
?.addEventListener("click", () => {
|
|
document.getElementById("mfa-disable-area").classList.add("hidden");
|
|
loadMfaStatus();
|
|
});
|
|
document
|
|
.getElementById("btn-change-password")
|
|
?.addEventListener("click", handleChangePassword);
|
|
document
|
|
.getElementById("btn-delete-account")
|
|
?.addEventListener("click", () => {
|
|
document.getElementById("btn-delete-account").classList.add("hidden");
|
|
document
|
|
.getElementById("delete-confirm-area")
|
|
.classList.remove("hidden");
|
|
document.getElementById("delete-password").focus();
|
|
});
|
|
document
|
|
.getElementById("btn-delete-cancel")
|
|
?.addEventListener("click", () => {
|
|
document.getElementById("delete-confirm-area").classList.add("hidden");
|
|
document
|
|
.getElementById("btn-delete-account")
|
|
.classList.remove("hidden");
|
|
document.getElementById("delete-password").value = "";
|
|
document.getElementById("delete-error").classList.add("hidden");
|
|
});
|
|
document
|
|
.getElementById("btn-delete-confirm")
|
|
?.addEventListener("click", handleDeleteAccount);
|
|
document
|
|
.getElementById("btn-copy-recovery-code")
|
|
?.addEventListener("click", () => {
|
|
const code = document.getElementById("recovery-code-value").textContent;
|
|
navigator.clipboard
|
|
.writeText(code)
|
|
.then(() => showToast("Recovery code copied"));
|
|
});
|
|
document
|
|
.getElementById("btn-recovery-done")
|
|
?.addEventListener("click", () => {
|
|
document
|
|
.getElementById("recovery-code-display")
|
|
.classList.add("hidden");
|
|
loadRecoveryStatus();
|
|
});
|
|
|
|
// Share modal
|
|
document
|
|
.getElementById("btn-share-item")
|
|
?.addEventListener("click", openShareModal);
|
|
document
|
|
.getElementById("btn-close-share-modal")
|
|
?.addEventListener("click", () => closeModal("share-modal"));
|
|
document
|
|
.getElementById("btn-cancel-share")
|
|
?.addEventListener("click", () => closeModal("share-modal"));
|
|
document.getElementById("share-modal")?.addEventListener("click", (e) => {
|
|
if (e.target.id === "share-modal") closeModal("share-modal");
|
|
});
|
|
document
|
|
.getElementById("share-form")
|
|
?.addEventListener("submit", handleShareFormSubmit);
|
|
document
|
|
.getElementById("btn-setup-keys-from-sharing")
|
|
?.addEventListener("click", () => {
|
|
closeModal("share-modal");
|
|
openSettingsModal();
|
|
});
|
|
|
|
// Emergency modal
|
|
document
|
|
.getElementById("btn-add-emergency")
|
|
?.addEventListener("click", () => {
|
|
document.getElementById("emergency-error").classList.add("hidden");
|
|
document.getElementById("emergency-modal").classList.add("open");
|
|
});
|
|
document
|
|
.getElementById("btn-close-emergency-modal")
|
|
?.addEventListener("click", () => closeModal("emergency-modal"));
|
|
document
|
|
.getElementById("btn-cancel-emergency")
|
|
?.addEventListener("click", () => closeModal("emergency-modal"));
|
|
document
|
|
.getElementById("emergency-modal")
|
|
?.addEventListener("click", (e) => {
|
|
if (e.target.id === "emergency-modal") closeModal("emergency-modal");
|
|
});
|
|
document
|
|
.getElementById("emergency-form")
|
|
?.addEventListener("submit", handleEmergencyFormSubmit);
|
|
|
|
// Tabs
|
|
document.querySelectorAll(".panel-tabs").forEach((c) => initTabs(c));
|
|
|
|
// Sidebar collapse toggle — desktop only (mobile uses hamburger instead)
|
|
const sidebar = document.getElementById("sidebar");
|
|
const toggleBtn = document.getElementById("btn-sidebar-toggle");
|
|
if (
|
|
sidebar &&
|
|
toggleBtn &&
|
|
document.body.classList.contains("is-desktop")
|
|
) {
|
|
const STORAGE_KEY = "sidebar_collapsed";
|
|
if (localStorage.getItem(STORAGE_KEY) === "1") {
|
|
sidebar.classList.add("collapsed");
|
|
toggleBtn.setAttribute("aria-label", "Expand sidebar");
|
|
toggleBtn.setAttribute("title", "Expand sidebar");
|
|
}
|
|
toggleBtn.addEventListener("click", () => {
|
|
const isCollapsed = sidebar.classList.toggle("collapsed");
|
|
localStorage.setItem(STORAGE_KEY, isCollapsed ? "1" : "0");
|
|
toggleBtn.setAttribute(
|
|
"aria-label",
|
|
isCollapsed ? "Expand sidebar" : "Collapse sidebar",
|
|
);
|
|
toggleBtn.setAttribute(
|
|
"title",
|
|
isCollapsed ? "Expand sidebar" : "Collapse sidebar",
|
|
);
|
|
});
|
|
}
|
|
|
|
// Mobile sidebar: hamburger opens overlay, backdrop/item-click closes it
|
|
const backdrop = document.getElementById("sidebar-backdrop");
|
|
const mobileMenuBtn = document.getElementById("btn-mobile-menu");
|
|
|
|
function openMobileSidebar() {
|
|
sidebar?.classList.add("mobile-open");
|
|
backdrop?.classList.add("open");
|
|
document.documentElement.classList.add("sidebar-open");
|
|
}
|
|
function closeMobileSidebar() {
|
|
sidebar?.classList.remove("mobile-open");
|
|
backdrop?.classList.remove("open");
|
|
document.documentElement.classList.remove("sidebar-open");
|
|
}
|
|
|
|
mobileMenuBtn?.addEventListener("click", openMobileSidebar);
|
|
backdrop?.addEventListener("click", closeMobileSidebar);
|
|
backdrop?.addEventListener("touchstart", closeMobileSidebar, {
|
|
passive: true,
|
|
});
|
|
|
|
// Close mobile sidebar when a nav item is tapped (mobile only)
|
|
document.querySelectorAll(".sidebar-item").forEach((item) => {
|
|
item.addEventListener("click", () => {
|
|
if (document.body.classList.contains("is-mobile")) closeMobileSidebar();
|
|
});
|
|
});
|
|
|
|
// Escape key closes the sidebar
|
|
document.addEventListener("keydown", (e) => {
|
|
if (e.key === "Escape" && sidebar?.classList.contains("mobile-open"))
|
|
closeMobileSidebar();
|
|
});
|
|
|
|
// Mobile FAB mirrors the desktop add-item button
|
|
document
|
|
.getElementById("btn-add-item-mobile")
|
|
?.addEventListener("click", () => {
|
|
document.getElementById("btn-add-item")?.click();
|
|
});
|
|
|
|
// ── Browser history / back-button support ─────────────────────────────────
|
|
const _hashToView = (hash) => {
|
|
const v = (hash || "").replace("#", "").trim();
|
|
const valid = [
|
|
"vault",
|
|
"security",
|
|
"sharing",
|
|
"emergency",
|
|
"import-export",
|
|
];
|
|
return valid.includes(v) ? v : "vault";
|
|
};
|
|
const _initialView = _hashToView(window.location.hash);
|
|
history.replaceState({ view: _initialView }, "", window.location.href);
|
|
window.addEventListener("popstate", (e) => {
|
|
const view = e.state?.view || _hashToView(window.location.hash);
|
|
switchView(view, { pushState: false });
|
|
});
|
|
|
|
// Start web-app inactivity tracking.
|
|
_startWebIdleTracking();
|
|
|
|
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); |