All components

Auth Two Factor Setup

Auth Two Factor Setup is a React and Tailwind CSS component from HextaUI, licensed MIT. Copy it and paste it into your project.

HextaUI MIT other

What it is

Auth Two Factor Setup is a React and Tailwind CSS component from HextaUI, licensed MIT. Copy it and paste it into your project.

How to use it

Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.

Open in the catalogue

Five free copies a month. No card.

Needs

npm i next

Licence

This component comes from HextaUI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.

Auth Two Factor Setup

registry/new-york/blocks/auth/auth-two-factor-setup.tsx
"use client";

import {
  CheckCircle2,
  Copy,
  Download,
  Eye,
  EyeOff,
  Loader2,
  QrCode,
  RefreshCw,
  Shield,
  ShieldCheck,
  ShieldOff,
} from "lucide-react";
import Image from "next/image";
import { useCallback, useState } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/new-york/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/registry/new-york/ui/card";
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldError,
  FieldLabel,
} from "@/registry/new-york/ui/field";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
} from "@/registry/new-york/ui/input-group";
import { Separator } from "@/registry/new-york/ui/separator";
import { Switch } from "@/registry/new-york/ui/switch";

interface ErrorAlertProps {
  message: string;
}

function ErrorAlert({ message }: ErrorAlertProps) {
  return (
    <div
      aria-live="polite"
      className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm"
      role="alert"
    >
      {message}
    </div>
  );
}

interface EnabledStateHeaderProps {
  className?: string;
}

function EnabledStateHeader({ className }: EnabledStateHeaderProps) {
  return (
    <div
      className={cn(
        "flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between",
        className
      )}
    >
      <div className="min-w-0 flex-1">
        <CardTitle className="flex flex-wrap items-center gap-2">
          <ShieldCheck
            aria-hidden="true"
            className="size-5 shrink-0 text-primary"
          />
          <span className="wrap-break-word">Two-factor authentication</span>
        </CardTitle>
        <CardDescription className="wrap-break-word">
          Your account is protected with 2FA
        </CardDescription>
      </div>
      <div className="flex shrink-0 items-center gap-2">
        <span className="whitespace-nowrap text-muted-foreground text-sm">
          Enabled
        </span>
        <Switch checked={true} disabled />
      </div>
    </div>
  );
}

function ActiveStatusCard() {
  return (
    <div className="rounded-lg border bg-muted/50 p-4">
      <div className="flex items-start gap-3">
        <ShieldCheck
          aria-hidden="true"
          className="size-5 shrink-0 text-primary"
        />
        <div className="flex min-w-0 flex-1 flex-col gap-2">
          <p className="wrap-break-word font-medium text-sm">2FA is active</p>
          <p className="wrap-break-word text-muted-foreground text-xs">
            Your account is protected with two-factor authentication.
            You&apos;ll need to enter a code from your authenticator app when
            signing in.
          </p>
        </div>
      </div>
    </div>
  );
}

interface BackupCodeItemProps {
  code: string;
  index: number;
  copiedIndex: number | null;
  onCopy: (code: string, index: number) => void;
}

function BackupCodeItem({
  code,
  index,
  copiedIndex,
  onCopy,
}: BackupCodeItemProps) {
  return (
    <div className="flex min-w-0 items-center justify-between gap-2 font-mono text-sm">
      <span className="min-w-0 break-all">{code}</span>
      <Button
        aria-label={`Copy backup code ${index + 1}`}
        className="min-h-[32px] min-w-[32px] shrink-0 touch-manipulation"
        onClick={() => onCopy(code, index)}
        size="icon-sm"
        type="button"
        variant="ghost"
      >
        {copiedIndex === index ? (
          <CheckCircle2 aria-hidden="true" className="size-3.5 text-primary" />
        ) : (
          <Copy aria-hidden="true" className="size-3.5" />
        )}
      </Button>
    </div>
  );
}

interface BackupCodesListProps {
  codes: string[];
  copiedIndex: number | null;
  onCopy: (code: string, index: number) => void;
}

function BackupCodesList({ codes, copiedIndex, onCopy }: BackupCodesListProps) {
  return (
    <div className="grid grid-cols-1 gap-2 rounded-lg border bg-background p-4 sm:grid-cols-2">
      {codes.map((code, index) => (
        <BackupCodeItem
          code={code}
          copiedIndex={copiedIndex}
          index={index}
          key={index}
          onCopy={onCopy}
        />
      ))}
    </div>
  );
}

interface BackupCodesActionsProps {
  onDownload: () => void;
  onRegenerate?: () => void;
}

