better-auth-ui
componentapi-keysource: ok · refreshed
API Key
API Key plugin: list, create, copy, and revoke API keys. Registers an API Keys security card in account settings.
npx shadcn add "https://better-auth-ui.com/r/{style}/api-key.json"free
Where it goes
signupsettings-form
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-dialogavatarbadgebuttoncalendarcardcheckboxcomboboxdialogemptyfieldinputinput-groupitemlabelpopoverselectskeletonslidersonnerspinnerswitchtextarea
Files31 · source cached 10/8/2026, 10:50:06 AM
examples/start-shadcn-example/src/components/auth/additional-field.tsx
"use client"
import {
type AdditionalField as AdditionalFieldConfig,
type AdditionalFieldFormValue,
getFormFieldErrors,
resolveInputType
} from "@better-auth-ui/core"
import { useAuth, useCopyToClipboard } from "@better-auth-ui/react"
import { format } from "date-fns"
import { CalendarIcon, Check, ChevronDownIcon, Copy } from "lucide-react"
import { type ComponentType, useRef, useState } from "react"
import { toast } from "sonner"
import { buttonVariants } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import { Checkbox } from "@/components/ui/checkbox"
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList
} from "@/components/ui/combobox"
import {
Field,
FieldContent,
FieldError,
FieldLabel
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput
} from "@/components/ui/input-group"
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@/components/ui/popover"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select"
import { Slider } from "@/components/ui/slider"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { cn } from "cn"
export type AdditionalFieldProps = {
name: string
field: AdditionalFieldConfig
value: AdditionalFieldFormValue
onBlur: () => void
onChange: (value: AdditionalFieldFormValue) => void
isInvalid?: boolean
errors?: unknown[]
isPending?: boolean
/** Complete suffix appended to labels for fields that are not required. */
optionalLabel?: string
}
function valueToString(value: AdditionalFieldFormValue) {
if (value == null) return ""
return value instanceof Date ? value.toISOString() : String(value)
}
/** Convert a `defaultValue` into a `Date` for the calendar. */
function toDate(value: unknown): Date | undefined {
if (value instanceof Date) return value
if (typeof value === "string") {
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? undefined : parsed
}
return undefined
}
/** Format a Date as `HH:mm:ss` for an `<input type="time">`. */
function formatTime(date: Date) {
const pad = (n: number) => n.toString().padStart(2, "0")
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
/**
* Icon-only copy button used as an `InputGroupAddon`. `getValue` is invoked
* lazily on click so the button copies the input's *live* value rather than a
* stale snapshot — important when paired with editable inputs.
*/
function CopyButton({
getValue,
isDisabled
}: {
getValue: () => string | undefined
isDisabled?: boolean
}) {
const { localization } = useAuth()
const { copied, copy } = useCopyToClipboard({
onError: (error) => {
console.error("[Better Auth UI] Copy failed", error)
toast.error(localization.errors.copyFailed)
}
})
async function handleCopy() {
const value = getValue()
if (!value) return
await copy(value)
}
return (
<InputGroupButton
aria-label={
copied
? localization.settings.copiedToClipboard
: localization.settings.copyToClipboard
}
title={
copied
? localization.settings.copiedToClipboard
: localization.settings.copyToClipboard
}
onClick={handleCopy}
disabled={isDisabled}
>
{copied ? <Check /> : <Copy />}
</InputGroupButton>
)
}
/** Renders a single additional user field via shadcn primitives. */
export function AdditionalField({
name,
field: configuredField,
value,
onBlur,
onChange,
isInvalid,
errors,
isPending,
optionalLabel
}: AdditionalFieldProps) {
const field =
optionalLabel && !configuredField.required
? {
...configuredField,
label: (
<>
{configuredField.label}
{optionalLabel}
</>
)
}
: configuredField
const inputType = resolveInputType(field)
const fieldErrors = getFormFieldErrors(errors ?? [])
if (field.render) {
const FieldRenderer = field.render as ComponentType<AdditionalFieldProps>
return (
<FieldRenderer
name={name}
field={field}
value={value}
onBlur={onBlur}
onChange={onChange}
isInvalid={isInvalid}
errors={errors}
isPending={isPending}
optionalLabel={optionalLabel}
/>
)
}
if (inputType === "hidden") {
return (
<input type="hidden" name={name} value={valueToString(value)} readOnly />
)
}
if (inputType === "textarea") {
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={name}>{field.label}</FieldLabel>
<Textarea
id={name}
name={name}
value={valueToString(value)}
onBlur={onBlur}
onChange={(event) => onChange(event.target.value || null)}
placeholder={field.placeholder}
required={field.required}
readOnly={field.readOnly}
disabled={isPending}
aria-invalid={isInvalid}
/>
<FieldError errors={fieldErrors} />
</Field>
)
}
if (inputType === "number") {
const maxFractionDigits = field.formatOptions?.maximumFractionDigits
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={name}>{field.label}</FieldLabel>
<Input
id={name}
name={name}
type="number"
inputMode={maxFractionDigits ? "decimal" : "numeric"}
min={field.min}
max={field.max}
step={
field.step ??
(maxFractionDigits ? 1 / 10 ** maxFractionDigits : undefined)
}
value={typeof value === "number" ? value : ""}
onBlur={onBlur}
onChange={(event) =>
onChange(
event.target.value === "" ? null : event.target.valueAsNumber
)
}
placeholder={field.placeholder}
required={field.required}
readOnly={field.readOnly}
disabled={isPending}
aria-invalid={isInvalid}
/>
<FieldError errors={fieldErrors} />
</Field>
)
}
if (inputType === "slider") {
return (
<SliderField
name={name}
field={field}
value={value}
onBlur={onBlur}
onChange={onChange}
isInvalid={isInvalid}
errors={errors}
isPending={isPending}
/>
)
}
if (inputType === "switch") {
return (
<Field data-invalid={isInvalid} orientation="horizontal">
<Switch
id={name}
name={name}
checked={value === true}
onBlur={onBlur}
onCheckedChange={onChange}
disabled={isPending || field.readOnly}
aria-invalid={isInvalid}
/>
<FieldContent>
<FieldLabel htmlFor={name}>{field.label}</FieldLabel>
</FieldContent>
<FieldError errors={fieldErrors} />
</Field>
)
}
if (inputType === "checkbox") {
return (
<Field data-invalid={isInvalid} orientation="horizontal">
<Checkbox
id={name}
name={name}
checked={value === true}
onBlur={onBlur}
onCheckedChange={(checked) => onChange(checked === true)}
required={field.required}
disabled={isPending || field.readOnly}
aria-invalid={isInvalid}
/>
<FieldContent>
<FieldLabel htmlFor={name}>{field.label}</FieldLabel>
</FieldContent>
<FieldError errors={fieldErrors} />
</Field>
)
}
if (inputType === "select") {
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={name}>{field.label}</FieldLabel>
<Select
name={name}
value={valueToString(value) || undefined}
onValueChange={(nextValue) => onChange(nextValue)}
required={field.required}
disabled={isPending || field.readOnly}
>
<SelectTrigger
id={name}
className="w-full"
onBlur={onBlur}
aria-invalid={isInvalid}
>
<SelectValue placeholder={field.placeholder} />
</SelectTrigger>
<SelectContent>
{field.options?.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={fieldErrors} />
</Field>
)
}
if (inputType === "combobox") {
const selectedOption = field.options?.find(
(option) => option.value === valueToString(value)
)
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={name}>{field.label}</FieldLabel>
<Combobox
items={field.options ?? []}
name={name}
value={selectedOption ?? null}
onValueChange={(option) => onChange(option?.value ?? null)}
required={field.required}
disabled={isPending || field.readOnly}
>
<ComboboxInput
placeholder={field.placeholder}
id={name}
onBlur={onBlur}
aria-invalid={isInvalid}
/>
<ComboboxContent>
<ComboboxEmpty>No items found.</ComboboxEmpty>
<ComboboxList>
{(option) => (
<ComboboxItem key={option.value} value={option}>
{option.label}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<FieldError errors={fieldErrors} />
</Field>
)
}
if (inputType === "date" || inputType === "datetime") {
return (
<DateInput
name={name}
field={field}
value={value}
onBlur={onBlur}
onChange={onChange}
isInvalid={isInvalid}
errors={errors}
isPending={isPending}
/>
)
}
return (
<InputField
name={name}
field={field}
value={value}
onBlur={onBlur}
onChange={onChange}
isInvalid={isInvalid}
errors={errors}
isPending={isPending}
/>
)
}
function InputField({
name,
field,
value,
onBlur,
onChange,
isInvalid,
errors,
isPending
}: AdditionalFieldProps) {
const inputRef = useRef<HTMLInputElement>(null)
const fieldErrors = getFormFieldErrors(errors ?? [])
const hasPrefix = field.prefix != null
const hasSuffix = field.suffix != null || field.copyable
const isNumeric = field.type === "number"
const maxFractionDigits = field.formatOptions?.maximumFractionDigits
const nativeInputType = isNumeric ? "number" : undefined
const nativeInputMode = isNumeric
? maxFractionDigits
? "decimal"
: "numeric"
: undefined
const nativeStep = maxFractionDigits ? 1 / 10 ** maxFractionDigits : undefined
if (hasPrefix || hasSuffix) {
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={name}>{field.label}</FieldLabel>
<InputGroup>
{hasPrefix && (
<InputGroupAddon align="inline-start">
{field.prefix}
</InputGroupAddon>
)}
<InputGroupInput
ref={inputRef}
id={name}
name={name}
type={nativeInputType}
inputMode={nativeInputMode}
step={nativeStep}
value={valueToString(value)}
onBlur={onBlur}
onChange={(event) => onChange(event.target.value || null)}
placeholder={field.placeholder}
required={field.required}
readOnly={field.readOnly}
disabled={isPending}
aria-invalid={isInvalid}
/>
{field.copyable ? (
<InputGroupAddon align="inline-end">
<CopyButton
getValue={() => inputRef.current?.value}
isDisabled={isPending}
/>
</InputGroupAddon>
) : (
field.suffix != null && (
<InputGroupAddon align="inline-end">
{field.suffix}
</InputGroupAddon>
)
)}
</InputGroup>
<FieldError errors={fieldErrors} />
</Field>
)
}
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={name}>{field.label}</FieldLabel>
<Input
id={name}
name={name}
type={nativeInputType}
inputMode={nativeInputMode}
step={nativeStep}
value={valueToString(value)}
onBlur={onBlur}
onChange={(event) => onChange(event.target.value || null)}
placeholder={field.placeholder}
required={field.required}
readOnly={field.readOnly}
disabled={isPending}
aria-invalid={isInvalid}
/>
<FieldError errors={fieldErrors} />
</Field>
)
}
/**
* Slider field. Radix Slider doesn't render the current value, so we render
* it next to the label and control the state to keep the displayed value in
* sync. The selected value is submitted via the underlying Radix `name` prop.
*/
function SliderField({
name,
field,
value,
onBlur,
onChange,
isInvalid,
errors,
isPending
}: AdditionalFieldProps) {
const maxFractionDigits = field.formatOptions?.maximumFractionDigits
const min = field.min ?? 0
const max = field.max ?? 100
const step =
field.step ?? (maxFractionDigits ? 1 / 10 ** maxFractionDigits : 1)
const numericValue = typeof value === "number" ? value : min
const fieldErrors = getFormFieldErrors(errors ?? [])
const formatter = new Intl.NumberFormat(undefined, field.formatOptions)
return (
<Field data-invalid={isInvalid}>
<div className="flex items-center justify-between gap-2">
<FieldLabel htmlFor={name}>{field.label}</FieldLabel>
<span className="text-sm text-muted-foreground tabular-nums">
{formatter.format(numericValue)}
</span>
</div>
<Slider
id={name}
name={name}
value={[numericValue]}
onBlur={onBlur}
onValueChange={(nextValue) =>
onChange((Array.isArray(nextValue) ? nextValue[0] : nextValue) ?? min)
}
min={min}
max={max}
step={step}
disabled={isPending || field.readOnly}
aria-invalid={isInvalid}
/>
<FieldError errors={fieldErrors} />
</Field>
)
}
/**
* Date / datetime input. Composes `Popover` + `Calendar` for the date and
* (optionally) `<input type="time">` for the time. Submits the combined ISO
* value via a hidden `<input>` so it shows up in `FormData`.
*/
function DateInput({
name,
field,
value,
onBlur,
onChange,
isInvalid,
errors,
isPending
}: AdditionalFieldProps) {
const { localization } = useAuth()
const inputType = resolveInputType(field)
const isDateTime = inputType === "datetime"
const fieldErrors = getFormFieldErrors(errors ?? [])
const date = toDate(value)
const [time, setTime] = useState<string>(
isDateTime && date ? formatTime(date) : ""
)
const [open, setOpen] = useState(false)
// Compose the hidden form value: ISO date for "date", ISO datetime for
// "datetime" (date + time).
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={`${name}-date`}>{field.label}</FieldLabel>
<div className="relative flex gap-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
type="button"
id={`${name}-date`}
data-empty={!date}
aria-invalid={isInvalid}
aria-required={field.required}
onBlur={onBlur}
disabled={isPending || field.readOnly}
className={cn(
buttonVariants({ variant: "outline" }),
"flex-1 justify-between font-normal",
"data-[empty=true]:text-muted-foreground"
)}
>
{date ? format(date, "PPP") : <span>{field.placeholder}</span>}
{isDateTime ? <ChevronDownIcon /> : <CalendarIcon />}
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
<Calendar
mode="single"
selected={date}
defaultMonth={date}
captionLayout="dropdown"
onSelect={(value) => {
if (!value) {
onChange(null)
} else {
const nextValue = new Date(value)
if (isDateTime && time.trim()) {
const [hours = "0", minutes = "0", seconds = "0"] =
time.split(":")
nextValue.setHours(
Number(hours),
Number(minutes),
Number(seconds),
0
)
} else {
nextValue.setHours(0, 0, 0, 0)
}
onChange(nextValue)
}
if (!isDateTime) setOpen(false)
}}
/>
</PopoverContent>
</Popover>
{isDateTime && (
<Field className="w-32">
<FieldLabel htmlFor={`${name}-time`} className="sr-only">
{localization.settings.time}
</FieldLabel>
<Input
type="time"
id={`${name}-time`}
step="1"
value={time}
onChange={(event) => {
const nextTime = event.target.value
setTime(nextTime)
if (!date) return
const nextValue = new Date(date)
const [hours = "0", minutes = "0", seconds = "0"] =
nextTime.split(":")
nextValue.setHours(
Number(hours),
Number(minutes),
Number(seconds),
0
)
onChange(nextValue)
}}
disabled={isPending || field.readOnly}
className="appearance-none bg-background [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
/>
</Field>
)}
</div>
<FieldError errors={fieldErrors} />
</Field>
)
}
examples/start-shadcn-example/src/components/auth/api-key/api-key-skeleton.tsx
"use client"
import { Item, ItemContent, ItemMedia } from "@/components/ui/item"
import { Skeleton } from "@/components/ui/skeleton"
export function ApiKeySkeleton() {
return (
<Item>
<ItemMedia>
<Skeleton className="size-10 rounded-md" />
</ItemMedia>
<ItemContent>
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-36" />
<Skeleton className="h-3 w-32" />
</ItemContent>
</Item>
)
}
examples/start-shadcn-example/src/components/auth/api-key/api-key.tsx
"use client"
import type { ListedApiKey } from "@better-auth-ui/core/plugins/api-key"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import { Key, Pencil, X } from "lucide-react"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle
} from "@/components/ui/item"
import { apiKeyPlugin } from "@/lib/auth/api-key-plugin"
import { DeleteApiKeyDialog } from "./delete-api-key-dialog"
import { EditApiKeyDialog } from "./edit-api-key-dialog"
export type ApiKeyProps = {
apiKey: ListedApiKey
/** Hide the row's delete button (e.g., when caller lacks `apiKey:delete`). */
hideDelete?: boolean
/** Hide the row's edit button (e.g., when caller lacks `apiKey:update`). */
hideUpdate?: boolean
/** Scope the delete payload to an organization (sets `configId`). */
organizationId?: string
}
export function ApiKey({
apiKey,
hideDelete,
hideUpdate,
organizationId
}: ApiKeyProps) {
const { localization } = useAuth()
const { localization: apiKeyLocalization } = useAuthPlugin(apiKeyPlugin)
const [deleteOpen, setDeleteOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const preview = `${apiKey.start}${"*".repeat(16)}`
return (
<Item>
<ItemMedia variant="icon">
<Key />
</ItemMedia>
<ItemContent>
<ItemTitle>{apiKey.name || apiKeyLocalization.apiKey}</ItemTitle>
<ItemDescription className="font-mono">{preview}</ItemDescription>
<ItemDescription>
{apiKeyLocalization.created}{" "}
{new Date(apiKey.createdAt).toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short"
})}
</ItemDescription>
<ItemDescription>
{apiKey.expiresAt
? `${apiKeyLocalization.expires} ${new Date(
apiKey.expiresAt
).toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short"
})}`
: apiKeyLocalization.neverExpires}
</ItemDescription>
<ItemDescription>
{apiKey.enabled
? apiKeyLocalization.enabled
: apiKeyLocalization.disabled}
{` · ${apiKeyLocalization.requests}: ${apiKey.requestCount}`}
{apiKey.remaining === null
? ""
: ` · ${apiKeyLocalization.remaining}: ${apiKey.remaining}`}
</ItemDescription>
<ItemDescription>
{apiKeyLocalization.lastRequest}:{" "}
{apiKey.lastRequest
? new Date(apiKey.lastRequest).toLocaleString()
: apiKeyLocalization.neverRequested}
</ItemDescription>
</ItemContent>
<ItemActions>
{!hideUpdate && (
<>
<Button
variant="outline"
size="sm"
onClick={() => setEditOpen(true)}
>
<Pencil />
{apiKeyLocalization.editApiKey}
</Button>
<EditApiKeyDialog
apiKey={apiKey}
open={editOpen}
onOpenChange={setEditOpen}
/>
</>
)}
{!hideDelete && (
<>
<Button
variant="outline"
size="sm"
onClick={() => setDeleteOpen(true)}
aria-label={apiKeyLocalization.deleteApiKey}
>
<X />
{localization.settings.delete}
</Button>
<DeleteApiKeyDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
apiKey={apiKey}
organizationId={organizationId}
/>
</>
)}
</ItemActions>
</Item>
)
}
examples/start-shadcn-example/src/components/auth/api-key/api-keys-empty.tsx
"use client"
import { useAuthPlugin } from "@better-auth-ui/react"
import { Key } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle
} from "@/components/ui/empty"
import { apiKeyPlugin } from "@/lib/auth/api-key-plugin"
export type ApiKeysEmptyProps = {
onCreatePress: () => void
hideCreate?: boolean
}
export function ApiKeysEmpty({ onCreatePress, hideCreate }: ApiKeysEmptyProps) {
const { localization: apiKeyLocalization } = useAuthPlugin(apiKeyPlugin)
return (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<Key />
</EmptyMedia>
<EmptyTitle>{apiKeyLocalization.noApiKeys}</EmptyTitle>
<EmptyDescription>
{apiKeyLocalization.apiKeysDescription}
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
{!hideCreate && (
<Button size="sm" onClick={onCreatePress}>
{apiKeyLocalization.createApiKey}
</Button>
)}
</EmptyContent>
</Empty>
)
}
examples/start-shadcn-example/src/components/auth/api-key/api-keys.tsx
"use client"
import { getLookaheadPage } from "@better-auth-ui/core"
import type {
ApiKeyAuthClient,
ListedApiKey
} from "@better-auth-ui/core/plugins/api-key"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import { useListApiKeys } from "@better-auth-ui/react/plugins/api-key"
import {
createTableHook,
rowPaginationFeature,
rowSortingFeature,
type SortingState,
tableFeatures
} from "@tanstack/react-table"
import { Fragment, useEffect, useMemo, useState } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { ItemGroup, ItemSeparator } from "@/components/ui/item"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select"
import { apiKeyPlugin } from "@/lib/auth/api-key-plugin"
import { cn } from "cn"
import { useServerTableState } from "../server-table-state"
import { ApiKey } from "./api-key"
import { ApiKeySkeleton } from "./api-key-skeleton"
import { ApiKeysEmpty } from "./api-keys-empty"
import { CreateApiKeyDialog } from "./create-api-key-dialog"
const { createAppColumnHelper, useAppTable: useApiKeyTable } = createTableHook({
enableMultiSort: false,
enableSortingRemoval: false,
sortDescFirst: false,
features: tableFeatures({ rowPaginationFeature, rowSortingFeature })
})
const apiKeyColumnHelper = createAppColumnHelper<ListedApiKey>()
const apiKeyColumns = apiKeyColumnHelper.columns([
apiKeyColumnHelper.accessor("createdAt", { id: "createdAt" }),
apiKeyColumnHelper.accessor("name", { id: "name" })
])
const EMPTY_API_KEYS: ListedApiKey[] = []
const INITIAL_API_KEY_SORTING: SortingState = [{ id: "createdAt", desc: true }]
export type ApiKeysProps = {
className?: string
/** Scope the list and create payload to an organization. */
organizationId?: string
/** Force the loading skeleton and disable the list query. */
isPending?: boolean
/** Hide the "Create API key" button (header + empty state). */
hideCreate?: boolean
/** Hide the per-row delete button on listed keys. */
hideDelete?: boolean
/** Hide the per-row edit button on listed keys. */
hideUpdate?: boolean
}
export function ApiKeys({
className,
organizationId,
isPending: isPendingProp,
hideCreate,
hideDelete,
hideUpdate
}: ApiKeysProps) {
const { authClient } = useAuth<ApiKeyAuthClient>()
const { localization: apiKeyLocalization, pageSize } =
useAuthPlugin(apiKeyPlugin)
const tableState = useServerTableState({
initialSorting: INITIAL_API_KEY_SORTING,
pageSize
})
const { pagination, setPagination, sorting } = tableState
const primarySort = sorting[0]
const sortBy = primarySort?.id === "name" ? "name" : "createdAt"
const sortDirection = primarySort?.desc ? "desc" : "asc"
const sort = `${sortBy}:${sortDirection}`
const {
data: listData,
isPending: isListPending,
isSuccess: isListSuccess
} = useListApiKeys(authClient, {
enabled: !isPendingProp,
query: {
limit: pagination.pageSize + 1,
offset: pagination.pageIndex * pagination.pageSize,
sortBy,
sortDirection,
...(organizationId ? { organizationId, configId: "organization" } : {})
}
})
const isPending = isPendingProp || isListPending
const page = useMemo(
() =>
getLookaheadPage(
listData?.apiKeys ?? EMPTY_API_KEYS,
pagination.pageSize
),
[listData?.apiKeys, pagination.pageSize]
)
useEffect(() => {
if (
isListSuccess &&
!isPendingProp &&
pagination.pageIndex > 0 &&
page.rows.length === 0
) {
setPagination((current) => ({
...current,
pageIndex: Math.max(0, current.pageIndex - 1)
}))
}
}, [
isListSuccess,
isPendingProp,
page.rows.length,
pagination.pageIndex,
setPagination
])
const table = useApiKeyTable(
{
atoms: tableState.atoms,
columns: apiKeyColumns,
data: page.rows,
getRowId: (apiKey) => apiKey.id,
manualPagination: true,
pageCount: -1,
manualSorting: true
},
() => null
)
const [createOpen, setCreateOpen] = useState(false)
return (
<div className={cn("flex flex-col gap-3", className)}>
<div className="flex items-end justify-between gap-3">
<h2 className="truncate text-sm font-semibold">
{apiKeyLocalization.apiKeys}
</h2>
{!hideCreate && (
<Button
className="shrink-0"
size="sm"
disabled={isPending}
onClick={() => setCreateOpen(true)}
>
{apiKeyLocalization.createApiKey}
</Button>
)}
</div>
<Select
value={sort}
onValueChange={(value) => {
const [id, direction] = value.split(":")
table.setSorting([{ id, desc: direction === "desc" }])
}}
>
<SelectTrigger aria-label={apiKeyLocalization.sortBy}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="createdAt:desc">
{apiKeyLocalization.newest}
</SelectItem>
<SelectItem value="createdAt:asc">
{apiKeyLocalization.oldest}
</SelectItem>
<SelectItem value="name:asc">
{apiKeyLocalization.nameAscending}
</SelectItem>
<SelectItem value="name:desc">
{apiKeyLocalization.nameDescending}
</SelectItem>
</SelectContent>
</Select>
<Card className="p-0">
<CardContent className="p-0">
{isPending ? (
<ApiKeySkeleton />
) : page.rows.length === 0 ? (
<ApiKeysEmpty
onCreatePress={() => setCreateOpen(true)}
hideCreate={hideCreate}
/>
) : (
<ItemGroup className="gap-0">
{table.getRowModel().rows.map((row, index) => (
<Fragment key={row.id}>
{index > 0 && <ItemSeparator />}
<ApiKey
apiKey={row.original}
hideDelete={hideDelete}
hideUpdate={hideUpdate}
organizationId={organizationId}
/>
</Fragment>
))}
</ItemGroup>
)}
</CardContent>
</Card>
{(pagination.pageIndex > 0 || page.hasNextPage) && (
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
disabled={!table.getCanPreviousPage()}
onClick={() => table.previousPage()}
>
{apiKeyLocalization.previousPage}
</Button>
<Button
variant="outline"
size="sm"
disabled={!page.hasNextPage}
onClick={() => table.nextPage()}
>
{apiKeyLocalization.nextPage}
</Button>
</div>
)}
{!hideCreate && (
<CreateApiKeyDialog
open={createOpen}
onOpenChange={setCreateOpen}
organizationId={organizationId}
/>
)}
</div>
)
}
examples/start-shadcn-example/src/components/auth/api-key/create-api-key-dialog.tsx
"use client"
import {
type ApiKeyAuthClient,
apiKeyExpirationDaysToSeconds
} from "@better-auth-ui/core/plugins/api-key"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import { useCreateApiKey } from "@better-auth-ui/react/plugins/api-key"
import { Key } from "lucide-react"
import { useState } from "react"
import { buttonVariants } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from "@/components/ui/dialog"
import {
Field,
FieldError,
FieldGroup,
FieldLabel
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select"
import { apiKeyPlugin } from "@/lib/auth/api-key-plugin"
import { useAuthForm } from "../auth-form"
import { NewApiKeyDialog } from "./new-api-key-dialog"
export type CreateApiKeyDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
/** Create an organization-owned key by passing the organization id. */
organizationId?: string
}
export function CreateApiKeyDialog({
open,
onOpenChange,
organizationId
}: CreateApiKeyDialogProps) {
const { authClient, localization } = useAuth<ApiKeyAuthClient>()
const {
configurations,
keyExpiration,
localization: apiKeyLocalization
} = useAuthPlugin(apiKeyPlugin)
const { mutateAsync: createApiKey, isPending: isCreating } =
useCreateApiKey(authClient)
const [isNewKeyDialogOpen, setIsNewKeyDialogOpen] = useState(false)
const [keyName, setKeyName] = useState<string | null>(null)
const [secretKey, setSecretKey] = useState<string | null>(null)
const availableConfigurations = configurations.filter(
(configuration) => configuration.organization === Boolean(organizationId)
)
const form = useAuthForm({
defaultValues: {
configId: availableConfigurations[0]?.id ?? "",
expiration:
keyExpiration === false || keyExpiration?.defaultInterval == null
? "never"
: String(keyExpiration.defaultInterval),
name: ""
},
onSubmit: async ({ value }) => {
const name = value.name.trim()
const expirationDays =
value.expiration !== "never" ? Number(value.expiration) : undefined
const expiresIn = expirationDays
? apiKeyExpirationDaysToSeconds(expirationDays)
: undefined
const configId = value.configId.trim()
const resolvedConfigId =
configId || (organizationId ? "organization" : undefined)
const payload = {
...(name ? { name } : {}),
...(expiresIn ? { expiresIn } : {}),
...(resolvedConfigId ? { configId: resolvedConfigId } : {}),
...(organizationId ? { organizationId } : {})
}
await createApiKey(
Object.keys(payload).length > 0 ? payload : undefined,
{
onSuccess: (result) => {
handleOpenChange(false)
setKeyName(name)
setSecretKey(result.key)
setIsNewKeyDialogOpen(true)
}
}
)
}
})
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) {
setKeyName(null)
setSecretKey(null)
form.reset()
}
onOpenChange(nextOpen)
}
const handleNewKeyDialogOpenChange = (nextOpen: boolean) => {
setIsNewKeyDialogOpen(nextOpen)
if (!nextOpen) {
setKeyName(null)
setSecretKey(null)
}
}
return (
<>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<form.AppForm>
<form.AuthFormRoot className="flex flex-col gap-6">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Key />
{apiKeyLocalization.createApiKey}
</DialogTitle>
<DialogDescription>
{apiKeyLocalization.apiKeysDescription}
</DialogDescription>
</DialogHeader>
<FieldGroup>
<form.AppField name="name">
{(field) => (
<Field>
<FieldLabel htmlFor="api-key-name">
{apiKeyLocalization.name}
</FieldLabel>
<Input
id="api-key-name"
name={field.name}
autoFocus
placeholder={localization.settings.optional}
disabled={isCreating}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) =>
field.handleChange(event.target.value)
}
/>
<FieldError />
</Field>
)}
</form.AppField>
{availableConfigurations.length > 0 && (
<form.AppField name="configId">
{(field) => (
<Field>
<FieldLabel htmlFor="api-key-configuration">
{apiKeyLocalization.configuration}
</FieldLabel>
<Select
name={field.name}
value={field.state.value}
onValueChange={(value) => field.handleChange(value)}
disabled={isCreating}
>
<SelectTrigger
id="api-key-configuration"
className="w-full"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{availableConfigurations.map((configuration) => (
<SelectItem
key={configuration.id}
value={configuration.id}
>
{configuration.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
)}
</form.AppField>
)}
{keyExpiration ? (
<form.AppField name="expiration">
{(field) => (
<Field>
<FieldLabel htmlFor="api-key-expiration">
{apiKeyLocalization.expiration}
</FieldLabel>
<Select
name={field.name}
value={field.state.value}
onValueChange={(value) => field.handleChange(value)}
disabled={isCreating}
>
<SelectTrigger
id="api-key-expiration"
className="w-full"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{keyExpiration.intervals.map((days) => (
<SelectItem key={days} value={String(days)}>
{days.toLocaleString()}{" "}
{days === 1
? apiKeyLocalization.day
: apiKeyLocalization.days}
</SelectItem>
))}
{keyExpiration.allowNever ? (
<SelectItem value="never">
{apiKeyLocalization.never}
</SelectItem>
) : null}
</SelectGroup>
</SelectContent>
</Select>
</Field>
)}
</form.AppField>
) : null}
</FieldGroup>
<DialogFooter>
<DialogClose
className={buttonVariants({ variant: "outline" })}
disabled={isCreating}
type="button"
>
{localization.settings.cancel}
</DialogClose>
<form.AuthFormSubmitButton
isPending={isCreating}
disabled={isCreating}
>
{apiKeyLocalization.createApiKey}
</form.AuthFormSubmitButton>
</DialogFooter>
</form.AuthFormRoot>
</form.AppForm>
</DialogContent>
</Dialog>
<NewApiKeyDialog
open={isNewKeyDialogOpen}
onOpenChange={handleNewKeyDialogOpenChange}
secretKey={secretKey}
name={keyName}
/>
</>
)
}
examples/start-shadcn-example/src/components/auth/api-key/delete-api-key-dialog.tsx
"use client"
import type {
ApiKeyAuthClient,
ListedApiKey
} from "@better-auth-ui/core/plugins/api-key"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import { useDeleteApiKey } from "@better-auth-ui/react/plugins/api-key"
import { Key } from "lucide-react"
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Field, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Spinner } from "@/components/ui/spinner"
import { apiKeyPlugin } from "@/lib/auth/api-key-plugin"
export type DeleteApiKeyDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
apiKey: ListedApiKey
/** Scope the delete payload to an organization (sets `configId`). */
organizationId?: string
}
export function DeleteApiKeyDialog({
open,
onOpenChange,
apiKey,
organizationId
}: DeleteApiKeyDialogProps) {
const { authClient, localization } = useAuth<ApiKeyAuthClient>()
const { localization: apiKeyLocalization } = useAuthPlugin(apiKeyPlugin)
const preview = `${apiKey.start}${"*".repeat(16)}`
const previewId = `delete-api-key-preview-${apiKey.id}`
const { mutate: deleteApiKey, isPending: isDeleting } = useDeleteApiKey(
authClient,
{
onSuccess: () => onOpenChange(false)
}
)
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogMedia>
<Key />
</AlertDialogMedia>
<AlertDialogTitle>{apiKeyLocalization.deleteApiKey}</AlertDialogTitle>
<AlertDialogDescription>
{apiKeyLocalization.deleteApiKeyWarning}
</AlertDialogDescription>
</AlertDialogHeader>
<Field>
<FieldLabel htmlFor={previewId}>
{apiKey.name || apiKeyLocalization.apiKey}
</FieldLabel>
<Input
id={previewId}
value={preview}
readOnly
className="font-mono text-xs"
disabled
/>
</Field>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>
{localization.settings.cancel}
</AlertDialogCancel>
<Button
type="button"
variant="destructive"
disabled={isDeleting}
onClick={() =>
deleteApiKey({
keyId: apiKey.id,
...(organizationId ? { configId: "organization" } : {})
})
}
>
{isDeleting && <Spinner />}
{apiKeyLocalization.deleteApiKey}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
examples/start-shadcn-example/src/components/auth/api-key/edit-api-key-dialog.tsx
"use client"
import type {
ApiKeyAuthClient,
ListedApiKey
} from "@better-auth-ui/core/plugins/api-key"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import { useUpdateApiKey } from "@better-auth-ui/react/plugins/api-key"
import { useEffect } from "react"
import { buttonVariants } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle
} from "@/components/ui/dialog"
import {
Field,
FieldError,
FieldGroup,
FieldLabel
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { apiKeyPlugin } from "@/lib/auth/api-key-plugin"
import { useAuthForm } from "../auth-form"
export function EditApiKeyDialog({
apiKey,
open,
onOpenChange
}: {
apiKey: ListedApiKey
open: boolean
onOpenChange: (open: boolean) => void
}) {
const { authClient, localization } = useAuth<ApiKeyAuthClient>()
const { localization: labels } = useAuthPlugin(apiKeyPlugin)
const updateApiKey = useUpdateApiKey(authClient, {
onSuccess: () => onOpenChange(false)
})
const form = useAuthForm({
defaultValues: { name: apiKey.name ?? "" },
onSubmit: async ({ value }) =>
await updateApiKey.mutateAsync({
configId: apiKey.configId,
keyId: apiKey.id,
name: value.name.trim()
})
})
useEffect(() => {
if (open) form.reset({ name: apiKey.name ?? "" })
}, [apiKey.name, form, open])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<form.AppForm>
<form.AuthFormRoot className="flex flex-col gap-6">
<DialogHeader>
<DialogTitle>{labels.editApiKey}</DialogTitle>
</DialogHeader>
<FieldGroup>
<form.AppField name="name">
{(field) => (
<Field>
<FieldLabel htmlFor={`api-key-name-${apiKey.id}`}>
{labels.name}
</FieldLabel>
<Input
id={`api-key-name-${apiKey.id}`}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) =>
field.handleChange(event.target.value)
}
/>
</Field>
)}
</form.AppField>
{updateApiKey.error && (
<FieldError>
{updateApiKey.error.error?.message ??
updateApiKey.error.message}
</FieldError>
)}
</FieldGroup>
<DialogFooter>
<DialogClose
className={buttonVariants({ variant: "outline" })}
type="button"
>
{localization.settings.cancel}
</DialogClose>
<form.AuthFormSubmitButton
isPending={updateApiKey.isPending}
disabled={updateApiKey.isPending}
>
{localization.settings.saveChanges}
</form.AuthFormSubmitButton>
</DialogFooter>
</form.AuthFormRoot>
</form.AppForm>
</DialogContent>
</Dialog>
)
}
examples/start-shadcn-example/src/components/auth/api-key/new-api-key-dialog.tsx
"use client"
import {
useAuth,
useAuthPlugin,
useCopyToClipboard
} from "@better-auth-ui/react"
import { Check, Copy, Key } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from "@/components/ui/dialog"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput
} from "@/components/ui/input-group"
import { Label } from "@/components/ui/label"
import { apiKeyPlugin } from "@/lib/auth/api-key-plugin"
export type NewApiKeyDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
name: string | null
secretKey: string | null
}
export function NewApiKeyDialog({
open,
onOpenChange,
name,
secretKey
}: NewApiKeyDialogProps) {
const { localization } = useAuth()
const { localization: apiKeyLocalization } = useAuthPlugin(apiKeyPlugin)
const { copied, copy, reset } = useCopyToClipboard({
onError: (error) => {
console.error("[Better Auth UI] Copy failed", error)
toast.error(localization.errors.copyFailed)
}
})
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) {
reset()
}
onOpenChange(nextOpen)
}
const copySecretKey = async () => {
if (!secretKey) return
await copy(secretKey)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Key />
{apiKeyLocalization.newApiKey}
</DialogTitle>
<DialogDescription>
{apiKeyLocalization.newApiKeyWarning}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
<Label htmlFor="new-api-key-secret">
{name || apiKeyLocalization.apiKey}
</Label>
<InputGroup>
<InputGroupInput
id="new-api-key-secret"
value={secretKey ?? ""}
readOnly
className="font-mono text-xs"
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label={
copied
? localization.settings.copiedToClipboard
: localization.settings.copyToClipboard
}
onClick={copySecretKey}
>
{copied ? <Check /> : <Copy />}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</div>
<DialogFooter>
<Button type="button" onClick={() => handleOpenChange(false)}>
{apiKeyLocalization.dismissNewKey}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
examples/start-shadcn-example/src/components/auth/api-key/organization-api-keys.tsx
"use client"
import {
hasMemberRole,
type OrganizationAuthClient
} from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import {
useActiveMemberRole,
useHasPermission
} from "@better-auth-ui/react/plugins/organization"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { ApiKeys } from "./api-keys"
export type OrganizationApiKeysProps = {
className?: string
organizationId: string
organizationSlug: string
}
/**
* {@link ApiKeys} scoped to an explicit organization.
*
* Access is resolved per API-key action. The configured organization creator
* role receives Better Auth's creator override.
*/
export function OrganizationApiKeys({
className,
organizationId
}: OrganizationApiKeysProps) {
const { authClient } = useAuth<OrganizationAuthClient>()
const { creatorRole } = useAuthPlugin(organizationPlugin)
const memberRole = useActiveMemberRole(authClient, {
query: { organizationId }
})
const isCreator = hasMemberRole(memberRole.data?.role, creatorRole)
const permissionOptions = {
enabled: !memberRole.isPending && !isCreator,
organizationId
}
const canRead = useHasPermission(authClient, {
...permissionOptions,
permissions: { apiKey: ["read"] } as Parameters<
OrganizationAuthClient["organization"]["hasPermission"]
>[0]["permissions"]
})
const canCreate = useHasPermission(authClient, {
...permissionOptions,
permissions: { apiKey: ["create"] } as Parameters<
OrganizationAuthClient["organization"]["hasPermission"]
>[0]["permissions"]
})
const canUpdate = useHasPermission(authClient, {
...permissionOptions,
permissions: { apiKey: ["update"] } as Parameters<
OrganizationAuthClient["organization"]["hasPermission"]
>[0]["permissions"]
})
const canDelete = useHasPermission(authClient, {
...permissionOptions,
permissions: { apiKey: ["delete"] } as Parameters<
OrganizationAuthClient["organization"]["hasPermission"]
>[0]["permissions"]
})
const permissionPending =
!isCreator &&
!memberRole.isPending &&
(canRead.isPending ||
canCreate.isPending ||
canUpdate.isPending ||
canDelete.isPending)
const isPending = memberRole.isPending || permissionPending
const canReadKeys = isCreator || canRead.data?.success
if (!isPending && !canReadKeys) return null
return (
<ApiKeys
className={className}
hideCreate={!isCreator && !canCreate.data?.success}
hideDelete={!isCreator && !canDelete.data?.success}
hideUpdate={!isCreator && !canUpdate.data?.success}
isPending={isPending}
organizationId={organizationId}
/>
)
}
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/organization/accept-invitation.tsx
"use client"
import { getSafeRedirectTo } from "@better-auth-ui/core"
import type { OrganizationAuthClient } from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthenticate, useAuthPlugin } from "@better-auth-ui/react"
import {
useAcceptInvitation,
useInvitation,
useRejectInvitation
} from "@better-auth-ui/react/plugins/organization"
import type { Invitation } from "better-auth/client"
import { BriefcaseBusiness } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle
} from "@/components/ui/card"
import { FieldDescription } from "@/components/ui/field"
import { Skeleton } from "@/components/ui/skeleton"
import { Spinner } from "@/components/ui/spinner"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { UserAvatar } from "../user/user-avatar"
import { cn } from "cn"
import { useIsHydrated } from "../use-is-hydrated"
type UserInvitation = Invitation & { organizationName?: string }
export type AcceptInvitationProps = {
className?: string
}
function isPendingInvitation(invitation: UserInvitation | undefined) {
if (invitation?.status !== "pending") return false
return new Date(invitation.expiresAt).getTime() > Date.now()
}
/**
* Render the organization invitation addressed by the `invitationId` query
* parameter and let the signed-in recipient accept or reject it directly.
*/
export function AcceptInvitation({ className }: AcceptInvitationProps) {
const { authClient, navigate, redirectTo } = useAuth()
const { localization, roles } = useAuthPlugin(organizationPlugin)
const organizationAuthClient = authClient as OrganizationAuthClient
const isHydrated = useIsHydrated()
const invitationId = isHydrated
? new URLSearchParams(window.location.search).get("invitationId")
: null
const session = useAuthenticate(organizationAuthClient)
const invitationQuery = useInvitation(organizationAuthClient, {
query: { id: invitationId ?? "" },
enabled: Boolean(invitationId)
})
const invitation = invitationQuery.data as UserInvitation | undefined
const returnToApplication = () => {
navigate({
to: getSafeRedirectTo(redirectTo, window.location.origin),
replace: true
})
}
const { mutate: acceptInvitation, isPending: isAccepting } =
useAcceptInvitation(organizationAuthClient, {
onSuccess: returnToApplication
})
const { mutate: rejectInvitation, isPending: isRejecting } =
useRejectInvitation(organizationAuthClient, {
onSuccess: returnToApplication
})
const isLoading =
!isHydrated ||
session.isPending ||
!session.data ||
(Boolean(invitationId) && invitationQuery.isPending)
const isAvailable = isPendingInvitation(invitation)
const organizationName =
invitation?.organizationName || localization.organization
const role = invitation
? (roles?.[invitation.role] ?? invitation.role)
: localization.member
return (
<Card
className={cn("w-full max-w-lg gap-0 overflow-hidden p-0", className)}
>
<CardHeader className="grid justify-items-center gap-5 p-6 text-center">
<div className="flex size-16 items-center justify-center rounded-full bg-muted/50">
<BriefcaseBusiness aria-hidden="true" className="size-7" />
</div>
<div className="grid gap-1">
<CardTitle className="max-w-full break-words text-xl font-semibold">
{isLoading ? (
<Skeleton className="h-6 w-48" />
) : isAvailable ? (
organizationName
) : (
localization.invitationUnavailable
)}
</CardTitle>
<CardDescription>
{localization.acceptInvitationTitle}
</CardDescription>
</div>
</CardHeader>
<CardContent className="px-4 pb-6 sm:px-6">
<div className="grid gap-5 rounded-lg bg-muted/50 p-5">
{isLoading ? (
<Skeleton className="h-10 w-full" />
) : isAvailable ? (
<>
<FieldDescription>
{localization.acceptInvitationDescription
.replace("{{organization}}", organizationName)
.replace("{{role}}", role)}
</FieldDescription>
<dl className="grid gap-1">
<dt className="text-sm text-muted-foreground">
{localization.role}
</dt>
<dd className="text-base font-medium">{role}</dd>
</dl>
</>
) : (
<FieldDescription>
{localization.invitationUnavailableDescription}
</FieldDescription>
)}
<div className="flex min-w-0 items-center gap-3">
<UserAvatar
user={session.data?.user}
isPending={session.isPending}
/>
{session.data ? (
<div className="grid min-w-0 gap-0.5">
<p className="break-words text-sm font-medium">
{session.data.user.name || session.data.user.email}
</p>
{session.data.user.name ? (
<p className="break-all text-sm text-muted-foreground">
{session.data.user.email}
</p>
) : null}
</div>
) : (
<Skeleton className="h-4 w-40" />
)}
</div>
</div>
</CardContent>
<CardFooter className="grid grid-cols-2 gap-3 p-4 sm:p-6">
{isLoading ? (
<>
<Skeleton className="h-11 w-full" />
<Skeleton className="h-11 w-full" />
</>
) : isAvailable && invitation ? (
<>
<Button
className="h-11 w-full"
variant="secondary"
disabled={isAccepting || isRejecting}
onClick={() => rejectInvitation({ invitationId: invitation.id })}
>
{isRejecting ? <Spinner /> : null}
{localization.rejectInvitation}
</Button>
<Button
className="h-11 w-full"
disabled={isAccepting || isRejecting}
onClick={() => acceptInvitation({ invitationId: invitation.id })}
>
{isAccepting ? <Spinner /> : null}
{localization.accept}
</Button>
</>
) : (
<Button
className="col-span-2 h-11 w-full"
onClick={returnToApplication}
>
{localization.return}
</Button>
)}
</CardFooter>
</Card>
)
}
examples/start-shadcn-example/src/components/auth/organization/create-organization-dialog.tsx
"use client"
import {
getAdditionalFieldDefaultValues,
getAdditionalFieldSubmitValues,
validateStringLength
} from "@better-auth-ui/core"
import type { OrganizationAuthClient } from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import { useCreateOrganization } from "@better-auth-ui/react/plugins/organization"
import { Briefcase } from "lucide-react"
import { useEffect, useRef, useState } from "react"
import { buttonVariants } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from "@/components/ui/dialog"
import { Field, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import {
getAuthAdditionalFieldValidators,
isAuthFormFieldInvalid,
useAuthForm
} from "../auth-form"
import { SlugField, sanitizeSlug } from "./slug-field"
/** Props for the `CreateOrganizationDialog` component. */
export type CreateOrganizationDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
hideSlug?: boolean
}
export function CreateOrganizationDialog({
open,
onOpenChange,
hideSlug: hideSlugProp
}: CreateOrganizationDialogProps) {
const { authClient, localization } = useAuth<OrganizationAuthClient>()
const {
additionalFields,
localization: organizationLocalization,
hideSlug: pluginHideSlug
} = useAuthPlugin(organizationPlugin)
const hideSlug = hideSlugProp ?? pluginHideSlug ?? false
const [slugEdited, setSlugEdited] = useState(false)
const submissionGeneration = useRef(0)
const submissionAttemptGeneration = useRef(0)
const { mutateAsync: createOrganization } = useCreateOrganization(authClient)
const form = useAuthForm({
defaultValues: {
additionalFields: getAdditionalFieldDefaultValues(additionalFields),
name: "",
slug: ""
},
onSubmit: async ({ value }) => {
const generation = submissionAttemptGeneration.current
if (generation !== submissionGeneration.current) return
await createOrganization({
...getAdditionalFieldSubmitValues(
additionalFields,
value.additionalFields
),
name: value.name,
slug: hideSlug ? undefined : value.slug
})
if (generation === submissionGeneration.current) onOpenChange(false)
}
})
useEffect(() => {
if (!open) {
submissionGeneration.current += 1
form.reset()
setSlugEdited(false)
}
}, [form, open])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<form.AppForm>
<form.AuthFormRoot
className="flex flex-col gap-6"
onBeforeSubmit={() => {
submissionAttemptGeneration.current = submissionGeneration.current
}}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Briefcase />
{organizationLocalization.createOrganization}
</DialogTitle>
<DialogDescription>
{organizationLocalization.organizationsDescription}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<form.AppField
name="name"
validators={{
onChange: ({ value }) =>
validateStringLength(value, {
requiredMessage: localization.auth.fieldRequired,
trim: true
})
}}
>
{(field) => {
const isInvalid = isAuthFormFieldInvalid(field.state.meta)
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor="create-organization-name">
{organizationLocalization.name}
</FieldLabel>
<Input
id="create-organization-name"
name={field.name}
autoFocus
placeholder={organizationLocalization.namePlaceholder}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => {
const value = event.target.value
field.handleChange(value)
if (!slugEdited) {
form.setFieldValue("slug", sanitizeSlug(value))
}
}}
aria-invalid={isInvalid}
/>
<field.AuthFormFieldError />
</Field>
)
}}
</form.AppField>
{!hideSlug && (
<form.AppField name="slug">
{(field) => (
<SlugField
id="create-organization-slug"
value={field.state.value}
onChange={(value) => {
field.handleChange(value)
setSlugEdited(true)
}}
/>
)}
</form.AppField>
)}
{additionalFields.map((configuredField) => (
<form.AppField
key={configuredField.name}
name={`additionalFields.${configuredField.name}`}
validators={getAuthAdditionalFieldValidators(
configuredField,
localization.auth.fieldRequired
)}
>
{(field) => (
<field.AuthFormAdditionalField
field={configuredField}
optionalLabel={localization.settings.optional}
/>
)}
</form.AppField>
))}
</div>
<DialogFooter>
<DialogClose
className={buttonVariants({ variant: "outline" })}
type="button"
>
{localization.settings.cancel}
</DialogClose>
<form.AuthFormSubmitButton>
{organizationLocalization.createOrganization}
</form.AuthFormSubmitButton>
</DialogFooter>
</form.AuthFormRoot>
</form.AppForm>
</DialogContent>
</Dialog>
)
}
examples/start-shadcn-example/src/components/auth/organization/organization-logo.tsx
"use client"
import type { Organization } from "better-auth/client"
import { Briefcase } from "lucide-react"
import type { ComponentProps, CSSProperties, ReactNode } from "react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "cn"
export type OrganizationLogoSize = "sm" | "md" | "lg"
export type OrganizationLogoProps = {
className?: string
fallback?: ReactNode
isPending?: boolean
organization?: Partial<Organization> | null
size?: OrganizationLogoSize
}
const sizeClasses: Record<OrganizationLogoSize, string> = {
sm: "size-8",
md: "size-10",
lg: "size-12"
}
const fallbackTextClasses: Record<OrganizationLogoSize, string> = {
sm: "text-sm",
md: "text-base",
lg: "text-xl"
}
export function OrganizationLogo({
className,
fallback,
isPending,
organization,
size = "sm",
style,
...props
}: OrganizationLogoProps &
Omit<ComponentProps<typeof Avatar>, "children" | "size" | "style"> & {
style?: CSSProperties
}) {
if (isPending && !organization) {
return (
<Skeleton
className={cn("rounded-full", sizeClasses[size], className)}
style={style}
/>
)
}
const initials = organization?.name?.slice(0, 2).toUpperCase()
const normalizedLogo = organization?.logo?.trim() || undefined
return (
<Avatar
className={cn("rounded-full", sizeClasses[size], className)}
style={style}
{...props}
>
<AvatarImage
alt={organization?.name ?? "Organization"}
src={normalizedLogo}
/>
<AvatarFallback
className={cn("text-muted-foreground!", fallbackTextClasses[size])}
>
{fallback || initials || <Briefcase className="size-4" />}
</AvatarFallback>
</Avatar>
)
}
examples/start-shadcn-example/src/components/auth/organization/organization-row.tsx
"use client"
import type { OrganizationAuthClient } from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import { useSetActiveOrganization } from "@better-auth-ui/react/plugins/organization"
import type { Organization } from "better-auth/client"
import { Settings as SettingsIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Item, ItemActions } from "@/components/ui/item"
import { Spinner } from "@/components/ui/spinner"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { OrganizationView } from "./organization-view"
export type OrganizationRowProps = {
organization: Organization
}
/**
* Single organization row: logo and labels via `OrganizationView`, plus a Manage action.
*/
export function OrganizationRow({ organization }: OrganizationRowProps) {
const { authClient, basePaths, navigate } = useAuth<OrganizationAuthClient>()
const {
localization: organizationLocalization,
viewPaths: organizationViewPaths,
slug,
slugPrefix
} = useAuthPlugin(organizationPlugin)
const { mutate: setActiveOrganization, isPending: setActivePending } =
useSetActiveOrganization(authClient, {
onSuccess: () => {
navigate({
to: `${basePaths.organization}/${organizationViewPaths.organization.settings}`
})
}
})
function manageOrganization() {
if (slug !== undefined) {
navigate({
to: `${basePaths.organization}/${slugPrefix}${organization.slug}/${organizationViewPaths.organization.settings}`
})
} else {
setActiveOrganization({ organizationId: organization.id })
}
}
return (
<Item>
<OrganizationView className="flex-1" organization={organization} />
<ItemActions>
<Button
variant="outline"
size="sm"
disabled={setActivePending}
onClick={manageOrganization}
aria-label={organizationLocalization.manage}
>
{setActivePending ? <Spinner /> : <SettingsIcon />}
{organizationLocalization.manage}
</Button>
</ItemActions>
</Item>
)
}
examples/start-shadcn-example/src/components/auth/organization/organization-view-skeleton.tsx
"use client"
import type { ComponentProps } from "react"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "cn"
import {
OrganizationLogo,
type OrganizationLogoSize
} from "./organization-logo"
export type OrganizationViewSkeletonProps = {
className?: string
hideSlug?: boolean
size?: OrganizationLogoSize
}
/**
* Placeholder matching `OrganizationView` while organization data loads.
*/
export function OrganizationViewSkeleton({
className,
hideSlug,
size = "md",
...props
}: OrganizationViewSkeletonProps & ComponentProps<"div">) {
return (
<div
className={cn("flex min-w-0 items-center gap-2", className)}
{...props}
>
<OrganizationLogo
isPending
className={size === "sm" ? "size-5" : undefined}
size={size === "lg" ? "md" : "sm"}
/>
<div className="flex flex-col min-w-0 gap-1">
<Skeleton className="h-3.5 w-20 rounded-md" />
{!hideSlug && <Skeleton className="h-3 w-28 rounded-md" />}
</div>
</div>
)
}
examples/start-shadcn-example/src/components/auth/organization/organization-view.tsx
"use client"
import type { OrganizationAuthClient } from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin, useSession } from "@better-auth-ui/react"
import {
useActiveOrganization,
useListOrganizationMembers
} from "@better-auth-ui/react/plugins/organization"
import type { Organization } from "better-auth/client"
import type { ComponentProps } from "react"
import { Badge } from "@/components/ui/badge"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { cn } from "cn"
import {
OrganizationLogo,
type OrganizationLogoSize
} from "./organization-logo"
import { OrganizationViewSkeleton } from "./organization-view-skeleton"
export type OrganizationViewProps = {
className?: string
isPending?: boolean
size?: OrganizationLogoSize
hideRole?: boolean
hideSlug?: boolean
organization?: Partial<Organization>
}
/**
* Compact organization row: logo, primary name, secondary slug — analogous to `UserView`.
*/
export function OrganizationView({
className,
isPending,
size = "md",
hideSlug: hideSlugProp,
hideRole,
organization,
...props
}: OrganizationViewProps & ComponentProps<"div">) {
const { authClient } = useAuth<OrganizationAuthClient>()
const {
roles,
slugPrefix,
hideSlug: pluginHideSlug
} = useAuthPlugin(organizationPlugin)
const hideSlug = hideSlugProp ?? pluginHideSlug ?? false
const { data: session } = useSession(authClient)
const { data: activeOrganization, isPending: activeOrganizationPending } =
useActiveOrganization(authClient, {
enabled: !organization && !isPending
})
const resolvedOrganization = organization ?? activeOrganization
const { data: membersList, isPending: membersPending } =
useListOrganizationMembers(authClient, {
query: {
organizationId: resolvedOrganization?.id
},
enabled: !!resolvedOrganization?.id && !hideRole
})
const membership = membersList?.members?.find(
(member) => member.userId === session?.user.id
)
if (
isPending ||
(!organization && activeOrganizationPending) ||
(!hideRole && !!resolvedOrganization?.id && membersPending)
) {
return (
<OrganizationViewSkeleton
className={className}
hideSlug={hideSlug}
size={size}
{...props}
/>
)
}
return (
<div
className={cn("flex min-w-0 items-center gap-2", className)}
{...props}
>
<OrganizationLogo
organization={resolvedOrganization}
className={size === "sm" ? "size-5" : undefined}
size={size === "lg" ? "md" : "sm"}
/>
<div className="flex min-w-0 flex-col">
<div className="flex min-w-0 items-center gap-2">
<p className="truncate text-sm font-medium leading-tight text-foreground">
{resolvedOrganization?.name}
</p>
{!hideRole && !!membership && (
<Badge variant="secondary" className="-my-0.5 shrink-0">
{roles?.[membership.role] ?? membership.role}
</Badge>
)}
</div>
{!hideSlug && !!resolvedOrganization?.slug && (
<p className="truncate overflow-x-hidden text-muted-foreground text-xs font-mono leading-tight">
{slugPrefix}
{resolvedOrganization.slug}
</p>
)}
</div>
</div>
)
}
examples/start-shadcn-example/src/components/auth/organization/organizations-empty.tsx
"use client"
import { useAuthPlugin } from "@better-auth-ui/react"
import { Briefcase } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle
} from "@/components/ui/empty"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
export type OrganizationsEmptyProps = {
onCreatePress: () => void
canCreate?: boolean
}
export function OrganizationsEmpty({
onCreatePress,
canCreate = true
}: OrganizationsEmptyProps) {
const { localization: organizationLocalization } =
useAuthPlugin(organizationPlugin)
return (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<Briefcase />
</EmptyMedia>
<EmptyTitle>{organizationLocalization.noOrganizations}</EmptyTitle>
<EmptyDescription>
{organizationLocalization.organizationsDescription}
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button size="sm" disabled={!canCreate} onClick={onCreatePress}>
{organizationLocalization.createOrganization}
</Button>
</EmptyContent>
</Empty>
)
}
examples/start-shadcn-example/src/components/auth/organization/organizations-settings.tsx
"use client"
import type { ComponentProps } from "react"
import { cn } from "cn"
import { Organizations } from "./organizations"
import { UserInvitations } from "./user-invitations"
export type OrganizationsSettingsProps = {
className?: string
}
/**
* Renders the organizations settings panel.
*
* Displays all organizations the user belongs to with an empty state and
* create button, followed by a card for invitations to the user.
*/
export function OrganizationsSettings({
className,
...props
}: OrganizationsSettingsProps & ComponentProps<"div">) {
return (
<div
className={cn("flex w-full flex-col gap-4 md:gap-6", className)}
{...props}
>
<Organizations />
<UserInvitations />
</div>
)
}
examples/start-shadcn-example/src/components/auth/organization/organizations.tsx
"use client"
import type { OrganizationAuthClient } from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import { useListOrganizations } from "@better-auth-ui/react/plugins/organization"
import { Fragment, useState } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Item, ItemGroup, ItemSeparator } from "@/components/ui/item"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { CreateOrganizationDialog } from "./create-organization-dialog"
import { OrganizationRow } from "./organization-row"
import { OrganizationViewSkeleton } from "./organization-view-skeleton"
import { OrganizationsEmpty } from "./organizations-empty"
export type OrganizationsProps = {
className?: string
}
/**
* Lists organizations the user belongs to (via `useListOrganizations`): loading skeleton,
* empty state with create, or a card of rows with a Manage control per organization.
* Owns `CreateOrganizationDialog` open state and the create actions.
*/
export function Organizations({ className }: OrganizationsProps) {
const { authClient } = useAuth<OrganizationAuthClient>()
const {
allowOrganizationCreation,
localization: organizationLocalization,
organizationLimit
} = useAuthPlugin(organizationPlugin)
const [createOpen, setCreateOpen] = useState(false)
const { data: organizations, isPending: organizationsPending } =
useListOrganizations(authClient)
const canCreate =
allowOrganizationCreation &&
(organizationLimit === undefined ||
(organizations?.length ?? 0) < organizationLimit)
return (
<>
<div className={className}>
<div className="flex flex-col gap-3">
<div className="flex items-end justify-between gap-3">
<h2 className="truncate text-sm font-semibold">
{organizationLocalization.organizations}
</h2>
{allowOrganizationCreation && (
<Button
className="shrink-0"
size="sm"
disabled={organizationsPending || !canCreate}
onClick={() => setCreateOpen(true)}
>
{organizationLocalization.createOrganization}
</Button>
)}
</div>
<Card className="p-0">
<CardContent className="p-0">
{organizationsPending ? (
<ItemGroup>
<Item>
<OrganizationViewSkeleton />
</Item>
</ItemGroup>
) : !organizations?.length ? (
<OrganizationsEmpty
canCreate={canCreate}
onCreatePress={() => setCreateOpen(true)}
/>
) : (
<ItemGroup className="gap-0">
{organizations.map((organization, index) => (
<Fragment key={organization.id}>
{index > 0 && <ItemSeparator />}
<OrganizationRow organization={organization} />
</Fragment>
))}
</ItemGroup>
)}
</CardContent>
</Card>
</div>
</div>
{canCreate && (
<CreateOrganizationDialog
open={createOpen}
onOpenChange={setCreateOpen}
/>
)}
</>
)
}
examples/start-shadcn-example/src/components/auth/organization/slug-field.tsx
"use client"
import type { OrganizationAuthClient } from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import { useCheckSlug } from "@better-auth-ui/react/plugins/organization"
import { useDebouncer } from "@tanstack/react-pacer"
import { Check, X } from "lucide-react"
import { useEffect, useState } from "react"
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import {
InputGroup,
InputGroupAddon,
InputGroupInput
} from "@/components/ui/input-group"
import { Spinner } from "@/components/ui/spinner"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
/** Props for the `SlugField` component. */
export type SlugFieldProps = {
value: string
onChange: (value: string) => void
currentSlug?: string
disabled?: boolean
id?: string
}
/**
* Sanitize a slug value so it only contains lowercase alphanumeric characters
* and dashes. Runs of disallowed characters are collapsed to a single dash, but
* leading/trailing dashes are preserved while the user is still typing.
*/
export function sanitizeSlug(value: string) {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-")
}
/**
* Organization slug field with debounced availability checking.
*/
export function SlugField({
value,
onChange,
currentSlug,
disabled,
id = "slug"
}: SlugFieldProps) {
const { authClient, localization: authLocalization } =
useAuth<OrganizationAuthClient>()
const {
localization,
checkSlug: checkSlugEnabled,
slugPrefix
} = useAuthPlugin(organizationPlugin)
const [slugError, setSlugError] = useState<string>()
const {
mutate: checkSlug,
data: checkSlugData,
error: checkSlugError,
reset: resetCheckSlug
} = useCheckSlug(authClient)
const debouncer = useDebouncer(
(next: string) => {
if (!checkSlugEnabled || !next.trim() || next.trim() === currentSlug)
return
checkSlug({ slug: next.trim() })
},
{ wait: 500 }
)
useEffect(() => {
// Clear stale validation errors when the controlled value changes
// externally (e.g. the parent resets the form), not just via this
// input's onChange.
setSlugError(undefined)
if (!checkSlugEnabled) return
resetCheckSlug()
debouncer.maybeExecute(value)
}, [checkSlugEnabled, value, debouncer.maybeExecute, resetCheckSlug])
return (
<Field data-invalid={!!slugError}>
<FieldLabel htmlFor={id}>{localization.slug}</FieldLabel>
<InputGroup>
{slugPrefix && (
<InputGroupAddon align="inline-start">{slugPrefix}</InputGroupAddon>
)}
<InputGroupInput
id={id}
name="slug"
value={value}
onChange={(e) => {
onChange(sanitizeSlug(e.target.value))
setSlugError(undefined)
}}
onInvalid={(e) => {
e.preventDefault()
setSlugError(authLocalization.auth.fieldRequired)
}}
aria-invalid={!!slugError}
placeholder={localization.slugPlaceholder}
required
disabled={disabled}
/>
{checkSlugEnabled && !!value.trim() && value.trim() !== currentSlug && (
<InputGroupAddon align="inline-end">
{checkSlugData?.status ? (
<Check className="size-4 text-foreground" />
) : checkSlugError ? (
<X className="size-4 text-destructive" />
) : (
<Spinner />
)}
</InputGroupAddon>
)}
</InputGroup>
<FieldError>{slugError}</FieldError>
</Field>
)
}
examples/start-shadcn-example/src/components/auth/organization/user-invitation-row-skeleton.tsx
"use client"
import { Item, ItemContent, ItemMedia } from "@/components/ui/item"
import { Skeleton } from "@/components/ui/skeleton"
/**
* Placeholder row matching `UserInvitationRow` while invitations load.
*/
export function UserInvitationRowSkeleton() {
return (
<Item>
<ItemMedia>
<Skeleton className="size-10 shrink-0 rounded-md" />
</ItemMedia>
<ItemContent>
<Skeleton className="h-4 w-40 rounded-md" />
<Skeleton className="h-3 w-28 rounded-md" />
</ItemContent>
</Item>
)
}
examples/start-shadcn-example/src/components/auth/organization/user-invitation-row.tsx
"use client"
import type { OrganizationAuthClient } from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import {
useAcceptInvitation,
useRejectInvitation
} from "@better-auth-ui/react/plugins/organization"
import type { Invitation } from "better-auth/client"
import { Check, Clock, X } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle
} from "@/components/ui/item"
import { Spinner } from "@/components/ui/spinner"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
export type UserInvitationRowProps = {
invitation: Invitation & { organizationName?: string }
}
/**
* Single invitation row with accept/reject actions for the current user.
*/
export function UserInvitationRow({ invitation }: UserInvitationRowProps) {
const { authClient } = useAuth<OrganizationAuthClient>()
const { localization: organizationLocalization, roles } =
useAuthPlugin(organizationPlugin)
const { mutate: acceptInvitation, isPending: isAccepting } =
useAcceptInvitation(authClient)
const { mutate: rejectInvitation, isPending: isRejecting } =
useRejectInvitation(authClient)
return (
<Item>
<ItemMedia variant="icon">
<Clock />
</ItemMedia>
<ItemContent>
<ItemTitle>
{invitation.organizationName}
<Badge variant="secondary">
{roles?.[invitation.role] ?? invitation.role}
</Badge>
</ItemTitle>
<ItemDescription>
{new Date(invitation.createdAt).toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short"
})}
</ItemDescription>
</ItemContent>
<ItemActions>
<Button
variant="outline"
size="sm"
disabled={isAccepting || isRejecting}
onClick={() => acceptInvitation({ invitationId: invitation.id })}
>
{isAccepting ? <Spinner /> : <Check />}
{organizationLocalization.accept}
</Button>
<Button
variant="outline"
size="icon"
className="size-8 text-destructive"
disabled={isAccepting || isRejecting}
onClick={() => rejectInvitation({ invitationId: invitation.id })}
aria-label={organizationLocalization.rejectInvitation}
>
{isRejecting ? <Spinner /> : <X />}
</Button>
</ItemActions>
</Item>
)
}
examples/start-shadcn-example/src/components/auth/organization/user-invitations-empty.tsx
"use client"
import { useAuthPlugin } from "@better-auth-ui/react"
import { MailWarning, Send } from "lucide-react"
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle
} from "@/components/ui/empty"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
/**
* Empty state for `UserInvitations`.
*/
export function UserInvitationsEmpty({
verificationRequired = false
}: {
verificationRequired?: boolean
}) {
const { localization: organizationLocalization } =
useAuthPlugin(organizationPlugin)
return (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
{verificationRequired ? <MailWarning /> : <Send />}
</EmptyMedia>
<EmptyTitle>
{verificationRequired
? organizationLocalization.verifyEmailToViewInvitations
: organizationLocalization.noInvitations}
</EmptyTitle>
<EmptyDescription>
{verificationRequired
? organizationLocalization.verifyEmailToViewInvitationsDescription
: organizationLocalization.userInvitationsEmptyDescription}
</EmptyDescription>
</EmptyHeader>
</Empty>
)
}
examples/start-shadcn-example/src/components/auth/organization/user-invitations.tsx
"use client"
import type { OrganizationAuthClient } from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin, useSession } from "@better-auth-ui/react"
import { useListUserInvitations } from "@better-auth-ui/react/plugins/organization"
import { Fragment } from "react"
import { Card, CardContent } from "@/components/ui/card"
import { ItemGroup, ItemSeparator } from "@/components/ui/item"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { UserInvitationRow } from "./user-invitation-row"
import { UserInvitationRowSkeleton } from "./user-invitation-row-skeleton"
import { UserInvitationsEmpty } from "./user-invitations-empty"
export type UserInvitationsProps = {
className?: string
}
/**
* Organization invitations for the signed-in user. Always renders the section
* card; uses `UserInvitationsEmpty` when there are no pending invitations.
*/
export function UserInvitations({ className }: UserInvitationsProps) {
const { authClient } = useAuth<OrganizationAuthClient>()
const { localization: organizationLocalization } =
useAuthPlugin(organizationPlugin)
const session = useSession(authClient)
const emailVerified = session.data?.user.emailVerified === true
const { data: invitations, isPending } = useListUserInvitations(authClient, {
enabled: emailVerified
})
return (
<div className={className}>
<div className="flex flex-col gap-3">
<h2 className="truncate text-sm font-semibold">
{organizationLocalization.invitations}
</h2>
<Card className="p-0">
<CardContent className="p-0">
{session.isPending || (emailVerified && isPending) ? (
<ItemGroup>
<UserInvitationRowSkeleton />
</ItemGroup>
) : !invitations?.length ? (
<UserInvitationsEmpty verificationRequired={!emailVerified} />
) : (
<ItemGroup className="gap-0">
{invitations.map((invitation, index) => (
<Fragment key={invitation.id}>
{index > 0 && <ItemSeparator />}
<UserInvitationRow invitation={invitation} />
</Fragment>
))}
</ItemGroup>
)}
</CardContent>
</Card>
</div>
</div>
)
}
examples/start-shadcn-example/src/components/auth/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/use-is-hydrated.ts
"use client"
import { useSyncExternalStore } from "react"
/**
* Returns `true` once the component is mounted on the client (hydrated) and
* `false` while rendering on the server, so client-only reads (e.g.
* `sessionStorage`) stay safe during SSR.
*
* @returns Whether the component has hydrated on the client.
*/
export function useIsHydrated() {
const subscribe = () => () => {}
return useSyncExternalStore(
subscribe,
() => true,
() => false
)
}
examples/start-shadcn-example/src/components/auth/user/user-avatar.tsx
"use client"
import type { UsernameAuthClient } from "@better-auth-ui/core/plugins/username"
import { useAuth, useSession } from "@better-auth-ui/react"
import type { User } from "better-auth"
import { User2 } from "lucide-react"
import type { ReactNode } from "react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "cn"
export type UserAvatarProps = {
className?: string
fallback?: ReactNode
isPending?: boolean
/** @remarks `User` */
user?: User & { username?: string | null; displayUsername?: string | null }
}
/**
* Display a user's avatar using session information or an explicit user prop.
*
* Renders a circular avatar that shows the user's image when available, a fallback node if provided, or the user's first two initials; while the session is loading (or when `isPending` is true) and no `user` prop is supplied, renders a skeleton placeholder.
*
* @param className - Additional CSS classes applied to the avatar container
* @param user - Optional user object to display instead of the session user
* @param isPending - When true, treat the component as loading and show the skeleton if no `user` is provided
* @param fallback - Node to render inside the avatar fallback area before initials or the default icon
* @returns The avatar element to render (JSX)
*/
export function UserAvatar({
className,
user,
isPending,
fallback
}: UserAvatarProps) {
const { authClient } = useAuth<UsernameAuthClient>()
const { data: session, isPending: sessionPending } = useSession(authClient, {
enabled: !user && !isPending
})
if ((isPending || sessionPending) && !user) {
return <Skeleton className={cn("size-8 rounded-full", className)} />
}
const resolvedUser = user ?? session?.user
const initials = (
resolvedUser?.username ||
resolvedUser?.name ||
resolvedUser?.email
)
?.slice(0, 2)
.toUpperCase()
return (
<Avatar
className={cn(
"size-8 bg-muted text-foreground text-sm rounded-full",
className
)}
>
<AvatarImage
src={resolvedUser?.image ?? undefined}
alt={
resolvedUser?.displayUsername ||
resolvedUser?.name ||
resolvedUser?.email
}
/>
<AvatarFallback className="text-muted-foreground!">
{fallback || initials || <User2 className="size-4" />}
</AvatarFallback>
</Avatar>
)
}
examples/start-shadcn-example/src/lib/auth/api-key-plugin.ts
import { createAuthPlugin } from "@better-auth-ui/core"
import {
type ApiKeyPluginOptions,
apiKeyPlugin as coreApiKeyPlugin
} from "@better-auth-ui/core/plugins/api-key"
import { ApiKeys } from "@/components/auth/api-key/api-keys"
import { OrganizationApiKeys } from "@/components/auth/api-key/organization-api-keys"
export const apiKeyPlugin = createAuthPlugin(
coreApiKeyPlugin.id,
(options: ApiKeyPluginOptions = {}) => {
const core = coreApiKeyPlugin(options)
return {
...core,
securityCards: [ApiKeys],
...(core.organization ? { organizationCards: [OrganizationApiKeys] } : {})
}
}
)
examples/start-shadcn-example/src/lib/auth/auth-plugin.ts
import type {
AuthPluginComponents,
AuthPlugin as AuthPluginPrimitive
} from "@better-auth-ui/react"
declare module "@better-auth-ui/core" {
/** Widens `useAuth().plugins` to the shadcn-typed `AuthPlugin`. */
interface AuthPluginRegister {
shadcn: AuthPlugin
}
}
/** Props the shadcn `<Auth>` router spreads onto plugin-contributed auth views. */
export type AuthViewProps = {
className?: string
socialLayout?: "auto" | "horizontal" | "vertical" | "grid"
socialPosition?: "top" | "bottom"
}
/** Props the shadcn `<Settings>` router spreads onto plugin-contributed settings views. */
export type SettingsViewProps = {
className?: string
}
/** Shadcn plugin type. Plugin authors import this from `@/lib/auth/auth-plugin`. */
export type AuthPlugin = AuthPluginPrimitive<
AuthPluginComponents,
AuthViewProps,
SettingsViewProps
>
examples/start-shadcn-example/src/lib/auth/organization-plugin.tsx
import { createAuthPlugin } from "@better-auth-ui/core"
import {
organizationPlugin as coreOrganizationPlugin,
type OrganizationLocalization,
type OrganizationPluginOptions
} from "@better-auth-ui/core/plugins/organization"
import { Briefcase } from "lucide-react"
import { AcceptInvitation } from "@/components/auth/organization/accept-invitation"
import { OrganizationsSettings } from "@/components/auth/organization/organizations-settings"
export const organizationPlugin = createAuthPlugin(
coreOrganizationPlugin.id,
(options: OrganizationPluginOptions = {}) => {
const core = coreOrganizationPlugin(options)
return {
...core,
localization: core.localization as OrganizationLocalization,
views: {
auth: { acceptInvitation: AcceptInvitation }
},
settingsTabs: [
{
view: "organizations",
label: (
<>
<Briefcase className="text-muted-foreground" />
{core.localization.organizations}
</>
),
component: OrganizationsSettings
}
]
}
}
)