Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions frontend/apps/web/app/settings/SecuritySection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -194,14 +195,18 @@ export function SecuritySection({ baseUrl, token }: { baseUrl: string; token: st
enroll ? (
<div className={`${cardCls} space-y-3`}>
<p className="text-sm text-neutral-600 dark:text-neutral-300">
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.
</p>
<div className="flex justify-center">
<QrCode value={enroll.otpauth_uri} />
</div>
<a href={enroll.otpauth_uri} className={`${ghostBtn} w-full`}>
Open in authenticator app
On this phone? Open in authenticator app
</a>
<div>
<p className="mb-1 text-[11px] font-medium uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
Or enter this key manually
Can&apos;t scan? Enter this key manually
</p>
<div className="flex items-center gap-2">
<code className="flex-1 break-all rounded-md bg-white px-2 py-1.5 font-mono text-xs text-neutral-800 dark:bg-neutral-950 dark:text-neutral-100">
Expand Down
12 changes: 8 additions & 4 deletions frontend/apps/web/lib/auth/AuthForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
type MfaChallenge,
type MfaEnrollData,
} from './api';
import { QrCode } from './QrCode';

export type AuthMode = 'signin' | 'signup';

Expand Down Expand Up @@ -480,21 +481,24 @@ function TwoFactorStep({
{!challenge.enrolled ? (
<div className="space-y-3">
<p className="text-sm leading-relaxed text-neutral-600 dark:text-neutral-300">
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.
</p>
{loadingEnroll ? (
<p className="text-xs text-neutral-500 dark:text-neutral-400">Preparing setup…</p>
) : enrollData ? (
<div className="space-y-2 rounded-xl border border-neutral-200 bg-neutral-50/70 p-3 dark:border-neutral-700 dark:bg-neutral-900/60">
<div className="flex justify-center">
<QrCode value={enrollData.otpauth_uri} />
</div>
<a
href={enrollData.otpauth_uri}
className="inline-flex h-10 w-full items-center justify-center rounded-lg border border-primary-300 bg-primary-50 text-sm font-semibold text-primary-700 transition-colors hover:bg-primary-100 dark:border-primary-600 dark:bg-primary-900/30 dark:text-primary-200"
>
Open in authenticator app
On this phone? Open in authenticator app
</a>
<p className="text-[11px] font-medium uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
Or enter this key manually
Can&apos;t scan? Enter this key manually
</p>
<div className="flex items-center gap-2">
<code className="flex-1 break-all rounded-md bg-white px-2 py-1.5 font-mono text-xs text-neutral-800 dark:bg-neutral-950 dark:text-neutral-100">
Expand Down
42 changes: 42 additions & 0 deletions frontend/apps/web/lib/auth/QrCode.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<div
className="animate-pulse rounded-lg bg-neutral-200 dark:bg-neutral-700"
style={{ width: size, height: size }}
aria-hidden
/>
);
}

return (
// eslint-disable-next-line @next/next/no-img-element -- data URL, no remote optimization
<img
src={dataUrl}
alt="Two-factor authentication QR code"
width={size}
height={size}
className="rounded-lg bg-white p-2 shadow-sm"
/>
);
}
2 changes: 2 additions & 0 deletions frontend/apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
35 changes: 35 additions & 0 deletions frontend/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading