shadimage
better-auth-ui
componentauthsource: ok · refreshed

Auth

A unified authentication component that renders the appropriate auth view based on path or view prop.

npx shadcn add "https://better-auth-ui.com/r/{style}/auth.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react

Registry dependencies

alertbadgebuttoncalendarcardcheckboxcomboboxfieldhttps://better-auth-ui.com/r/radix-nova/auth-provider.jsonhttps://better-auth-ui.com/r/radix-nova/auth-redirect.jsonhttps://better-auth-ui.com/r/radix-nova/forgot-password.jsonhttps://better-auth-ui.com/r/radix-nova/reset-password.jsonhttps://better-auth-ui.com/r/radix-nova/sign-in.jsonhttps://better-auth-ui.com/r/radix-nova/sign-out.jsonhttps://better-auth-ui.com/r/radix-nova/sign-up.jsonhttps://better-auth-ui.com/r/radix-nova/verify-email.jsoninputinput-grouppopoverselectslidersonnerspinnerswitchtextareatooltip

Files21 · source cached 10/8/2026, 6:18:29 AM

examples/start-shadcn-example/src/components/auth/additional-field.tsx
"use client"

import {
  type AdditionalField as AdditionalFieldConfig,
  type AdditionalFieldFormValue,
  getFormFieldErrors,
  resolveInputType
} from "@better-auth-ui/core"
import { useAuth, useCopyToClipboard } from "@better-auth-ui/react"
import { format } from "date-fns"
import { CalendarIcon, Check, ChevronDownIcon, Copy } from "lucide-react"
import { type ComponentType, useRef, useState } from "react"
import { toast } from "sonner"

import { buttonVariants } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import { Checkbox } from "@/components/ui/checkbox"
import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxList
} from "@/components/ui/combobox"
import {
  Field,
  FieldContent,
  FieldError,
  FieldLabel
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput
} from "@/components/ui/input-group"
import {
  Popover,
  PopoverContent,
  PopoverTrigger
} from "@/components/ui/popover"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue
} from "@/components/ui/select"
import { Slider } from "@/components/ui/slider"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { cn } from "cn"

export type AdditionalFieldProps = {
  name: string
  field: AdditionalFieldConfig
  value: AdditionalFieldFormValue
  onBlur: () => void
  onChange: (value: AdditionalFieldFormValue) => void
  isInvalid?: boolean
  errors?: unknown[]
  isPending?: boolean
  /** Complete suffix appended to labels for fields that are not required. */
  optionalLabel?: string
}

function valueToString(value: AdditionalFieldFormValue) {
  if (value == null) return ""
  return value instanceof Date ? value.toISOString() : String(value)
}

/** Convert a `defaultValue` into a `Date` for the calendar. */
function toDate(value: unknown): Date | undefined {
  if (value instanceof Date) return value
  if (typeof value === "string") {
    const parsed = new Date(value)
    return Number.isNaN(parsed.getTime()) ? undefined : parsed
  }
  return undefined
}

/** Format a Date as `HH:mm:ss` for an `<input type="time">`. */
function formatTime(date: Date) {
  const pad = (n: number) => n.toString().padStart(2, "0")
  return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}

/**
 * Icon-only copy button used as an `InputGroupAddon`. `getValue` is invoked
 * lazily on click so the button copies the input's *live* value rather than a
 * stale snapshot — important when paired with editable inputs.
 */
function CopyButton({
  getValue,
  isDisabled
}: {
  getValue: () => string | undefined
  isDisabled?: boolean
}) {
  const { localization } = useAuth()
  const { copied, copy } = useCopyToClipboard({
    onError: (error) => {
      console.error("[Better Auth UI] Copy failed", error)
      toast.error(localization.errors.copyFailed)
    }
  })

  async function handleCopy() {
    const value = getValue()
    if (!value) return

    await copy(value)
  }

  return (
    <InputGroupButton
      aria-label={
        copied
          ? localization.settings.copiedToClipboard
          : localization.settings.copyToClipboard
      }
      title={
        copied
          ? localization.settings.copiedToClipboard
          : localization.settings.copyToClipboard
      }
      onClick={handleCopy}
      disabled={isDisabled}
    >
      {copied ? <Check /> : <Copy />}
    </InputGroupButton>
  )
}