function BackupCodesActions({
  onDownload,
  onRegenerate,
}: BackupCodesActionsProps) {
  return (
    <div className="flex flex-col gap-2 sm:flex-row">
      <Button
        className="min-h-[44px] w-full touch-manipulation sm:min-h-[32px] sm:w-auto"
        onClick={onDownload}
        type="button"
        variant="outline"
      >
        <Download aria-hidden="true" className="size-4" />
        Download codes
      </Button>
      {onRegenerate && (
        <Button
          className="min-h-[44px] w-full touch-manipulation sm:min-h-[32px] sm:w-auto"
          onClick={onRegenerate}
          type="button"
          variant="outline"
        >
          <RefreshCw aria-hidden="true" className="size-4" />
          Regenerate codes
        </Button>
      )}
    </div>
  );
}

interface BackupCodesSectionProps {
  codes: string[];
  showCodes: boolean;
  onToggleShow: () => void;
  copiedIndex: number | null;
  onCopy: (code: string, index: number) => void;
  onDownload: () => void;
  onRegenerate?: () => void;
}

function BackupCodesSection({
  codes,
  showCodes,
  onToggleShow,
  copiedIndex,
  onCopy,
  onDownload,
  onRegenerate,
}: BackupCodesSectionProps) {
  return (
    <div className="flex flex-col gap-3">
      <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
        <div className="min-w-0 flex-1">
          <h3 className="wrap-break-word font-medium text-sm">Backup codes</h3>
          <p className="wrap-break-word text-muted-foreground text-xs">
            Save these codes in a safe place. You can use them to access your
            account if you lose your device.
          </p>
        </div>
        <Button
          className="min-h-[44px] w-full touch-manipulation sm:min-h-[32px] sm:w-auto"
          onClick={onToggleShow}
          type="button"
          variant="outline"
        >
          {showCodes ? "Hide" : "Show"} codes
        </Button>
      </div>
      {showCodes && (
        <BackupCodesList
          codes={codes}
          copiedIndex={copiedIndex}
          onCopy={onCopy}
        />
      )}
      <BackupCodesActions onDownload={onDownload} onRegenerate={onRegenerate} />
    </div>
  );
}

interface PasswordFieldProps {
  value: string;
  onChange: (value: string) => void;
  showPassword: boolean;
  onToggleShow: () => void;
  error?: string;
}

function PasswordField({
  value,
  onChange,
  showPassword,
  onToggleShow,
  error,
}: PasswordFieldProps) {
  return (
    <Field data-invalid={!!error}>
      <FieldLabel htmlFor="disable-2fa-password">
        Password
        <span aria-label="required" className="text-destructive">
          *
        </span>
      </FieldLabel>
      <FieldContent>
        <InputGroup aria-invalid={!!error}>
          <InputGroupAddon>
            <Shield aria-hidden="true" className="size-4" />
          </InputGroupAddon>
          <InputGroupInput
            aria-describedby={error ? "disable-2fa-password-error" : undefined}
            aria-invalid={!!error}
            autoComplete="current-password"
            id="disable-2fa-password"
            name="password"
            onChange={(e) => onChange(e.target.value)}
            placeholder="Enter your password…"
            required
            type={showPassword ? "text" : "password"}
            value={value}
          />
          <InputGroupButton
            aria-label={showPassword ? "Hide password" : "Show password"}
            className="min-h-[32px] min-w-[32px] touch-manipulation"
            onClick={(e) => {
              e.preventDefault();
              onToggleShow();
            }}
            type="button"
          >
            {showPassword ? (
              <EyeOff aria-hidden="true" className="size-4" />
            ) : (
              <Eye aria-hidden="true" className="size-4" />
            )}
          </InputGroupButton>
        </InputGroup>
        {error && (
          <FieldError id="disable-2fa-password-error">{error}</FieldError>
        )}
      </FieldContent>
    </Field>
  );
}

interface DisableButtonProps {
  isLoading: boolean;
  disabled: boolean;
  onClick: () => void;
}

function DisableButton({ isLoading, disabled, onClick }: DisableButtonProps) {
  return (
    <Button
      aria-busy={isLoading}
      className="min-h-[44px] w-full touch-manipulation sm:min-h-[32px]"
      data-loading={isLoading}
      disabled={disabled}
      onClick={onClick}
      type="button"
      variant="destructive"
    >
      {isLoading ? (
        <>
          <Loader2 aria-hidden="true" className="size-4 animate-spin" />
          Disabling…
        </>
      ) : (
        <>
          <ShieldOff aria-hidden="true" className="size-4" />
          Disable 2FA
        </>
      )}
    </Button>
  );
}

