// ============================================================ // 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; // CE QUE L'UTILISATEUR PREFERE quand une cible existe : remplir, ou copier // quand meme - le remplissage echoue vers une application lancee en // administrateur, ou quand le gestionnaire de Chrome s'en mele. Retenu en pref // DPAPI : localStorage se vide a chaque lancement (port ephemere). let quickSearchPreferCopy = false; // NOTE DE COMMANDES ouverte dans la recherche rapide : la liste remplace les // entrees, la saisie la filtre, Esc revient aux entrees. null = liste normale. let qsCommands = null; async function qsEnterCommands(entry) { const body = await decryptPwd(entry.encrypted_password, entry.iv); if (body === '[ERROR]') { toast('Decryption error', 'error'); return; } qsCommands = parseCommandNote(body); const input = document.getElementById('quickSearchInput'); input.value = ''; input.placeholder = entryDisplayName(entry) + ' - filter commands… (Esc = back)'; quickSearchSelected = 0; qsApplyMode(); quickSearchRender(); input.focus(); } function qsLeaveCommands() { qsCommands = null; const input = document.getElementById('quickSearchInput'); input.value = ''; input.placeholder = 'Search…'; quickSearchSelected = 0; qsApplyMode(); quickSearchRender(); } // Une commande, une ligne, SANS Entree : l'utilisateur valide lui-meme. Ni // Ctrl+A ni Suppr avant (noClear) - dans un terminal Ctrl+A ramene en debut de // ligne, ce ne serait pas "tout selectionner". async function qsPickCommand(cmd, forceCopy) { if (qsFills() && !forceCopy) { autofillPendingToast = 'Command sent'; if (Bridge.active) Bridge.executeAutofill('', cmd, quickSearchHideAfter, undefined, true); quickSearchFillMode = false; quickSearchHideAfter = false; qsCommands = null; closeQuickSearchModal(); return; } if (Bridge.active) Bridge.copySecure(cmd, 30000); else { try { await navigator.clipboard.writeText(cmd); } catch (_) {} } toast('Command copied'); qsCommands = null; closeQuickSearchModal(true); } function qsRenderCommands(box, q) { const ql = q.toLowerCase(); const list = qsCommands .map((c, i) => ({ c, i })) .filter(x => !ql || x.c.cmd.toLowerCase().includes(ql) || x.c.group.toLowerCase().includes(ql)); if (!list.length) { box.appendChild(el('div', { class: 'quick-search-empty' }, q ? 'No command matches "' + q + '"' : 'No command in this note')); quickSearchSelected = 0; return; } if (quickSearchSelected >= list.length) quickSearchSelected = 0; if (quickSearchSelected < 0) quickSearchSelected = list.length - 1; list.forEach((x, n) => { const row = el('div', { class: 'quick-search-row' + (n === quickSearchSelected ? ' is-selected' : ''), 'data-cmd': String(x.i), }); const main = el('div', { class: 'quick-search-main' }); main.appendChild(el('div', { class: 'quick-search-name qs-cmd' }, state.maskNotes ? maskCommand(x.c.cmd) : x.c.cmd)); if (x.c.group) main.appendChild(el('div', { class: 'quick-search-sub' }, x.c.group)); row.appendChild(main); const acts = el('div', { class: 'quick-search-acts' }); const b = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: qsFills() ? 'Copy instead of typing' : 'Copy command' }); b.appendChild(icon('i-copy')); b.addEventListener('click', ev => { ev.stopPropagation(); qsPickCommand(x.c.cmd, true); }); acts.appendChild(b); row.appendChild(acts); row.addEventListener('click', ev => qsPickCommand(x.c.cmd, ev.ctrlKey || ev.metaKey)); box.appendChild(row); }); } /// Remplit-on vraiment ? Il faut une cible ET ne pas avoir choisi Copy. function qsFills() { return quickSearchFillMode && !quickSearchPreferCopy; } function qsApplyMode() { const box = document.getElementById('qsMode'); if (box) { box.hidden = !quickSearchFillMode; box.querySelectorAll('[data-qsmode]').forEach(b => b.classList.toggle('is-on', (b.dataset.qsmode === 'fill') === qsFills())); } const back = document.getElementById('qsBack'); if (back) back.hidden = !qsCommands; const ico = document.getElementById('qsSearchIcon'); if (ico) ico.style.display = qsCommands ? 'none' : ''; const hintEl = document.querySelector('#quickSearchModal .quick-search-hint'); if (hintEl && qsCommands) { hintEl.textContent = qsFills() ? 'Enter / click = type the command (no Enter sent) · Ctrl+Enter / Ctrl+click = copy · Esc = back' : 'Enter / click = copy the command · Esc = back'; } else if (hintEl) { hintEl.textContent = qsFills() ? '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'; } } document.addEventListener('click', ev => { if (ev.target.closest && ev.target.closest('#qsBack')) { ev.stopPropagation(); qsLeaveCommands(); document.getElementById('quickSearchInput').focus(); return; } const b = ev.target.closest && ev.target.closest('[data-qsmode]'); if (!b) return; quickSearchPreferCopy = b.dataset.qsmode === 'copy'; if (Bridge.active) Bridge.setPref('qsCopy', quickSearchPreferCopy ? '1' : '0'); qsApplyMode(); quickSearchRender(); document.getElementById('quickSearchInput').focus(); }); 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(); if (qsCommands) { const box = document.getElementById('quickSearchResults'); box.innerHTML = ''; qsRenderCommands(box, q); return; } 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); // Un bouton par valeur presente : pas besoin de retenir clic / Ctrl+clic. const verb = qsFills() ? 'Fill ' : 'Copy '; const acts = el('div', { class: 'quick-search-acts' }); const act = (ic, title, mode) => { const b = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title }); b.appendChild(icon(ic)); b.addEventListener('click', ev => { ev.stopPropagation(); quickSearchPickEntry(e, mode); }); b.addEventListener('contextmenu', ev => ev.stopPropagation()); acts.appendChild(b); }; if (e.username) act('i-user', verb + 'username', 'user'); // UNE NOTE SE COPIE TOUJOURS, meme en Fill : tapee, chacun de ses // retours a la ligne partirait comme un Entree dans le formulaire. if (e.template === 'commands') act('i-note', 'Open the command list', 'cmds'); else if (e.kind === 'note') act('i-note', qsFills() ? 'Copy note (a note is never typed)' : 'Copy note', 'note'); else act('i-key', verb + 'password', 'pwd'); if (e.totp_secret && e.totp_iv) act('i-clock', verb + '2FA code', 'totp'); row.appendChild(acts); // 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. // 'totp' = code 2FA courant (tape comme un mot de passe, ou copie) ; 'note' = // corps d'une note, toujours COPIE - le taper enverrait ses retours a la ligne // comme des Entree dans le formulaire cible. async function quickSearchPickEntry(entry, mode) { mode = mode || 'full'; // Note de commandes : on choisit UNE commande, jamais la note entiere. if (entry.template === 'commands' && mode !== 'user') return qsEnterCommands(entry); if (mode === 'totp') { const secret = await decryptTotpSecret(entry.totp_secret, entry.totp_iv); let code = ''; try { if (secret && secret !== '[ERROR]') code = (await generateTOTP(secret)).code; } catch (_) { code = ''; } if (!code) { toast('2FA: cannot read the secret', 'error'); return; } if (qsFills()) { autofillPendingToast = entryDisplayName(entry) + ' · 2FA code sent'; if (Bridge.active) Bridge.executeAutofill('', code, quickSearchHideAfter); quickSearchFillMode = false; quickSearchHideAfter = false; closeQuickSearchModal(); return; } if (Bridge.active) Bridge.copySecure(code, 30000); else { try { await navigator.clipboard.writeText(code); } catch (_) {} } toast(entryDisplayName(entry) + ' · 2FA code copied'); closeQuickSearchModal(true); return; } if (mode === 'note') { const body = await decryptPwd(entry.encrypted_password, entry.iv); if (body === '[ERROR]') { toast('Decryption error', 'error'); return; } if (Bridge.active) Bridge.copySecure(body, 30000); else { try { await navigator.clipboard.writeText(body); } catch (_) {} } toast(entryDisplayName(entry) + ' · note copied'); closeQuickSearchModal(true); return; } // Fill mode (Ctrl+Shift+Q hotkey): SendInput directly into the HWND // Delphi saved when the hotkey fired. No clipboard touch. if (qsFills()) { if (mode === 'user') { const u = entry.username || ''; if (!u) { toast('No username on this entry', 'warning'); return; } // Toast deferred to Bridge.onAutofillResult — Delphi reports // whether the keystrokes actually landed (elevated target = no). autofillPendingToast = entryDisplayName(entry) + ' · username sent'; if (Bridge.active) Bridge.executeAutofill(u, '', quickSearchHideAfter, 'user'); } 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. autofillPendingToast = entryDisplayName(entry) + (u ? ' · username + password sent' : ' · password sent'); if (Bridge.active) Bridge.executeAutofill(u, pwd, quickSearchHideAfter); } // 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' + clipClearSuffix()); } 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', 'T'], desc: 'Autofill the 2FA code (click the 2FA field first)' }, { 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)' }, { 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' + clipClearSuffix()); }); 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 = '