refactor(js): extract quick-search overlay cluster from app.js (§3.1)
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>
This commit is contained in:
@@ -723,385 +723,10 @@ async function api(path, opts) {
|
||||
// ============================================================
|
||||
|
||||
// ============================================================
|
||||
// QUICK SEARCH MODAL (tray menu → fast password copy)
|
||||
// QUICK SEARCH + CHEATSHEET + HISTORY — extracted to
|
||||
// js/app.overlays.js (§3.1), loaded as a separate <script> before
|
||||
// this file (pure declarations).
|
||||
// ============================================================
|
||||
//
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a cryptographically random RFC 4648 base32 secret. 20 bytes =
|
||||
// 160 bits → 32 base32 chars, RFC 6238 §5.1 recommended TOTP key size.
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
// ============================================================
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ const { webcrypto } = require('node:crypto');
|
||||
// top-level const/let across separate runInContext calls, so we CONCATENATE
|
||||
// the app.* parts (in <script> load order) into one script. argon2.js is a
|
||||
// self-contained IIFE and loads separately (see below).
|
||||
const APP_PARTS = ['app.crypto.js', 'app.totp.js', 'app.favicon.js', 'app.import.js', 'app.backup.js', 'app.health.js', 'app.js', 'app.sync.js'].map(f => path.join(__dirname, '..', f));
|
||||
const APP_PARTS = ['app.crypto.js', 'app.totp.js', 'app.favicon.js', 'app.import.js', 'app.backup.js', 'app.health.js', 'app.overlays.js', 'app.js', 'app.sync.js'].map(f => path.join(__dirname, '..', f));
|
||||
|
||||
// In-memory Storage stub (Web Storage API surface used by app.js).
|
||||
function makeStorage() {
|
||||
|
||||
Reference in New Issue
Block a user