Auth OTP Verify

PreviousNext

Verify OTP code sent via email, SMS, or WhatsApp.

Docs
hextauiui

Preview

Loading preview…
registry/new-york/blocks/auth/auth-otp-verify.tsx
"use client";

import { Loader2, Mail, MessageSquare, Phone, RefreshCw } from "lucide-react";
import { useCallback, useEffect, 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 {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
} from "@/registry/new-york/ui/input-otp";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/registry/new-york/ui/select";

export type OTPDeliveryMethod = "email" | "sms" | "whatsapp";

export interface AuthOTPVerifyProps {
  deliveryMethod?: OTPDeliveryMethod;
  deliveryAddress?: string;
  onDeliveryMethodChange?: (method: OTPDeliveryMethod) => void;
  onSubmit?: (code: string) => void;
  onResend?: (method: OTPDeliveryMethod) => void;
  className?: string;
  isLoading?: boolean;
  resendCooldown?: number;
  errors?: {
    code?: string;
    general?: string;
  };
  autoSubmit?: boolean;
  codeLength?: number;
  availableMethods?: OTPDeliveryMethod[];
}

const DELIVERY_METHOD_CONFIG: Record<
  OTPDeliveryMethod,
  { label: string; icon: React.ComponentType<{ className?: string }> }
> = {
  email: {
    label: "Email",
    icon: Mail,
  },
  sms: {
    label: "SMS",
    icon: MessageSquare,
  },
  whatsapp: {
    label: "WhatsApp",
    icon: Phone,
  },
};

function formatDeliveryAddress(
  address: string | undefined,
  method: OTPDeliveryMethod
): string {
  if (!address) return "";
  if (method === "email") return address;
  if (address.length > 4) {
    const visible = address.slice(-4);
    const masked = "*".repeat(address.length - 4);
    return `${masked}${visible}`;
  }
  return address;
}

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 DeliveryMethodSelectProps {
  availableMethods: OTPDeliveryMethod[];
  deliveryMethod: OTPDeliveryMethod;
  onDeliveryMethodChange: (method: OTPDeliveryMethod) => void;
}

function DeliveryMethodSelect({
  availableMethods,
  deliveryMethod,
  onDeliveryMethodChange,
}: DeliveryMethodSelectProps) {
  return (
    <Field>
      <FieldLabel>Delivery method</FieldLabel>
      <FieldContent>
        <Select
          onValueChange={(value) =>
            onDeliveryMethodChange(value as OTPDeliveryMethod)
          }
          value={deliveryMethod}
        >
          <SelectTrigger className="w-full">
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            {availableMethods.map((method) => {
              const config = DELIVERY_METHOD_CONFIG[method];
              const Icon = config.icon;
              return (
                <SelectItem key={method} value={method}>
                  <div className="flex items-center gap-2">
                    <Icon aria-hidden="true" className="size-4" />
                    {config.label}
                  </div>
                </SelectItem>
              );
            })}
          </SelectContent>
        </Select>
        <FieldDescription>
          Choose how you want to receive the verification code
        </FieldDescription>
      </FieldContent>
    </Field>
  );
}

interface ResendButtonProps {
  cooldown: number;
  isLoading: boolean;
  onClick: () => void;
}

function ResendButton({ cooldown, isLoading, onClick }: ResendButtonProps) {
  return (
    <button
      className="min-h-[32px] touch-manipulation self-start rounded-sm hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:self-auto"
      disabled={cooldown > 0 || isLoading}
      onClick={onClick}
      type="button"
    >
      {cooldown > 0 ? (
        `Resend in ${cooldown}s`
      ) : (
        <span className="flex items-center gap-1">
          <RefreshCw aria-hidden="true" className="size-3" />
          Resend code
        </span>
      )}
    </button>
  );
}

interface OTPFieldProps {
  code: string;
  codeError?: string;
  codeLength: number;
  isLoading: boolean;
  onCodeChange: (code: string) => void;
  onResend?: () => void;
  resendCooldown: number;
}

function OTPField({
  code,
  codeError,
  codeLength,
  isLoading,
  onCodeChange,
  onResend,
  resendCooldown,
}: OTPFieldProps) {
  return (
    <Field data-invalid={!!codeError}>
      <FieldLabel htmlFor="otp-code">
        Verification code
        <span aria-label="required" className="text-destructive">
          *
        </span>
      </FieldLabel>
      <FieldContent>
        <InputOTP
          aria-describedby={codeError ? "otp-code-error" : undefined}
          aria-invalid={!!codeError}
          disabled={isLoading}
          id="otp-code"
          maxLength={codeLength}
          onChange={onCodeChange}
          value={code}
        >
          <InputOTPGroup>
            {Array.from({ length: codeLength }).map((_, index) => (
              <InputOTPSlot index={index} key={index} />
            ))}
          </InputOTPGroup>
        </InputOTP>
        {codeError && <FieldError id="otp-code-error">{codeError}</FieldError>}
        <div className="flex flex-col gap-2 text-muted-foreground text-xs sm:flex-row sm:items-center sm:justify-between">
          <span>Enter the {codeLength}-digit code</span>
          {onResend && (
            <ResendButton
              cooldown={resendCooldown}
              isLoading={isLoading}
              onClick={onResend}
            />
          )}
        </div>
      </FieldContent>
    </Field>
  );
}

interface VerifyButtonProps {
  code: string;
  codeLength: number;
  isLoading: boolean;
  onSubmit: (code: string) => void;
}

function VerifyButton({
  code,
  codeLength,
  isLoading,
  onSubmit,
}: VerifyButtonProps) {
  return (
    <Button
      aria-busy={isLoading}
      className="min-h-[44px] w-full touch-manipulation"
      data-loading={isLoading}
      disabled={isLoading || code.length !== codeLength}
      onClick={() => onSubmit(code)}
      type="button"
    >
      {isLoading ? (
        <>
          <Loader2 aria-hidden="true" className="size-4 animate-spin" />
          Verifying…
        </>
      ) : (
        "Verify code"
      )}
    </Button>
  );
}

export default function AuthOTPVerify({
  deliveryMethod = "email",
  deliveryAddress,
  onDeliveryMethodChange,
  onSubmit,
  onResend,
  className,
  isLoading = false,
  resendCooldown = 60,
  errors,
  autoSubmit = true,
  codeLength = 6,
  availableMethods = ["email", "sms"],
}: AuthOTPVerifyProps) {
  const [code, setCode] = useState("");
  const [cooldown, setCooldown] = useState(0);

  useEffect(() => {
    if (cooldown > 0) {
      const timer = setTimeout(() => {
        setCooldown((prev) => prev - 1);
      }, 1000);
      return () => clearTimeout(timer);
    }
  }, [cooldown]);

  useEffect(() => {
    if (autoSubmit && code.length === codeLength && !isLoading) {
      onSubmit?.(code);
    }
  }, [code, autoSubmit, isLoading, codeLength, onSubmit]);

  const handleResend = useCallback(async () => {
    if (cooldown > 0) return;
    await onResend?.(deliveryMethod);
    setCooldown(resendCooldown);
  }, [cooldown, onResend, deliveryMethod, resendCooldown]);

  const handleCodeChange = useCallback((newCode: string) => {
    setCode(newCode);
  }, []);

  const codeError = errors?.code;
  const generalError = errors?.general;
  const methodConfig = DELIVERY_METHOD_CONFIG[deliveryMethod];
  const MethodIcon = methodConfig.icon;
  const formattedAddress = formatDeliveryAddress(
    deliveryAddress,
    deliveryMethod
  );

  return (
    <Card className={cn("w-full max-w-sm shadow-xs", className)}>
      <CardHeader>
        <CardTitle className="flex items-center gap-2">
          Verify your {methodConfig.label.toLowerCase()}
        </CardTitle>
        <CardDescription>
          We&apos;ve sent a {codeLength}-digit code to{" "}
          {deliveryAddress ? (
            <span className="font-medium">{formattedAddress}</span>
          ) : (
            "your " + methodConfig.label.toLowerCase()
          )}
        </CardDescription>
      </CardHeader>
      <CardContent>
        <div className="flex flex-col gap-6">
          {generalError && <ErrorAlert message={generalError} />}

          {availableMethods.length > 1 && onDeliveryMethodChange && (
            <DeliveryMethodSelect
              availableMethods={availableMethods}
              deliveryMethod={deliveryMethod}
              onDeliveryMethodChange={onDeliveryMethodChange}
            />
          )}

          <OTPField
            code={code}
            codeError={codeError}
            codeLength={codeLength}
            isLoading={isLoading}
            onCodeChange={handleCodeChange}
            onResend={onResend ? handleResend : undefined}
            resendCooldown={cooldown}
          />

          {!autoSubmit && (
            <VerifyButton
              code={code}
              codeLength={codeLength}
              isLoading={isLoading}
              onSubmit={onSubmit || (() => {})}
            />
          )}
        </div>
      </CardContent>
    </Card>
  );
}

Installation

npx shadcn@latest add @hextaui/auth-otp-verify

Usage

import { AuthOtpVerify } from "@/components/ui/auth-otp-verify"
<AuthOtpVerify />