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 · lighticonslucide-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} "{path ?? view}"
</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
>