/** Renders a single additional user field via shadcn primitives. */
export function AdditionalField({
  name,
  field: configuredField,
  value,
  onBlur,
  onChange,
  isInvalid,
  errors,
  isPending,
  optionalLabel
}: AdditionalFieldProps) {
  const field =
    optionalLabel && !configuredField.required
      ? {
          ...configuredField,
          label: (
            <>
              {configuredField.label}
              {optionalLabel}
            </>
          )
        }
      : configuredField
  const inputType = resolveInputType(field)
  const fieldErrors = getFormFieldErrors(errors ?? [])

  if (field.render) {
    const FieldRenderer = field.render as ComponentType<AdditionalFieldProps>
    return (
      <FieldRenderer
        name={name}
        field={field}
        value={value}
        onBlur={onBlur}
        onChange={onChange}
        isInvalid={isInvalid}
        errors={errors}
        isPending={isPending}
        optionalLabel={optionalLabel}
      />
    )
  }

  if (inputType === "hidden") {
    return (
      <input type="hidden" name={name} value={valueToString(value)} readOnly />
    )
  }

  if (inputType === "textarea") {
    return (
      <Field data-invalid={isInvalid}>
        <FieldLabel htmlFor={name}>{field.label}</FieldLabel>

        <Textarea
          id={name}
          name={name}
          value={valueToString(value)}
          onBlur={onBlur}
          onChange={(event) => onChange(event.target.value || null)}
          placeholder={field.placeholder}
          required={field.required}
          readOnly={field.readOnly}
          disabled={isPending}
          aria-invalid={isInvalid}
        />

        <FieldError errors={fieldErrors} />
      </Field>
    )
  }

  if (inputType === "number") {
    const maxFractionDigits = field.formatOptions?.maximumFractionDigits

    return (
      <Field data-invalid={isInvalid}>
        <FieldLabel htmlFor={name}>{field.label}</FieldLabel>

        <Input
          id={name}
          name={name}
          type="number"
          inputMode={maxFractionDigits ? "decimal" : "numeric"}
          min={field.min}
          max={field.max}
          step={
            field.step ??
            (maxFractionDigits ? 1 / 10 ** maxFractionDigits : undefined)
          }
          value={typeof value === "number" ? value : ""}
          onBlur={onBlur}
          onChange={(event) =>
            onChange(
              event.target.value === "" ? null : event.target.valueAsNumber
            )
          }
          placeholder={field.placeholder}
          required={field.required}
          readOnly={field.readOnly}
          disabled={isPending}
          aria-invalid={isInvalid}
        />

        <FieldError errors={fieldErrors} />
      </Field>
    )
  }

  if (inputType === "slider") {
    return (
      <SliderField
        name={name}
        field={field}
        value={value}
        onBlur={onBlur}
        onChange={onChange}
        isInvalid={isInvalid}
        errors={errors}
        isPending={isPending}
      />
    )
  }

  if (inputType === "switch") {
    return (
      <Field data-invalid={isInvalid} orientation="horizontal">
        <Switch
          id={name}
          name={name}
          checked={value === true}
          onBlur={onBlur}
          onCheckedChange={onChange}
          disabled={isPending || field.readOnly}
          aria-invalid={isInvalid}
        />

        <FieldContent>
          <FieldLabel htmlFor={name}>{field.label}</FieldLabel>
        </FieldContent>
        <FieldError errors={fieldErrors} />
      </Field>
    )
  }

  if (inputType === "checkbox") {
    return (
      <Field data-invalid={isInvalid} orientation="horizontal">
        <Checkbox
          id={name}
          name={name}
          checked={value === true}
          onBlur={onBlur}
          onCheckedChange={(checked) => onChange(checked === true)}
          required={field.required}
          disabled={isPending || field.readOnly}
          aria-invalid={isInvalid}
        />

        <FieldContent>
          <FieldLabel htmlFor={name}>{field.label}</FieldLabel>
        </FieldContent>
        <FieldError errors={fieldErrors} />
      </Field>
    )
  }

  if (inputType === "select") {
    return (
      <Field data-invalid={isInvalid}>
        <FieldLabel htmlFor={name}>{field.label}</FieldLabel>

        <Select
          name={name}
          value={valueToString(value) || undefined}
          onValueChange={(nextValue) => onChange(nextValue)}
          required={field.required}
          disabled={isPending || field.readOnly}
        >
          <SelectTrigger
            id={name}
            className="w-full"
            onBlur={onBlur}
            aria-invalid={isInvalid}
          >
            <SelectValue placeholder={field.placeholder} />
          </SelectTrigger>

          <SelectContent>
            {field.options?.map((option) => (
              <SelectItem key={option.value} value={option.value}>
                {option.label}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>

        <FieldError errors={fieldErrors} />
      </Field>
    )
  }

  if (inputType === "combobox") {
    const selectedOption = field.options?.find(
      (option) => option.value === valueToString(value)
    )

    return (
      <Field data-invalid={isInvalid}>
        <FieldLabel htmlFor={name}>{field.label}</FieldLabel>

        <Combobox
          items={field.options ?? []}
          name={name}
          value={selectedOption ?? null}
          onValueChange={(option) => onChange(option?.value ?? null)}
          required={field.required}
          disabled={isPending || field.readOnly}
        >
          <ComboboxInput
            placeholder={field.placeholder}
            id={name}
            onBlur={onBlur}
            aria-invalid={isInvalid}
          />

          <ComboboxContent>
            <ComboboxEmpty>No items found.</ComboboxEmpty>

            <ComboboxList>
              {(option) => (
                <ComboboxItem key={option.value} value={option}>
                  {option.label}
                </ComboboxItem>
              )}
            </ComboboxList>
          </ComboboxContent>
        </Combobox>

        <FieldError errors={fieldErrors} />
      </Field>
    )
  }

  if (inputType === "date" || inputType === "datetime") {
    return (
      <DateInput
        name={name}
        field={field}
        value={value}
        onBlur={onBlur}
        onChange={onChange}
        isInvalid={isInvalid}
        errors={errors}
        isPending={isPending}
      />
    )
  }

  return (
    <InputField
      name={name}
      field={field}
      value={value}
      onBlur={onBlur}
      onChange={onChange}
      isInvalid={isInvalid}
      errors={errors}
      isPending={isPending}
    />
  )
}

function InputField({
  name,
  field,
  value,
  onBlur,
  onChange,
  isInvalid,
  errors,
  isPending
}: AdditionalFieldProps) {
  const inputRef = useRef<HTMLInputElement>(null)
  const fieldErrors = getFormFieldErrors(errors ?? [])

  const hasPrefix = field.prefix != null
  const hasSuffix = field.suffix != null || field.copyable

  const isNumeric = field.type === "number"
  const maxFractionDigits = field.formatOptions?.maximumFractionDigits
  const nativeInputType = isNumeric ? "number" : undefined
  const nativeInputMode = isNumeric
    ? maxFractionDigits
      ? "decimal"
      : "numeric"
    : undefined
  const nativeStep = maxFractionDigits ? 1 / 10 ** maxFractionDigits : undefined

  if (hasPrefix || hasSuffix) {
    return (
      <Field data-invalid={isInvalid}>
        <FieldLabel htmlFor={name}>{field.label}</FieldLabel>

        <InputGroup>
          {hasPrefix && (
            <InputGroupAddon align="inline-start">
              {field.prefix}
            </InputGroupAddon>
          )}

          <InputGroupInput
            ref={inputRef}
            id={name}
            name={name}
            type={nativeInputType}
            inputMode={nativeInputMode}
            step={nativeStep}
            value={valueToString(value)}
            onBlur={onBlur}
            onChange={(event) => onChange(event.target.value || null)}
            placeholder={field.placeholder}
            required={field.required}
            readOnly={field.readOnly}
            disabled={isPending}
            aria-invalid={isInvalid}
          />

          {field.copyable ? (
            <InputGroupAddon align="inline-end">
              <CopyButton
                getValue={() => inputRef.current?.value}
                isDisabled={isPending}
              />
            </InputGroupAddon>
          ) : (
            field.suffix != null && (
              <InputGroupAddon align="inline-end">
                {field.suffix}
              </InputGroupAddon>
            )
          )}
        </InputGroup>

        <FieldError errors={fieldErrors} />
      </Field>
    )
  }

  return (
    <Field data-invalid={isInvalid}>
      <FieldLabel htmlFor={name}>{field.label}</FieldLabel>

      <Input
        id={name}
        name={name}
        type={nativeInputType}
        inputMode={nativeInputMode}
        step={nativeStep}
        value={valueToString(value)}
        onBlur={onBlur}
        onChange={(event) => onChange(event.target.value || null)}
        placeholder={field.placeholder}
        required={field.required}
        readOnly={field.readOnly}
        disabled={isPending}
        aria-invalid={isInvalid}
      />

      <FieldError errors={fieldErrors} />
    </Field>
  )
}

/**
 * Slider field. Radix Slider doesn't render the current value, so we render
 * it next to the label and control the state to keep the displayed value in
 * sync. The selected value is submitted via the underlying Radix `name` prop.
 */
function SliderField({
  name,
  field,
  value,
  onBlur,
  onChange,
  isInvalid,
  errors,
  isPending
}: AdditionalFieldProps) {
  const maxFractionDigits = field.formatOptions?.maximumFractionDigits
  const min = field.min ?? 0
  const max = field.max ?? 100
  const step =
    field.step ?? (maxFractionDigits ? 1 / 10 ** maxFractionDigits : 1)
  const numericValue = typeof value === "number" ? value : min
  const fieldErrors = getFormFieldErrors(errors ?? [])

  const formatter = new Intl.NumberFormat(undefined, field.formatOptions)

  return (
    <Field data-invalid={isInvalid}>
      <div className="flex items-center justify-between gap-2">
        <FieldLabel htmlFor={name}>{field.label}</FieldLabel>
        <span className="text-sm text-muted-foreground tabular-nums">
          {formatter.format(numericValue)}
        </span>
      </div>

      <Slider
        id={name}
        name={name}
        value={[numericValue]}
        onBlur={onBlur}
        onValueChange={(nextValue) =>
          onChange((Array.isArray(nextValue) ? nextValue[0] : nextValue) ?? min)
        }
        min={min}
        max={max}
        step={step}
        disabled={isPending || field.readOnly}
        aria-invalid={isInvalid}
      />

      <FieldError errors={fieldErrors} />
    </Field>
  )
}

/**
 * Date / datetime input. Composes `Popover` + `Calendar` for the date and
 * (optionally) `<input type="time">` for the time. Submits the combined ISO
 * value via a hidden `<input>` so it shows up in `FormData`.
 */
function DateInput({
  name,
  field,
  value,
  onBlur,
  onChange,
  isInvalid,
  errors,
  isPending
}: AdditionalFieldProps) {
  const { localization } = useAuth()
  const inputType = resolveInputType(field)
  const isDateTime = inputType === "datetime"
  const fieldErrors = getFormFieldErrors(errors ?? [])

  const date = toDate(value)
  const [time, setTime] = useState<string>(
    isDateTime && date ? formatTime(date) : ""
  )
  const [open, setOpen] = useState(false)

  // Compose the hidden form value: ISO date for "date", ISO datetime for
  // "datetime" (date + time).
  return (
    <Field data-invalid={isInvalid}>
      <FieldLabel htmlFor={`${name}-date`}>{field.label}</FieldLabel>

      <div className="relative flex gap-2">
        <Popover open={open} onOpenChange={setOpen}>
          <PopoverTrigger
            type="button"
            id={`${name}-date`}
            data-empty={!date}
            aria-invalid={isInvalid}
            aria-required={field.required}
            onBlur={onBlur}
            disabled={isPending || field.readOnly}
            className={cn(
              buttonVariants({ variant: "outline" }),
              "flex-1 justify-between font-normal",
              "data-[empty=true]:text-muted-foreground"
            )}
          >
            {date ? format(date, "PPP") : <span>{field.placeholder}</span>}

            {isDateTime ? <ChevronDownIcon /> : <CalendarIcon />}
          </PopoverTrigger>

          <PopoverContent className="w-auto overflow-hidden p-0" align="start">
            <Calendar
              mode="single"
              selected={date}
              defaultMonth={date}
              captionLayout="dropdown"
              onSelect={(value) => {
                if (!value) {
                  onChange(null)
                } else {
                  const nextValue = new Date(value)
                  if (isDateTime && time.trim()) {
                    const [hours = "0", minutes = "0", seconds = "0"] =
                      time.split(":")
                    nextValue.setHours(
                      Number(hours),
                      Number(minutes),
                      Number(seconds),
                      0
                    )
                  } else {
                    nextValue.setHours(0, 0, 0, 0)
                  }
                  onChange(nextValue)
                }
                if (!isDateTime) setOpen(false)
              }}
            />
          </PopoverContent>
        </Popover>

        {isDateTime && (
          <Field className="w-32">
            <FieldLabel htmlFor={`${name}-time`} className="sr-only">
              {localization.settings.time}
            </FieldLabel>

            <Input
              type="time"
              id={`${name}-time`}
              step="1"
              value={time}
              onChange={(event) => {
                const nextTime = event.target.value
                setTime(nextTime)
                if (!date) return
                const nextValue = new Date(date)
                const [hours = "0", minutes = "0", seconds = "0"] =
                  nextTime.split(":")
                nextValue.setHours(
                  Number(hours),
                  Number(minutes),
                  Number(seconds),
                  0
                )
                onChange(nextValue)
              }}
              disabled={isPending || field.readOnly}
              className="appearance-none bg-background [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
            />
          </Field>
        )}
      </div>

      <FieldError errors={fieldErrors} />
    </Field>
  )
}
examples/start-shadcn-example/src/components/auth/auth-form.tsx
"use client"

import {
  type AdditionalField as AdditionalFieldConfig,
  type AdditionalFieldFormValue,
  DEFAULT_ADDITIONAL_FIELD_VALIDATION_DEBOUNCE_MS,
  getFormFieldErrors,
  normalizeAuthFormServerError,
  validateAdditionalFieldRequired,
  validateAdditionalFieldValue
} from "@better-auth-ui/core"
import {
  type AnyFormApi,
  createFormHook,
  createFormHookContexts
} from "@tanstack/react-form"
import {
  type ComponentProps,
  type FormEvent,
  type ReactNode,
  useRef
} from "react"

import { Button } from "@/components/ui/button"
import {
  Field,
  FieldDescription,
  FieldError,
  FieldLabel
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Spinner } from "@/components/ui/spinner"
import { AdditionalField, type AdditionalFieldProps } from "./additional-field"

const { fieldContext, formContext, useFieldContext, useFormContext } =
  createFormHookContexts()

const DEFAULT_AUTH_FORM_SERVER_ERROR = "Unable to submit this form. Try again."

export function focusFirstInvalidAuthFormControl(form: HTMLFormElement) {
  requestAnimationFrame(() => {
    form
      .querySelector<HTMLElement>(
        '[aria-invalid="true"]:not([disabled]), :invalid:not([disabled])'
      )
      ?.focus()
  })
}

function AuthFormFieldError() {
  const field = useFieldContext<unknown>()
  const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid

  if (!isInvalid) return null

  const errors = getFormFieldErrors(field.state.meta.errors)

  return errors.length > 0 ? <FieldError errors={errors} /> : null
}

function AuthFormServerError() {
  const form = useFormContext()

  return (
    <form.Subscribe selector={(state) => state.errorMap.onServer}>
      {(error) => {
        const formError =
          error && typeof error === "object" && "form" in error
            ? error.form
            : error
        const errors = getFormFieldErrors(formError ? [formError] : [])
        return errors.length > 0 ? <FieldError errors={errors} /> : null
      }}
    </form.Subscribe>
  )
}

export function setAuthFormServerError(
  form: AnyFormApi,
  error: unknown,
  fallbackMessage: string
) {
  const normalized = normalizeAuthFormServerError(error, fallbackMessage)
  form.setErrorMap({
    onServer: {
      fields: normalized.fields ?? {},
      form: normalized.form
    }
  })
}

export function clearAuthFormServerError(form: AnyFormApi) {
  form.setErrorMap({ onServer: { fields: {} } })
}

export function clearAuthFormFieldServerError(
  form: AnyFormApi,
  fieldName: string
) {
  form.setErrorMap({ onServer: undefined })
  if (!fieldName) return

  const fieldMeta = form.getFieldMeta(fieldName as never)
  if (!fieldMeta?.errorMap.onServer) return

  form.setFieldMeta(fieldName as never, (current = fieldMeta) => ({
    ...current,
    errorMap: { ...current.errorMap, onServer: undefined },
    errorSourceMap: { ...current.errorSourceMap, onServer: undefined }
  }))
}

export async function runAuthFormAction(
  form: AnyFormApi,
  action: () => Promise<unknown>,
  serverErrorMessage = DEFAULT_AUTH_FORM_SERVER_ERROR
) {
  clearAuthFormServerError(form)
  try {
    await action()
    return true
  } catch (error) {
    if (!form.state.errorMap.onServer) {
      setAuthFormServerError(form, error, serverErrorMessage)
    }
    return false
  }
}

export async function submitAuthForm(
  form: AnyFormApi,
  serverErrorMessage = DEFAULT_AUTH_FORM_SERVER_ERROR
) {
  clearAuthFormServerError(form)
  try {
    await form.handleSubmit()
    return form.state.isValid
  } catch (error) {
    if (!form.state.errorMap.onServer) {
      setAuthFormServerError(form, error, serverErrorMessage)
    }
    return false
  }
}

type AuthFormRootProps = Omit<ComponentProps<"form">, "onSubmit"> & {
  onBeforeSubmit?: () => void
  serverErrorMessage?: string
}

function AuthFormRoot({
  children,
  onBeforeSubmit,
  onInput,
  serverErrorMessage = DEFAULT_AUTH_FORM_SERVER_ERROR,
  ...props
}: AuthFormRootProps) {
  const form = useFormContext()
  const submittingRef = useRef(false)

  async function submit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault()
    if (submittingRef.current || form.state.isSubmitting) return

    const formElement = event.currentTarget
    onBeforeSubmit?.()
    submittingRef.current = true
    try {
      const isValid = await submitAuthForm(form, serverErrorMessage)
      if (!isValid) focusFirstInvalidAuthFormControl(formElement)
    } finally {
      submittingRef.current = false
    }
  }

  return (
    <form
      {...props}
      onInvalid={(event) =>
        focusFirstInvalidAuthFormControl(event.currentTarget)
      }
      onInput={(event) => {
        const target = event.target
        const fieldName =
          target instanceof HTMLInputElement ||
          target instanceof HTMLSelectElement ||
          target instanceof HTMLTextAreaElement
            ? target.name
            : ""
        clearAuthFormFieldServerError(form, fieldName)
        onInput?.(event)
      }}
      onSubmit={submit}
    >
      {children}
    </form>
  )
}

type AuthFormTextFieldProps = Omit<
  ComponentProps<typeof Input>,
  "name" | "onBlur" | "onChange" | "value"
> & {
  description?: ReactNode
  label: ReactNode
}

function AuthFormTextField({
  description,
  id,
  label,
  ...props
}: AuthFormTextFieldProps) {
  const field = useFieldContext<string>()
  const form = useFormContext()
  const isInvalid = isAuthFormFieldInvalid(field.state.meta)
  const inputId = id ?? field.name

  return (
    <Field data-invalid={isInvalid}>
      <FieldLabel htmlFor={inputId}>{label}</FieldLabel>
      <Input
        {...props}
        aria-busy={field.state.meta.isValidating || undefined}
        aria-invalid={isInvalid}
        id={inputId}
        name={field.name}
        onBlur={field.handleBlur}
        onChange={(event) => {
          clearAuthFormFieldServerError(form, field.name)
          field.handleChange(event.target.value)
        }}
        value={field.state.value}
      />
      {description ? <FieldDescription>{description}</FieldDescription> : null}
      <AuthFormFieldError />
    </Field>
  )
}

function AuthFormSubmitButton({
  children,
  disabled,
  isPending,
  ...props
}: ComponentProps<typeof Button> & { isPending?: boolean }) {
  const form = useFormContext()

  return (
    <form.Subscribe
      selector={(state) => [state.isSubmitting, state.isValidating] as const}
    >
      {([isSubmitting, isValidating]) => (
        <Button
          {...props}
          aria-busy={isPending || isSubmitting || undefined}
          aria-disabled={
            disabled || isPending || isSubmitting || isValidating || undefined
          }
          disabled={disabled || isPending || isSubmitting || isValidating}
          type="submit"
        >
          {isPending || isSubmitting ? (
            <Spinner data-icon="inline-start" />
          ) : null}
          {children}
        </Button>
      )}
    </form.Subscribe>
  )
}

type AuthFormAdditionalFieldProps = Omit<
  AdditionalFieldProps,
  "errors" | "isInvalid" | "name" | "onBlur" | "onChange" | "value"
>

function AuthFormAdditionalField(props: AuthFormAdditionalFieldProps) {
  const field = useFieldContext<AdditionalFieldFormValue>()
  const form = useFormContext()
  const isInvalid = isAuthFormFieldInvalid(field.state.meta)

  return (
    <AdditionalField
      {...props}
      errors={
        isInvalid ? getFormFieldErrors(field.state.meta.errors) : undefined
      }
      isInvalid={isInvalid}
      name={field.name}
      onBlur={field.handleBlur}
      onChange={(value) => {
        clearAuthFormFieldServerError(form, field.name)
        field.handleChange(value)
      }}
      value={field.state.value}
    />
  )
}

export const {
  useAppForm: useAuthForm,
  withFieldGroup: withAuthFieldGroup,
  withForm: withAuthForm
} = createFormHook({
  fieldComponents: {
    AuthFormAdditionalField,
    AuthFormFieldError,
    AuthFormTextField
  },
  fieldContext,
  formComponents: {
    AuthFormRoot,
    AuthFormServerError,
    AuthFormSubmitButton
  },
  formContext
})

export function isAuthFormFieldInvalid({
  isTouched,
  isValid
}: {
  isTouched: boolean
  isValid: boolean
}) {
  return isTouched && !isValid
}

export function getAuthAdditionalFieldValidators(
  field: AdditionalFieldConfig,
  requiredMessage: string
) {
  return {
    onChange: ({ value }: { value: AdditionalFieldFormValue }) =>
      validateAdditionalFieldRequired(field, value, requiredMessage),
    onChangeAsync: field.validate
      ? ({ value }: { value: AdditionalFieldFormValue }) =>
          validateAdditionalFieldValue(field, value)
      : undefined,
    onChangeAsyncDebounceMs: field.validate
      ? (field.validateDebounceMs ??
        DEFAULT_ADDITIONAL_FIELD_VALIDATION_DEBOUNCE_MS)
      : undefined
  }
}
examples/start-shadcn-example/src/components/auth/auth-redirect.tsx
"use client"

import { getAuthRedirectAction } from "@better-auth-ui/core"
import { useAuth, useSession } from "@better-auth-ui/react"
import { useEffect, useRef } from "react"
import { Spinner } from "@/components/ui/spinner"
import { cn } from "cn"

export type AuthRedirectProps = {
  className?: string
}

/**
 * Redirects authenticated users to a validated same-origin target.
 *
 * Signed-out users are sent through the sign-in view first. The redirect view
 * is preserved as the post-authentication destination so API callbacks receive
 * a full-page request after the session is established.
 *
 * @param className - Optional additional class names applied to the spinner
 * @returns The centered spinner shown while the session and redirect resolve
 */
export function AuthRedirect({ className }: AuthRedirectProps) {
  const { authClient, basePaths, viewPaths } = useAuth()
  const { data: session, isPending } = useSession(authClient)
  const hasRedirected = useRef(false)

  useEffect(() => {
    if (isPending || hasRedirected.current) return
    hasRedirected.current = true

    const action = getAuthRedirectAction(
      new URL(window.location.href),
      Boolean(session),
      `${basePaths.auth}/${viewPaths.auth.signIn}`
    )

    window.location.replace(action.to)
  }, [basePaths.auth, isPending, session, viewPaths.auth.signIn])

  return <Spinner className={cn("mx-auto my-auto", className)} />
}
examples/start-shadcn-example/src/components/auth/auth-result.tsx
"use client"

import {
  type AuthResult,
  getAuthResultMessage,
  parseAuthResult
} from "@better-auth-ui/core"
import { useAuth } from "@better-auth-ui/react"
import { CircleCheckIcon, CircleXIcon, TriangleAlertIcon } from "lucide-react"
import { useEffect, useState } from "react"

import { Button } from "@/components/ui/button"
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle
} from "@/components/ui/card"
import { cn } from "cn"

type AuthResultProps = {
  className?: string
  fallbackIntent: "danger" | "success"
}

function AuthResultView({ className, fallbackIntent }: AuthResultProps) {
  const { basePaths, localization, navigate, viewPaths } = useAuth()
  const [result, setResult] = useState<AuthResult>(() =>
    parseAuthResult("", fallbackIntent)
  )

  useEffect(() => {
    setResult(parseAuthResult(window.location.search, fallbackIntent))
  }, [fallbackIntent])

  const message = getAuthResultMessage(result, localization)
  const action = (() => {
    switch (result.action) {
      case "accountSettings":
        return {
          label: localization.auth.callbackViewAccountSettings,
          to: `${basePaths.settings}/${viewPaths.settings.security}`
        }
      case "continue":
        return {
          label: localization.auth.callbackContinue,
          to: result.redirectTo ?? "/"
        }
      case "forgotPassword":
        return {
          label: localization.auth.forgotPassword,
          to: `${basePaths.auth}/${viewPaths.auth.forgotPassword}`
        }
      case "signUp":
        return {
          label: localization.auth.signUp,
          to: `${basePaths.auth}/${viewPaths.auth.signUp}`
        }
      case "verifyEmail":
        return {
          label: localization.auth.verifyEmail,
          to: `${basePaths.auth}/${viewPaths.auth.verifyEmail}`
        }
      default:
        return {
          label: localization.auth.signIn,
          to: `${basePaths.auth}/${viewPaths.auth.signIn}`
        }
    }
  })()
  const Icon =
    result.intent === "success"
      ? CircleCheckIcon
      : result.intent === "warning"
        ? TriangleAlertIcon
        : CircleXIcon

  return (
    <Card className={cn("w-full max-w-sm", className)}>
      <CardHeader className="justify-items-center text-center">
        <Icon
          aria-hidden="true"
          className={cn(
            "mb-1 size-10",
            result.intent === "success"
              ? "text-primary"
              : result.intent === "warning"
                ? "text-amber-600 dark:text-amber-400"
                : "text-destructive"
          )}
        />
        <CardTitle className="text-xl">{message.title}</CardTitle>
        <CardDescription>{message.description}</CardDescription>
      </CardHeader>
      <CardContent>
        <Button className="w-full" onClick={() => navigate({ to: action.to })}>
          {action.label}
        </Button>
      </CardContent>
    </Card>
  )
}

export function AuthCallback(props: Omit<AuthResultProps, "fallbackIntent">) {
  return <AuthResultView {...props} fallbackIntent="success" />
}

export function AuthError(props: Omit<AuthResultProps, "fallbackIntent">) {
  return <AuthResultView {...props} fallbackIntent="danger" />
}
examples/start-shadcn-example/src/components/auth/auth.tsx
"use client"

import type { AuthView } from "@better-auth-ui/core"
import { useAuth } from "@better-auth-ui/react"
import { type ComponentType, useEffect } from "react"

import { AuthRedirect } from "./auth-redirect"
import { AuthCallback, AuthError } from "./auth-result"
import { ForgotPassword } from "./forgot-password"
import type { SocialLayout } from "./provider-buttons"
import { ResetLinkSent } from "./reset-link-sent"
import { ResetPassword } from "./reset-password"
import { SignIn } from "./sign-in"
import { SignOut } from "./sign-out"
import { SignUp } from "./sign-up"
import { VerifyEmail } from "./verify-email"

export type AuthProps = {
  className?: string
  path?: string
  socialLayout?: SocialLayout
  socialPosition?: "top" | "bottom"
  /** @remarks `AuthView` */
  view?: AuthView
}

/**
 * Built-in views that only make sense when email + password auth is enabled.
 * When it's disabled, the `<Auth>` router redirects these to `signIn` so a
 * plugin's `fallbackViews.auth.signIn` (e.g. magic link) takes over.
 */
const PASSWORD_ONLY_VIEWS = [
  "signUp",
  "forgotPassword",
  "resetPassword",
  "resetLinkSent"
]

const AUTH_VIEWS: Partial<Record<AuthView, ComponentType<AuthProps>>> = {
  callback: AuthCallback,
  error: AuthError,
  redirect: AuthRedirect,
  signIn: SignIn,
  signOut: SignOut,
  signUp: SignUp,
  forgotPassword: ForgotPassword,
  resetPassword: ResetPassword,
  resetLinkSent: ResetLinkSent,
  verifyEmail: VerifyEmail
}

/**
 * Render the appropriate authentication view based on the provided `view` or `path`.
 *
 * Resolution order:
 *   1. Plugin overrides (`plugin.views.auth[currentView]`) — first registered wins.
 *   2. Plugin fallbacks (`plugin.fallbackViews.auth.signIn`) when password auth is off.
 *   3. Built-in views.
 *
 * @param path - Route path used to resolve an auth view when `view` is not provided
 * @param socialLayout - Social layout to apply to sign-in/sign-up/magic-link views
 * @param socialPosition - Position for social buttons (`"top"` or `"bottom"`)
 * @param view - Explicit auth view to render (e.g., `"signIn"`, `"signUp"`)
 * @returns The React element for the resolved authentication view
 */
export function Auth({
  className,
  path,
  socialLayout,
  socialPosition,
  view
}: AuthProps) {
  const { basePaths, emailAndPassword, plugins, viewPaths, navigate } =
    useAuth()

  if (!view && !path) {
    throw new Error("[Better Auth UI] Either `view` or `path` must be provided")
  }

  const authView =
    view ||
    (Object.keys(viewPaths.auth) as AuthView[]).find(
      (key) => viewPaths.auth[key] === path
    )

  // When email + password auth is disabled, password-only views (signUp,
  // forgotPassword, resetPassword) have no meaning. Redirect them to signIn,
  // where a plugin's `fallbackViews.auth.signIn` (e.g. magic link) takes
  // over as the primary entry point.
  const shouldRedirectToSignIn =
    !emailAndPassword?.enabled &&
    authView &&
    PASSWORD_ONLY_VIEWS.includes(authView)

  useEffect(() => {
    if (shouldRedirectToSignIn) {
      navigate({
        to: `${basePaths.auth}/${viewPaths.auth.signIn}`,
        replace: true
      })
    }
  }, [shouldRedirectToSignIn, navigate, basePaths.auth, viewPaths.auth.signIn])

  if (shouldRedirectToSignIn) {
    return null
  }

  // 1. Plugin overrides (`views.auth[currentView]`) — first plugin wins,
  //    including over built-in views. Resolves the view key from `view`,
  //    then `authView` (built-in path match), then plugin-introduced paths
  //    (e.g. `magicLink` → `/auth/magic-link`).
  for (const plugin of plugins) {
    const pluginAuthPaths = plugin.viewPaths?.auth

    const pluginView =
      view ??
      authView ??
      (pluginAuthPaths &&
        Object.keys(pluginAuthPaths).find(
          (key) => pluginAuthPaths[key] === path
        ))
    if (!pluginView) continue

    const PluginView = plugin.views?.auth?.[pluginView]
    if (!PluginView) continue

    return (
      <PluginView
        className={className}
        socialLayout={socialLayout}
        socialPosition={socialPosition}
      />
    )
  }

  // 2. Plugin fallbacks — only when the built-in `signIn` isn't viable
  //    (password auth is off). Used by `magicLinkPlugin` to render the
  //    magic-link form as the primary passwordless sign-in surface.
  if (authView === "signIn" && !emailAndPassword?.enabled) {
    const Fallback = plugins.find(
      (plugin) => plugin.fallbackViews?.auth?.signIn
    )?.fallbackViews?.auth?.signIn

    if (Fallback) {
      return (
        <Fallback
          className={className}
          socialLayout={socialLayout}
          socialPosition={socialPosition}
        />
      )
    }
  }

  const AuthView = authView ? AUTH_VIEWS[authView] : undefined

  if (!AuthView) {
    throw new Error(
      `[Better Auth UI] Unknown view "${authView}". Valid views are: ${Object.keys(AUTH_VIEWS).join(", ")}`
    )
  }

  return (
    <AuthView
      className={className}
      socialLayout={socialLayout}
      socialPosition={socialPosition}
    />
  )
}
examples/start-shadcn-example/src/components/auth/forgot-password.tsx
"use client"

import { getViewURL, validateEmailAddress } from "@better-auth-ui/core"
import {
  useAuth,
  useFetchOptions,
  useRequestPasswordReset
} from "@better-auth-ui/react"

import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
  Field,
  FieldDescription,
  FieldGroup,
  FieldLabel
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { cn } from "cn"
import { isAuthFormFieldInvalid, useAuthForm } from "./auth-form"
import { RESET_LINK_SENT_STORAGE_KEY } from "./reset-link-sent"

export type ForgotPasswordProps = {
  className?: string
}

/**
 * Render a card-based "Forgot Password" form that sends a password-reset email.
 *
 * The form displays an email input, submit button, and a link back to sign-in.
 * After a successful request the submitted email is stored in `sessionStorage`
 * and the user is redirected to the reset-link-sent view, which offers to open
 * their email provider.
 *
 * @param className - Optional additional CSS class names applied to the card
 * @returns The forgot-password form UI as a JSX element
 */
export function ForgotPassword({ className }: ForgotPasswordProps) {
  const {
    authClient,
    baseURL,
    basePaths,
    localization,
    navigate,
    plugins,
    viewPaths,
    Link
  } = useAuth()

  const { fetchOptions, resetFetchOptions } = useFetchOptions()

  const { mutateAsync: requestPasswordReset, isPending } =
    useRequestPasswordReset(authClient, {
      onError: () => {
        resetFetchOptions()
      },
      onSuccess: (_data, { email }) => {
        sessionStorage.setItem(RESET_LINK_SENT_STORAGE_KEY, email)
        navigate({ to: `${basePaths.auth}/${viewPaths.auth.resetLinkSent}` })
      }
    })

  const form = useAuthForm({
    defaultValues: { email: "" },
    onSubmit: async ({ value }) =>
      await requestPasswordReset({
        email: value.email,
        redirectTo: getViewURL(
          baseURL,
          basePaths.auth,
          viewPaths.auth.resetPassword
        ),
        fetchOptions
      })
  })

  const Captcha = plugins.find(
    (plugin) => plugin.captchaComponent
  )?.captchaComponent

  return (
    <Card className={cn("w-full max-w-sm", className)}>
      <CardHeader>
        <CardTitle className="text-xl font-semibold">
          {localization.auth.forgotPassword}
        </CardTitle>
      </CardHeader>

      <CardContent>
        <form.AppForm>
          <form.AuthFormRoot>
            <FieldGroup>
              <form.AppField
                name="email"
                validators={{
                  onChange: ({ value }) =>
                    validateEmailAddress(value, {
                      invalidMessage: localization.auth.invalidEmail,
                      requiredMessage: localization.auth.fieldRequired
                    })
                }}
              >
                {(field) => {
                  const isInvalid = isAuthFormFieldInvalid(field.state.meta)
                  return (
                    <Field data-invalid={isInvalid}>
                      <FieldLabel htmlFor="email">
                        {localization.auth.email}
                      </FieldLabel>
                      <Input
                        id="email"
                        name={field.name}
                        type="email"
                        autoComplete="email"
                        placeholder={localization.auth.emailPlaceholder}
                        required
                        disabled={isPending}
                        value={field.state.value}
                        onBlur={field.handleBlur}
                        onChange={(event) =>
                          field.handleChange(event.target.value)
                        }
                        aria-invalid={isInvalid}
                      />
                      <field.AuthFormFieldError />
                    </Field>
                  )
                }}
              </form.AppField>

              {Captcha && <div className="flex justify-center">{Captcha}</div>}

              <div className="flex flex-col gap-3">
                <form.AuthFormSubmitButton
                  isPending={isPending}
                  disabled={isPending}
                >
                  {localization.auth.sendResetLink}
                </form.AuthFormSubmitButton>
              </div>
            </FieldGroup>
          </form.AuthFormRoot>
        </form.AppForm>

        <div className="flex flex-col gap-3 items-center w-full mt-4">
          <FieldDescription className="text-center">
            {localization.auth.rememberYourPassword}{" "}
            <Link
              href={`${basePaths.auth}/${viewPaths.auth.signIn}`}
              className="underline underline-offset-4"
            >
              {localization.auth.signIn}
            </Link>
          </FieldDescription>
        </div>
      </CardContent>
    </Card>
  )
}
examples/start-shadcn-example/src/components/auth/last-login-method/last-used-badge.tsx
"use client"

import { useLastLoginMethod } from "@better-auth-ui/react"

import { Badge } from "@/components/ui/badge"

export type LastUsedBadgeProps = {
  /** Login method IDs that should display the indicator. */
  method: string | string[]
  /** Use the shorter label in constrained layouts. */
  compact?: boolean
  /** Float the compact indicator over the top-right edge of its container. */
  floating?: boolean
}

/**
 * Displays an indicator when one of the supplied method IDs matches Better
 * Auth's stored last login method.
 */
export function LastUsedBadge({
  method,
  compact,
  floating
}: LastUsedBadgeProps) {
  const { method: lastLoginMethod, localization } = useLastLoginMethod()
  const methods = Array.isArray(method) ? method : [method]

  if (!lastLoginMethod || !methods.includes(lastLoginMethod)) return null

  return (
    <Badge
      className={
        floating
          ? "pointer-events-none absolute top-0 right-0 z-10 translate-x-1/4 -translate-y-1/2 shadow-sm"
          : undefined
      }
      variant="secondary"
    >
      {compact || floating ? localization.lastUsedShort : localization.lastUsed}
    </Badge>
  )
}
examples/start-shadcn-example/src/components/auth/open-email-button.tsx
"use client"

import { createQrCodeSvgData, getEmailProviderLink } from "@better-auth-ui/core"
import { useAuth } from "@better-auth-ui/react"
import type { VariantProps } from "class-variance-authority"
import { QrCode } from "lucide-react"
import { useMemo } from "react"

import { buttonVariants } from "@/components/ui/button"
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger
} from "@/components/ui/tooltip"
import { cn } from "cn"

