shadimage
better-auth-ui
componentadminsource: ok · hit

Admin

Static administration shell with user management, a user inspector, session controls, and the stop-impersonating action.

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

Where it goes

statsdashboard-shell

Interaction

animated-counter

Use cases

dashboard

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react

Registry dependencies

alert-dialogavatarbadgebuttoncalendarcheckboxcomboboxdialogdropdown-menufieldhttps://better-auth-ui.com/r/radix-nova/user-button.jsoninputinput-grouppopoverradio-groupselectseparatorskeletonslidersonnerspinnerswitchtabletabstextarea

Files10 · source cached 10/9/2026, 11:57:05 PM

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/admin/admin-table.ts
"use client"

import {
  columnFilteringFeature,
  createTableHook,
  globalFilteringFeature,
  rowPaginationFeature,
  rowSortingFeature,
  tableFeatures
} from "@tanstack/react-table"

export const {
  createAppColumnHelper: createAdminColumnHelper,
  useAppTable: useAdminTable
} = createTableHook({
  enableMultiSort: false,
  enableSortingRemoval: false,
  sortDescFirst: false,
  features: tableFeatures({
    columnFilteringFeature,
    globalFilteringFeature,
    rowPaginationFeature,
    rowSortingFeature
  })
})
examples/start-shadcn-example/src/components/auth/admin/admin-users.tsx
"use client"

import {
  DEFAULT_TABLE_SEARCH_DEBOUNCE_MS,
  fieldsWithModelValues,
  getAdditionalFieldDefaultValues,
  getAdditionalFieldSubmitValues,
  getClampedTablePageIndex
} from "@better-auth-ui/core"
import {
  type AdminAuthClient,
  type AdminListUsersParams,
  type AdminUser,
  banAdminUserOptions,
  createAdminUserOptions,
  impersonateAdminUserOptions,
  isAdminTarget,
  removeAdminUserOptions,
  revokeAdminUserSessionOptions,
  revokeAdminUserSessionsOptions,
  setAdminUserPasswordOptions,
  setAdminUserRoleOptions,
  unbanAdminUserOptions,
  updateAdminUserOptions
} from "@better-auth-ui/core/plugins/admin"
import {
  useAuth,
  useAuthPlugin,
  useCopyToClipboard,
  useSession
} from "@better-auth-ui/react"
import {
  useAdminPermission,
  useAdminUser,
  useAdminUserSessions,
  useAdminUsers
} from "@better-auth-ui/react/plugins/admin"
import { useDebouncedValue } from "@tanstack/react-pacer"
import { keepPreviousData, useMutation } from "@tanstack/react-query"
import type { SortingState } from "@tanstack/react-table"
import type { BetterFetchError } from "better-auth/react"
import {
  BanIcon,
  CheckIcon,
  ChevronLeftIcon,
  ChevronRightIcon,
  CopyIcon,
  EllipsisIcon,
  KeyRoundIcon,
  LogInIcon,
  Monitor,
  SearchIcon,
  ShieldAlertIcon,
  Trash2Icon,
  UserPlusIcon,
  UserRound
} from "lucide-react"
import { type FormEvent, useEffect, useMemo, useState } from "react"
import { UserAvatar } from "@/components/auth/user/user-avatar"
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 { Checkbox } from "@/components/ui/checkbox"
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle
} from "@/components/ui/dialog"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import {
  Field,
  FieldContent,
  FieldError,
  FieldGroup,
  FieldLabel,
  FieldLegend,
  FieldSet
} from "@/components/ui/field"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput
} from "@/components/ui/input-group"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue
} from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow
} from "@/components/ui/table"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { adminPlugin } from "@/lib/auth/admin-plugin"
import { getAuthAdditionalFieldValidators, useAuthForm } from "../auth-form"
import { useServerTableState } from "../server-table-state"

import { createAdminColumnHelper, useAdminTable } from "./admin-table"

type SearchField = "email" | "name"
type SearchOperator = "contains" | "ends_with" | "starts_with"
type StatusFilter = "all" | "active" | "banned"
type DangerousAction = "ban" | "delete" | "impersonate" | "revokeAll"

export type AdminUsersProps = {
  className?: string
  onSelectedUserIdChange?: (userId: string | undefined) => void
  selectedUserId?: string
}

const formatDate = (value: Date | string | undefined | null) =>
  value
    ? new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(
        new Date(value)
      )
    : "–"

const asAdminRoles = (roles: string[]) => roles as ("user" | "admin")[]

const parseAdminRoles = (
  role: string | undefined,
  fallback: string,
  allowMultipleRoles: boolean
) => {
  const roles = role
    ?.split(",")
    .map((value) => value.trim())
    .filter(Boolean)
  const resolved = roles?.length ? roles : [fallback]
  return allowMultipleRoles ? resolved : resolved.slice(0, 1)
}

const getBanDurationSeconds = (value: string) => {
  if (!value) return undefined
  const days = Number(value)
  if (!Number.isSafeInteger(days) || days <= 0) return null
  const seconds = days * 86_400
  return Number.isSafeInteger(seconds) ? seconds : null
}

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

const adminColumnHelper = createAdminColumnHelper<AdminUser>()
const adminColumns = adminColumnHelper.columns([
  adminColumnHelper.accessor("name", { id: "name" }),
  adminColumnHelper.accessor("role", {
    id: "role",
    enableSorting: false
  }),
  adminColumnHelper.accessor("banned", {
    id: "status",
    enableSorting: false
  }),
  adminColumnHelper.accessor((user) => new Date(user.createdAt).getTime(), {
    id: "createdAt"
  })
])
const EMPTY_USERS: AdminUser[] = []
const INITIAL_ADMIN_SORTING: SortingState = [{ id: "createdAt", desc: true }]

