/** * vault.js — Vault UI: all views (vault, security, sharing, emergency, settings) * * All sensitive data is decrypted client-side. The server only ever stores * encrypted blobs (zero-knowledge architecture). */ const Vault = (() => { let _items = []; let _folders = []; let _currentView = 'vault'; let _activeFilter = null; let _sortOrder = 'name-asc'; const FIELD_LABELS = { url: 'Website URL', username: 'Username', password: 'Password', notes: 'Notes', note_body: 'Note', cardholder_name: 'Cardholder Name', card_number: 'Card Number', expiry_month: 'Expiry Month', expiry_year: 'Expiry Year', cvv: 'CVV', bank_name: 'Bank Name', account_type: 'Account Type', routing_number: 'Routing Number', account_number: 'Account Number', first_name: 'First Name', last_name: 'Last Name', company: 'Company', address_line: 'Street Address', city: 'City', state: 'State', zip: 'ZIP', country: 'Country', phone: 'Phone', email: 'Email', ssn_number: 'Social Security Number', }; const SENSITIVE_FIELDS = new Set(['password', 'cvv', 'card_number', 'account_number', 'routing_number', 'ssn_number']); // ── API helpers ─────────────────────────────────────────────────────────── async function apiFetch(path, options = {}) { let token = sessionStorage.getItem('access_token'); if (!token) { redirectToLogin(); return null; } const defaults = { headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, 'X-CSRFToken': csrfToken(), }, }; const merged = { ...defaults, ...options }; merged.headers = { ...defaults.headers, ...(options.headers || {}) }; let res = await fetch(path, merged); if (res.status === 401) { const refreshed = await tryRefreshToken(); if (!refreshed) { redirectToLogin(); return null; } merged.headers['Authorization'] = `Bearer ${sessionStorage.getItem('access_token')}`; res = await fetch(path, merged); } if (!res.ok && res.status !== 404) { const err = await res.json().catch(() => ({})); throw new Error(err.error || `HTTP ${res.status}`); } return res; } let _refreshPromise = null; async function tryRefreshToken() { // Singleton: if a refresh is already in flight, reuse it so concurrent 401s // don't each fire a separate rotation (which would blacklist each other's tokens). if (_refreshPromise) return _refreshPromise; _refreshPromise = (async () => { const refreshToken = localStorage.getItem('refresh_token'); if (!refreshToken) return false; try { const res = await fetch('/api/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken() }, body: JSON.stringify({ refresh_token: refreshToken }), }); if (!res.ok) return false; const data = await res.json(); sessionStorage.setItem('access_token', data.access_token); if (data.refresh_token) localStorage.setItem('refresh_token', data.refresh_token); return true; } catch { return false; } })().finally(() => { _refreshPromise = null; }); return _refreshPromise; } function csrfToken() { return document.querySelector('meta[name="csrf-token"]')?.content || ''; } function redirectToLogin() { VaultSession.clear(); SharingSession.clear(); sessionStorage.removeItem('access_token'); window.dispatchEvent(new CustomEvent('passkeeper:logout')); window.location.href = '/login'; } // ── Load ────────────────────────────────────────────────────────────────── async function loadVault() { const vaultKey = VaultSession.getKey(); if (!vaultKey) { showUnlockOverlay(); return; } showLoadingState(true); try { const [itemsRes, foldersRes] = await Promise.all([ apiFetch('/api/vault'), apiFetch('/api/folders'), ]); if (!itemsRes || !foldersRes) { showUnlockOverlay(); return; } const rawItems = await itemsRes.json(); _folders = await foldersRes.json(); _items = await Promise.all(rawItems.map(async item => { try { const plain = await Crypto.decryptItem(vaultKey, item.enc_data, item.iv); return { ...item, plain }; } catch { return { ...item, plain: null, decryptError: true }; } })); renderFolderList(); applyCurrentFilter(); } catch (err) { showToast('Failed to load vault: ' + err.message, 'error'); } finally { showLoadingState(false); } } async function loadFolders() { try { const res = await apiFetch('/api/folders'); if (!res) return; _folders = await res.json(); renderFolderList(); } catch (err) { showToast('Could not refresh folders: ' + err.message, 'error'); } } // ── View switching ──────────────────────────────────────────────────────── function switchView(view) { _currentView = view; ['vault', 'security', 'sharing', 'emergency'].forEach(v => { document.getElementById(`view-${v}`)?.classList.toggle('hidden', v !== view); }); document.querySelectorAll('.sidebar-item[data-view]').forEach(el => { el.classList.toggle('active', el.dataset.view === view); }); if (view !== 'vault') { document.querySelectorAll('.sidebar-item[data-type-filter], .sidebar-item[data-folder-id]') .forEach(el => el.classList.remove('active')); } if (view === 'security') renderSecurityDashboard(); if (view === 'sharing') loadSharingView(); if (view === 'emergency') loadEmergencyView(); } // ── Vault render ────────────────────────────────────────────────────────── function renderFolderList() { const ul = document.getElementById('sidebar-folders'); if (!ul) return; ul.innerHTML = ''; _folders.forEach(folder => { const li = document.createElement('li'); li.className = 'sidebar-item folder-item'; li.dataset.folderId = folder.id; const label = document.createElement('span'); label.className = 'folder-label'; label.innerHTML = `📁 ${escHtml(folder.name)}`; label.addEventListener('click', () => filterByFolder(folder.id, folder.name)); const delBtn = document.createElement('button'); delBtn.className = 'btn-delete-folder'; delBtn.title = 'Delete folder'; delBtn.textContent = '🗑'; delBtn.addEventListener('click', e => { e.stopPropagation(); confirmDeleteFolder(folder); }); li.appendChild(label); li.appendChild(delBtn); ul.appendChild(li); }); } function getSortedItems(items) { const sorted = [...items]; switch (_sortOrder) { case 'name-asc': sorted.sort((a, b) => a.name.localeCompare(b.name)); break; case 'name-desc': sorted.sort((a, b) => b.name.localeCompare(a.name)); break; case 'date-desc': sorted.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); break; case 'date-asc': sorted.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); break; case 'folder': sorted.sort((a, b) => folderName(a.folder_id).localeCompare(folderName(b.folder_id)) || a.name.localeCompare(b.name)); break; } return sorted; } function folderName(id) { if (!id) return '(No folder)'; return _folders.find(f => f.id === id)?.name || 'Unknown'; } function renderItemList(items) { const list = document.getElementById('vault-list'); if (!list) return; list.innerHTML = ''; if (items.length === 0) { list.innerHTML = '
  • No items found. Click + to add one.
  • '; return; } const sorted = getSortedItems(items); const groups = {}; sorted.forEach(item => { const key = folderName(item.folder_id); if (!groups[key]) groups[key] = []; groups[key].push(item); }); const keys = _sortOrder === 'folder' ? Object.keys(groups).sort() : ['(No folder)', ..._folders.map(f => f.name)].filter(k => groups[k]); Object.keys(groups).forEach(k => { if (!keys.includes(k)) keys.push(k); }); keys.forEach(groupName => { if (!groups[groupName]) return; const header = document.createElement('li'); header.className = 'vault-group-header'; header.textContent = groupName; list.appendChild(header); groups[groupName].forEach(item => list.appendChild(createItemElement(item))); }); } function createItemElement(item) { const li = document.createElement('li'); li.className = 'vault-item'; li.dataset.id = item.id; const iconMap = { password: '🔑', note: '📝', card: '💳', bank: '🏦', address: '🏠', ssn: '🪪', passkey: '🔐' }; const icon = iconMap[item.item_type] || '🔑'; let subText = ''; if (item.plain) { switch (item.item_type) { case 'password': subText = item.plain.username || item.plain.url || ''; break; case 'note': subText = (item.plain.note_body || '').slice(0, 60); break; case 'card': subText = item.plain.card_number ? '•••• ' + String(item.plain.card_number).replace(/\s/g, '').slice(-4) : ''; break; case 'bank': subText = item.plain.bank_name || ''; break; case 'address': subText = [item.plain.first_name, item.plain.last_name].filter(Boolean).join(' '); break; case 'ssn': subText = '•••-••-••••'; break; } } const showCopyUser = item.item_type === 'password'; const showCopyPass = ['password', 'card', 'bank', 'ssn'].includes(item.item_type); const showLaunch = item.item_type === 'password' && item.plain?.url; li.innerHTML = `
    ${icon}
    ${escHtml(item.name)} ${escHtml(subText)}
    ${showLaunch ? `` : ''} ${showCopyUser ? `` : ''} ${showCopyPass ? `` : ''}
    `; if (showLaunch) { li.querySelector('[data-action="launch"]').addEventListener('click', e => { e.stopPropagation(); let url = item.plain?.url || ''; if (url && !/^https?:\/\//i.test(url)) url = 'https://' + url; window.open(url, '_blank', 'noopener,noreferrer'); }); } if (showCopyUser) { li.querySelector('[data-action="copy-user"]').addEventListener('click', e => { e.stopPropagation(); copyToClipboard(item.plain?.username || '', 'Username copied'); }); } if (showCopyPass) { li.querySelector('[data-action="copy-pass"]').addEventListener('click', e => { e.stopPropagation(); const s = item.item_type === 'password' ? item.plain?.password : item.item_type === 'card' ? item.plain?.cvv : item.item_type === 'bank' ? item.plain?.account_number : item.plain?.ssn_number; copyToClipboard(s || '', 'Copied to clipboard'); }); } li.querySelector('[data-action="edit"]').addEventListener('click', e => { e.stopPropagation(); openModal('edit', item); }); li.querySelector('[data-action="delete"]').addEventListener('click', e => { e.stopPropagation(); confirmDeleteItem(item); }); li.addEventListener('click', () => openModal('edit', item)); return li; } // ── Security Dashboard ──────────────────────────────────────────────────── function renderSecurityDashboard() { const summaryEl = document.getElementById('security-summary'); const sectionsEl = document.getElementById('security-sections'); if (!summaryEl || !sectionsEl) return; // If vault hasn't loaded yet, wait for it then re-render if (!_items.length && VaultSession.getKey()) { summaryEl.innerHTML = '
    Loading…
    '; sectionsEl.innerHTML = ''; loadVault().then(() => renderSecurityDashboard()); return; } const pwItems = _items.filter(i => i.item_type === 'password' && i.plain?.password); if (!pwItems.length) { summaryEl.innerHTML = '
    No passwords to analyze.
    '; sectionsEl.innerHTML = ''; return; } const weak = pwItems.filter(i => { const p = i.plain.password; if (p.length < 10) return true; return [/[A-Z]/, /[a-z]/, /[0-9]/, /[^A-Za-z0-9]/].filter(r => r.test(p)).length < 2; }); const passCounts = {}; pwItems.forEach(i => { const p = i.plain.password; (passCounts[p] = passCounts[p] || []).push(i); }); const reused = Object.values(passCounts).filter(a => a.length > 1).flat(); const cutoff = Date.now() - 180 * 86400000; const old = pwItems.filter(i => new Date(i.created_at).getTime() < cutoff); const total = pwItems.length; const score = Math.max(0, Math.round(100 - (weak.length / total) * 40 - (reused.length / total) * 30 - (old.length / total) * 15)); const cls = score >= 80 ? 'score-good' : score >= 50 ? 'score-fair' : 'score-poor'; const label = score >= 80 ? 'Good' : score >= 50 ? 'Fair' : 'Needs attention'; summaryEl.innerHTML = `
    ${score}
    ${label}
    Based on ${total} password${total !== 1 ? 's' : ''}
    ${weak.length}Weak
    ${reused.length}Reused
    ${old.length}Old (>180d)
    `; sectionsEl.innerHTML = ''; const makeSection = (title, icon, items, desc) => { if (!items.length) return; const sec = document.createElement('div'); sec.className = 'sec-section'; sec.innerHTML = `
    ${icon}
    ${title} (${items.length})
    ${desc}
    `; sec.querySelectorAll('[data-sec-edit]').forEach(btn => { btn.addEventListener('click', () => { const item = _items.find(it => it.id === parseInt(btn.dataset.secEdit)); if (item) { switchView('vault'); openModal('edit', item); } }); }); sectionsEl.appendChild(sec); }; makeSection('Weak Passwords', '⚠️', weak, 'Short or low-complexity passwords.'); makeSection('Reused Passwords', '♻️', [...new Set(reused.map(i => i.id))].map(id => _items.find(i => i.id === id)).filter(Boolean), 'Same password used on multiple sites.'); makeSection('Old Passwords', '🕐', old, 'Not changed in over 180 days.'); } // ── Sharing View ────────────────────────────────────────────────────────── async function loadSharingView() { try { const keysRes = await apiFetch('/api/sharing/keys'); if (!keysRes) return; const keysData = await keysRes.json(); const notice = document.getElementById('sharing-keys-notice'); if (!keysData.keys_setup) { notice.classList.remove('hidden'); } else { notice.classList.add('hidden'); // Load sharing private key into SharingSession if vault key available const vaultKey = VaultSession.getKey(); if (vaultKey && !SharingSession.isReady()) { try { const privKey = await SharingCrypto.decryptPrivateKey( vaultKey, keysData.private_key_enc, keysData.private_key_iv ); SharingSession.setKey(privKey); } catch (err) { console.warn('[PassKeeper] Could not load sharing private key:', err); } } } await Promise.all([loadSharedOut(), loadSharedIn()]); } catch (err) { showToast('Could not load sharing data: ' + err.message, 'error'); } } async function loadSharedOut() { try { const res = await apiFetch('/api/sharing'); if (!res) return; const shares = await res.json(); renderSharedOut(shares); } catch (err) { console.error('loadSharedOut:', err); } } async function loadSharedIn() { try { const res = await apiFetch('/api/sharing/inbox'); if (!res) return; const shares = await res.json(); renderSharedIn(shares); } catch (err) { console.error('loadSharedIn:', err); } } function renderSharedOut(shares) { const ul = document.getElementById('shared-out-list'); if (!ul) return; if (!shares.length) { ul.innerHTML = '
  • You haven\'t shared any items yet.
  • '; return; } ul.innerHTML = shares.map(s => `
  • ${itemIcon(s.item_type)}
  • `).join(''); ul.querySelectorAll('[data-revoke]').forEach(btn => { btn.addEventListener('click', async () => { if (!confirm('Remove this share?')) return; try { await apiFetch(`/api/sharing/${btn.dataset.revoke}`, { method: 'DELETE' }); showToast('Share removed'); loadSharedOut(); } catch (err) { showToast(err.message, 'error'); } }); }); } function renderSharedIn(shares) { const ul = document.getElementById('shared-in-list'); if (!ul) return; if (!shares.length) { ul.innerHTML = '
  • No items have been shared with you.
  • '; return; } ul.innerHTML = shares.map(s => `
  • ${itemIcon(s.item_type)}
    ${!s.accepted ? `` : `` }
  • `).join(''); ul.querySelectorAll('[data-accept]').forEach(btn => { btn.addEventListener('click', async () => { try { await apiFetch(`/api/sharing/inbox/${btn.dataset.accept}/accept`, { method: 'POST' }); showToast('Share accepted'); loadSharedIn(); } catch (err) { showToast(err.message, 'error'); } }); }); ul.querySelectorAll('[data-view-share]').forEach(btn => { btn.addEventListener('click', async () => { if (!SharingSession.isReady()) { showToast('Sharing keys not loaded. Check Settings.', 'error'); return; } try { const ownerPubKey = await SharingCrypto.importPublicKey(btn.dataset.ownerKey); const sharedKey = await SharingCrypto.deriveSharedKey(SharingSession.getKey(), ownerPubKey); const plain = await SharingCrypto.decryptShare(sharedKey, btn.dataset.enc, btn.dataset.iv); showSharedItemDetails(btn.dataset.name, btn.dataset.type, plain); } catch (err) { showToast('Could not decrypt: ' + err.message, 'error'); } }); }); } function showSharedItemDetails(name, itemType, plain) { document.getElementById('detail-modal-title').textContent = `${itemIcon(itemType || 'password')} ${name}`; renderDetailFields(document.getElementById('detail-modal-body'), plain); document.getElementById('detail-modal').classList.add('open'); } function renderDetailFields(container, plain) { container.innerHTML = Object.entries(plain) .filter(([, v]) => v) .map(([k, v]) => { const label = FIELD_LABELS[k] || k.replace(/_/g, ' '); const val = String(v); const sensitive = SENSITIVE_FIELDS.has(k); const valHtml = sensitive ? ` •••••••• ` : `${escHtml(val)}`; return `
    ${escHtml(label)}
    ${valHtml}
    `; }).join('') || '

    No fields to display.

    '; container.querySelectorAll('.btn-reveal').forEach(btn => { btn.addEventListener('click', () => { const span = btn.previousElementSibling; const revealed = span.dataset.revealed === 'true'; span.dataset.revealed = String(!revealed); span.querySelector('.detail-masked').classList.toggle('hidden', !revealed); span.querySelector('.detail-actual').classList.toggle('hidden', revealed); }); }); container.querySelectorAll('.btn-copy-field').forEach(btn => { btn.addEventListener('click', () => { navigator.clipboard.writeText(btn.dataset.copy).then(() => showToast('Copied!')); }); }); } async function handleShareFormSubmit(e) { e.preventDefault(); const errEl = document.getElementById('share-error'); errEl.classList.add('hidden'); if (!SharingSession.isReady()) { showToast('Set up sharing keys first (Settings)', 'error'); return; } const itemId = parseInt(document.getElementById('share-item-select').value); const recipientEmail = document.getElementById('share-recipient-email').value.trim().toLowerCase(); if (!itemId || !recipientEmail) { errEl.textContent = 'Please select an item and enter a recipient email.'; errEl.classList.remove('hidden'); return; } try { // Fetch recipient's public key const pkRes = await apiFetch(`/api/sharing/public-key?email=${encodeURIComponent(recipientEmail)}`); if (!pkRes) return; const pkData = await pkRes.json(); const recipientPubKey = await SharingCrypto.importPublicKey(pkData.public_key); const sharedKey = await SharingCrypto.deriveSharedKey(SharingSession.getKey(), recipientPubKey); // Find item plaintext const item = _items.find(i => i.id === itemId); if (!item?.plain) { showToast('Item not found or not decrypted', 'error'); return; } const { enc_data, iv } = await SharingCrypto.encryptForShare(sharedKey, item.plain); const res = await apiFetch('/api/sharing', { method: 'POST', body: JSON.stringify({ item_id: itemId, recipient_email: recipientEmail, enc_data, iv, item_name: item.name, item_type: item.item_type, }), }); if (!res) return; closeModal('share-modal'); showToast('Item shared successfully'); loadSharedOut(); } catch (err) { errEl.textContent = err.message; errEl.classList.remove('hidden'); } } function openShareModal() { const sel = document.getElementById('share-item-select'); sel.innerHTML = _items.map(i => `` ).join(''); document.getElementById('share-recipient-email').value = ''; document.getElementById('share-error').classList.add('hidden'); document.getElementById('share-modal').classList.add('open'); } // ── Emergency Access View ───────────────────────────────────────────────── async function loadEmergencyView() { try { const res = await apiFetch('/api/emergency'); if (!res) return; const data = await res.json(); renderEmergencyGrants(data.grants); renderEmergencyAccess(data.access); } catch (err) { showToast('Could not load emergency access data: ' + err.message, 'error'); } } function renderEmergencyGrants(grants) { const ul = document.getElementById('em-grants-list'); if (!ul) return; if (!grants.length) { ul.innerHTML = '
  • No trusted contacts set up yet.
  • '; return; } ul.innerHTML = grants.map(g => { let actions = ''; if (g.status === 'accepted') { actions = ``; } if (g.status === 'pending') { const waitInfo = g.wait_elapsed ? 'Wait period elapsed' : 'Access requested'; actions = `${waitInfo} `; } return `
  • 🚨
    ${actions}
  • `; }).join(''); ul.querySelectorAll('[data-provide]').forEach(btn => { btn.addEventListener('click', () => handleProvideEmergencyData(parseInt(btn.dataset.provide))); }); ul.querySelectorAll('[data-deny]').forEach(btn => { btn.addEventListener('click', async () => { if (!confirm('Deny this access request?')) return; try { await apiFetch(`/api/emergency/${btn.dataset.deny}/deny`, { method: 'POST' }); showToast('Access request denied'); loadEmergencyView(); } catch (err) { showToast(err.message, 'error'); } }); }); ul.querySelectorAll('[data-remove-ea]').forEach(btn => { btn.addEventListener('click', async () => { if (!confirm('Remove this emergency access grant?')) return; try { await apiFetch(`/api/emergency/${btn.dataset.removeEa}`, { method: 'DELETE' }); showToast('Emergency access removed'); loadEmergencyView(); } catch (err) { showToast(err.message, 'error'); } }); }); } async function handleProvideEmergencyData(eaId) { if (!SharingSession.isReady()) { showToast('Set up sharing keys first (Settings → Sharing Keys)', 'error'); return; } if (!confirm('This will create an encrypted snapshot of all your vault items for your trusted contact. Continue?')) return; try { // Get grantee's public key from the emergency record const listRes = await apiFetch('/api/emergency'); const listData = await listRes.json(); const ea = listData.grants.find(g => g.id === eaId); if (!ea) { showToast('Emergency access record not found', 'error'); return; } // Fetch grantee's public key const pkRes = await apiFetch(`/api/sharing/public-key?email=${encodeURIComponent(ea.grantee_email)}`); if (!pkRes) return; const pkData = await pkRes.json(); const granteePubKey = await SharingCrypto.importPublicKey(pkData.public_key); const sharedKey = await SharingCrypto.deriveSharedKey(SharingSession.getKey(), granteePubKey); // Re-encrypt all decrypted vault items const encItems = await Promise.all( _items.filter(i => i.plain).map(async i => { const { enc_data, iv } = await SharingCrypto.encryptForShare(sharedKey, i.plain); return { id: i.id, name: i.name, item_type: i.item_type, enc_data, iv }; }) ); await apiFetch(`/api/emergency/${eaId}/provide`, { method: 'POST', body: JSON.stringify({ enc_vault: JSON.stringify(encItems) }), }); showToast('Recovery data provided'); loadEmergencyView(); } catch (err) { showToast('Failed to provide recovery data: ' + err.message, 'error'); } } function renderEmergencyAccess(access) { const ul = document.getElementById('em-access-list'); if (!ul) return; if (!access.length) { ul.innerHTML = '
  • 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)); } actions = a.wait_elapsed ? `` : `Waiting (~${daysLeft} day${daysLeft !== 1 ? 's' : ''} left)`; } return `
  • 🔐
    ${actions}
  • `; }).join(''); ul.querySelectorAll('[data-accept-ea]').forEach(btn => { btn.addEventListener('click', async () => { try { await apiFetch(`/api/emergency/${btn.dataset.acceptEa}/accept`, { method: 'POST' }); showToast('Emergency access accepted'); loadEmergencyView(); } catch (err) { showToast(err.message, 'error'); } }); }); ul.querySelectorAll('[data-request-ea]').forEach(btn => { btn.addEventListener('click', async () => { if (!confirm('Request emergency access? This will start the waiting period. The vault owner will be notified.')) return; try { await apiFetch(`/api/emergency/${btn.dataset.requestEa}/request`, { method: 'POST' }); showToast('Access request sent — wait period has started'); loadEmergencyView(); } catch (err) { showToast(err.message, 'error'); } }); }); ul.querySelectorAll('[data-get-vault]').forEach(btn => { btn.addEventListener('click', () => handleGetEmergencyVault(parseInt(btn.dataset.getVault))); }); } async function handleGetEmergencyVault(eaId) { if (!SharingSession.isReady()) { showToast('Set up sharing keys first (Settings → Sharing Keys)', 'error'); return; } try { const res = await apiFetch(`/api/emergency/${eaId}/vault`); if (!res) return; const data = await res.json(); const grantorPubKey = await SharingCrypto.importPublicKey(data.grantor_public_key); const sharedKey = await SharingCrypto.deriveSharedKey(SharingSession.getKey(), grantorPubKey); const encItems = JSON.parse(data.enc_vault); const decItems = await Promise.all(encItems.map(async i => { try { const plain = await SharingCrypto.decryptShare(sharedKey, i.enc_data, i.iv); return { ...i, plain }; } catch { return { ...i, plain: null }; } })); renderEmergencyVaultPanel(decItems); } catch (err) { showToast('Could not retrieve vault: ' + err.message, 'error'); } } function renderEmergencyVaultPanel(items) { const list = document.getElementById('em-access-list'); const panel = document.getElementById('em-vault-panel'); const vaultList = document.getElementById('em-vault-list'); if (!panel || !vaultList) return; list.classList.add('hidden'); panel.classList.remove('hidden'); if (!items.length) { vaultList.innerHTML = '
  • 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 `
  • `; }).join(''); // Populate detail fields and wire expand/collapse items.forEach((item, idx) => { const header = vaultList.querySelector(`[data-em-idx="${idx}"]`); const body = document.getElementById(`em-vault-body-${idx}`); const detailBody = body?.querySelector('.em-detail-body'); if (detailBody && item.plain) renderDetailFields(detailBody, item.plain); header?.addEventListener('click', () => { const expanded = header.getAttribute('aria-expanded') === 'true'; header.setAttribute('aria-expanded', String(!expanded)); header.querySelector('.em-vault-chevron').textContent = expanded ? '▸' : '▾'; body?.classList.toggle('hidden', expanded); }); }); document.getElementById('btn-em-vault-back')?.addEventListener('click', () => { panel.classList.add('hidden'); list.classList.remove('hidden'); }); } // ── Password Generator ──────────────────────────────────────────────── const GEN_CHARSETS = { upper: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', lower: 'abcdefghijklmnopqrstuvwxyz', digits: '0123456789', symbols: '!@#$%^&*()-_=+[]{}|;:,.<>?', }; function generatePassword(length = 20, opts = { upper: true, lower: true, digits: true, symbols: true }) { let pool = ''; const required = []; if (opts.upper) { pool += GEN_CHARSETS.upper; required.push(GEN_CHARSETS.upper); } if (opts.lower) { pool += GEN_CHARSETS.lower; required.push(GEN_CHARSETS.lower); } if (opts.digits) { pool += GEN_CHARSETS.digits; required.push(GEN_CHARSETS.digits); } if (opts.symbols) { pool += GEN_CHARSETS.symbols; required.push(GEN_CHARSETS.symbols); } if (!pool) pool = GEN_CHARSETS.lower; const arr = new Uint32Array(length + required.length); window.crypto.getRandomValues(arr); // Guarantee at least one char from each required charset const chars = required.map((cs, i) => cs[arr[i] % cs.length]); for (let i = required.length; i < length + required.length; i++) { chars.push(pool[arr[i] % pool.length]); } // Fisher-Yates shuffle using crypto random const shuffle = new Uint32Array(chars.length); window.crypto.getRandomValues(shuffle); for (let i = chars.length - 1; i > 0; i--) { const j = shuffle[i] % (i + 1); [chars[i], chars[j]] = [chars[j], chars[i]]; } return chars.slice(0, length).join(''); } function scorePassword(pw) { if (!pw) return { score: 0, label: '', cls: '' }; let score = 0; if (pw.length >= 8) score++; if (pw.length >= 12) score++; if (pw.length >= 16) score++; if (/[A-Z]/.test(pw)) score++; if (/[a-z]/.test(pw)) score++; if (/[0-9]/.test(pw)) score++; if (/[^A-Za-z0-9]/.test(pw)) score++; // clamp to 5 bands const band = Math.min(5, Math.ceil(score / 7 * 5)); const labels = ['', 'Very Weak', 'Weak', 'Fair', 'Strong', 'Very Strong']; const clses = ['', 'strength-1', 'strength-2', 'strength-3', 'strength-4', 'strength-5']; return { score: band, label: labels[band], cls: clses[band] }; } // Attach strength meter + generate button to the password field in the item modal function initPasswordFieldEnhancements() { const pwInput = document.getElementById('field-password'); if (!pwInput) return; // Strength bar below the password field if (!document.getElementById('field-password-strength')) { const bar = document.createElement('div'); bar.id = 'field-password-strength'; bar.className = 'password-strength'; pwInput.closest('.input-with-toggle').insertAdjacentElement('afterend', bar); } pwInput.addEventListener('input', () => { const { label, cls } = scorePassword(pwInput.value); const bar = document.getElementById('field-password-strength'); bar.textContent = pwInput.value ? label : ''; bar.className = 'password-strength ' + (pwInput.value ? cls : ''); }); // Generate button — add only once if (!document.getElementById('btn-generate-pass')) { const btn = document.createElement('button'); btn.type = 'button'; btn.id = 'btn-generate-pass'; btn.className = 'btn-secondary btn-sm'; btn.textContent = '⚡ Generate'; btn.style.marginTop = '6px'; pwInput.closest('.form-group').appendChild(btn); } document.getElementById('btn-generate-pass').onclick = () => { const pw = generatePassword(20); pwInput.value = pw; pwInput.type = 'text'; // reveal so user can see what was generated pwInput.dispatchEvent(new Event('input')); // trigger strength bar }; } function openPasswordGeneratorModal() { // Build modal content dynamically — keeps the HTML lean let modal = document.getElementById('gen-modal'); if (!modal) { modal = document.createElement('div'); modal.id = 'gen-modal'; modal.className = 'modal-overlay'; modal.setAttribute('role', 'dialog'); modal.setAttribute('aria-modal', 'true'); modal.innerHTML = ` `; document.body.appendChild(modal); const lengthSlider = document.getElementById('gen-length'); const lengthDisplay = document.getElementById('gen-length-display'); const output = document.getElementById('gen-output'); const strengthEl = document.getElementById('gen-strength'); function refreshGen() { const pw = generatePassword(parseInt(lengthSlider.value), { upper: document.getElementById('gen-upper').checked, lower: document.getElementById('gen-lower').checked, digits: document.getElementById('gen-digits').checked, symbols: document.getElementById('gen-symbols').checked, }); output.value = pw; const { label, cls } = scorePassword(pw); strengthEl.textContent = label; strengthEl.className = 'password-strength ' + cls; } lengthSlider.addEventListener('input', () => { lengthDisplay.textContent = lengthSlider.value; refreshGen(); }); ['gen-upper', 'gen-lower', 'gen-digits', 'gen-symbols'].forEach(id => { document.getElementById(id).addEventListener('change', refreshGen); }); document.getElementById('btn-regenerate').addEventListener('click', refreshGen); document.getElementById('btn-gen-copy').addEventListener('click', () => { navigator.clipboard.writeText(output.value).then(() => showToast('Password copied')); }); const closeGen = () => modal.classList.remove('open'); document.getElementById('btn-close-gen-modal').addEventListener('click', closeGen); document.getElementById('btn-close-gen-modal2').addEventListener('click', closeGen); modal.addEventListener('click', e => { if (e.target === modal) closeGen(); }); refreshGen(); } modal.classList.add('open'); // Regenerate fresh password each time modal opens const lengthSlider = document.getElementById('gen-length'); document.getElementById('gen-length-display').textContent = lengthSlider.value; const pw = generatePassword(parseInt(lengthSlider.value), { upper: document.getElementById('gen-upper').checked, lower: document.getElementById('gen-lower').checked, digits: document.getElementById('gen-digits').checked, symbols: document.getElementById('gen-symbols').checked, }); document.getElementById('gen-output').value = pw; const { label, cls } = scorePassword(pw); const strengthEl = document.getElementById('gen-strength'); strengthEl.textContent = label; strengthEl.className = 'password-strength ' + cls; } // ── Account Settings ────────────────────────────────────────────────────── let _pendingMfaSecret = null; async function openSettingsModal() { document.getElementById('settings-modal').classList.add('open'); await Promise.all([loadMfaStatus(), loadSharingKeysStatus()]); } async function loadMfaStatus() { try { const res = await apiFetch('/api/auth/mfa/status'); if (!res) return; const data = await res.json(); renderMfaStatus(data.totp_enabled); } catch (err) { console.error('loadMfaStatus:', err); } } function renderMfaStatus(enabled) { const statusText = document.getElementById('mfa-status-text'); const actions = document.getElementById('mfa-actions'); document.getElementById('mfa-setup-area').classList.add('hidden'); document.getElementById('mfa-disable-area').classList.add('hidden'); if (enabled) { statusText.textContent = '✅ Two-factor authentication is enabled.'; actions.innerHTML = ''; 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); } } async function handleMfaSetup() { try { const res = await apiFetch('/api/auth/mfa/setup'); if (!res) return; const data = await res.json(); _pendingMfaSecret = data.secret; document.getElementById('mfa-qr-img').src = data.qr_code; document.getElementById('mfa-secret-display').textContent = data.secret; document.getElementById('mfa-setup-area').classList.remove('hidden'); document.getElementById('mfa-actions').innerHTML = ''; document.getElementById('mfa-status-text').textContent = 'Scan the QR code below with your authenticator app.'; document.getElementById('mfa-verify-code').value = ''; document.getElementById('mfa-verify-code').focus(); } catch (err) { showToast('Could not start MFA setup: ' + err.message, 'error'); } } async function handleMfaConfirm() { const code = document.getElementById('mfa-verify-code').value.trim(); if (!code || !_pendingMfaSecret) return; try { const res = await apiFetch('/api/auth/mfa/enable', { method: 'POST', body: JSON.stringify({ secret: _pendingMfaSecret, totp_code: code }), }); if (!res) return; _pendingMfaSecret = null; showToast('MFA enabled successfully'); renderMfaStatus(true); } catch (err) { showToast('Failed to enable MFA: ' + err.message, 'error'); } } async function handleMfaDisableConfirm() { const code = document.getElementById('mfa-disable-code').value.trim(); if (!code) return; try { const res = await apiFetch('/api/auth/mfa/disable', { method: 'POST', body: JSON.stringify({ totp_code: code }), }); if (!res) return; showToast('MFA disabled'); renderMfaStatus(false); } catch (err) { showToast('Failed to disable MFA: ' + err.message, 'error'); } } async function loadSharingKeysStatus() { try { const res = await apiFetch('/api/sharing/keys'); if (!res) return; const data = await res.json(); const statusEl = document.getElementById('sharing-keys-status'); const actionsEl = document.getElementById('sharing-keys-actions'); if (data.keys_setup) { statusEl.textContent = '✅ Sharing keys are set up. You can share items and use emergency access.'; actionsEl.innerHTML = ''; 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); } } catch (err) { console.error('loadSharingKeysStatus:', err); } } async function handleGenerateKeys() { const vaultKey = VaultSession.getKey(); if (!vaultKey) { showToast('Vault is locked. Unlock first.', 'error'); return; } try { const keyPair = await SharingCrypto.generateKeyPair(); const public_key = await SharingCrypto.exportPublicKey(keyPair.publicKey); const { private_key_enc, private_key_iv } = await SharingCrypto.encryptPrivateKey(vaultKey, keyPair.privateKey); const res = await apiFetch('/api/sharing/keys', { method: 'POST', body: JSON.stringify({ public_key, private_key_enc, private_key_iv }), }); if (!res) return; // Cache private key in session SharingSession.setKey(keyPair.privateKey); showToast('Sharing keys generated'); await loadSharingKeysStatus(); } catch (err) { showToast('Key generation failed: ' + err.message, 'error'); } } // ── Folder CRUD ─────────────────────────────────────────────────────────── function showNewFolderRow() { document.getElementById('new-folder-row').classList.remove('hidden'); document.getElementById('new-folder-input').focus(); } function hideNewFolderRow() { document.getElementById('new-folder-row').classList.add('hidden'); document.getElementById('new-folder-input').value = ''; } async function handleNewFolder(e) { e.preventDefault(); const name = document.getElementById('new-folder-input').value.trim(); if (!name) return; try { const res = await apiFetch('/api/folders', { method: 'POST', body: JSON.stringify({ name }) }); if (!res) return; hideNewFolderRow(); await loadFolders(); showToast('Folder created'); } catch (err) { showToast('Could not create folder: ' + err.message, 'error'); } } async function confirmDeleteFolder(folder) { if (!confirm(`Delete folder "${folder.name}"?\nItems will be moved to No Folder.`)) return; try { const res = await apiFetch(`/api/folders/${folder.id}`, { method: 'DELETE' }); if (!res) return; _items = _items.map(i => i.folder_id === folder.id ? { ...i, folder_id: null } : i); if (_activeFilter?.type === 'folder' && _activeFilter.value === folder.id) { _activeFilter = null; updateVaultTitle('All Items'); } await loadFolders(); applyCurrentFilter(); showToast('Folder deleted'); } catch (err) { showToast('Could not delete folder: ' + err.message, 'error'); } } // ── Filtering / Search ──────────────────────────────────────────────────── function applyCurrentFilter() { if (!_activeFilter) { renderItemList(_items); return; } if (_activeFilter.type === 'itemType') renderItemList(_items.filter(i => i.item_type === _activeFilter.value)); else if (_activeFilter.type === 'folder') renderItemList(_items.filter(i => i.folder_id === _activeFilter.value)); } function filterByFolder(folderId, name) { _activeFilter = { type: 'folder', value: folderId }; switchView('vault'); document.querySelectorAll('.sidebar-item').forEach(el => el.classList.remove('active')); document.querySelector(`[data-folder-id="${folderId}"]`)?.classList.add('active'); updateVaultTitle(name); renderItemList(_items.filter(i => i.folder_id === folderId)); } function handleSearch(e) { const q = e.target.value.trim().toLowerCase(); if (!q) { applyCurrentFilter(); return; } const pool = _activeFilter ? (_activeFilter.type === 'itemType' ? _items.filter(i => i.item_type === _activeFilter.value) : _items.filter(i => i.folder_id === _activeFilter.value)) : _items; renderItemList(pool.filter(item => item.name.toLowerCase().includes(q) || (item.plain?.username || '').toLowerCase().includes(q) || (item.plain?.url || '').toLowerCase().includes(q) || (item.plain?.note_body || '').toLowerCase().includes(q) )); } function updateVaultTitle(text) { const el = document.getElementById('vault-title'); if (el) el.textContent = text; } // ── Item Modal ──────────────────────────────────────────────────────────── const TYPE_LABELS = { password: '🔑 Password', note: '📝 Secure Note', card: '💳 Payment Card', bank: '🏦 Bank Account', address: '🏠 Address', ssn: '🪪 Identity (SSN)' }; function switchModalType(type) { document.querySelectorAll('.type-fields').forEach(el => { el.classList.toggle('hidden', !el.dataset.forTypes.split(' ').includes(type)); }); } function openModal(mode, item = null) { const form = document.getElementById('item-form'); form.reset(); form.dataset.mode = mode; form.dataset.itemId = item ? item.id : ''; const folderSel = document.getElementById('field-folder'); folderSel.innerHTML = ''; _folders.forEach(f => { const opt = document.createElement('option'); opt.value = f.id; opt.textContent = f.name; folderSel.appendChild(opt); }); if (mode === 'add') { document.getElementById('type-selector-group').classList.remove('hidden'); document.getElementById('modal-title').textContent = 'Add Item'; switchModalType(document.getElementById('field-type').value || 'password'); } else { document.getElementById('type-selector-group').classList.add('hidden'); const t = item.item_type || 'password'; document.getElementById('modal-title').textContent = `Edit ${TYPE_LABELS[t] || 'Item'}`; switchModalType(t); form.dataset.itemType = t; setVal('field-name', item.name); setVal('field-folder', item.folder_id || ''); if (item.plain) { const p = item.plain; switch (t) { case 'password': setVal('field-url', p.url); setVal('field-username', p.username); setVal('field-password', p.password); setVal('field-notes', p.notes); break; case 'note': setVal('field-note-body', p.note_body); break; case 'card': setVal('field-cardholder', p.cardholder_name); setVal('field-card-number', p.card_number); setVal('field-expiry-month', p.expiry_month); setVal('field-expiry-year', p.expiry_year); setVal('field-cvv', p.cvv); setVal('field-notes', p.notes); break; case 'bank': setVal('field-bank-name', p.bank_name); setVal('field-account-type', p.account_type); setVal('field-routing', p.routing_number); setVal('field-account-number', p.account_number); setVal('field-notes', p.notes); break; case 'address': ['first_name', 'last_name', 'company', 'address_line', 'city', 'state', 'zip', 'country', 'phone', 'email'].forEach(k => setVal('field-' + k.replace(/_/g, '-'), p[k])); setVal('field-notes', p.notes); break; case 'ssn': setVal('field-ssn-number', p.ssn_number); setVal('field-notes', p.notes); break; } } } document.getElementById('item-modal').classList.add('open'); document.getElementById('field-name').focus(); if ((mode === 'add' && (document.getElementById('field-type').value || 'password') === 'password') || (mode === 'edit' && (item?.item_type || 'password') === 'password')) { initPasswordFieldEnhancements(); } } function setVal(id, val) { const el = document.getElementById(id); if (el) el.value = val || ''; } function getVal(id) { const el = document.getElementById(id); return el ? el.value : ''; } function closeModal(id = 'item-modal') { document.getElementById(id)?.classList.remove('open'); } function buildPlainData(t) { switch (t) { case 'password': return { url: getVal('field-url').trim(), username: getVal('field-username').trim(), password: getVal('field-password'), notes: getVal('field-notes').trim() }; case 'note': return { note_body: getVal('field-note-body') }; case 'card': return { cardholder_name: getVal('field-cardholder').trim(), card_number: getVal('field-card-number').trim(), expiry_month: getVal('field-expiry-month'), expiry_year: getVal('field-expiry-year').trim(), cvv: getVal('field-cvv').trim(), notes: getVal('field-notes').trim() }; case 'bank': return { bank_name: getVal('field-bank-name').trim(), account_type: getVal('field-account-type'), routing_number: getVal('field-routing').trim(), account_number: getVal('field-account-number').trim(), notes: getVal('field-notes').trim() }; case 'address': return { first_name: getVal('field-first-name').trim(), last_name: getVal('field-last-name').trim(), company: getVal('field-company').trim(), address_line: getVal('field-address-line').trim(), city: getVal('field-city').trim(), state: getVal('field-state').trim(), zip: getVal('field-zip').trim(), country: getVal('field-country').trim(), phone: getVal('field-phone').trim(), email: getVal('field-email').trim(), notes: getVal('field-notes').trim() }; case 'ssn': return { ssn_number: getVal('field-ssn-number'), notes: getVal('field-notes').trim() }; default: return {}; } } async function handleFormSubmit(e) { e.preventDefault(); try { const vaultKey = VaultSession.getKey(); if (!vaultKey) { closeModal(); showUnlockOverlay(); return; } const form = e.target; const mode = form.dataset.mode; const itemId = form.dataset.itemId; const name = getVal('field-name').trim(); if (!name) { showToast('Name is required', 'error'); return; } const itemType = mode === 'add' ? getVal('field-type') : (form.dataset.itemType || 'password'); const { enc_data, iv } = await Crypto.encryptItem(vaultKey, buildPlainData(itemType)); const folderVal = getVal('field-folder'); const payload = { name, item_type: itemType, folder_id: folderVal ? parseInt(folderVal) : null, enc_data, iv }; if (mode === 'add') { const res = await apiFetch('/api/vault', { method: 'POST', body: JSON.stringify(payload) }); if (!res) return; showToast('Item added'); } else { const res = await apiFetch(`/api/vault/${itemId}`, { method: 'PUT', body: JSON.stringify(payload) }); if (!res) return; showToast('Item updated'); } closeModal(); await loadVault(); } catch (err) { showToast('Save failed: ' + err.message, 'error'); } } async function confirmDeleteItem(item) { if (!confirm(`Delete "${item.name}"? This cannot be undone.`)) return; try { const res = await apiFetch(`/api/vault/${item.id}`, { method: 'DELETE' }); if (!res) return; showToast('Item deleted'); await loadVault(); } catch (err) { showToast('Delete failed: ' + err.message, 'error'); } } // ── Logout ──────────────────────────────────────────────────────────────── async function handleLogout() { const refreshToken = localStorage.getItem('refresh_token'); await fetch('/api/auth/logout', { method: 'POST', headers: { 'Authorization': `Bearer ${sessionStorage.getItem('access_token') || ''}`, 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken() }, body: JSON.stringify({ refresh_token: refreshToken }), }).catch(() => { }); VaultSession.clear(); SharingSession.clear(); sessionStorage.removeItem('access_token'); localStorage.removeItem('refresh_token'); window.dispatchEvent(new CustomEvent('passkeeper:logout')); window.location.href = '/login'; } // ── Utilities ───────────────────────────────────────────────────────────── function copyToClipboard(text, msg = 'Copied') { navigator.clipboard.writeText(text).then(() => showToast(msg)).catch(() => { }); } function escHtml(str) { return String(str).replace(/&/g, '&').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; } btn.disabled = true; btn.textContent = 'Unlocking…'; errEl.classList.add('hidden'); try { const vaultKey = await Crypto.deriveVaultKey(password, enc_key_salt); VaultSession.setKey(vaultKey); document.getElementById('unlock-overlay').classList.remove('open'); await loadFolders(); await loadVault(); } catch { errEl.classList.remove('hidden'); } finally { btn.disabled = false; btn.textContent = 'Unlock'; document.getElementById('unlock-password').value = ''; } } // ── Tabs ────────────────────────────────────────────────────────────────── function initTabs(container) { const tabs = container.querySelectorAll('.tab-btn'); tabs.forEach(btn => { btn.addEventListener('click', () => { tabs.forEach(t => t.classList.remove('active')); btn.classList.add('active'); const target = btn.dataset.tab; container.querySelectorAll('.tab-pane').forEach(pane => { pane.classList.toggle('hidden', !pane.id.endsWith(target)); }); }); }); } // ── Init ────────────────────────────────────────────────────────────────── async function init() { if (!sessionStorage.getItem('access_token')) { // Give the extension bridge up to 800 ms to inject a session before redirecting await new Promise(resolve => { const timer = setTimeout(resolve, 800); window.addEventListener('passkeeper:ext-login', () => { clearTimeout(timer); resolve(); }, { once: true }); }); if (!sessionStorage.getItem('access_token')) { window.location.href = '/login'; return; } } // Vault document.getElementById('unlock-form')?.addEventListener('submit', handleUnlock); document.getElementById('btn-new-folder')?.addEventListener('click', showNewFolderRow); document.getElementById('btn-cancel-folder')?.addEventListener('click', hideNewFolderRow); document.getElementById('new-folder-form')?.addEventListener('submit', handleNewFolder); document.getElementById('search-input')?.addEventListener('input', handleSearch); document.getElementById('btn-add-item')?.addEventListener('click', () => openModal('add')); document.getElementById('btn-close-modal')?.addEventListener('click', () => closeModal()); document.getElementById('btn-cancel-modal')?.addEventListener('click', () => closeModal()); document.getElementById('item-form')?.addEventListener('submit', handleFormSubmit); document.getElementById('btn-logout')?.addEventListener('click', handleLogout); document.getElementById('sort-select')?.addEventListener('change', e => { _sortOrder = e.target.value; applyCurrentFilter(); }); document.getElementById('field-type')?.addEventListener('change', e => { switchModalType(e.target.value); if (e.target.value === 'password') initPasswordFieldEnhancements(); }); document.getElementById('btn-toggle-pass')?.addEventListener('click', () => { const el = document.getElementById('field-password'); el.type = el.type === 'password' ? 'text' : 'password'; }); document.getElementById('btn-toggle-ssn')?.addEventListener('click', () => { const el = document.getElementById('field-ssn-number'); el.type = el.type === 'password' ? 'text' : 'password'; }); // Sidebar navigation document.getElementById('sidebar-all')?.addEventListener('click', () => { _activeFilter = null; switchView('vault'); document.querySelectorAll('.sidebar-item').forEach(el => el.classList.remove('active')); document.getElementById('sidebar-all').classList.add('active'); updateVaultTitle('All Items'); renderItemList(_items); }); document.getElementById('sidebar-security')?.addEventListener('click', () => switchView('security')); document.getElementById('sidebar-sharing')?.addEventListener('click', () => switchView('sharing')); document.getElementById('sidebar-emergency')?.addEventListener('click', () => switchView('emergency')); document.getElementById('sidebar-generator')?.addEventListener('click', () => openPasswordGeneratorModal()); document.querySelectorAll('[data-type-filter]').forEach(el => { el.addEventListener('click', () => { const type = el.dataset.typeFilter; const labels = { password: 'Passwords', note: 'Secure Notes', card: 'Payment Cards', bank: 'Bank Accounts', address: 'Addresses', ssn: 'Identities' }; _activeFilter = { type: 'itemType', value: type }; switchView('vault'); document.querySelectorAll('.sidebar-item').forEach(s => s.classList.remove('active')); el.classList.add('active'); updateVaultTitle(labels[type] || type); renderItemList(_items.filter(i => i.item_type === type)); }); }); document.getElementById('item-modal')?.addEventListener('click', e => { /* backdrop click intentionally disabled — modal closes only via Cancel or Save */ }); document.addEventListener('keydown', e => { if (e.key === 'Escape') { closeModal('share-modal'); closeModal('emergency-modal'); closeModal('settings-modal'); closeModal('detail-modal'); closeModal('gen-modal'); } }); // Detail modal (shared item view) const closeDetailModal = () => closeModal('detail-modal'); document.getElementById('btn-close-detail-modal')?.addEventListener('click', closeDetailModal); document.getElementById('btn-detail-done')?.addEventListener('click', closeDetailModal); document.getElementById('detail-modal')?.addEventListener('click', e => { if (e.target.id === 'detail-modal') closeDetailModal(); }); document.getElementById('field-card-number')?.addEventListener('input', e => { let v = e.target.value.replace(/\D/g, '').slice(0, 16); e.target.value = v.replace(/(.{4})/g, '$1 ').trim(); }); // Settings modal document.getElementById('btn-settings')?.addEventListener('click', openSettingsModal); document.getElementById('btn-close-settings')?.addEventListener('click', () => closeModal('settings-modal')); document.getElementById('settings-modal')?.addEventListener('click', e => { if (e.target.id === 'settings-modal') closeModal('settings-modal'); }); document.getElementById('btn-mfa-confirm')?.addEventListener('click', handleMfaConfirm); document.getElementById('btn-mfa-cancel-setup')?.addEventListener('click', () => { document.getElementById('mfa-setup-area').classList.add('hidden'); loadMfaStatus(); }); document.getElementById('btn-mfa-disable-confirm')?.addEventListener('click', handleMfaDisableConfirm); document.getElementById('btn-mfa-disable-cancel')?.addEventListener('click', () => { document.getElementById('mfa-disable-area').classList.add('hidden'); loadMfaStatus(); }); // Share modal document.getElementById('btn-share-item')?.addEventListener('click', openShareModal); document.getElementById('btn-close-share-modal')?.addEventListener('click', () => closeModal('share-modal')); document.getElementById('btn-cancel-share')?.addEventListener('click', () => closeModal('share-modal')); document.getElementById('share-modal')?.addEventListener('click', e => { if (e.target.id === 'share-modal') closeModal('share-modal'); }); document.getElementById('share-form')?.addEventListener('submit', handleShareFormSubmit); document.getElementById('btn-setup-keys-from-sharing')?.addEventListener('click', () => { closeModal('share-modal'); openSettingsModal(); }); // Emergency modal document.getElementById('btn-add-emergency')?.addEventListener('click', () => { document.getElementById('emergency-error').classList.add('hidden'); document.getElementById('emergency-modal').classList.add('open'); }); document.getElementById('btn-close-emergency-modal')?.addEventListener('click', () => closeModal('emergency-modal')); document.getElementById('btn-cancel-emergency')?.addEventListener('click', () => closeModal('emergency-modal')); document.getElementById('emergency-modal')?.addEventListener('click', e => { if (e.target.id === 'emergency-modal') closeModal('emergency-modal'); }); document.getElementById('emergency-form')?.addEventListener('submit', handleEmergencyFormSubmit); // Tabs document.querySelectorAll('.panel-tabs').forEach(c => initTabs(c)); if (!VaultSession.getKey()) { showUnlockOverlay(); } else { loadVault(); } } async function handleEmergencyFormSubmit(e) { e.preventDefault(); const errEl = document.getElementById('emergency-error'); errEl.classList.add('hidden'); const email = document.getElementById('emergency-email').value.trim().toLowerCase(); const waitDays = document.getElementById('emergency-wait-days').value; if (!email) { errEl.textContent = 'Email is required.'; errEl.classList.remove('hidden'); return; } try { const res = await apiFetch('/api/emergency', { method: 'POST', body: JSON.stringify({ grantee_email: email, wait_days: parseInt(waitDays) }), }); if (!res) return; closeModal('emergency-modal'); showToast('Invitation sent'); loadEmergencyView(); } catch (err) { errEl.textContent = err.message; errEl.classList.remove('hidden'); } } return { init }; })(); document.addEventListener('DOMContentLoaded', Vault.init);