Files
Password-Manager/js/app.js
T

970 lines
48 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 curUser = sessionStorage.getItem('currentUsername');
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 idleT, warnT, countT;
let draggedId = null;
let showTrash = false;
let selectMode = false;
let selectedIds = new Set();
// ==================== 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 }, false, ['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]'; } }
// ==================== 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 },
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 }
});
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 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;
html += `<span class="folder-chip${selectedFolder === f ? ' active' : ''}" 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;
}
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 } }); if (r.ok) { toast('✅ Restored!'); loadEntries(); playSound('success'); } } catch (e) { toast('⚠️ Error', 'error'); }
}
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 } }); if (r.ok) { toast('🗑️ Permanently deleted'); 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 } }); if (r.ok) { toast('🗑️ Trash emptied'); 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;
}
// ==================== 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 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 = ''; for (let i = 0; i < l; i++) p += c.charAt(Math.floor(Math.random() * 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 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 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; curUser = u;
sessionStorage.setItem('masterPassword', p);
sessionStorage.setItem('salt', d.salt);
cryptoKey = await deriveKey(p, d.salt);
sessionStorage.setItem('authToken', token);
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; curUser = u;
sessionStorage.setItem('masterPassword', p);
sessionStorage.setItem('salt', d.salt);
cryptoKey = await deriveKey(p, d.salt);
sessionStorage.setItem('authToken', token);
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; }
}
function doLogout() {
saveUsername();
clearTimeout(idleT); clearTimeout(warnT); clearInterval(countT);
document.getElementById('idleWarning').classList.remove('show');
token = null; 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 });
} else {
entries.push({ id: e.id, site: e.site, username: e.username, password: e.password || '', folder: e.folder || 'All', deleted_at: e.deleted_at });
}
}
entries = applyOrder(entries);
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>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 => {
h += '<tr class="table-row-drag" draggable="true" data-id="' + e.id + '">' +
'<td>🌐 ' + esc(e.site) + '</td>' +
(showMail ? '<td>👤 ' + esc(e.username) + '</td>' : '') +
'<td class="password-cell"><span id="p-' + e.id + '" data-pw="' + esc(e.password) + '">••••••••</span></td>';
if (!showTrash) {
h += '<td><span class="entry-folder">📁 ' + esc(e.folder || 'All') + '</span></td>' +
'<td class="actions-cell">' +
(selectMode ? `<input type="checkbox" class="select-checkbox" style="position:static;margin-right:8px;" ${selectedIds.has(e.id) ? 'checked' : ''} onclick="event.stopPropagation();toggleSelectEntry(${e.id})">` : '') +
(showView ? '<button class="icon-btn toggle-p" data-id="' + e.id + '">👁️</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">' +
(selectMode ? `<input type="checkbox" class="select-checkbox" style="position:static;margin-right:8px;" ${selectedIds.has(e.id) ? 'checked' : ''} onclick="event.stopPropagation();toggleSelectEntry(${e.id})">` : '') +
'<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) {
let html = '<div class="entry-card" draggable="' + (!selectMode) + '" data-id="' + e.id + '">';
if (selectMode) {
html += `<input type="checkbox" class="select-checkbox" ${selectedIds.has(e.id) ? 'checked' : ''} onclick="event.stopPropagation();toggleSelectEntry(${e.id})">`;
}
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="edit-btn" data-id="' + e.id + '">✏️</button>';
html += '<button class="delete-btn" data-id="' + e.id + '">✕</button>';
}
html += '</div>';
html += '<div class="card-site">🌐 ' + esc(e.site) + '</div>';
if (showMail) html += '<div class="card-user">👤 ' + esc(e.username) + '</div>';
html += '<div class="card-folder">📁 ' + esc(e.folder || 'All') + '</div>';
if (!showTrash) {
html += '<div class="card-password"><span id="p-' + e.id + '" data-pw="' + esc(e.password) + '">••••••••</span><div>' +
(showView ? '<button class="icon-btn toggle-p" data-id="' + e.id + '">👁️</button>' : '') +
'<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) {
let html = '<div class="entry-row" draggable="' + (!selectMode) + '" data-id="' + e.id + '">';
if (selectMode) {
html += `<input type="checkbox" class="select-checkbox" ${selectedIds.has(e.id) ? 'checked' : ''} onclick="event.stopPropagation();toggleSelectEntry(${e.id})">`;
}
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="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">🌐 ' + esc(e.site) + '</span>';
if (showMail) html += '<span class="entry-user">👤 ' + esc(e.username) + '</span>';
if (!showTrash) {
html += '<span class="entry-folder">📁 ' + esc(e.folder || 'All') + '</span>';
html += '<div class="password-field"><span class="password-text" id="p-' + e.id + '" data-pw="' + esc(e.password) + '">••••••••</span>' +
(showView ? '<button class="icon-btn toggle-p" data-id="' + e.id + '">👁️</button>' : '') +
'<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) {
let html = '<div class="entry-compact" draggable="' + (!selectMode) + '" data-id="' + e.id + '">';
if (selectMode) {
html += `<input type="checkbox" class="select-checkbox" ${selectedIds.has(e.id) ? 'checked' : ''} onclick="event.stopPropagation();toggleSelectEntry(${e.id})">`;
}
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="edit-btn" data-id="' + e.id + '">✏️</button>';
html += '<button class="delete-btn" data-id="' + e.id + '">✕</button>';
}
html += '</div>';
html += '<span>🌐 ' + esc(e.site) + '</span>';
if (showMail) html += '<span>👤 ' + esc(e.username) + '</span>';
if (!showTrash) {
html += '<span class="entry-folder">📁 ' + esc(e.folder || 'All') + '</span>';
html += '<span id="p-' + e.id + '" data-pw="' + esc(e.password) + '">••••••••</span>';
if (showView) html += '<button class="icon-btn toggle-p" data-id="' + e.id + '">👁️</button>';
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 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('.toggle-p').forEach(b => b.onclick = function(ev) { ev.stopPropagation(); const el = document.getElementById('p-' + this.dataset.id); el.textContent = el.textContent === '••••••••' ? el.dataset.pw : '••••••••'; });
document.querySelectorAll('.copy-p').forEach(b => b.onclick = async function(ev) { ev.stopPropagation(); const el = document.getElementById('p-' + this.dataset.id); try { await navigator.clipboard.writeText(el.dataset.pw); this.textContent = '✓'; const btn = this; setTimeout(() => { btn.textContent = '📋'; }, 1000); showZigzagToast(this, '📋 Copied!', 'success'); playSound('copy'); } catch (e) { showZigzagToast(this, 'Failed', 'error'); } });
}
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')); };
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 || fromId === toId) return; let fi = -1, ti = -1; for (let i = 0; i < entries.length; i++) { if (entries[i] && entries[i].id === fromId) fi = i; if (entries[i] && entries[i].id === toId) ti = i; } if (fi > -1 && ti > -1 && fi !== ti) { const moved = entries.splice(fi, 1)[0]; entries.splice(ti, 0, moved); order = entries.map(e => e.id); localStorage.setItem('entryOrder', JSON.stringify(order)); 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 }, 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 operations ==========================
function toggleSelectMode() {
selectMode = !selectMode;
document.getElementById('selectBtn').textContent = selectMode ? '☑ Deselect' : '☐ Select';
document.getElementById('selectBtn').classList.toggle('active', selectMode);
if (!selectMode) {
selectedIds.clear();
hideBatchBar();
}
render();
}
function toggleSelectEntry(id) {
if (selectedIds.has(id)) {
selectedIds.delete(id);
} else {
selectedIds.add(id);
}
updateBatchBar();
}
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="toggleSelectMode()">Cancel</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);
selectedIds.clear();
hideBatchBar();
loadEntries();
}
async function batchPermanentDelete() {
if (!confirm(`Permanently delete ${selectedIds.size} entries?`)) return;
for (const id of selectedIds) await permanentDelete(id);
selectedIds.clear();
hideBatchBar();
loadEntries();
}
async function batchRestore() {
for (const id of selectedIds) await restoreEntry(id);
selectedIds.clear();
hideBatchBar();
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;
await fetch(API + '/entries/' + id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ site: e.site, username: e.username, encrypted_password: await encryptPwd(e.password), iv: 'batch', folder })
});
}
}
selectedIds.clear();
hideBatchBar();
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 },
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 } });
if (r.ok) { order = order.filter(x => x != id); localStorage.setItem('entryOrder', JSON.stringify(order)); toast('📦 Moved to trash'); loadEntries(); playSound('delete'); }
} catch (e) { toast('Error', 'error'); }
}
// ==================== UTILS ====================
function searchEntries() {
const input = document.getElementById('searchInput');
const btn = document.getElementById('clearSearchBtn');
if (btn) btn.style.display = input.value.trim() ? 'block' : 'none';
loadEntries(input.value);
}
function exportPasswords() { if (!confirm('Export plain text?')) return; 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!'); }
function esc(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; }
function clearSearch() {
const input = document.getElementById('searchInput');
input.value = '';
const btn = document.getElementById('clearSearchBtn');
if (btn) btn.style.display = 'none';
loadEntries();
input.focus();
}
// ==================== STARTUP session persistence ====================
init();
applyTheme();
if (token && curUser) {
const savedPassword = sessionStorage.getItem('masterPassword');
const savedSalt = sessionStorage.getItem('salt');
if (savedPassword && savedSalt) {
deriveKey(savedPassword, savedSalt).then(key => {
cryptoKey = key;
loadFolders().then(() => {
showVault();
loadEntries();
});
}).catch(() => {
sessionStorage.clear();
token = null;
curUser = null;
});
} else {
sessionStorage.clear();
token = null;
curUser = null;
}
}
['click', 'keypress', 'scroll', 'mousemove'].forEach(e => document.addEventListener(e, () => { if (token) resetIdle(); }));
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 (override browser) ====================
document.addEventListener('keydown', function(e) {
// Don't fire when typing in inputs / textareas / selects
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 (!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;
}
// Only handle Ctrl+key without Shift, Alt, etc.
if (!e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) return;
if (e.key === 'n' || e.key === 'N') {
e.preventDefault();
if (!isInput && !document.getElementById('addModal').classList.contains('show')) {
openAdd();
}
} else if (e.key === 'f' || e.key === 'F') {
e.preventDefault();
const searchInput = document.getElementById('searchInput');
if (searchInput) {
searchInput.focus();
searchInput.select();
}
} else if (e.key === 't' || e.key === 'T') {
e.preventDefault();
if (!isInput) toggleTrash();
} else if (e.key === 'l' || e.key === 'L') {
e.preventDefault();
if (!isInput) doLogout();
} else if (e.key === 's' || e.key === 'S') {
e.preventDefault();
// Save if add/edit modal is open
if (document.getElementById('addModal').classList.contains('show')) {
addEntry();
} else if (document.getElementById('editModal').classList.contains('show')) {
saveEdit();
}
}
});