+ : `
${escHtml(val)}`;
+ return `
${escHtml(label)}
${valHtml}
`;
- }).join('') || '
No fields to display.
';
+ })
+ .join("") || '
No fields to display.
';
- container.querySelectorAll('.btn-reveal').forEach(btn => {
- btn.addEventListener('click', () => {
+ container.querySelectorAll(".btn-reveal").forEach((btn) => {
+ btn.addEventListener("click", () => {
const span = btn.previousElementSibling;
- const revealed = span.dataset.revealed === 'true';
+ 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);
+ 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!'));
+ 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');
+ const errEl = document.getElementById("share-error");
+ errEl.classList.add("hidden");
if (!SharingSession.isReady()) {
- showToast('Set up sharing keys first (Settings)', 'error');
+ 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();
+ 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');
+ 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)}`);
+ 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);
+ 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 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 { enc_data, iv } = await SharingCrypto.encryptForShare(
+ sharedKey,
+ item.plain,
+ );
- const res = await apiFetch('/api/sharing', {
- method: 'POST',
+ const res = await apiFetch("/api/sharing", {
+ method: "POST",
body: JSON.stringify({
item_id: itemId,
recipient_email: recipientEmail,
- enc_data, iv,
+ enc_data,
+ iv,
item_name: item.name,
item_type: item.item_type,
}),
});
if (!res) return;
- closeModal('share-modal');
- showToast('Item shared successfully');
+ closeModal("share-modal");
+ showToast("Item shared successfully");
loadSharedOut();
} catch (err) {
errEl.textContent = err.message;
- errEl.classList.remove('hidden');
+ errEl.classList.remove("hidden");
}
}
function openShareModal() {
- const sel = document.getElementById('share-item-select');
- sel.innerHTML = _items.map(i =>
- `
`
- ).join('');
- document.getElementById('share-recipient-email').value = '';
- document.getElementById('share-error').classList.add('hidden');
- document.getElementById('share-modal').classList.add('open');
+ const sel = document.getElementById("share-item-select");
+ sel.innerHTML = _items
+ .map(
+ (i) =>
+ `
`,
+ )
+ .join("");
+ document.getElementById("share-recipient-email").value = "";
+ document.getElementById("share-error").classList.add("hidden");
+ document.getElementById("share-modal").classList.add("open");
}
// ── Emergency Access View ─────────────────────────────────────────────────
async function loadEmergencyView() {
try {
- const res = await apiFetch('/api/emergency');
+ 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');
+ showToast(
+ "Could not load emergency access data: " + err.message,
+ "error",
+ );
}
}
function renderEmergencyGrants(grants) {
- const ul = document.getElementById('em-grants-list');
+ const ul = document.getElementById("em-grants-list");
if (!ul) return;
if (!grants.length) {
- ul.innerHTML = '
No trusted contacts set up yet.';
+ ul.innerHTML =
+ '
No trusted contacts set up yet.';
return;
}
- ul.innerHTML = grants.map(g => {
- let actions = '';
- if (g.status === 'accepted') {
- actions = `
`;
- }
- if (g.status === 'pending') {
- const waitInfo = g.wait_elapsed ? 'Wait period elapsed' : 'Access requested';
- actions = `
${waitInfo}
+ ul.innerHTML = grants
+ .map((g) => {
+ let actions = "";
+ if (g.status === "accepted") {
+ actions = `
`;
+ }
+ if (g.status === "pending") {
+ const waitInfo = g.wait_elapsed
+ ? "Wait period elapsed"
+ : "Access requested";
+ actions = `
${waitInfo}
`;
- }
- return `
+ }
+ return `
🚨
${escHtml(g.grantee_email)}
@@ -662,136 +950,193 @@ const Vault = (() => {
`;
- }).join('');
+ })
+ .join("");
- ul.querySelectorAll('[data-provide]').forEach(btn => {
- btn.addEventListener('click', () => handleProvideEmergencyData(parseInt(btn.dataset.provide)));
+ 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;
+ 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');
+ await apiFetch(`/api/emergency/${btn.dataset.deny}/deny`, {
+ method: "POST",
+ });
+ showToast("Access request denied");
loadEmergencyView();
- } catch (err) { showToast(err.message, 'error'); }
+ } 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;
+ 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');
+ await apiFetch(`/api/emergency/${btn.dataset.removeEa}`, {
+ method: "DELETE",
+ });
+ showToast("Emergency access removed");
loadEmergencyView();
- } catch (err) { showToast(err.message, 'error'); }
+ } catch (err) {
+ showToast(err.message, "error");
+ }
});
});
}
async function handleProvideEmergencyData(eaId) {
if (!SharingSession.isReady()) {
- showToast('Set up sharing keys first (Settings → Sharing Keys)', 'error');
+ 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;
+ 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 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; }
+ 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)}`);
+ 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);
+ 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 };
- })
+ _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',
+ method: "POST",
body: JSON.stringify({ enc_vault: JSON.stringify(encItems) }),
});
- showToast('Recovery data provided');
+ showToast("Recovery data provided");
loadEmergencyView();
} catch (err) {
- showToast('Failed to provide recovery data: ' + err.message, 'error');
+ showToast("Failed to provide recovery data: " + err.message, "error");
}
}
function renderEmergencyAccess(access) {
- const ul = document.getElementById('em-access-list');
+ const ul = document.getElementById("em-access-list");
if (!ul) return;
if (!access.length) {
- ul.innerHTML = '
No one has designated you as an emergency contact.';
+ ul.innerHTML =
+ '
No one has designated you as an emergency contact.';
return;
}
- ul.innerHTML = access.map(a => {
- let actions = '';
- if (a.status === 'invited') {
- actions = `
`;
- } else if (a.status === 'ready') {
- actions = `
`;
- } else if (a.status === 'pending') {
- let daysLeft = a.wait_days;
- if (a.request_initiated_at) {
- const elapsed = (Date.now() - new Date(a.request_initiated_at).getTime()) / 86400000;
- daysLeft = Math.max(0, Math.ceil(a.wait_days - elapsed));
+ ul.innerHTML = access
+ .map((a) => {
+ let actions = "";
+ if (a.status === "invited") {
+ actions = `
`;
+ } else if (a.status === "ready") {
+ actions = `
`;
+ } else if (a.status === "pending") {
+ let daysLeft = a.wait_days;
+ if (a.request_initiated_at) {
+ const elapsed =
+ (Date.now() - new Date(a.request_initiated_at).getTime()) /
+ 86400000;
+ daysLeft = Math.max(0, Math.ceil(a.wait_days - elapsed));
+ }
+ actions = a.wait_elapsed
+ ? `
`
+ : `
Waiting (~${daysLeft} day${daysLeft !== 1 ? "s" : ""} left)`;
}
- actions = a.wait_elapsed
- ? `
`
- : `
Waiting (~${daysLeft} day${daysLeft !== 1 ? 's' : ''} left)`;
- }
- return `
+ return `
🔐
- ${escHtml(a.grantor_email || 'Unknown')}
+ ${escHtml(a.grantor_email || "Unknown")}
Status: ${escHtml(a.status)} · Wait: ${a.wait_days} day(s)
${actions}
`;
- }).join('');
+ })
+ .join("");
- ul.querySelectorAll('[data-accept-ea]').forEach(btn => {
- btn.addEventListener('click', async () => {
+ 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');
+ await apiFetch(`/api/emergency/${btn.dataset.acceptEa}/accept`, {
+ method: "POST",
+ });
+ showToast("Emergency access accepted");
loadEmergencyView();
- } catch (err) { showToast(err.message, 'error'); }
+ } 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;
+ 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');
+ 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'); }
+ } catch (err) {
+ showToast(err.message, "error");
+ }
});
});
- ul.querySelectorAll('[data-get-vault]').forEach(btn => {
- btn.addEventListener('click', () => handleGetEmergencyVault(parseInt(btn.dataset.getVault)));
+ 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');
+ showToast("Set up sharing keys first (Settings → Sharing Keys)", "error");
return;
}
try {
@@ -799,43 +1144,57 @@ const 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 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 };
- }
- }));
+ 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');
+ 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');
+ 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');
+ list.classList.add("hidden");
+ panel.classList.remove("hidden");
if (!items.length) {
- vaultList.innerHTML = '
No items in emergency vault.';
+ vaultList.innerHTML =
+ '
No items in emergency vault.';
return;
}
- vaultList.innerHTML = items.map((item, idx) => {
- const icon = itemIcon(item.item_type);
- const hasFields = item.plain && Object.values(item.plain).some(v => v);
- return `
+ vaultList.innerHTML = items
+ .map((item, idx) => {
+ const icon = itemIcon(item.item_type);
+ const hasFields =
+ item.plain && Object.values(item.plain).some((v) => v);
+ return `
${hasFields
- ? '
'
- : '
Could not decrypt this item.
'}
+ ? '
'
+ : '
Could not decrypt this item.
'
+ }
`;
- }).join('');
+ })
+ .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');
+ 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);
+ 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');
- });
+ 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: '!@#$%^&*()-_=+[]{}|;:,.<>?',
+ upper: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
+ lower: "abcdefghijklmnopqrstuvwxyz",
+ digits: "0123456789",
+ symbols: "!@#$%^&*()-_=+[]{}|;:,.<>?",
};
- function generatePassword(length = 20, opts = { upper: true, lower: true, digits: true, symbols: true }) {
- let pool = '';
+ 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 (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);
@@ -904,11 +1284,11 @@ const Vault = (() => {
const j = shuffle[i] % (i + 1);
[chars[i], chars[j]] = [chars[j], chars[i]];
}
- return chars.slice(0, length).join('');
+ return chars.slice(0, length).join("");
}
function scorePassword(pw) {
- if (!pw) return { score: 0, label: '', cls: '' };
+ if (!pw) return { score: 0, label: "", cls: "" };
let score = 0;
if (pw.length >= 8) score++;
if (pw.length >= 12) score++;
@@ -918,60 +1298,69 @@ const Vault = (() => {
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'];
+ 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');
+ 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);
+ 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', () => {
+ 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 : '');
+ const bar = document.getElementById("field-password-strength");
+ bar.textContent = pwInput.value ? label : "";
+ bar.className = "password-strength " + (pwInput.value ? cls : "");
});
// Generate button — add only once
- if (!document.getElementById('btn-generate-pass')) {
- const btn = document.createElement('button');
- btn.type = 'button';
- btn.id = 'btn-generate-pass';
- btn.className = 'btn-secondary btn-sm';
- btn.textContent = '⚡ Generate';
- btn.style.marginTop = '6px';
- pwInput.closest('.form-group').appendChild(btn);
+ if (!document.getElementById("btn-generate-pass")) {
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.id = "btn-generate-pass";
+ btn.className = "btn-secondary btn-sm";
+ btn.textContent = "⚡ Generate";
+ btn.style.marginTop = "6px";
+ pwInput.closest(".form-group").appendChild(btn);
}
- document.getElementById('btn-generate-pass').onclick = () => {
+ 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
+ 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');
+ 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 = document.createElement("div");
+ modal.id = "gen-modal";
+ modal.className = "modal-overlay";
+ modal.setAttribute("role", "dialog");
+ modal.setAttribute("aria-modal", "true");
modal.innerHTML = `
`;
document.body.appendChild(modal);
- const lengthSlider = document.getElementById('gen-length');
- const lengthDisplay = document.getElementById('gen-length-display');
- const output = document.getElementById('gen-output');
- const strengthEl = document.getElementById('gen-strength');
+ 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,
+ 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;
+ strengthEl.className = "password-strength " + cls;
}
- lengthSlider.addEventListener('input', () => {
+ lengthSlider.addEventListener("input", () => {
lengthDisplay.textContent = lengthSlider.value;
refreshGen();
});
- ['gen-upper', 'gen-lower', 'gen-digits', 'gen-symbols'].forEach(id => {
- document.getElementById(id).addEventListener('change', 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'));
+ 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();
});
- 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');
+ 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 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,
+ 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;
+ document.getElementById("gen-output").value = pw;
const { label, cls } = scorePassword(pw);
- const strengthEl = document.getElementById('gen-strength');
+ const strengthEl = document.getElementById("gen-strength");
strengthEl.textContent = label;
- strengthEl.className = 'password-strength ' + cls;
+ strengthEl.className = "password-strength " + cls;
}
// ── Account Settings ──────────────────────────────────────────────────────
@@ -1061,139 +1461,165 @@ const Vault = (() => {
let _pendingMfaSecret = null;
async function openSettingsModal() {
- document.getElementById('settings-modal').classList.add('open');
+ document.getElementById("settings-modal").classList.add("open");
// Reset change password fields
- ['cp-current', 'cp-new', 'cp-confirm'].forEach(id => {
- const el = document.getElementById(id); if (el) el.value = '';
+ ["cp-current", "cp-new", "cp-confirm"].forEach((id) => {
+ const el = document.getElementById(id);
+ if (el) el.value = "";
});
- document.getElementById('cp-error')?.classList.add('hidden');
+ 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');
+ 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()]);
+ document.getElementById("recovery-code-display")?.classList.add("hidden");
+ await Promise.all([
+ loadMfaStatus(),
+ loadSharingKeysStatus(),
+ loadRecoveryStatus(),
+ ]);
}
async function loadMfaStatus() {
try {
- const res = await apiFetch('/api/auth/mfa/status');
+ const res = await apiFetch("/api/auth/mfa/status");
if (!res) return;
const data = await res.json();
renderMfaStatus(data.totp_enabled);
} catch (err) {
- console.error('loadMfaStatus:', err);
+ console.error("loadMfaStatus:", err);
}
}
function renderMfaStatus(enabled) {
- const statusText = document.getElementById('mfa-status-text');
- const actions = document.getElementById('mfa-actions');
- document.getElementById('mfa-setup-area').classList.add('hidden');
- document.getElementById('mfa-disable-area').classList.add('hidden');
+ const statusText = document.getElementById("mfa-status-text");
+ const actions = document.getElementById("mfa-actions");
+ document.getElementById("mfa-setup-area").classList.add("hidden");
+ document.getElementById("mfa-disable-area").classList.add("hidden");
if (enabled) {
- statusText.textContent = '✅ Two-factor authentication is enabled.';
- actions.innerHTML = '';
- 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();
- });
+ statusText.textContent = "✅ Two-factor authentication is enabled.";
+ actions.innerHTML =
+ '';
+ document
+ .getElementById("btn-mfa-disable")
+ ?.addEventListener("click", () => {
+ document
+ .getElementById("mfa-disable-area")
+ .classList.remove("hidden");
+ document.getElementById("mfa-actions").innerHTML = "";
+ document.getElementById("mfa-disable-code").focus();
+ });
} else {
- statusText.textContent = 'Two-factor authentication is not enabled.';
- actions.innerHTML = '';
- document.getElementById('btn-mfa-setup')?.addEventListener('click', handleMfaSetup);
+ statusText.textContent = "Two-factor authentication is not enabled.";
+ actions.innerHTML =
+ '';
+ document
+ .getElementById("btn-mfa-setup")
+ ?.addEventListener("click", handleMfaSetup);
}
}
async function handleMfaSetup() {
try {
- const res = await apiFetch('/api/auth/mfa/setup');
+ 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();
+ 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');
+ showToast("Could not start MFA setup: " + err.message, "error");
}
}
async function handleMfaConfirm() {
- const code = document.getElementById('mfa-verify-code').value.trim();
+ const code = document.getElementById("mfa-verify-code").value.trim();
if (!code || !_pendingMfaSecret) return;
try {
- const res = await apiFetch('/api/auth/mfa/enable', {
- method: 'POST',
+ const res = await apiFetch("/api/auth/mfa/enable", {
+ method: "POST",
body: JSON.stringify({ secret: _pendingMfaSecret, totp_code: code }),
});
if (!res) return;
_pendingMfaSecret = null;
- showToast('MFA enabled successfully');
+ showToast("MFA enabled successfully");
renderMfaStatus(true);
} catch (err) {
- showToast('Failed to enable MFA: ' + err.message, 'error');
+ showToast("Failed to enable MFA: " + err.message, "error");
}
}
async function handleMfaDisableConfirm() {
- const code = document.getElementById('mfa-disable-code').value.trim();
+ const code = document.getElementById("mfa-disable-code").value.trim();
if (!code) return;
try {
- const res = await apiFetch('/api/auth/mfa/disable', {
- method: 'POST',
+ const res = await apiFetch("/api/auth/mfa/disable", {
+ method: "POST",
body: JSON.stringify({ totp_code: code }),
});
if (!res) return;
- showToast('MFA disabled');
+ showToast("MFA disabled");
renderMfaStatus(false);
} catch (err) {
- showToast('Failed to disable MFA: ' + err.message, 'error');
+ showToast("Failed to disable MFA: " + err.message, "error");
}
}
async function loadSharingKeysStatus() {
try {
- const res = await apiFetch('/api/sharing/keys');
+ 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');
+ const statusEl = document.getElementById("sharing-keys-status");
+ const actionsEl = document.getElementById("sharing-keys-actions");
if (data.keys_setup) {
- statusEl.textContent = '✅ Sharing keys are set up. You can share items and use emergency access.';
- actionsEl.innerHTML = '';
- document.getElementById('btn-regen-keys')?.addEventListener('click', handleGenerateKeys);
+ statusEl.textContent =
+ "✅ Sharing keys are set up. You can share items and use emergency access.";
+ actionsEl.innerHTML =
+ '';
+ document
+ .getElementById("btn-regen-keys")
+ ?.addEventListener("click", handleGenerateKeys);
} else {
- statusEl.textContent = 'No sharing keys set up. Generate keys to enable item sharing and emergency access.';
- actionsEl.innerHTML = '';
- document.getElementById('btn-gen-keys')?.addEventListener('click', handleGenerateKeys);
+ statusEl.textContent =
+ "No sharing keys set up. Generate keys to enable item sharing and emergency access.";
+ actionsEl.innerHTML =
+ '';
+ document
+ .getElementById("btn-gen-keys")
+ ?.addEventListener("click", handleGenerateKeys);
}
} catch (err) {
- console.error('loadSharingKeysStatus:', err);
+ console.error("loadSharingKeysStatus:", err);
}
}
async function handleGenerateKeys() {
const vaultKey = VaultSession.getKey();
- if (!vaultKey) { showToast('Vault is locked. Unlock first.', 'error'); return; }
+ 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 { private_key_enc, private_key_iv } =
+ await SharingCrypto.encryptPrivateKey(vaultKey, keyPair.privateKey);
- const res = await apiFetch('/api/sharing/keys', {
- method: 'POST',
+ const res = await apiFetch("/api/sharing/keys", {
+ method: "POST",
body: JSON.stringify({ public_key, private_key_enc, private_key_iv }),
});
if (!res) return;
@@ -1201,58 +1627,62 @@ const Vault = (() => {
// Cache private key in session
SharingSession.setKey(keyPair.privateKey);
- showToast('Sharing keys generated');
+ showToast("Sharing keys generated");
await loadSharingKeysStatus();
} catch (err) {
- showToast('Key generation failed: ' + err.message, 'error');
+ showToast("Key generation failed: " + err.message, "error");
}
}
// ── Change Password ───────────────────────────────────────────────────────
async function handleChangePassword() {
- const cpError = document.getElementById('cp-error');
- cpError.classList.add('hidden');
+ 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;
+ 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');
+ 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');
+ 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');
+ 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');
+ cpError.textContent =
+ "New password must be different from the current password.";
+ cpError.classList.remove("hidden");
return;
}
- const btn = document.getElementById('btn-change-password');
+ const btn = document.getElementById("btn-change-password");
const originalText = btn.textContent;
btn.disabled = true;
- btn.textContent = 'Re-encrypting vault…';
+ btn.textContent = "Re-encrypting vault…";
try {
const vaultKey = VaultSession.getKey();
- if (!vaultKey) { showToast('Vault is locked. Reload and unlock first.', 'error'); return; }
+ 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');
+ 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');
+ const profileRes = await apiFetch("/api/auth/me");
if (!profileRes) return;
const profile = await profileRes.json();
const email = profile.email;
@@ -1264,7 +1694,7 @@ const Vault = (() => {
const newVaultKey = await Crypto.deriveVaultKey(newPass, newEncKeySalt);
// Fetch all vault items and re-encrypt
- const itemsRes = await apiFetch('/api/vault');
+ const itemsRes = await apiFetch("/api/vault");
if (!itemsRes) return;
const items = await itemsRes.json();
@@ -1272,14 +1702,18 @@ const Vault = (() => {
const reEncrypted = [];
for (const item of items) {
- const plain = await Crypto.decryptItem(vaultKey, item.enc_data, item.iv);
+ const plain = await Crypto.decryptItem(
+ vaultKey,
+ item.enc_data,
+ item.iv,
+ );
const { enc_data, iv } = await Crypto.encryptItem(newVaultKey, plain);
reEncrypted.push({ id: item.id, enc_data, iv });
}
// Submit atomic password change
- const res = await apiFetch('/api/auth/change-password', {
- method: 'POST',
+ const res = await apiFetch("/api/auth/change-password", {
+ method: "POST",
body: JSON.stringify({
current_auth_hash: currentAuthHash,
new_auth_hash: newAuthHash,
@@ -1290,16 +1724,16 @@ const Vault = (() => {
if (!res) return;
const data = await res.json();
if (!res.ok) {
- cpError.textContent = data.error || 'Password change failed.';
- cpError.classList.remove('hidden');
+ cpError.textContent = data.error || "Password change failed.";
+ cpError.classList.remove("hidden");
return;
}
- showToast('Password changed. Please log in again.');
+ showToast("Password changed. Please log in again.");
setTimeout(() => redirectToLogin(), 1500);
} catch (err) {
- cpError.textContent = 'An error occurred: ' + err.message;
- cpError.classList.remove('hidden');
+ cpError.textContent = "An error occurred: " + err.message;
+ cpError.classList.remove("hidden");
console.error(err);
} finally {
btn.disabled = false;
@@ -1311,70 +1745,86 @@ const Vault = (() => {
async function loadRecoveryStatus() {
try {
- const res = await apiFetch('/api/auth/recovery/status');
+ 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');
+ 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 = '';
+ statusEl.textContent =
+ "✅ A recovery code is configured for your account.";
+ actionsEl.innerHTML =
+ '';
} else {
- statusEl.textContent = 'No recovery code set up. If you forget your master password, your vault cannot be recovered.';
- actionsEl.innerHTML = '';
+ statusEl.textContent =
+ "No recovery code set up. If you forget your master password, your vault cannot be recovered.";
+ actionsEl.innerHTML =
+ '';
}
- document.getElementById('btn-gen-recovery')?.addEventListener('click', handleSetupRecovery);
- document.getElementById('btn-regen-recovery')?.addEventListener('click', handleSetupRecovery);
+ document
+ .getElementById("btn-gen-recovery")
+ ?.addEventListener("click", handleSetupRecovery);
+ document
+ .getElementById("btn-regen-recovery")
+ ?.addEventListener("click", handleSetupRecovery);
} catch (err) {
- console.error('loadRecoveryStatus:', err);
+ console.error("loadRecoveryStatus:", err);
}
}
async function handleSetupRecovery() {
const vaultKey = VaultSession.getKey();
- if (!vaultKey) { showToast('Vault is locked. Unlock first.', 'error'); return; }
+ 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; }
+ 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('');
+ 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',
+ "raw",
new TextEncoder().encode(recoveryCode),
- 'PBKDF2',
+ "PBKDF2",
false,
- ['deriveKey']
+ ["deriveKey"],
);
const recoveryKey = await window.crypto.subtle.deriveKey(
{
- name: 'PBKDF2',
- salt: new TextEncoder().encode('passkeeper-recovery'),
+ name: "PBKDF2",
+ salt: new TextEncoder().encode("passkeeper-recovery"),
iterations: 200_000,
- hash: 'SHA-256',
+ hash: "SHA-256",
},
recoveryKeyMaterial,
- { name: 'AES-GCM', length: 256 },
+ { name: "AES-GCM", length: 256 },
false,
- ['encrypt']
+ ["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 },
+ { name: "AES-GCM", iv },
recoveryKey,
- new TextEncoder().encode(encKeySalt)
+ new TextEncoder().encode(encKeySalt),
);
function bytesToBase64(bytes) {
- let bin = '';
- new Uint8Array(bytes).forEach(b => (bin += String.fromCharCode(b)));
+ let bin = "";
+ new Uint8Array(bytes).forEach((b) => (bin += String.fromCharCode(b)));
return btoa(bin);
}
@@ -1382,21 +1832,23 @@ const Vault = (() => {
const recovery_iv = bytesToBase64(iv);
// Store on server
- const res = await apiFetch('/api/auth/recovery/setup', {
- method: 'POST',
+ 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.';
-
+ 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');
+ showToast("Recovery code generation failed: " + err.message, "error");
console.error(err);
}
}
@@ -1404,45 +1856,45 @@ const Vault = (() => {
// ── Delete Account ────────────────────────────────────────────────────────
async function handleDeleteAccount() {
- const deleteError = document.getElementById('delete-error');
- deleteError.classList.add('hidden');
- const password = document.getElementById('delete-password').value;
+ 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');
+ deleteError.textContent = "Please enter your master password.";
+ deleteError.classList.remove("hidden");
return;
}
- const btn = document.getElementById('btn-delete-confirm');
+ const btn = document.getElementById("btn-delete-confirm");
const originalText = btn.textContent;
btn.disabled = true;
- btn.textContent = 'Deleting…';
+ btn.textContent = "Deleting…";
try {
- const profileRes = await apiFetch('/api/auth/me');
+ 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',
+ 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');
+ 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';
+ window.location.href = "/login";
} catch (err) {
- deleteError.textContent = 'An error occurred: ' + err.message;
- deleteError.classList.remove('hidden');
+ deleteError.textContent = "An error occurred: " + err.message;
+ deleteError.classList.remove("hidden");
console.error(err);
} finally {
btn.disabled = false;
@@ -1453,155 +1905,291 @@ const Vault = (() => {
// ── Folder CRUD ───────────────────────────────────────────────────────────
function showNewFolderRow() {
- document.getElementById('new-folder-row').classList.remove('hidden');
- document.getElementById('new-folder-input').focus();
+ 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 = '';
+ 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();
+ 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 }) });
+ const res = await apiFetch("/api/folders", {
+ method: "POST",
+ body: JSON.stringify({ name }),
+ });
if (!res) return;
hideNewFolderRow();
await loadFolders();
- showToast('Folder created');
+ showToast("Folder created");
} catch (err) {
- showToast('Could not create folder: ' + err.message, 'error');
+ 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;
+ 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' });
+ 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) {
+ _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');
+ updateVaultTitle("All Items");
}
await loadFolders();
applyCurrentFilter();
- showToast('Folder deleted');
+ showToast("Folder deleted");
} catch (err) {
- showToast('Could not delete folder: ' + err.message, 'error');
+ 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));
+ if (!_activeFilter) {
+ renderItemList(_items);
+ return;
+ }
+ if (_activeFilter.type === "itemType")
+ renderItemList(_items.filter((i) => i.item_type === _activeFilter.value));
+ else if (_activeFilter.type === "folder")
+ renderItemList(_items.filter((i) => i.folder_id === _activeFilter.value));
}
function filterByFolder(folderId, name) {
- _activeFilter = { type: 'folder', value: folderId };
- switchView('vault');
- document.querySelectorAll('.sidebar-item').forEach(el => el.classList.remove('active'));
- document.querySelector(`[data-folder-id="${folderId}"]`)?.classList.add('active');
+ _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));
+ renderItemList(_items.filter((i) => i.folder_id === folderId));
}
function handleSearch(e) {
const q = e.target.value.trim().toLowerCase();
- if (!q) { applyCurrentFilter(); return; }
- const pool = _activeFilter ? (_activeFilter.type === 'itemType' ? _items.filter(i => i.item_type === _activeFilter.value) : _items.filter(i => i.folder_id === _activeFilter.value)) : _items;
- renderItemList(pool.filter(item =>
- item.name.toLowerCase().includes(q) ||
- (item.plain?.username || '').toLowerCase().includes(q) ||
- (item.plain?.url || '').toLowerCase().includes(q) ||
- (item.plain?.note_body || '').toLowerCase().includes(q)
- ));
+ if (!q) {
+ applyCurrentFilter();
+ return;
+ }
+ const pool = _activeFilter
+ ? _activeFilter.type === "itemType"
+ ? _items.filter((i) => i.item_type === _activeFilter.value)
+ : _items.filter((i) => i.folder_id === _activeFilter.value)
+ : _items;
+ renderItemList(
+ pool.filter(
+ (item) =>
+ item.name.toLowerCase().includes(q) ||
+ (item.plain?.username || "").toLowerCase().includes(q) ||
+ (item.plain?.url || "").toLowerCase().includes(q) ||
+ (item.plain?.note_body || "").toLowerCase().includes(q),
+ ),
+ );
}
function updateVaultTitle(text) {
- const el = document.getElementById('vault-title');
+ 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)' };
+ const TYPE_LABELS = {
+ password: "🔑 Password",
+ note: "📝 Secure Note",
+ card: "💳 Payment Card",
+ bank: "🏦 Bank Account",
+ address: "🏠 Address",
+ ssn: "🪪 Identity (SSN)",
+ };
function switchModalType(type) {
- document.querySelectorAll('.type-fields').forEach(el => {
- el.classList.toggle('hidden', !el.dataset.forTypes.split(' ').includes(type));
+ 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');
+ const form = document.getElementById("item-form");
form.reset();
form.dataset.mode = mode;
- form.dataset.itemId = item ? item.id : '';
+ form.dataset.itemId = item ? item.id : "";
- const folderSel = document.getElementById('field-folder');
+ const folderSel = document.getElementById("field-folder");
folderSel.innerHTML = '';
- _folders.forEach(f => {
- const opt = document.createElement('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');
+ 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'}`;
+ 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 || '');
+ setVal("field-name", item.name);
+ setVal("field-folder", item.folder_id || "");
if (item.plain) {
const p = item.plain;
switch (t) {
- case 'password': setVal('field-url', p.url); setVal('field-username', p.username); setVal('field-password', p.password); setVal('field-notes', p.notes); break;
- case 'note': setVal('field-note-body', p.note_body); break;
- case 'card': setVal('field-cardholder', p.cardholder_name); setVal('field-card-number', p.card_number); setVal('field-expiry-month', p.expiry_month); setVal('field-expiry-year', p.expiry_year); setVal('field-cvv', p.cvv); setVal('field-notes', p.notes); break;
- case 'bank': setVal('field-bank-name', p.bank_name); setVal('field-account-type', p.account_type); setVal('field-routing', p.routing_number); setVal('field-account-number', p.account_number); setVal('field-notes', p.notes); break;
- case 'address': ['first_name', 'last_name', 'company', 'address_line', 'city', 'state', 'zip', 'country', 'phone', 'email'].forEach(k => setVal('field-' + k.replace(/_/g, '-'), p[k])); setVal('field-notes', p.notes); break;
- case 'ssn': setVal('field-ssn-number', p.ssn_number); setVal('field-notes', p.notes); break;
+ 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);
+ break;
+ case "note":
+ setVal("field-note-body", p.note_body);
+ break;
+ case "card":
+ setVal("field-cardholder", p.cardholder_name);
+ setVal("field-card-number", p.card_number);
+ setVal("field-expiry-month", p.expiry_month);
+ setVal("field-expiry-year", p.expiry_year);
+ setVal("field-cvv", p.cvv);
+ setVal("field-notes", p.notes);
+ break;
+ case "bank":
+ setVal("field-bank-name", p.bank_name);
+ setVal("field-account-type", p.account_type);
+ setVal("field-routing", p.routing_number);
+ setVal("field-account-number", p.account_number);
+ setVal("field-notes", p.notes);
+ break;
+ case "address":
+ [
+ "first_name",
+ "last_name",
+ "company",
+ "address_line",
+ "city",
+ "state",
+ "zip",
+ "country",
+ "phone",
+ "email",
+ ].forEach((k) => setVal("field-" + k.replace(/_/g, "-"), p[k]));
+ setVal("field-notes", p.notes);
+ break;
+ case "ssn":
+ setVal("field-ssn-number", p.ssn_number);
+ setVal("field-notes", p.notes);
+ break;
}
}
}
- document.getElementById('item-modal').classList.add('open');
- document.getElementById('field-name').focus();
- if ((mode === 'add' && (document.getElementById('field-type').value || 'password') === 'password') ||
- (mode === 'edit' && (item?.item_type || 'password') === 'password')) {
+ 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 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 closeModal(id = "item-modal") {
+ document.getElementById(id)?.classList.remove("open");
}
function buildPlainData(t) {
switch (t) {
- case 'password': return { url: getVal('field-url').trim(), username: getVal('field-username').trim(), password: getVal('field-password'), notes: getVal('field-notes').trim() };
- case 'note': return { note_body: getVal('field-note-body') };
- case 'card': return { cardholder_name: getVal('field-cardholder').trim(), card_number: getVal('field-card-number').trim(), expiry_month: getVal('field-expiry-month'), expiry_year: getVal('field-expiry-year').trim(), cvv: getVal('field-cvv').trim(), notes: getVal('field-notes').trim() };
- case 'bank': return { bank_name: getVal('field-bank-name').trim(), account_type: getVal('field-account-type'), routing_number: getVal('field-routing').trim(), account_number: getVal('field-account-number').trim(), notes: getVal('field-notes').trim() };
- case 'address': return { first_name: getVal('field-first-name').trim(), last_name: getVal('field-last-name').trim(), company: getVal('field-company').trim(), address_line: getVal('field-address-line').trim(), city: getVal('field-city').trim(), state: getVal('field-state').trim(), zip: getVal('field-zip').trim(), country: getVal('field-country').trim(), phone: getVal('field-phone').trim(), email: getVal('field-email').trim(), notes: getVal('field-notes').trim() };
- case 'ssn': return { ssn_number: getVal('field-ssn-number'), notes: getVal('field-notes').trim() };
- default: return {};
+ 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(),
+ };
+ case "note":
+ return { note_body: getVal("field-note-body") };
+ case "card":
+ return {
+ cardholder_name: getVal("field-cardholder").trim(),
+ card_number: getVal("field-card-number").trim(),
+ expiry_month: getVal("field-expiry-month"),
+ expiry_year: getVal("field-expiry-year").trim(),
+ cvv: getVal("field-cvv").trim(),
+ notes: getVal("field-notes").trim(),
+ };
+ case "bank":
+ return {
+ bank_name: getVal("field-bank-name").trim(),
+ account_type: getVal("field-account-type"),
+ routing_number: getVal("field-routing").trim(),
+ account_number: getVal("field-account-number").trim(),
+ notes: getVal("field-notes").trim(),
+ };
+ case "address":
+ return {
+ first_name: getVal("field-first-name").trim(),
+ last_name: getVal("field-last-name").trim(),
+ company: getVal("field-company").trim(),
+ address_line: getVal("field-address-line").trim(),
+ city: getVal("field-city").trim(),
+ state: getVal("field-state").trim(),
+ zip: getVal("field-zip").trim(),
+ country: getVal("field-country").trim(),
+ phone: getVal("field-phone").trim(),
+ email: getVal("field-email").trim(),
+ notes: getVal("field-notes").trim(),
+ };
+ case "ssn":
+ return {
+ ssn_number: getVal("field-ssn-number"),
+ notes: getVal("field-notes").trim(),
+ };
+ default:
+ return {};
}
}
@@ -1609,362 +2197,673 @@ const Vault = (() => {
e.preventDefault();
try {
const vaultKey = VaultSession.getKey();
- if (!vaultKey) { closeModal(); showUnlockOverlay(); return; }
+ 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 name = getVal("field-name").trim();
+ if (!name) {
+ showToast("Name is required", "error");
+ return;
+ }
- const itemType = mode === 'add' ? getVal('field-type') : (form.dataset.itemType || 'password');
- const { enc_data, iv } = await Crypto.encryptItem(vaultKey, buildPlainData(itemType));
- const folderVal = getVal('field-folder');
- const payload = { name, item_type: itemType, folder_id: folderVal ? parseInt(folderVal) : null, enc_data, iv };
+ const itemType =
+ mode === "add"
+ ? getVal("field-type")
+ : form.dataset.itemType || "password";
+ const { enc_data, iv } = await Crypto.encryptItem(
+ vaultKey,
+ buildPlainData(itemType),
+ );
+ const folderVal = getVal("field-folder");
+ const payload = {
+ name,
+ item_type: itemType,
+ folder_id: folderVal ? parseInt(folderVal) : null,
+ enc_data,
+ iv,
+ };
- if (mode === 'add') {
- const res = await apiFetch('/api/vault', { method: 'POST', body: JSON.stringify(payload) });
+ if (mode === "add") {
+ const res = await apiFetch("/api/vault", {
+ method: "POST",
+ body: JSON.stringify(payload),
+ });
if (!res) return;
- showToast('Item added');
+ showToast("Item added");
} else {
- const res = await apiFetch(`/api/vault/${itemId}`, { method: 'PUT', body: JSON.stringify(payload) });
+ const res = await apiFetch(`/api/vault/${itemId}`, {
+ method: "PUT",
+ body: JSON.stringify(payload),
+ });
if (!res) return;
- showToast('Item updated');
+ showToast("Item updated");
}
closeModal();
await loadVault();
} catch (err) {
- showToast('Save failed: ' + err.message, 'error');
+ 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' });
+ const res = await apiFetch(`/api/vault/${item.id}`, { method: "DELETE" });
if (!res) return;
- showToast('Item deleted');
+ showToast("Item deleted");
await loadVault();
} catch (err) {
- showToast('Delete failed: ' + err.message, 'error');
+ showToast("Delete failed: " + err.message, "error");
}
}
// ── Logout ────────────────────────────────────────────────────────────────
async function handleLogout() {
- const refreshToken = localStorage.getItem('refresh_token');
- await fetch('/api/auth/logout', {
- method: 'POST',
- headers: { 'Authorization': `Bearer ${sessionStorage.getItem('access_token') || ''}`, 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken() },
+ 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';
+ sessionStorage.removeItem("access_token");
+ localStorage.removeItem("refresh_token");
+ window.dispatchEvent(new CustomEvent("passkeeper:logout"));
+ window.location.href = "/login";
}
// ── Utilities ─────────────────────────────────────────────────────────────
- function copyToClipboard(text, msg = 'Copied') {
- navigator.clipboard.writeText(text).then(() => showToast(msg)).catch(() => { });
+ // ── TOTP (RFC 6238) ── pure Web Crypto, no external library ──────────────
+
+ /**
+ * Extract the base32 secret from either a plain base32 string or an
+ * otpauth://totp/... URI (both are common when users scan a QR code).
+ */
+ function extractTotpSecret(uri) {
+ if (!uri) return null;
+ uri = uri.trim();
+ if (uri.startsWith("otpauth://")) {
+ try {
+ const secret = new URL(uri).searchParams.get("secret");
+ return secret ? secret.toUpperCase().replace(/\s+/g, "") : null;
+ } catch (e) { return null; }
+ }
+ // Plain base32 secret (spaces and hyphens stripped for convenience).
+ return uri.toUpperCase().replace(/[\s-]/g, "") || null;
}
+ /** Decode a base32 string to a Uint8Array. */
+ function base32ToBytes(b32) {
+ const CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
+ b32 = b32.replace(/=+$/, "");
+ let bits = 0, val = 0;
+ const out = [];
+ for (const c of b32) {
+ const idx = CHARS.indexOf(c);
+ if (idx === -1) continue;
+ val = (val << 5) | idx;
+ bits += 5;
+ if (bits >= 8) { bits -= 8; out.push((val >> bits) & 0xff); }
+ }
+ return new Uint8Array(out);
+ }
+
+ /**
+ * Compute the current TOTP code (6 digits, 30-second window).
+ * Returns a Promise.
+ */
+ async function getTotpCode(secret) {
+ const keyBytes = base32ToBytes(secret);
+ if (!keyBytes.length) return null;
+
+ const counter = Math.floor(Date.now() / 1000 / 30);
+ const msg = new Uint8Array(8);
+ // Write counter as big-endian 64-bit integer.
+ let c = counter;
+ for (let i = 7; i >= 0; i--) { msg[i] = c & 0xff; c = Math.floor(c / 256); }
+
+ const cryptoKey = await crypto.subtle.importKey(
+ "raw", keyBytes, { name: "HMAC", hash: "SHA-1" }, false, ["sign"]
+ );
+ const sig = new Uint8Array(await crypto.subtle.sign("HMAC", cryptoKey, msg));
+ const offset = sig[19] & 0x0f;
+ const code = ((sig[offset] & 0x7f) << 24 | sig[offset + 1] << 16 |
+ sig[offset + 2] << 8 | sig[offset + 3]) % 1_000_000;
+ return String(code).padStart(6, "0");
+ }
+
+ /** Seconds remaining in the current 30-second TOTP window. */
+ function totpSecondsLeft() {
+ return 30 - (Math.floor(Date.now() / 1000) % 30);
+ }
+
+
+ navigator.clipboard
+ .writeText(text)
+ .then(() => showToast(msg))
+ .catch(() => { });
+}
+
function escHtml(str) {
- return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+ return String(str)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
+function itemIcon(type) {
+ return (
+ {
+ password: "🔑",
+ note: "📝",
+ card: "💳",
+ bank: "🏦",
+ address: "🏠",
+ ssn: "🪪",
+ }[type] || "🔑"
+ );
+}
+
+function showToast(message, type = "success") {
+ const toast = document.getElementById("toast");
+ if (!toast) return;
+ toast.textContent = message;
+ toast.className = `toast toast-${type} show`;
+ clearTimeout(toast._timer);
+ toast._timer = setTimeout(() => toast.classList.remove("show"), 2500);
+}
+
+function showLoadingState(loading) {
+ document
+ .getElementById("vault-spinner")
+ ?.classList.toggle("hidden", !loading);
+}
+
+// ── Unlock overlay ────────────────────────────────────────────────────────
+
+function showUnlockOverlay() {
+ document.getElementById("unlock-overlay").classList.add("open");
+ document.getElementById("unlock-password").focus();
+}
+
+async function handleUnlock(e) {
+ e.preventDefault();
+ const password = document.getElementById("unlock-password").value;
+ const enc_key_salt = sessionStorage.getItem("enc_key_salt");
+ const btn = e.target.querySelector('[type="submit"]');
+ const errEl = document.getElementById("unlock-error");
+ if (!enc_key_salt) {
+ redirectToLogin();
+ return;
}
- function itemIcon(type) {
- return { password: '🔑', note: '📝', card: '💳', bank: '🏦', address: '🏠', ssn: '🪪' }[type] || '🔑';
+ 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 = "";
}
+}
- 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);
- }
+// ── Tabs ──────────────────────────────────────────────────────────────────
- 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));
- });
+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 ──────────────────────────────────────────────────────────────────
+// ── 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 });
- });
+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; }
+ // 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);
+ // 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("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-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'; });
+ document.getElementById("btn-toggle-ssn")?.addEventListener("click", () => {
+ const el = document.getElementById("field-ssn-number");
+ el.type = el.type === "password" ? "text" : "password";
+ });
- // Sidebar navigation
- document.getElementById('sidebar-all')?.addEventListener('click', () => {
- _activeFilter = null; switchView('vault');
- document.querySelectorAll('.sidebar-item').forEach(el => el.classList.remove('active'));
- document.getElementById('sidebar-all').classList.add('active');
- updateVaultTitle('All Items'); renderItemList(_items);
+ // Sidebar navigation
+ document.getElementById("sidebar-all")?.addEventListener("click", () => {
+ _activeFilter = null;
+ switchView("vault");
+ document
+ .querySelectorAll(".sidebar-item")
+ .forEach((el) => el.classList.remove("active"));
+ document.getElementById("sidebar-all").classList.add("active");
+ updateVaultTitle("All Items");
+ renderItemList(_items);
+ });
+ document
+ .getElementById("sidebar-security")
+ ?.addEventListener("click", () => switchView("security"));
+ document
+ .getElementById("sidebar-sharing")
+ ?.addEventListener("click", () => switchView("sharing"));
+ document
+ .getElementById("sidebar-emergency")
+ ?.addEventListener("click", () => switchView("emergency"));
+ document
+ .getElementById("sidebar-generator")
+ ?.addEventListener("click", () => openPasswordGeneratorModal());
+
+ document.querySelectorAll("[data-type-filter]").forEach((el) => {
+ el.addEventListener("click", () => {
+ const type = el.dataset.typeFilter;
+ const labels = {
+ password: "Passwords",
+ note: "Secure Notes",
+ card: "Payment Cards",
+ bank: "Bank Accounts",
+ address: "Addresses",
+ ssn: "Identities",
+ };
+ _activeFilter = { type: "itemType", value: type };
+ switchView("vault");
+ document
+ .querySelectorAll(".sidebar-item")
+ .forEach((s) => s.classList.remove("active"));
+ el.classList.add("active");
+ updateVaultTitle(labels[type] || type);
+ renderItemList(_items.filter((i) => i.item_type === type));
});
- document.getElementById('sidebar-security')?.addEventListener('click', () => switchView('security'));
- document.getElementById('sidebar-sharing')?.addEventListener('click', () => switchView('sharing'));
- document.getElementById('sidebar-emergency')?.addEventListener('click', () => switchView('emergency'));
- document.getElementById('sidebar-generator')?.addEventListener('click', () => openPasswordGeneratorModal());
+ });
- document.querySelectorAll('[data-type-filter]').forEach(el => {
- el.addEventListener('click', () => {
- const type = el.dataset.typeFilter;
- const labels = { password: 'Passwords', note: 'Secure Notes', card: 'Payment Cards', bank: 'Bank Accounts', address: 'Addresses', ssn: 'Identities' };
- _activeFilter = { type: 'itemType', value: type };
- switchView('vault');
- document.querySelectorAll('.sidebar-item').forEach(s => s.classList.remove('active'));
- el.classList.add('active');
- updateVaultTitle(labels[type] || type);
- renderItemList(_items.filter(i => i.item_type === type));
- });
+ document.getElementById("item-modal")?.addEventListener("click", (e) => {
+ /* backdrop click intentionally disabled — modal closes only via Cancel or Save */
+ });
+ document.addEventListener("keydown", (e) => {
+ if (e.key === "Escape") {
+ closeModal("share-modal");
+ closeModal("emergency-modal");
+ closeModal("settings-modal");
+ closeModal("detail-modal");
+ closeModal("gen-modal");
+ }
+ });
+
+ // Detail modal (shared item view)
+ const closeDetailModal = () => closeModal("detail-modal");
+ document
+ .getElementById("btn-close-detail-modal")
+ ?.addEventListener("click", closeDetailModal);
+ document
+ .getElementById("btn-detail-done")
+ ?.addEventListener("click", closeDetailModal);
+ document.getElementById("detail-modal")?.addEventListener("click", (e) => {
+ if (e.target.id === "detail-modal") closeDetailModal();
+ });
+
+ document
+ .getElementById("field-card-number")
+ ?.addEventListener("input", (e) => {
+ let v = e.target.value.replace(/\D/g, "").slice(0, 16);
+ e.target.value = v.replace(/(.{4})/g, "$1 ").trim();
});
- document.getElementById('item-modal')?.addEventListener('click', e => { /* backdrop click intentionally disabled — modal closes only via Cancel or Save */ });
- document.addEventListener('keydown', e => { if (e.key === 'Escape') { closeModal('share-modal'); closeModal('emergency-modal'); closeModal('settings-modal'); closeModal('detail-modal'); closeModal('gen-modal'); } });
-
- // Detail modal (shared item view)
- const closeDetailModal = () => closeModal('detail-modal');
- document.getElementById('btn-close-detail-modal')?.addEventListener('click', closeDetailModal);
- document.getElementById('btn-detail-done')?.addEventListener('click', closeDetailModal);
- document.getElementById('detail-modal')?.addEventListener('click', e => { if (e.target.id === 'detail-modal') closeDetailModal(); });
-
- document.getElementById('field-card-number')?.addEventListener('input', e => {
- let v = e.target.value.replace(/\D/g, '').slice(0, 16);
- e.target.value = v.replace(/(.{4})/g, '$1 ').trim();
+ // Settings modal
+ document
+ .getElementById("btn-settings")
+ ?.addEventListener("click", openSettingsModal);
+ document
+ .getElementById("btn-close-settings")
+ ?.addEventListener("click", () => closeModal("settings-modal"));
+ document
+ .getElementById("settings-modal")
+ ?.addEventListener("click", (e) => {
+ if (e.target.id === "settings-modal") closeModal("settings-modal");
});
-
- // Settings modal
- document.getElementById('btn-settings')?.addEventListener('click', openSettingsModal);
- document.getElementById('btn-close-settings')?.addEventListener('click', () => closeModal('settings-modal'));
- document.getElementById('settings-modal')?.addEventListener('click', e => { if (e.target.id === 'settings-modal') closeModal('settings-modal'); });
- document.getElementById('btn-mfa-confirm')?.addEventListener('click', handleMfaConfirm);
- document.getElementById('btn-mfa-cancel-setup')?.addEventListener('click', () => { document.getElementById('mfa-setup-area').classList.add('hidden'); loadMfaStatus(); });
- document.getElementById('btn-mfa-disable-confirm')?.addEventListener('click', handleMfaDisableConfirm);
- document.getElementById('btn-mfa-disable-cancel')?.addEventListener('click', () => { document.getElementById('mfa-disable-area').classList.add('hidden'); loadMfaStatus(); });
- document.getElementById('btn-change-password')?.addEventListener('click', handleChangePassword);
- document.getElementById('btn-delete-account')?.addEventListener('click', () => {
- document.getElementById('btn-delete-account').classList.add('hidden');
- document.getElementById('delete-confirm-area').classList.remove('hidden');
- document.getElementById('delete-password').focus();
+ document
+ .getElementById("btn-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-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-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-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-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-recovery-done')?.addEventListener('click', () => {
- document.getElementById('recovery-code-display').classList.add('hidden');
+ 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');
+ // 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();
});
- 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));
+ // 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);
- // Sidebar collapse toggle (desktop) and mobile hamburger
- const sidebar = document.getElementById('sidebar');
- const toggleBtn = document.getElementById('btn-sidebar-toggle');
- if (sidebar && toggleBtn) {
- 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');
- });
+ // 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");
}
-
- // 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.style.overflow = 'hidden';
- }
- function closeMobileSidebar() {
- sidebar?.classList.remove('mobile-open');
- backdrop?.classList.remove('open');
- document.documentElement.style.overflow = '';
- }
-
- 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)
- const mobileQuery = window.matchMedia('(max-width: 768px)');
- document.querySelectorAll('.sidebar-item').forEach(item => {
- item.addEventListener('click', () => {
- if (mobileQuery.matches) closeMobileSidebar();
- });
+ 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",
+ );
});
-
- // 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();
- });
-
- if (!VaultSession.getKey()) { showUnlockOverlay(); } else { loadVault(); }
}
- async function handleEmergencyFormSubmit(e) {
- e.preventDefault();
- const errEl = document.getElementById('emergency-error');
- errEl.classList.add('hidden');
- const email = document.getElementById('emergency-email').value.trim().toLowerCase();
- const waitDays = document.getElementById('emergency-wait-days').value;
- if (!email) { errEl.textContent = 'Email is required.'; errEl.classList.remove('hidden'); return; }
- try {
- const res = await apiFetch('/api/emergency', {
- method: 'POST',
- body: JSON.stringify({ grantee_email: email, wait_days: parseInt(waitDays) }),
- });
- if (!res) return;
- closeModal('emergency-modal');
- showToast('Invitation sent');
- loadEmergencyView();
- } catch (err) {
- errEl.textContent = err.message;
- errEl.classList.remove('hidden');
- }
+ // 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.style.overflow = "hidden";
+ }
+ function closeMobileSidebar() {
+ sidebar?.classList.remove("mobile-open");
+ backdrop?.classList.remove("open");
+ document.documentElement.style.overflow = "";
}
- return { init };
-})();
+ 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();
+ });
+
+ if (!VaultSession.getKey()) {
+ showUnlockOverlay();
+ } else {
+ loadVault();
+ }
+}
+
+async function handleEmergencyFormSubmit(e) {
+ e.preventDefault();
+ const errEl = document.getElementById("emergency-error");
+ errEl.classList.add("hidden");
+ const email = document
+ .getElementById("emergency-email")
+ .value.trim()
+ .toLowerCase();
+ const waitDays = document.getElementById("emergency-wait-days").value;
+ if (!email) {
+ errEl.textContent = "Email is required.";
+ errEl.classList.remove("hidden");
+ return;
+ }
+ try {
+ const res = await apiFetch("/api/emergency", {
+ method: "POST",
+ body: JSON.stringify({
+ grantee_email: email,
+ wait_days: parseInt(waitDays),
+ }),
+ });
+ if (!res) return;
+ closeModal("emergency-modal");
+ showToast("Invitation sent");
+ loadEmergencyView();
+ } catch (err) {
+ errEl.textContent = err.message;
+ errEl.classList.remove("hidden");
+ }
+}
+
+return { init };
+}) ();
/**
- * layout-init.js sets html.is-mobile / html.is-desktop synchronously in .
- * Here we only need to handle the case where viewport changes after load
- * (orientation change, browser resize, split-screen).
+ * 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 watchLayout() {
- var mq = window.matchMedia('(max-width: 768px)');
- mq.addEventListener('change', function (e) {
- document.documentElement.classList.toggle('is-mobile', e.matches);
- document.documentElement.classList.toggle('is-desktop', !e.matches);
+(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) {
- // Switched to desktop — close mobile sidebar if open
- document.getElementById('sidebar')?.classList.remove('mobile-open');
- document.getElementById('sidebar-backdrop')?.classList.remove('open');
- document.documentElement.style.overflow = '';
+ document.getElementById("sidebar")?.classList.remove("mobile-open");
+ document.getElementById("sidebar-backdrop")?.classList.remove("open");
+ document.documentElement.style.overflow = "";
}
});
})();
@@ -1978,14 +2877,19 @@ const Vault = (() => {
*/
(function setViewportHeight() {
function update() {
- document.documentElement.style.setProperty('--vh', `${window.innerHeight * 0.01}px`);
+ 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 });
+ 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);
\ No newline at end of file
+document.addEventListener("DOMContentLoaded", Vault.init);
\ No newline at end of file
diff --git a/app/templates/vault/index.html b/app/templates/vault/index.html
index 3cdd539..24496b6 100644
--- a/app/templates/vault/index.html
+++ b/app/templates/vault/index.html
@@ -7,11 +7,7 @@
☰
🔒 PassKeeper
-
@@ -24,109 +20,56 @@