two-factor-setup.tsx4.9 KBView on GitHub import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp';
import { Button } from '@/components/ui/button';
import { QRCodeSVG } from 'qrcode.react';
import { useState } from 'react';
import { toast } from 'sonner';
export const TOTP_CODE_LENGTH = 6;
/** Rendered size of the enrollment QR, in pixels. Large enough to scan off a laptop screen. */
const QR_SIZE = 176;
/**
* The first enrollment step: hand the `otpauth://` URI to an authenticator app.
*
* Scanning is the primary path and the setup key is the fallback, kept behind a disclosure
* so the common case is one action. The key still has to be reachable — a desktop password
* manager on the same screen as the QR has no camera to scan with.
*/
export function AuthenticatorSetupPanel({
totpURI,
setupKey,
}: {
totpURI: string;
setupKey=[redacted] | null;
}) {
const [showSetupKey, setShowSetupKey] = useState(false);
// The full URI is a poor thing to type by hand, but it is what every authenticator app
// accepts, so it beats showing nothing if the secret could not be parsed out.
const manualKey=[redacted] ?? totpURI;
return (
<section className="space-y-3">
<h3 className="text-sm font-medium">1. Add Cedar to your authenticator app</h3>
<p className="text-muted-foreground text-sm">
In 1Password, Authy, Google Authenticator or similar, choose “add account”, then scan
this code.
</p>
{/* White ground and dark modules are painted explicitly rather than inherited: scanners
need dark-on-light and Cedar renders dark by default, so a themed QR would not read.
`marginSize` supplies the 4-module quiet zone the spec requires. */}
<div className="w-fit rounded-md bg-white p-2">
<QRCodeSVG
value={totpURI}
size={QR_SIZE}
level="M"
marginSize={4}
title="Two-factor authentication setup code"
/>
</div>
{showSetupKey ? (
<div className="space-y-2">
<p className="text-muted-foreground text-sm">
Choose “enter a setup key” in your authenticator app and paste this instead.
</p>
<div className="flex items-center gap-2">
<code className="bg-muted block flex-1 break-all rounded-md p-3 font-mono text-sm">
{manualKey}
</code>
<Button
variant="outline"
className="cursor-pointer"
onClick={() => void copyToClipboard(manualKey, 'Setup key copied')}
>
Copy
</Button>
</div>
</div>
) : (
<button
type="button"
className="text-muted-foreground cursor-pointer text-sm underline"
onClick={() => setShowSetupKey(true)}
>
Can’t scan? Enter a setup key instead
</button>
)}
</section>
);
}
/**
* Recovery codes. Rendered wherever a fresh set is produced — first enrollment and
* regeneration alike — because in both cases this is the only time they are shown.
*/
export function RecoveryCodesPanel({
codes,
heading = '2. Save your recovery codes',
}: {
codes: string[];
heading?: string;
}) {
return (
<section className="space-y-2">
<h3 className="text-sm font-medium">{heading}</h3>
<p className="text-muted-foreground text-sm">
Each code works once, and this is the only time they are shown. Keep them somewhere you
can reach without your authenticator app — they are how you get back in if you lose
your phone.
</p>
<ul className="bg-muted grid grid-cols-2 gap-2 rounded-md p-3 font-mono text-sm">
{codes.map((code) => (
<li key=[redacted]
))}
</ul>
<Button
variant="outline"
className="cursor-pointer"
onClick={() => void copyToClipboard(codes.join('\n'), 'Recovery codes copied')}
>
Copy recovery codes
</Button>
</section>
);
}
/** Six-digit code entry, submitting on its own once the field is full. */
export function TotpCodeInput({
value,
onChange,
onComplete,
disabled,
autoFocus,
}: {
value: string;
onChange: (value: string) => void;
onComplete?: () => void;
disabled?: boolean;
autoFocus?: boolean;
}) {
return (
<InputOTP
autoFocus={autoFocus}
maxLength={TOTP_CODE_LENGTH}
value={value}
onChange={(next) => {
onChange(next);
if (next.length === TOTP_CODE_LENGTH) onComplete?.();
}}
disabled={disabled}
>
<InputOTPGroup>
{Array.from({ length: TOTP_CODE_LENGTH }, (_, index) => (
<InputOTPSlot key=[redacted] index={index} />
))}
</InputOTPGroup>
</InputOTP>
);
}
async function copyToClipboard(text: string, successMessage: string) {
try {
await navigator.clipboard.writeText(text);
toast.success(successMessage);
} catch {
toast.error('Could not copy — select and copy manually.');
}
}