/** Server-paginated user management with optional controlled inspector state. */
export function AdminUsers({
  className,
  onSelectedUserIdChange,
  selectedUserId: controlledSelectedUserId
}: AdminUsersProps) {
  const auth = useAuth<AdminAuthClient>()
  const config = useAuthPlugin(adminPlugin)
  const { localization } = config
  const [localSelectedUserId, setLocalSelectedUserId] = useState<string>()
  const tableState = useServerTableState({
    initialSorting: INITIAL_ADMIN_SORTING,
    pageSize: config.pageSize
  })
  const { columnFilters, globalFilter, pagination, setPagination, sorting } =
    tableState
  const [searchField, setSearchField] = useState<SearchField>("email")
  const [searchOperator, setSearchOperator] =
    useState<SearchOperator>("contains")
  const [createOpen, setCreateOpen] = useState(false)
  const [debouncedSearch] = useDebouncedValue(globalFilter.trim(), {
    wait: DEFAULT_TABLE_SEARCH_DEBOUNCE_MS
  })
  const status = String(
    columnFilters.find((filter) => filter.id === "status")?.value ?? "all"
  ) as StatusFilter
  const primarySort = sorting[0]
  const sortBy = primarySort?.id === "name" ? "name" : "createdAt"
  const sortDirection = primarySort?.desc ? "desc" : "asc"
  const isSelectionControlled = onSelectedUserIdChange !== undefined
  const selectedUserId = isSelectionControlled
    ? controlledSelectedUserId
    : localSelectedUserId

  const setSelectedUserId = (userId: string | undefined) => {
    if (!isSelectionControlled) setLocalSelectedUserId(userId)
    onSelectedUserIdChange?.(userId)
  }

  const params = useMemo<AdminListUsersParams>(
    () => ({
      limit: pagination.pageSize,
      offset: pagination.pageIndex * pagination.pageSize,
      searchField,
      searchOperator,
      searchValue: debouncedSearch || undefined,
      sortBy,
      sortDirection,
      ...(status === "all"
        ? {}
        : {
            filterField: "banned",
            filterOperator: "eq" as const,
            filterValue: status === "banned"
          })
    }),
    [
      debouncedSearch,
      pagination.pageIndex,
      pagination.pageSize,
      searchField,
      searchOperator,
      sortBy,
      sortDirection,
      status
    ]
  )
  const permission = useAdminPermission(auth.authClient, { user: ["list"] })
  const users = useAdminUsers(auth.authClient, {
    enabled: permission.data?.success === true,
    params,
    placeholderData: keepPreviousData
  })
  const canCreate = useAdminPermission(auth.authClient, { user: ["create"] })
  const canGet = useAdminPermission(auth.authClient, { user: ["get"] })

  const total = users.data?.total ?? 0

  useEffect(() => {
    if (!users.isSuccess) return
    const pageIndex = getClampedTablePageIndex(
      pagination.pageIndex,
      pagination.pageSize,
      total
    )
    if (pageIndex !== pagination.pageIndex) {
      setPagination((current) => ({ ...current, pageIndex }))
    }
  }, [
    pagination.pageIndex,
    pagination.pageSize,
    setPagination,
    total,
    users.isSuccess
  ])
  const table = useAdminTable(
    {
      atoms: tableState.atoms,
      columns: adminColumns,
      data: users.data?.users ?? EMPTY_USERS,
      getRowId: (user) => user.id,
      manualFiltering: true,
      manualPagination: true,
      manualSorting: true,
      rowCount: total
    },
    () => null
  )
  const from = total ? pagination.pageIndex * pagination.pageSize + 1 : 0
  const to = Math.min(total, (pagination.pageIndex + 1) * pagination.pageSize)

  return (
    <section className={className}>
      <div className="flex flex-col gap-4">
        <div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
          <div className="flex flex-col gap-1">
            <h1 className="text-xl font-semibold tracking-tight">
              {localization.users}
            </h1>
            <p className="text-sm text-muted-foreground">
              {localization.usersDescription}
            </p>
          </div>
          {canCreate.isPending ? (
            <Skeleton className="h-8 w-28" />
          ) : canCreate.data?.success ? (
            <Button onClick={() => setCreateOpen(true)}>
              <UserPlusIcon />
              {localization.createUser}
            </Button>
          ) : null}
        </div>

        <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-[9rem_10rem_minmax(14rem,1fr)_10rem]">
          <Select
            value={searchField}
            onValueChange={(value) => {
              setSearchField(value as SearchField)
              table.setPageIndex(0)
            }}
          >
            <SelectTrigger
              aria-label={localization.search}
              className="w-full sm:w-36"
            >
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              <SelectItem value="email">{localization.email}</SelectItem>
              <SelectItem value="name">{localization.name}</SelectItem>
            </SelectContent>
          </Select>
          <Select
            value={searchOperator}
            onValueChange={(value) => {
              setSearchOperator(value as SearchOperator)
              table.setPageIndex(0)
            }}
          >
            <SelectTrigger aria-label={localization.searchOperator}>
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              <SelectItem value="contains">
                {localization.searchContains}
              </SelectItem>
              <SelectItem value="starts_with">
                {localization.startsWith}
              </SelectItem>
              <SelectItem value="ends_with">{localization.endsWith}</SelectItem>
            </SelectContent>
          </Select>
          <InputGroup>
            <InputGroupAddon>
              <SearchIcon />
            </InputGroupAddon>
            <InputGroupInput
              aria-label={
                searchField === "email"
                  ? localization.searchByEmail
                  : localization.searchByName
              }
              onChange={(event) => {
                table.setGlobalFilter(event.target.value)
              }}
              placeholder={
                searchField === "email"
                  ? localization.searchByEmail
                  : localization.searchByName
              }
              value={globalFilter}
            />
          </InputGroup>
          <Select
            value={status}
            onValueChange={(value) => {
              table
                .getColumn("status")
                ?.setFilterValue(value === "all" ? undefined : value)
            }}
          >
            <SelectTrigger
              aria-label={localization.status}
              className="w-full sm:w-36"
            >
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              <SelectItem value="all">
                {localization.filterAllStatuses}
              </SelectItem>
              <SelectItem value="active">{localization.active}</SelectItem>
              <SelectItem value="banned">{localization.banned}</SelectItem>
            </SelectContent>
          </Select>
        </div>

        {permission.isPending ? (
          <UserTableSkeleton />
        ) : !permission.data?.success ? (
          <AdminState
            icon={<ShieldAlertIcon />}
            title={localization.accessDenied}
            description={localization.accessDeniedDescription}
          />
        ) : users.isError ? (
          <AdminState
            icon={<ShieldAlertIcon />}
            title={localization.loadUsersError}
            description={localization.loadUsersErrorDescription}
            action={
              <Button variant="outline" onClick={() => users.refetch()}>
                {localization.retry}
              </Button>
            }
          />
        ) : (
          <div className="overflow-hidden rounded-lg border">
            <Table>
              <TableHeader>
                <TableRow>
                  <TableHead>
                    <SortButton
                      onClick={table
                        .getColumn("name")
                        ?.getToggleSortingHandler()}
                      sorted={table.getColumn("name")?.getIsSorted() ?? false}
                    >
                      {localization.name}
                    </SortButton>
                  </TableHead>
                  <TableHead className="hidden md:table-cell">
                    {localization.role}
                  </TableHead>
                  <TableHead>{localization.status}</TableHead>
                  <TableHead className="hidden lg:table-cell">
                    <SortButton
                      onClick={table
                        .getColumn("createdAt")
                        ?.getToggleSortingHandler()}
                      sorted={
                        table.getColumn("createdAt")?.getIsSorted() ?? false
                      }
                    >
                      {localization.created}
                    </SortButton>
                  </TableHead>
                </TableRow>
              </TableHeader>
              <TableBody>
                {users.isPending ? (
                  <UserRowsSkeleton />
                ) : table.getRowModel().rows.length ? (
                  table.getRowModel().rows.map((row) => {
                    const user = row.original
                    return (
                      <TableRow
                        key={row.id}
                        aria-selected={selectedUserId === user.id}
                        className={
                          canGet.data?.success ? "cursor-pointer" : undefined
                        }
                        onClick={
                          canGet.data?.success
                            ? () => setSelectedUserId(user.id)
                            : undefined
                        }
                      >
                        <TableCell>
                          <div className="flex items-center gap-3">
                            <UserAvatar className="size-8" user={user} />
                            <div className="min-w-0">
                              {canGet.data?.success ? (
                                <Button
                                  className="h-auto min-w-0 justify-start p-0 font-medium"
                                  onClick={(event) => {
                                    event.stopPropagation()
                                    setSelectedUserId(user.id)
                                  }}
                                  variant="link"
                                >
                                  <span className="truncate">{user.name}</span>
                                </Button>
                              ) : (
                                <span className="truncate">{user.name}</span>
                              )}
                              <div className="truncate text-xs text-muted-foreground">
                                {user.email}
                              </div>
                            </div>
                          </div>
                        </TableCell>
                        <TableCell className="hidden md:table-cell">
                          <Badge variant="outline">
                            {user.role ?? config.defaultRole}
                          </Badge>
                        </TableCell>
                        <TableCell>
                          <Badge
                            variant={user.banned ? "destructive" : "secondary"}
                          >
                            {user.banned
                              ? localization.banned
                              : localization.active}
                          </Badge>
                        </TableCell>
                        <TableCell className="hidden text-muted-foreground lg:table-cell">
                          {formatDate(user.createdAt)}
                        </TableCell>
                      </TableRow>
                    )
                  })
                ) : (
                  <TableRow>
                    <TableCell colSpan={4} className="h-40 text-center">
                      <strong className="block font-medium">
                        {localization.noUsers}
                      </strong>
                      <span className="text-muted-foreground">
                        {localization.noUsersDescription}
                      </span>
                    </TableCell>
                  </TableRow>
                )}
              </TableBody>
            </Table>
          </div>
        )}

        <div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
          <span>
            {localization.usersPaginationRange
              .replace("{{from}}", String(from))
              .replace("{{to}}", String(to))
              .replace("{{total}}", String(total))}
          </span>
          <div className="flex gap-1">
            <Button
              aria-label={localization.previousPage}
              disabled={!table.getCanPreviousPage() || users.isFetching}
              onClick={() => table.previousPage()}
              size="icon-sm"
              variant="outline"
            >
              <ChevronLeftIcon />
            </Button>
            <Button
              aria-label={localization.nextPage}
              disabled={!table.getCanNextPage() || users.isFetching}
              onClick={() => table.nextPage()}
              size="icon-sm"
              variant="outline"
            >
              <ChevronRightIcon />
            </Button>
          </div>
        </div>
      </div>

      <CreateUserDialog open={createOpen} onOpenChange={setCreateOpen} />
      <UserInspector
        canGetUser={canGet.data?.success === true}
        open={Boolean(selectedUserId) && canGet.data?.success === true}
        onOpenChange={(open) => !open && setSelectedUserId(undefined)}
        userId={selectedUserId}
      />
    </section>
  )
}

