fix: vault paths, TOTP caching, and keyboard nav on filtered list
- Fix .idfoto/ prefix for salt and params.json in vault.ts - Cache TOTP secrets by entry ID to avoid re-fetching every second - Fix keyboard navigation to use filtered entries, not unfiltered - Add window.close() on Escape from entry list Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -25,26 +25,7 @@ function getGroups(entries: Array<[string, ManifestEntry]>): string[] {
|
|||||||
export function renderEntryList(app: HTMLElement): void {
|
export function renderEntryList(app: HTMLElement): void {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
const groups = getGroups(state.entries);
|
const groups = getGroups(state.entries);
|
||||||
|
const filtered = getFilteredEntries();
|
||||||
// Filter entries by active group (already filtered if group was sent to service worker,
|
|
||||||
// but we also support client-side filtering for instant response).
|
|
||||||
let filtered = state.entries;
|
|
||||||
if (state.activeGroup) {
|
|
||||||
const g = state.activeGroup.toLowerCase();
|
|
||||||
filtered = filtered.filter(([, e]) => e.group?.toLowerCase() === g);
|
|
||||||
}
|
|
||||||
if (state.searchQuery) {
|
|
||||||
const q = state.searchQuery.toLowerCase();
|
|
||||||
filtered = filtered.filter(([, e]) => {
|
|
||||||
if (e.name.toLowerCase().includes(q)) return true;
|
|
||||||
if (e.url?.toLowerCase().includes(q)) return true;
|
|
||||||
if (e.username?.toLowerCase().includes(q)) return true;
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by name.
|
|
||||||
filtered.sort((a, b) => a[1].name.localeCompare(b[1].name));
|
|
||||||
|
|
||||||
const groupTabsHtml = groups.length > 0
|
const groupTabsHtml = groups.length > 0
|
||||||
? `<div class="group-tabs">
|
? `<div class="group-tabs">
|
||||||
@@ -126,6 +107,27 @@ async function openEntry(id: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compute the visible (filtered) entry list from current state.
|
||||||
|
function getFilteredEntries(): Array<[string, ManifestEntry]> {
|
||||||
|
const state = getState();
|
||||||
|
let filtered = state.entries;
|
||||||
|
if (state.activeGroup) {
|
||||||
|
const g = state.activeGroup.toLowerCase();
|
||||||
|
filtered = filtered.filter(([, e]) => e.group?.toLowerCase() === g);
|
||||||
|
}
|
||||||
|
if (state.searchQuery) {
|
||||||
|
const q = state.searchQuery.toLowerCase();
|
||||||
|
filtered = filtered.filter(([, e]) => {
|
||||||
|
if (e.name.toLowerCase().includes(q)) return true;
|
||||||
|
if (e.url?.toLowerCase().includes(q)) return true;
|
||||||
|
if (e.username?.toLowerCase().includes(q)) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
filtered.sort((a, b) => a[1].name.localeCompare(b[1].name));
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
function handleListKeydown(e: KeyboardEvent): void {
|
function handleListKeydown(e: KeyboardEvent): void {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
@@ -143,9 +145,11 @@ function handleListKeydown(e: KeyboardEvent): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filtered = getFilteredEntries();
|
||||||
|
|
||||||
if (e.key === 'ArrowDown') {
|
if (e.key === 'ArrowDown') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const max = state.entries.length - 1;
|
const max = Math.max(filtered.length - 1, 0);
|
||||||
setState({ selectedIndex: Math.min(state.selectedIndex + 1, max) });
|
setState({ selectedIndex: Math.min(state.selectedIndex + 1, max) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -158,7 +162,6 @@ function handleListKeydown(e: KeyboardEvent): void {
|
|||||||
|
|
||||||
if (e.key === 'Enter' && !isSearch) {
|
if (e.key === 'Enter' && !isSearch) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const filtered = state.entries;
|
|
||||||
if (filtered[state.selectedIndex]) {
|
if (filtered[state.selectedIndex]) {
|
||||||
openEntry(filtered[state.selectedIndex][0]);
|
openEntry(filtered[state.selectedIndex][0]);
|
||||||
}
|
}
|
||||||
@@ -166,8 +169,8 @@ function handleListKeydown(e: KeyboardEvent): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
// Remove listener to avoid stacking.
|
|
||||||
document.removeEventListener('keydown', handleListKeydown);
|
document.removeEventListener('keydown', handleListKeydown);
|
||||||
|
window.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ let masterKey: Uint8Array | null = null;
|
|||||||
let manifest: Manifest | null = null;
|
let manifest: Manifest | null = null;
|
||||||
let gitHost: GitHost | null = null;
|
let gitHost: GitHost | null = null;
|
||||||
let wasmReady = false;
|
let wasmReady = false;
|
||||||
|
// Cache TOTP secrets by entry ID to avoid re-fetching the entry every second
|
||||||
|
const totpSecretCache: Map<string, string> = new Map();
|
||||||
|
|
||||||
// --- WASM initialization ---
|
// --- WASM initialization ---
|
||||||
|
|
||||||
@@ -121,6 +123,7 @@ async function handleMessage(req: Request): Promise<Response> {
|
|||||||
case 'lock':
|
case 'lock':
|
||||||
masterKey = null;
|
masterKey = null;
|
||||||
manifest = null;
|
manifest = null;
|
||||||
|
totpSecretCache.clear();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|
||||||
// --- Entries ---
|
// --- Entries ---
|
||||||
@@ -220,11 +223,18 @@ async function handleMessage(req: Request): Promise<Response> {
|
|||||||
case 'get_totp': {
|
case 'get_totp': {
|
||||||
if (!masterKey || !gitHost) return { ok: false, error: 'Vault is locked' };
|
if (!masterKey || !gitHost) return { ok: false, error: 'Vault is locked' };
|
||||||
const w = await initWasm();
|
const w = await initWasm();
|
||||||
const entry = await vault.fetchAndDecryptEntry(gitHost, masterKey, req.id);
|
|
||||||
if (!entry.totp_secret) return { ok: false, error: 'No TOTP secret for this entry' };
|
// Use cached TOTP secret to avoid re-fetching the entry every second
|
||||||
|
let totpSecret = totpSecretCache.get(req.id);
|
||||||
|
if (!totpSecret) {
|
||||||
|
const entry = await vault.fetchAndDecryptEntry(gitHost, masterKey, req.id);
|
||||||
|
if (!entry.totp_secret) return { ok: false, error: 'No TOTP secret for this entry' };
|
||||||
|
totpSecret = entry.totp_secret;
|
||||||
|
totpSecretCache.set(req.id, totpSecret);
|
||||||
|
}
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const code = w.generate_totp(entry.totp_secret, BigInt(now));
|
const code = w.generate_totp(totpSecret, BigInt(now));
|
||||||
const remaining = 30 - (now % 30);
|
const remaining = 30 - (now % 30);
|
||||||
|
|
||||||
return { ok: true, data: { code, remaining_seconds: remaining } };
|
return { ok: true, data: { code, remaining_seconds: remaining } };
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ export interface VaultMeta {
|
|||||||
|
|
||||||
/// Read the vault salt and KDF params from the git repo.
|
/// Read the vault salt and KDF params from the git repo.
|
||||||
export async function fetchVaultMeta(git: GitHost): Promise<VaultMeta> {
|
export async function fetchVaultMeta(git: GitHost): Promise<VaultMeta> {
|
||||||
const saltBytes = await git.readFile('salt');
|
const saltBytes = await git.readFile('.idfoto/salt');
|
||||||
const paramsRaw = await git.readFile('params.json');
|
const paramsRaw = await git.readFile('.idfoto/params.json');
|
||||||
const paramsJson = new TextDecoder().decode(paramsRaw);
|
const paramsJson = new TextDecoder().decode(paramsRaw);
|
||||||
return { salt: saltBytes, paramsJson };
|
return { salt: saltBytes, paramsJson };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user