ext(affordances): wireTotpPreview live ticker

This commit is contained in:
adlee-was-taken
2026-05-01 19:56:55 -04:00
parent bb8b86f0d5
commit c91b31a7ca
4 changed files with 155 additions and 0 deletions

View File

@@ -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;
}

View File

@@ -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<typeof vi.fn>;
beforeEach(() => {
form = document.createElement('div');
form.innerHTML = `
<input id="f-totp" type="text" value="" />
<div id="totp-preview-row" class="totp-preview" hidden>
<span class="totp-code">…</span>
<span class="totp-countdown">…</span>
</div>
`;
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);
});
});

View File

@@ -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<HTMLInputElement>('#f-totp');
const row = form.querySelector<HTMLElement>('#totp-preview-row');
if (!input || !row) return () => {};
const codeEl = row.querySelector<HTMLElement>('.totp-code');
const cdEl = row.querySelector<HTMLElement>('.totp-countdown');
if (!codeEl || !cdEl) return () => {};
let interval: ReturnType<typeof setInterval> | 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;
};
}

View File

@@ -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;
}