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.
+- 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
+
+ );
+}
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