export type OpenEmailButtonProps = {
  /** Email address used to detect the provider, e.g. from the verify-email flow. */
  email: string
  className?: string
  /**
   * Button variant. Defaults to the primary style for dead-end views where
   * opening the inbox is the only action; pass `"secondary"` where it sits
   * beside a submit button that should stay the primary call to action.
   */
  variant?: VariantProps<typeof buttonVariants>["variant"]
}

/**
 * Render a button that opens the user's email provider login page in a new
 * tab. Hovering or focusing the button reveals a QR code for opening the same
 * provider on another device.
 *
 * The provider is resolved from the email domain via the curated
 * `@mikkelscheike/email-provider-links` dataset (Gmail, Outlook, GMX, etc.).
 * Renders nothing when the domain is empty or not a known provider.
 *
 * @param email - Email address to resolve the provider from.
 * @param className - Additional CSS classes applied to the button.
 * @param variant - Button variant. Defaults to the primary style.
 * @returns The open-email button, or `null` when no provider matches.
 */
export function OpenEmailButton({
  email,
  className,
  variant
}: OpenEmailButtonProps) {
  const { localization } = useAuth()

  const provider = getEmailProviderLink(email)
  const loginUrl = provider?.loginUrl
  const qrCode = useMemo(
    () => (loginUrl ? createQrCodeSvgData(loginUrl) : null),
    [loginUrl]
  )

  if (!provider || !qrCode) return null

  const scanLabel = localization.auth.scanToOpenEmailProvider.replace(
    "{{provider}}",
    provider.companyProvider
  )

  return (
    <TooltipProvider>
      <Tooltip>
        <TooltipTrigger
          type="button"
          className={cn(buttonVariants({ variant }), "w-full", className)}
          onClick={() =>
            window.open(provider.loginUrl, "_blank", "noopener,noreferrer")
          }
        >
          {localization.auth.openEmailProvider.replace(
            "{{provider}}",
            provider.companyProvider
          )}
          <QrCode data-icon="inline-end" />
        </TooltipTrigger>
        <TooltipContent
          sideOffset={8}
          className="flex-col items-center gap-2 p-3"
        >
          <svg
            viewBox={`0 0 ${qrCode.size} ${qrCode.size}`}
            aria-hidden="true"
            focusable="false"
            className="size-40"
          >
            <path fill="white" d={`M0 0h${qrCode.size}v${qrCode.size}H0z`} />
            <path fill="black" d={qrCode.path} shapeRendering="crispEdges" />
          </svg>
          <p className="max-w-40 text-center leading-snug">{scanLabel}</p>
        </TooltipContent>
      </Tooltip>
    </TooltipProvider>
  )
}
examples/start-shadcn-example/src/components/auth/password-strength-meter.tsx
"use client"