function SortButton({
  children,
  onClick,
  sorted
}: {
  children: React.ReactNode
  onClick?: (event: unknown) => void
  sorted: false | "asc" | "desc"
}) {
  return (
    <button
      className="inline-flex items-center gap-1 hover:text-foreground"
      onClick={onClick}
      type="button"
    >
      {children}
      {sorted ? (sorted === "asc" ? "↑" : "↓") : null}
    </button>
  )
}

function AdminState({
  action,
  description,
  icon,
  title
}: {
  action?: React.ReactNode
  description: string
  icon: React.ReactNode
  title: string
}) {
  return (
    <div className="flex min-h-64 flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8 text-center">
      <span className="text-muted-foreground [&>svg]:size-8">{icon}</span>
      <div className="flex flex-col gap-1">
        <h2 className="font-medium">{title}</h2>
        <p className="max-w-md text-sm text-muted-foreground">{description}</p>
      </div>
      {action}
    </div>
  )
}

const skeletonRowIds = [
  "admin-row-1",
  "admin-row-2",
  "admin-row-3",
  "admin-row-4",
  "admin-row-5"
]

function UserRowsSkeleton() {
  return skeletonRowIds.map((id) => (
    <TableRow key={id}>
      <TableCell>
        <div className="flex items-center gap-3">
          <Skeleton className="size-8 rounded-full" />
          <div className="flex flex-col gap-1">
            <Skeleton className="h-4 w-32" />
            <Skeleton className="h-3 w-48" />
          </div>
        </div>
      </TableCell>
      <TableCell className="hidden md:table-cell">
        <Skeleton className="h-5 w-16" />
      </TableCell>
      <TableCell>
        <Skeleton className="h-5 w-16" />
      </TableCell>
      <TableCell className="hidden lg:table-cell">
        <Skeleton className="h-4 w-24" />
      </TableCell>
    </TableRow>
  ))
}

function UserTableSkeleton() {
  return (
    <div className="overflow-hidden rounded-lg border">
      <Table>
        <TableBody>
          <UserRowsSkeleton />
        </TableBody>
      </Table>
    </div>
  )
}

function CreateUserDialog({
  open,
  onOpenChange
}: {
  open: boolean
  onOpenChange: (open: boolean) => void
}) {
  const auth = useAuth<AdminAuthClient>()
  const config = useAuthPlugin(adminPlugin)
  const { data: session } = useSession(auth.authClient)
  const createUser = useMutation(
    createAdminUserOptions(auth.authClient, session?.user.id)
  )
  const canSetRole = useAdminPermission(auth.authClient, {
    user: ["set-role"]
  })
  const additionalFields = auth.additionalFields ?? []
  const form = useAuthForm({
    defaultValues: {
      additionalFields: getAdditionalFieldDefaultValues(additionalFields),
      email: "",
      emailVerified: false,
      name: "",
      password: "",
      roles: [config.defaultRole]
    },
    onSubmit: async ({ value }) => {
      try {
        await createUser.mutateAsync(
          {
            data: {
              ...getAdditionalFieldSubmitValues(
                additionalFields,
                value.additionalFields
              ),
              emailVerified: value.emailVerified
            },
            email: value.email,
            name: value.name,
            password: value.password,
            ...(canSetRole.data?.success
              ? { role: asAdminRoles(value.roles) }
              : {})
          },
          { onSuccess: close }
        )
      } catch {
        // The mutation reports the error through its configured handler.
      }
    }
  })

  useEffect(() => {
    if (!config.allowMultipleRoles)
      form.setFieldValue("roles", (current) => current.slice(0, 1))
  }, [config.allowMultipleRoles, form.setFieldValue])

  const close = () => {
    form.reset()
    createUser.reset()
    onOpenChange(false)
  }

  return (
    <Dialog
      open={open}
      onOpenChange={(value) => (value ? onOpenChange(true) : close())}
    >
      <DialogContent>
        <form.AppForm>
          <form.AuthFormRoot className="flex flex-col gap-4">
            <DialogHeader>
              <DialogTitle>{config.localization.createUser}</DialogTitle>
              <DialogDescription>
                {config.localization.usersDescription}
              </DialogDescription>
            </DialogHeader>
            <FieldGroup>
              <form.Field name="name">
                {(field) => (
                  <Field>
                    <FieldLabel htmlFor="admin-create-name">
                      {config.localization.name}
                    </FieldLabel>
                    <InputGroup>
                      <InputGroupInput
                        id="admin-create-name"
                        name={field.name}
                        onChange={(event) =>
                          field.handleChange(event.target.value)
                        }
                        required
                        value={field.state.value}
                      />
                    </InputGroup>
                  </Field>
                )}
              </form.Field>
              <form.Field name="email">
                {(field) => (
                  <Field>
                    <FieldLabel htmlFor="admin-create-email">
                      {config.localization.email}
                    </FieldLabel>
                    <InputGroup>
                      <InputGroupInput
                        autoComplete="off"
                        id="admin-create-email"
                        name={field.name}
                        onChange={(event) =>
                          field.handleChange(event.target.value)
                        }
                        required
                        type="email"
                        value={field.state.value}
                      />
                    </InputGroup>
                  </Field>
                )}
              </form.Field>
              <form.Field name="password">
                {(field) => (
                  <Field>
                    <FieldLabel htmlFor="admin-create-password">
                      {config.localization.password}
                    </FieldLabel>
                    <InputGroup>
                      <InputGroupInput
                        autoComplete="new-password"
                        id="admin-create-password"
                        name={field.name}
                        onChange={(event) =>
                          field.handleChange(event.target.value)
                        }
                        required
                        type="password"
                        value={field.state.value}
                      />
                    </InputGroup>
                  </Field>
                )}
              </form.Field>
              {canSetRole.isPending ? (
                <Skeleton className="h-16 w-full" />
              ) : canSetRole.data?.success ? (
                <FieldSet>
                  <FieldLegend variant="label">
                    {config.localization.role}
                  </FieldLegend>
                  <form.Field name="roles">
                    {(field) =>
                      config.allowMultipleRoles ? (
                        <FieldGroup data-slot="checkbox-group">
                          {config.roles.map((role) => (
                            <Field key={role} orientation="horizontal">
                              <Checkbox
                                checked={field.state.value.includes(role)}
                                id={`admin-create-role-${role}`}
                                onCheckedChange={(checked) => {
                                  const next = checked
                                    ? [...field.state.value, role]
                                    : field.state.value.filter(
                                        (item) => item !== role
                                      )
                                  if (next.length) field.handleChange(next)
                                }}
                              />
                              <FieldLabel htmlFor={`admin-create-role-${role}`}>
                                {role}
                              </FieldLabel>
                            </Field>
                          ))}
                        </FieldGroup>
                      ) : (
                        <RadioGroup
                          onValueChange={(role) => field.handleChange([role])}
                          value={field.state.value[0] ?? ""}
                        >
                          {config.roles.map((role) => (
                            <Field key={role} orientation="horizontal">
                              <RadioGroupItem
                                id={`admin-create-role-${role}`}
                                value={role}
                              />
                              <FieldLabel htmlFor={`admin-create-role-${role}`}>
                                {role}
                              </FieldLabel>
                            </Field>
                          ))}
                        </RadioGroup>
                      )
                    }
                  </form.Field>
                </FieldSet>
              ) : null}
              <form.Field name="emailVerified">
                {(field) => (
                  <Field orientation="horizontal">
                    <Switch
                      checked={field.state.value}
                      id="admin-create-email-verified"
                      onCheckedChange={field.handleChange}
                    />
                    <FieldContent>
                      <FieldLabel htmlFor="admin-create-email-verified">
                        {config.localization.emailVerified}
                      </FieldLabel>
                    </FieldContent>
                  </Field>
                )}
              </form.Field>
              {additionalFields.map((configuredField) => (
                <form.AppField
                  key={configuredField.name}
                  name={`additionalFields.${configuredField.name}`}
                  validators={getAuthAdditionalFieldValidators(
                    configuredField,
                    auth.localization.auth.fieldRequired
                  )}
                >
                  {(field) => (
                    <field.AuthFormAdditionalField
                      field={configuredField}
                      isPending={createUser.isPending}
                    />
                  )}
                </form.AppField>
              ))}
            </FieldGroup>
            <FieldError>{getAdminErrorMessage(createUser.error)}</FieldError>
            <DialogFooter>
              <Button onClick={close} type="button" variant="outline">
                {config.localization.cancel}
              </Button>
              <form.AuthFormSubmitButton disabled={createUser.isPending}>
                {config.localization.createUser}
              </form.AuthFormSubmitButton>
            </DialogFooter>
          </form.AuthFormRoot>
        </form.AppForm>
      </DialogContent>
    </Dialog>
  )
}

