diff --git a/frontend/apps/web/app/settings/SecuritySection.tsx b/frontend/apps/web/app/settings/SecuritySection.tsx index 22f9710..8fbb6c2 100644 --- a/frontend/apps/web/app/settings/SecuritySection.tsx +++ b/frontend/apps/web/app/settings/SecuritySection.tsx @@ -12,6 +12,7 @@ import { type MfaEnrollData, type MfaStatus, } from '@/lib/auth/api'; +import { QrCode } from '@/lib/auth/QrCode'; const cardCls = 'rounded-xl border border-neutral-200 bg-neutral-50/70 p-4 dark:border-neutral-700 dark:bg-neutral-900/60'; @@ -194,14 +195,18 @@ export function SecuritySection({ baseUrl, token }: { baseUrl: string; token: st enroll ? (

- Add this account to your authenticator app, then enter the 6-digit code it shows. + Scan this QR code with your authenticator app (Google Authenticator, Authy, + 1Password), then enter the 6-digit code it shows.

+
+ +
- Open in authenticator app + On this phone? Open in authenticator app

- Or enter this key manually + Can't scan? Enter this key manually

diff --git a/frontend/apps/web/lib/auth/AuthForm.tsx b/frontend/apps/web/lib/auth/AuthForm.tsx index 955688f..2247b3e 100644 --- a/frontend/apps/web/lib/auth/AuthForm.tsx +++ b/frontend/apps/web/lib/auth/AuthForm.tsx @@ -21,6 +21,7 @@ import { type MfaChallenge, type MfaEnrollData, } from './api'; +import { QrCode } from './QrCode'; export type AuthMode = 'signin' | 'signup'; @@ -480,21 +481,24 @@ function TwoFactorStep({ {!challenge.enrolled ? (

- Set up an authenticator app (Google Authenticator, Authy, 1Password). Add this - account, then enter the 6-digit code it shows. + Scan this QR code with an authenticator app (Google Authenticator, Authy, + 1Password), then enter the 6-digit code it shows.

{loadingEnroll ? (

Preparing setup…

) : enrollData ? (
+
+ +
- Open in authenticator app + On this phone? Open in authenticator app

- Or enter this key manually + Can't scan? Enter this key manually

diff --git a/frontend/apps/web/lib/auth/QrCode.tsx b/frontend/apps/web/lib/auth/QrCode.tsx new file mode 100644 index 0000000..8b39c52 --- /dev/null +++ b/frontend/apps/web/lib/auth/QrCode.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import QRCode from 'qrcode'; + +/** + * Renders a QR code for an otpauth:// URI so a phone authenticator (Google + * Authenticator, Authy, ...) can scan it. Generated entirely in the browser - + * the TOTP secret never leaves the device. + */ +export function QrCode({ value, size = 180 }: { value: string; size?: number }) { + const [dataUrl, setDataUrl] = useState(null); + + useEffect(() => { + let active = true; + QRCode.toDataURL(value, { width: size, margin: 1, errorCorrectionLevel: 'M' }) + .then((url) => { if (active) setDataUrl(url); }) + .catch(() => { if (active) setDataUrl(null); }); + return () => { active = false; }; + }, [value, size]); + + if (!dataUrl) { + return ( +
+ ); + } + + return ( + // eslint-disable-next-line @next/next/no-img-element -- data URL, no remote optimization + Two-factor authentication QR code + ); +} diff --git a/frontend/apps/web/package.json b/frontend/apps/web/package.json index 9f94add..2edb6da 100644 --- a/frontend/apps/web/package.json +++ b/frontend/apps/web/package.json @@ -18,6 +18,7 @@ "@vercel/analytics": "^2.0.1", "clsx": "2.1.1", "next": "14.2.35", + "qrcode": "1.5.4", "react": "18.3.1", "react-dom": "18.3.1", "react-dropzone": "14.3.5", @@ -26,6 +27,7 @@ "zustand": "4.5.5" }, "devDependencies": { + "@types/qrcode": "1.5.5", "@testing-library/jest-dom": "6.5.0", "@testing-library/react": "16.0.1", "@testing-library/user-event": "14.5.2", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index a52f96d..5d505c6 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -202,6 +202,9 @@ importers: next: specifier: 14.2.35 version: 14.2.35(@babel/core@7.28.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + qrcode: + specifier: 1.5.4 + version: 1.5.4 react: specifier: 18.3.1 version: 18.3.1 @@ -233,6 +236,9 @@ importers: '@types/node': specifier: 20.16.10 version: 20.16.10 + '@types/qrcode': + specifier: 1.5.5 + version: 1.5.5 '@types/react': specifier: 18.3.10 version: 18.3.10 @@ -2239,6 +2245,9 @@ packages: '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + '@types/qrcode@1.5.5': + resolution: {integrity: sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==} + '@types/qs@6.15.1': resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} @@ -3335,6 +3344,9 @@ packages: didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -5488,6 +5500,10 @@ packages: resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==} engines: {node: '>=4.0.0'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + polished@4.3.1: resolution: {integrity: sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==} engines: {node: '>=10'} @@ -5614,6 +5630,11 @@ packages: resolution: {integrity: sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==} hasBin: true + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + qs@6.15.2: resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} @@ -9525,6 +9546,10 @@ snapshots: '@types/prop-types@15.7.15': {} + '@types/qrcode@1.5.5': + dependencies: + '@types/node': 20.16.10 + '@types/qs@6.15.1': {} '@types/range-parser@1.2.7': {} @@ -10659,6 +10684,8 @@ snapshots: didyoumean@1.2.2: {} + dijkstrajs@1.0.3: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -13387,6 +13414,8 @@ snapshots: pngjs@3.4.0: {} + pngjs@5.0.0: {} + polished@4.3.1: dependencies: '@babel/runtime': 7.28.4 @@ -13511,6 +13540,12 @@ snapshots: qrcode-terminal@0.11.0: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + qs@6.15.2: dependencies: side-channel: 1.1.0