interface DisableSectionProps {
  password: string;
  onPasswordChange: (value: string) => void;
  showPassword: boolean;
  onToggleShowPassword: () => void;
  error?: string;
  isLoading: boolean;
  onDisable: () => void;
}

function DisableSection({
  password,
  onPasswordChange,
  showPassword,
  onToggleShowPassword,
  error,
  isLoading,
  onDisable,
}: DisableSectionProps) {
  return (
    <div className="flex flex-col gap-4">
      <div className="min-w-0">
        <h3 className="wrap-break-word font-medium text-destructive text-sm">
          Disable two-factor authentication
        </h3>
        <p className="wrap-break-word text-muted-foreground text-xs">
          You&apos;ll need to enter your password to disable 2FA.
        </p>
      </div>
      <PasswordField
        error={error}
        onChange={onPasswordChange}
        onToggleShow={onToggleShowPassword}
        showPassword={showPassword}
        value={password}
      />
      <DisableButton
        disabled={isLoading || !password.trim()}
        isLoading={isLoading}
        onClick={onDisable}
      />
    </div>
  );
}

function SetupHeader() {
  return (
    <>
      <CardTitle className="flex flex-wrap items-center gap-2">
        <span className="wrap-break-word">
          Set up two-factor authentication
        </span>
      </CardTitle>
      <CardDescription className="wrap-break-word">
        Add an extra layer of security to your account
      </CardDescription>
    </>
  );
}

function SetupInstructions() {
  return (
    <div className="rounded-lg border bg-muted/50 p-4">
      <p className="wrap-break-word text-muted-foreground text-sm">
        Scan the QR code with your authenticator app (like Google Authenticator,
        Authy, or 1Password) to set up two-factor authentication.
      </p>
    </div>
  );
}

interface QRCodeDisplayProps {
  qrCodeUrl: string;
}

function QRCodeDisplay({ qrCodeUrl }: QRCodeDisplayProps) {
  return (
    <div className="flex flex-col items-center gap-4 rounded-lg border bg-background p-4 sm:p-6">
      <div className="relative aspect-square w-full max-w-[192px] rounded-lg border bg-white p-3 sm:size-48 sm:p-4">
        <Image
          alt="QR code for two-factor authentication"
          className="object-contain p-4"
          fill
          sizes="(max-width: 640px) 192px, 192px"
          src={qrCodeUrl}
        />
      </div>
      <p className="text-center text-muted-foreground text-xs">
        Scan this QR code with your authenticator app
      </p>
    </div>
  );
}

interface SecretKeyFieldProps {
  secretKey: string;
  onCopy: () => void;
}

function SecretKeyField({ secretKey, onCopy }: SecretKeyFieldProps) {
  return (
    <Field>
      <FieldLabel>Manual entry key</FieldLabel>
      <FieldContent>
        <InputGroup>
          <InputGroupAddon>
            <QrCode aria-hidden="true" className="size-4" />
          </InputGroupAddon>
          <InputGroupInput readOnly type="text" value={secretKey} />
          <InputGroupButton
            aria-label="Copy secret key"
            className="min-h-[32px] min-w-[32px] touch-manipulation"
            onClick={onCopy}
            type="button"
          >
            <Copy aria-hidden="true" className="size-4" />
          </InputGroupButton>
        </InputGroup>
        <FieldDescription>
          If you can&apos;t scan the QR code, enter this key manually in your
          authenticator app
        </FieldDescription>
      </FieldContent>
    </Field>
  );
}

interface RegenerateButtonProps {
  onRegenerate: () => void;
}

function RegenerateButton({ onRegenerate }: RegenerateButtonProps) {
  return (
    <Button
      className="min-h-[44px] w-full touch-manipulation sm:min-h-[32px] sm:w-auto"
      onClick={onRegenerate}
      type="button"
      variant="outline"
    >
      <RefreshCw aria-hidden="true" className="size-4" />
      Generate new QR code
    </Button>
  );
}

interface EnableButtonProps {
  isLoading: boolean;
  onClick?: () => void;
}

function EnableButton({ isLoading, onClick }: EnableButtonProps) {
  return (
    <div className="flex flex-col gap-2">
      <Button
        aria-busy={isLoading}
        className="min-h-[44px] w-full touch-manipulation sm:min-h-[32px]"
        data-loading={isLoading}
        disabled={isLoading}
        onClick={onClick}
        type="button"
      >
        {isLoading ? (
          <>
            <Loader2 aria-hidden="true" className="size-4 animate-spin" />
            Enabling 2FA…
          </>
        ) : (
          <>
            <ShieldCheck aria-hidden="true" className="size-4" />
            Enable 2FA
          </>
        )}
      </Button>
      <p className="text-center text-muted-foreground text-xs">
        After enabling, you&apos;ll need to verify with a code from your
        authenticator app
      </p>
    </div>
  );
}

