shadimage
better-auth-ui
componentssosource: ok · refreshed

SSO and Email-First Sign In

Discovers organization SSO from an email address and manages organization identity providers and domain verification.

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

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

Dependencies

Bundle weight · light
iconslucide-react

Registry dependencies

alertalert-dialogavatarbadgebuttoncalendarcardcheckboxcomboboxdialogemptyfieldhttps://better-auth-ui.com/r/radix-nova/sign-in.jsoninputinput-groupitempopoverselectskeletonslidersonnerspinnerswitchtabstextarea

Files31 · source cached 10/8/2026, 7:44:33 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/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/organization/accept-invitation.tsx
"use client"

import { getSafeRedirectTo } from "@better-auth-ui/core"
import type { OrganizationAuthClient } from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthenticate, useAuthPlugin } from "@better-auth-ui/react"
import {
  useAcceptInvitation,
  useInvitation,
  useRejectInvitation
} from "@better-auth-ui/react/plugins/organization"
import type { Invitation } from "better-auth/client"
import { BriefcaseBusiness } from "lucide-react"

import { Button } from "@/components/ui/button"
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle
} from "@/components/ui/card"
import { FieldDescription } from "@/components/ui/field"
import { Skeleton } from "@/components/ui/skeleton"
import { Spinner } from "@/components/ui/spinner"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { UserAvatar } from "../user/user-avatar"
import { cn } from "cn"
import { useIsHydrated } from "../use-is-hydrated"

type UserInvitation = Invitation & { organizationName?: string }

export type AcceptInvitationProps = {
  className?: string
}

function isPendingInvitation(invitation: UserInvitation | undefined) {
  if (invitation?.status !== "pending") return false

  return new Date(invitation.expiresAt).getTime() > Date.now()
}

/**
 * Render the organization invitation addressed by the `invitationId` query
 * parameter and let the signed-in recipient accept or reject it directly.
 */
export function AcceptInvitation({ className }: AcceptInvitationProps) {
  const { authClient, navigate, redirectTo } = useAuth()
  const { localization, roles } = useAuthPlugin(organizationPlugin)
  const organizationAuthClient = authClient as OrganizationAuthClient
  const isHydrated = useIsHydrated()
  const invitationId = isHydrated
    ? new URLSearchParams(window.location.search).get("invitationId")
    : null
  const session = useAuthenticate(organizationAuthClient)
  const invitationQuery = useInvitation(organizationAuthClient, {
    query: { id: invitationId ?? "" },
    enabled: Boolean(invitationId)
  })
  const invitation = invitationQuery.data as UserInvitation | undefined

  const returnToApplication = () => {
    navigate({
      to: getSafeRedirectTo(redirectTo, window.location.origin),
      replace: true
    })
  }

  const { mutate: acceptInvitation, isPending: isAccepting } =
    useAcceptInvitation(organizationAuthClient, {
      onSuccess: returnToApplication
    })
  const { mutate: rejectInvitation, isPending: isRejecting } =
    useRejectInvitation(organizationAuthClient, {
      onSuccess: returnToApplication
    })
  const isLoading =
    !isHydrated ||
    session.isPending ||
    !session.data ||
    (Boolean(invitationId) && invitationQuery.isPending)
  const isAvailable = isPendingInvitation(invitation)
  const organizationName =
    invitation?.organizationName || localization.organization
  const role = invitation
    ? (roles?.[invitation.role] ?? invitation.role)
    : localization.member

  return (
    <Card
      className={cn("w-full max-w-lg gap-0 overflow-hidden p-0", className)}
    >
      <CardHeader className="grid justify-items-center gap-5 p-6 text-center">
        <div className="flex size-16 items-center justify-center rounded-full bg-muted/50">
          <BriefcaseBusiness aria-hidden="true" className="size-7" />
        </div>
        <div className="grid gap-1">
          <CardTitle className="max-w-full break-words text-xl font-semibold">
            {isLoading ? (
              <Skeleton className="h-6 w-48" />
            ) : isAvailable ? (
              organizationName
            ) : (
              localization.invitationUnavailable
            )}
          </CardTitle>
          <CardDescription>
            {localization.acceptInvitationTitle}
          </CardDescription>
        </div>
      </CardHeader>
      <CardContent className="px-4 pb-6 sm:px-6">
        <div className="grid gap-5 rounded-lg bg-muted/50 p-5">
          {isLoading ? (
            <Skeleton className="h-10 w-full" />
          ) : isAvailable ? (
            <>
              <FieldDescription>
                {localization.acceptInvitationDescription
                  .replace("{{organization}}", organizationName)
                  .replace("{{role}}", role)}
              </FieldDescription>
              <dl className="grid gap-1">
                <dt className="text-sm text-muted-foreground">
                  {localization.role}
                </dt>
                <dd className="text-base font-medium">{role}</dd>
              </dl>
            </>
          ) : (
            <FieldDescription>
              {localization.invitationUnavailableDescription}
            </FieldDescription>
          )}
          <div className="flex min-w-0 items-center gap-3">
            <UserAvatar
              user={session.data?.user}
              isPending={session.isPending}
            />
            {session.data ? (
              <div className="grid min-w-0 gap-0.5">
                <p className="break-words text-sm font-medium">
                  {session.data.user.name || session.data.user.email}
                </p>
                {session.data.user.name ? (
                  <p className="break-all text-sm text-muted-foreground">
                    {session.data.user.email}
                  </p>
                ) : null}
              </div>
            ) : (
              <Skeleton className="h-4 w-40" />
            )}
          </div>
        </div>
      </CardContent>
      <CardFooter className="grid grid-cols-2 gap-3 p-4 sm:p-6">
        {isLoading ? (
          <>
            <Skeleton className="h-11 w-full" />
            <Skeleton className="h-11 w-full" />
          </>
        ) : isAvailable && invitation ? (
          <>
            <Button
              className="h-11 w-full"
              variant="secondary"
              disabled={isAccepting || isRejecting}
              onClick={() => rejectInvitation({ invitationId: invitation.id })}
            >
              {isRejecting ? <Spinner /> : null}
              {localization.rejectInvitation}
            </Button>
            <Button
              className="h-11 w-full"
              disabled={isAccepting || isRejecting}
              onClick={() => acceptInvitation({ invitationId: invitation.id })}
            >
              {isAccepting ? <Spinner /> : null}
              {localization.accept}
            </Button>
          </>
        ) : (
          <Button
            className="col-span-2 h-11 w-full"
            onClick={returnToApplication}
          >
            {localization.return}
          </Button>
        )}
      </CardFooter>
    </Card>
  )
}
examples/start-shadcn-example/src/components/auth/organization/create-organization-dialog.tsx
"use client"

import {
  getAdditionalFieldDefaultValues,
  getAdditionalFieldSubmitValues,
  validateStringLength
} from "@better-auth-ui/core"
import type { OrganizationAuthClient } from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import { useCreateOrganization } from "@better-auth-ui/react/plugins/organization"
import { Briefcase } from "lucide-react"
import { useEffect, useRef, useState } from "react"
import { buttonVariants } from "@/components/ui/button"
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle
} from "@/components/ui/dialog"
import { Field, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import {
  getAuthAdditionalFieldValidators,
  isAuthFormFieldInvalid,
  useAuthForm
} from "../auth-form"
import { SlugField, sanitizeSlug } from "./slug-field"

/** Props for the `CreateOrganizationDialog` component. */
export type CreateOrganizationDialogProps = {
  open: boolean
  onOpenChange: (open: boolean) => void
  hideSlug?: boolean
}

export function CreateOrganizationDialog({
  open,
  onOpenChange,
  hideSlug: hideSlugProp
}: CreateOrganizationDialogProps) {
  const { authClient, localization } = useAuth<OrganizationAuthClient>()
  const {
    additionalFields,
    localization: organizationLocalization,
    hideSlug: pluginHideSlug
  } = useAuthPlugin(organizationPlugin)
  const hideSlug = hideSlugProp ?? pluginHideSlug ?? false

  const [slugEdited, setSlugEdited] = useState(false)
  const submissionGeneration = useRef(0)
  const submissionAttemptGeneration = useRef(0)

  const { mutateAsync: createOrganization } = useCreateOrganization(authClient)

  const form = useAuthForm({
    defaultValues: {
      additionalFields: getAdditionalFieldDefaultValues(additionalFields),
      name: "",
      slug: ""
    },
    onSubmit: async ({ value }) => {
      const generation = submissionAttemptGeneration.current
      if (generation !== submissionGeneration.current) return
      await createOrganization({
        ...getAdditionalFieldSubmitValues(
          additionalFields,
          value.additionalFields
        ),
        name: value.name,
        slug: hideSlug ? undefined : value.slug
      })
      if (generation === submissionGeneration.current) onOpenChange(false)
    }
  })

  useEffect(() => {
    if (!open) {
      submissionGeneration.current += 1
      form.reset()
      setSlugEdited(false)
    }
  }, [form, open])

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent>
        <form.AppForm>
          <form.AuthFormRoot
            className="flex flex-col gap-6"
            onBeforeSubmit={() => {
              submissionAttemptGeneration.current = submissionGeneration.current
            }}
          >
            <DialogHeader>
              <DialogTitle className="flex items-center gap-2">
                <Briefcase />
                {organizationLocalization.createOrganization}
              </DialogTitle>

              <DialogDescription>
                {organizationLocalization.organizationsDescription}
              </DialogDescription>
            </DialogHeader>

            <div className="flex flex-col gap-4">
              <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="create-organization-name">
                        {organizationLocalization.name}
                      </FieldLabel>

                      <Input
                        id="create-organization-name"
                        name={field.name}
                        autoFocus
                        placeholder={organizationLocalization.namePlaceholder}
                        value={field.state.value}
                        onBlur={field.handleBlur}
                        onChange={(event) => {
                          const value = event.target.value
                          field.handleChange(value)
                          if (!slugEdited) {
                            form.setFieldValue("slug", sanitizeSlug(value))
                          }
                        }}
                        aria-invalid={isInvalid}
                      />

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

              {!hideSlug && (
                <form.AppField name="slug">
                  {(field) => (
                    <SlugField
                      id="create-organization-slug"
                      value={field.state.value}
                      onChange={(value) => {
                        field.handleChange(value)
                        setSlugEdited(true)
                      }}
                    />
                  )}
                </form.AppField>
              )}

              {additionalFields.map((configuredField) => (
                <form.AppField
                  key={configuredField.name}
                  name={`additionalFields.${configuredField.name}`}
                  validators={getAuthAdditionalFieldValidators(
                    configuredField,
                    localization.auth.fieldRequired
                  )}
                >
                  {(field) => (
                    <field.AuthFormAdditionalField
                      field={configuredField}
                      optionalLabel={localization.settings.optional}
                    />
                  )}
                </form.AppField>
              ))}
            </div>

            <DialogFooter>
              <DialogClose
                className={buttonVariants({ variant: "outline" })}
                type="button"
              >
                {localization.settings.cancel}
              </DialogClose>

              <form.AuthFormSubmitButton>
                {organizationLocalization.createOrganization}
              </form.AuthFormSubmitButton>
            </DialogFooter>
          </form.AuthFormRoot>
        </form.AppForm>
      </DialogContent>
    </Dialog>
  )
}
examples/start-shadcn-example/src/components/auth/organization/organization-logo.tsx
"use client"

