Files
Password-Manager/js/app.overlays.js
T
r-zakarya a42e4b205d feat(entries): encrypt template at rest, guided tour, import fixes, cleanup
Batched session work sharing app.js / index.html / Entries.pas, so it can't
split cleanly without interactive hunk staging.

- feat: encrypt `template` metadata at rest (template_enc/iv, added to
  ENCRYPTED_META_FIELDS). withEncryptedMeta skips an absent template key so
  partial re-ships (add-tag, move-to-folder) don't wipe it via LHasTemplate.
  Cleartext column kept as migration fallback. +3 unit tests.
- feat: first-run guided tour ("How it works") — spotlight + bubble, no GIFs,
  re-launchable from Settings, seen-flag in DPAPI prefs.
- fix(import): preserve original created_at on restore (was stamped to import
  time); restore entry icons on overwrite (PUT ignores icon_b64).
- fix(settings): correct clipboard-privacy copy (already excluded from Win+V);
  PIN text 4-6 -> 4-12; reorder Set-PIN above unlock-method; move tray/startup
  toggles to General; dedicated backup-password button + warning status; tab icons.
- chore: remove dead legacy monolith (app-legacy.js, index-legacy.html,
  style-legacy.css) + unused passkeyBtn stub.
- docs: full-source review (CODE_AUDIT 6b), template + favorite/pinned notes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 18:32:37 +01:00

506 lines
22 KiB
JavaScript

