ext(affordances): wireTotpQr (jsqr lazy-load) for QR -> otpauth:// fill
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1432,3 +1432,20 @@ textarea {
|
||||
.totp-countdown {
|
||||
font-size: 11px;
|
||||
}
|
||||
.totp-qr-panel {
|
||||
margin-top: 6px;
|
||||
padding: 10px;
|
||||
border: 1px dashed var(--border-subtle);
|
||||
border-radius: 3px;
|
||||
background: var(--bg-input);
|
||||
}
|
||||
.totp-qr-panel input[type="file"] {
|
||||
display: block;
|
||||
font-family: inherit;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.totp-qr-error {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--danger, #c75a4f);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { wireTotpPreview } from '../totp-tools';
|
||||
import { wireTotpPreview, wireTotpQr } from '../totp-tools';
|
||||
|
||||
describe('wireTotpPreview', () => {
|
||||
let form: HTMLElement;
|
||||
@@ -58,3 +58,68 @@ describe('wireTotpPreview', () => {
|
||||
expect(sendMessage.mock.calls.length).toBe(callsBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wireTotpQr', () => {
|
||||
let form: HTMLElement;
|
||||
let decodeQrFromBlob: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
form = document.createElement('div');
|
||||
form.innerHTML = `
|
||||
<input id="f-totp" type="text" value="" />
|
||||
<button id="totp-qr-btn" class="glyph-btn" type="button" title="QR">◫</button>
|
||||
<div id="totp-qr-panel" class="totp-qr-panel" hidden>
|
||||
<input id="totp-qr-file" type="file" accept="image/*" />
|
||||
<div id="totp-qr-error" class="totp-qr-error"></div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(form);
|
||||
decodeQrFromBlob = vi.fn();
|
||||
});
|
||||
|
||||
it('toggles the panel on button click', () => {
|
||||
wireTotpQr(form, { decodeQrFromBlob });
|
||||
const btn = form.querySelector('#totp-qr-btn') as HTMLButtonElement;
|
||||
const panel = form.querySelector('#totp-qr-panel') as HTMLElement;
|
||||
expect(panel.hidden).toBe(true);
|
||||
btn.click();
|
||||
expect(panel.hidden).toBe(false);
|
||||
btn.click();
|
||||
expect(panel.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('fills f-totp on successful decode of otpauth:// URI', async () => {
|
||||
decodeQrFromBlob.mockResolvedValue('otpauth://totp/Example:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example');
|
||||
wireTotpQr(form, { decodeQrFromBlob });
|
||||
const fileInput = form.querySelector('#totp-qr-file') as HTMLInputElement;
|
||||
const fakeFile = new File(['x'], 'qr.png', { type: 'image/png' });
|
||||
Object.defineProperty(fileInput, 'files', { value: [fakeFile] });
|
||||
fileInput.dispatchEvent(new Event('change'));
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
expect((form.querySelector('#f-totp') as HTMLInputElement).value).toBe('JBSWY3DPEHPK3PXP');
|
||||
});
|
||||
|
||||
it('shows error when QR decodes but is not otpauth://', async () => {
|
||||
decodeQrFromBlob.mockResolvedValue('https://example.com/');
|
||||
wireTotpQr(form, { decodeQrFromBlob });
|
||||
const fileInput = form.querySelector('#totp-qr-file') as HTMLInputElement;
|
||||
Object.defineProperty(fileInput, 'files', { value: [new File(['x'], 'x.png', { type: 'image/png' })] });
|
||||
fileInput.dispatchEvent(new Event('change'));
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
const err = form.querySelector('#totp-qr-error') as HTMLElement;
|
||||
expect(err.textContent).toMatch(/not a totp uri/i);
|
||||
expect((form.querySelector('#f-totp') as HTMLInputElement).value).toBe('');
|
||||
});
|
||||
|
||||
it('shows error when decode returns null (no QR found)', async () => {
|
||||
decodeQrFromBlob.mockResolvedValue(null);
|
||||
wireTotpQr(form, { decodeQrFromBlob });
|
||||
const fileInput = form.querySelector('#totp-qr-file') as HTMLInputElement;
|
||||
Object.defineProperty(fileInput, 'files', { value: [new File(['x'], 'x.png', { type: 'image/png' })] });
|
||||
fileInput.dispatchEvent(new Event('change'));
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
const err = form.querySelector('#totp-qr-error') as HTMLElement;
|
||||
expect(err.textContent).toMatch(/no qr found/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,3 +51,92 @@ export function wireTotpPreview(form: HTMLElement, opts: TotpPreviewOpts): () =>
|
||||
row.hidden = true;
|
||||
};
|
||||
}
|
||||
|
||||
/// Lazy-load jsqr and decode a QR from a Blob/File. Returns the decoded
|
||||
/// string, or null if no QR was found.
|
||||
async function defaultDecodeQrFromBlob(blob: Blob): Promise<string | null> {
|
||||
const [{ default: jsQR }] = await Promise.all([import('jsqr')]);
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
ctx.drawImage(bitmap, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const result = jsQR(imageData.data, imageData.width, imageData.height);
|
||||
return result?.data ?? null;
|
||||
}
|
||||
|
||||
export interface TotpQrOpts {
|
||||
/// Inject a stub in tests where canvas + imports aren't available.
|
||||
decodeQrFromBlob?: (blob: Blob) => Promise<string | null>;
|
||||
}
|
||||
|
||||
export function wireTotpQr(form: HTMLElement, opts: TotpQrOpts = {}): void {
|
||||
const btn = form.querySelector<HTMLButtonElement>('#totp-qr-btn');
|
||||
const panel = form.querySelector<HTMLElement>('#totp-qr-panel');
|
||||
const fileInput = form.querySelector<HTMLInputElement>('#totp-qr-file');
|
||||
const errEl = form.querySelector<HTMLElement>('#totp-qr-error');
|
||||
const totpInput = form.querySelector<HTMLInputElement>('#f-totp');
|
||||
if (!btn || !panel || !fileInput || !errEl || !totpInput) return;
|
||||
|
||||
const decode = opts.decodeQrFromBlob ?? defaultDecodeQrFromBlob;
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
panel.hidden = !panel.hidden;
|
||||
errEl.textContent = '';
|
||||
});
|
||||
|
||||
const handleBlob = async (blob: Blob) => {
|
||||
errEl.textContent = '';
|
||||
let decoded: string | null;
|
||||
try {
|
||||
decoded = await decode(blob);
|
||||
} catch (e) {
|
||||
errEl.textContent = `decode failed: ${e instanceof Error ? e.message : String(e)}`;
|
||||
return;
|
||||
}
|
||||
if (!decoded) {
|
||||
errEl.textContent = 'no QR found in image';
|
||||
return;
|
||||
}
|
||||
if (!decoded.startsWith('otpauth://')) {
|
||||
errEl.textContent = 'not a TOTP URI (expected otpauth://...)';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const u = new URL(decoded);
|
||||
const secret = u.searchParams.get('secret');
|
||||
if (!secret) {
|
||||
errEl.textContent = 'TOTP URI missing secret';
|
||||
return;
|
||||
}
|
||||
totpInput.value = secret;
|
||||
totpInput.dispatchEvent(new Event('input', { bubbles: true })); // trigger preview
|
||||
panel.hidden = true;
|
||||
} catch {
|
||||
errEl.textContent = 'TOTP URI did not parse';
|
||||
}
|
||||
};
|
||||
|
||||
fileInput.addEventListener('change', () => {
|
||||
const f = fileInput.files?.[0];
|
||||
if (f) void handleBlob(f);
|
||||
});
|
||||
|
||||
panel.addEventListener('paste', (e) => {
|
||||
const item = Array.from((e as ClipboardEvent).clipboardData?.items ?? []).find((i) => i.type.startsWith('image/'));
|
||||
if (item) {
|
||||
const blob = item.getAsFile();
|
||||
if (blob) void handleBlob(blob);
|
||||
}
|
||||
});
|
||||
|
||||
panel.addEventListener('dragover', (e) => { e.preventDefault(); });
|
||||
panel.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
const f = (e as DragEvent).dataTransfer?.files?.[0];
|
||||
if (f) void handleBlob(f);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1462,3 +1462,20 @@ textarea {
|
||||
.totp-countdown {
|
||||
font-size: 11px;
|
||||
}
|
||||
.totp-qr-panel {
|
||||
margin-top: 6px;
|
||||
padding: 10px;
|
||||
border: 1px dashed var(--border-subtle);
|
||||
border-radius: 3px;
|
||||
background: var(--bg-input);
|
||||
}
|
||||
.totp-qr-panel input[type="file"] {
|
||||
display: block;
|
||||
font-family: inherit;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.totp-qr-error {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--danger, #c75a4f);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user