import type { Organization } from "better-auth/client"
import { Briefcase } from "lucide-react"
import type { ComponentProps, CSSProperties, ReactNode } from "react"

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "cn"

export type OrganizationLogoSize = "sm" | "md" | "lg"

export type OrganizationLogoProps = {
  className?: string
  fallback?: ReactNode
  isPending?: boolean
  organization?: Partial<Organization> | null
  size?: OrganizationLogoSize
}

const sizeClasses: Record<OrganizationLogoSize, string> = {
  sm: "size-8",
  md: "size-10",
  lg: "size-12"
}

const fallbackTextClasses: Record<OrganizationLogoSize, string> = {
  sm: "text-sm",
  md: "text-base",
  lg: "text-xl"
}

export function OrganizationLogo({
  className,
  fallback,
  isPending,
  organization,
  size = "sm",
  style,
  ...props
}: OrganizationLogoProps &
  Omit<ComponentProps<typeof Avatar>, "children" | "size" | "style"> & {
    style?: CSSProperties
  }) {
  if (isPending && !organization) {
    return (
      <Skeleton
        className={cn("rounded-full", sizeClasses[size], className)}
        style={style}
      />
    )
  }

  const initials = organization?.name?.slice(0, 2).toUpperCase()
  const normalizedLogo = organization?.logo?.trim() || undefined

  return (
    <Avatar
      className={cn("rounded-full", sizeClasses[size], className)}
      style={style}
      {...props}
    >
      <AvatarImage
        alt={organization?.name ?? "Organization"}
        src={normalizedLogo}
      />

      <AvatarFallback
        className={cn("text-muted-foreground!", fallbackTextClasses[size])}
      >
        {fallback || initials || <Briefcase className="size-4" />}
      </AvatarFallback>
    </Avatar>
  )
}
examples/start-shadcn-example/src/components/auth/organization/organization-row.tsx
"use client"

import type { OrganizationAuthClient } from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import { useSetActiveOrganization } from "@better-auth-ui/react/plugins/organization"
import type { Organization } from "better-auth/client"
import { Settings as SettingsIcon } from "lucide-react"

import { Button } from "@/components/ui/button"
import { Item, ItemActions } from "@/components/ui/item"
import { Spinner } from "@/components/ui/spinner"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { OrganizationView } from "./organization-view"

export type OrganizationRowProps = {
  organization: Organization
}

/**
 * Single organization row: logo and labels via `OrganizationView`, plus a Manage action.
 */
export function OrganizationRow({ organization }: OrganizationRowProps) {
  const { authClient, basePaths, navigate } = useAuth<OrganizationAuthClient>()
  const {
    localization: organizationLocalization,
    viewPaths: organizationViewPaths,
    slug,
    slugPrefix
  } = useAuthPlugin(organizationPlugin)

  const { mutate: setActiveOrganization, isPending: setActivePending } =
    useSetActiveOrganization(authClient, {
      onSuccess: () => {
        navigate({
          to: `${basePaths.organization}/${organizationViewPaths.organization.settings}`
        })
      }
    })

  function manageOrganization() {
    if (slug !== undefined) {
      navigate({
        to: `${basePaths.organization}/${slugPrefix}${organization.slug}/${organizationViewPaths.organization.settings}`
      })
    } else {
      setActiveOrganization({ organizationId: organization.id })
    }
  }

  return (
    <Item>
      <OrganizationView className="flex-1" organization={organization} />
      <ItemActions>
        <Button
          variant="outline"
          size="sm"
          disabled={setActivePending}
          onClick={manageOrganization}
          aria-label={organizationLocalization.manage}
        >
          {setActivePending ? <Spinner /> : <SettingsIcon />}

          {organizationLocalization.manage}
        </Button>
      </ItemActions>
    </Item>
  )
}
examples/start-shadcn-example/src/components/auth/organization/organization-view-skeleton.tsx
"use client"

import type { ComponentProps } from "react"

import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "cn"
import {
  OrganizationLogo,
  type OrganizationLogoSize
} from "./organization-logo"

export type OrganizationViewSkeletonProps = {
  className?: string
  hideSlug?: boolean
  size?: OrganizationLogoSize
}

/**
 * Placeholder matching `OrganizationView` while organization data loads.
 */
export function OrganizationViewSkeleton({
  className,
  hideSlug,
  size = "md",
  ...props
}: OrganizationViewSkeletonProps & ComponentProps<"div">) {
  return (
    <div
      className={cn("flex min-w-0 items-center gap-2", className)}
      {...props}
    >
      <OrganizationLogo
        isPending
        className={size === "sm" ? "size-5" : undefined}
        size={size === "lg" ? "md" : "sm"}
      />

      <div className="flex flex-col min-w-0 gap-1">
        <Skeleton className="h-3.5 w-20 rounded-md" />

        {!hideSlug && <Skeleton className="h-3 w-28 rounded-md" />}
      </div>
    </div>
  )
}
examples/start-shadcn-example/src/components/auth/organization/organization-view.tsx
"use client"

import type { OrganizationAuthClient } from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin, useSession } from "@better-auth-ui/react"
import {
  useActiveOrganization,
  useListOrganizationMembers
} from "@better-auth-ui/react/plugins/organization"
import type { Organization } from "better-auth/client"
import type { ComponentProps } from "react"

import { Badge } from "@/components/ui/badge"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { cn } from "cn"
import {
  OrganizationLogo,
  type OrganizationLogoSize
} from "./organization-logo"
import { OrganizationViewSkeleton } from "./organization-view-skeleton"

export type OrganizationViewProps = {
  className?: string
  isPending?: boolean
  size?: OrganizationLogoSize
  hideRole?: boolean
  hideSlug?: boolean
  organization?: Partial<Organization>
}

/**
 * Compact organization row: logo, primary name, secondary slug — analogous to `UserView`.
 */
export function OrganizationView({
  className,
  isPending,
  size = "md",
  hideSlug: hideSlugProp,
  hideRole,
  organization,
  ...props
}: OrganizationViewProps & ComponentProps<"div">) {
  const { authClient } = useAuth<OrganizationAuthClient>()
  const {
    roles,
    slugPrefix,
    hideSlug: pluginHideSlug
  } = useAuthPlugin(organizationPlugin)
  const hideSlug = hideSlugProp ?? pluginHideSlug ?? false

  const { data: session } = useSession(authClient)

  const { data: activeOrganization, isPending: activeOrganizationPending } =
    useActiveOrganization(authClient, {
      enabled: !organization && !isPending
    })

  const resolvedOrganization = organization ?? activeOrganization

  const { data: membersList, isPending: membersPending } =
    useListOrganizationMembers(authClient, {
      query: {
        organizationId: resolvedOrganization?.id
      },
      enabled: !!resolvedOrganization?.id && !hideRole
    })

  const membership = membersList?.members?.find(
    (member) => member.userId === session?.user.id
  )

  if (
    isPending ||
    (!organization && activeOrganizationPending) ||
    (!hideRole && !!resolvedOrganization?.id && membersPending)
  ) {
    return (
      <OrganizationViewSkeleton
        className={className}
        hideSlug={hideSlug}
        size={size}
        {...props}
      />
    )
  }

  return (
    <div
      className={cn("flex min-w-0 items-center gap-2", className)}
      {...props}
    >
      <OrganizationLogo
        organization={resolvedOrganization}
        className={size === "sm" ? "size-5" : undefined}
        size={size === "lg" ? "md" : "sm"}
      />

      <div className="flex min-w-0 flex-col">
        <div className="flex min-w-0 items-center gap-2">
          <p className="truncate text-sm font-medium leading-tight text-foreground">
            {resolvedOrganization?.name}
          </p>

          {!hideRole && !!membership && (
            <Badge variant="secondary" className="-my-0.5 shrink-0">
              {roles?.[membership.role] ?? membership.role}
            </Badge>
          )}
        </div>

        {!hideSlug && !!resolvedOrganization?.slug && (
          <p className="truncate overflow-x-hidden text-muted-foreground text-xs font-mono leading-tight">
            {slugPrefix}
            {resolvedOrganization.slug}
          </p>
        )}
      </div>
    </div>
  )
}
examples/start-shadcn-example/src/components/auth/organization/organizations-empty.tsx
"use client"

import { useAuthPlugin } from "@better-auth-ui/react"
import { Briefcase } from "lucide-react"

import { Button } from "@/components/ui/button"
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle
} from "@/components/ui/empty"
import { organizationPlugin } from "@/lib/auth/organization-plugin"

export type OrganizationsEmptyProps = {
  onCreatePress: () => void
  canCreate?: boolean
}

export function OrganizationsEmpty({
  onCreatePress,
  canCreate = true
}: OrganizationsEmptyProps) {
  const { localization: organizationLocalization } =
    useAuthPlugin(organizationPlugin)

  return (
    <Empty>
      <EmptyHeader>
        <EmptyMedia variant="icon">
          <Briefcase />
        </EmptyMedia>
        <EmptyTitle>{organizationLocalization.noOrganizations}</EmptyTitle>
        <EmptyDescription>
          {organizationLocalization.organizationsDescription}
        </EmptyDescription>
      </EmptyHeader>
      <EmptyContent>
        <Button size="sm" disabled={!canCreate} onClick={onCreatePress}>
          {organizationLocalization.createOrganization}
        </Button>
      </EmptyContent>
    </Empty>
  )
}
examples/start-shadcn-example/src/components/auth/organization/organizations-settings.tsx
"use client"

import type { ComponentProps } from "react"

import { cn } from "cn"
import { Organizations } from "./organizations"
import { UserInvitations } from "./user-invitations"

export type OrganizationsSettingsProps = {
  className?: string
}

/**
 * Renders the organizations settings panel.
 *
 * Displays all organizations the user belongs to with an empty state and
 * create button, followed by a card for invitations to the user.
 */
export function OrganizationsSettings({
  className,
  ...props
}: OrganizationsSettingsProps & ComponentProps<"div">) {
  return (
    <div
      className={cn("flex w-full flex-col gap-4 md:gap-6", className)}
      {...props}
    >
      <Organizations />
      <UserInvitations />
    </div>
  )
}
examples/start-shadcn-example/src/components/auth/organization/organizations.tsx
"use client"