// ============================================================
// app.overlays.js — QUICK SEARCH + CHEATSHEET + PASSWORD HISTORY (§3.1)
// ============================================================
//
// Three small tray/overlay UI features that were interleaved in the monofile
// (quick-search functions sit on both sides of the cheatsheet + history
// modal), so they're extracted together as one contiguous block rather than
// surgically split. Pure declarations + a few module lets/consts, no
// top-level side effects → loads BEFORE app.js. All refs to state, api,
// Bridge, render, decryptPwd resolve via shared global scope at call time.
//
// ============================================================
// QUICK SEARCH MODAL (tray menu → fast password copy)
// ============================================================
//
// Trades on the autofill workflow when SendInput can't reach the target
// (UIPI-elevated app, native non-text-input UI, etc.): right-click tray →
// Quick search → type → Enter → password is on the clipboard, ready to
// paste with Ctrl+V. App returns to whatever state it was in afterwards.
let quickSearchSelected = 0;
// When opened from the tray menu, we hide back to tray after the user
// picks an entry — so the previously-foreground app comes back and
// Ctrl+V drops the password in.
let quickSearchHideAfter = false;
// When opened by Ctrl+Shift+Q hotkey, Delphi has saved the foreground
// HWND and is waiting for cmd://autofill/execute. On pick we SendInput
// the password instead of copying to the clipboard.
let quickSearchFillMode = false;
function quickSearchScoreEntry(e, q) {
if (!q) return 1; // empty query → all entries pass, ordering preserved
const ql = q.toLowerCase();
const fields = [
(e.title || ''),
(e.site || ''),
(e.username || ''),
].map(x => x.toLowerCase());
let score = 0;
fields.forEach((f, i) => {
if (!f) return;
if (f.startsWith(ql)) score += 100 - i; // strong prefix match
else if (f.includes(ql)) score += 50 - i; // substring fallback
});
return score;
}
function quickSearchRender() {
const q = document.getElementById('quickSearchInput').value.trim();
const list = state.entries
.map(e => ({ e, s: quickSearchScoreEntry(e, q) }))
.filter(x => x.s > 0)
.sort((a, b) => b.s - a.s)
.slice(0, 8)
.map(x => x.e);
const box = document.getElementById('quickSearchResults');
box.innerHTML = '';
if (list.length === 0) {
box.appendChild(el('div', { class: 'quick-search-empty' },
q ? 'No match for "' + q + '"' : 'No entries'));
quickSearchSelected = 0;
return;
}
if (quickSearchSelected >= list.length) quickSearchSelected = 0;
if (quickSearchSelected < 0) quickSearchSelected = list.length - 1;
list.forEach((e, i) => {
const row = el('div', {
class: 'quick-search-row' + (i === quickSearchSelected ? ' is-selected' : ''),
'data-id': String(e.id),
});
// Avatar — favicon if cached, else initials.
const avatar = el('div', { class: 'quick-search-avatar' });
if (e.icon_b64) {
const img = el('img', { src: e.icon_b64, alt: '' });
img.addEventListener('error', () => {
avatar.innerHTML = '';
avatar.textContent = initials(entryDisplayName(e));
});
avatar.appendChild(img);
} else {
avatar.textContent = initials(entryDisplayName(e));
}
const main = el('div', { class: 'quick-search-main' });
main.appendChild(el('div', { class: 'quick-search-name' }, entryDisplayName(e)));
if (e.username)
main.appendChild(el('div', { class: 'quick-search-sub' }, e.username));
row.appendChild(avatar);
row.appendChild(main);
// Left click → password only; Ctrl+click → username only
// (step-2 forms / unlock screens).
row.addEventListener('click', ev =>
quickSearchPickEntry(e, (ev.ctrlKey || ev.metaKey) ? 'user' : 'pwd'));
// Right click → full (user + Tab + password). preventDefault +
// stopPropagation so the custom context menu doesn't pop.
row.addEventListener('contextmenu', ev => {
ev.preventDefault();
ev.stopPropagation();
quickSearchPickEntry(e, 'full');
});
box.appendChild(row);
});
}
// mode: 'full' (user + Tab + password), 'user' (username only) or 'pwd'
// (password only). In fill mode each maps to a SendInput variant; in copy
// mode 'full' has no meaning so it falls back to copying the password.
async function quickSearchPickEntry(entry, mode) {
mode = mode || 'full';
// Fill mode (Ctrl+Shift+Q hotkey): SendInput directly into the HWND
// Delphi saved when the hotkey fired. No clipboard touch.
if (quickSearchFillMode) {
if (mode === 'user') {
const u = entry.username || '';
if (!u) { toast('No username on this entry', 'warning'); return; }
if (Bridge.active) Bridge.executeAutofill(u, '', quickSearchHideAfter, 'user');
toast(entryDisplayName(entry) + ' · username sent');
} else {
const pwd = await decryptPwd(entry.encrypted_password, entry.iv);
if (pwd === '[ERROR]') {
toast('Decryption error', 'error');
if (Bridge.active) Bridge.cancelAutofill();
return;
}
// 'full' → user + Tab + password (needs a username to make sense);
// 'pwd' (or 'full' on an entry without a username) → password only.
const u = (mode === 'full') ? (entry.username || '') : '';
// Single command — Delphi defers the SendInput by 60 ms then,
// if hide_after=1, MinimizeToTray's AFTER the keystrokes land.
// Hiding before SendInput would tip the Win10/11 anti-focus-
// stealing rules into refusing to hand focus to the target.
if (Bridge.active) Bridge.executeAutofill(u, pwd, quickSearchHideAfter);
toast(entryDisplayName(entry) +
(u ? ' · username + password sent' : ' · password sent'));
}
// Flags consumed — closeQuickSearchModal must not re-trigger.
quickSearchFillMode = false;
quickSearchHideAfter = false;
closeQuickSearchModal();
return;
}
// Copy mode (tray / palette): no target window, so we can only place a
// single value on the clipboard. 'user' copies the username, everything
// else copies the password.
if (mode === 'user') {
const u = entry.username || '';
if (!u) { toast('No username on this entry', 'warning'); return; }
if (Bridge.active) Bridge.copySecure(u, 30000);
else { try { await navigator.clipboard.writeText(u); } catch (_) {} }
toast('Username copied · clears in 30s');
} else {
const pwd = await decryptPwd(entry.encrypted_password, entry.iv);
if (pwd === '[ERROR]') { toast('Decryption error', 'error'); return; }
if (Bridge.active) Bridge.copySecure(pwd, 30000);
else { try { await navigator.clipboard.writeText(pwd); } catch (_) {} }
toast(entryDisplayName(entry) + ' · password copied');
}
// keepClipboard=true — we just copied, so minimising back to the tray
// must NOT clear the clipboard (the 30s auto-clear still applies).
closeQuickSearchModal(true);
}
// ============================================================
// CHEATSHEET — press '?' anywhere to see all hotkeys
// ============================================================
//
// Discovery aid. Built dynamically so adding a new hotkey only requires
// extending CHEATSHEET_GROUPS — the overlay picks it up automatically.
const CHEATSHEET_GROUPS = [
{
title: 'Inside the app',
items: [
{ keys: ['Ctrl', 'K'], desc: 'Command palette / quick search' },
{ keys: ['?'], desc: 'Show this cheatsheet' },
{ keys: ['Esc'], desc: 'Close modal / panel / cheatsheet' },
{ keys: ['Enter'], desc: 'Open / confirm / submit' },
],
},
{
title: 'Global (Windows-only, works even when minimised)',
items: [
{ keys: ['Ctrl', 'Shift', 'L'], desc: 'Autofill username + password into the active window' },
{ keys: ['Ctrl', 'Shift', 'P'], desc: 'Autofill password only (step-2 forms, unlock screens)' },
{ keys: ['Ctrl', 'Shift', 'Q'], desc: 'Quick search → SendInput password into the active window' },
{ keys: ['Ctrl', 'Shift', 'A'], desc: 'Quick-add a new entry pre-filled with the foreground window title' },
],
},
{
title: 'Tray',
items: [
{ keys: ['Right-click tray'], desc: 'Open / Quick search… / Lock vault / Quit' },
{ keys: ['Click tray'], desc: 'Restore window' },
],
},
{
title: 'On each card',
items: [
{ keys: [{ icon: 'i-globe' }], desc: 'Open the site in your default browser' },
{ keys: [{ icon: 'i-copy' }], desc: 'Copy password to the secure clipboard (auto-clears in 30s)' },
{ keys: ['Click card'], desc: 'Open the entry details / edit panel' },
],
},
];
function renderCheatsheet() {
const body = document.getElementById('cheatsheetBody');
body.innerHTML = '';
CHEATSHEET_GROUPS.forEach(group => {
const section = el('section', { class: 'cheatsheet-group' });
section.appendChild(el('h4', null, group.title));
const list = el('div', { class: 'cheatsheet-list' });
group.items.forEach(item => {
const row = el('div', { class: 'cheatsheet-row' });
const kc = el('div', { class: 'cheatsheet-keys' });
item.keys.forEach((k, i) => {
if (i > 0) kc.appendChild(el('span', { class: 'cheatsheet-plus' }, '+'));
if (k && typeof k === 'object' && k.icon) {
// SVG icon — wrap in kbd-shaped chip for visual consistency
// with the text key chips next to it.
const chip = el('span', { class: 'cheatsheet-icon-chip' });
chip.appendChild(icon(k.icon));
kc.appendChild(chip);
} else {
kc.appendChild(el('kbd', null, String(k)));
}
});
row.appendChild(kc);
row.appendChild(el('div', { class: 'cheatsheet-desc' }, item.desc));
list.appendChild(row);
});
section.appendChild(list);
body.appendChild(section);
});
}
// ============================================================
// PASSWORD HISTORY — open the modal, decrypt previous versions,
// optionally revert one into the current field.
// ============================================================
async function openHistoryModal(entryId) {
const modal = document.getElementById('historyModal');
const body = document.getElementById('historyBody');
body.innerHTML = '';
body.appendChild(el('div', { class: 'history-loading' }, 'Loading…'));
modal.classList.remove('is-hidden');
let rows;
try {
rows = await fetch(API + '/entries/' + entryId + '/history', {
headers: authHeaders(),
}).then(r => r.ok ? r.json() : []);
} catch (e) {
rows = [];
}
body.innerHTML = '';
if (!rows.length) {
body.appendChild(el('p', { class: 'history-empty' },
'No previous versions yet — they accumulate on each save.'));
return;
}
// Decrypt each row's stored ciphertext with the CURRENT vault key
// (master-pw change wipes the history, so the key always works).
const list = el('ul', { class: 'history-list' });
for (const row of rows) {
const li = el('li', { class: 'history-row' });
const meta = el('div', { class: 'history-meta' });
meta.appendChild(el('span', { class: 'history-date' },
formatDateShort(row.changed_at) + ' · ' + row.changed_at.slice(11, 16)));
let plain = '';
try {
plain = await decryptPwd(row.encrypted_password, row.iv);
} catch (_) { plain = '[ERROR]'; }
if (plain === '[ERROR]') plain = '';
const preview = el('div', { class: 'history-preview' });
const isNote = (row.kind === 'note');
const snippet = isNote
? (plain.replace(/\s+/g, ' ').slice(0, 80) +
(plain.length > 80 ? '…' : ''))
: '•'.repeat(Math.max(plain.length, 8));
const valueSpan = el('span', { class: 'history-value' }, snippet);
preview.appendChild(valueSpan);
let revealed = false;
if (!isNote) {
const eye = el('button', { class: 'icon-btn icon-btn-sm', type: 'button',
title: 'Show / hide' });
eye.appendChild(icon('i-eye'));
eye.addEventListener('click', () => {
revealed = !revealed;
valueSpan.textContent = revealed ? plain
: '•'.repeat(Math.max(plain.length, 8));
});
preview.appendChild(eye);
}
const copy = el('button', { class: 'icon-btn icon-btn-sm', type: 'button',
title: 'Copy' });
copy.appendChild(icon('i-copy'));
copy.addEventListener('click', () => {
if (Bridge.active) Bridge.copySecure(plain, 30000);
else { try { navigator.clipboard.writeText(plain); } catch (_) {} }
toast('Copied · clears in 30s');
});
preview.appendChild(copy);
const revert = el('button', { class: 'btn btn-ghost btn-xs', type: 'button' });
revert.appendChild(icon('i-rotate-ccw'));
revert.appendChild(document.createTextNode(' Revert'));
revert.addEventListener('click', () => {
const target = isNote
? document.getElementById('soNoteBody')
: document.getElementById('soPassword');
if (target) {
target.value = plain;
target.dispatchEvent(new Event('input', { bubbles: true }));
soDirtyCheck();
toast('Restored — click Save to commit', 'warning');
}
closeHistoryModal();
});
const actions = el('div', { class: 'history-actions' });
actions.appendChild(revert);
li.appendChild(meta);
li.appendChild(preview);
li.appendChild(actions);
list.appendChild(li);
}
body.appendChild(list);
}
function closeHistoryModal() {
document.getElementById('historyModal').classList.add('is-hidden');
}
function openCheatsheet() {
renderCheatsheet();
document.getElementById('cheatsheetModal').classList.remove('is-hidden');
}
function closeCheatsheet() {
document.getElementById('cheatsheetModal').classList.add('is-hidden');
}
// ============================================================
// Guided tour ("How it works") — spotlights real UI elements with a
// bubble, no GIFs. Cheaper than baking videos into assets.res and never
// goes stale when the UI changes. Auto-runs once, re-launchable from Settings.
// ============================================================
const TOUR_STEPS = [
{ sel: '#searchInput', title: 'Search',
body: 'Find any entry instantly (Ctrl+K). Anywhere in Windows, press Ctrl+Shift+Q for quick search — copy or autofill without opening the app.' },
{ sel: '#newEntryBtn', title: 'Add entries',
body: 'Create a login, secure note, card, SSH key and more. The ▾ caret picks the type.' },
{ sel: '.view-toggle', title: 'Views',
body: 'Switch between cards, list and table. Your choice is remembered.' },
{ sel: '.sidebar-section[data-section="tools"]', title: 'Tools',
body: 'Authenticator (2FA codes), Vault health score and the password generator live here.' },
{ sel: '#settingsBtn', title: 'Settings',
body: 'WebDAV sync, encrypted auto-backup, autofill hotkeys (Ctrl+Shift+L) and security options.' },
{ sel: '#cheatsheetBtn', title: 'Shortcuts',
body: 'Every keyboard shortcut, anytime — or just press ?.' },
];
let tourIdx = -1;
function tourSeen() {
return (Bridge.active ? null : localStorage.getItem('tourSeen')) === '1';
}
function markTourSeen() {
if (Bridge.active) Bridge.setPref('tourSeen', '1');
localStorage.setItem('tourSeen', '1'); // fast path + fallback
}
// Auto-launch on first unlock. Bridge pref is the source of truth (survives
// the port-rotation localStorage wipe); fall back to localStorage when no Bridge.
async function maybeStartTour() {
let seen = localStorage.getItem('tourSeen') === '1';
if (Bridge.active) {
try { seen = (await Bridge.getPref('tourSeen')) === '1'; } catch (_) {}
}
if (!seen) setTimeout(startTour, 600); // let the app shell settle first
}
function startTour() {
tourIdx = 0;
let bd = document.getElementById('tourBackdrop');
if (!bd) {
bd = el('div', { id: 'tourBackdrop', class: 'tour-backdrop' });
const spot = el('div', { id: 'tourSpot', class: 'tour-spot' });
const bubble = el('div', { id: 'tourBubble', class: 'tour-bubble' });
document.body.append(bd, spot, bubble);
}
window.addEventListener('resize', showTourStep);
showTourStep();
}
function showTourStep() {
// Skip any step whose target isn't in the DOM (feature hidden/disabled).
while (tourIdx < TOUR_STEPS.length &&
!document.querySelector(TOUR_STEPS[tourIdx].sel)) tourIdx++;
if (tourIdx >= TOUR_STEPS.length) return endTour();
const step = TOUR_STEPS[tourIdx];
const target = document.querySelector(step.sel);
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
// Reposition after any scroll settles so the spotlight lands on the rect.
setTimeout(() => positionTour(target, step), 120);
}
function positionTour(target, step) {
const spot = document.getElementById('tourSpot');
const bubble = document.getElementById('tourBubble');
if (!spot || !bubble) return;
const r = target.getBoundingClientRect();
const pad = 6;
spot.style.top = (r.top - pad) + 'px';
spot.style.left = (r.left - pad) + 'px';
spot.style.width = (r.width + pad * 2) + 'px';
spot.style.height = (r.height + pad * 2) + 'px';
const last = tourIdx === TOUR_STEPS.length - 1;
bubble.innerHTML =
'<div class="tour-bubble-title">' + step.title + '</div>' +
'<div class="tour-bubble-body">' + step.body + '</div>' +
'<div class="tour-bubble-foot">' +
'<span class="tour-bubble-count">' + (tourIdx + 1) + ' / ' + TOUR_STEPS.length + '</span>' +
'<span class="tour-bubble-btns">' +
'<button class="btn btn-ghost btn-sm" id="tourSkip">Skip</button>' +
'<button class="btn btn-primary btn-sm" id="tourNext">' +
(last ? 'Done' : 'Next') + '</button>' +
'</span></div>';
bubble.querySelector('#tourSkip').onclick = endTour;
bubble.querySelector('#tourNext').onclick = () => { tourIdx++; showTourStep(); };
// Place the bubble below the target if there's room, else above.
bubble.style.visibility = 'hidden';
bubble.style.top = '0px'; bubble.style.left = '0px';
const bh = bubble.offsetHeight, bw = bubble.offsetWidth;
const gap = 12;
let top = r.bottom + gap;
if (top + bh > window.innerHeight - 8) top = Math.max(8, r.top - gap - bh);
let left = r.left;
if (left + bw > window.innerWidth - 8) left = window.innerWidth - 8 - bw;
bubble.style.top = Math.max(8, top) + 'px';
bubble.style.left = Math.max(8, left) + 'px';
bubble.style.visibility = '';
}
function endTour() {
tourIdx = -1;
window.removeEventListener('resize', showTourStep);
['tourBackdrop', 'tourSpot', 'tourBubble'].forEach(id => {
const n = document.getElementById(id);
if (n) n.remove();
});
markTourSeen();
}
function openQuickSearchModal(hideAfter, forFill) {
const modal = document.getElementById('quickSearchModal');
const input = document.getElementById('quickSearchInput');
modal.classList.remove('is-hidden');
input.value = '';
quickSearchSelected = 0;
quickSearchHideAfter = !!hideAfter;
quickSearchFillMode = !!forFill;
// Subtle hint to the user about what Enter will do.
const hintEl = modal.querySelector('.quick-search-hint');
if (hintEl) {
hintEl.textContent = forFill
? 'Enter / click = password · Shift+Enter / right-click = user+password · Ctrl+Enter / Ctrl+click = username · Esc = cancel'
: 'Enter / click = copy password · Ctrl+Enter / Ctrl+click = copy username · Esc = close';
}
quickSearchRender();
setTimeout(() => input.focus(), 50);
}
function closeQuickSearchModal(keepClipboard) {
document.getElementById('quickSearchModal').classList.add('is-hidden');
// Fill-mode cancel: tell Delphi to drop the saved HWND so the next
// /execute (e.g. an unrelated Ctrl+Shift+L) doesn't accidentally
// target the stale window.
if (quickSearchFillMode) {
if (Bridge.active && typeof Bridge.cancelAutofill === 'function')
Bridge.cancelAutofill();
quickSearchFillMode = false;
}
// If the modal was opened from the tray (window was hidden), restore
// the previous "in tray" state so the user can paste straight into
// the target app. Cancel (Esc / close X) also triggers this — they
// came from the tray, they should go back to the tray. keepClipboard
// is set by the copy path so minimising doesn't wipe the password we
// just placed on the clipboard.
if (quickSearchHideAfter) {
quickSearchHideAfter = false;
if (Bridge.active && typeof Bridge.minimizeToTray === 'function')
Bridge.minimizeToTray(!!keepClipboard);
}
}