feat(ext/popup): Totp view + form (countdown ring, Steam toggle)

Detail view renders a signature block with a large monospace rotating code and
a thin SVG countdown ring that sweeps via CSS transition. The ticker polls
get_totp every second and is stopped on teardown (back/edit/trash/Escape/e/d/t).

Form has a two-button kind toggle (TOTP / Steam Guard) that re-renders in place
while preserving entered values. TOTP uses digits=6 kind='totp'; Steam uses
digits=5 kind='steam'. Both default to algorithm='sha1' period_seconds=30.

Keyboard shortcuts on detail: Escape=back, e=edit, d=trash, t=copy-code.
Guarded against stealing keystrokes from editable targets.

Wires totp.renderDetail / totp.renderForm into both dispatchers and calls
totp.teardown() alongside the other types so tickers can't leak across views.

Closes T8 of the extension 1C-β1 plan (5/5 typed-item modules in place;
only T9 picker and T10 acceptance remain).
This commit is contained in:
adlee-was-taken
2026-04-23 22:54:49 -04:00
parent e084790756
commit 673981379e
4 changed files with 433 additions and 2 deletions

View File

@@ -9,6 +9,7 @@ import * as secureNote from './types/secure-note';
import * as identity from './types/identity'; import * as identity from './types/identity';
import * as card from './types/card'; import * as card from './types/card';
import * as key from './types/key'; import * as key from './types/key';
import * as totp from './types/totp';
export async function renderItemDetail(app: HTMLElement): Promise<void> { export async function renderItemDetail(app: HTMLElement): Promise<void> {
// Tear down any tickers/handlers from a previous detail render before // Tear down any tickers/handlers from a previous detail render before
@@ -19,6 +20,7 @@ export async function renderItemDetail(app: HTMLElement): Promise<void> {
identity.teardown(); identity.teardown();
card.teardown(); card.teardown();
key.teardown(); key.teardown();
totp.teardown();
const item = getState().selectedItem; const item = getState().selectedItem;
if (!item) { navigate('list'); return; } if (!item) { navigate('list'); return; }
@@ -29,7 +31,7 @@ export async function renderItemDetail(app: HTMLElement): Promise<void> {
case 'identity': return identity.renderDetail(app, item); case 'identity': return identity.renderDetail(app, item);
case 'card': return card.renderDetail(app, item); case 'card': return card.renderDetail(app, item);
case 'key': return key.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); case 'document': return renderComingSoon(app, item);
} }
} }

View File

@@ -8,6 +8,7 @@ import * as secureNote from './types/secure-note';
import * as identity from './types/identity'; import * as identity from './types/identity';
import * as card from './types/card'; import * as card from './types/card';
import * as key from './types/key'; import * as key from './types/key';
import * as totp from './types/totp';
export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void { export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void {
login.teardown(); // detail-view's ticker/listener don't leak into form 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(); identity.teardown();
card.teardown(); card.teardown();
key.teardown(); key.teardown();
totp.teardown();
const state = getState(); const state = getState();
const existing = mode === 'edit' ? state.selectedItem : null; const existing = mode === 'edit' ? state.selectedItem : null;
const type: ItemType = existing?.type ?? state.newType ?? 'login'; 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 'identity': return identity.renderForm(app, mode, existing);
case 'card': return card.renderForm(app, mode, existing); case 'card': return card.renderForm(app, mode, existing);
case 'key': return key.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); case 'document': return renderComingSoon(app, type);
} }
} }

View File

@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
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 = '<div id="app"></div>';
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();
});
});

View File

@@ -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<typeof setInterval> | 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<void> {
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 = `
<svg width="32" height="32" viewBox="0 0 32 32" style="display:block;">
<circle cx="16" cy="16" r="14" stroke="#30363d" stroke-width="2" fill="none"/>
<circle id="totp-ring-arc" cx="16" cy="16" r="14" stroke="#58a6ff" stroke-width="2" fill="none"
stroke-linecap="round" stroke-dasharray="87.96"
transform="rotate(-90 16 16)" style="transition:stroke-dashoffset 1s linear;"/>
</svg>
`;
const sigInner = `
<div style="display:flex;justify-content:space-between;align-items:center;">
<div>
<div style="font-size:11px;color:#8b949e;letter-spacing:0.04em;">${headerLine}</div>
<div id="totp-code" style="font-family:monospace;font-size:28px;letter-spacing:0.12em;color:#c9d1d9;margin-top:4px;">${isSteam ? '·····' : '······'}</div>
</div>
<div style="display:flex;flex-direction:column;align-items:center;gap:4px;">
${ringSvg}
<span id="totp-countdown" style="font-size:10px;color:#8b949e;font-variant-numeric:tabular-nums;">…</span>
</div>
</div>
`;
app.innerHTML = `
<div class="pad">
<div style="margin-bottom:12px;">
<div class="detail-title" style="margin-bottom:8px;">${escapeHtml(item.title)}</div>
${renderSignatureBlock({ accent: 'blue', children: sigInner })}
</div>
${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 })}
<div class="form-actions" style="margin-top:14px;">
<button class="btn" id="back-btn">back</button>
<button class="btn" id="edit-btn">edit</button>
<button class="btn danger" id="trash-btn">trash</button>
</div>
</div>
`;
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 => `
<div class="pad">
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new totp' : 'edit totp'}</div>
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
<div class="form-group"><label class="label" for="f-title">title *</label>
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="GitHub"></div>
<div class="form-group"><label class="label">kind</label>
<div class="inline-row">
<button type="button" id="kind-totp" class="btn ${formKind === 'totp' ? 'btn-primary' : ''}" style="flex:1;">TOTP</button>
<button type="button" id="kind-steam" class="btn ${formKind === 'steam' ? 'btn-primary' : ''}" style="flex:1;">Steam Guard</button>
</div>
<p class="muted" style="font-size:11px;margin-top:4px;" id="kind-blurb">${formKind === 'steam' ? 'Steam Mobile Authenticator (5-char alphanumeric)' : 'Standard time-based codes (6 digits)'}</p>
</div>
<div class="form-group"><label class="label" for="f-secret">secret (base32) *</label>
<input id="f-secret" type="text" value="${escapeHtml(secretB32)}" placeholder="JBSWY3DPEHPK3PXP"></div>
<div class="form-group"><label class="label" for="f-issuer">issuer</label>
<input id="f-issuer" type="text" value="${escapeHtml(c?.issuer ?? '')}" placeholder="GitHub"></div>
<div class="form-group"><label class="label" for="f-label">label</label>
<input id="f-label" type="text" value="${escapeHtml(c?.label ?? '')}" placeholder="alice@github.com"></div>
<div class="form-actions">
<button class="btn" id="cancel-btn">cancel</button>
<button class="btn btn-primary" id="save-btn">save</button>
</div>
</div>
`;
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<void> {
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 });
}
}