function UserInspector({
  canGetUser,
  open,
  onOpenChange,
  userId
}: {
  canGetUser: boolean
  open: boolean
  onOpenChange: (open: boolean) => void
  userId?: string
}) {
  const auth = useAuth<AdminAuthClient>()
  const config = useAuthPlugin(adminPlugin)
  const contributedTabs = auth.plugins.flatMap((plugin) =>
    (plugin.adminUserTabs ?? []).map((tab) => ({
      ...tab,
      value: `${plugin.id}:${tab.id}`
    }))
  )
  const detail = useAdminUser(auth.authClient, userId, {
    enabled: canGetUser
  })
  const user = detail.data
  const sessionsPermission = useAdminPermission(
    auth.authClient,
    {
      session: ["list"]
    },
    { enabled: Boolean(userId) }
  )
  const sessions = useAdminUserSessions(auth.authClient, userId, {
    enabled: sessionsPermission.data?.success === true
  })
  const { data: actor } = useSession(auth.authClient)
  const canSetRole = useAdminPermission(
    auth.authClient,
    {
      user: ["set-role"]
    },
    { enabled: Boolean(userId) }
  )
  const canUpdate = useAdminPermission(
    auth.authClient,
    { user: ["update"] },
    { enabled: Boolean(userId) }
  )
  const canSetEmail = useAdminPermission(
    auth.authClient,
    { user: ["set-email"] },
    { enabled: Boolean(userId) }
  )
  const canSetPassword = useAdminPermission(
    auth.authClient,
    {
      user: ["set-password"]
    },
    { enabled: Boolean(userId) }
  )
  const canBan = useAdminPermission(
    auth.authClient,
    { user: ["ban"] },
    { enabled: Boolean(userId) }
  )
  const canImpersonate = useAdminPermission(
    auth.authClient,
    {
      user: ["impersonate"]
    },
    { enabled: Boolean(userId) }
  )
  const targetIsAdmin = user
    ? isAdminTarget(user, config.adminRoles, config.adminUserIds)
    : false
  const canImpersonateAdmins = useAdminPermission(
    auth.authClient,
    { user: ["impersonate-admins"] },
    { enabled: Boolean(userId && targetIsAdmin) }
  )
  const canDelete = useAdminPermission(
    auth.authClient,
    { user: ["delete"] },
    { enabled: Boolean(userId) }
  )
  const canRevoke = useAdminPermission(
    auth.authClient,
    {
      session: ["revoke"]
    },
    { enabled: Boolean(userId) }
  )
  const {
    copied: userIdCopied,
    copy: copyUserId,
    reset: resetUserIdCopy
  } = useCopyToClipboard()

  useEffect(() => {
    if (open && userId) resetUserIdCopy()
  }, [open, userId, resetUserIdCopy])

  const [banReason, setBanReason] = useState("")
  const [banDuration, setBanDuration] = useState("")
  const banDurationSeconds = getBanDurationSeconds(banDuration)
  const [passwordOpen, setPasswordOpen] = useState(false)
  const [dangerousAction, setDangerousAction] = useState<DangerousAction>()
  const isSelf = user?.id === actor?.user.id

  const updateUser = useMutation(
    updateAdminUserOptions(auth.authClient, actor?.user.id)
  )

  useEffect(() => {
    if (user?.id) updateUser.reset()
  }, [user?.id, updateUser.reset])

  useEffect(() => {
    if (!open) updateUser.reset()
  }, [open, updateUser.reset])

  const setRoleMutation = useMutation(
    setAdminUserRoleOptions(auth.authClient, actor?.user.id)
  )
  const ban = useMutation(banAdminUserOptions(auth.authClient, actor?.user.id))
  const unban = useMutation(
    unbanAdminUserOptions(auth.authClient, actor?.user.id)
  )
  const remove = useMutation(
    removeAdminUserOptions(auth.authClient, actor?.user.id)
  )
  const impersonate = useMutation(
    impersonateAdminUserOptions(auth.authClient, actor?.user.id)
  )
  const revokeSession = useMutation(
    revokeAdminUserSessionOptions(auth.authClient, actor?.user.id, userId)
  )
  const revokeSessions = useMutation(
    revokeAdminUserSessionsOptions(auth.authClient, actor?.user.id, userId)
  )
  const configuredUserFields = useMemo(
    () =>
      fieldsWithModelValues(
        auth.additionalFields ?? [],
        user ? (user as unknown as Record<string, unknown>) : {}
      ),
    [auth.additionalFields, user]
  )
  const profileForm = useAuthForm({
    defaultValues: {
      additionalFields: getAdditionalFieldDefaultValues(configuredUserFields),
      email: "",
      emailVerified: false,
      name: "",
      roles: [config.defaultRole]
    },
    onSubmit: async ({ value }) => {
      if (!user) return

      const mutations: Promise<unknown>[] = []
      if (canUpdate.data?.success) {
        mutations.push(
          updateUser.mutateAsync({
            userId: user.id,
            data: {
              ...getAdditionalFieldSubmitValues(
                configuredUserFields,
                value.additionalFields
              ),
              name: value.name.trim(),
              ...(canSetEmail.data?.success
                ? {
                    email: value.email.trim(),
                    emailVerified: value.emailVerified
                  }
                : {})
            }
          })
        )
      }
      if (canSetRole.data?.success && !isSelf) {
        mutations.push(
          setRoleMutation.mutateAsync({
            userId: user.id,
            role: asAdminRoles(value.roles)
          })
        )
      }

      try {
        await Promise.all(mutations)
        onOpenChange(false)
      } catch {
        // Mutation errors are rendered next to the form.
      }
    }
  })

  useEffect(() => {
    profileForm.reset({
      additionalFields: getAdditionalFieldDefaultValues(configuredUserFields),
      email: user?.email ?? "",
      emailVerified: user?.emailVerified ?? false,
      name: user?.name ?? "",
      roles: parseAdminRoles(
        user?.role,
        config.defaultRole,
        config.allowMultipleRoles
      )
    })
  }, [
    config.allowMultipleRoles,
    config.defaultRole,
    configuredUserFields,
    profileForm.reset,
    user?.email,
    user?.emailVerified,
    user?.name,
    user?.role
  ])

  const confirm = () => {
    if (!user) return
    if (dangerousAction === "ban") {
      if (banDurationSeconds === null) return
      ban.mutate(
        {
          banExpiresIn: banDurationSeconds,
          banReason: banReason.trim() || undefined,
          userId: user.id
        },
        {
          onSuccess: () => {
            setBanDuration("")
            setBanReason("")
            setDangerousAction(undefined)
          }
        }
      )
    }
    if (dangerousAction === "delete")
      remove.mutate(
        { userId: user.id },
        {
          onSuccess: () => {
            setDangerousAction(undefined)
            onOpenChange(false)
          }
        }
      )
    if (dangerousAction === "revokeAll")
      revokeSessions.mutate(
        { userId: user.id },
        { onSuccess: () => setDangerousAction(undefined) }
      )
    if (dangerousAction === "impersonate")
      impersonate.mutate(
        { userId: user.id },
        {
          onSuccess: () => {
            setDangerousAction(undefined)
            if (config.impersonationRedirectTo)
              auth.navigate({ to: config.impersonationRedirectTo })
          }
        }
      )
  }
  const closeDangerousAction = () => {
    ban.reset()
    remove.reset()
    revokeSessions.reset()
    impersonate.reset()
    setBanDuration("")
    setBanReason("")
    setDangerousAction(undefined)
  }
  const dangerousMutation =
    dangerousAction === "ban"
      ? ban
      : dangerousAction === "delete"
        ? remove
        : dangerousAction === "revokeAll"
          ? revokeSessions
          : impersonate
  const dangerousError = getAdminErrorMessage(dangerousMutation.error)
  const dangerLabel =
    dangerousAction === "ban"
      ? config.localization.banUser
      : dangerousAction === "delete"
        ? config.localization.deleteUser
        : dangerousAction === "revokeAll"
          ? config.localization.revokeAllSessions
          : config.localization.impersonateUser

  return (
    <>
      <Dialog open={open} onOpenChange={onOpenChange}>
        <DialogContent className="h-[min(52rem,calc(100vh-2rem))] grid-rows-[auto_minmax(0,1fr)] gap-0 overflow-hidden p-0 sm:max-w-4xl">
          <DialogHeader className="border-b px-6 py-5 pr-14">
            {user ? (
              <div className="flex items-center justify-between gap-4">
                <div className="flex min-w-0 items-center gap-3">
                  <UserAvatar className="size-12" user={user} />
                  <div className="min-w-0">
                    <DialogTitle className="truncate">{user.name}</DialogTitle>
                    <DialogDescription className="truncate">
                      {user.email}
                    </DialogDescription>
                  </div>
                </div>
                <div className="flex shrink-0 items-center gap-2">
                  <Badge variant={user.banned ? "destructive" : "secondary"}>
                    {user.banned
                      ? config.localization.banned
                      : config.localization.active}
                  </Badge>
                  <DropdownMenu>
                    <DropdownMenuTrigger asChild>
                      <Button
                        aria-label={config.localization.moreActions}
                        size="icon-sm"
                        variant="ghost"
                      >
                        <EllipsisIcon />
                      </Button>
                    </DropdownMenuTrigger>
                    <DropdownMenuContent align="end" className="w-48">
                      <DropdownMenuGroup>
                        <DropdownMenuItem
                          disabled={
                            canImpersonate.isPending ||
                            !canImpersonate.data?.success ||
                            (targetIsAdmin &&
                              (canImpersonateAdmins.isPending ||
                                !canImpersonateAdmins.data?.success)) ||
                            isSelf
                          }
                          onSelect={() => setDangerousAction("impersonate")}
                        >
                          <LogInIcon />
                          {config.localization.impersonateUser}
                        </DropdownMenuItem>
                      </DropdownMenuGroup>
                    </DropdownMenuContent>
                  </DropdownMenu>
                </div>
              </div>
            ) : (
              <>
                <DialogTitle>{config.localization.userDetails}</DialogTitle>
                <DialogDescription>
                  {config.localization.usersDescription}
                </DialogDescription>
              </>
            )}
          </DialogHeader>
          {detail.isPending ? (
            <div className="flex flex-col gap-3 p-6">
              <Skeleton className="size-14 rounded-full" />
              <Skeleton className="h-5 w-40" />
              <Skeleton className="h-4 w-64" />
            </div>
          ) : user ? (
            <Tabs
              className="min-h-0 gap-0 overflow-hidden"
              defaultValue="overview"
            >
              <TabsList className="mx-6 h-11 shrink-0" variant="line">
                <TabsTrigger value="overview">
                  <UserRound
                    aria-hidden="true"
                    className="text-muted-foreground"
                  />
                  {config.localization.overview}
                </TabsTrigger>
                <TabsTrigger
                  disabled={
                    sessionsPermission.isPending ||
                    !sessionsPermission.data?.success
                  }
                  value="sessions"
                >
                  <Monitor
                    aria-hidden="true"
                    className="text-muted-foreground"
                  />
                  {config.localization.sessions}
                </TabsTrigger>
                {contributedTabs.map((tab) => (
                  <TabsTrigger key={tab.value} value={tab.value}>
                    {tab.label}
                  </TabsTrigger>
                ))}
              </TabsList>
              <TabsContent className="min-h-0 overflow-hidden" value="overview">
                <profileForm.AppForm>
                  <profileForm.AuthFormRoot className="grid h-full grid-rows-[minmax(0,1fr)_auto]">
                    <div className="overflow-y-auto">
                      <section className="flex flex-col gap-5 p-6">
                        <h3 className="font-medium">
                          {config.localization.profileAndAccess}
                        </h3>
                        <FieldGroup className="grid gap-5 md:grid-cols-2">
                          <profileForm.Field name="name">
                            {(field) => (
                              <Field>
                                <FieldLabel htmlFor="admin-user-name">
                                  {config.localization.name}
                                </FieldLabel>
                                <InputGroup>
                                  <InputGroupInput
                                    disabled={!canUpdate.data?.success}
                                    id="admin-user-name"
                                    name={field.name}
                                    value={field.state.value}
                                    onChange={(event) =>
                                      field.handleChange(event.target.value)
                                    }
                                  />
                                </InputGroup>
                              </Field>
                            )}
                          </profileForm.Field>
                          <profileForm.Field name="email">
                            {(field) => (
                              <Field>
                                <FieldLabel htmlFor="admin-user-email">
                                  {config.localization.email}
                                </FieldLabel>
                                <InputGroup>
                                  <InputGroupInput
                                    disabled={
                                      !canUpdate.data?.success ||
                                      !canSetEmail.data?.success
                                    }
                                    id="admin-user-email"
                                    name={field.name}
                                    onChange={(event) =>
                                      field.handleChange(event.target.value)
                                    }
                                    required
                                    type="email"
                                    value={field.state.value}
                                  />
                                </InputGroup>
                              </Field>
                            )}
                          </profileForm.Field>
                          <profileForm.Field name="emailVerified">
                            {(field) => (
                              <Field orientation="horizontal">
                                <Switch
                                  checked={field.state.value}
                                  disabled={
                                    !canUpdate.data?.success ||
                                    !canSetEmail.data?.success
                                  }
                                  id="admin-user-email-verified"
                                  onCheckedChange={field.handleChange}
                                />
                                <FieldContent>
                                  <FieldLabel htmlFor="admin-user-email-verified">
                                    {config.localization.emailVerified}
                                  </FieldLabel>
                                </FieldContent>
                              </Field>
                            )}
                          </profileForm.Field>
                          <FieldSet>
                            <FieldLegend variant="label">
                              {config.localization.role}
                            </FieldLegend>
                            <profileForm.Field name="roles">
                              {(field) =>
                                config.allowMultipleRoles ? (
                                  <FieldGroup
                                    className="flex-row flex-wrap gap-4"
                                    data-slot="checkbox-group"
                                  >
                                    {config.roles.map((item) => (
                                      <Field
                                        key={item}
                                        orientation="horizontal"
                                      >
                                        <Checkbox
                                          checked={field.state.value.includes(
                                            item
                                          )}
                                          disabled={
                                            isSelf || !canSetRole.data?.success
                                          }
                                          id={`admin-user-role-${item}`}
                                          onCheckedChange={(checked) => {
                                            const next = checked
                                              ? [...field.state.value, item]
                                              : field.state.value.filter(
                                                  (role) => role !== item
                                                )
                                            if (next.length)
                                              field.handleChange(next)
                                          }}
                                        />
                                        <FieldLabel
                                          htmlFor={`admin-user-role-${item}`}
                                        >
                                          {item}
                                        </FieldLabel>
                                      </Field>
                                    ))}
                                  </FieldGroup>
                                ) : (
                                  <RadioGroup
                                    className="flex-row flex-wrap gap-4"
                                    disabled={
                                      isSelf || !canSetRole.data?.success
                                    }
                                    onValueChange={(role) =>
                                      field.handleChange([role])
                                    }
                                    value={field.state.value[0] ?? ""}
                                  >
                                    {config.roles.map((item) => (
                                      <Field
                                        key={item}
                                        orientation="horizontal"
                                      >
                                        <RadioGroupItem
                                          id={`admin-user-role-${item}`}
                                          value={item}
                                        />
                                        <FieldLabel
                                          htmlFor={`admin-user-role-${item}`}
                                        >
                                          {item}
                                        </FieldLabel>
                                      </Field>
                                    ))}
                                  </RadioGroup>
                                )
                              }
                            </profileForm.Field>
                          </FieldSet>
                          {configuredUserFields.map((configuredField) => (
                            <profileForm.AppField
                              key={configuredField.name}
                              name={`additionalFields.${configuredField.name}`}
                              validators={getAuthAdditionalFieldValidators(
                                configuredField,
                                auth.localization.auth.fieldRequired
                              )}
                            >
                              {(field) => (
                                <field.AuthFormAdditionalField
                                  field={configuredField}
                                  isPending={
                                    updateUser.isPending ||
                                    !canUpdate.data?.success
                                  }
                                />
                              )}
                            </profileForm.AppField>
                          ))}
                        </FieldGroup>
                        <FieldError>
                          {getAdminErrorMessage(updateUser.error) ??
                            getAdminErrorMessage(setRoleMutation.error)}
                        </FieldError>
                      </section>
                      <Separator />
                      <section className="flex flex-col gap-4 p-6">
                        <h3 className="font-medium">
                          {config.localization.accountInformation}
                        </h3>
                        <dl className="grid gap-x-8 gap-y-4 text-sm sm:grid-cols-2">
                          <div className="flex flex-col gap-1">
                            <dt className="text-muted-foreground">
                              {config.localization.userId}
                            </dt>
                            <dd className="flex min-w-0 items-center gap-1">
                              <code className="truncate text-xs">
                                {user.id}
                              </code>
                              <Button
                                aria-label={
                                  userIdCopied
                                    ? auth.localization.settings
                                        .copiedToClipboard
                                    : config.localization.copyUserId
                                }
                                onClick={() => copyUserId(user.id)}
                                size="icon-xs"
                                type="button"
                                variant="ghost"
                              >
                                {userIdCopied ? <CheckIcon /> : <CopyIcon />}
                              </Button>
                            </dd>
                          </div>
                          <div className="flex flex-col gap-1">
                            <dt className="text-muted-foreground">
                              {config.localization.created}
                            </dt>
                            <dd>{formatDate(user.createdAt)}</dd>
                          </div>
                          <div className="flex flex-col gap-1">
                            <dt className="text-muted-foreground">
                              {config.localization.status}
                            </dt>
                            <dd>
                              <Badge
                                variant={
                                  user.banned ? "destructive" : "secondary"
                                }
                              >
                                {user.banned
                                  ? config.localization.banned
                                  : config.localization.active}
                              </Badge>
                            </dd>
                          </div>
                          {user.banned && user.banReason ? (
                            <div className="flex flex-col gap-1">
                              <dt className="text-muted-foreground">
                                {config.localization.banReason}
                              </dt>
                              <dd>{user.banReason}</dd>
                            </div>
                          ) : null}
                          {user.banned && user.banExpires ? (
                            <div className="flex flex-col gap-1">
                              <dt className="text-muted-foreground">
                                {config.localization.banExpires}
                              </dt>
                              <dd>{formatDate(user.banExpires)}</dd>
                            </div>
                          ) : null}
                        </dl>
                      </section>
                      <Separator />
                      <section className="flex flex-col gap-4 p-6">
                        <h3 className="font-medium">
                          {config.localization.security}
                        </h3>
                        <div>
                          <Button
                            disabled={
                              canSetPassword.isPending ||
                              !canSetPassword.data?.success
                            }
                            onClick={() => setPasswordOpen(true)}
                            type="button"
                            variant="outline"
                          >
                            <KeyRoundIcon />
                            {config.localization.setPassword}
                          </Button>
                        </div>
                      </section>
                      <Separator />
                      <section className="flex flex-col gap-4 p-6">
                        <h3 className="font-medium">
                          {config.localization.dangerZone}
                        </h3>
                        <div className="flex flex-wrap gap-2">
                          <Button
                            disabled={
                              canBan.isPending ||
                              !canBan.data?.success ||
                              isSelf
                            }
                            onClick={() =>
                              user.banned
                                ? unban.mutate({ userId: user.id })
                                : setDangerousAction("ban")
                            }
                            type="button"
                            variant="outline"
                          >
                            <BanIcon />
                            {user.banned
                              ? config.localization.unbanUser
                              : config.localization.banUser}
                          </Button>
                          <Button
                            disabled={
                              canDelete.isPending ||
                              !canDelete.data?.success ||
                              isSelf
                            }
                            onClick={() => setDangerousAction("delete")}
                            type="button"
                            variant="destructive"
                          >
                            <Trash2Icon />
                            {config.localization.deleteUser}
                          </Button>
                        </div>
                        {unban.error ? (
                          <FieldError>
                            {getAdminErrorMessage(unban.error)}
                          </FieldError>
                        ) : null}
                      </section>
                    </div>
                    <div className="flex flex-col-reverse gap-2 border-t bg-muted/50 px-6 py-4 sm:flex-row sm:justify-end">
                      <Button
                        onClick={() => onOpenChange(false)}
                        type="button"
                        variant="outline"
                      >
                        {config.localization.cancel}
                      </Button>
                      <profileForm.Subscribe
                        selector={(state) => [
                          state.values.name,
                          state.values.email
                        ]}
                      >
                        {([name, email]) => (
                          <profileForm.AuthFormSubmitButton
                            disabled={
                              !name.trim() ||
                              !email.trim() ||
                              updateUser.isPending ||
                              setRoleMutation.isPending ||
                              canUpdate.isPending ||
                              canSetRole.isPending ||
                              (!canUpdate.data?.success &&
                                (!canSetRole.data?.success || isSelf))
                            }
                          >
                            {config.localization.saveChanges}
                          </profileForm.AuthFormSubmitButton>
                        )}
                      </profileForm.Subscribe>
                    </div>
                  </profileForm.AuthFormRoot>
                </profileForm.AppForm>
              </TabsContent>
              <TabsContent
                className="min-h-0 overflow-y-auto p-6"
                value="sessions"
              >
                <div className="flex flex-col gap-3">
                  {sessionsPermission.isPending || sessions.isPending ? (
                    skeletonRowIds
                      .slice(0, 3)
                      .map((id) => (
                        <Skeleton
                          className="h-20 w-full"
                          key={`session-${id}`}
                        />
                      ))
                  ) : !sessionsPermission.data?.success ? (
                    <p className="text-sm text-muted-foreground">
                      {config.localization.accessDeniedDescription}
                    </p>
                  ) : sessions.data?.sessions.length ? (
                    <>
                      <Button
                        className="self-end"
                        disabled={
                          canRevoke.isPending ||
                          !canRevoke.data?.success ||
                          isSelf
                        }
                        onClick={() => setDangerousAction("revokeAll")}
                        variant="outline"
                      >
                        {config.localization.revokeAllSessions}
                      </Button>
                      {sessions.data.sessions.map((item) => (
                        <div
                          className="flex items-start justify-between gap-3 rounded-lg border p-3"
                          key={item.id}
                        >
                          <div className="min-w-0 text-sm">
                            <div className="truncate font-medium">
                              {item.userAgent || config.localization.sessions}
                            </div>
                            <div className="text-xs text-muted-foreground">
                              {formatDate(item.createdAt)} ·{" "}
                              {formatDate(item.expiresAt)}
                            </div>
                            {config.showIpAddress && item.ipAddress ? (
                              <div className="mt-1 font-mono text-xs text-muted-foreground">
                                {item.ipAddress}
                              </div>
                            ) : null}
                          </div>
                          <Button
                            aria-label={config.localization.revoke}
                            disabled={
                              revokeSession.isPending ||
                              canRevoke.isPending ||
                              !canRevoke.data?.success ||
                              isSelf
                            }
                            onClick={() =>
                              revokeSession.mutate({ sessionToken: item.token })
                            }
                            size="icon-sm"
                            variant="ghost"
                          >
                            <Trash2Icon />
                          </Button>
                        </div>
                      ))}
                    </>
                  ) : (
                    <p className="py-8 text-center text-sm text-muted-foreground">
                      {config.localization.noSessions}
                    </p>
                  )}
                </div>
              </TabsContent>
              {contributedTabs.map((tab) => {
                const ContributedTab = tab.component
                return (
                  <TabsContent
                    className="min-h-0 overflow-y-auto p-6"
                    key={tab.value}
                    value={tab.value}
                  >
                    <ContributedTab userId={user.id} />
                  </TabsContent>
                )
              })}
            </Tabs>
          ) : (
            <AdminState
              icon={<ShieldAlertIcon />}
              title={config.localization.loadUsersError}
              description={config.localization.loadUsersErrorDescription}
            />
          )}
        </DialogContent>
      </Dialog>
      <PasswordDialog
        open={passwordOpen}
        onOpenChange={setPasswordOpen}
        userId={user?.id}
      />
      <AlertDialog
        open={Boolean(dangerousAction)}
        onOpenChange={(value) => !value && closeDangerousAction()}
      >
        <AlertDialogContent>
          <AlertDialogHeader>
            <AlertDialogTitle>{dangerLabel}</AlertDialogTitle>
            <AlertDialogDescription>{user?.email}</AlertDialogDescription>
          </AlertDialogHeader>
          {dangerousAction === "ban" ? (
            <FieldGroup>
              <Field>
                <FieldLabel htmlFor="admin-ban-reason">
                  {config.localization.banReason}
                </FieldLabel>
                <InputGroup>
                  <InputGroupInput
                    id="admin-ban-reason"
                    onChange={(event) => setBanReason(event.target.value)}
                    value={banReason}
                  />
                </InputGroup>
              </Field>
              <Field>
                <FieldLabel htmlFor="admin-ban-duration">
                  {config.localization.banDuration}
                </FieldLabel>
                <InputGroup>
                  <InputGroupInput
                    id="admin-ban-duration"
                    min="1"
                    onChange={(event) => setBanDuration(event.target.value)}
                    step="1"
                    type="number"
                    value={banDuration}
                  />
                </InputGroup>
                <p className="text-xs text-muted-foreground">
                  {config.localization.banDurationDescription}
                </p>
              </Field>
            </FieldGroup>
          ) : null}
          {dangerousError ? <FieldError>{dangerousError}</FieldError> : null}
          <AlertDialogFooter>
            <AlertDialogCancel>{config.localization.cancel}</AlertDialogCancel>
            <AlertDialogAction
              disabled={
                isSelf ||
                dangerousMutation.isPending ||
                (dangerousAction === "ban" && banDurationSeconds === null)
              }
              onClick={(event) => {
                event.preventDefault()
                confirm()
              }}
              variant={dangerousAction === "delete" ? "destructive" : "default"}
            >
              {dangerLabel}
            </AlertDialogAction>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>
    </>
  )
}