import type { OrganizationAuthClient } from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import { useListOrganizations } from "@better-auth-ui/react/plugins/organization"
import { Fragment, useState } from "react"

import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Item, ItemGroup, ItemSeparator } from "@/components/ui/item"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { CreateOrganizationDialog } from "./create-organization-dialog"
import { OrganizationRow } from "./organization-row"
import { OrganizationViewSkeleton } from "./organization-view-skeleton"
import { OrganizationsEmpty } from "./organizations-empty"

export type OrganizationsProps = {
  className?: string
}

/**
 * Lists organizations the user belongs to (via `useListOrganizations`): loading skeleton,
 * empty state with create, or a card of rows with a Manage control per organization.
 * Owns `CreateOrganizationDialog` open state and the create actions.
 */
export function Organizations({ className }: OrganizationsProps) {
  const { authClient } = useAuth<OrganizationAuthClient>()
  const {
    allowOrganizationCreation,
    localization: organizationLocalization,
    organizationLimit
  } = useAuthPlugin(organizationPlugin)

  const [createOpen, setCreateOpen] = useState(false)

  const { data: organizations, isPending: organizationsPending } =
    useListOrganizations(authClient)
  const canCreate =
    allowOrganizationCreation &&
    (organizationLimit === undefined ||
      (organizations?.length ?? 0) < organizationLimit)

  return (
    <>
      <div className={className}>
        <div className="flex flex-col gap-3">
          <div className="flex items-end justify-between gap-3">
            <h2 className="truncate text-sm font-semibold">
              {organizationLocalization.organizations}
            </h2>

            {allowOrganizationCreation && (
              <Button
                className="shrink-0"
                size="sm"
                disabled={organizationsPending || !canCreate}
                onClick={() => setCreateOpen(true)}
              >
                {organizationLocalization.createOrganization}
              </Button>
            )}
          </div>

          <Card className="p-0">
            <CardContent className="p-0">
              {organizationsPending ? (
                <ItemGroup>
                  <Item>
                    <OrganizationViewSkeleton />
                  </Item>
                </ItemGroup>
              ) : !organizations?.length ? (
                <OrganizationsEmpty
                  canCreate={canCreate}
                  onCreatePress={() => setCreateOpen(true)}
                />
              ) : (
                <ItemGroup className="gap-0">
                  {organizations.map((organization, index) => (
                    <Fragment key={organization.id}>
                      {index > 0 && <ItemSeparator />}
                      <OrganizationRow organization={organization} />
                    </Fragment>
                  ))}
                </ItemGroup>
              )}
            </CardContent>
          </Card>
        </div>
      </div>

      {canCreate && (
        <CreateOrganizationDialog
          open={createOpen}
          onOpenChange={setCreateOpen}
        />
      )}
    </>
  )
}
examples/start-shadcn-example/src/components/auth/organization/slug-field.tsx
"use client"

import type { OrganizationAuthClient } from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import { useCheckSlug } from "@better-auth-ui/react/plugins/organization"
import { useDebouncer } from "@tanstack/react-pacer"
import { Check, X } from "lucide-react"
import { useEffect, useState } from "react"

import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput
} from "@/components/ui/input-group"
import { Spinner } from "@/components/ui/spinner"
import { organizationPlugin } from "@/lib/auth/organization-plugin"

/** Props for the `SlugField` component. */
export type SlugFieldProps = {
  value: string
  onChange: (value: string) => void
  currentSlug?: string
  disabled?: boolean
  id?: string
}

/**
 * Sanitize a slug value so it only contains lowercase alphanumeric characters
 * and dashes. Runs of disallowed characters are collapsed to a single dash, but
 * leading/trailing dashes are preserved while the user is still typing.
 */
export function sanitizeSlug(value: string) {
  return value.toLowerCase().replace(/[^a-z0-9]+/g, "-")
}

/**
 * Organization slug field with debounced availability checking.
 */
export function SlugField({
  value,
  onChange,
  currentSlug,
  disabled,
  id = "slug"
}: SlugFieldProps) {
  const { authClient, localization: authLocalization } =
    useAuth<OrganizationAuthClient>()
  const {
    localization,
    checkSlug: checkSlugEnabled,
    slugPrefix
  } = useAuthPlugin(organizationPlugin)

  const [slugError, setSlugError] = useState<string>()

  const {
    mutate: checkSlug,
    data: checkSlugData,
    error: checkSlugError,
    reset: resetCheckSlug
  } = useCheckSlug(authClient)

  const debouncer = useDebouncer(
    (next: string) => {
      if (!checkSlugEnabled || !next.trim() || next.trim() === currentSlug)
        return

      checkSlug({ slug: next.trim() })
    },
    { wait: 500 }
  )

  useEffect(() => {
    // Clear stale validation errors when the controlled value changes
    // externally (e.g. the parent resets the form), not just via this
    // input's onChange.
    setSlugError(undefined)

    if (!checkSlugEnabled) return

    resetCheckSlug()
    debouncer.maybeExecute(value)
  }, [checkSlugEnabled, value, debouncer.maybeExecute, resetCheckSlug])

  return (
    <Field data-invalid={!!slugError}>
      <FieldLabel htmlFor={id}>{localization.slug}</FieldLabel>

      <InputGroup>
        {slugPrefix && (
          <InputGroupAddon align="inline-start">{slugPrefix}</InputGroupAddon>
        )}

        <InputGroupInput
          id={id}
          name="slug"
          value={value}
          onChange={(e) => {
            onChange(sanitizeSlug(e.target.value))
            setSlugError(undefined)
          }}
          onInvalid={(e) => {
            e.preventDefault()
            setSlugError(authLocalization.auth.fieldRequired)
          }}
          aria-invalid={!!slugError}
          placeholder={localization.slugPlaceholder}
          required
          disabled={disabled}
        />

        {checkSlugEnabled && !!value.trim() && value.trim() !== currentSlug && (
          <InputGroupAddon align="inline-end">
            {checkSlugData?.status ? (
              <Check className="size-4 text-foreground" />
            ) : checkSlugError ? (
              <X className="size-4 text-destructive" />
            ) : (
              <Spinner />
            )}
          </InputGroupAddon>
        )}
      </InputGroup>

      <FieldError>{slugError}</FieldError>
    </Field>
  )
}
examples/start-shadcn-example/src/components/auth/organization/user-invitation-row-skeleton.tsx
"use client"

import { Item, ItemContent, ItemMedia } from "@/components/ui/item"
import { Skeleton } from "@/components/ui/skeleton"

/**
 * Placeholder row matching `UserInvitationRow` while invitations load.
 */
export function UserInvitationRowSkeleton() {
  return (
    <Item>
      <ItemMedia>
        <Skeleton className="size-10 shrink-0 rounded-md" />
      </ItemMedia>
      <ItemContent>
        <Skeleton className="h-4 w-40 rounded-md" />
        <Skeleton className="h-3 w-28 rounded-md" />
      </ItemContent>
    </Item>
  )
}
examples/start-shadcn-example/src/components/auth/organization/user-invitation-row.tsx
"use client"

import type { OrganizationAuthClient } from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import {
  useAcceptInvitation,
  useRejectInvitation
} from "@better-auth-ui/react/plugins/organization"
import type { Invitation } from "better-auth/client"
import { Check, Clock, X } from "lucide-react"

import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemMedia,
  ItemTitle
} from "@/components/ui/item"
import { Spinner } from "@/components/ui/spinner"
import { organizationPlugin } from "@/lib/auth/organization-plugin"

export type UserInvitationRowProps = {
  invitation: Invitation & { organizationName?: string }
}

/**
 * Single invitation row with accept/reject actions for the current user.
 */
export function UserInvitationRow({ invitation }: UserInvitationRowProps) {
  const { authClient } = useAuth<OrganizationAuthClient>()
  const { localization: organizationLocalization, roles } =
    useAuthPlugin(organizationPlugin)

  const { mutate: acceptInvitation, isPending: isAccepting } =
    useAcceptInvitation(authClient)

  const { mutate: rejectInvitation, isPending: isRejecting } =
    useRejectInvitation(authClient)

  return (
    <Item>
      <ItemMedia variant="icon">
        <Clock />
      </ItemMedia>
      <ItemContent>
        <ItemTitle>
          {invitation.organizationName}
          <Badge variant="secondary">
            {roles?.[invitation.role] ?? invitation.role}
          </Badge>
        </ItemTitle>
        <ItemDescription>
          {new Date(invitation.createdAt).toLocaleString(undefined, {
            dateStyle: "medium",
            timeStyle: "short"
          })}
        </ItemDescription>
      </ItemContent>
      <ItemActions>
        <Button
          variant="outline"
          size="sm"
          disabled={isAccepting || isRejecting}
          onClick={() => acceptInvitation({ invitationId: invitation.id })}
        >
          {isAccepting ? <Spinner /> : <Check />}

          {organizationLocalization.accept}
        </Button>

        <Button
          variant="outline"
          size="icon"
          className="size-8 text-destructive"
          disabled={isAccepting || isRejecting}
          onClick={() => rejectInvitation({ invitationId: invitation.id })}
          aria-label={organizationLocalization.rejectInvitation}
        >
          {isRejecting ? <Spinner /> : <X />}
        </Button>
      </ItemActions>
    </Item>
  )
}
examples/start-shadcn-example/src/components/auth/organization/user-invitations-empty.tsx
"use client"

import { useAuthPlugin } from "@better-auth-ui/react"
import { MailWarning, Send } from "lucide-react"

import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle
} from "@/components/ui/empty"
import { organizationPlugin } from "@/lib/auth/organization-plugin"

/**
 * Empty state for `UserInvitations`.
 */
export function UserInvitationsEmpty({
  verificationRequired = false
}: {
  verificationRequired?: boolean
}) {
  const { localization: organizationLocalization } =
    useAuthPlugin(organizationPlugin)

  return (
    <Empty>
      <EmptyHeader>
        <EmptyMedia variant="icon">
          {verificationRequired ? <MailWarning /> : <Send />}
        </EmptyMedia>
        <EmptyTitle>
          {verificationRequired
            ? organizationLocalization.verifyEmailToViewInvitations
            : organizationLocalization.noInvitations}
        </EmptyTitle>
        <EmptyDescription>
          {verificationRequired
            ? organizationLocalization.verifyEmailToViewInvitationsDescription
            : organizationLocalization.userInvitationsEmptyDescription}
        </EmptyDescription>
      </EmptyHeader>
    </Empty>
  )
}
examples/start-shadcn-example/src/components/auth/organization/user-invitations.tsx
"use client"

import type { OrganizationAuthClient } from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin, useSession } from "@better-auth-ui/react"
import { useListUserInvitations } from "@better-auth-ui/react/plugins/organization"
import { Fragment } from "react"

