9e424efaf4
Eighth slice. Quick search wasn't contiguous — its functions sat on both sides of the cheatsheet and password-history modals (lines 725-1104). Rather than a fiddly non-contiguous cut, the whole overlay cluster is extracted as one byte-identical block: js/app.overlays.js (quick search + cheatsheet + password history). Pure declarations, no top-level side effects → loads before app.js; all state/api/Bridge/render/decryptPwd refs resolve via shared global scope at call time. - Byte-for-byte identical; syntax OK on all nine app parts; 62/62 tests green. - index.html + BuildAssets whitelist + harness APP_PARTS updated. app.js: 11936 → 9170 lines (8 modules extracted, ~2770 lines). Load order: argon2 → crypto → totp → favicon → import → backup → health → overlays → app → sync. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
393 lines
17 KiB
JavaScript
393 lines
17 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');
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|