const API = '/password-manager/api.php';
let token = sessionStorage.getItem('authToken');
let csrfToken = sessionStorage.getItem('csrfToken') || '';
let curUser = sessionStorage.getItem('currentUsername');
function a2b64(arr) { return btoa(String.fromCharCode(...new Uint8Array(arr))).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,''); }
function b642ab(s) { return Uint8Array.from(atob(s.replace(/-/g,'+').replace(/_/g,'/')), c=>c.charCodeAt(0)).buffer; }
let view = localStorage.getItem('vaultView') || 'grid';
let detailIndex = 0;
let showView = localStorage.getItem('showViewBtn') !== 'false';
let showMail = localStorage.getItem('showEmail') !== 'false';
let dark = localStorage.getItem('darkTheme') !== 'false';
let lockMin = parseInt(localStorage.getItem('autoLockMinutes') || '5');
let order = JSON.parse(localStorage.getItem('entryOrder') || '[]');
let selectedFolder = localStorage.getItem('selectedFolder') || 'All';
let entries = [];
let folders = ['All'];
let genPwdVal = '';
let cryptoKey = null;
let searchQuery = '';
let idleT, warnT, countT;
let draggedId = null;
let showTrash = false;
let selectedIds = new Set();
let lastSelectedId = null;
let arrowAnchor = -1;
let arrowFocus = -1;
let rectState = { active: false, startX: 0, startY: 0, el: null, started: false };
// ==================== SOUND ====================
let soundEnabled = localStorage.getItem('soundEnabled') !== 'false';
let audioCtx = null;
function getAudioContext() {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
return audioCtx;
}
function playTone(freq, duration, type = 'sine', volume = 0.08) {
if (!soundEnabled) return;
try {
const ctx = getAudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, ctx.currentTime);
gain.gain.setValueAtTime(volume, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + duration);
} catch (e) { /* ignore */ }
}
function playSound(type) {
if (!soundEnabled) return;
switch (type) {
case 'click': playTone(800, 0.08, 'sine', 0.06); break;
case 'success':
playTone(523, 0.1, 'sine', 0.1);
setTimeout(() => playTone(659, 0.1, 'sine', 0.1), 100);
setTimeout(() => playTone(784, 0.15, 'sine', 0.1), 200);
break;
case 'error':
playTone(200, 0.2, 'square', 0.06);
setTimeout(() => playTone(150, 0.3, 'square', 0.06), 150);
break;
case 'delete':
playTone(150, 0.15, 'triangle', 0.08);
break;
case 'copy':
playTone(1200, 0.05, 'sine', 0.07);
break;
case 'generate':
playTone(440, 0.05, 'sine', 0.05);
setTimeout(() => playTone(554, 0.05, 'sine', 0.05), 60);
setTimeout(() => playTone(659, 0.05, 'sine', 0.05), 120);
setTimeout(() => playTone(880, 0.1, 'sine', 0.07), 180);
break;
case 'open':
playTone(600, 0.12, 'sine', 0.06);
setTimeout(() => playTone(800, 0.1, 'sine', 0.06), 80);
break;
case 'close':
playTone(800, 0.08, 'sine', 0.05);
setTimeout(() => playTone(600, 0.1, 'sine', 0.05), 80);
break;
case 'login':
playTone(523, 0.1, 'sine', 0.08);
setTimeout(() => playTone(659, 0.1, 'sine', 0.08), 100);
setTimeout(() => playTone(784, 0.2, 'sine', 0.1), 200);
break;
case 'register':
playTone(440, 0.1, 'sine', 0.08);
setTimeout(() => playTone(554, 0.1, 'sine', 0.08), 100);
setTimeout(() => playTone(659, 0.15, 'sine', 0.1), 200);
break;
}
}
function toggleSound() {
soundEnabled = !soundEnabled;
localStorage.setItem('soundEnabled', soundEnabled);
if (soundEnabled) playTone(440, 0.05);
syncSettingsUI();
}
// ==================== TOAST ====================
function toast(m, t) { t = t || 'success'; const c = document.getElementById('toastContainer'); const d = document.createElement('div'); d.className = 'toast ' + t; d.textContent = m; c.appendChild(d); setTimeout(() => d.remove(), 3000); }
function showZigzagToast(elem, msg, type) {
const t = document.createElement('div');
t.className = 'toast-zigzag ' + (type || 'success');
t.textContent = msg;
document.body.appendChild(t);
const r = elem.getBoundingClientRect();
t.style.left = r.left + 'px';
t.style.top = r.top + 'px';
setTimeout(() => t.remove(), 1500);
}
// ==================== THEME ====================
function applyTheme() { document.body.classList.toggle('light', !dark); }
function toggleTheme() { dark = !dark; localStorage.setItem('darkTheme', dark); applyTheme(); syncSettingsUI(); playSound('click'); }
// ==================== CRYPTO ====================
function checkStrength() { const p = document.getElementById('passwordInput').value; const b = document.getElementById('strengthBar'); let s = 0; if (p.length >= 8) s++; if (p.length >= 12) s++; if (/[A-Z]/.test(p) && /[a-z]/.test(p)) s++; if (/\d/.test(p)) s++; if (/[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(p)) s++; b.className = 'strength-bar s' + Math.min(4, s); }
async function deriveKey(pwd, salt) { const enc = new TextEncoder(); const km = await crypto.subtle.importKey('raw', enc.encode(pwd), 'PBKDF2', false, ['deriveKey']); const sb = Uint8Array.from(atob(salt), c => c.charCodeAt(0)); return crypto.subtle.deriveKey({ name: 'PBKDF2', salt: sb, iterations: 100000, hash: 'SHA-256' }, km, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); }
async function encryptPwd(plain) { const iv = crypto.getRandomValues(new Uint8Array(12)); const enc = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, new TextEncoder().encode(plain)); return { encrypted: btoa(String.fromCharCode(...new Uint8Array(enc))), iv: btoa(String.fromCharCode(...iv)) }; }
async function decryptPwd(encB64, ivB64) { try { const enc = Uint8Array.from(atob(encB64), c => c.charCodeAt(0)); const iv = Uint8Array.from(atob(ivB64), c => c.charCodeAt(0)); const dec = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, cryptoKey, enc); return new TextDecoder().decode(dec); } catch (e) { return '[ERROR]'; } }
async function persistCryptoKey() { const raw = await crypto.subtle.exportKey('raw', cryptoKey); sessionStorage.setItem('cryptoKey', btoa(String.fromCharCode(...new Uint8Array(raw)))); }
async function restoreCryptoKey() { const saved = sessionStorage.getItem('cryptoKey'); if (!saved) return false; try { const raw = Uint8Array.from(atob(saved), c => c.charCodeAt(0)); cryptoKey = await crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); return true; } catch (e) { return false; } }
// ==================== AUTO-LOCK ====================
function setAutoLock() { lockMin = parseInt(document.getElementById('autoLockTimer').value); localStorage.setItem('autoLockMinutes', lockMin); resetIdle(); }
function resetIdle() { clearTimeout(idleT); clearTimeout(warnT); clearInterval(countT); document.getElementById('idleWarning').classList.remove('show'); if (lockMin > 0 && token) { const lm = lockMin * 60000; warnT = setTimeout(() => { document.getElementById('idleWarning').classList.add('show'); let cd = 30; document.getElementById('idleCountdown').textContent = cd; countT = setInterval(() => { cd--; document.getElementById('idleCountdown').textContent = cd; if (cd <= 0) { clearInterval(countT); doLogout(); } }, 1000); }, Math.max(0, lm - 30000)); idleT = setTimeout(() => doLogout(), lm); } }
// ==================== USERNAME ====================
function saveUsername() { const f = document.getElementById('addUsername'); if (f && f.value.trim()) localStorage.setItem('savedUsername', f.value.trim()); }
function loadUsername() { const s = localStorage.getItem('savedUsername'); const f = document.getElementById('addUsername'); if (s && f) f.value = s; }
// ==================== FOLDERS ====================
async function loadFolders() {
if (!token) return;
try {
const r = await fetch(API + '/folders', { headers: { 'Authorization': 'Bearer ' + token } });
if (r.ok) {
const data = await r.json();
if (Array.isArray(data)) {
folders = data.filter(f => typeof f === 'string');
if (!folders.includes('All')) folders.unshift('All');
} else {
folders = ['All'];
}
} else {
folders = ['All'];
}
} catch (e) {
folders = ['All'];
}
}
async function addFolderToServer(name) {
try {
const r = await fetch(API + '/folders', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken },
body: JSON.stringify({ name })
});
if (r.ok) { await loadFolders(); return true; }
const d = await r.json();
toast('❌ ' + (d.error || 'Error'), 'error');
return false;
} catch (e) { toast('⚠️ Connection error', 'error'); return false; }
}
async function deleteFolderFromServer(name) {
try {
const r = await fetch(API + '/folders/' + encodeURIComponent(name), {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken }
});
if (r.ok) {
await loadFolders();
if (selectedFolder === name) { selectedFolder = 'All'; localStorage.setItem('selectedFolder', 'All'); }
return true;
}
const d = await r.json();
toast('❌ ' + (d.error || 'Error'), 'error');
return false;
} catch (e) { toast('⚠️ Connection error', 'error'); return false; }
}
function folderColor(name) {
if (name === 'All') return '';
let hash = 0;
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
const hue = ((hash % 360) + 360) % 360;
return `style="--chip-color:hsl(${hue},60%,55%)"`;
}
function renderFolders() {
const bar = document.getElementById('foldersBar');
if (!bar) return;
entries.forEach(e => { if (!e.folder || typeof e.folder !== 'string') e.folder = 'All'; });
const counts = {};
entries.forEach(e => { const f = e.folder; counts[f] = (counts[f] || 0) + 1; });
let html = '';
folders.forEach(f => {
if (!f) return;
const count = counts[f] || 0;
const color = folderColor(f);
html += `📁 ${esc(f)}${count} ${f !== 'All' ? `✕ ` : ''} `;
});
html += `+ New `;
bar.innerHTML = html;
// Make folders drop targets for moving entries
bar.querySelectorAll('.folder-chip[data-folder]').forEach(chip => {
chip.addEventListener('dragover', e => { e.preventDefault(); chip.classList.add('drag-over'); });
chip.addEventListener('dragleave', () => chip.classList.remove('drag-over'));
chip.addEventListener('drop', async function(e) {
e.preventDefault();
this.classList.remove('drag-over');
const id = parseInt(e.dataTransfer.getData('text/plain'));
if (!id) return;
const entry = entries.find(x => x.id === id);
if (!entry) return;
const folder = this.dataset.folder;
const ids = selectedIds.has(id) && selectedIds.size > 1 ? [...selectedIds] : [id];
let moved = 0;
for (const sid of ids) {
const e2 = entries.find(x => x.id === sid);
if (!e2 || e2.folder === folder) continue;
const enc = await encryptPwd(e2.password);
const r = await fetch(API + '/entries/' + sid, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken },
body: JSON.stringify({ site: e2.site, username: e2.username, encrypted_password: enc.encrypted, iv: enc.iv, folder })
});
if (r.ok) { e2.folder = folder; moved++; }
}
renderFolders();
render();
if (moved) playSound('success');
});
});
}
function selectFolder(f) {
selectedFolder = f;
localStorage.setItem('selectedFolder', f);
renderFolders();
populateFolderSelects();
render();
playSound('click');
}
function showAddFolderModal() {
const overlay = document.createElement('div');
overlay.className = 'custom-modal-overlay show';
overlay.innerHTML = `
`;
document.body.appendChild(overlay);
document.getElementById('cancelAddFolder').onclick = () => overlay.remove();
document.getElementById('confirmAddFolder').onclick = async () => {
const name = document.getElementById('newFolderName').value.trim();
if (!name) { toast('Enter a name', 'error'); return; }
const ok = await addFolderToServer(name);
if (ok) { renderFolders(); populateFolderSelects(); overlay.remove(); toast('📁 Folder created!'); playSound('success'); }
};
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
playSound('open');
}
function showDeleteFolderConfirm(folderName) {
const overlay = document.createElement('div');
overlay.className = 'custom-modal-overlay show';
overlay.innerHTML = `🗑️ Delete Folder Delete "${folderName}"? Entries move to "All".
Cancel Delete
`;
document.body.appendChild(overlay);
document.getElementById('cancelDeleteFolder').onclick = () => overlay.remove();
document.getElementById('confirmDeleteFolder').onclick = async () => {
const ok = await deleteFolderFromServer(folderName);
if (ok) { renderFolders(); populateFolderSelects(); render(); overlay.remove(); toast('📁 Folder deleted'); playSound('delete'); }
};
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
}
// ==================== TRASH ====================
function toggleTrash() {
showTrash = !showTrash;
const btn = document.getElementById('trashBtn');
const actions = document.getElementById('trashActions');
if (btn) { btn.textContent = showTrash ? '📋 Active' : '🗑️ Trash'; btn.classList.toggle('btn-danger', showTrash); }
if (actions) actions.classList.toggle('hidden', !showTrash);
loadEntries();
playSound('click');
}
async function restoreEntry(id, noToast) {
try { const r = await fetch(API + '/entries/' + id + '/restore', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); if (r.ok) { if (!noToast) { toast('✅ Restored!'); await loadEntries(); playSound('success'); } } } catch (e) { if (!noToast) toast('⚠️ Error', 'error'); }
}
async function toggleFavorite(id) {
try { await fetch(API + '/entries/' + id + '/favorite', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); const e = entries.find(x => x.id == id); if (e) e.favorite = e.favorite ? 0 : 1; renderFolders(); render(); playSound('click'); } catch (e) {}
}
async function permanentDelete(id, silent) {
const doDelete = async () => {
try { const r = await fetch(API + '/entries/' + id + '?permanent=1', { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); if (r.ok) { order = order.filter(x => x != id); localStorage.setItem('entryOrder', JSON.stringify(order)); if (!silent) { toast('🗑️ Permanently deleted'); await loadEntries(); playSound('error'); } } } catch (e) { if (!silent) toast('⚠️ Error', 'error'); }
};
if (silent) { await doDelete(); return; }
const btn = document.querySelector('.delete-btn[data-id="' + id + '"]');
if (btn) showBatchConfirm(btn, 'Permanently delete?', doDelete);
else showBatchConfirm(document.body, 'Permanently delete?', doDelete);
}
async function emptyTrash() {
const btn = document.querySelector('.empty-trash-btn');
showBatchConfirm(btn || document.body, 'Delete ALL trashed entries?', async () => {
try { const r = await fetch(API + '/entries/trash/empty', { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); if (r.ok) { toast('🗑️ Trash emptied'); await loadEntries(); playSound('error'); } } catch (e) { toast('⚠️ Error', 'error'); }
});
}
function timeAgo(dateStr) {
if (!dateStr) return '';
const now = new Date(); const d = new Date(dateStr + 'Z');
const days = 30 - Math.floor((now - d) / (1000 * 60 * 60 * 24));
return days <= 0 ? 'Expiring' : days + 'd left';
}
// ==================== SETTINGS ====================
function toggleSettings() {
document.getElementById('settingsMenu').classList.toggle('hidden');
}
function syncSettingsUI() {
document.getElementById('soundToggleSwitch').classList.toggle('active', soundEnabled);
document.getElementById('themeToggleSwitch').classList.toggle('active', !dark);
document.getElementById('showViewBtnToggle').classList.toggle('active', showView);
document.getElementById('showEmailToggle').classList.toggle('active', showMail);
document.getElementById('autoLockTimer').value = lockMin;
}
async function registerPasskey() {
try {
const r = await fetch(API + '/passkey/register/begin', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken }
});
if (!r.ok) { const d = await r.json(); toast('❌ ' + (d.error || 'Failed'), 'error'); return; }
const opts = await r.json();
opts.challenge = b642ab(opts.challenge);
opts.user.id = b642ab(opts.user.id);
if (!window.PublicKeyCredential) { toast('❌ Passkeys not supported', 'error'); return; }
const cred = await navigator.credentials.create({ publicKey: opts });
const result = {
id: cred.id,
response: {
clientDataJSON: a2b64(cred.response.clientDataJSON),
attestationObject: a2b64(cred.response.attestationObject)
}
};
const r2 = await fetch(API + '/passkey/register/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken },
body: JSON.stringify(result)
});
if (r2.ok) { toast('✅ Passkey registered!'); playSound('success'); }
else { const d = await r2.json(); toast('❌ ' + (d.error || 'Failed'), 'error'); }
} catch (e) { toast('⚠️ Passkey setup failed: ' + e.message, 'error'); }
}
// ==================== INIT ====================
function init() {
document.getElementById('autoLockTimer').value = lockMin;
applyTheme();
// document.getElementById('usernameInput').style.display = showMail ? '' : 'none';
loadUsername();
const sl = localStorage.getItem('savedLoginUser');
if (sl) document.getElementById('loginUsername').value = sl;
syncSettingsUI();
// View dropdown
const vdd = document.getElementById('viewDropdown');
const vBtn = document.getElementById('viewDropdownBtn');
const vMenu = document.getElementById('viewDropdownMenu');
const vIcons = {grid:'🟫',compact:'📝',list:'📋',table:'📊',card:'🃏',grouped:'📂',detail:'🔍'};
const updateViewBtn = () => { vBtn.textContent = (vIcons[view] || '🟫') + ' ' + view.charAt(0).toUpperCase() + view.slice(1) + ' ▾'; };
updateViewBtn();
vMenu.querySelectorAll('.view-opt').forEach(o => o.classList.toggle('active', o.dataset.view === view));
vBtn.onclick = (e) => { e.stopPropagation(); vMenu.classList.toggle('hidden'); };
vMenu.onclick = (e) => {
const opt = e.target.closest('.view-opt');
if (!opt) return;
vMenu.querySelectorAll('.view-opt').forEach(o => o.classList.remove('active'));
opt.classList.add('active');
view = opt.dataset.view;
detailIndex = 0;
localStorage.setItem('vaultView', view);
updateViewBtn();
render();
playSound('click');
};
document.addEventListener('click', () => vMenu.classList.add('hidden'));
const clearBtn = document.getElementById('clearSearchBtn');
if (clearBtn) clearBtn.style.display = 'none';
document.getElementById('addModal').addEventListener('click', e => {
if (e.target === e.currentTarget) closeAdd();
});
// Close settings when clicking outside
document.addEventListener('click', (e) => {
const menu = document.getElementById('settingsMenu');
const btn = document.getElementById('settingsBtn');
if (menu && !menu.classList.contains('hidden') && !menu.contains(e.target) && e.target !== btn) {
menu.classList.add('hidden');
}
});
}
function toggleViewBtn() { showView = !showView; localStorage.setItem('showViewBtn', showView); syncSettingsUI(); render(); playSound('click'); }
function toggleShowEmail() { showMail = !showMail; localStorage.setItem('showEmail', showMail); syncSettingsUI(); render(); playSound('click'); }
// ==================== GENERATOR ====================
function openGen() { document.getElementById('genModal').style.display = 'flex'; genPwd(); playSound('open'); }
function closeGen() { document.getElementById('genModal').style.display = 'none'; playSound('close'); }
function onLenChange() { document.getElementById('lenVal').textContent = document.getElementById('pwdLen').value; genPwd(); }
function genPreset(len, chars) {
document.getElementById('pwdLen').value = len;
document.getElementById('lenVal').textContent = len;
document.getElementById('useUpper').checked = chars.includes('upper');
document.getElementById('useLower').checked = chars.includes('lower');
document.getElementById('useNum').checked = chars.includes('num');
document.getElementById('useSym').checked = chars.includes('sym');
genPwd();
playSound('click');
}
function genPwd() { const l = parseInt(document.getElementById('pwdLen').value); let c = ''; if (document.getElementById('useUpper').checked) c += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; if (document.getElementById('useLower').checked) c += 'abcdefghijklmnopqrstuvwxyz'; if (document.getElementById('useNum').checked) c += '0123456789'; if (document.getElementById('useSym').checked) c += '!@#$%^&*()_+-=[]{}|;:,.<>?'; if (!c) { document.getElementById('genPreview').textContent = 'Select option'; return; } let p = ''; const max = 256 - (256 % c.length); const buf = new Uint8Array(1); for (let i = 0; i < l; i++) { do { crypto.getRandomValues(buf); } while (buf[0] >= max); p += c.charAt(buf[0] % c.length); } genPwdVal = p; document.getElementById('genPreview').textContent = p; }
function useGen() {
if (!genPwdVal) genPwd();
// Put the generated password into the add‑modal’s password field
const pwdField = document.getElementById('addPassword');
if (pwdField) {
pwdField.value = genPwdVal;
checkAddStrength(); // update the strength bar
}
navigator.clipboard.writeText(genPwdVal);
toast('🎲 Copied!');
closeGen(); // closes the generator modal, not the add modal
}
function populateFolderSelects() {
['addFolder', 'editFolder'].forEach(id => {
const select = document.getElementById(id);
if (!select) return;
select.innerHTML = '';
folders.forEach(f => {
if (!f) return;
const option = document.createElement('option');
option.value = f;
option.textContent = '📁 ' + f;
if (f === selectedFolder) option.selected = true;
select.appendChild(option);
});
});
}
function openAdd() {
document.getElementById('addSite').value = '';
document.getElementById('addPassword').value = '';
document.getElementById('addStrengthBar').className = 'strength-bar s0';
loadUsername();
populateFolderSelects();
document.getElementById('addModal').classList.add('show');
document.getElementById('addSite').focus();
playSound('open');
}
function closeAdd() {
document.getElementById('addModal').classList.remove('show');
playSound('close');
}
function checkRegStrength() {
const p = document.getElementById('regPassword').value;
const bar = document.getElementById('regStrengthBar');
let s = 0;
if (p.length >= 8) s++;
if (p.length >= 12) s++;
if (/[A-Z]/.test(p) && /[a-z]/.test(p)) s++;
if (/\d/.test(p)) s++;
if (/[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(p)) s++;
bar.className = 'strength-bar s' + Math.min(4, s);
}
function checkAddStrength() {
const p = document.getElementById('addPassword').value;
const bar = document.getElementById('addStrengthBar');
let s = 0;
if (p.length >= 8) s++;
if (p.length >= 12) s++;
if (/[A-Z]/.test(p) && /[a-z]/.test(p)) s++;
if (/\d/.test(p)) s++;
if (/[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(p)) s++;
bar.className = 'strength-bar s' + Math.min(4, s);
}
// ==================== AUTH ====================
function switchTab(t) { document.querySelectorAll('.auth-tab').forEach(x => x.classList.remove('active')); event.target.classList.add('active'); document.getElementById('loginForm').classList.toggle('hidden', t !== 'login'); document.getElementById('registerForm').classList.toggle('hidden', t !== 'register'); }
async function loginWithPasskey() {
if (!window.PublicKeyCredential) { toast('❌ Passkeys not supported', 'error'); return; }
const u = document.getElementById('loginUsername').value.trim();
if (!u) { toast('Enter username first', 'error'); return; }
document.getElementById('loginBtn').disabled = true;
try {
const r = await fetch(API + '/passkey/login/begin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: u })
});
if (!r.ok) { const d = await r.json(); toast('❌ ' + (d.error || 'Failed'), 'error'); document.getElementById('loginBtn').disabled = false; return; }
const opts = await r.json();
opts.challenge = b642ab(opts.challenge);
opts.allowCredentials.forEach(c => { c.id = b642ab(c.id); });
const cred = await navigator.credentials.get({ publicKey: opts });
const result = {
id: cred.id,
response: {
clientDataJSON: a2b64(cred.response.clientDataJSON),
authenticatorData: a2b64(cred.response.authenticatorData),
signature: a2b64(cred.response.signature),
userHandle: cred.response.userHandle ? a2b64(cred.response.userHandle) : null
}
};
const r2 = await fetch(API + '/passkey/login/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(result)
});
const d = await r2.json();
if (r2.ok) {
token = d.token; csrfToken = d.csrfToken || ''; curUser = d.username || u;
sessionStorage.setItem('authToken', token);
sessionStorage.setItem('csrfToken', csrfToken);
sessionStorage.setItem('currentUsername', curUser);
// Try to restore crypto key from sessionStorage
const restored = await restoreCryptoKey();
if (!restored) {
// Need master password once to derive crypto key
const mp = await new Promise(resolve => {
const overlay = document.createElement('div');
overlay.className = 'custom-modal-overlay show';
overlay.innerHTML = ``;
document.body.appendChild(overlay);
document.getElementById('passkeyTempBtn').onclick = () => resolve(document.getElementById('passkeyTempPwd').value);
overlay.addEventListener('keydown', function handler(e) { if (e.key === 'Enter') { resolve(document.getElementById('passkeyTempPwd').value); overlay.remove(); document.removeEventListener('keydown', handler); } });
});
const m = document.querySelector('.custom-modal-overlay.show');
if (m) m.remove();
cryptoKey = await deriveKey(mp, d.salt);
persistCryptoKey();
}
await loadFolders();
toast('✅ Biometric login!');
playSound('login');
showVault();
loadEntries();
} else { toast('❌ ' + (d.error || 'Failed'), 'error'); }
} catch (e) { toast('⚠️ Passkey login failed: ' + e.message, 'error'); }
finally { document.getElementById('loginBtn').disabled = false; }
}
async function login() {
const u = document.getElementById('loginUsername').value.trim();
const p = document.getElementById('loginPassword').value;
if (!u || !p) { toast('Fill all fields', 'error'); return; }
document.getElementById('loginBtn').disabled = true;
localStorage.setItem('savedLoginUser', u);
try {
const r = await fetch(API + '/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: u, masterPassword: p }) });
const d = await r.json();
if (r.ok) {
token = d.token; csrfToken = d.csrfToken || ''; curUser = u;
cryptoKey = await deriveKey(p, d.salt);
persistCryptoKey();
sessionStorage.setItem('authToken', token);
sessionStorage.setItem('csrfToken', csrfToken);
sessionStorage.setItem('currentUsername', u);
await loadFolders();
toast('✅ Login!');
playSound('login');
showVault();
loadEntries();
} else { toast('❌ ' + (d.error || 'Invalid'), 'error'); document.getElementById('loginPassword').value = ''; }
} catch (e) { toast('⚠️ Connection error', 'error'); }
finally { document.getElementById('loginBtn').disabled = false; }
}
async function register() {
const u = document.getElementById('regUsername').value.trim();
const p = document.getElementById('regPassword').value;
if (u.length < 3) { toast('Username min 3', 'error'); return; }
if (p.length < 8) { toast('Password min 8', 'error'); return; }
document.getElementById('registerBtn').disabled = true;
try {
const r = await fetch(API + '/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: u, masterPassword: p }) });
const d = await r.json();
if (r.ok) {
token = d.token; csrfToken = d.csrfToken || ''; curUser = u;
cryptoKey = await deriveKey(p, d.salt);
persistCryptoKey();
sessionStorage.setItem('authToken', token);
sessionStorage.setItem('csrfToken', csrfToken);
sessionStorage.setItem('currentUsername', u);
await loadFolders();
toast('✅ Created!');
playSound('register');
showVault();
loadEntries();
} else { toast('❌ ' + (d.error || 'Failed'), 'error'); }
} catch (e) { toast('⚠️ Connection error', 'error'); }
finally { document.getElementById('registerBtn').disabled = false; }
}
async function doLogout() {
saveUsername();
if (token) {
try { await fetch(API + '/logout', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); } catch (e) {}
}
clearTimeout(idleT); clearTimeout(warnT); clearInterval(countT);
document.getElementById('idleWarning').classList.remove('show');
token = null; csrfToken = ''; curUser = null; entries = []; cryptoKey = null; folders = ['All']; showTrash = false;
sessionStorage.clear();
document.getElementById('authSection').classList.remove('hidden');
document.getElementById('vaultSection').classList.add('hidden');
document.getElementById('loginPassword').value = '';
document.getElementById('loginUsername').value = localStorage.getItem('savedLoginUser') || '';
}
function showVault() {
document.getElementById('authSection').classList.add('hidden');
document.getElementById('vaultSection').classList.remove('hidden');
document.getElementById('currentUser').textContent = '👤 ' + curUser;
//document.getElementById('usernameInput').style.display = showMail ? '' : 'none';
document.getElementById('autoLockTimer').value = lockMin;
loadUsername();
renderFolders();
populateFolderSelects();
syncSettingsUI();
resetIdle();
}
// ==================== ENTRIES ====================
function applyOrder(list) { if (!list || !list.length) return []; if (!order || !order.length) return list; const map = new Map(list.filter(e => e && e.id).map(e => [e.id, e])); const ord = []; order.forEach(id => { if (map.has(id)) { ord.push(map.get(id)); map.delete(id); } }); map.forEach(e => ord.push(e)); return ord; }
async function loadEntries(q) {
try {
let url = API + '/entries?deleted=' + (showTrash ? '1' : '0');
if (q) url += '&search=' + encodeURIComponent(q);
const r = await fetch(url, { headers: { 'Authorization': 'Bearer ' + token } });
if (r.ok) {
const raw = await r.json();
entries = [];
for (const e of raw) {
if (e.encryption_method === 'client') {
const pw = await decryptPwd(e.encrypted_password, e.iv);
entries.push({ id: e.id, site: e.site, username: e.username, password: pw, folder: e.folder || 'All', deleted_at: e.deleted_at, favorite: e.favorite || 0 });
} else {
entries.push({ id: e.id, site: e.site, username: e.username, password: e.password || '', folder: e.folder || 'All', deleted_at: e.deleted_at, favorite: e.favorite || 0 });
}
}
entries = applyOrder(entries);
entries.sort((a, b) => (b.favorite || 0) - (a.favorite || 0));
document.getElementById('connectionStatus').textContent = '🟢 Connected';
document.getElementById('entryCount').textContent = '(' + entries.length + ' entries)';
renderFolders();
populateFolderSelects();
render();
} else if (r.status === 401) { toast('Session expired', 'error'); doLogout(); }
} catch (e) { document.getElementById('connectionStatus').textContent = '🔴 Error'; toast('Connection error', 'error'); }
}
function getFilteredEntries(noFolder) {
if (showTrash) return entries;
if (noFolder || selectedFolder === 'All') return entries;
return entries.filter(e => (e.folder || 'All') === selectedFolder);
}
function getGridCols() {
const c = document.getElementById('entriesContainer');
if (!c || !c.firstElementChild) return 1;
const w = c.firstElementChild.offsetWidth;
const gap = parseInt(getComputedStyle(c).columnGap) || 0;
return Math.max(1, Math.round(c.offsetWidth / (w + gap)));
}
// ==================== RENDER ====================
function render() {
const c = document.getElementById('entriesContainer');
c.className = '';
if (showTrash) c.classList.add('trash-view');
c.classList.add(view + '-view');
const filtered = getFilteredEntries();
if (!filtered.length) { c.innerHTML = '' + (showTrash ? '📭 Trash empty' : '📭 No entries') + '
'; return; }
if (view === 'table') {
let h = 'Site ' + (showMail ? 'User ' : '') + 'Password ' + (!showTrash ? 'Folder ' : 'Deleted ') + 'Actions ';
filtered.forEach(e => {
const sel = selectedIds.has(e.id);
h += '' +
'' + (e.favorite ? '⭐' : '') + ' ' +
'🌐 ' + highlightText(e.site, searchQuery) + ' ' +
(showMail ? '👤 ' + highlightText(e.username, searchQuery) + ' ' : '') +
'•••••••• ';
if (!showTrash) {
h += '📁 ' + esc(e.folder || 'All') + ' ' +
'' +
'' + (e.favorite ? '⭐' : '☆') + ' ' +
'📋 ' +
'✏️ ' +
'✕ ' +
' ';
} else {
h += '🗑️ ' + timeAgo(e.deleted_at) + ' ' +
'' +
'♻️ ' +
'✕ ' +
' ';
}
h += ' ';
});
h += '
';
c.innerHTML = h;
} else if (view === 'grouped') {
c.innerHTML = groupedC(getFilteredEntries(true));
} else if (view === 'detail') {
c.innerHTML = detailC(getFilteredEntries(true));
} else {
c.innerHTML = filtered.map(e => {
if (view === 'grid' || view === 'card') return gridC(e);
if (view === 'compact') return compC(e);
return listC(e);
}).join('');
}
attachEvents();
setupDrag();
}
function gridC(e) {
const sel = selectedIds.has(e.id);
let html = '';
html += '
';
if (showTrash) {
html += '♻️ ';
html += '✕ ';
} else {
html += '' + (e.favorite ? '⭐' : '☆') + ' ';
html += '✏️ ';
html += '✕ ';
}
html += '
';
html += '
🌐 ' + highlightText(e.site, searchQuery) + '
';
if (showMail) html += '
👤 ' + highlightText(e.username, searchQuery) + '
';
html += '
📁 ' + esc(e.folder || 'All') + '
';
if (!showTrash) {
html += '
';
} else {
html += '
🗑️ ' + timeAgo(e.deleted_at) + '
';
}
html += '
';
return html;
}
function listC(e) {
const sel = selectedIds.has(e.id);
let html = '';
html += '
';
if (showTrash) {
html += '♻️ ';
html += '✕ ';
} else {
html += '' + (e.favorite ? '⭐' : '☆') + ' ';
html += '✏️ ';
html += '✕ ';
}
html += '
';
html += '
🌐 ' + highlightText(e.site, searchQuery) + ' ';
if (showMail) html += '
👤 ' + highlightText(e.username, searchQuery) + ' ';
if (!showTrash) {
html += '
📁 ' + esc(e.folder || 'All') + ' ';
html += '
•••••••• ' +
'📋
';
} else {
html += '
🗑️ ' + timeAgo(e.deleted_at) + ' ';
}
html += '
';
return html;
}
function compC(e) {
const sel = selectedIds.has(e.id);
let html = '';
html += '
';
if (showTrash) {
html += '♻️ ';
html += '✕ ';
} else {
html += '' + (e.favorite ? '⭐' : '☆') + ' ';
html += '✏️ ';
html += '✕ ';
}
html += '
';
html += '
🌐 ' + highlightText(e.site, searchQuery) + ' ';
if (showMail) html += '
👤 ' + highlightText(e.username, searchQuery) + ' ';
if (!showTrash) {
html += '
📁 ' + esc(e.folder || 'All') + ' ';
html += '
•••••••• ';
html += '
📋 ';
} else {
html += '
🗑️ ' + timeAgo(e.deleted_at) + ' ';
}
html += '
';
return html;
}
function groupedC(list) {
const groups = {};
list.forEach(e => {
const f = e.folder || 'All';
if (!groups[f]) groups[f] = [];
groups[f].push(e);
});
let html = '';
for (const [folder, items] of Object.entries(groups)) {
html += '';
items.forEach(e => {
const sel = selectedIds.has(e.id);
html += '';
html += '
';
if (showTrash) {
html += '♻️ ';
html += '✕ ';
} else {
html += '' + (e.favorite ? '⭐' : '☆') + ' ';
html += '✏️ ';
html += '✕ ';
}
html += '
';
html += '
🌐 ' + highlightText(e.site, searchQuery) + ' ';
if (showMail) html += '
👤 ' + highlightText(e.username, searchQuery) + ' ';
if (!showTrash) {
html += '
•••••••• ' +
'📋
';
} else {
html += '
🗑️ ' + timeAgo(e.deleted_at) + ' ';
}
html += '
';
});
}
return html;
}
function detailC(list) {
if (detailIndex >= list.length) detailIndex = 0;
if (detailIndex < 0) detailIndex = list.length - 1;
const e = list[detailIndex];
const hasPrev = detailIndex > 0, hasNext = detailIndex < list.length - 1;
const sel = selectedIds.has(e.id);
let html = '';
html += '◀ Prev ';
html += '' + (detailIndex + 1) + ' of ' + list.length + ' ';
html += 'Next ▶ ';
html += '
';
html += '';
html += '
Site 🌐 ' + highlightText(e.site, searchQuery) + '
';
if (showMail) html += '
Username 👤 ' + highlightText(e.username, searchQuery) + '
';
if (!showTrash) {
html += '
Password ••••••••
';
html += '
Folder 📁 ' + esc(e.folder || 'All') + '
';
html += '
' +
'' + (e.favorite ? '⭐' : '☆') + ' ' +
'📋 ' +
'✏️ ' +
'✕ ' +
'
';
} else {
html += '
Deleted 🗑️ ' + timeAgo(e.deleted_at) + '
';
html += '
' +
'♻️ Restore ' +
'✕ Delete ' +
'
';
}
html += '
';
return html;
}
function goDetail(dir) {
const list = getFilteredEntries(true);
detailIndex += dir;
if (detailIndex < 0) detailIndex = list.length - 1;
if (detailIndex >= list.length) detailIndex = 0;
render();
}
// ==================== EVENTS ====================
function showConfirm(btn, message, callback) {
const id = btn.dataset.id;
const existing = document.querySelector('.custom-confirm');
if (existing) existing.remove();
const confirm = document.createElement('div');
confirm.className = 'custom-confirm show';
confirm.innerHTML =
'' + message + '
' +
'' +
'Yes ' +
'No ' +
'
';
document.body.appendChild(confirm);
const rect = btn.getBoundingClientRect();
confirm.style.top = (rect.top - 60) + 'px';
let leftPos = rect.left - confirm.offsetWidth + rect.width;
if (leftPos < 10) leftPos = 10;
confirm.style.left = leftPos + 'px';
const yesBtn = confirm.querySelector('.confirm-yes');
const noBtn = confirm.querySelector('.confirm-no');
const cleanup = () => {
confirm.remove();
document.removeEventListener('keydown', keyHandler);
};
const keyHandler = (e) => {
if (e.key === 'Enter' || e.key === 'y' || e.key === 'Y') {
e.preventDefault();
cleanup();
callback(id);
showZigzagToast(btn, '🗑️ Deleted!', 'error');
playSound('delete');
} else if (e.key === 'Escape' || e.key === 'n' || e.key === 'N') {
e.preventDefault();
cleanup();
}
};
yesBtn.onclick = () => {
cleanup();
callback(id);
showZigzagToast(btn, '🗑️ Deleted!', 'error');
playSound('delete');
};
noBtn.onclick = () => cleanup();
// Focus the confirm box so keyboard events are captured
confirm.tabIndex = 0;
confirm.focus();
document.addEventListener('keydown', keyHandler);
// Close if clicking outside
setTimeout(() => {
document.addEventListener('click', function closeConfirm(e) {
if (!confirm.contains(e.target) && e.target !== btn) {
cleanup();
document.removeEventListener('click', closeConfirm);
}
});
}, 10);
}
function entryPw(id) { const e = entries.find(x => x.id == id); return e ? e.password : ''; }
function showBatchConfirm(btn, message, callback) {
const existing = document.querySelector('.batch-confirm-overlay');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.className = 'batch-confirm-overlay';
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;z-index:9999;background:transparent;';
const confirm = document.createElement('div');
confirm.className = 'custom-confirm show';
confirm.style.cssText = 'position:fixed;background:var(--bg2);border:1px solid var(--accent);border-radius:0.8rem;padding:0.7rem 1rem;z-index:10000;box-shadow:0 10px 30px rgba(0,0,0,0.5);font-size:0.8rem;color:var(--text);white-space:nowrap;';
confirm.innerHTML =
'' + message + '
' +
'' +
'Yes ' +
'No ' +
'
';
const rect = btn.getBoundingClientRect();
confirm.style.top = (rect.top - 60) + 'px';
let leftPos = rect.left - 20;
if (leftPos < 10) leftPos = 10;
confirm.style.left = leftPos + 'px';
overlay.appendChild(confirm);
document.body.appendChild(overlay);
const yesBtn = confirm.querySelector('.confirm-yes');
const noBtn = confirm.querySelector('.confirm-no');
const cleanup = () => { overlay.remove(); document.removeEventListener('keydown', keyHandler); };
const keyHandler = (e) => {
if (e.key === 'Enter' || e.key === 'y' || e.key === 'Y') { e.preventDefault(); cleanup(); callback(); playSound('delete'); }
else if (e.key === 'Escape' || e.key === 'n' || e.key === 'N') { e.preventDefault(); cleanup(); }
};
yesBtn.onclick = () => { cleanup(); callback(); playSound('delete'); };
noBtn.onclick = () => cleanup();
overlay.onclick = (e) => { if (e.target === overlay) cleanup(); };
confirm.tabIndex = 0; confirm.focus();
document.addEventListener('keydown', keyHandler);
}
function attachEvents() {
document.querySelectorAll('.delete-btn').forEach(b => b.onclick = function(ev) { ev.stopPropagation(); if (showTrash) { permanentDelete(this.dataset.id); } else { showConfirm(this, 'Delete this entry?', id => delEntry(id)); } });
document.querySelectorAll('.edit-btn').forEach(b => b.onclick = function(ev) { ev.stopPropagation(); openEdit(this.dataset.id); });
document.querySelectorAll('.star-btn').forEach(b => b.onclick = function(ev) { ev.stopPropagation(); toggleFavorite(this.dataset.id); });
document.querySelectorAll('.copy-p').forEach(b => b.onclick = async function(ev) { ev.stopPropagation(); const pw = entryPw(this.dataset.id); try { await navigator.clipboard.writeText(pw); this.textContent = '✓'; const btn = this; setTimeout(() => { btn.textContent = '📋'; }, 1000); showZigzagToast(this, '📋 Copied!', 'success'); playSound('copy'); } catch (e) { showZigzagToast(this, 'Failed', 'error'); } });
// Double-click entry to edit
document.querySelectorAll('[draggable="true"], .detail-card').forEach(el => {
el.addEventListener('dblclick', function(ev) {
const id = this.dataset.id;
if (id && !showTrash) { ev.stopPropagation(); openEdit(id); }
});
});
if (showView) {
document.querySelectorAll('.pw-display.pw-hover').forEach(el => {
el.addEventListener('mouseenter', function() {
const pw = entryPw(this.id.replace('p-', ''));
this.textContent = pw;
});
el.addEventListener('mouseleave', function() {
this.textContent = '••••••••';
});
});
}
}
function setupDrag() {
const c = document.getElementById('entriesContainer'); if (!c) return;
c.querySelectorAll('[draggable="true"]').forEach(el => {
el.ondragstart = function(e) { draggedId = this.dataset.id; const dragIds = selectedIds.has(parseInt(draggedId)) && selectedIds.size > 1 ? [...selectedIds] : [parseInt(draggedId)]; c.querySelectorAll('[draggable="true"]').forEach(card => { card.classList.toggle('drag-dim', dragIds.includes(parseInt(card.dataset.id))); }); e.dataTransfer.setData('text/plain', this.dataset.id); e.dataTransfer.effectAllowed = 'move'; if (dragIds.length > 1) { const cv = document.createElement('canvas'); cv.width = 100; cv.height = 50; const g = cv.getContext('2d'); for (let i = dragIds.length - 1; i >= 0; i--) { const ox = i * 4, oy = i * 4; g.fillStyle = i === 0 ? 'rgba(30,40,55,0.9)' : 'rgba(59,130,246,0.15)'; g.fillRect(ox, oy, 80, 36); g.strokeStyle = 'rgba(255,255,255,0.15)'; g.strokeRect(ox, oy, 80, 36); } g.fillStyle = 'rgba(0,0,0,0.7)'; g.fillRect(0, 34, 100, 16); g.fillStyle = '#fff'; g.font = '11px sans-serif'; g.textAlign = 'center'; g.fillText(dragIds.length + ' items', 50, 46); cv.style.position = 'fixed'; cv.style.top = '-1000px'; document.body.appendChild(cv); e.dataTransfer.setDragImage(cv, 6, 10); setTimeout(() => cv.remove(), 50); } };
el.ondragend = function(e) { c.querySelectorAll('.drag-dim').forEach(card => card.classList.remove('drag-dim')); draggedId = null; c.querySelectorAll('.drag-over').forEach(x => x.classList.remove('drag-over')); document.getElementById('trashBtn')?.classList.remove('drag-over'); };
el.ondragover = function(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (this.dataset.id !== draggedId) this.classList.add('drag-over'); };
el.ondragleave = function(e) { this.classList.remove('drag-over'); };
el.ondrop = function(e) { e.preventDefault(); e.stopPropagation(); this.classList.remove('drag-over'); const fromId = parseInt(e.dataTransfer.getData('text/plain')); const toId = parseInt(this.dataset.id); if (!fromId || !toId) return; const ids = selectedIds.has(fromId) && selectedIds.size > 1 ? [...selectedIds] : [fromId]; if (ids.length === 1 && ids[0] === toId) return; const base = order.length > 0 ? order : entries.filter(e => e).map(e => e.id); const filtered = base.filter(id => !ids.includes(id)); const idx = filtered.indexOf(toId); idx > -1 ? filtered.splice(idx, 0, ...ids) : filtered.push(...ids); order = filtered; // Ensure no entries are lost from order
entries.forEach(e => { if (!order.includes(e.id)) order.push(e.id); }); localStorage.setItem('entryOrder', JSON.stringify(order)); const map = new Map(entries.filter(e => e).map(e => [e.id, e])); entries = order.map(id => map.get(id)).filter(e => e); entries.sort((a, b) => (b.favorite || 0) - (a.favorite || 0)); render(); };
});
}
// ==================== EDIT ====================
function openEdit(id) {
let e = null;
for (let i = 0; i < entries.length; i++) { if (entries[i] && entries[i].id == id) { e = entries[i]; break; } }
if (!e) return;
const folderSelect = document.getElementById('editFolder');
folderSelect.innerHTML = '';
folders.forEach(f => {
if (!f) return;
const option = document.createElement('option');
option.value = f;
option.textContent = '📁 ' + f;
if (f === (e.folder || 'All')) option.selected = true;
folderSelect.appendChild(option);
});
document.getElementById('editId').value = id;
document.getElementById('editSite').value = e.site;
document.getElementById('editUsername').value = e.username;
document.getElementById('editPassword').value = e.password;
document.getElementById('editPassword').type = 'password';
document.getElementById('editModal').classList.add('show');
document.getElementById('editSite').focus();
playSound('open');
}
function closeEdit() { document.getElementById('editModal').classList.remove('show'); playSound('close'); }
function toggleEditPassword() { const f = document.getElementById('editPassword'); f.type = f.type === 'password' ? 'text' : 'password'; }
async function saveEdit() {
const id = document.getElementById('editId').value;
const site = document.getElementById('editSite').value.trim();
const username = document.getElementById('editUsername').value.trim();
const password = document.getElementById('editPassword').value;
const folder = document.getElementById('editFolder').value;
if (!site || !password) { toast('Site and password required', 'error'); return; }
try {
const enc = await encryptPwd(password);
const r = await fetch(API + '/entries/' + id, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken }, body: JSON.stringify({ site, username, encrypted_password: enc.encrypted, iv: enc.iv, folder }) });
if (r.ok) { toast('✅ Updated!'); closeEdit(); loadEntries(); playSound('success'); }
else { const d = await r.json(); toast('❌ ' + (d.error || 'Failed'), 'error'); }
} catch (e) { toast('⚠️ Error', 'error'); }
}
//======================== batch selection ==========================
function toggleSelectEntry(id, e) {
if (e?.shiftKey && lastSelectedId !== null) {
const ids = getFilteredEntries().map(x => x.id);
const i1 = ids.indexOf(lastSelectedId);
const i2 = ids.indexOf(id);
if (i1 > -1 && i2 > -1) {
const start = Math.min(i1, i2), end = Math.max(i1, i2);
for (let i = start; i <= end; i++) selectedIds.add(ids[i]);
}
} else if (e?.ctrlKey || e?.metaKey) {
if (selectedIds.has(id)) selectedIds.delete(id); else selectedIds.add(id);
} else {
if (selectedIds.size === 1 && selectedIds.has(id)) { selectedIds.clear(); }
else { selectedIds.clear(); selectedIds.add(id); }
}
lastSelectedId = id;
arrowAnchor = -1;
arrowFocus = -1;
updateBatchBar();
render();
}
function clearSelection() {
selectedIds.clear();
lastSelectedId = null;
arrowAnchor = -1;
arrowFocus = -1;
hideBatchBar();
render();
}
function updateBatchBar() {
const existing = document.getElementById('batchBar');
if (existing) existing.remove();
if (selectedIds.size === 0) return;
const bar = document.createElement('div');
bar.id = 'batchBar';
bar.className = 'batch-actions';
bar.innerHTML = `${selectedIds.size} selected `;
if (showTrash) {
bar.innerHTML += `
♻️ Restore All
🗑️ Delete
`;
} else {
bar.innerHTML += `
${folders.map(f => `📁 ${f} `).join('')}
Move
Delete
`;
}
bar.innerHTML += `Clear `;
document.body.appendChild(bar);
}
function hideBatchBar() {
const bar = document.getElementById('batchBar');
if (bar) bar.remove();
}
async function batchDelete() {
const count = selectedIds.size;
const btn = document.querySelector('#batchBar .btn-danger');
showBatchConfirm(btn || document.body, 'Move ' + count + ' entries to trash?', async () => {
for (const id of selectedIds) await delEntry(id, true);
toast('📦 Moved ' + count + ' entries to trash');
playSound('delete');
clearSelection();
await loadEntries();
});
}
async function batchPermanentDelete() {
const count = selectedIds.size;
const btn = document.querySelector('#batchBar .btn-danger');
showBatchConfirm(btn || document.body, 'Permanently delete ' + count + ' entries?', async () => {
for (const id of selectedIds) await permanentDelete(id, true);
toast('🗑️ Permanently deleted ' + count + ' entries');
playSound('error');
clearSelection();
await loadEntries();
});
}
async function batchRestore() {
const count = selectedIds.size;
for (const id of selectedIds) await restoreEntry(id, true);
toast('✅ Restored ' + count + ' entries');
playSound('success');
clearSelection();
await loadEntries();
}
async function batchMove() {
const folder = document.getElementById('batchFolder')?.value || 'All';
for (const id of selectedIds) {
const e = entries.find(x => x.id == id);
if (e) {
e.folder = folder;
const enc = await encryptPwd(e.password);
await fetch(API + '/entries/' + id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken },
body: JSON.stringify({ site: e.site, username: e.username, encrypted_password: enc.encrypted, iv: enc.iv, folder })
});
}
}
clearSelection();
loadEntries();
}
// ==================== ADD / DELETE ====================
async function addEntry() {
const site = document.getElementById('addSite').value.trim();
const user = document.getElementById('addUsername').value.trim();
const pass = document.getElementById('addPassword').value;
if (!site || !pass) { toast('❌ Site and password required', 'error'); return; }
if (user) localStorage.setItem('savedUsername', user);
const btn = document.getElementById('addEntryBtn');
btn.disabled = true;
try {
const enc = await encryptPwd(pass);
const folder = document.getElementById('addFolder').value;
const r = await fetch(API + '/entries', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken },
body: JSON.stringify({ site, username: user, encrypted_password: enc.encrypted, iv: enc.iv, encryption_method: 'client', folder })
});
if (r.ok) {
closeAdd();
toast('✅ Saved!');
loadEntries();
playSound('success');
} else {
const d = await r.json();
toast('❌ ' + (d.error || 'Failed'), 'error');
}
} catch (e) { toast('⚠️ Error', 'error'); }
finally { btn.disabled = false; }
}
async function delEntry(id, noToast) {
try {
const r = await fetch(API + '/entries/' + id, { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } });
if (r.ok) { selectedIds.delete(id); order = order.filter(x => x != id); localStorage.setItem('entryOrder', JSON.stringify(order)); if (!noToast) { toast('📦 Moved to trash'); await loadEntries(); playSound('delete'); } }
} catch (e) { if (!noToast) toast('Error', 'error'); }
}
// ==================== UTILS ====================
function searchEntries() {
const input = document.getElementById('searchInput');
searchQuery = input.value.trim();
const btn = document.getElementById('clearSearchBtn');
if (btn) btn.style.display = searchQuery ? 'block' : 'none';
loadEntries(searchQuery);
}
function showExportModal() {
const overlay = document.createElement('div');
overlay.className = 'custom-modal-overlay show';
overlay.innerHTML = ``;
document.body.appendChild(overlay);
document.getElementById('cancelExport').onclick = () => overlay.remove();
document.getElementById('confirmExport').onclick = async () => {
const pwd = document.getElementById('exportPassword').value;
if (!pwd) { toast('Enter your master password', 'error'); return; }
try {
const r = await fetch(API + '/reauth', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken },
body: JSON.stringify({ masterPassword: pwd })
});
if (r.ok) {
overlay.remove();
const b = new Blob([JSON.stringify(entries, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(b);
a.download = 'vault-' + new Date().toISOString().slice(0, 10) + '.json';
a.click();
URL.revokeObjectURL(a.href);
toast('Exported!');
playSound('success');
} else {
toast('❌ Invalid password', 'error');
}
} catch (e) { toast('⚠️ Error', 'error'); }
};
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
playSound('open');
}
function esc(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; }
function escRegex(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
function highlightText(text, query) {
if (!query || !query.trim()) return esc(text);
const re = new RegExp('(' + escRegex(query.trim()) + ')', 'gi');
return esc(text).replace(re, '$1 ');
}
function showShortcutsHelp() {
const overlay = document.createElement('div');
overlay.className = 'custom-modal-overlay show';
overlay.innerHTML = `⌨️ Keyboard Shortcuts Alt+N New entry Ctrl+A Select all Ctrl+F Search Alt+T Toggle trash Ctrl+L Lock vault Ctrl+S Save entry Del Delete selected Esc Close modal / deselect ◀ ▶ Detail view nav ? Show this help
💡 Click any entry to select, Shift+click for range, Ctrl+click to toggle
Got it
`;
document.body.appendChild(overlay);
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
}
function clearSearch() {
const input = document.getElementById('searchInput');
input.value = '';
searchQuery = '';
const btn = document.getElementById('clearSearchBtn');
if (btn) btn.style.display = 'none';
loadEntries();
input.focus();
}
// ==================== STARTUP – session persistence ====================
init();
applyTheme();
if (token && curUser) {
(async () => {
if (await restoreCryptoKey()) {
await loadFolders();
showVault();
loadEntries();
} else {
sessionStorage.clear();
token = null;
curUser = null;
document.getElementById('loginUsername').value = localStorage.getItem('savedLoginUser') || '';
}
})();
}
['click', 'keypress', 'scroll', 'mousemove'].forEach(e => document.addEventListener(e, () => { if (token) resetIdle(); }));
// Trash button as drop target (set up once, outside setupDrag to avoid duplicates)
(function() {
const trashBtn = document.getElementById('trashBtn');
if (trashBtn) {
trashBtn.addEventListener('dragover', e => { if (!showTrash) { e.preventDefault(); trashBtn.classList.add('drag-over'); } });
trashBtn.addEventListener('dragleave', () => trashBtn.classList.remove('drag-over'));
trashBtn.addEventListener('drop', async function(e) {
e.preventDefault();
this.classList.remove('drag-over');
const id = parseInt(e.dataTransfer.getData('text/plain'));
if (!id) return;
const ids = selectedIds.has(id) && selectedIds.size > 1 ? [...selectedIds] : [id];
for (const sid of ids) await delEntry(sid, true);
toast('📦 Moved ' + ids.length + ' entries to trash');
playSound('delete');
clearSelection();
await loadEntries();
});
}
})();
// Prevent native drag on non-card elements (table headers, text, etc.)
document.getElementById('entriesContainer').addEventListener('dragstart', function(e) {
if (!e.target?.closest?.('[draggable="true"]')) e.preventDefault();
});
// Document mousedown: rect selection on vault background, clear outside vault
document.addEventListener('mousedown', function(e) {
if (e.button !== 0 || rectState.active) return;
if (e.target?.closest?.('.entry-card,.entry-row,.entry-compact,.table-row-drag,.detail-card,.detail-nav,#batchBar,.custom-modal-overlay.show,.edit-modal.show,.modal-overlay.show,#genModal,#settingsMenu,.batch-confirm-overlay')) return;
if (e.target?.closest?.('button,input,select,.folders-bar,.toolbar,#trashActions,.settings-dropdown,.fab,.auth-section')) {
if (selectedIds.size > 0) clearSelection();
return;
}
if (e.target?.closest?.('.vault')) {
rectState.active = true;
rectState.startX = e.clientX;
rectState.startY = e.clientY;
rectState.started = false;
rectState.el = null;
selectedIds.clear();
lastSelectedId = null;
hideBatchBar();
document.getElementById('entriesContainer')?.querySelectorAll('.selected').forEach(el => el.classList.remove('selected'));
} else if (selectedIds.size > 0) {
clearSelection();
}
});
document.addEventListener('mousemove', function(e) {
if (!rectState.active) return;
const dx = e.clientX - rectState.startX;
const dy = e.clientY - rectState.startY;
if (!rectState.started && (dx > 5 || dx < -5 || dy > 5 || dy < -5)) {
rectState.started = true;
rectState.el = document.createElement('div');
rectState.el.id = 'rectSelect';
document.body.appendChild(rectState.el);
}
if (rectState.el) {
const x = Math.min(rectState.startX, e.clientX);
const y = Math.min(rectState.startY, e.clientY);
const w = Math.abs(dx);
const h = Math.abs(dy);
rectState.el.style.cssText = `left:${x}px;top:${y}px;width:${w}px;height:${h}px;display:block;position:fixed;pointer-events:none;z-index:999;border:1px solid var(--accent);background:rgba(59,130,246,0.1);`;
}
});
document.addEventListener('mouseup', function(e) {
if (!rectState.active) return;
rectState.active = false;
if (rectState.el) {
rectState.el.remove();
rectState.el = null;
if (rectState.started) {
const r = {
left: Math.min(rectState.startX, e.clientX),
top: Math.min(rectState.startY, e.clientY),
right: Math.max(rectState.startX, e.clientX),
bottom: Math.max(rectState.startY, e.clientY)
};
const container = document.getElementById('entriesContainer');
if (container) {
container.querySelectorAll('.entry-card,.entry-row,.entry-compact,.table-row-drag').forEach(el => {
const er = el.getBoundingClientRect();
if (er.left < r.right && er.right > r.left && er.top < r.bottom && er.bottom > r.top) {
const id = parseInt(el.dataset.id);
if (id) selectedIds.add(id);
}
});
}
if (selectedIds.size > 0) { updateBatchBar(); render(); }
}
}
});
document.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.ctrlKey && !e.altKey && !e.metaKey) { const a = document.activeElement; if (!a || a.tagName === 'BUTTON') return; e.preventDefault(); if (document.getElementById('editModal').classList.contains('show') && a.closest('.edit-box')) saveEdit(); else if (document.getElementById('addModal').classList.contains('show') && a.closest('.modal-box')) addEntry(); else if (!document.getElementById('authSection').classList.contains('hidden')) { if (a.id === 'loginUsername' || a.id === 'loginPassword') login(); else if (a.id === 'regPassword') register(); } } });
document.getElementById('genModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeGen(); });
document.getElementById('editModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeEdit(); });
// ==================== KEYBOARD SHORTCUTS ====================
document.addEventListener('keydown', function(e) {
const tag = document.activeElement?.tagName;
const isInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
// Escape – close any open modal or settings
if (e.key === 'Escape') {
if (rectState.active || rectState.el) { rectState.active = false; if (rectState.el) { rectState.el.remove(); rectState.el = null; } clearSelection(); return; }
if (selectedIds.size > 0) { clearSelection(); return; }
if (!document.getElementById('settingsMenu').classList.contains('hidden')) {
document.getElementById('settingsMenu').classList.add('hidden');
return;
}
if (document.getElementById('addModal').classList.contains('show')) { closeAdd(); return; }
if (document.getElementById('editModal').classList.contains('show')) { closeEdit(); return; }
if (document.getElementById('genModal').style.display === 'flex') { closeGen(); return; }
const confirm = document.querySelector('.custom-confirm.show');
if (confirm) confirm.remove();
return;
}
// Arrow keys for detail view navigation
if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && !isInput && view === 'detail' && document.getElementById('authSection').classList.contains('hidden')) {
e.preventDefault();
goDetail(e.key === 'ArrowLeft' ? -1 : 1);
return;
}
// Arrow keys — navigate entries in vault
if ((e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'ArrowLeft' || e.key === 'ArrowRight') && !isInput && document.getElementById('authSection').classList.contains('hidden') && view !== 'detail') {
e.preventDefault();
const filtered = getFilteredEntries();
if (!filtered.length) return;
const isNext = e.key === 'ArrowDown' || e.key === 'ArrowRight';
let idx = arrowFocus >= 0 ? arrowFocus : -1;
if (idx < 0 && selectedIds.size > 0) {
const firstId = [...selectedIds][0];
idx = filtered.findIndex(e => e.id == firstId);
}
if (view === 'grid' && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
if (idx < 0) idx = 0;
else { const cols = getGridCols(); if (e.key === 'ArrowDown') { const next = idx + cols; idx = next < filtered.length ? next : idx; } else { const prev = idx - cols; idx = prev >= 0 ? prev : idx; } }
} else {
if (isNext) idx = idx < filtered.length - 1 ? idx + 1 : 0;
else idx = idx > 0 ? idx - 1 : filtered.length - 1;
}
if (e.shiftKey) {
if (arrowAnchor < 0) arrowAnchor = idx;
arrowFocus = idx;
const start = Math.min(arrowAnchor, arrowFocus), end = Math.max(arrowAnchor, arrowFocus);
selectedIds.clear();
for (let i = start; i <= end; i++) selectedIds.add(filtered[i].id);
} else {
selectedIds.clear();
selectedIds.add(filtered[idx].id);
arrowAnchor = idx;
arrowFocus = idx;
}
updateBatchBar(); render(); playSound('click');
return;
}
// Enter — open edit for single selected entry
if (e.key === 'Enter' && !isInput && selectedIds.size === 1 && document.getElementById('authSection').classList.contains('hidden') && !document.getElementById('editModal').classList.contains('show') && !document.getElementById('addModal').classList.contains('show')) {
e.preventDefault();
openEdit([...selectedIds][0]);
return;
}
// ? or / to show shortcuts help (only in vault)
if ((e.key === '?' || e.key === '/') && !isInput) {
if (!document.getElementById('authSection').classList.contains('hidden')) return;
e.preventDefault();
showShortcutsHelp();
return;
}
// Delete key — move to trash or permanently delete selected entries
if (e.key === 'Delete' && !isInput && selectedIds.size > 0 && document.getElementById('authSection').classList.contains('hidden')) {
e.preventDefault();
if (showTrash) batchPermanentDelete();
else batchDelete();
return;
}
// Alt+N — New entry (Ctrl+N intercepted by browser)
if (e.altKey && !e.shiftKey && !e.ctrlKey && !e.metaKey && (e.key === 'n' || e.key === 'N') && !isInput && !document.getElementById('addModal').classList.contains('show') && document.getElementById('authSection').classList.contains('hidden')) {
e.preventDefault();
openAdd();
return;
}
// Alt+T — Toggle trash (Ctrl+T intercepted by browser)
if (e.altKey && !e.shiftKey && !e.ctrlKey && !e.metaKey && (e.key === 't' || e.key === 'T') && !isInput && document.getElementById('authSection').classList.contains('hidden')) {
e.preventDefault();
toggleTrash();
return;
}
// Only handle Ctrl+[key], no Shift/Alt/Meta
if (!e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) return;
// Prevent browser defaults for ALL our shortcuts BEFORE dispatching
const code = e.code;
if (code === 'KeyF' || code === 'KeyL' || code === 'KeyS') {
e.preventDefault();
}
if (e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey && (e.key === 'a' || e.key === 'A') && !isInput && document.getElementById('authSection').classList.contains('hidden')) {
e.preventDefault();
getFilteredEntries(view === 'grouped' || view === 'detail').forEach(e => selectedIds.add(e.id));
updateBatchBar();
render();
playSound('click');
return;
}
if (code === 'KeyF') {
const el = document.getElementById('searchInput');
if (el) { el.focus(); el.select(); }
} else if (code === 'KeyL') {
if (!isInput) doLogout();
} else if (code === 'KeyS') {
if (document.getElementById('addModal').classList.contains('show')) addEntry();
else if (document.getElementById('editModal').classList.contains('show')) saveEdit();
}
});
document.getElementById('loginUsername').focus();