import { Card, CardContent } from "@/components/ui/card"
import { ItemGroup, ItemSeparator } from "@/components/ui/item"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { UserInvitationRow } from "./user-invitation-row"
import { UserInvitationRowSkeleton } from "./user-invitation-row-skeleton"
import { UserInvitationsEmpty } from "./user-invitations-empty"

export type UserInvitationsProps = {
  className?: string
}

/**
 * Organization invitations for the signed-in user. Always renders the section
 * card; uses `UserInvitationsEmpty` when there are no pending invitations.
 */
export function UserInvitations({ className }: UserInvitationsProps) {
  const { authClient } = useAuth<OrganizationAuthClient>()
  const { localization: organizationLocalization } =
    useAuthPlugin(organizationPlugin)
  const session = useSession(authClient)
  const emailVerified = session.data?.user.emailVerified === true

  const { data: invitations, isPending } = useListUserInvitations(authClient, {
    enabled: emailVerified
  })

  return (
    <div className={className}>
      <div className="flex flex-col gap-3">
        <h2 className="truncate text-sm font-semibold">
          {organizationLocalization.invitations}
        </h2>

        <Card className="p-0">
          <CardContent className="p-0">
            {session.isPending || (emailVerified && isPending) ? (
              <ItemGroup>
                <UserInvitationRowSkeleton />
              </ItemGroup>
            ) : !invitations?.length ? (
              <UserInvitationsEmpty verificationRequired={!emailVerified} />
            ) : (
              <ItemGroup className="gap-0">
                {invitations.map((invitation, index) => (
                  <Fragment key={invitation.id}>
                    {index > 0 && <ItemSeparator />}
                    <UserInvitationRow invitation={invitation} />
                  </Fragment>
                ))}
              </ItemGroup>
            )}
          </CardContent>
        </Card>
      </div>
    </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/sso/email-first-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 {
  type SsoAuthClient,
  setSsoFallbackEmail
} from "@better-auth-ui/core/plugins/sso"
import {
  AuthPrompts,
  getAuthButtonKey,
  useAuth,
  useAuthPlugin,
  useFetchOptions,
  useSignInEmail
} from "@better-auth-ui/react"
import { useSignInSso } from "@better-auth-ui/react/plugins/sso"
import { useSelector } from "@tanstack/react-form"
import { useIsMutating } from "@tanstack/react-query"
import { Eye, EyeOff } from "lucide-react"
import { useState } from "react"

import { Button } from "@/components/ui/button"
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  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 { ssoPlugin } from "@/lib/auth/sso-plugin"
import { useSignInContinuation } from "@/lib/auth/use-sign-in-continuation"
import { cn } from "cn"
import { isAuthFormFieldInvalid, useAuthForm } from "../auth-form"
import { ProviderButtons } from "../provider-buttons"
import { ReauthenticationNotice } from "../reauthentication"

export type EmailFirstSignInProps = {
  className?: string
  socialLayout?: "auto" | "horizontal" | "vertical" | "grid"
  socialPosition?: "top" | "bottom"
}

/** Discover organization SSO by email, then expose configured fallback methods. */
export function EmailFirstSignIn({
  className,
  socialLayout,
  socialPosition = "bottom"
}: EmailFirstSignInProps) {
  const {
    authClient,
    basePaths,
    baseURL,
    emailAndPassword,
    localization,
    navigate,
    plugins,
    redirectTo,
    socialProviders,
    viewPaths,
    Link
  } = useAuth()
  const { localization: ssoLocalization } = useAuthPlugin(ssoPlugin)
  const { fetchOptions, resetFetchOptions } = useFetchOptions()
  const continueSignIn = useSignInContinuation()

  const [step, setStep] = useState<"email" | "fallback">("email")
  const [isPasswordVisible, setIsPasswordVisible] = useState(false)
  const [discoveryError, setDiscoveryError] = useState("")

  const { mutateAsync: signInSso, isPending: isDiscovering } = useSignInSso(
    authClient as SsoAuthClient,
    {
      onError: (error) => {
        if (error.status === 404) {
          setSsoFallbackEmail(form.state.values.email)
          setDiscoveryError(ssoLocalization.noProvider)
          setStep("fallback")
          return
        }

        setDiscoveryError(ssoLocalization.ssoUnavailable)
      }
    }
  )

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

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

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

  const signInMutating = useIsMutating({
    mutationKey: authMutationKeys.signIn.all
  })
  const isPending = signInMutating > 0
  const Captcha = plugins.find(
    (plugin) => plugin.captchaComponent
  )?.captchaComponent

  const passkeyAutoFill = isPasskeyAutoFillEnabled(plugins)
  const showSocialSeparator =
    emailAndPassword.enabled && !!socialProviders?.length

  const form = useAuthForm({
    defaultValues: { email: "", password: "", rememberMe: false },
    onSubmit: async ({ value }) => {
      if (step === "email") {
        setDiscoveryError("")
        setSsoFallbackEmail(value.email)
        try {
          await signInSso({
            callbackURL: `${baseURL}${redirectTo}`,
            email: value.email,
            loginHint: value.email
          })
        } catch (error) {
          if ((error as { status?: number }).status !== 404) throw error
        }
        return
      }
      await signInEmail({
        email: value.email,
        password: value.password,
        ...(emailAndPassword.rememberMe
          ? { rememberMe: value.rememberMe }
          : {}),
        fetchOptions
      })
    }
  })
  const email = useSelector(form.store, (state) => state.values.email)

  const startOver = () => {
    setStep("email")
    form.setFieldValue("password", "")
    setDiscoveryError("")
  }

  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>
        <CardDescription>
          {step === "email" ? ssoLocalization.emailFirstDescription : email}
        </CardDescription>
      </CardHeader>

      <CardContent>
        <form.AppForm>
          {step === "email" ? (
            <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="sso-email">
                          {localization.auth.email}
                        </FieldLabel>
                        <Input
                          id="sso-email"
                          name={field.name}
                          type="email"
                          autoComplete={withPasskeyAutoFill(
                            "email",
                            passkeyAutoFill
                          )}
                          autoFocus
                          value={field.state.value}
                          onBlur={field.handleBlur}
                          onChange={(event) =>
                            field.handleChange(event.target.value)
                          }
                          placeholder={localization.auth.emailPlaceholder}
                          required
                          disabled={isPending}
                          aria-invalid={isInvalid}
                        />
                        <field.AuthFormFieldError />
                      </Field>
                    )
                  }}
                </form.AppField>

                {discoveryError && (
                  <FieldDescription role="alert" className="text-destructive">
                    {discoveryError}
                  </FieldDescription>
                )}

                <form.AuthFormServerError />

                <form.AuthFormSubmitButton
                  isPending={isDiscovering}
                  disabled={isPending}
                >
                  {ssoLocalization.continueWithEmail}
                </form.AuthFormSubmitButton>
              </FieldGroup>
            </form.AuthFormRoot>
          ) : (
            <div className="flex flex-col gap-4">
              {socialPosition === "top" && (
                <>
                  {!!socialProviders?.length && (
                    <ProviderButtons
                      socialLayout={socialLayout}
                      view="signIn"
                    />
                  )}
                  {showSocialSeparator && (
                    <FieldSeparator>{localization.auth.or}</FieldSeparator>
                  )}
                </>
              )}

              {discoveryError && (
                <FieldDescription role="status">
                  {discoveryError}
                </FieldDescription>
              )}

              {emailAndPassword.enabled && (
                <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
                        )
                        return (
                          <Field data-invalid={isInvalid}>
                            <FieldLabel htmlFor="sso-password">
                              {localization.auth.password}
                            </FieldLabel>
                            <InputGroup>
                              <InputGroupInput
                                id="sso-password"
                                name={field.name}
                                type={isPasswordVisible ? "text" : "password"}
                                autoComplete={withPasskeyAutoFill(
                                  "current-password",
                                  passkeyAutoFill
                                )}
                                autoFocus
                                value={field.state.value}
                                onBlur={field.handleBlur}
                                onChange={(event) =>
                                  field.handleChange(event.target.value)
                                }
                                placeholder={
                                  localization.auth.passwordPlaceholder
                                }
                                minLength={emailAndPassword.minPasswordLength}
                                maxLength={emailAndPassword.maxPasswordLength}
                                required
                                disabled={isPending}
                                aria-invalid={isInvalid}
                              />
                              <InputGroupAddon align="inline-end">
                                <InputGroupButton
                                  size="icon-xs"
                                  aria-label={
                                    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>
                            <div className="flex items-center gap-3">
                              <Checkbox
                                id="sso-remember-me"
                                name={field.name}
                                checked={field.state.value}
                                disabled={isPending}
                                onCheckedChange={(checked) =>
                                  field.handleChange(checked === true)
                                }
                              />
                              <FieldLabel
                                htmlFor="sso-remember-me"
                                className="cursor-pointer text-sm font-normal"
                              >
                                {localization.auth.rememberMe}
                              </FieldLabel>
                            </div>
                          </Field>
                        )}
                      </form.AppField>
                    )}

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

                    <form.AuthFormServerError />

                    <form.AuthFormSubmitButton
                      isPending={isSigningIn}
                      disabled={isPending}
                    >
                      {localization.auth.signIn}
                    </form.AuthFormSubmitButton>
                  </FieldGroup>
                </form.AuthFormRoot>
              )}

              {plugins.flatMap((plugin) =>
                (plugin.authButtons ?? []).map((AuthButton) => (
                  <AuthButton
                    key={getAuthButtonKey(plugin.id, AuthButton)}
                    view="signIn"
                  />
                ))
              )}

              {socialPosition === "bottom" && (
                <>
                  {showSocialSeparator && (
                    <FieldSeparator>{localization.auth.or}</FieldSeparator>
                  )}
                  {!!socialProviders?.length && (
                    <ProviderButtons
                      socialLayout={socialLayout}
                      view="signIn"
                    />
                  )}
                </>
              )}

              <Button variant="ghost" onClick={startOver}>
                {ssoLocalization.useDifferentEmail}
              </Button>
            </div>
          )}
        </form.AppForm>
      </CardContent>

      {emailAndPassword.enabled && (
        <CardFooter className="flex-col gap-3">
          {step === "fallback" && emailAndPassword.forgotPassword && (
            <Link
              href={`${basePaths.auth}/${viewPaths.auth.forgotPassword}`}
              className="text-sm underline-offset-4 hover:underline"
            >
              {localization.auth.forgotPasswordLink}
            </Link>
          )}
          <FieldDescription className="text-center">
            {localization.auth.needToCreateAnAccount}{" "}
            <Link
              href={`${basePaths.auth}/${viewPaths.auth.signUp}`}
              className="underline underline-offset-4"
            >
              {localization.auth.signUp}
            </Link>
          </FieldDescription>
        </CardFooter>
      )}
    </Card>
  )
}
examples/start-shadcn-example/src/components/auth/sso/organization-sso-providers.tsx
"use client"