import {
  evaluatePasswordStrength,
  type PasswordStrengthLevel
} from "@better-auth-ui/core"
import { useAuth } from "@better-auth-ui/react"

import { cn } from "cn"

/** Fixed segment identities, so the bars keep their own React keys. */
const STRENGTH_SEGMENTS = [1, 2, 3, 4] as const

type FilledLevel = Exclude<PasswordStrengthLevel, "empty">

const segmentColors: Record<FilledLevel, string> = {
  weak: "bg-destructive",
  fair: "bg-amber-500",
  good: "bg-sky-500",
  strong: "bg-emerald-500"
}

export type PasswordStrengthMeterProps = {
  /** The password as typed. Renders nothing while it is empty. */
  password: string
  className?: string
}

/**
 * Four-segment strength hint shown while someone picks a new password.
 *
 * Renders nothing when `emailAndPassword.strengthMeter` is off or the field is
 * empty. The score never gates submission: your server rules stay the
 * authority on what is acceptable.
 */
export function PasswordStrengthMeter({
  password,
  className
}: PasswordStrengthMeterProps) {
  const { emailAndPassword, localization } = useAuth()

  if (!emailAndPassword?.strengthMeter) return null

  const { score, level } = evaluatePasswordStrength(password, {
    minLength: emailAndPassword.minPasswordLength
  })

  if (level === "empty") return null

  const levelLabels: Record<FilledLevel, string> = {
    weak: localization.auth.passwordWeak,
    fair: localization.auth.passwordFair,
    good: localization.auth.passwordGood,
    strong: localization.auth.passwordStrong
  }

  return (
    <div className={cn("flex flex-col gap-1.5", className)}>
      {/* Decorative: the live region below is what gets announced. */}
      <div aria-hidden="true" className="flex gap-1">
        {STRENGTH_SEGMENTS.map((segment) => (
          <span
            key={segment}
            className={cn(
              "h-1 flex-1 rounded-full bg-muted transition-colors",
              segment <= score && segmentColors[level]
            )}
          />
        ))}
      </div>

      <p aria-live="polite" className="text-muted-foreground text-xs">
        {localization.auth.passwordStrength}:{" "}
        <span className="font-medium text-foreground">
          {levelLabels[level]}
        </span>
      </p>
    </div>
  )
}
examples/start-shadcn-example/src/components/auth/provider-button.tsx
"use client"

import {
  type AuthSocialProvider,
  type AuthView,
  authMutationKeys,
  getProviderId,
  getProviderName,
  type OAuthPopupAuthClient
} from "@better-auth-ui/core"
import {
  renderProviderIcon,
  useAuth,
  useFetchOptions,
  useSignInOAuthPopup,
  useSignInSocial
} from "@better-auth-ui/react"
import { useIsMutating } from "@tanstack/react-query"
import type { ComponentProps } from "react"

import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import { cn } from "cn"
import { LastUsedBadge } from "./last-login-method/last-used-badge"

export type ProviderButtonProps = {
  provider: AuthSocialProvider
  display?: "full" | "name" | "icon"
  view?: AuthView
} & Omit<ComponentProps<typeof Button>, "onClick" | "children" | "disabled">

/**
 * Social provider sign-in button.
 *
 * @param provider - Provider to sign in with.
 * @param display - `"full"` (e.g. "Continue with Google"), `"name"` (just the provider name), or `"icon"` (icon only).
 */
export function ProviderButton({
  provider,
  display = "full",
  view = "signIn",
  variant = "outline",
  className,
  ...props
}: ProviderButtonProps) {
  const {
    authClient,
    baseURL,
    localization,
    navigate,
    redirectTo,
    socialSignInMode
  } = useAuth()

  const callbackURL = `${baseURL}${redirectTo}`
  const { fetchOptions, resetFetchOptions } = useFetchOptions()

  const { mutate: signInSocial, isPending: signInSocialPending } =
    useSignInSocial(authClient, { onError: resetFetchOptions })
  const { mutate: signInPopup, isPending: signInPopupPending } =
    useSignInOAuthPopup(authClient as OAuthPopupAuthClient, {
      onError: resetFetchOptions
    })

  const providerId = getProviderId(provider)
  const providerIcon = renderProviderIcon(provider)

  const signInMutating = useIsMutating({
    mutationKey: authMutationKeys.signIn.all
  })
  const signUpMutating = useIsMutating({
    mutationKey: authMutationKeys.signUp.all
  })
  const isPending = signInMutating + signUpMutating > 0

  const handleSignIn = () => {
    if (socialSignInMode === "popup") {
      signInPopup(
        {
          provider: providerId,
          callbackURL,
          requestSignUp: view === "signUp"
        },
        { onSuccess: () => navigate({ to: redirectTo }) }
      )
      return
    }

    signInSocial({ provider: providerId, callbackURL, fetchOptions })
  }

  return (
    <Button
      type="button"
      variant={variant}
      disabled={isPending}
      onClick={handleSignIn}
      className={cn("relative overflow-visible", className)}
      {...props}
    >
      {signInSocialPending || signInPopupPending ? <Spinner /> : providerIcon}

      {display === "full"
        ? localization.auth.continueWith.replace(
            "{{provider}}",
            getProviderName(provider)
          )
        : display === "name"
          ? getProviderName(provider)
          : null}

      {display === "icon" && (
        <span className="sr-only">{getProviderName(provider)}</span>
      )}

      {view !== "signUp" && <LastUsedBadge method={providerId} floating />}
    </Button>
  )
}
examples/start-shadcn-example/src/components/auth/provider-buttons.tsx
"use client"

import { type AuthView, getProviderId } from "@better-auth-ui/core"
import { useAuth } from "@better-auth-ui/react"
import { useMemo } from "react"

import { cn } from "cn"
import { ProviderButton } from "./provider-button"

export type ProviderButtonsProps = {
  socialLayout?: SocialLayout
  view?: AuthView
}

export type SocialLayout = "auto" | "horizontal" | "vertical" | "grid"

/**
 * Render sign-in buttons for configured social providers. Each button owns its own sign-in mutation
 * and reads the shared sign-in pending state from React Query.
 *
 * @param socialLayout - Preferred layout for the provider buttons; `"auto"` chooses based on the number of providers.
 */