function PasswordDialog({
  open,
  onOpenChange,
  userId
}: {
  open: boolean
  onOpenChange: (open: boolean) => void
  userId?: string
}) {
  const auth = useAuth<AdminAuthClient>()
  const config = useAuthPlugin(adminPlugin)
  const [password, setPassword] = useState("")
  const [errorMessage, setErrorMessage] = useState<string>()
  const mutation = useMutation(
    setAdminUserPasswordOptions(auth.authClient, () => {
      setTimeout(() => mutation.reset(), 0)
    })
  )
  const close = () => {
    setPassword("")
    setErrorMessage(undefined)
    mutation.reset()
    onOpenChange(false)
  }
  const submit = (event: FormEvent) => {
    event.preventDefault()
    setErrorMessage(undefined)
    if (userId)
      mutation.mutate(
        { userId, newPassword: password },
        {
          onError: (error) => setErrorMessage(getAdminErrorMessage(error)),
          onSuccess: close
        }
      )
  }
  return (
    <Dialog
      open={open}
      onOpenChange={(value) => (value ? onOpenChange(true) : close())}
    >
      <DialogContent>
        <form className="flex flex-col gap-4" onSubmit={submit}>
          <DialogHeader>
            <DialogTitle>{config.localization.setPassword}</DialogTitle>
            <DialogDescription>
              {config.localization.userDetails}
            </DialogDescription>
          </DialogHeader>
          <Field data-invalid={Boolean(errorMessage)}>
            <FieldLabel htmlFor="admin-new-password">
              {config.localization.password}
            </FieldLabel>
            <InputGroup>
              <InputGroupInput
                aria-invalid={Boolean(errorMessage)}
                autoComplete="new-password"
                id="admin-new-password"
                onChange={(event) => setPassword(event.target.value)}
                required
                type="password"
                value={password}
              />
            </InputGroup>
            <FieldError>{errorMessage}</FieldError>
          </Field>
          <DialogFooter>
            <Button onClick={close} type="button" variant="outline">
              {config.localization.cancel}
            </Button>
            <Button disabled={!password || mutation.isPending} type="submit">
              {config.localization.setPassword}
            </Button>
          </DialogFooter>
        </form>
      </DialogContent>
    </Dialog>
  )
}
examples/start-shadcn-example/src/components/auth/admin/admin.tsx
"use client"