import { validateAbsoluteUrl, validateStringLength } from "@better-auth-ui/core"
import {
  hasMemberRole,
  type OrganizationAuthClient
} from "@better-auth-ui/core/plugins/organization"
import type {
  SsoAuthClient,
  SsoProvider,
  UpdateSsoProviderParams
} from "@better-auth-ui/core/plugins/sso"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import { useActiveMemberRole } from "@better-auth-ui/react/plugins/organization"
import {
  useDeleteSsoProvider,
  useSsoProviders,
  useUpdateSsoProvider
} from "@better-auth-ui/react/plugins/sso"
import type { BetterFetchError } from "better-auth/client"
import { PencilIcon, Trash2Icon } from "lucide-react"
import { useMemo, useState } from "react"
import { toast } from "sonner"
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle
} from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
  Card,
  CardAction,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle
} from "@/components/ui/card"
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle
} from "@/components/ui/dialog"
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyTitle
} from "@/components/ui/empty"
import {
  Field,
  FieldError,
  FieldGroup,
  FieldLabel
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { Textarea } from "@/components/ui/textarea"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { ssoPlugin } from "@/lib/auth/sso-plugin"
import { cn } from "cn"
import { isAuthFormFieldInvalid, useAuthForm } from "../auth-form"

import { SsoDomainVerification } from "./sso-domain-verification"
import { SsoProviderSetup } from "./sso-provider-setup"

export type OrganizationSsoProvidersProps = {
  className?: string
  organizationId: string
  organizationSlug: string
}

const providerSkeletonIds = ["sso-provider-1", "sso-provider-2"]

type SsoProviderEditorValues = {
  clientId: string
  clientSecret: string
  discoveryEndpoint: string
  domain: string
  entryPoint: string
  identityProviderMetadata: string
  issuer: string
}

const getSsoProviderEditorValues = (
  provider?: SsoProvider
): SsoProviderEditorValues => ({
  clientId: "",
  clientSecret: "",
  discoveryEndpoint: provider?.oidcConfig?.discoveryEndpoint ?? "",
  domain: provider?.domain ?? "",
  entryPoint: provider?.samlConfig?.entryPoint ?? "",
  identityProviderMetadata: "",
  issuer: provider?.issuer ?? ""
})

const getErrorMessage = (error: Error | null) => {
  const authError = error as BetterFetchError | null
  return authError?.error?.message ?? authError?.message
}

/** Manage the SSO providers that belong to one explicit organization. */
export function OrganizationSsoProviders({
  className,
  organizationId,
  organizationSlug: _organizationSlug
}: OrganizationSsoProvidersProps) {
  const { authClient } = useAuth()
  const { localization } = useAuthPlugin(ssoPlugin)
  const { creatorRole } = useAuthPlugin(organizationPlugin)
  const memberRole = useActiveMemberRole(authClient as OrganizationAuthClient, {
    query: { organizationId }
  })
  const canManage =
    hasMemberRole(memberRole.data?.role, creatorRole) ||
    hasMemberRole(memberRole.data?.role, "admin")
  const providersQuery = useSsoProviders(authClient as SsoAuthClient, {
    enabled: !memberRole.isPending && canManage
  })
  const [creating, setCreating] = useState(false)
  const [editing, setEditing] = useState<SsoProvider>()
  const [verifying, setVerifying] = useState<SsoProvider>()
  const [deleting, setDeleting] = useState<SsoProvider>()
  const providers = useMemo(
    () =>
      providersQuery.data?.providers.filter(
        (provider) => provider.organizationId === organizationId
      ) ?? [],
    [organizationId, providersQuery.data?.providers]
  )

  return (
    <Card className={cn(className)}>
      <CardHeader>
        <CardTitle>{localization.providerList}</CardTitle>
        <CardDescription>
          {localization.providerListDescription}
        </CardDescription>
        <CardAction>
          <Button
            disabled={memberRole.isPending || !canManage}
            onClick={() => setCreating((open) => !open)}
            size="sm"
          >
            {localization.addProvider}
          </Button>
        </CardAction>
      </CardHeader>
      <CardContent className="flex flex-col gap-4">
        {creating ? (
          <SsoProviderSetup
            className="max-w-none"
            organizationId={organizationId}
            onRegistered={() => setCreating(false)}
          />
        ) : null}

        {memberRole.isPending || (canManage && providersQuery.isPending) ? (
          <div className="flex flex-col gap-2">
            {providerSkeletonIds.map((id) => (
              <Skeleton className="h-24 w-full" key={id} />
            ))}
          </div>
        ) : !canManage ? (
          <div
            className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-sm"
            role="alert"
          >
            {localization.providerAccessDenied}
          </div>
        ) : providersQuery.error ? (
          <div
            className="flex items-center justify-between gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm"
            role="alert"
          >
            <span>{localization.providerLoadError}</span>
            <Button
              onClick={() => providersQuery.refetch()}
              size="sm"
              variant="outline"
            >
              {localization.retry}
            </Button>
          </div>
        ) : providers.length ? (
          <div className="flex flex-col gap-2">
            {providers.map((provider) => (
              <div
                className="flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between"
                key={provider.providerId}
              >
                <div className="flex min-w-0 flex-col gap-1">
                  <div className="flex flex-wrap items-center gap-2">
                    <span className="font-medium">{provider.providerId}</span>
                    <Badge variant="outline">
                      {provider.type.toUpperCase()}
                    </Badge>
                    <Badge
                      variant={
                        provider.domainVerified ? "secondary" : "outline"
                      }
                    >
                      {provider.domainVerified
                        ? localization.domainVerified
                        : localization.verifyDomain}
                    </Badge>
                  </div>
                  <span className="truncate text-sm text-muted-foreground">
                    {provider.domain} · {provider.issuer}
                  </span>
                </div>
                <div className="flex shrink-0 flex-wrap gap-2">
                  {!provider.domainVerified ? (
                    <Button
                      onClick={() => setVerifying(provider)}
                      size="sm"
                      variant="outline"
                    >
                      {localization.verifyDomain}
                    </Button>
                  ) : null}
                  <Button
                    aria-label={localization.editProvider}
                    onClick={() => setEditing(provider)}
                    size="icon-sm"
                    variant="ghost"
                  >
                    <PencilIcon />
                  </Button>
                  <Button
                    aria-label={localization.deleteProvider}
                    onClick={() => setDeleting(provider)}
                    size="icon-sm"
                    variant="destructive"
                  >
                    <Trash2Icon />
                  </Button>
                </div>
              </div>
            ))}
          </div>
        ) : (
          <Empty className="min-h-32 border">
            <EmptyHeader>
              <EmptyTitle>{localization.noProviders}</EmptyTitle>
              <EmptyDescription>
                {localization.noProvidersDescription}
              </EmptyDescription>
            </EmptyHeader>
          </Empty>
        )}
      </CardContent>

      <EditSsoProviderDialog
        key={editing?.providerId ?? "closed"}
        onOpenChange={setEditing}
        provider={editing}
      />
      <DeleteSsoProviderDialog onOpenChange={setDeleting} provider={deleting} />
      <Dialog
        open={Boolean(verifying)}
        onOpenChange={(open) => !open && setVerifying(undefined)}
      >
        <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
          <DialogHeader>
            <DialogTitle>{localization.domainVerification}</DialogTitle>
          </DialogHeader>
          {verifying ? (
            <SsoDomainVerification
              className="max-w-none shadow-none ring-0"
              defaultProviderId={verifying.providerId}
            />
          ) : null}
        </DialogContent>
      </Dialog>
    </Card>
  )
}

function EditSsoProviderDialog({
  onOpenChange,
  provider
}: {
  onOpenChange: (provider: SsoProvider | undefined) => void
  provider?: SsoProvider
}) {
  const { authClient, localization: authLocalization } = useAuth()
  const { localization } = useAuthPlugin(ssoPlugin)
  const update = useUpdateSsoProvider(authClient as SsoAuthClient)

  const close = () => {
    update.reset()
    onOpenChange(undefined)
  }
  const form = useAuthForm({
    defaultValues: getSsoProviderEditorValues(provider),
    onSubmit: async ({ value }) => {
      if (!provider) return
      const clientId = value.clientId.trim()
      const clientSecret = value.clientSecret.trim()
      const identityProviderMetadata = value.identityProviderMetadata.trim()
      const params = {
        providerId: provider.providerId,
        issuer: value.issuer.trim(),
        domain: value.domain.trim(),
        ...(provider.oidcConfig
          ? {
              oidcConfig: {
                ...(clientId ? { clientId } : {}),
                ...(clientSecret ? { clientSecret } : {}),
                discoveryEndpoint: value.discoveryEndpoint.trim() || undefined
              }
            }
          : {}),
        ...(provider.samlConfig
          ? {
              samlConfig: {
                entryPoint: value.entryPoint.trim(),
                ...(identityProviderMetadata
                  ? { idpMetadata: { metadata: identityProviderMetadata } }
                  : {})
              }
            }
          : {})
      } as UpdateSsoProviderParams

      try {
        await update.mutateAsync(params)
        toast.success(localization.providerUpdated)
        close()
      } catch {
        // The mutation error is rendered below the fields.
      }
    }
  })

  return (
    <Dialog
      open={Boolean(provider)}
      onOpenChange={(open) => {
        if (!open && !update.isPending) close()
      }}
    >
      <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
        <form.AppForm>
          <form.AuthFormRoot className="flex flex-col gap-4">
            <DialogHeader>
              <DialogTitle>{localization.editProvider}</DialogTitle>
              <DialogDescription>{provider?.providerId}</DialogDescription>
            </DialogHeader>
            <FieldGroup>
              <form.AppField
                name="domain"
                validators={{
                  onChange: ({ value }) =>
                    validateStringLength(value, {
                      requiredMessage: authLocalization.auth.fieldRequired,
                      trim: true
                    })
                }}
              >
                {(field) => (
                  <Field
                    data-invalid={isAuthFormFieldInvalid(field.state.meta)}
                  >
                    <FieldLabel htmlFor="sso-edit-domain">
                      {localization.domain}
                    </FieldLabel>
                    <Input
                      id="sso-edit-domain"
                      name={field.name}
                      onBlur={field.handleBlur}
                      onChange={(event) =>
                        field.handleChange(event.target.value)
                      }
                      required
                      value={field.state.value}
                      aria-invalid={isAuthFormFieldInvalid(field.state.meta)}
                    />
                    <field.AuthFormFieldError />
                  </Field>
                )}
              </form.AppField>
              <form.AppField
                name="issuer"
                validators={{
                  onChange: ({ value }) =>
                    validateAbsoluteUrl(value, {
                      invalidMessage: localization.invalidUrl,
                      requiredMessage: authLocalization.auth.fieldRequired
                    })
                }}
              >
                {(field) => (
                  <Field
                    data-invalid={isAuthFormFieldInvalid(field.state.meta)}
                  >
                    <FieldLabel htmlFor="sso-edit-issuer">
                      {localization.issuer}
                    </FieldLabel>
                    <Input
                      id="sso-edit-issuer"
                      name={field.name}
                      onBlur={field.handleBlur}
                      onChange={(event) =>
                        field.handleChange(event.target.value)
                      }
                      required
                      type="url"
                      value={field.state.value}
                      aria-invalid={isAuthFormFieldInvalid(field.state.meta)}
                    />
                    <field.AuthFormFieldError />
                  </Field>
                )}
              </form.AppField>
              {provider?.oidcConfig ? (
                <>
                  <form.AppField
                    name="discoveryEndpoint"
                    validators={{
                      onChange: ({ value }) =>
                        validateAbsoluteUrl(value, {
                          invalidMessage: localization.invalidUrl
                        })
                    }}
                  >
                    {(field) => (
                      <Field
                        data-invalid={isAuthFormFieldInvalid(field.state.meta)}
                      >
                        <FieldLabel htmlFor="sso-edit-discovery">
                          {localization.discoveryEndpoint}
                        </FieldLabel>
                        <Input
                          id="sso-edit-discovery"
                          name={field.name}
                          onBlur={field.handleBlur}
                          onChange={(event) =>
                            field.handleChange(event.target.value)
                          }
                          type="url"
                          value={field.state.value}
                          aria-invalid={isAuthFormFieldInvalid(
                            field.state.meta
                          )}
                        />
                        <field.AuthFormFieldError />
                      </Field>
                    )}
                  </form.AppField>
                  <div className="grid gap-4 sm:grid-cols-2">
                    {(
                      [
                        [
                          "clientId",
                          "sso-edit-client-id",
                          localization.clientId
                        ],
                        [
                          "clientSecret",
                          "sso-edit-client-secret",
                          localization.clientSecret
                        ]
                      ] as const
                    ).map(([name, id, label]) => (
                      <form.Field key={name} name={name}>
                        {(field) => (
                          <Field>
                            <FieldLabel htmlFor={id}>{label}</FieldLabel>
                            <Input
                              autoComplete={
                                name === "clientSecret"
                                  ? "new-password"
                                  : undefined
                              }
                              id={id}
                              name={field.name}
                              onBlur={field.handleBlur}
                              onChange={(event) =>
                                field.handleChange(event.target.value)
                              }
                              placeholder={
                                name === "clientId"
                                  ? `••••${provider.oidcConfig?.clientIdLastFour}`
                                  : undefined
                              }
                              type={
                                name === "clientSecret" ? "password" : "text"
                              }
                              value={field.state.value}
                            />
                          </Field>
                        )}
                      </form.Field>
                    ))}
                  </div>
                </>
              ) : null}
              {provider?.samlConfig ? (
                <>
                  <form.AppField
                    name="entryPoint"
                    validators={{
                      onChange: ({ value }) =>
                        validateAbsoluteUrl(value, {
                          invalidMessage: localization.invalidUrl,
                          requiredMessage: authLocalization.auth.fieldRequired
                        })
                    }}
                  >
                    {(field) => (
                      <Field
                        data-invalid={isAuthFormFieldInvalid(field.state.meta)}
                      >
                        <FieldLabel htmlFor="sso-edit-entry-point">
                          {localization.entryPoint}
                        </FieldLabel>
                        <Input
                          id="sso-edit-entry-point"
                          name={field.name}
                          onBlur={field.handleBlur}
                          onChange={(event) =>
                            field.handleChange(event.target.value)
                          }
                          required
                          type="url"
                          value={field.state.value}
                          aria-invalid={isAuthFormFieldInvalid(
                            field.state.meta
                          )}
                        />
                        <field.AuthFormFieldError />
                      </Field>
                    )}
                  </form.AppField>
                  <form.Field name="identityProviderMetadata">
                    {(field) => (
                      <Field>
                        <FieldLabel htmlFor="sso-edit-metadata">
                          {localization.identityProviderMetadata}
                        </FieldLabel>
                        <Textarea
                          className="font-mono text-xs"
                          id="sso-edit-metadata"
                          name={field.name}
                          onBlur={field.handleBlur}
                          onChange={(event) =>
                            field.handleChange(event.target.value)
                          }
                          rows={6}
                          value={field.state.value}
                        />
                      </Field>
                    )}
                  </form.Field>
                </>
              ) : null}
            </FieldGroup>
            <FieldError>{getErrorMessage(update.error)}</FieldError>
            <DialogFooter>
              <Button
                disabled={update.isPending}
                onClick={close}
                type="button"
                variant="outline"
              >
                {localization.cancel}
              </Button>
              <form.AuthFormSubmitButton disabled={update.isPending}>
                {localization.saveProvider}
              </form.AuthFormSubmitButton>
            </DialogFooter>
          </form.AuthFormRoot>
        </form.AppForm>
      </DialogContent>
    </Dialog>
  )
}

function DeleteSsoProviderDialog({
  onOpenChange,
  provider
}: {
  onOpenChange: (provider: SsoProvider | undefined) => void
  provider?: SsoProvider
}) {
  const { authClient } = useAuth()
  const { localization } = useAuthPlugin(ssoPlugin)
  const remove = useDeleteSsoProvider(authClient as SsoAuthClient)
  const close = () => {
    remove.reset()
    onOpenChange(undefined)
  }

  return (
    <AlertDialog
      open={Boolean(provider)}
      onOpenChange={(open) => {
        if (!open && !remove.isPending) close()
      }}
    >
      <AlertDialogContent>
        <AlertDialogHeader>
          <AlertDialogTitle>{localization.deleteProvider}</AlertDialogTitle>
          <AlertDialogDescription>
            {localization.deleteProviderDescription}
          </AlertDialogDescription>
        </AlertDialogHeader>
        <p className="font-mono text-xs">{provider?.providerId}</p>
        <FieldError>{getErrorMessage(remove.error)}</FieldError>
        <AlertDialogFooter>
          <AlertDialogCancel disabled={remove.isPending}>
            {localization.cancel}
          </AlertDialogCancel>
          <AlertDialogAction
            disabled={remove.isPending}
            onClick={(event) => {
              event.preventDefault()
              if (!provider) return
              remove.mutate(
                { providerId: provider.providerId },
                {
                  onSuccess: () => {
                    toast.success(localization.providerDeleted)
                    close()
                  }
                }
              )
            }}
            variant="destructive"
          >
            {localization.deleteProvider}
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  )
}
examples/start-shadcn-example/src/components/auth/sso/sso-domain-verification.tsx
"use client"

import type { SsoAuthClient } from "@better-auth-ui/core/plugins/sso"
import {
  useAuth,
  useAuthPlugin,
  useCopyToClipboard
} from "@better-auth-ui/react"
import {
  useRequestSsoDomainVerification,
  useVerifySsoDomain
} from "@better-auth-ui/react/plugins/sso"
import type { BetterFetchError } from "better-auth/client"
import { CheckIcon, CopyIcon } from "lucide-react"
import { useState } from "react"

import { Button } from "@/components/ui/button"
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle
} from "@/components/ui/card"
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput
} from "@/components/ui/input-group"
import { Spinner } from "@/components/ui/spinner"
import { ssoPlugin } from "@/lib/auth/sso-plugin"
import { cn } from "cn"
import { useAuthForm } from "../auth-form"

