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