import type { AdminView } from "@better-auth-ui/core"
import { useAuth, useAuthenticate, useAuthPlugin } from "@better-auth-ui/react"
import { ShieldAlertIcon, UsersIcon } from "lucide-react"

import { Button } from "@/components/ui/button"
import { adminPlugin } from "@/lib/auth/admin-plugin"
import { cn } from "cn"

import { AdminUsers } from "./admin-users"

export type AdminProps = {
  className?: string
  hideNav?: boolean
  path?: string
  view?: AdminView | string
}

/** Render a finite, static administration route and plugin-contributed tabs. */
export function Admin({ className, hideNav, path, view }: AdminProps) {
  const { authClient, basePaths, plugins, viewPaths } = useAuth()
  const { localization } = useAuthPlugin(adminPlugin)
  useAuthenticate(authClient)

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

  const contributedTabs = plugins.flatMap((plugin) => plugin.adminTabs ?? [])
  const currentView =
    view ??
    (viewPaths.admin.users === path ? "users" : undefined) ??
    contributedTabs.find((tab) => tab.path === path)?.id
  const contributedView = contributedTabs.find((tab) => tab.id === currentView)

  return (
    <div className={cn("flex w-full flex-col gap-6", className)}>
      {!hideNav && (
        <nav aria-label={localization.admin} className="flex gap-1 border-b">
          <Button
            aria-current={currentView === "users" ? "page" : undefined}
            asChild
            className="rounded-b-none"
            variant={currentView === "users" ? "secondary" : "ghost"}
          >
            <a href={`${basePaths.admin}/${viewPaths.admin.users}`}>
              <UsersIcon />
              {localization.users}
            </a>
          </Button>

          {contributedTabs.map((tab) => (
            <Button
              key={`${tab.id}-${tab.path}`}
              aria-current={currentView === tab.id ? "page" : undefined}
              asChild
              className="rounded-b-none"
              variant={currentView === tab.id ? "secondary" : "ghost"}
            >
              <a href={`${basePaths.admin}/${tab.path}`}>{tab.label}</a>
            </Button>
          ))}
        </nav>
      )}

      {currentView === "users" ? (
        <AdminUsers />
      ) : contributedView ? (
        <contributedView.component />
      ) : (
        <div className="flex min-h-64 flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8 text-center">
          <ShieldAlertIcon className="size-8 text-muted-foreground" />
          <div className="flex flex-col gap-1">
            <h2 className="font-medium">{localization.unknownView}</h2>
            <p className="max-w-md text-sm text-muted-foreground">
              {localization.unknownViewDescription} &quot;{path ?? view}&quot;
            </p>
          </div>
        </div>
      )}
    </div>
  )
}
examples/start-shadcn-example/src/components/auth/admin/stop-impersonating.tsx
"use client"