export type SsoDomainVerificationProps = {
  className?: string
  defaultProviderId?: string
  defaultToken?: string
  tokenPrefix?: string
}

const getErrorMessage = (error: Error | null) => {
  const authError = error as BetterFetchError | null
  return authError?.error?.message ?? authError?.message
}

/** DNS token renewal and verification for an existing SSO provider. */
export function SsoDomainVerification({
  className,
  defaultProviderId = "",
  defaultToken = "",
  tokenPrefix = "better-auth-token"
}: SsoDomainVerificationProps) {
  const { authClient, localization: authLocalization } = useAuth()
  const { localization } = useAuthPlugin(ssoPlugin)
  const [token, setToken] = useState(defaultToken)
  const [verified, setVerified] = useState(false)
  const [copyError, setCopyError] = useState("")
  const requestToken = useRequestSsoDomainVerification(
    authClient as SsoAuthClient,
    {
      onSuccess: (data) => setToken(data.domainVerificationToken)
    }
  )
  const verify = useVerifySsoDomain(authClient as SsoAuthClient, {
    onSuccess: () => setVerified(true)
  })
  const hostCopy = useCopyToClipboard({
    onError: (error) => {
      console.error("[Better Auth UI] Copy failed", error)
      setCopyError(authLocalization.errors.copyFailed)
    }
  })
  const tokenCopy = useCopyToClipboard({
    onError: (error) => {
      console.error("[Better Auth UI] Copy failed", error)
      setCopyError(authLocalization.errors.copyFailed)
    }
  })
  const error =
    requestToken.submittedAt > verify.submittedAt
      ? requestToken.error
      : verify.error

  const form = useAuthForm({
    defaultValues: { providerId: defaultProviderId },
    onSubmit: async ({ value }) => {
      setCopyError("")
      setVerified(false)
      await verify.mutateAsync({ providerId: value.providerId })
    }
  })

  return (
    <Card className={cn("w-full max-w-xl", className)}>
      <CardHeader>
        <CardTitle>{localization.domainVerification}</CardTitle>
        <CardDescription>
          {localization.domainVerificationDescription}
        </CardDescription>
      </CardHeader>
      <CardContent>
        <form.AppForm>
          <form.AuthFormRoot>
            <form.Subscribe selector={(state) => state.values.providerId}>
              {(providerId) => {
                const host = providerId ? `_${tokenPrefix}-${providerId}` : ""
                return (
                  <FieldGroup>
                    <form.AppField name="providerId">
                      {(field) => (
                        <Field>
                          <FieldLabel htmlFor="sso-verification-provider-id">
                            {localization.providerId}
                          </FieldLabel>
                          <Input
                            id="sso-verification-provider-id"
                            name={field.name}
                            value={field.state.value}
                            onBlur={field.handleBlur}
                            onChange={(event) => {
                              field.handleChange(event.target.value.trim())
                              setToken("")
                              setVerified(false)
                            }}
                            required
                          />
                        </Field>
                      )}
                    </form.AppField>

                    {token ? (
                      <div className="grid gap-3 rounded-lg border p-3">
                        <Field>
                          <FieldLabel htmlFor="sso-dns-host">
                            {localization.txtRecordHost}
                          </FieldLabel>
                          <InputGroup>
                            <InputGroupInput
                              className="font-mono text-xs"
                              id="sso-dns-host"
                              readOnly
                              value={host}
                            />
                            <InputGroupAddon align="inline-end">
                              <InputGroupButton
                                aria-label={localization.copyDnsHost}
                                size="icon-xs"
                                onClick={() => {
                                  setCopyError("")
                                  void hostCopy.copy(host)
                                }}
                              >
                                {hostCopy.copied ? <CheckIcon /> : <CopyIcon />}
                              </InputGroupButton>
                            </InputGroupAddon>
                          </InputGroup>
                        </Field>
                        <Field>
                          <FieldLabel htmlFor="sso-dns-value">
                            {localization.txtRecordValue}
                          </FieldLabel>
                          <InputGroup>
                            <InputGroupInput
                              className="font-mono text-xs"
                              id="sso-dns-value"
                              readOnly
                              value={token}
                            />
                            <InputGroupAddon align="inline-end">
                              <InputGroupButton
                                aria-label={localization.copyDnsValue}
                                size="icon-xs"
                                onClick={() => {
                                  setCopyError("")
                                  void tokenCopy.copy(token)
                                }}
                              >
                                {tokenCopy.copied ? (
                                  <CheckIcon />
                                ) : (
                                  <CopyIcon />
                                )}
                              </InputGroupButton>
                            </InputGroupAddon>
                          </InputGroup>
                        </Field>
                      </div>
                    ) : null}

                    {error ? (
                      <FieldError>{getErrorMessage(error)}</FieldError>
                    ) : null}
                    {copyError ? <FieldError>{copyError}</FieldError> : null}
                    {verified ? (
                      <FieldDescription
                        role="status"
                        className="text-foreground"
                      >
                        {localization.domainVerified}
                      </FieldDescription>
                    ) : null}

                    <div className="flex flex-wrap justify-end gap-2">
                      <Button
                        type="button"
                        variant="outline"
                        disabled={!providerId || verify.isPending}
                        onClick={() => {
                          setCopyError("")
                          setVerified(false)
                          requestToken.mutate({ providerId })
                        }}
                      >
                        {requestToken.isPending ? (
                          <Spinner data-icon="inline-start" />
                        ) : null}
                        {localization.requestNewToken}
                      </Button>
                      <form.AuthFormSubmitButton
                        isPending={verify.isPending}
                        disabled={!providerId || requestToken.isPending}
                      >
                        {localization.verifyDomain}
                      </form.AuthFormSubmitButton>
                    </div>

                    <span className="sr-only" aria-live="polite">
                      {token && !verified
                        ? localization.domainVerificationRequested
                        : ""}
                    </span>
                  </FieldGroup>
                )
              }}
            </form.Subscribe>
          </form.AuthFormRoot>
        </form.AppForm>
      </CardContent>
    </Card>
  )
}
examples/start-shadcn-example/src/components/auth/sso/sso-provider-setup.tsx
"use client"

