From 3d9136b837d3a35429f00994b8470c932b9fe8a8 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Fri, 7 Aug 2026 22:14:39 +0000 Subject: [PATCH] fix(totp): only request a code for items that carry a seed --- apps/desktop/src/main/vault-service.ts | 7 +++++-- .../src/renderer/src/components/ItemDetail.tsx | 16 ++++++++++++---- apps/desktop/src/shared/api.ts | 3 ++- packages/cli/src/commands/vault.ts | 3 +++ 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/main/vault-service.ts b/apps/desktop/src/main/vault-service.ts index 9b66809..dddd1ea 100644 --- a/apps/desktop/src/main/vault-service.ts +++ b/apps/desktop/src/main/vault-service.ts @@ -87,11 +87,13 @@ export class VaultService { return this.vault; } - async totpEntry(itemId: string): Promise { + /** Null for items that carry no one-time-code seed, e.g. a plain login. */ + async totpEntry(itemId: string): Promise { const vault = this.current(); const item = await vault.getItem(itemId); if (!item) throw new Error('item not found'); const fields = await vault.listFields(itemId); + if (!fields.some((field) => field.purpose === 'totp_seed')) return null; const numericField = async (name: string, fallback: number): Promise => { if (!fields.some((field) => field.name === name)) return fallback; const value = Number(await vault.revealField(itemId, name)); @@ -107,6 +109,7 @@ export class VaultService { async totpList(): Promise { const vault = this.current(); const items = await vault.listItems({ kind: 'totp' }); - return Promise.all(items.map((item) => this.totpEntry(item.id))); + const entries = await Promise.all(items.map((item) => this.totpEntry(item.id))); + return entries.filter((entry): entry is TotpEntry => entry !== null); } } diff --git a/apps/desktop/src/renderer/src/components/ItemDetail.tsx b/apps/desktop/src/renderer/src/components/ItemDetail.tsx index 2075477..276bfe3 100644 --- a/apps/desktop/src/renderer/src/components/ItemDetail.tsx +++ b/apps/desktop/src/renderer/src/components/ItemDetail.tsx @@ -40,7 +40,11 @@ export const ItemDetail = ({ onChanged: () => void; onDeleted: () => void; }) => { - const [fields, setFields] = useState([]); + // Keyed by item so a pending load never renders the previous item's fields. + const [loaded, setLoaded] = useState<{ itemId: string; fields: VaultFieldMeta[] }>({ + itemId: item.id, + fields: [], + }); const [urls, setUrls] = useState([]); const [tags, setTags] = useState([]); const [revealed, setRevealed] = useState>({}); @@ -53,7 +57,7 @@ export const ItemDetail = ({ const refresh = useCallback(async () => { setRevealed({}); - setFields(await dcrypt.fields.list(item.id)); + setLoaded({ itemId: item.id, fields: await dcrypt.fields.list(item.id) }); setUrls(await dcrypt.organize.urls(item.id)); setTags(await dcrypt.organize.tags(item.id)); }, [item.id]); @@ -62,6 +66,7 @@ export const ItemDetail = ({ void refresh(); }, [refresh]); + const fields = loaded.itemId === item.id ? loaded.fields : []; const hasTotpSeed = fields.some((field) => field.purpose === 'totp_seed'); useEffect(() => { @@ -70,16 +75,19 @@ export const ItemDetail = ({ return; } let cancelled = false; + let timer: ReturnType | undefined; const tick = async () => { try { const entry = await dcrypt.totp.code(item.id); - if (!cancelled) setTotp(entry); + if (cancelled) return; + setTotp(entry); + if (!entry) clearInterval(timer); } catch { // vault locked mid-refresh } }; void tick(); - const timer = setInterval(() => void tick(), 1000); + timer = setInterval(() => void tick(), 1000); return () => { cancelled = true; clearInterval(timer); diff --git a/apps/desktop/src/shared/api.ts b/apps/desktop/src/shared/api.ts index c6b48c0..33bd9da 100644 --- a/apps/desktop/src/shared/api.ts +++ b/apps/desktop/src/shared/api.ts @@ -90,7 +90,8 @@ export interface DcryptApi { remove(itemId: string, name: string): Promise; }; totp: { - code(itemId: string): Promise; + /** Null when the item carries no one-time-code seed. */ + code(itemId: string): Promise; list(): Promise; importUri(uri: string): Promise; }; diff --git a/packages/cli/src/commands/vault.ts b/packages/cli/src/commands/vault.ts index 5350f15..491edbb 100644 --- a/packages/cli/src/commands/vault.ts +++ b/packages/cli/src/commands/vault.ts @@ -190,6 +190,9 @@ const totp = async (argv: ParsedArgs, prompter: Inquirerer): Promise => { try { const item = await findItem(vault, first); const fields = await vault.listFields(item.id); + if (!fields.some((field) => field.purpose === 'totp_seed')) { + throw new CliError(`"${item.title}" has no one-time-code secret`); + } const numeric = async (name: string, fallback: number): Promise => { if (!fields.some((field) => field.name === name)) return fallback; const value = Number(await vault.revealField(item.id, name));