export function ProviderButtons({
  socialLayout = "auto",
  view = "signIn"
}: ProviderButtonsProps) {
  const { socialProviders } = useAuth()

  const resolvedSocialLayout = useMemo(() => {
    if (socialLayout === "auto") {
      if (socialProviders?.length && socialProviders.length >= 4) {
        return "horizontal"
      }

      return "vertical"
    }

    return socialLayout
  }, [socialLayout, socialProviders?.length])

  return (
    <div
      className={cn(
        "gap-3",
        resolvedSocialLayout === "grid" && "grid grid-cols-2",
        resolvedSocialLayout === "vertical" && "flex flex-col",
        resolvedSocialLayout === "horizontal" && "flex flex-row flex-wrap"
      )}
    >
      {socialProviders?.map((provider) => (
        <ProviderButton
          key={getProviderId(provider)}
          provider={provider}
          view={view}
          display={
            resolvedSocialLayout === "vertical"
              ? "full"
              : resolvedSocialLayout === "grid"
                ? "name"
                : "icon"
          }
          className={cn(resolvedSocialLayout === "horizontal" && "flex-1")}
        />
      ))}
    </div>
  )
}
examples/start-shadcn-example/src/components/auth/reauthentication.tsx
"use client"

import {
  getReauthenticationSignInURL,
  isReauthenticationSignInURL
} from "@better-auth-ui/core"
import { useAuth, useSignOut } from "@better-auth-ui/react"
import { useSyncExternalStore } from "react"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import { cn } from "cn"

const subscribeToLocation = () => () => undefined

function useIsReauthenticationSignIn() {
  return useSyncExternalStore(
    subscribeToLocation,
    () => isReauthenticationSignInURL(new URL(window.location.href)),
    () => false
  )
}

export type ReauthenticationActionProps = {
  className?: string
  showTitle?: boolean
}

export function ReauthenticationAction({
  className,
  showTitle = true
}: ReauthenticationActionProps) {
  const auth = useAuth()
  const signOut = useSignOut(auth.authClient)

  const handleReauthentication = () => {
    const signInURL = getReauthenticationSignInURL(
      new URL(window.location.href),
      `${auth.basePaths.auth}/${auth.viewPaths.auth.signIn}`
    )
    signOut.mutate(undefined, {
      onSuccess: () => auth.navigate({ to: signInURL })
    })
  }

  return (
    <div className={cn("flex flex-col items-start gap-3 p-4", className)}>
      <div className="flex flex-col gap-1">
        {showTitle ? (
          <h3 className="text-sm font-medium">
            {auth.localization.settings.reauthenticationTitle}
          </h3>
        ) : null}
        <p className="text-sm text-muted-foreground">
          {auth.localization.settings.reauthenticationDescription}
        </p>
      </div>
      <Button
        disabled={signOut.isPending}
        onClick={handleReauthentication}
        size="sm"
      >
        {signOut.isPending ? <Spinner data-icon="inline-start" /> : null}
        {auth.localization.settings.reauthenticationAction}
      </Button>
    </div>
  )
}

export function ReauthenticationNotice() {
  const auth = useAuth()
  const isReauthenticationSignIn = useIsReauthenticationSignIn()
  if (!isReauthenticationSignIn) return null

  return (
    <Alert className="mx-4 w-auto group-data-[size=sm]/card:mx-3">
      <AlertTitle>
        {auth.localization.settings.reauthenticationTitle}
      </AlertTitle>
      <AlertDescription>
        {auth.localization.settings.reauthenticationDescription}
      </AlertDescription>
    </Alert>
  )
}
examples/start-shadcn-example/src/components/auth/reset-link-sent.tsx
"use client"

import { getAuthLinkURL } from "@better-auth-ui/core"
import { useAuth } from "@better-auth-ui/react"
import { useEffect, useState } from "react"

import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { FieldDescription } from "@/components/ui/field"
import { cn } from "cn"
import { OpenEmailButton } from "./open-email-button"
import { useIsHydrated } from "./use-is-hydrated"

/** `sessionStorage` key the forgot-password form stores the submitted email under. */
export const RESET_LINK_SENT_STORAGE_KEY = "better-auth-ui.reset-link-sent"

export type ResetLinkSentProps = {
  className?: string
}

/**
 * Render a card confirming that a password-reset email was sent, with a
 * button to open the user's email provider.
 *
 * The target email is read from `sessionStorage` (set when the forgot-password
 * form redirects here); the OpenEmail button is only shown when an email is
 * stored and resolves to a known provider.
 *
 * @param className - Additional CSS classes applied to the card
 * @returns The reset-link-sent card React element
 */
export function ResetLinkSent({ className }: ResetLinkSentProps) {
  const { basePaths, localization, redirectTo, viewPaths, Link } = useAuth()

  const isHydrated = useIsHydrated()
  const [email, setEmail] = useState(
    (isHydrated && sessionStorage.getItem(RESET_LINK_SENT_STORAGE_KEY)) || ""
  )

  useEffect(() => {
    setEmail(sessionStorage.getItem(RESET_LINK_SENT_STORAGE_KEY) ?? "")
  }, [])

  return (
    <Card className={cn("w-full max-w-sm", className)}>
      <CardHeader>
        <CardTitle className="text-xl font-semibold">
          {localization.auth.checkYourEmailTitle}
        </CardTitle>
      </CardHeader>

      <CardContent>
        <div className="flex flex-col gap-4">
          <FieldDescription>
            {email
              ? localization.auth.resetLinkSentTo.replace("{{email}}", email)
              : localization.auth.passwordResetEmailSent}
          </FieldDescription>

          {email && <OpenEmailButton email={email} />}
        </div>

        <div className="flex flex-col gap-3 items-center w-full mt-4">
          <FieldDescription className="text-center">
            {localization.auth.rememberYourPassword}{" "}
            <Link
              href={getAuthLinkURL(
                `${basePaths.auth}/${viewPaths.auth.signIn}`,
                redirectTo
              )}
              className="underline underline-offset-4"
            >
              {localization.auth.signIn}
            </Link>
          </FieldDescription>
        </div>
      </CardContent>
    </Card>
  )
}
examples/start-shadcn-example/src/components/auth/reset-password.tsx
"use client"

import {
  getAuthLinkURL,
  isPasswordCompromisedError,
  validateMatchingValue,
  validateStringLength
} from "@better-auth-ui/core"
import { useAuth, useResetPassword } from "@better-auth-ui/react"
import { Eye, EyeOff } from "lucide-react"
import { useEffect, useState } from "react"
import { toast } from "sonner"

import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel
} from "@/components/ui/field"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput
} from "@/components/ui/input-group"
import { cn } from "cn"
import { isAuthFormFieldInvalid, useAuthForm } from "./auth-form"
import { PasswordStrengthMeter } from "./password-strength-meter"

export type ResetPasswordProps = {
  className?: string
}

/**
 * Render a password reset form that validates the reset token from the URL, accepts a new password (and optional confirmation), and submits it to the auth client.
 *
 * The component checks for a `token` query parameter on mount and, if missing, shows an error toast and navigates to the sign-in page. It exposes per-field validation messages, toggles for password visibility, and disables inputs while the reset request is pending.
 *
 * @returns The password reset form UI ready to be mounted in the app layout.
 */
export function ResetPassword({ className }: ResetPasswordProps) {
  const {
    authClient,
    basePaths,
    emailAndPassword,
    localization,
    navigate,
    redirectTo,
    viewPaths,
    Link
  } = useAuth()
  const signInURL = getAuthLinkURL(
    `${basePaths.auth}/${viewPaths.auth.signIn}`,
    redirectTo
  )

  const { mutateAsync: resetPassword, isPending } = useResetPassword(
    authClient,
    {
      onError: (error) => {
        // The haveIBeenPwned plugin rejects on the password itself, so it
        // belongs against the field rather than in a toast.
        if (isPasswordCompromisedError(error)) {
          setIsCompromised(true)
        }
      },
      onSuccess: () => {
        toast.success(localization.auth.passwordResetSuccess)
        navigate({ to: signInURL })
      }
    }
  )

  const [isPasswordVisible, setIsPasswordVisible] = useState(false)
  const [isConfirmPasswordVisible, setIsConfirmPasswordVisible] =
    useState(false)
  const [isCompromised, setIsCompromised] = useState(false)

  useEffect(() => {
    const searchParams = new URLSearchParams(window.location.search)
    const token = searchParams.get("token") as string

    if (!token) {
      toast.error(localization.auth.invalidResetPasswordToken)
      navigate({ to: signInURL })
    }
  }, [localization.auth.invalidResetPasswordToken, navigate, signInURL])

  const form = useAuthForm({
    defaultValues: { confirmPassword: "", password: "" },
    onSubmit: async ({ value }) => {
      const searchParams = new URLSearchParams(window.location.search)
      const token = searchParams.get("token") as string

      if (!token) {
        toast.error(localization.auth.invalidResetPasswordToken)
        navigate({ to: signInURL })
        return
      }

      try {
        await resetPassword({ token, newPassword: value.password })
      } catch {
        // The mutation reports the error through its configured handler.
      }
    }
  })

  return (
    <Card className={cn("w-full max-w-sm", className)}>
      <CardHeader>
        <CardTitle className="text-xl font-semibold">
          {localization.auth.resetPassword}
        </CardTitle>
      </CardHeader>

      <CardContent>
        <form.AppForm>
          <form.AuthFormRoot>
            <FieldGroup>
              <form.AppField
                name="password"
                validators={{
                  onChange: ({ value }) =>
                    validateStringLength(value, {
                      maxLength: emailAndPassword?.maxPasswordLength,
                      maxLengthMessage: localization.auth.tooLong.replace(
                        "{{max}}",
                        String(emailAndPassword?.maxPasswordLength)
                      ),
                      minLength: emailAndPassword?.minPasswordLength,
                      minLengthMessage: localization.auth.tooShort.replace(
                        "{{min}}",
                        String(emailAndPassword?.minPasswordLength)
                      ),
                      requiredMessage: localization.auth.fieldRequired
                    })
                }}
              >
                {(field) => {
                  const isInvalid =
                    isAuthFormFieldInvalid(field.state.meta) || isCompromised

                  return (
                    <Field data-invalid={isInvalid}>
                      <FieldLabel htmlFor="password">
                        {localization.auth.password}
                      </FieldLabel>

                      <InputGroup>
                        <InputGroupInput
                          id="password"
                          type={isPasswordVisible ? "text" : "password"}
                          autoComplete="new-password"
                          placeholder={localization.auth.newPasswordPlaceholder}
                          required
                          minLength={emailAndPassword?.minPasswordLength}
                          maxLength={emailAndPassword?.maxPasswordLength}
                          disabled={isPending}
                          name={field.name}
                          onBlur={field.handleBlur}
                          onChange={(e) => {
                            field.handleChange(e.target.value)
                            setIsCompromised(false)
                          }}
                          aria-invalid={isInvalid}
                          value={field.state.value}
                        />

                        <InputGroupAddon align="inline-end">
                          <InputGroupButton
                            size="icon-xs"
                            aria-label={
                              isPasswordVisible
                                ? localization.auth.hidePassword
                                : localization.auth.showPassword
                            }
                            title={
                              isPasswordVisible
                                ? localization.auth.hidePassword
                                : localization.auth.showPassword
                            }
                            onClick={() => {
                              setIsPasswordVisible((visible) => !visible)
                            }}
                          >
                            {isPasswordVisible ? <EyeOff /> : <Eye />}
                          </InputGroupButton>
                        </InputGroupAddon>
                      </InputGroup>

                      {isCompromised ? (
                        <FieldError>
                          {localization.auth.passwordCompromised}
                        </FieldError>
                      ) : (
                        <field.AuthFormFieldError />
                      )}

                      <PasswordStrengthMeter password={field.state.value} />
                    </Field>
                  )
                }}
              </form.AppField>

              {emailAndPassword?.confirmPassword && (
                <form.AppField
                  name="confirmPassword"
                  validators={{
                    onChangeListenTo: ["password"],
                    onChange: ({ fieldApi, value }) =>
                      validateStringLength(value, {
                        maxLength: emailAndPassword?.maxPasswordLength,
                        maxLengthMessage: localization.auth.tooLong.replace(
                          "{{max}}",
                          String(emailAndPassword?.maxPasswordLength)
                        ),
                        minLength: emailAndPassword?.minPasswordLength,
                        minLengthMessage: localization.auth.tooShort.replace(
                          "{{min}}",
                          String(emailAndPassword?.minPasswordLength)
                        ),
                        requiredMessage: localization.auth.fieldRequired
                      }) ??
                      validateMatchingValue(
                        value,
                        fieldApi.form.getFieldValue("password"),
                        localization.auth.passwordsDoNotMatch
                      )
                  }}
                >
                  {(field) => {
                    const isInvalid = isAuthFormFieldInvalid(field.state.meta)

                    return (
                      <Field data-invalid={isInvalid}>
                        <FieldLabel htmlFor="confirmPassword">
                          {localization.auth.confirmPassword}
                        </FieldLabel>

                        <InputGroup>
                          <InputGroupInput
                            id="confirmPassword"
                            name={field.name}
                            type={
                              isConfirmPasswordVisible ? "text" : "password"
                            }
                            autoComplete="new-password"
                            placeholder={
                              localization.auth.confirmPasswordPlaceholder
                            }
                            required
                            minLength={emailAndPassword?.minPasswordLength}
                            maxLength={emailAndPassword?.maxPasswordLength}
                            disabled={isPending}
                            onBlur={field.handleBlur}
                            onChange={(event) =>
                              field.handleChange(event.target.value)
                            }
                            aria-invalid={isInvalid}
                            value={field.state.value}
                          />

                          <InputGroupAddon align="inline-end">
                            <InputGroupButton
                              size="icon-xs"
                              aria-label={
                                isConfirmPasswordVisible
                                  ? localization.auth.hidePassword
                                  : localization.auth.showPassword
                              }
                              title={
                                isConfirmPasswordVisible
                                  ? localization.auth.hidePassword
                                  : localization.auth.showPassword
                              }
                              onClick={() => {
                                setIsConfirmPasswordVisible(
                                  (visible) => !visible
                                )
                              }}
                            >
                              {isConfirmPasswordVisible ? <EyeOff /> : <Eye />}
                            </InputGroupButton>
                          </InputGroupAddon>
                        </InputGroup>

                        <field.AuthFormFieldError />
                      </Field>
                    )
                  }}
                </form.AppField>
              )}

              <div className="flex flex-col gap-3">
                <form.AuthFormSubmitButton disabled={isPending}>
                  {localization.auth.resetPassword}
                </form.AuthFormSubmitButton>
              </div>
            </FieldGroup>
          </form.AuthFormRoot>
        </form.AppForm>

        <div className="flex flex-col gap-3 items-center w-full mt-4">
          <FieldDescription className="text-center">
            {localization.auth.rememberYourPassword}{" "}
            <Link
              href={getAuthLinkURL(
                `${basePaths.auth}/${viewPaths.auth.signIn}`,
                redirectTo
              )}
              className="underline underline-offset-4"
            >
              {localization.auth.signIn}
            </Link>
          </FieldDescription>
        </div>
      </CardContent>
    </Card>
  )
}
examples/start-shadcn-example/src/components/auth/sign-in.tsx
"use client"

