diff --git a/extension/src/popup/styles.css b/extension/src/popup/styles.css index 3f278fd..8e78353 100644 --- a/extension/src/popup/styles.css +++ b/extension/src/popup/styles.css @@ -1411,3 +1411,24 @@ textarea { color: var(--text-muted); font-variant-numeric: tabular-nums; } + +.totp-preview { + margin-top: 6px; + padding: 6px 10px; + border: 1px dashed var(--border-subtle); + border-radius: 3px; + display: flex; + justify-content: space-between; + align-items: center; + font-variant-numeric: tabular-nums; + color: var(--text-muted); +} +.totp-code { + font-size: 14px; + font-weight: 600; + letter-spacing: 1px; + color: var(--accent); +} +.totp-countdown { + font-size: 11px; +} diff --git a/extension/src/shared/form-affordances/__tests__/totp-tools.test.ts b/extension/src/shared/form-affordances/__tests__/totp-tools.test.ts new file mode 100644 index 0000000..0d0ff17 --- /dev/null +++ b/extension/src/shared/form-affordances/__tests__/totp-tools.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { wireTotpPreview } from '../totp-tools'; + +describe('wireTotpPreview', () => { + let form: HTMLElement; + let sendMessage: ReturnType; + + beforeEach(() => { + form = document.createElement('div'); + form.innerHTML = ` + + + `; + document.body.appendChild(form); + sendMessage = vi.fn(); + vi.useFakeTimers(); + }); + + it('shows preview when secret is valid base32', async () => { + sendMessage.mockResolvedValue({ ok: true, data: { code: '492837', expires_at: Math.floor(Date.now() / 1000) + 23 } }); + const teardown = wireTotpPreview(form, { sendMessage }); + const input = form.querySelector('#f-totp') as HTMLInputElement; + input.value = 'JBSWY3DPEHPK3PXP'; + input.dispatchEvent(new Event('input')); + await vi.advanceTimersByTimeAsync(50); + const row = form.querySelector('#totp-preview-row') as HTMLElement; + expect(row.hidden).toBe(false); + expect(row.querySelector('.totp-code')?.textContent).toBe('492 837'); + expect(row.querySelector('.totp-countdown')?.textContent).toMatch(/\d+s/); + teardown(); + }); + + it('hides preview when secret is too short', async () => { + const teardown = wireTotpPreview(form, { sendMessage }); + const input = form.querySelector('#f-totp') as HTMLInputElement; + input.value = 'TOOSHORT'; + input.dispatchEvent(new Event('input')); + await vi.advanceTimersByTimeAsync(50); + const row = form.querySelector('#totp-preview-row') as HTMLElement; + expect(row.hidden).toBe(true); + expect(sendMessage).not.toHaveBeenCalled(); + teardown(); + }); + + it('teardown stops the interval', async () => { + sendMessage.mockResolvedValue({ ok: true, data: { code: '111111', expires_at: Math.floor(Date.now() / 1000) + 30 } }); + const teardown = wireTotpPreview(form, { sendMessage }); + const input = form.querySelector('#f-totp') as HTMLInputElement; + input.value = 'JBSWY3DPEHPK3PXP'; + input.dispatchEvent(new Event('input')); + await vi.advanceTimersByTimeAsync(50); + const callsBefore = sendMessage.mock.calls.length; + teardown(); + await vi.advanceTimersByTimeAsync(2000); + expect(sendMessage.mock.calls.length).toBe(callsBefore); + }); +}); diff --git a/extension/src/shared/form-affordances/totp-tools.ts b/extension/src/shared/form-affordances/totp-tools.ts new file mode 100644 index 0000000..c1232ab --- /dev/null +++ b/extension/src/shared/form-affordances/totp-tools.ts @@ -0,0 +1,53 @@ +export interface TotpPreviewOpts { + sendMessage: (msg: { type: 'preview_totp_from_secret'; secret_b32: string }) => + Promise<{ ok: boolean; data?: { code: string; expires_at: number }; error?: string }>; +} + +const VALID_B32 = /^[A-Z2-7]{16,}=*$/; + +export function wireTotpPreview(form: HTMLElement, opts: TotpPreviewOpts): () => void { + const input = form.querySelector('#f-totp'); + const row = form.querySelector('#totp-preview-row'); + if (!input || !row) return () => {}; + const codeEl = row.querySelector('.totp-code'); + const cdEl = row.querySelector('.totp-countdown'); + if (!codeEl || !cdEl) return () => {}; + + let interval: ReturnType | null = null; + let lastSecret = ''; + + const tick = async () => { + const cleaned = lastSecret.toUpperCase().replace(/\s+/g, '').replace(/=+$/, ''); + if (!VALID_B32.test(cleaned)) { + row.hidden = true; + return; + } + const resp = await opts.sendMessage({ type: 'preview_totp_from_secret', secret_b32: cleaned }); + if (!resp.ok || !resp.data) { + row.hidden = true; + return; + } + row.hidden = false; + // Format "492837" → "492 837" for legibility. + codeEl.textContent = resp.data.code.length === 6 + ? `${resp.data.code.slice(0, 3)} ${resp.data.code.slice(3)}` + : resp.data.code; + const remaining = Math.max(0, resp.data.expires_at - Math.floor(Date.now() / 1000)); + cdEl.textContent = `${remaining}s`; + }; + + const onInput = () => { + lastSecret = input.value; + void tick(); + }; + input.addEventListener('input', onInput); + if (interval === null) { + interval = setInterval(() => { void tick(); }, 1000); + } + + return () => { + input.removeEventListener('input', onInput); + if (interval !== null) { clearInterval(interval); interval = null; } + row.hidden = true; + }; +} diff --git a/extension/src/vault/vault.css b/extension/src/vault/vault.css index b7099c8..6ae5155 100644 --- a/extension/src/vault/vault.css +++ b/extension/src/vault/vault.css @@ -1441,3 +1441,24 @@ textarea { color: var(--text-muted); font-variant-numeric: tabular-nums; } + +.totp-preview { + margin-top: 6px; + padding: 6px 10px; + border: 1px dashed var(--border-subtle); + border-radius: 3px; + display: flex; + justify-content: space-between; + align-items: center; + font-variant-numeric: tabular-nums; + color: var(--text-muted); +} +.totp-code { + font-size: 14px; + font-weight: 600; + letter-spacing: 1px; + color: var(--accent); +} +.totp-countdown { + font-size: 11px; +}