UI improvements: fix keyboard shortcuts, search highlighting, strength meter on register, colored folders, shortcut help

- Rewrite keyboard shortcuts using e.code and early preventDefault() to reliably override browser defaults
- Add ? key and toolbar button for shortcuts help modal
- Add password strength meter to register form
- Add search highlighting in all view modes (grid/list/compact/table)
- Add hash-based color coding for folder chips
- Add highlightText utility with regex escaping
This commit is contained in:
2026-05-08 23:35:59 +01:00
parent c8c7b69ae0
commit c6504f70d1
4 changed files with 84 additions and 40 deletions
+2 -2
View File
@@ -115,8 +115,8 @@ input:focus, select:focus { border-color:var(--accent); }
/* Folders */
.folders-bar { display:flex; gap:0.4rem; margin-bottom:0.8rem; flex-wrap:wrap; align-items:center; padding:0.4rem 0.6rem; background:rgba(0,0,0,0.2); border-radius:1rem; }
.folder-chip { background:var(--bg2); border:1px solid var(--border); color:var(--text2); padding:0.3rem 0.8rem; border-radius:2rem; cursor:pointer; font-size:0.78rem; transition:0.2s; white-space:nowrap; }
.folder-chip:hover { background:var(--accent); color:#fff; border-color:var(--accent); }
.folder-chip.active { background:var(--accent); color:#fff; border-color:var(--accent); }
.folder-chip:hover { background:var(--chip-color, var(--accent)); color:#fff; border-color:var(--chip-color, var(--accent)); }
.folder-chip.active { background:var(--chip-color, var(--accent)); color:#fff; border-color:var(--chip-color, var(--accent)); }
.folder-count { background:rgba(0,0,0,0.3); padding:0.1rem 0.4rem; border-radius:1rem; margin-left:0.3rem; font-size:0.7rem; }
.folder-delete-btn { background:transparent; border:none; color:var(--danger); cursor:pointer; font-size:0.7rem; margin-left:0.2rem; opacity:0.7; }
.folder-delete-btn:hover { opacity:1; }
+3 -1
View File
@@ -77,9 +77,10 @@
<div id="registerForm" class="hidden">
<div class="input-group">
<input type="text" id="regUsername" placeholder="Username (min 3)" autocomplete="off">
<input type="password" id="regPassword" placeholder="Password (min 8)" autocomplete="off">
<input type="password" id="regPassword" placeholder="Password (min 8)" autocomplete="off" oninput="checkRegStrength()">
<button class="btn" id="registerBtn" onclick="register()">✨ Create</button>
</div>
<div class="strength-bar s0" id="regStrengthBar" style="margin-top:0.4rem;margin-bottom:0.2rem;flex-basis:100%"></div>
</div>
</div>
@@ -149,6 +150,7 @@
<div style="display:flex;gap:.4rem;align-items:center;flex-wrap:wrap">
<button class="btn btn-outline btn-sm" id="trashBtn" onclick="toggleTrash()" title="Trash">🗑️</button>
<button class="btn btn-outline btn-sm" id="selectBtn" onclick="toggleSelectMode()" title="Batch select">☐ Select</button>
<button class="btn btn-outline btn-sm" onclick="showShortcutsHelp()" title="Shortcuts">⌨️</button>
<div class="view-toggle" id="viewToggle">
<button class="view-btn active" data-view="grid">🟫 Grid</button>
<button class="view-btn" data-view="compact">📝 Compact</button>
+71 -37
View File
@@ -13,6 +13,7 @@ let entries = [];
let folders = ['All'];
let genPwdVal = '';
let cryptoKey = null;
let searchQuery = '';
let idleT, warnT, countT;
let draggedId = null;
let showTrash = false;
@@ -185,6 +186,13 @@ async function deleteFolderFromServer(name) {
} 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;
@@ -195,7 +203,8 @@ function renderFolders() {
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>`;
const color = folderColor(f);
html += `<span class="folder-chip${selectedFolder === f ? ' active' : ''}" ${color} 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;
@@ -366,6 +375,17 @@ function closeAdd() {
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');
@@ -507,8 +527,8 @@ function render() {
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>🌐 ' + highlightText(e.site, searchQuery) + '</td>' +
(showMail ? '<td>👤 ' + highlightText(e.username, searchQuery) + '</td>' : '') +
'<td class="password-cell"><span id="p-' + e.id + '">••••••••</span></td>';
if (!showTrash) {
h += '<td><span class="entry-folder">📁 ' + esc(e.folder || 'All') + '</span></td>' +
@@ -556,8 +576,8 @@ function gridC(e) {
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-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 + '">••••••••</span><div>' +
@@ -583,8 +603,8 @@ function listC(e) {
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>';
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" id="p-' + e.id + '">••••••••</span>' +
@@ -610,8 +630,8 @@ function compC(e) {
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>';
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 + '">••••••••</span>';
@@ -878,9 +898,10 @@ async function delEntry(id) {
// ==================== UTILS ====================
function searchEntries() {
const input = document.getElementById('searchInput');
searchQuery = input.value.trim();
const btn = document.getElementById('clearSearchBtn');
if (btn) btn.style.display = input.value.trim() ? 'block' : 'none';
loadEntries(input.value);
if (btn) btn.style.display = searchQuery ? 'block' : 'none';
loadEntries(searchQuery);
}
function showExportModal() {
const overlay = document.createElement('div');
@@ -916,9 +937,23 @@ function showExportModal() {
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:360px"><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 / settings</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><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();
@@ -947,9 +982,8 @@ if (token && curUser) {
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) ====================
// ==================== KEYBOARD SHORTCUTS ====================
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';
@@ -967,34 +1001,34 @@ document.addEventListener('keydown', function(e) {
return;
}
// Only handle Ctrl+key without Shift, Alt, etc.
// ? 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;
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') {
// 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 (e.key === 'l' || e.key === 'L') {
e.preventDefault();
} else if (code === 'KeyL') {
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();
}
} else if (code === 'KeyS') {
if (document.getElementById('addModal').classList.contains('show')) addEntry();
else if (document.getElementById('editModal').classList.contains('show')) saveEdit();
}
});
+8
View File
@@ -0,0 +1,8 @@
# Remaining Security Issues
1. **No rate limiting on `/reauth`** — brute-force possible via export dialog
2. **No Content Security Policy (CSP)** header — XSS could leak crypto key from sessionStorage
3. **Crypto key in sessionStorage (extractable)** — necessary for refresh persistence, but XSS can steal it. HttpOnly cookie + service worker is more secure but complex
4. **No session rotation** — same token until logout; if leaked, valid for 24h
5. **No 2FA** — opted out of TOTP implementation
6. **Password generator modulo bias**`c.charAt(arr[i] % c.length)` has slight bias when c.length does not divide 2^32; not practically exploitable