import {
  authMutationKeys,
  validateEmailAddress,
  validateStringLength
} from "@better-auth-ui/core"
import {
  isPasskeyAutoFillEnabled,
  withPasskeyAutoFill
} from "@better-auth-ui/core/plugins/passkey"
import {
  AuthPrompts,
  useAuth,
  useFetchOptions,
  useSignInEmail
} from "@better-auth-ui/react"
import { useIsMutating } from "@tanstack/react-query"
import { Eye, EyeOff } from "lucide-react"
import { useState } from "react"

import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import {
  Field,
  FieldDescription,
  FieldGroup,
  FieldLabel,
  FieldSeparator
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput
} from "@/components/ui/input-group"
import { useSignInContinuation } from "@/lib/auth/use-sign-in-continuation"
import { cn } from "cn"
import { isAuthFormFieldInvalid, useAuthForm } from "./auth-form"
import { LastUsedBadge } from "./last-login-method/last-used-badge"
import { ProviderButtons, type SocialLayout } from "./provider-buttons"
import { ReauthenticationNotice } from "./reauthentication"

export type SignInProps = {
  className?: string
  socialLayout?: SocialLayout
  socialPosition?: "top" | "bottom"
}

/**
 * Render the sign-in form UI with email/password, magic link, and social provider options.
 *
 * @param className - Optional additional container class names
 * @param socialLayout - Layout style for social provider buttons
 * @param socialPosition - Position of social provider buttons; `"top"` or `"bottom"`. Defaults to `"bottom"`.
 * @returns The rendered sign-in UI as a JSX element
 */
export function SignIn({
  className,
  socialLayout,
  socialPosition = "bottom"
}: SignInProps) {
  const {
    authClient,
    basePaths,
    emailAndPassword,
    localization,
    plugins,
    socialProviders,
    viewPaths,
    navigate,
    Link
  } = useAuth()

  const { fetchOptions, resetFetchOptions } = useFetchOptions()
  const continueSignIn = useSignInContinuation()

  const { mutateAsync: signInEmail, isPending: signInEmailPending } =
    useSignInEmail(authClient, {
      onError: (error, { email }) => {
        form.setFieldValue("password", "")

        if (error.error?.code === "EMAIL_NOT_VERIFIED") {
          sessionStorage.setItem("better-auth-ui.verify-email", email)
          navigate({
            to: `${basePaths.auth}/${viewPaths.auth.verifyEmail}`
          })
        }

        resetFetchOptions()
      },
      onSuccess: (data) => continueSignIn(data)
    })

  const signInMutating = useIsMutating({
    mutationKey: authMutationKeys.signIn.all
  })
  const signUpMutating = useIsMutating({
    mutationKey: authMutationKeys.signUp.all
  })
  const isPending = signInMutating + signUpMutating > 0

  const Captcha = plugins.find(
    (plugin) => plugin.captchaComponent
  )?.captchaComponent

  const passkeyAutoFill = isPasskeyAutoFillEnabled(plugins)

  const [isPasswordVisible, setIsPasswordVisible] = useState(false)
  const form = useAuthForm({
    defaultValues: { email: "", password: "", rememberMe: false },
    onSubmit: async ({ value }) =>
      await signInEmail({
        email: value.email,
        password: value.password,
        ...(emailAndPassword?.rememberMe
          ? { rememberMe: value.rememberMe }
          : {}),
        fetchOptions
      })
  })

  const showSeparator =
    emailAndPassword?.enabled && socialProviders && socialProviders.length > 0

  return (
    <Card className={cn("w-full max-w-sm", className)}>
      <AuthPrompts view="signIn" />
      <ReauthenticationNotice />
      <CardHeader>
        <CardTitle className="text-xl font-semibold">
          {localization.auth.signIn}
        </CardTitle>
      </CardHeader>

      <CardContent>
        <div className="flex flex-col gap-6">
          {socialPosition === "top" && (
            <>
              {socialProviders && socialProviders.length > 0 && (
                <ProviderButtons socialLayout={socialLayout} view="signIn" />
              )}

              {showSeparator && (
                <FieldSeparator className="*:data-[slot=field-separator-content]:bg-card m-0 text-xs flex items-center">
                  {localization.auth.or}
                </FieldSeparator>
              )}
            </>
          )}

          {emailAndPassword?.enabled && (
            <form.AppForm>
              <form.AuthFormRoot>
                <FieldGroup>
                  <form.AppField
                    name="email"
                    validators={{
                      onChange: ({ value }) =>
                        validateEmailAddress(value, {
                          invalidMessage: localization.auth.invalidEmail,
                          requiredMessage: localization.auth.fieldRequired
                        })
                    }}
                  >
                    {(field) => {
                      const isInvalid = isAuthFormFieldInvalid(field.state.meta)
                      return (
                        <Field data-invalid={isInvalid}>
                          <FieldLabel htmlFor="email">
                            {localization.auth.email}
                          </FieldLabel>

                          <Input
                            id="email"
                            name={field.name}
                            type="email"
                            autoComplete={withPasskeyAutoFill(
                              "email",
                              passkeyAutoFill
                            )}
                            placeholder={localization.auth.emailPlaceholder}
                            required
                            disabled={isPending}
                            value={field.state.value}
                            onBlur={field.handleBlur}
                            onChange={(event) =>
                              field.handleChange(event.target.value)
                            }
                            aria-invalid={isInvalid}
                          />
                          <field.AuthFormFieldError />
                        </Field>
                      )
                    }}
                  </form.AppField>

                  <form.AppField
                    name="password"
                    validators={{
                      onChange: ({ value }) =>
                        validateStringLength(value, {
                          maxLength: emailAndPassword?.maxPasswordLength,
                          maxLengthMessage: localization.auth.tooLong.replace(
                            "{{max}}",
                            String(emailAndPassword?.maxPasswordLength)
                          ),
                          minLength: emailAndPassword?.minPasswordLength,
                          minLengthMessage: localization.auth.tooShort.replace(
                            "{{min}}",
                            String(emailAndPassword?.minPasswordLength)
                          ),
                          requiredMessage: localization.auth.fieldRequired
                        })
                    }}
                  >
                    {(field) => {
                      const isInvalid = isAuthFormFieldInvalid(field.state.meta)
                      return (
                        <Field data-invalid={isInvalid}>
                          <FieldLabel htmlFor="password">
                            {localization.auth.password}
                          </FieldLabel>

                          <InputGroup>
                            <InputGroupInput
                              id="password"
                              name={field.name}
                              type={isPasswordVisible ? "text" : "password"}
                              autoComplete={withPasskeyAutoFill(
                                "current-password",
                                passkeyAutoFill
                              )}
                              value={field.state.value}
                              onBlur={field.handleBlur}
                              onChange={(event) =>
                                field.handleChange(event.target.value)
                              }
                              placeholder={
                                localization.auth.passwordPlaceholder
                              }
                              required
                              minLength={emailAndPassword?.minPasswordLength}
                              maxLength={emailAndPassword?.maxPasswordLength}
                              disabled={isPending}
                              aria-invalid={isInvalid}
                            />

                            <InputGroupAddon align="inline-end">
                              <InputGroupButton
                                size="icon-xs"
                                aria-label={
                                  isPasswordVisible
                                    ? localization.auth.hidePassword
                                    : localization.auth.showPassword
                                }
                                title={
                                  isPasswordVisible
                                    ? localization.auth.hidePassword
                                    : localization.auth.showPassword
                                }
                                onClick={() => {
                                  setIsPasswordVisible((visible) => !visible)
                                }}
                              >
                                {isPasswordVisible ? <EyeOff /> : <Eye />}
                              </InputGroupButton>
                            </InputGroupAddon>
                          </InputGroup>

                          <field.AuthFormFieldError />
                        </Field>
                      )
                    }}
                  </form.AppField>

                  {emailAndPassword.rememberMe && (
                    <form.AppField name="rememberMe">
                      {(field) => (
                        <Field className="my-1">
                          <div className="flex items-center gap-3">
                            <Checkbox
                              id="rememberMe"
                              name={field.name}
                              checked={field.state.value}
                              disabled={isPending}
                              onCheckedChange={(checked) =>
                                field.handleChange(checked === true)
                              }
                            />

                            <FieldLabel
                              htmlFor="rememberMe"
                              className="cursor-pointer text-sm font-normal"
                            >
                              {localization.auth.rememberMe}
                            </FieldLabel>
                          </div>
                        </Field>
                      )}
                    </form.AppField>
                  )}

                  {Captcha && (
                    <div className="flex justify-center">{Captcha}</div>
                  )}

                  <div className="flex flex-col gap-3">
                    <form.AuthFormSubmitButton
                      isPending={signInEmailPending}
                      className="relative overflow-visible"
                      disabled={isPending}
                    >
                      {localization.auth.signIn}

                      <LastUsedBadge method="email" floating />
                    </form.AuthFormSubmitButton>

                    {plugins.flatMap((plugin) =>
                      (plugin.authButtons ?? []).map((AuthButton, index) => (
                        <AuthButton
                          key={`${plugin.id}-${index.toString()}`}
                          view="signIn"
                        />
                      ))
                    )}
                  </div>
                </FieldGroup>
              </form.AuthFormRoot>
            </form.AppForm>
          )}

          {socialPosition === "bottom" && (
            <>
              {showSeparator && (
                <FieldSeparator className="*:data-[slot=field-separator-content]:bg-card text-xs flex items-center">
                  {localization.auth.or}
                </FieldSeparator>
              )}

              {socialProviders && socialProviders.length > 0 && (
                <ProviderButtons socialLayout={socialLayout} view="signIn" />
              )}
            </>
          )}
        </div>

        <div className="flex flex-col gap-3 items-center w-full mt-4">
          {emailAndPassword?.enabled && emailAndPassword?.forgotPassword && (
            <Link
              href={`${basePaths.auth}/${viewPaths.auth.forgotPassword}`}
              className="self-center text-sm underline-offset-4 hover:underline"
            >
              {localization.auth.forgotPasswordLink}
            </Link>
          )}

          {emailAndPassword?.enabled && (
            <FieldDescription className="text-center">
              {localization.auth.needToCreateAnAccount}{" "}
              <Link
                href={`${basePaths.auth}/${viewPaths.auth.signUp}`}
                className="underline underline-offset-4"
              >
                {localization.auth.signUp}
              </Link>
            </FieldDescription>
          )}
        </div>
      </CardContent>
    </Card>
  )
}
examples/start-shadcn-example/src/components/auth/sign-out.tsx
"use client"