import {
  type AdminAuthClient,
  isImpersonatingSession
} from "@better-auth-ui/core/plugins/admin"
import { useAuth, useAuthPlugin, useSession } from "@better-auth-ui/react"
import { useStopImpersonating } from "@better-auth-ui/react/plugins/admin"
import { UserRoundCheck } from "lucide-react"

import { DropdownMenuItem } from "@/components/ui/dropdown-menu"
import { Spinner } from "@/components/ui/spinner"
import { adminPlugin } from "@/lib/auth/admin-plugin"

export type StopImpersonatingProps = {
  className?: string
}

/**
 * Restore the administrator's session when the current session is
 * impersonating another user.
 */
export function StopImpersonating({ className }: StopImpersonatingProps) {
  const { authClient } = useAuth()
  const { localization } = useAuthPlugin(adminPlugin)
  const { data: session } = useSession(authClient)
  const stopImpersonating = useStopImpersonating(authClient as AdminAuthClient)

  if (!isImpersonatingSession(session)) {
    return null
  }

  return (
    <DropdownMenuItem
      className={className}
      disabled={stopImpersonating.isPending}
      onClick={() => stopImpersonating.mutate(undefined)}
    >
      {stopImpersonating.isPending ? (
        <Spinner />
      ) : (
        <UserRoundCheck className="text-muted-foreground" />
      )}

      {localization.stopImpersonating}
    </DropdownMenuItem>
  )
}
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/server-table-state.ts
"use client"

