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:
r-zakarya
2026-07-08 18:43:55 +01:00
parent b44b05118e
commit 9e424efaf4
7 changed files with 408 additions and 383 deletions
+2
View File
@@ -38,6 +38,7 @@ js/app.favicon.js (favicon fetch/cache + faviconHost — extrait §3.1)
js/app.import.js (export container + import CSV/JSON — extrait §3.1) js/app.import.js (export container + import CSV/JSON — extrait §3.1)
js/app.backup.js (auto-backup planifié — extrait §3.1) js/app.backup.js (auto-backup planifié — extrait §3.1)
js/app.health.js (vault health dashboard — extrait §3.1) js/app.health.js (vault health dashboard — extrait §3.1)
js/app.overlays.js (quick search + cheatsheet + password history — extrait §3.1)
js/app.js (le reste : state, Bridge, api, UI…) js/app.js (le reste : state, Bridge, api, UI…)
js/app.sync.js (WebDAV + merge — extrait §3.1, APRÈS app.js car js/app.sync.js (WebDAV + merge — extrait §3.1, APRÈS app.js car
effet de bord top-level `Bridge.onWebdavResult = …`) effet de bord top-level `Bridge.onWebdavResult = …`)
@@ -109,6 +110,7 @@ seule `api()` est stubbée).
| Import/export frontend (CSV/JSON parse, export container) — extrait §3.1 | `js/app.import.js` | | Import/export frontend (CSV/JSON parse, export container) — extrait §3.1 | `js/app.import.js` |
| Auto-backup frontend (planifié, chiffré) — extrait §3.1 | `js/app.backup.js` | | Auto-backup frontend (planifié, chiffré) — extrait §3.1 | `js/app.backup.js` |
| Vault health dashboard — extrait §3.1 | `js/app.health.js` | | Vault health dashboard — extrait §3.1 | `js/app.health.js` |
| Quick search + cheatsheet + history modal — extrait §3.1 | `js/app.overlays.js` |
| Sync frontend (WebDAV, snapshot, merge) — extrait §3.1 | `js/app.sync.js` | | Sync frontend (WebDAV, snapshot, merge) — extrait §3.1 | `js/app.sync.js` |
| Argon2id vendé (bundle `@noble/hashes`, IIFE) | `js/argon2.js` | | Argon2id vendé (bundle `@noble/hashes`, IIFE) | `js/argon2.js` |
| HTML racine | `index.html` | | HTML racine | `index.html` |
+8 -4
View File
@@ -245,11 +245,15 @@ réécriture des call-sites, risque quasi nul vs conversion en modules ES).
-`js/app.health.js` extrait (vault health dashboard) — byte-for-byte -`js/app.health.js` extrait (vault health dashboard) — byte-for-byte
identique, chargé AVANT app.js (`auditCache`/`auditFilter` viennent avec, identique, chargé AVANT app.js (`auditCache`/`auditFilter` viennent avec,
résolus cross-fichier). résolus cross-fichier).
- `app.js` : 11 936 → **9 545 lignes** (7 modules sortis). - `js/app.overlays.js` extrait (quick search + cheatsheet + password
history) — quicksearch était entrelacé avec ces 2 overlays, donc extraits
ensemble en un bloc contigu byte-for-byte. Chargé AVANT app.js.
- `app.js` : 11 936 → **9 170 lignes** (8 modules sortis, ~2 770 lignes).
- Suite de tests : 42 → **62 tests**. - Suite de tests : 42 → **62 tests**.
- Reste : slideover, settings, autofill… Note : **quicksearch n'est PAS - Reste : sections très couplées au state/DOM (slideover, settings, folders,
contigu** (entrelacé avec cheatsheet + history-modal 886-1063) → extraction attachments, autofill, PIN/recovery/quick-unlock) — rendement/risque faible,
propre pas triviale, reportée. à faire au fil de l'eau. Le socle §3.1 (pattern + modules à risque isolés +
filet de tests) est en place.
-`node --check` en pré-étape de `BuildAssets.ps1` : **déjà fait** (cf. §3.2). -`node --check` en pré-étape de `BuildAssets.ps1` : **déjà fait** (cf. §3.2).
### 3.2 🟡 Aucun test automatisé — **partiellement adressé (2026-07-04)** ### 3.2 🟡 Aucun test automatisé — **partiellement adressé (2026-07-04)**
+1
View File
@@ -58,6 +58,7 @@ $patterns = @(
'js\app.import.js', 'js\app.import.js',
'js\app.backup.js', 'js\app.backup.js',
'js\app.health.js', 'js\app.health.js',
'js\app.overlays.js',
'js\app.js', 'js\app.js',
'js\app.sync.js', 'js\app.sync.js',
'css\style.css' 'css\style.css'
+1
View File
@@ -1198,6 +1198,7 @@
<script src="js/app.import.js"></script> <script src="js/app.import.js"></script>
<script src="js/app.backup.js"></script> <script src="js/app.backup.js"></script>
<script src="js/app.health.js"></script> <script src="js/app.health.js"></script>
<script src="js/app.overlays.js"></script>
<script src="js/app.js"></script> <script src="js/app.js"></script>
<script src="js/app.sync.js"></script> <script src="js/app.sync.js"></script>
</body> </body>
+3 -378
View File
@@ -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 = // Generate a cryptographically random RFC 4648 base32 secret. 20 bytes =
// 160 bits → 32 base32 chars, RFC 6238 §5.1 recommended TOTP key size. // 160 bits → 32 base32 chars, RFC 6238 §5.1 recommended TOTP key size.
+392
View File
@@ -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
View File
@@ -30,7 +30,7 @@ const { webcrypto } = require('node:crypto');
// top-level const/let across separate runInContext calls, so we CONCATENATE // 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 // the app.* parts (in <script> load order) into one script. argon2.js is a
// self-contained IIFE and loads separately (see below). // 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). // In-memory Storage stub (Web Storage API surface used by app.js).
function makeStorage() { function makeStorage() {