import { useAuth, useSignOut } from "@better-auth-ui/react"
import { useEffect, useRef } from "react"
import { Spinner } from "@/components/ui/spinner"
import { cn } from "cn"

export type SignOutProps = {
  className?: string
}

/**
 * Signs the current user out on mount and renders a centered spinner while the operation completes.
 *
 * @param className - Optional additional class names appended to the root element
 * @returns The spinner shown during sign-out
 */
export function SignOut({ className }: SignOutProps) {
  const { authClient, basePaths, navigate, viewPaths } = useAuth()

  const { mutate: signOut } = useSignOut(authClient, {
    onError: () => {
      navigate({
        to: `${basePaths.auth}/${viewPaths.auth.signIn}`,
        replace: true
      })
    },
    onSuccess: () =>
      navigate({
        to: `${basePaths.auth}/${viewPaths.auth.signIn}`,
        replace: true
      })
  })

  const hasSignedOut = useRef(false)

  useEffect(() => {
    if (hasSignedOut.current) return
    hasSignedOut.current = true

    signOut()
  }, [signOut])

  return <Spinner className={cn("mx-auto my-auto", className)} />
}
examples/start-shadcn-example/src/components/auth/sign-up.tsx
"use client"

import {
  authMutationKeys,
  getAdditionalFieldDefaultValues,
  getAdditionalFieldSubmitValues,
  getAuthCallbackURL,
  getAuthLinkURL,
  isPasswordCompromisedError,
  validateEmailAddress,
  validateMatchingValue,
  validateStringLength
} from "@better-auth-ui/core"
import {
  AuthPrompts,
  useAuth,
  useFetchOptions,
  useSignUpEmail
} from "@better-auth-ui/react"
import { useIsMutating } from "@tanstack/react-query"
import { Eye, EyeOff } from "lucide-react"
import { useMemo, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
  FieldSeparator
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput
} from "@/components/ui/input-group"
import { cn } from "cn"
import {
  getAuthAdditionalFieldValidators,
  isAuthFormFieldInvalid,
  useAuthForm
} from "./auth-form"
import { PasswordStrengthMeter } from "./password-strength-meter"
import { ProviderButtons, type SocialLayout } from "./provider-buttons"

export type SignUpProps = {
  className?: string
  socialLayout?: SocialLayout
  socialPosition?: "top" | "bottom"
  /**
   * Runs instead of the post-sign-up redirect, but only when the sign-up
   * created an immediately usable session. Email verification still takes
   * priority, and social sign-ups are unaffected.
   */
  onSignUpSuccess?: () => void
}

/**
 * Renders a sign-up form with name, email, and password fields, optional social provider buttons, and submission handling.
 *
 * Submits credentials to the configured auth client and handles the response:
 * - If email verification is required, shows a notification and navigates to sign-in
 * - On success, refreshes the session and navigates to the configured redirect path
 * - On failure, displays error toasts
 * - Manages a pending state while the request is in-flight
 *
 * @param className - Additional CSS classes applied to the outer container
 * @param socialLayout - Social layout to apply to the component
 * @param socialPosition - Social position to apply to the component
 * @param onSignUpSuccess - Replaces the post-sign-up redirect when the new account is immediately usable
 * @returns The sign-up form React element.
 */
export function SignUp({
  className,
  socialLayout,
  socialPosition = "bottom",
  onSignUpSuccess
}: SignUpProps) {
  const {
    additionalFields,
    authClient,
    basePaths,
    baseURL,
    emailAndPassword,
    localization,
    plugins,
    redirectTo,
    socialProviders,
    viewPaths,
    navigate,
    Link
  } = useAuth()

  const { fetchOptions, resetFetchOptions } = useFetchOptions()

  const { mutateAsync: signUpEmail } = useSignUpEmail(authClient, {
    onError: (error) => {
      // The haveIBeenPwned plugin rejects on the password itself,
      // so it belongs against the field rather than in a toast.
      if (isPasswordCompromisedError(error)) {
        setIsCompromised(true)
      }

      form.setFieldValue("password", "")
      form.setFieldValue("confirmPassword", "")
      resetFetchOptions()
    },
    onSuccess: (_data, { email }) => {
      if (emailAndPassword?.requireEmailVerification) {
        sessionStorage.setItem("better-auth-ui.verify-email", email)
        navigate({
          to: getAuthLinkURL(
            `${basePaths.auth}/${viewPaths.auth.verifyEmail}`,
            redirectTo
          )
        })
      } else if (onSignUpSuccess) {
        onSignUpSuccess()
      } else {
        navigate({ to: redirectTo })
      }
    }
  })

  const signInMutating = useIsMutating({
    mutationKey: authMutationKeys.signIn.all
  })
  const signUpMutating = useIsMutating({
    mutationKey: authMutationKeys.signUp.all
  })
  const isPending = signInMutating + signUpMutating > 0

  const Captcha = plugins.find(
    (plugin) => plugin.captchaComponent
  )?.captchaComponent

  const [isPasswordVisible, setIsPasswordVisible] = useState(false)
  const [isConfirmPasswordVisible, setIsConfirmPasswordVisible] =
    useState(false)

  const [isCompromised, setIsCompromised] = useState(false)
  const signUpFields = useMemo(
    () => additionalFields?.filter((field) => field.signUp) ?? [],
    [additionalFields]
  )
  const form = useAuthForm({
    defaultValues: {
      additionalFields: getAdditionalFieldDefaultValues(signUpFields),
      confirmPassword: "",
      email: "",
      name: "",
      password: ""
    },
    onSubmit: async ({ value }) => {
      try {
        await signUpEmail({
          name: emailAndPassword?.name === false ? "" : value.name,
          email: value.email.trim(),
          password: value.password,
          ...getAdditionalFieldSubmitValues(
            signUpFields,
            value.additionalFields
          ),
          callbackURL: getAuthCallbackURL(baseURL, redirectTo),
          fetchOptions
        })
      } catch {
        // The mutation reports the error through its configured handler.
      }
    }
  })

  const showSeparator =
    emailAndPassword?.enabled && socialProviders && socialProviders.length > 0

  return (
    <Card className={cn("w-full max-w-sm", className)}>
      <AuthPrompts view="signUp" />
      <CardHeader>
        <CardTitle className="text-xl font-semibold">
          {localization.auth.signUp}
        </CardTitle>
      </CardHeader>

      <CardContent>
        <div className="flex flex-col gap-6">
          {socialPosition === "top" && (
            <>
              {socialProviders && socialProviders.length > 0 && (
                <ProviderButtons socialLayout={socialLayout} view="signUp" />
              )}

              {showSeparator && (
                <FieldSeparator className="*:data-[slot=field-separator-content]:bg-card text-xs flex items-center">
                  {localization.auth.or}
                </FieldSeparator>
              )}
            </>
          )}

          {emailAndPassword?.enabled && (
            <form.AppForm>
              <form.AuthFormRoot>
                <FieldGroup>
                  {emailAndPassword.name !== false && (
                    <form.AppField
                      name="name"
                      validators={{
                        onChange: ({ value }) =>
                          validateStringLength(value, {
                            requiredMessage: localization.auth.fieldRequired,
                            trim: true
                          })
                      }}
                    >
                      {(field) => {
                        const isInvalid = isAuthFormFieldInvalid(
                          field.state.meta
                        )

                        return (
                          <Field data-invalid={isInvalid}>
                            <FieldLabel htmlFor="name">
                              {localization.auth.name}
                            </FieldLabel>

                            <Input
                              id="name"
                              name={field.name}
                              type="text"
                              autoComplete="name"
                              placeholder={localization.auth.namePlaceholder}
                              required
                              disabled={isPending}
                              value={field.state.value}
                              onBlur={field.handleBlur}
                              onChange={(event) =>
                                field.handleChange(event.target.value)
                              }
                              aria-invalid={isInvalid}
                            />

                            <field.AuthFormFieldError />
                          </Field>
                        )
                      }}
                    </form.AppField>
                  )}

                  <form.AppField
                    name="email"
                    validators={{
                      onChange: ({ value }) =>
                        validateEmailAddress(value, {
                          invalidMessage: localization.auth.invalidEmail,
                          requiredMessage: localization.auth.fieldRequired
                        })
                    }}
                  >
                    {(field) => {
                      const isInvalid = isAuthFormFieldInvalid(field.state.meta)

                      return (
                        <Field data-invalid={isInvalid}>
                          <FieldLabel htmlFor="email">
                            {localization.auth.email}
                          </FieldLabel>

                          <Input
                            id="email"
                            name={field.name}
                            type="email"
                            autoComplete="email"
                            placeholder={localization.auth.emailPlaceholder}
                            required
                            disabled={isPending}
                            value={field.state.value}
                            onBlur={field.handleBlur}
                            onChange={(event) =>
                              field.handleChange(event.target.value)
                            }
                            aria-invalid={isInvalid}
                          />

                          <field.AuthFormFieldError />
                        </Field>
                      )
                    }}
                  </form.AppField>

                  {signUpFields.map(
                    (configuredField) =>
                      configuredField.signUp === "above" && (
                        <form.AppField
                          key={configuredField.name}
                          name={`additionalFields.${configuredField.name}`}
                          validators={getAuthAdditionalFieldValidators(
                            configuredField,
                            localization.auth.fieldRequired
                          )}
                        >
                          {(field) => (
                            <field.AuthFormAdditionalField
                              field={configuredField}
                              isPending={isPending}
                              optionalLabel={localization.auth.optional}
                            />
                          )}
                        </form.AppField>
                      )
                  )}

                  <form.AppField
                    name="password"
                    validators={{
                      onChange: ({ value }) =>
                        validateStringLength(value, {
                          maxLength: emailAndPassword?.maxPasswordLength,
                          maxLengthMessage: localization.auth.tooLong.replace(
                            "{{max}}",
                            String(emailAndPassword?.maxPasswordLength)
                          ),
                          minLength: emailAndPassword?.minPasswordLength,
                          minLengthMessage: localization.auth.tooShort.replace(
                            "{{min}}",
                            String(emailAndPassword?.minPasswordLength)
                          ),
                          requiredMessage: localization.auth.fieldRequired
                        })
                    }}
                  >
                    {(field) => {
                      const isInvalid =
                        isAuthFormFieldInvalid(field.state.meta) ||
                        isCompromised

                      return (
                        <Field data-invalid={isInvalid}>
                          <FieldLabel htmlFor="password">
                            {localization.auth.password}
                          </FieldLabel>

                          <InputGroup>
                            <InputGroupInput
                              id="password"
                              name={field.name}
                              type={isPasswordVisible ? "text" : "password"}
                              autoComplete="new-password"
                              value={field.state.value}
                              onBlur={field.handleBlur}
                              onChange={(e) => {
                                field.handleChange(e.target.value)
                                setIsCompromised(false)
                              }}
                              placeholder={
                                localization.auth.passwordPlaceholder
                              }
                              required
                              minLength={emailAndPassword?.minPasswordLength}
                              maxLength={emailAndPassword?.maxPasswordLength}
                              disabled={isPending}
                              aria-invalid={isInvalid}
                            />

                            <InputGroupAddon align="inline-end">
                              <InputGroupButton
                                size="icon-xs"
                                aria-label={
                                  isPasswordVisible
                                    ? localization.auth.hidePassword
                                    : localization.auth.showPassword
                                }
                                title={
                                  isPasswordVisible
                                    ? localization.auth.hidePassword
                                    : localization.auth.showPassword
                                }
                                onClick={() => {
                                  setIsPasswordVisible((visible) => !visible)
                                }}
                              >
                                {isPasswordVisible ? <EyeOff /> : <Eye />}
                              </InputGroupButton>
                            </InputGroupAddon>
                          </InputGroup>

                          {isCompromised ? (
                            <FieldError>
                              {localization.auth.passwordCompromised}
                            </FieldError>
                          ) : (
                            <field.AuthFormFieldError />
                          )}

                          <PasswordStrengthMeter password={field.state.value} />
                        </Field>
                      )
                    }}
                  </form.AppField>

                  {emailAndPassword?.confirmPassword && (
                    <form.AppField
                      name="confirmPassword"
                      validators={{
                        onChangeListenTo: ["password"],
                        onChange: ({ fieldApi, value }) =>
                          validateStringLength(value, {
                            maxLength: emailAndPassword?.maxPasswordLength,
                            maxLengthMessage: localization.auth.tooLong.replace(
                              "{{max}}",
                              String(emailAndPassword?.maxPasswordLength)
                            ),
                            minLength: emailAndPassword?.minPasswordLength,
                            minLengthMessage:
                              localization.auth.tooShort.replace(
                                "{{min}}",
                                String(emailAndPassword?.minPasswordLength)
                              ),
                            requiredMessage: localization.auth.fieldRequired
                          }) ??
                          validateMatchingValue(
                            value,
                            fieldApi.form.getFieldValue("password"),
                            localization.auth.passwordsDoNotMatch
                          )
                      }}
                    >
                      {(field) => {
                        const isInvalid = isAuthFormFieldInvalid(
                          field.state.meta
                        )

                        return (
                          <Field data-invalid={isInvalid}>
                            <FieldLabel htmlFor="confirmPassword">
                              {localization.auth.confirmPassword}
                            </FieldLabel>

                            <InputGroup>
                              <InputGroupInput
                                id="confirmPassword"
                                name={field.name}
                                type={
                                  isConfirmPasswordVisible ? "text" : "password"
                                }
                                autoComplete="new-password"
                                value={field.state.value}
                                onBlur={field.handleBlur}
                                onChange={(e) =>
                                  field.handleChange(e.target.value)
                                }
                                placeholder={
                                  localization.auth.confirmPasswordPlaceholder
                                }
                                required
                                minLength={emailAndPassword?.minPasswordLength}
                                maxLength={emailAndPassword?.maxPasswordLength}
                                disabled={isPending}
                                aria-invalid={isInvalid}
                              />

                              <InputGroupAddon align="inline-end">
                                <InputGroupButton
                                  size="icon-xs"
                                  aria-label={
                                    isConfirmPasswordVisible
                                      ? localization.auth.hidePassword
                                      : localization.auth.showPassword
                                  }
                                  title={
                                    isConfirmPasswordVisible
                                      ? localization.auth.hidePassword
                                      : localization.auth.showPassword
                                  }
                                  onClick={() =>
                                    setIsConfirmPasswordVisible(
                                      (visible) => !visible
                                    )
                                  }
                                >
                                  {isConfirmPasswordVisible ? (
                                    <EyeOff />
                                  ) : (
                                    <Eye />
                                  )}
                                </InputGroupButton>
                              </InputGroupAddon>
                            </InputGroup>

                            <field.AuthFormFieldError />
                          </Field>
                        )
                      }}
                    </form.AppField>
                  )}

                  {signUpFields.map(
                    (configuredField) =>
                      configuredField.signUp !== "above" && (
                        <form.AppField
                          key={configuredField.name}
                          name={`additionalFields.${configuredField.name}`}
                          validators={getAuthAdditionalFieldValidators(
                            configuredField,
                            localization.auth.fieldRequired
                          )}
                        >
                          {(field) => (
                            <field.AuthFormAdditionalField
                              field={configuredField}
                              isPending={isPending}
                              optionalLabel={localization.auth.optional}
                            />
                          )}
                        </form.AppField>
                      )
                  )}

                  {Captcha && (
                    <div className="flex justify-center">{Captcha}</div>
                  )}

                  <div className="flex flex-col gap-3">
                    <form.AuthFormSubmitButton disabled={isPending}>
                      {localization.auth.signUp}
                    </form.AuthFormSubmitButton>

                    {plugins.flatMap((plugin) =>
                      (plugin.authButtons ?? []).map((AuthButton, index) => (
                        <AuthButton
                          key={`${plugin.id}-${index.toString()}`}
                          view="signUp"
                        />
                      ))
                    )}
                  </div>
                </FieldGroup>
              </form.AuthFormRoot>
            </form.AppForm>
          )}

          {socialPosition === "bottom" && (
            <>
              {showSeparator && (
                <FieldSeparator className="*:data-[slot=field-separator-content]:bg-card text-xs flex items-center">
                  {localization.auth.or}
                </FieldSeparator>
              )}

              {socialProviders && socialProviders.length > 0 && (
                <ProviderButtons socialLayout={socialLayout} view="signUp" />
              )}
            </>
          )}
        </div>

        {emailAndPassword?.enabled && (
          <div className="flex flex-col gap-3 items-center w-full mt-4">
            <FieldDescription className="text-center">
              {localization.auth.alreadyHaveAnAccount}{" "}
              <Link
                href={getAuthLinkURL(
                  `${basePaths.auth}/${viewPaths.auth.signIn}`,
                  redirectTo
                )}
                className="underline underline-offset-4"
              >
                {localization.auth.signIn}
              </Link>
            </FieldDescription>
          </div>
        )}
      </CardContent>
    </Card>
  )
}
examples/start-shadcn-example/src/components/auth/use-is-hydrated.ts
"use client"

