diff --git a/extension/src/popup/components/item-detail.ts b/extension/src/popup/components/item-detail.ts index 85b0f59..9e1b199 100644 --- a/extension/src/popup/components/item-detail.ts +++ b/extension/src/popup/components/item-detail.ts @@ -9,6 +9,7 @@ import * as secureNote from './types/secure-note'; import * as identity from './types/identity'; import * as card from './types/card'; import * as key from './types/key'; +import * as totp from './types/totp'; export async function renderItemDetail(app: HTMLElement): Promise { // Tear down any tickers/handlers from a previous detail render before @@ -19,6 +20,7 @@ export async function renderItemDetail(app: HTMLElement): Promise { identity.teardown(); card.teardown(); key.teardown(); + totp.teardown(); const item = getState().selectedItem; if (!item) { navigate('list'); return; } @@ -29,7 +31,7 @@ export async function renderItemDetail(app: HTMLElement): Promise { case 'identity': return identity.renderDetail(app, item); case 'card': return card.renderDetail(app, item); case 'key': return key.renderDetail(app, item); - case 'totp': + case 'totp': return totp.renderDetail(app, item); case 'document': return renderComingSoon(app, item); } } diff --git a/extension/src/popup/components/item-form.ts b/extension/src/popup/components/item-form.ts index a5d6e57..97dab1b 100644 --- a/extension/src/popup/components/item-form.ts +++ b/extension/src/popup/components/item-form.ts @@ -8,6 +8,7 @@ import * as secureNote from './types/secure-note'; import * as identity from './types/identity'; import * as card from './types/card'; import * as key from './types/key'; +import * as totp from './types/totp'; export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void { login.teardown(); // detail-view's ticker/listener don't leak into form @@ -15,6 +16,7 @@ export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void { identity.teardown(); card.teardown(); key.teardown(); + totp.teardown(); const state = getState(); const existing = mode === 'edit' ? state.selectedItem : null; const type: ItemType = existing?.type ?? state.newType ?? 'login'; @@ -25,7 +27,7 @@ export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void { case 'identity': return identity.renderForm(app, mode, existing); case 'card': return card.renderForm(app, mode, existing); case 'key': return key.renderForm(app, mode, existing); - case 'totp': + case 'totp': return totp.renderForm(app, mode, existing); case 'document': return renderComingSoon(app, type); } } diff --git a/extension/src/popup/components/types/__tests__/totp.save.test.ts b/extension/src/popup/components/types/__tests__/totp.save.test.ts new file mode 100644 index 0000000..036bf4f --- /dev/null +++ b/extension/src/popup/components/types/__tests__/totp.save.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../popup', async () => { + const navigate = vi.fn(); + const setState = vi.fn(); + const sendMessage = vi.fn(); + const getState = vi.fn(() => ({ + view: 'add', entries: [], selectedId: null, selectedItem: null, selectedIndex: 0, + searchQuery: '', activeGroup: null, error: null, loading: false, + capturedTabId: null, capturedUrl: '', newType: 'totp', + })); + const escapeHtml = (s: string) => s + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + return { navigate, setState, sendMessage, getState, escapeHtml }; +}); + +import { renderForm } from '../totp'; +import { sendMessage } from '../../../popup'; +import { base32Decode } from '../../../../shared/base32'; + +describe('Totp save shape', () => { + beforeEach(() => { + document.body.innerHTML = '
'; + vi.mocked(sendMessage).mockReset(); + vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { id: 'fakeid0000000000', items: [] } }); + }); + + it('TOTP kind: secret round-trips via base32, defaults applied', async () => { + const app = document.getElementById('app')!; + renderForm(app, 'add', null); + + (document.getElementById('f-title') as HTMLInputElement).value = 'GitHub'; + (document.getElementById('f-secret') as HTMLInputElement).value = 'JBSWY3DPEHPK3PXP'; + (document.getElementById('f-issuer') as HTMLInputElement).value = 'GitHub'; + (document.getElementById('f-label') as HTMLInputElement).value = 'alice'; + + document.getElementById('save-btn')!.click(); + await new Promise(r => setTimeout(r, 5)); + + const calls = vi.mocked(sendMessage).mock.calls; + const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item'); + const msg = addCall![0] as { type: 'add_item'; item: any }; + expect(msg.item.type).toBe('totp'); + expect(msg.item.core).toMatchObject({ + type: 'totp', + issuer: 'GitHub', + label: 'alice', + config: { + secret: Array.from(base32Decode('JBSWY3DPEHPK3PXP')), + algorithm: 'sha1', + digits: 6, + period_seconds: 30, + kind: 'totp', + }, + }); + }); + + it('Steam kind: digits set to 5, kind set to steam', async () => { + const app = document.getElementById('app')!; + renderForm(app, 'add', null); + + (document.getElementById('f-title') as HTMLInputElement).value = 'Steam'; + (document.getElementById('f-secret') as HTMLInputElement).value = 'JBSWY3DPEHPK3PXP'; + (document.getElementById('kind-steam') as HTMLButtonElement).click(); + // After the click, the form re-renders; re-query the secret field and re-populate. + (document.getElementById('f-secret') as HTMLInputElement).value = 'JBSWY3DPEHPK3PXP'; + (document.getElementById('f-title') as HTMLInputElement).value = 'Steam'; + + document.getElementById('save-btn')!.click(); + await new Promise(r => setTimeout(r, 5)); + + const calls = vi.mocked(sendMessage).mock.calls; + const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item'); + const msg = addCall![0] as { type: 'add_item'; item: any }; + expect(msg.item.core.config).toMatchObject({ + digits: 5, + kind: 'steam', + algorithm: 'sha1', + period_seconds: 30, + }); + }); + + it('rejects empty secret', async () => { + const app = document.getElementById('app')!; + renderForm(app, 'add', null); + (document.getElementById('f-title') as HTMLInputElement).value = 'no secret'; + document.getElementById('save-btn')!.click(); + await new Promise(r => setTimeout(r, 5)); + + const calls = vi.mocked(sendMessage).mock.calls; + const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item'); + expect(addCall).toBeUndefined(); + }); +}); diff --git a/extension/src/popup/components/types/totp.ts b/extension/src/popup/components/types/totp.ts new file mode 100644 index 0000000..a794376 --- /dev/null +++ b/extension/src/popup/components/types/totp.ts @@ -0,0 +1,332 @@ +/// Totp standalone item type. Detail view shows the rotating code in a +/// signature block with a thin SVG countdown ring; form has a kind toggle +/// (TOTP vs Steam Guard) and a single secret input. + +import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup'; +import type { Item, ItemId, ManifestEntry, TotpKind } from '../../../shared/types'; +import { base32Decode, base32Encode } from '../../../shared/base32'; +import { + renderRow, renderConcealedRow, renderSignatureBlock, wireFieldHandlers, +} from '../fields'; + +// ---------------------------------------------------------------------- +// Module-scope lifecycle state +// ---------------------------------------------------------------------- + +let totpTickerId: ReturnType | null = null; +let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null; + +function stopTotpTicker(): void { + if (totpTickerId !== null) { clearInterval(totpTickerId); totpTickerId = null; } +} + +/// Called by the dispatcher before each render. Stops the countdown ticker +/// AND removes the detail-view's keyboard handler so they don't leak. +export function teardown(): void { + stopTotpTicker(); + if (activeKeyHandler) { + document.removeEventListener('keydown', activeKeyHandler); + activeKeyHandler = null; + } +} + +// ---------------------------------------------------------------------- +// Detail view +// ---------------------------------------------------------------------- + +export async function renderDetail(app: HTMLElement, item: Item): Promise { + if (item.core.type !== 'totp') return; + const c = item.core; + const secretB32 = base32Encode(new Uint8Array(c.config.secret)); + const isSteam = c.config.kind === 'steam'; + + const headerLine = c.issuer + ? `${escapeHtml(c.issuer)}${c.label ? ` · ${escapeHtml(c.label)}` : ''}` + : escapeHtml(item.title); + + // Countdown ring SVG. Stroke-dashoffset animates per tick (CSS transition + // gives the smooth sweep between seconds). + const ringSvg = ` + + + + + `; + + const sigInner = ` +
+
+
${headerLine}
+
${isSteam ? '·····' : '······'}
+
+
+ ${ringSvg} + +
+
+ `; + + app.innerHTML = ` +
+
+
${escapeHtml(item.title)}
+ ${renderSignatureBlock({ accent: 'blue', children: sigInner })} +
+ ${c.issuer ? renderRow({ label: 'issuer', value: c.issuer }) : ''} + ${c.label ? renderRow({ label: 'label', value: c.label }) : ''} + ${renderRow({ label: 'kind', value: isSteam ? 'Steam Guard' : 'TOTP' })} + ${renderConcealedRow({ id: 'totp-secret', label: 'secret', value: secretB32, monospace: true })} +
+ + + +
+
+ `; + + wireFieldHandlers(app); + + // Start the ticker — re-fetches code + countdown every second from the SW. + startTotpTicker(item.id, c.config.period_seconds || 30); + + document.getElementById('back-btn')?.addEventListener('click', () => { + teardown(); + navigate('list'); + }); + document.getElementById('edit-btn')?.addEventListener('click', () => { + teardown(); + navigate('edit'); + }); + document.getElementById('trash-btn')?.addEventListener('click', async () => { + if (!confirm(`Move "${item.title}" to trash?`)) return; + teardown(); + const resp = await sendMessage({ type: 'delete_item', id: item.id }); + if (!resp.ok) { setState({ error: resp.error }); return; } + const listResp = await sendMessage({ type: 'list_items' }); + if (listResp.ok) { + const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> }; + navigate('list', { entries: data.items, selectedId: null, selectedItem: null }); + } else navigate('list'); + }); + + const handler = async (e: KeyboardEvent) => { + // Don't steal printable keystrokes from editable fields. + const t = e.target; + if (t instanceof HTMLElement) { + const tag = t.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) return; + } + switch (e.key) { + case 'Escape': teardown(); navigate('list'); break; + case 'e': teardown(); navigate('edit'); break; + case 't': { + // Copy the currently displayed rotating code. + const codeEl = document.getElementById('totp-code'); + const code = codeEl?.textContent?.trim(); + if (code && code !== '……' && code !== '·····' && code !== '······') { + try { await navigator.clipboard.writeText(code); } catch { /* swallow */ } + } + break; + } + case 'd': + e.preventDefault(); + if (confirm(`Move "${item.title}" to trash?`)) { + teardown(); + const resp = await sendMessage({ type: 'delete_item', id: item.id }); + if (!resp.ok) { setState({ error: resp.error }); return; } + const listResp = await sendMessage({ type: 'list_items' }); + if (listResp.ok) { + const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> }; + navigate('list', { entries: data.items, selectedId: null, selectedItem: null }); + } else navigate('list'); + } + break; + } + }; + activeKeyHandler = handler; + document.addEventListener('keydown', handler); +} + +// ---------------------------------------------------------------------- +// Countdown ticker +// ---------------------------------------------------------------------- + +function startTotpTicker(id: ItemId, period: number): void { + stopTotpTicker(); + const circumference = 2 * Math.PI * 14; + const tick = async () => { + const r = await sendMessage({ type: 'get_totp', id }); + if (!r.ok) return; + const { code, expires_at } = r.data as { code: string; expires_at: number }; + const codeEl = document.getElementById('totp-code'); + const cdEl = document.getElementById('totp-countdown'); + const ring = document.getElementById('totp-ring-arc') as SVGCircleElement | null; + if (codeEl) codeEl.textContent = code; + const remaining = Math.max(0, expires_at - Math.floor(Date.now() / 1000)); + if (cdEl) cdEl.textContent = `${remaining}s`; + if (ring) { + const offset = circumference * (1 - remaining / period); + ring.style.strokeDashoffset = String(offset); + } + }; + void tick(); + totpTickerId = setInterval(() => void tick(), 1000); +} + +// ---------------------------------------------------------------------- +// Form (add / edit) with TOTP/Steam kind toggle +// ---------------------------------------------------------------------- + +let formKind: TotpKind = 'totp'; + +export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void { + const state = getState(); + const title = existing?.title ?? ''; + const c = (existing?.core.type === 'totp') ? existing.core : null; + formKind = c?.config.kind === 'steam' ? 'steam' : 'totp'; + const secretB32 = c?.config.secret ? base32Encode(new Uint8Array(c.config.secret)) : ''; + + const renderInner = (): string => ` +
+
${mode === 'add' ? 'new totp' : 'edit totp'}
+ ${state.error ? `
${escapeHtml(state.error)}
` : ''} +
+
+
+
+ + +
+

${formKind === 'steam' ? 'Steam Mobile Authenticator (5-char alphanumeric)' : 'Standard time-based codes (6 digits)'}

+
+
+
+
+
+
+
+
+ + +
+
+ `; + + app.innerHTML = renderInner(); + + // In-place re-render on kind toggle. Preserves current input values so + // the user doesn't lose what they've typed. + const reRender = (): void => { + const titleVal = (document.getElementById('f-title') as HTMLInputElement).value; + const secretVal = (document.getElementById('f-secret') as HTMLInputElement).value; + const issuerVal = (document.getElementById('f-issuer') as HTMLInputElement).value; + const labelVal = (document.getElementById('f-label') as HTMLInputElement).value; + app.innerHTML = renderInner(); + (document.getElementById('f-title') as HTMLInputElement).value = titleVal; + (document.getElementById('f-secret') as HTMLInputElement).value = secretVal; + (document.getElementById('f-issuer') as HTMLInputElement).value = issuerVal; + (document.getElementById('f-label') as HTMLInputElement).value = labelVal; + wireKindToggle(); + wireFormButtons(mode, existing); + }; + + const wireKindToggle = (): void => { + document.getElementById('kind-totp')?.addEventListener('click', () => { + formKind = 'totp'; + reRender(); + }); + document.getElementById('kind-steam')?.addEventListener('click', () => { + formKind = 'steam'; + reRender(); + }); + }; + + wireKindToggle(); + wireFormButtons(mode, existing); + + const escHandler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + document.removeEventListener('keydown', escHandler); + setState({ error: null }); + navigate(mode === 'edit' ? 'detail' : 'list'); + } + }; + document.addEventListener('keydown', escHandler); + + (document.getElementById('f-title') as HTMLInputElement | null)?.focus(); +} + +function wireFormButtons(mode: 'add' | 'edit', existing: Item | null): void { + document.getElementById('cancel-btn')?.addEventListener('click', () => { + setState({ error: null }); + navigate(mode === 'edit' ? 'detail' : 'list'); + }); + document.getElementById('save-btn')?.addEventListener('click', async () => { + await saveTotp(mode, existing); + }); +} + +async function saveTotp(mode: 'add' | 'edit', existing: Item | null): Promise { + const state = getState(); + const title = (document.getElementById('f-title') as HTMLInputElement).value.trim(); + if (!title) { setState({ error: 'Title is required' }); return; } + + const secretStr = (document.getElementById('f-secret') as HTMLInputElement).value.trim(); + if (!secretStr) { setState({ error: 'Secret is required' }); return; } + + let secretBytes: Uint8Array; + try { + secretBytes = base32Decode(secretStr); + } catch (err) { + setState({ error: `Invalid TOTP secret: ${err instanceof Error ? err.message : String(err)}` }); + return; + } + if (secretBytes.length === 0) { setState({ error: 'Secret decoded to zero bytes' }); return; } + + const get = (id: string) => (document.getElementById(id) as HTMLInputElement).value.trim(); + + const isSteam = formKind === 'steam'; + const core = { + type: 'totp' as const, + config: { + secret: Array.from(secretBytes), + algorithm: 'sha1' as const, + digits: isSteam ? 5 : 6, + period_seconds: 30, + kind: (isSteam ? 'steam' : 'totp') as TotpKind, + }, + issuer: get('f-issuer') || undefined, + label: get('f-label') || undefined, + }; + + const now = Math.floor(Date.now() / 1000); + const item: Item = { + id: existing?.id ?? '', + title, type: 'totp', + tags: existing?.tags ?? [], + favorite: existing?.favorite ?? false, + group: existing?.group, notes: existing?.notes, + created: existing?.created ?? now, + modified: now, trashed_at: undefined, + core, + sections: existing?.sections ?? [], + attachments: existing?.attachments ?? [], + field_history: existing?.field_history ?? {}, + }; + + setState({ loading: true, error: null }); + const resp = mode === 'add' + ? await sendMessage({ type: 'add_item', item }) + : await sendMessage({ type: 'update_item', id: state.selectedId!, item }); + if (resp.ok) { + const listResp = await sendMessage({ type: 'list_items' }); + if (listResp.ok) { + const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> }; + navigate('list', { entries: data.items, selectedId: null, selectedItem: null }); + } else navigate('list'); + } else { + setState({ loading: false, error: resp.error }); + } +}