import { useCreateAtom, useSelector } from "@tanstack/react-store"
import {
  type ColumnFiltersState,
  functionalUpdate,
  type PaginationState,
  type SortingState,
  type Updater
} from "@tanstack/react-table"
import { useCallback, useEffect, useMemo } from "react"

export function useServerTableState({
  initialSorting = [],
  pageSize
}: {
  initialSorting?: SortingState
  pageSize: number
}) {
  const columnFiltersAtom = useCreateAtom<ColumnFiltersState>([])
  const globalFilterAtom = useCreateAtom("")
  const paginationAtom = useCreateAtom<PaginationState>({
    pageIndex: 0,
    pageSize
  })
  const sortingAtom = useCreateAtom<SortingState>(initialSorting)
  const columnFilters = useSelector(columnFiltersAtom)
  const globalFilter = useSelector(globalFilterAtom)
  const pagination = useSelector(paginationAtom)
  const sorting = useSelector(sortingAtom)
  const atoms = useMemo(
    () => ({
      columnFilters: columnFiltersAtom,
      globalFilter: globalFilterAtom,
      pagination: paginationAtom,
      sorting: sortingAtom
    }),
    [columnFiltersAtom, globalFilterAtom, paginationAtom, sortingAtom]
  )

  useEffect(() => {
    const resetPage = () => {
      const current = paginationAtom.get()
      if (current.pageIndex !== 0) {
        paginationAtom.set({ ...current, pageIndex: 0 })
      }
    }
    const subscriptions = [
      columnFiltersAtom.subscribe(resetPage),
      globalFilterAtom.subscribe(resetPage),
      sortingAtom.subscribe(resetPage)
    ]

    return () => {
      for (const subscription of subscriptions) subscription.unsubscribe()
    }
  }, [columnFiltersAtom, globalFilterAtom, paginationAtom, sortingAtom])

  const setPagination = useCallback(
    (updater: Updater<PaginationState>) => {
      paginationAtom.set((current) => functionalUpdate(updater, current))
    },
    [paginationAtom]
  )

  return {
    atoms,
    columnFilters,
    globalFilter,
    pagination,
    setPagination,
    sorting
  }
}
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/admin-plugin.ts
import { createAuthPlugin } from "@better-auth-ui/core"
import {
  type AdminPluginOptions,
  adminPlugin as coreAdminPlugin
} from "@better-auth-ui/core/plugins/admin"

import { StopImpersonating } from "@/components/auth/admin/stop-impersonating"

export const adminPlugin = createAuthPlugin(
  coreAdminPlugin.id,
  (options: AdminPluginOptions = {}) => ({
    ...coreAdminPlugin(options),
    userMenuItems: [StopImpersonating]
  })
)
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
>

More from better-auth-ui

Browse all components in this library →