import { useSyncExternalStore } from "react"

/**
 * Returns `true` once the component is mounted on the client (hydrated) and
 * `false` while rendering on the server, so client-only reads (e.g.
 * `sessionStorage`) stay safe during SSR.
 *
 * @returns Whether the component has hydrated on the client.
 */
export function useIsHydrated() {
  const subscribe = () => () => {}
  return useSyncExternalStore(
    subscribe,
    () => true,
    () => false
  )
}
examples/start-shadcn-example/src/components/auth/verify-email.tsx
"use client"

import { useAuth, useSendVerificationEmail } from "@better-auth-ui/react"
import { useEffect, useState } from "react"
import { toast } from "sonner"

import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { FieldDescription } from "@/components/ui/field"
import { Spinner } from "@/components/ui/spinner"
import { cn } from "cn"
import { OpenEmailButton } from "./open-email-button"
import { useIsHydrated } from "./use-is-hydrated"

export type VerifyEmailProps = {
  className?: string
}

/** Seconds the resend button stays disabled to prevent spamming the endpoint. */
const RESEND_COOLDOWN_SECONDS = 60

/**
 * Render a card prompting the user to verify their email, with a resend button
 * that is rate-limited by a cooldown timer.
 *
 * The target email is read from `sessionStorage` (set when sign-up or sign-in
 * redirects here); the OpenEmail/Resend controls are only shown when an email
 * is stored. The resend button is disabled while a cooldown is active and shows
 * the remaining seconds.
 *
 * @param className - Additional CSS classes applied to the card
 * @returns The verify-email card React element
 */
export function VerifyEmail({ className }: VerifyEmailProps) {
  const {
    authClient,
    basePaths,
    baseURL,
    localization,
    redirectTo,
    viewPaths,
    Link
  } = useAuth()

  const isHydrated = useIsHydrated()
  const [email, setEmail] = useState(
    (isHydrated && sessionStorage.getItem("better-auth-ui.verify-email")) || ""
  )
  const [cooldown, setCooldown] = useState(RESEND_COOLDOWN_SECONDS)

  useEffect(() => {
    setEmail(sessionStorage.getItem("better-auth-ui.verify-email") ?? "")
  }, [])

  useEffect(() => {
    if (cooldown <= 0 || !email) return

    const interval = setInterval(() => {
      setCooldown((current) => (current > 0 ? current - 1 : 0))
    }, 1000)

    return () => clearInterval(interval)
  }, [cooldown, email])

  const { mutate: sendVerificationEmail, isPending } = useSendVerificationEmail(
    authClient,
    {
      onSuccess: () => {
        toast.success(localization.auth.verificationEmailSent)
        setCooldown(RESEND_COOLDOWN_SECONDS)
      }
    }
  )

  const isCoolingDown = cooldown > 0

  return (
    <Card className={cn("w-full max-w-sm", className)}>
      <CardHeader>
        <CardTitle className="text-xl font-semibold">
          {localization.auth.verifyEmail}
        </CardTitle>
      </CardHeader>

      <CardContent>
        <div className="flex flex-col gap-4">
          <FieldDescription>
            {localization.auth.checkYourEmail}
          </FieldDescription>

          {email && (
            <div className="flex flex-col gap-3">
              <OpenEmailButton email={email} />

              <Button
                type="button"
                variant="outline"
                disabled={!email || isCoolingDown || isPending}
                onClick={() =>
                  sendVerificationEmail({
                    email,
                    callbackURL: `${baseURL}${redirectTo}`
                  })
                }
              >
                {isPending && <Spinner />}

                {isCoolingDown
                  ? localization.auth.resendIn.replace(
                      "{{seconds}}",
                      String(cooldown)
                    )
                  : localization.auth.resend}
              </Button>
            </div>
          )}
        </div>

        <div className="flex flex-col gap-3 items-center w-full mt-4">
          <FieldDescription className="text-center">
            {localization.auth.alreadyVerifiedYourEmail}{" "}
            <Link
              href={`${basePaths.auth}/${viewPaths.auth.signIn}`}
              className="underline underline-offset-4"
            >
              {localization.auth.signIn}
            </Link>
          </FieldDescription>
        </div>
      </CardContent>
    </Card>
  )
}
examples/start-shadcn-example/src/lib/auth/two-factor-methods.ts
/**
 * Second-factor methods a user can complete the challenge with.
 *
 * `totp` and `otp` come from the server's `twoFactorMethods` list. Backup
 * codes are always available when the server has them enabled, so they are
 * never part of that list.
 */
export type TwoFactorMethod = "totp" | "otp"

const TWO_FACTOR_METHODS: TwoFactorMethod[] = ["totp", "otp"]

/** Auth plugin id used by Better Auth UI's two-factor integration. */
export const TWO_FACTOR_PLUGIN_ID = "twoFactor"

/**
 * `sessionStorage` key holding the methods reported by the sign-in response.
 *
 * Only the non-sensitive method names are stored, never a code, token, or the
 * two-factor cookie, which stays HTTP-only.
 */
export const TWO_FACTOR_METHODS_STORAGE_KEY =
  "better-auth-ui.two-factor-methods"

type TwoFactorRedirect = {
  twoFactorRedirect: true
  twoFactorMethods?: unknown
}

/** Detect the redirect payload Better Auth returns before a second factor. */
export function isTwoFactorRedirect(data: unknown): data is TwoFactorRedirect {
  return (
    typeof data === "object" &&
    data !== null &&
    (data as { twoFactorRedirect?: unknown }).twoFactorRedirect === true
  )
}

/** Narrow arbitrary method names to the challenge views this UI supports. */
export function parseTwoFactorMethods(methods?: unknown): TwoFactorMethod[] {
  if (!Array.isArray(methods)) return []

  return TWO_FACTOR_METHODS.filter((method) => methods.includes(method))
}

/** Persist the enabled method names without blocking sign-in on storage errors. */
export function storeTwoFactorMethods(methods?: unknown) {
  if (typeof sessionStorage === "undefined") return

  try {
    sessionStorage.setItem(
      TWO_FACTOR_METHODS_STORAGE_KEY,
      JSON.stringify(parseTwoFactorMethods(methods))
    )
  } catch {
    // The challenge falls back to every method when storage is unavailable.
  }
}

/** Read the stored methods, falling back to every supported challenge. */
export function readTwoFactorMethods(): TwoFactorMethod[] {
  if (typeof sessionStorage === "undefined") return TWO_FACTOR_METHODS

  try {
    const stored = sessionStorage.getItem(TWO_FACTOR_METHODS_STORAGE_KEY)
    if (!stored) return TWO_FACTOR_METHODS

    const methods = parseTwoFactorMethods(JSON.parse(stored))
    return methods.length ? methods : TWO_FACTOR_METHODS
  } catch {
    return TWO_FACTOR_METHODS
  }
}

/** Clear stored method hints after the challenge finishes or is abandoned. */
export function clearTwoFactorMethods() {
  if (typeof sessionStorage === "undefined") return

  try {
    sessionStorage.removeItem(TWO_FACTOR_METHODS_STORAGE_KEY)
  } catch {
    // Stale method hints are harmless and must not block navigation.
  }
}
examples/start-shadcn-example/src/lib/auth/use-sign-in-continuation.ts
"use client"

import { useAuth } from "@better-auth-ui/react"
import { useCallback } from "react"
import {
  isTwoFactorRedirect,
  storeTwoFactorMethods,
  TWO_FACTOR_PLUGIN_ID
} from "./two-factor-methods"

/**
 * Resolve what happens after a sign-in request succeeds.
 *
 * Better Auth withholds the session when a second factor is required and
 * answers with `{ twoFactorRedirect: true, twoFactorMethods }` instead, so no
 * sign-in strategy may navigate to `redirectTo` unconditionally. This hook is
 * the single place that decision lives — every password-based form calls it
 * from `onSuccess`.
 *
 * The enabled methods are stashed in session storage (names only, never a
 * code or token) and `redirectTo` rides along in the query string so the
 * challenge view can finish the original navigation.
 *
 * The two-factor plugin is looked up by its stable id, so sign-in forms stay
 * installable without either the two-factor components or a matching release
 * of `@better-auth-ui/core`.
 *
 * @returns A callback taking the resolved data of a sign-in mutation.
 */
export function useSignInContinuation() {
  const { basePaths, navigate, plugins, redirectTo } = useAuth()

  const twoFactorPath = plugins.find(
    (plugin) => plugin.id === TWO_FACTOR_PLUGIN_ID
  )?.viewPaths?.auth?.twoFactor

  return useCallback(
    (data: unknown) => {
      if (twoFactorPath && isTwoFactorRedirect(data)) {
        storeTwoFactorMethods(data.twoFactorMethods)

        navigate({
          to: `${basePaths.auth}/${twoFactorPath}?redirectTo=${encodeURIComponent(redirectTo)}`
        })
        return
      }

      navigate({ to: redirectTo })
    },
    [basePaths.auth, navigate, redirectTo, twoFactorPath]
  )
}

More from better-auth-ui

Browse all components in this library →