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:
3528
extension/package-lock.json
generated
Normal file
3528
extension/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,9 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"jsqr": "^1.4.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/chrome": "^0.1.40",
|
"@types/chrome": "^0.1.40",
|
||||||
"copy-webpack-plugin": "^12.0",
|
"copy-webpack-plugin": "^12.0",
|
||||||
|
|||||||
@@ -1432,3 +1432,20 @@ textarea {
|
|||||||
.totp-countdown {
|
.totp-countdown {
|
||||||
font-size: 11px;
|
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 { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { wireTotpPreview } from '../totp-tools';
|
import { wireTotpPreview, wireTotpQr } from '../totp-tools';
|
||||||
|
|
||||||
describe('wireTotpPreview', () => {
|
describe('wireTotpPreview', () => {
|
||||||
let form: HTMLElement;
|
let form: HTMLElement;
|
||||||
@@ -58,3 +58,68 @@ describe('wireTotpPreview', () => {
|
|||||||
expect(sendMessage.mock.calls.length).toBe(callsBefore);
|
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;
|
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 {
|
.totp-countdown {
|
||||||
font-size: 11px;
|
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