import { validateAbsoluteUrl, validateStringLength } from "@better-auth-ui/core"
import type {
  RegisterSsoProviderData,
  RegisterSsoProviderParams,
  SsoAuthClient
} from "@better-auth-ui/core/plugins/sso"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import { useRegisterSsoProvider } from "@better-auth-ui/react/plugins/sso"
import type { BetterFetchError } from "better-auth/client"
import { FileCode2, KeyRound } from "lucide-react"
import { useState } from "react"
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle
} from "@/components/ui/card"
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Textarea } from "@/components/ui/textarea"
import { ssoPlugin } from "@/lib/auth/sso-plugin"
import { cn } from "cn"
import { isAuthFormFieldInvalid, useAuthForm } from "../auth-form"

type SsoProtocol = "oidc" | "saml"

type SsoProviderFormValues = {
  clientId: string
  clientSecret: string
  domain: string
  entryPoint: string
  identityProviderMetadata: string
  issuer: string
  organizationId: string
  protocol: SsoProtocol
  providerId: string
}

export type SsoProviderSetupProps = {
  className?: string
  defaultOrganizationId?: string
  organizationId?: string
  onRegistered?: (provider: RegisterSsoProviderData) => void
}

const getErrorMessage = (error: Error | null) => {
  const authError = error as BetterFetchError | null
  return authError?.error?.message ?? authError?.message
}

/** Self-service form for registering an OIDC or SAML SSO provider. */
export function SsoProviderSetup({
  className,
  defaultOrganizationId,
  organizationId: fixedOrganizationId,
  onRegistered
}: SsoProviderSetupProps) {
  const { authClient, localization: authLocalization } = useAuth()
  const { localization } = useAuthPlugin(ssoPlugin)
  const [created, setCreated] = useState(false)
  const register = useRegisterSsoProvider(authClient as SsoAuthClient)
  const form = useAuthForm({
    defaultValues: {
      clientId: "",
      clientSecret: "",
      domain: "",
      entryPoint: "",
      identityProviderMetadata: "",
      issuer: "",
      organizationId: defaultOrganizationId ?? "",
      protocol: "oidc" as SsoProtocol,
      providerId: ""
    } satisfies SsoProviderFormValues,
    onSubmit: async ({ value }) => {
      setCreated(false)
      const common = {
        providerId: value.providerId.trim(),
        issuer: value.issuer.trim(),
        domain: value.domain.trim(),
        organizationId:
          fixedOrganizationId || value.organizationId.trim() || undefined
      }
      const params =
        value.protocol === "oidc"
          ? {
              ...common,
              oidcConfig: {
                clientId: value.clientId.trim(),
                clientSecret: value.clientSecret.trim()
              }
            }
          : {
              ...common,
              samlConfig: {
                entryPoint: value.entryPoint.trim(),
                idpMetadata: {
                  metadata: value.identityProviderMetadata.trim()
                }
              }
            }

      try {
        const provider = await register.mutateAsync(
          params as RegisterSsoProviderParams<SsoAuthClient>
        )
        setCreated(true)
        onRegistered?.(provider)
      } catch {
        // The mutation exposes its error below the fields.
      }
    }
  })

  return (
    <form.AppForm>
      <form.AuthFormRoot className={cn("w-full max-w-xl", className)}>
        <Card>
          <CardHeader>
            <CardTitle>{localization.providerSetup}</CardTitle>
            <CardDescription>
              {localization.providerSetupDescription}
            </CardDescription>
          </CardHeader>
          <CardContent>
            <FieldGroup>
              <div className="grid gap-4 sm:grid-cols-2">
                <form.AppField
                  name="providerId"
                  validators={{
                    onChange: ({ value }) =>
                      validateStringLength(value, {
                        requiredMessage: authLocalization.auth.fieldRequired,
                        trim: true
                      })
                  }}
                >
                  {(field) => (
                    <Field
                      data-invalid={isAuthFormFieldInvalid(field.state.meta)}
                    >
                      <FieldLabel htmlFor="sso-provider-id">
                        {localization.providerId}
                      </FieldLabel>
                      <Input
                        id="sso-provider-id"
                        name={field.name}
                        onBlur={field.handleBlur}
                        onChange={(event) =>
                          field.handleChange(event.target.value)
                        }
                        required
                        value={field.state.value}
                        aria-invalid={isAuthFormFieldInvalid(field.state.meta)}
                      />
                      <field.AuthFormFieldError />
                    </Field>
                  )}
                </form.AppField>
                <form.AppField
                  name="domain"
                  validators={{
                    onChange: ({ value }) =>
                      validateStringLength(value, {
                        requiredMessage: authLocalization.auth.fieldRequired,
                        trim: true
                      })
                  }}
                >
                  {(field) => (
                    <Field
                      data-invalid={isAuthFormFieldInvalid(field.state.meta)}
                    >
                      <FieldLabel htmlFor="sso-domain">
                        {localization.domain}
                      </FieldLabel>
                      <Input
                        id="sso-domain"
                        name={field.name}
                        onBlur={field.handleBlur}
                        onChange={(event) =>
                          field.handleChange(event.target.value)
                        }
                        placeholder="example.com"
                        required
                        value={field.state.value}
                        aria-invalid={isAuthFormFieldInvalid(field.state.meta)}
                      />
                      <field.AuthFormFieldError />
                    </Field>
                  )}
                </form.AppField>
              </div>

              <form.AppField
                name="issuer"
                validators={{
                  onChange: ({ value }) =>
                    validateAbsoluteUrl(value, {
                      invalidMessage: localization.invalidUrl,
                      requiredMessage: authLocalization.auth.fieldRequired
                    })
                }}
              >
                {(field) => (
                  <Field
                    data-invalid={isAuthFormFieldInvalid(field.state.meta)}
                  >
                    <FieldLabel htmlFor="sso-issuer">
                      {localization.issuer}
                    </FieldLabel>
                    <Input
                      id="sso-issuer"
                      name={field.name}
                      onBlur={field.handleBlur}
                      onChange={(event) =>
                        field.handleChange(event.target.value)
                      }
                      placeholder="https://idp.example.com"
                      required
                      type="url"
                      value={field.state.value}
                      aria-invalid={isAuthFormFieldInvalid(field.state.meta)}
                    />
                    <field.AuthFormFieldError />
                  </Field>
                )}
              </form.AppField>

              {!fixedOrganizationId ? (
                <form.Field name="organizationId">
                  {(field) => (
                    <Field>
                      <FieldLabel htmlFor="sso-organization-id">
                        {localization.organizationId}
                      </FieldLabel>
                      <Input
                        id="sso-organization-id"
                        name={field.name}
                        onBlur={field.handleBlur}
                        onChange={(event) =>
                          field.handleChange(event.target.value)
                        }
                        value={field.state.value}
                      />
                    </Field>
                  )}
                </form.Field>
              ) : null}

              <form.Field name="protocol">
                {(protocolField) => (
                  <Tabs
                    value={protocolField.state.value}
                    onValueChange={(value) =>
                      protocolField.handleChange(value as SsoProtocol)
                    }
                  >
                    <TabsList aria-label={localization.providerSetup}>
                      <TabsTrigger value="oidc">
                        <KeyRound
                          aria-hidden="true"
                          className="text-muted-foreground"
                        />
                        {localization.oidc}
                      </TabsTrigger>
                      <TabsTrigger value="saml">
                        <FileCode2
                          aria-hidden="true"
                          className="text-muted-foreground"
                        />
                        {localization.saml}
                      </TabsTrigger>
                    </TabsList>
                    <TabsContent
                      className="grid gap-4 sm:grid-cols-2"
                      value="oidc"
                    >
                      <form.AppField
                        name="clientId"
                        validators={{
                          onChange: ({ value }) =>
                            validateStringLength(value, {
                              requiredMessage:
                                authLocalization.auth.fieldRequired,
                              trim: true
                            })
                        }}
                      >
                        {(field) => (
                          <Field
                            data-invalid={isAuthFormFieldInvalid(
                              field.state.meta
                            )}
                          >
                            <FieldLabel htmlFor="sso-client-id">
                              {localization.clientId}
                            </FieldLabel>
                            <Input
                              autoComplete="off"
                              id="sso-client-id"
                              name={field.name}
                              onBlur={field.handleBlur}
                              onChange={(event) =>
                                field.handleChange(event.target.value)
                              }
                              required
                              value={field.state.value}
                              aria-invalid={isAuthFormFieldInvalid(
                                field.state.meta
                              )}
                            />
                            <field.AuthFormFieldError />
                          </Field>
                        )}
                      </form.AppField>
                      <form.AppField
                        name="clientSecret"
                        validators={{
                          onChange: ({ value }) =>
                            validateStringLength(value, {
                              requiredMessage:
                                authLocalization.auth.fieldRequired,
                              trim: true
                            })
                        }}
                      >
                        {(field) => (
                          <Field
                            data-invalid={isAuthFormFieldInvalid(
                              field.state.meta
                            )}
                          >
                            <FieldLabel htmlFor="sso-client-secret">
                              {localization.clientSecret}
                            </FieldLabel>
                            <Input
                              autoComplete="new-password"
                              id="sso-client-secret"
                              name={field.name}
                              onBlur={field.handleBlur}
                              onChange={(event) =>
                                field.handleChange(event.target.value)
                              }
                              required
                              type="password"
                              value={field.state.value}
                              aria-invalid={isAuthFormFieldInvalid(
                                field.state.meta
                              )}
                            />
                            <field.AuthFormFieldError />
                          </Field>
                        )}
                      </form.AppField>
                    </TabsContent>
                    <TabsContent className="flex flex-col gap-4" value="saml">
                      <form.AppField
                        name="entryPoint"
                        validators={{
                          onChange: ({ value }) =>
                            validateAbsoluteUrl(value, {
                              invalidMessage: localization.invalidUrl,
                              requiredMessage:
                                authLocalization.auth.fieldRequired
                            })
                        }}
                      >
                        {(field) => (
                          <Field
                            data-invalid={isAuthFormFieldInvalid(
                              field.state.meta
                            )}
                          >
                            <FieldLabel htmlFor="sso-entry-point">
                              {localization.entryPoint}
                            </FieldLabel>
                            <Input
                              id="sso-entry-point"
                              name={field.name}
                              onBlur={field.handleBlur}
                              onChange={(event) =>
                                field.handleChange(event.target.value)
                              }
                              required
                              type="url"
                              value={field.state.value}
                              aria-invalid={isAuthFormFieldInvalid(
                                field.state.meta
                              )}
                            />
                            <field.AuthFormFieldError />
                          </Field>
                        )}
                      </form.AppField>
                      <form.AppField
                        name="identityProviderMetadata"
                        validators={{
                          onChange: ({ value }) =>
                            validateStringLength(value, {
                              requiredMessage:
                                authLocalization.auth.fieldRequired,
                              trim: true
                            })
                        }}
                      >
                        {(field) => (
                          <Field
                            data-invalid={isAuthFormFieldInvalid(
                              field.state.meta
                            )}
                          >
                            <FieldLabel htmlFor="sso-idp-metadata">
                              {localization.identityProviderMetadata}
                            </FieldLabel>
                            <Textarea
                              className="min-h-40 font-mono text-xs"
                              id="sso-idp-metadata"
                              name={field.name}
                              onBlur={field.handleBlur}
                              onChange={(event) =>
                                field.handleChange(event.target.value)
                              }
                              required
                              value={field.state.value}
                              aria-invalid={isAuthFormFieldInvalid(
                                field.state.meta
                              )}
                            />
                            <field.AuthFormFieldError />
                          </Field>
                        )}
                      </form.AppField>
                    </TabsContent>
                  </Tabs>
                )}
              </form.Field>

              {register.error ? (
                <FieldError>{getErrorMessage(register.error)}</FieldError>
              ) : null}
              {created ? (
                <FieldDescription role="status" className="text-foreground">
                  {localization.providerCreated}
                </FieldDescription>
              ) : null}
            </FieldGroup>
          </CardContent>
          <CardFooter className="justify-end">
            <form.AuthFormSubmitButton disabled={register.isPending}>
              {localization.addProvider}
            </form.AuthFormSubmitButton>
          </CardFooter>
        </Card>
      </form.AuthFormRoot>
    </form.AppForm>
  )
}
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/user/user-avatar.tsx
"use client"

