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