Files
Password-Manager/js/app.js
T
Zaki a97d05412e Fix duplicate trash drop listener and rectangle selection bugs
- Move trash button drop listener out of setupDrag() to an IIFE so it
  only registers once (was accumulating listeners on every render() call,
  causing multiple toast messages on single drop).
- Fix rectangle selection mousedown handler: use optional chaining on
  closest() for safety with text nodes; avoid calling render() inside
  mousedown (which would detach the event target's children mid-handler)
  and instead just remove .selected class directly from the DOM.
2026-05-09 14:34:20 +01:00

1258 lines
66 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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 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 += `<span class="folder-chip${selectedFolder === f ? ' active' : ''}" ${color} data-folder="${esc(f)}" onclick="selectFolder('${esc(f)}')">📁 ${esc(f)}<span class="folder-count">${count}</span>${f !== 'All' ? `<button class="folder-delete-btn" onclick="event.stopPropagation();showDeleteFolderConfirm('${esc(f)}')">✕</button>` : ''}</span>`;
});
html += `<button class="folder-add-btn" onclick="showAddFolderModal()">+ New</button>`;
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 = `<div class="custom-modal"><h3>📁 New Folder</h3><input type="text" id="newFolderName" placeholder="Folder name"><div class="modal-actions"><button class="btn btn-outline btn-sm" id="cancelAddFolder">Cancel</button><button class="btn btn-sm" id="confirmAddFolder">Create</button></div></div>`;
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 = `<div class="custom-modal"><h3>🗑️ Delete Folder</h3><p style="color:var(--text2);margin-bottom:1rem;">Delete "${folderName}"? Entries move to "All".</p><div class="modal-actions"><button class="btn btn-outline btn-sm" id="cancelDeleteFolder">Cancel</button><button class="btn btn-sm btn-danger" id="confirmDeleteFolder">Delete</button></div></div>`;
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) {
try { const r = await fetch(API + '/entries/' + id + '/restore', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); if (r.ok) { toast('✅ Restored!'); await loadEntries(); playSound('success'); } } catch (e) { 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) {
if (!confirm('Permanently delete?')) return;
try { const r = await fetch(API + '/entries/' + id + '?permanent=1', { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); if (r.ok) { toast('🗑️ Permanently deleted'); await loadEntries(); playSound('error'); } } catch (e) { toast('⚠️ Error', 'error'); }
}
async function emptyTrash() {
if (!confirm('Delete ALL trashed entries?')) return;
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.querySelectorAll('.view-btn').forEach(b => b.classList.toggle('active', b.dataset.view === view));
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();
document.getElementById('viewToggle').addEventListener('click', e => {
if (e.target.classList.contains('view-btn')) {
document.querySelectorAll('.view-btn').forEach(b => b.classList.remove('active'));
e.target.classList.add('active');
view = e.target.dataset.view;
localStorage.setItem('vaultView', view);
render();
playSound('click');
}
});
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 arr = new Uint32Array(l); crypto.getRandomValues(arr); for (let i = 0; i < l; i++) p += c.charAt(arr[i] % c.length); genPwdVal = p; document.getElementById('genPreview').textContent = p; }
function useGen() {
if (!genPwdVal) genPwd();
// Put the generated password into the addmodals 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');
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 = `<div class="custom-modal"><h3>🔑 One more step</h3><p style="color:var(--text2);margin-bottom:1rem">Enter your master password to unlock the vault</p><input type="password" id="passkeyTempPwd" placeholder="Master password" style="width:100%"><div class="modal-actions" style="margin-top:1rem"><button class="btn btn-sm" id="passkeyTempBtn">Unlock</button></div></div>`;
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() {
if (showTrash) return entries;
if (selectedFolder === 'All') return entries;
return entries.filter(e => (e.folder || 'All') === selectedFolder);
}
// ==================== 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 = '<div style="text-align:center;color:var(--text2);padding:2rem;grid-column:1/-1">' + (showTrash ? '📭 Trash empty' : '📭 No entries') + '</div>'; return; }
if (view === 'table') {
let h = '<table><thead><tr><th></th><th>Site</th>' + (showMail ? '<th>User</th>' : '') + '<th>Password</th>' + (!showTrash ? '<th>Folder</th>' : '<th>Deleted</th>') + '<th>Actions</th></tr></thead><tbody>';
filtered.forEach(e => {
const sel = selectedIds.has(e.id);
h += '<tr class="table-row-drag' + (e.favorite ? ' favorite' : '') + (sel ? ' selected' : '') + '" draggable="true" data-id="' + e.id + '" onclick="toggleSelectEntry(' + e.id + ', event)">' +
'<td>' + (e.favorite ? '⭐' : '') + '</td>' +
'<td>🌐 ' + highlightText(e.site, searchQuery) + '</td>' +
(showMail ? '<td>👤 ' + highlightText(e.username, searchQuery) + '</td>' : '') +
'<td class="password-cell"><span id="p-' + e.id + '" class="pw-display' + (showView ? ' pw-hover' : '') + '">••••••••</span></td>';
if (!showTrash) {
h += '<td><span class="entry-folder">📁 ' + esc(e.folder || 'All') + '</span></td>' +
'<td class="actions-cell">' +
'<button class="star-btn" data-id="' + e.id + '">' + (e.favorite ? '⭐' : '☆') + '</button> ' +
'<button class="icon-btn copy-p" data-id="' + e.id + '">📋</button> ' +
'<button class="edit-btn" data-id="' + e.id + '" style="position:static;display:inline-flex;vertical-align:middle;">✏️</button> ' +
'<button class="delete-btn" data-id="' + e.id + '" style="position:static;display:inline-flex;vertical-align:middle;">✕</button>' +
'</td>';
} else {
h += '<td><span class="trash-badge">🗑️ ' + timeAgo(e.deleted_at) + '</span></td>' +
'<td class="actions-cell">' +
'<button class="restore-btn" onclick="restoreEntry(' + e.id + ')">↩️</button> ' +
'<button class="delete-btn" data-id="' + e.id + '" style="position:static;display:inline-flex;vertical-align:middle;">✕</button>' +
'</td>';
}
h += '</tr>';
});
h += '</tbody></table>';
c.innerHTML = h;
} else {
c.innerHTML = filtered.map(e => {
if (view === 'grid') 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 = '<div class="entry-card' + (e.favorite ? ' favorite' : '') + (sel ? ' selected' : '') + '" draggable="true" data-id="' + e.id + '" onclick="toggleSelectEntry(' + e.id + ', event)">';
html += '<div class="action-btns">';
if (showTrash) {
html += '<button class="restore-btn" onclick="restoreEntry(' + e.id + ')">↩️</button>';
html += '<button class="delete-btn" data-id="' + e.id + '">✕</button>';
} else {
html += '<button class="star-btn" data-id="' + e.id + '">' + (e.favorite ? '⭐' : '☆') + '</button>';
html += '<button class="edit-btn" data-id="' + e.id + '">✏️</button>';
html += '<button class="delete-btn" data-id="' + e.id + '">✕</button>';
}
html += '</div>';
html += '<div class="card-site">🌐 ' + highlightText(e.site, searchQuery) + '</div>';
if (showMail) html += '<div class="card-user">👤 ' + highlightText(e.username, searchQuery) + '</div>';
html += '<div class="card-folder">📁 ' + esc(e.folder || 'All') + '</div>';
if (!showTrash) {
html += '<div class="card-password"><span id="p-' + e.id + '" class="pw-display' + (showView ? ' pw-hover' : '') + '">••••••••</span><div>' +
'<button class="icon-btn copy-p" data-id="' + e.id + '">📋</button></div></div>';
} else {
html += '<div class="trash-info">🗑️ ' + timeAgo(e.deleted_at) + '</div>';
}
html += '</div>';
return html;
}
function listC(e) {
const sel = selectedIds.has(e.id);
let html = '<div class="entry-row' + (e.favorite ? ' favorite' : '') + (sel ? ' selected' : '') + '" draggable="true" data-id="' + e.id + '" onclick="toggleSelectEntry(' + e.id + ', event)">';
html += '<div class="action-btns">';
if (showTrash) {
html += '<button class="restore-btn" onclick="restoreEntry(' + e.id + ')">↩️</button>';
html += '<button class="delete-btn" data-id="' + e.id + '">✕</button>';
} else {
html += '<button class="star-btn" data-id="' + e.id + '">' + (e.favorite ? '⭐' : '☆') + '</button>';
html += '<button class="edit-btn" data-id="' + e.id + '">✏️</button>';
html += '<button class="delete-btn" data-id="' + e.id + '">✕</button>';
}
html += '</div>';
html += '<div class="entry-info"><span class="entry-site">🌐 ' + highlightText(e.site, searchQuery) + '</span>';
if (showMail) html += '<span class="entry-user">👤 ' + highlightText(e.username, searchQuery) + '</span>';
if (!showTrash) {
html += '<span class="entry-folder">📁 ' + esc(e.folder || 'All') + '</span>';
html += '<div class="password-field"><span class="password-text pw-display' + (showView ? ' pw-hover' : '') + '" id="p-' + e.id + '">••••••••</span>' +
'<button class="icon-btn copy-p" data-id="' + e.id + '">📋</button></div>';
} else {
html += '<span class="trash-badge">🗑️ ' + timeAgo(e.deleted_at) + '</span>';
}
html += '</div></div>';
return html;
}
function compC(e) {
const sel = selectedIds.has(e.id);
let html = '<div class="entry-compact' + (e.favorite ? ' favorite' : '') + (sel ? ' selected' : '') + '" draggable="true" data-id="' + e.id + '" onclick="toggleSelectEntry(' + e.id + ', event)">';
html += '<div class="action-btns">';
if (showTrash) {
html += '<button class="restore-btn" onclick="restoreEntry(' + e.id + ')">↩️</button>';
html += '<button class="delete-btn" data-id="' + e.id + '">✕</button>';
} else {
html += '<button class="star-btn" data-id="' + e.id + '">' + (e.favorite ? '⭐' : '☆') + '</button>';
html += '<button class="edit-btn" data-id="' + e.id + '">✏️</button>';
html += '<button class="delete-btn" data-id="' + e.id + '">✕</button>';
}
html += '</div>';
html += '<span>🌐 ' + highlightText(e.site, searchQuery) + '</span>';
if (showMail) html += '<span>👤 ' + highlightText(e.username, searchQuery) + '</span>';
if (!showTrash) {
html += '<span class="entry-folder">📁 ' + esc(e.folder || 'All') + '</span>';
html += '<span id="p-' + e.id + '" class="pw-display' + (showView ? ' pw-hover' : '') + '">••••••••</span>';
html += '<button class="icon-btn copy-p" data-id="' + e.id + '">📋</button>';
} else {
html += '<span class="trash-badge">🗑️ ' + timeAgo(e.deleted_at) + '</span>';
}
html += '</div>';
return html;
}
// ==================== 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 =
'<div class="confirm-text">' + message + '</div>' +
'<div class="confirm-btns">' +
'<button class="confirm-yes">Yes</button>' +
'<button class="confirm-no">No</button>' +
'</div>';
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 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'); } });
// Password reveal on hover
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; this.style.opacity = '0.5'; e.dataTransfer.setData('text/plain', this.dataset.id); e.dataTransfer.effectAllowed = 'move'; };
el.ondragend = function(e) { this.style.opacity = '1'; 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; 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');
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;
updateBatchBar();
render();
}
function clearSelection() {
selectedIds.clear();
lastSelectedId = null;
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 = `<span class="selected-count">${selectedIds.size} selected</span>`;
if (showTrash) {
bar.innerHTML += `
<button class="btn btn-sm restore-btn" onclick="batchRestore()">↩️ Restore All</button>
<button class="btn btn-sm btn-danger" onclick="batchPermanentDelete()">🗑️ Delete</button>
`;
} else {
bar.innerHTML += `
<select id="batchFolder" style="font-size:0.75rem;padding:0.2rem 0.5rem;">
${folders.map(f => `<option value="${f}">📁 ${f}</option>`).join('')}
</select>
<button class="btn btn-sm" onclick="batchMove()">Move</button>
<button class="btn btn-sm btn-danger" onclick="batchDelete()">Delete</button>
`;
}
bar.innerHTML += `<button class="btn btn-sm btn-outline" onclick="clearSelection()">Clear</button>`;
document.body.appendChild(bar);
}
function hideBatchBar() {
const bar = document.getElementById('batchBar');
if (bar) bar.remove();
}
async function batchDelete() {
if (!confirm(`Move ${selectedIds.size} entries to trash?`)) return;
for (const id of selectedIds) await delEntry(id);
clearSelection();
}
async function batchPermanentDelete() {
if (!confirm(`Permanently delete ${selectedIds.size} entries?`)) return;
for (const id of selectedIds) await permanentDelete(id);
clearSelection();
}
async function batchRestore() {
for (const id of selectedIds) await restoreEntry(id);
clearSelection();
}
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) {
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)); toast('📦 Moved to trash'); await loadEntries(); playSound('delete'); }
} catch (e) { 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 = `<div class="custom-modal"><h3>📤 Export Passwords</h3><p style="color:var(--text2);margin-bottom:1rem;">Re-enter master password to export plaintext passwords</p><input type="password" id="exportPassword" placeholder="Master password" style="width:100%"><div class="modal-actions" style="margin-top:1rem"><button class="btn btn-outline btn-sm" id="cancelExport">Cancel</button><button class="btn btn-sm" id="confirmExport">Export</button></div></div>`;
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, '<mark style="background:var(--accent);color:#fff;border-radius:3px;padding:0 2px">$1</mark>');
}
function showShortcutsHelp() {
const overlay = document.createElement('div');
overlay.className = 'custom-modal-overlay show';
overlay.innerHTML = `<div class="custom-modal" style="min-width:380px"><h3>⌨️ Keyboard Shortcuts</h3><div style="display:grid;grid-template-columns:auto 1fr;gap:0.5rem 1.2rem;font-size:0.85rem;margin-bottom:1rem"><span style="background:var(--input);padding:0.15rem 0.6rem;border-radius:0.4rem;font-family:monospace;text-align:center">Ctrl+N</span><span>New entry</span><span style="background:var(--input);padding:0.15rem 0.6rem;border-radius:0.4rem;font-family:monospace;text-align:center">Ctrl+F</span><span>Search</span><span style="background:var(--input);padding:0.15rem 0.6rem;border-radius:0.4rem;font-family:monospace;text-align:center">Ctrl+T</span><span>Toggle trash</span><span style="background:var(--input);padding:0.15rem 0.6rem;border-radius:0.4rem;font-family:monospace;text-align:center">Ctrl+L</span><span>Lock vault</span><span style="background:var(--input);padding:0.15rem 0.6rem;border-radius:0.4rem;font-family:monospace;text-align:center">Ctrl+S</span><span>Save entry</span><span style="background:var(--input);padding:0.15rem 0.6rem;border-radius:0.4rem;font-family:monospace;text-align:center">Esc</span><span>Close modal / deselect</span><span style="background:var(--input);padding:0.15rem 0.6rem;border-radius:0.4rem;font-family:monospace;text-align:center">?</span><span>Show this help</span></div><p style="color:var(--text2);font-size:0.8rem;margin-bottom:0.8rem">💡 Click any entry to select, Shift+click for range, Ctrl+click to toggle</p><div class="modal-actions"><button class="btn btn-sm" onclick="this.closest('.custom-modal-overlay').remove()">Got it</button></div></div>`;
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);
clearSelection();
});
}
})();
// Rectangle selection
document.getElementById('entriesContainer').addEventListener('mousedown', function(e) {
if (rectState.active || e.button !== 0) return;
if (e.target?.closest?.('[draggable="true"]') || e.target?.closest?.('.action-btns')) return;
rectState.active = true;
rectState.startX = e.clientX;
rectState.startY = e.clientY;
rectState.started = false;
rectState.el = null;
// Clear selection without full re-render to avoid detaching event target
selectedIds.clear();
lastSelectedId = null;
hideBatchBar();
document.getElementById('entriesContainer')?.querySelectorAll('.selected').forEach(el => el.classList.remove('selected'));
});
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('keypress', e => { if (e.key === 'Enter') { if (document.getElementById('passwordInput') === document.activeElement) addEntry(); else if (document.getElementById('loginPassword') === document.activeElement) login(); else if (document.getElementById('regPassword') === document.activeElement) 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;
}
// ? 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;
}
// 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 === 'KeyN' || code === 'KeyF' || code === 'KeyT' || code === 'KeyL' || code === 'KeyS') {
e.preventDefault();
}
if (code === 'KeyN') {
if (!isInput && !document.getElementById('addModal').classList.contains('show')) openAdd();
} else if (code === 'KeyF') {
const el = document.getElementById('searchInput');
if (el) { el.focus(); el.select(); }
} else if (code === 'KeyT') {
if (!isInput) toggleTrash();
} 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();
}
});