import type { UsernameAuthClient } from "@better-auth-ui/core/plugins/username"
import { useAuth, useSession } from "@better-auth-ui/react"
import type { User } from "better-auth"
import { User2 } from "lucide-react"
import type { ReactNode } from "react"

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "cn"

export type UserAvatarProps = {
  className?: string
  fallback?: ReactNode
  isPending?: boolean
  /** @remarks `User` */
  user?: User & { username?: string | null; displayUsername?: string | null }
}

/**
 * Display a user's avatar using session information or an explicit user prop.
 *
 * Renders a circular avatar that shows the user's image when available, a fallback node if provided, or the user's first two initials; while the session is loading (or when `isPending` is true) and no `user` prop is supplied, renders a skeleton placeholder.
 *
 * @param className - Additional CSS classes applied to the avatar container
 * @param user - Optional user object to display instead of the session user
 * @param isPending - When true, treat the component as loading and show the skeleton if no `user` is provided
 * @param fallback - Node to render inside the avatar fallback area before initials or the default icon
 * @returns The avatar element to render (JSX)
 */
export function UserAvatar({
  className,
  user,
  isPending,
  fallback
}: UserAvatarProps) {
  const { authClient } = useAuth<UsernameAuthClient>()
  const { data: session, isPending: sessionPending } = useSession(authClient, {
    enabled: !user && !isPending
  })

  if ((isPending || sessionPending) && !user) {
    return <Skeleton className={cn("size-8 rounded-full", className)} />
  }

  const resolvedUser = user ?? session?.user

  const initials = (
    resolvedUser?.username ||
    resolvedUser?.name ||
    resolvedUser?.email
  )
    ?.slice(0, 2)
    .toUpperCase()

  return (
    <Avatar
      className={cn(
        "size-8 bg-muted text-foreground text-sm rounded-full",
        className
      )}
    >
      <AvatarImage
        src={resolvedUser?.image ?? undefined}
        alt={
          resolvedUser?.displayUsername ||
          resolvedUser?.name ||
          resolvedUser?.email
        }
      />

      <AvatarFallback className="text-muted-foreground!">
        {fallback || initials || <User2 className="size-4" />}
      </AvatarFallback>
    </Avatar>
  )
}
examples/start-shadcn-example/src/lib/auth/auth-plugin.ts
import type {
  AuthPluginComponents,
  AuthPlugin as AuthPluginPrimitive
} from "@better-auth-ui/react"

declare module "@better-auth-ui/core" {
  /** Widens `useAuth().plugins` to the shadcn-typed `AuthPlugin`. */
  interface AuthPluginRegister {
    shadcn: AuthPlugin
  }
}

/** Props the shadcn `<Auth>` router spreads onto plugin-contributed auth views. */
export type AuthViewProps = {
  className?: string
  socialLayout?: "auto" | "horizontal" | "vertical" | "grid"
  socialPosition?: "top" | "bottom"
}

/** Props the shadcn `<Settings>` router spreads onto plugin-contributed settings views. */
export type SettingsViewProps = {
  className?: string
}

/** Shadcn plugin type. Plugin authors import this from `@/lib/auth/auth-plugin`. */
export type AuthPlugin = AuthPluginPrimitive<
  AuthPluginComponents,
  AuthViewProps,
  SettingsViewProps
>
examples/start-shadcn-example/src/lib/auth/organization-plugin.tsx
import { createAuthPlugin } from "@better-auth-ui/core"
import {
  organizationPlugin as coreOrganizationPlugin,
  type OrganizationLocalization,
  type OrganizationPluginOptions
} from "@better-auth-ui/core/plugins/organization"
import { Briefcase } from "lucide-react"

import { AcceptInvitation } from "@/components/auth/organization/accept-invitation"
import { OrganizationsSettings } from "@/components/auth/organization/organizations-settings"

export const organizationPlugin = createAuthPlugin(
  coreOrganizationPlugin.id,
  (options: OrganizationPluginOptions = {}) => {
    const core = coreOrganizationPlugin(options)

    return {
      ...core,
      localization: core.localization as OrganizationLocalization,
      views: {
        auth: { acceptInvitation: AcceptInvitation }
      },
      settingsTabs: [
        {
          view: "organizations",
          label: (
            <>
              <Briefcase className="text-muted-foreground" />
              {core.localization.organizations}
            </>
          ),
          component: OrganizationsSettings
        }
      ]
    }
  }
)
examples/start-shadcn-example/src/lib/auth/sso-plugin.ts
import {
  type AuthPluginBase,
  type AuthPluginLocalizationContext,
  createAuthPlugin
} from "@better-auth-ui/core"
import {
  ssoPlugin as coreSsoPlugin,
  type SsoLocalization,
  type SsoPluginOptions
} from "@better-auth-ui/core/plugins/sso"
import { ShieldCheck } from "lucide-react"
import { createElement } from "react"

import { EmailFirstSignIn } from "@/components/auth/sso/email-first-sign-in"
import { OrganizationSsoProviders } from "@/components/auth/sso/organization-sso-providers"

export const ssoPlugin = createAuthPlugin(
  coreSsoPlugin.id,
  (options: SsoPluginOptions = {}) => {
    const plugin = coreSsoPlugin(options)
    const localizedOrganizationTab = (localization: SsoLocalization) =>
      plugin.organization
        ? {
            organizationTabs: [
              {
                id: "sso",
                path: plugin.path,
                label: createElement(
                  "span",
                  { className: "inline-flex items-center gap-1" },
                  createElement(ShieldCheck, {
                    "aria-hidden": true,
                    className: "text-muted-foreground"
                  }),
                  localization.providerList
                ),
                component: OrganizationSsoProviders
              }
            ]
          }
        : {}

    return {
      ...plugin,
      ...localizedOrganizationTab(plugin.localization),
      ...(plugin.emailFirst && {
        views: { auth: { signIn: EmailFirstSignIn } }
      }),
      _localizationResolver: (
        resolvedPlugin: AuthPluginBase,
        context: AuthPluginLocalizationContext
      ) => ({
        ...resolvedPlugin,
        ...localizedOrganizationTab(context.localization as SsoLocalization)
      })
    }
  }
)
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 →