function downloadBackupCodes(codes: string[]): void {
  if (codes.length === 0) return;
  const content = codes.join("\n");
  const blob = new Blob([content], { type: "text/plain" });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = "backup-codes.txt";
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
  URL.revokeObjectURL(url);
}

export interface AuthTwoFactorSetupProps {
  isEnabled?: boolean;
  qrCodeUrl?: string;
  secretKey?: string;
  backupCodes?: string[];
  onEnable?: () => void;
  onDisable?: (password: string) => void;
  onGenerateBackupCodes?: () => void;
  onRegenerateSecret?: () => void;
  className?: string;
  isLoading?: boolean;
  errors?: {
    password?: string;
    general?: string;
  };
}

export default function AuthTwoFactorSetup({
  isEnabled = false,
  qrCodeUrl,
  secretKey,
  backupCodes = [],
  onEnable,
  onDisable,
  onGenerateBackupCodes,
  onRegenerateSecret,
  className,
  isLoading = false,
  errors,
}: AuthTwoFactorSetupProps) {
  const [password, setPassword] = useState("");
  const [showPassword, setShowPassword] = useState(false);
  const [showBackupCodes, setShowBackupCodes] = useState(false);
  const [copiedIndex, setCopiedIndex] = useState<number | null>(null);

  const handleCopyCode = useCallback(async (code: string, index: number) => {
    try {
      await navigator.clipboard.writeText(code);
      setCopiedIndex(index);
      setTimeout(() => setCopiedIndex(null), 2000);
    } catch {}
  }, []);

  const handleCopySecret = useCallback(async () => {
    if (secretKey) {
      try {
        await navigator.clipboard.writeText(secretKey);
      } catch {}
    }
  }, [secretKey]);

  const handleDisable = useCallback(() => {
    if (!password.trim()) return;
    onDisable?.(password);
    setPassword("");
  }, [password, onDisable]);

  const handleDownloadBackupCodes = useCallback(() => {
    downloadBackupCodes(backupCodes);
  }, [backupCodes]);

  const handleToggleShowPassword = useCallback(() => {
    setShowPassword((prev) => !prev);
  }, []);

  const handleToggleShowBackupCodes = useCallback(() => {
    setShowBackupCodes((prev) => !prev);
  }, []);

  if (isEnabled) {
    return (
      <Card className={cn("w-full max-w-sm shadow-xs", className)}>
        <CardHeader>
          <EnabledStateHeader />
        </CardHeader>
        <CardContent>
          <div className="flex flex-col gap-6">
            {errors?.general && <ErrorAlert message={errors.general} />}

            <div className="flex flex-col gap-4">
              <ActiveStatusCard />

              {backupCodes.length > 0 && (
                <BackupCodesSection
                  codes={backupCodes}
                  copiedIndex={copiedIndex}
                  onCopy={handleCopyCode}
                  onDownload={handleDownloadBackupCodes}
                  onRegenerate={onGenerateBackupCodes}
                  onToggleShow={handleToggleShowBackupCodes}
                  showCodes={showBackupCodes}
                />
              )}

              <Separator />

              <DisableSection
                error={errors?.password}
                isLoading={isLoading}
                onDisable={handleDisable}
                onPasswordChange={setPassword}
                onToggleShowPassword={handleToggleShowPassword}
                password={password}
                showPassword={showPassword}
              />
            </div>
          </div>
        </CardContent>
      </Card>
    );
  }

  return (
    <Card className={cn("w-full max-w-sm shadow-xs", className)}>
      <CardHeader>
        <SetupHeader />
      </CardHeader>
      <CardContent>
        <div className="flex flex-col gap-6">
          {errors?.general && <ErrorAlert message={errors.general} />}

          <div className="flex flex-col gap-4">
            <SetupInstructions />

            {qrCodeUrl && <QRCodeDisplay qrCodeUrl={qrCodeUrl} />}

            {secretKey && (
              <SecretKeyField onCopy={handleCopySecret} secretKey={secretKey} />
            )}

            {onRegenerateSecret && (
              <RegenerateButton onRegenerate={onRegenerateSecret} />
            )}

            <Separator />

            <EnableButton isLoading={isLoading} onClick={onEnable} />
          </div>
        </div>
      </CardContent>
    </Card>
  );
}

Similar components