ext(affordances): wireTotpPreview live ticker
This commit is contained in:
@@ -1411,3 +1411,24 @@ textarea {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-variant-numeric: tabular-nums;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
53
extension/src/shared/form-affordances/totp-tools.ts
Normal file
53
extension/src/shared/form-affordances/totp-tools.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1441,3 +1441,24 @@ textarea {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-variant-numeric: tabular-nums;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user