better-auth-ui
componentdashsource: ok · hit
Dash Activity
Filtered personal, organization, and administrator audit logs backed by Better Auth Infrastructure Dash.
npx shadcn add "https://better-auth-ui.com/r/{style}/dash.json"free
Where it goes
dashboard-shell
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
avatarbadgebuttoncalendarcardcheckboxcomboboxdialogemptyfieldinputinput-groupitempopoverselectseparatorskeletonslidersonnerspinnerswitchtextarea
Files22 · source cached 10/8/2026, 3:40:50 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/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/dash/activity.tsx
"use client"
import {
DEFAULT_TABLE_SEARCH_DEBOUNCE_MS,
getClampedTablePageIndex
} from "@better-auth-ui/core"
import {
type DashAuditLog,
type DashAuthClient,
formatDashEventName,
getDashEventDetail,
getDashEventKey,
getDashEventLocation
} from "@better-auth-ui/core/plugins/dash"
import {
hasMemberRole,
type OrganizationAuthClient
} from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import {
useDashAllAuditLogs,
useDashAuditLogs,
useDashUserAuditLogs
} from "@better-auth-ui/react/plugins/dash"
import { useActiveMemberRole } from "@better-auth-ui/react/plugins/organization"
import { useDebouncedValue } from "@tanstack/react-pacer"
import { keepPreviousData } from "@tanstack/react-query"
import {
columnFilteringFeature,
createTableHook,
globalFilteringFeature,
rowPaginationFeature,
tableFeatures
} from "@tanstack/react-table"
import {
Activity,
Building2,
ChevronLeft,
ChevronRight,
MonitorDot,
Search,
ShieldCheck,
UserRound,
Users
} from "lucide-react"
import { Fragment, useEffect, useMemo } from "react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle
} from "@/components/ui/card"
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle
} from "@/components/ui/empty"
import { Field, FieldLabel } from "@/components/ui/field"
import {
InputGroup,
InputGroupAddon,
InputGroupInput
} from "@/components/ui/input-group"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
import { Skeleton } from "@/components/ui/skeleton"
import { Spinner } from "@/components/ui/spinner"
import { dashPlugin } from "@/lib/auth/dash-plugin"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { cn } from "cn"
import { useServerTableState } from "../server-table-state"
type ActivityAccess = "admin" | "admin-user" | "organization" | "user"
const { createAppColumnHelper, useAppTable: useActivityTable } =
createTableHook({
features: tableFeatures({
columnFilteringFeature,
globalFilteringFeature,
rowPaginationFeature
})
})
const activityColumnHelper = createAppColumnHelper<DashAuditLog>()
const activityColumns = activityColumnHelper.columns([
activityColumnHelper.accessor("eventType", { id: "eventType" })
])
const EMPTY_EVENTS: DashAuditLog[] = []
type ActivityFeedProps = {
access: ActivityAccess
organizationId?: string
ready?: boolean
className?: string
userId?: string
}
export type AdminUserActivityProps = {
className?: string
userId: string
}
export type AdminActivityProps = { className?: string }
export type UserActivityProps = { className?: string }
export type OrganizationActivityProps = {
className?: string
organizationId: string
organizationSlug: string
}
const generateN = (count: number) =>
Array.from(
{ length: Math.max(0, Math.floor(count)) },
(_, index) => index + 1
)
const formatRelativeTime = (value: string) => {
const date = new Date(value)
const seconds = Math.floor((Date.now() - date.getTime()) / 1000)
if (!Number.isFinite(seconds)) return value
const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" })
const units: [Intl.RelativeTimeFormatUnit, number][] = [
["year", 31_536_000],
["month", 2_592_000],
["week", 604_800],
["day", 86_400],
["hour", 3_600],
["minute", 60],
["second", 1]
]
for (const [unit, threshold] of units) {
if (Math.abs(seconds) >= threshold) {
return formatter.format(-Math.trunc(seconds / threshold), unit)
}
}
return formatter.format(0, "second")
}
const getEventIcon = (eventType: string) => {
if (
eventType.includes("password") ||
eventType.includes("two_factor") ||
eventType.includes("banned") ||
eventType === "user_sign_in_failed"
) {
return ShieldCheck
}
if (eventType.startsWith("organization_member")) return Users
if (eventType.startsWith("organization_")) return Building2
if (eventType.includes("session") || eventType.includes("signed_")) {
return MonitorDot
}
if (
eventType.startsWith("user_") ||
eventType.startsWith("profile_") ||
eventType.startsWith("email_") ||
eventType.startsWith("account_")
) {
return UserRound
}
return Activity
}
function ActivityRow({ event }: { event: DashAuditLog }) {
const { localization, showIpAddress } = useAuthPlugin(dashPlugin)
const Icon = getEventIcon(event.eventType)
const title =
(localization.eventLabels as Record<string, string>)[event.eventType] ??
formatDashEventName(event.eventType) ??
localization.unknownEvent
const detail = getDashEventDetail(event)
const location = getDashEventLocation(event, showIpAddress)
const absoluteTime = new Date(event.createdAt).toLocaleString()
return (
<li className="flex items-start gap-3 py-4 first:pt-0 last:pb-0">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<Icon className="size-4" />
</div>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex flex-col gap-0.5 sm:flex-row sm:items-baseline sm:justify-between sm:gap-3">
<p className="truncate text-sm font-medium">{title}</p>
<time
className="shrink-0 text-xs text-muted-foreground"
dateTime={event.createdAt}
title={absoluteTime}
>
{formatRelativeTime(event.createdAt)}
</time>
</div>
{(detail || location) && (
<p className="truncate text-xs text-muted-foreground">
{[detail, location].filter(Boolean).join(" · ")}
</p>
)}
</div>
</li>
)
}
function ActivityRowSkeleton() {
return (
<li className="flex items-start gap-3 py-4 first:pt-0 last:pb-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex min-w-0 flex-1 flex-col gap-2 pt-0.5">
<Skeleton className="h-4 w-36" />
<Skeleton className="h-3 w-52 max-w-full" />
</div>
</li>
)
}
function ActivityFeed({
access,
organizationId,
ready = true,
className,
userId
}: ActivityFeedProps) {
const { authClient } = useAuth()
const { localization, pageSize } = useAuthPlugin(dashPlugin)
const tableState = useServerTableState({ pageSize })
const { columnFilters, globalFilter, pagination, setPagination } = tableState
const eventType = String(
columnFilters.find((filter) => filter.id === "eventType")?.value ?? "all"
)
const [debouncedIdentifier] = useDebouncedValue(globalFilter.trim(), {
wait: DEFAULT_TABLE_SEARCH_DEBOUNCE_MS
})
const eventOptions = useMemo(
() => Object.entries(localization.eventLabels),
[localization.eventLabels]
)
const offset = pagination.pageIndex * pagination.pageSize
const params = {
eventType: eventType === "all" ? undefined : eventType,
identifier: debouncedIdentifier || undefined,
limit: pagination.pageSize,
offset,
organizationId
}
const userQuery = useDashAuditLogs(authClient as DashAuthClient, {
enabled: ready && access === "user",
params,
placeholderData: keepPreviousData
})
const organizationQuery = useDashAllAuditLogs(authClient as DashAuthClient, {
enabled: ready && (access === "admin" || access === "organization"),
params,
placeholderData: keepPreviousData
})
const adminUserQuery = useDashUserAuditLogs(
authClient as DashAuthClient,
userId,
{
enabled: ready && access === "admin-user",
params: {
eventType: params.eventType,
identifier: params.identifier,
limit: pagination.pageSize,
offset
}
}
)
const query =
access === "admin" || access === "organization"
? organizationQuery
: access === "admin-user"
? adminUserQuery
: userQuery
const { data, error, isFetching, isPending } = query
const showPending = !ready || isPending
const pageEnd = offset + (data?.events.length ?? 0)
useEffect(() => {
if (!ready || !query.isSuccess) return
const pageIndex = getClampedTablePageIndex(
pagination.pageIndex,
pagination.pageSize,
data?.total ?? 0
)
if (pageIndex !== pagination.pageIndex) {
setPagination((current) => ({ ...current, pageIndex }))
}
}, [
data?.total,
pagination.pageIndex,
pagination.pageSize,
query.isSuccess,
ready,
setPagination
])
const table = useActivityTable(
{
atoms: tableState.atoms,
columns: activityColumns,
data: data?.events ?? EMPTY_EVENTS,
getRowId: getDashEventKey,
manualFiltering: true,
manualPagination: true,
rowCount: data?.total ?? 0
},
() => null
)
return (
<Card
className={cn("w-full", className)}
aria-busy={showPending || isFetching}
>
<CardHeader>
<CardTitle>{localization.activity}</CardTitle>
<CardDescription>
{access === "admin"
? localization.adminActivityDescription
: access === "admin-user"
? localization.adminUserActivityDescription
: organizationId
? localization.organizationActivityDescription
: localization.activityDescription}
</CardDescription>
{organizationId && (
<CardAction>
<Badge variant="secondary">
{access === "organization"
? localization.organizationWide
: localization.personalOnly}
</Badge>
</CardAction>
)}
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid gap-2 sm:grid-cols-[12rem_minmax(14rem,1fr)]">
<Field>
<FieldLabel htmlFor="dash-event-type">
{localization.eventType}
</FieldLabel>
<Select
value={eventType}
onValueChange={(value) => {
table
.getColumn("eventType")
?.setFilterValue(value === "all" ? undefined : value)
}}
>
<SelectTrigger id="dash-event-type" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{localization.allEvents}</SelectItem>
{eventOptions.map(([key, label]) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="dash-identifier">
{localization.identifier}
</FieldLabel>
<InputGroup>
<InputGroupAddon>
<Search />
</InputGroupAddon>
<InputGroupInput
id="dash-identifier"
onChange={(event) => {
table.setGlobalFilter(event.target.value)
}}
placeholder={localization.identifierPlaceholder}
value={globalFilter}
/>
</InputGroup>
</Field>
</div>
{showPending ? (
<ul>
{generateN(3).map((skeletonId, position) => (
<Fragment key={skeletonId}>
{position > 0 && <Separator />}
<ActivityRowSkeleton />
</Fragment>
))}
</ul>
) : error ? (
<Empty className="min-h-48 border-0 p-4">
<EmptyHeader>
<EmptyMedia variant="icon">
<Activity />
</EmptyMedia>
<EmptyTitle>{localization.activityLoadError}</EmptyTitle>
<EmptyDescription>
{localization.activityLoadErrorDescription}
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button
size="sm"
variant="outline"
onClick={() => query.refetch()}
>
{localization.retry}
</Button>
</EmptyContent>
</Empty>
) : data?.events.length ? (
<ul>
{table.getRowModel().rows.map((row, position) => (
<Fragment key={row.id}>
{position > 0 && <Separator />}
<ActivityRow event={row.original} />
</Fragment>
))}
</ul>
) : (
<Empty className="min-h-48 border-0 p-4">
<EmptyHeader>
<EmptyMedia variant="icon">
<Activity />
</EmptyMedia>
<EmptyTitle>{localization.noActivity}</EmptyTitle>
<EmptyDescription>
{localization.noActivityDescription}
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</CardContent>
{!error && !!data?.total && (
<CardFooter className="justify-between gap-3">
<div className="flex items-center gap-2">
<p className="text-xs tabular-nums text-muted-foreground">
{localization.paginationRange
.replace("{{from}}", String(offset + 1))
.replace("{{to}}", String(pageEnd))
.replace("{{total}}", String(data.total))}
</p>
{isFetching && <Spinner />}
</div>
<div className="flex gap-1">
<Button
aria-label={localization.previousPage}
disabled={isFetching || !table.getCanPreviousPage()}
size="icon-sm"
variant="ghost"
onClick={() => table.previousPage()}
>
<ChevronLeft />
</Button>
<Button
aria-label={localization.nextPage}
disabled={isFetching || !table.getCanNextPage()}
size="icon-sm"
variant="ghost"
onClick={() => table.nextPage()}
>
<ChevronRight />
</Button>
</div>
</CardFooter>
)}
</Card>
)
}
/** Authentication and account activity for a user selected by an administrator. */
export function AdminUserActivity(props: AdminUserActivityProps) {
return <ActivityFeed key={props.userId} access="admin-user" {...props} />
}
/** Activity across every organization the current owner or administrator can manage. */
export function AdminActivity(props: AdminActivityProps) {
return <ActivityFeed access="admin" {...props} />
}
/** Personal authentication and account activity. */
export function UserActivity(props: UserActivityProps) {
return <ActivityFeed access="user" {...props} />
}
/** Activity for an explicit organization. */
export function OrganizationActivity({
organizationId,
organizationSlug: _organizationSlug,
...props
}: OrganizationActivityProps) {
const { authClient } = useAuth()
const { creatorRole } = useAuthPlugin(organizationPlugin)
const { data: memberRole, isPending } = useActiveMemberRole(
authClient as OrganizationAuthClient,
{ query: { organizationId } }
)
const canViewOrganization =
hasMemberRole(memberRole?.role, creatorRole) ||
hasMemberRole(memberRole?.role, "admin")
if (isPending) {
return (
<ActivityFeed
key={organizationId}
access="user"
organizationId={organizationId}
ready={false}
{...props}
/>
)
}
return (
<ActivityFeed
key={organizationId}
access={canViewOrganization ? "organization" : "user"}
organizationId={organizationId}
{...props}
/>
)
}
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, Check, X } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, 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 { 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-sm", className)}>
<CardHeader className="gap-3">
<div className="flex size-10 items-center justify-center rounded-md bg-muted">
<BriefcaseBusiness className="size-5" />
</div>
<CardTitle className="text-xl font-semibold">
{isLoading ? (
<Skeleton className="h-6 w-48" />
) : isAvailable ? (
localization.acceptInvitationTitle
) : (
localization.invitationUnavailable
)}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{isLoading ? (
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
) : isAvailable ? (
<>
<FieldDescription>
{localization.acceptInvitationDescription
.replace("{{organization}}", organizationName)
.replace("{{role}}", role)}
</FieldDescription>
<div className="flex items-center gap-3 rounded-md bg-muted p-3">
<div className="min-w-0 flex-1 truncate text-sm font-medium">
{organizationName}
</div>
<Badge variant="secondary">{role}</Badge>
</div>
</>
) : (
<FieldDescription>
{localization.invitationUnavailableDescription}
</FieldDescription>
)}
<div className="flex gap-2">
{isLoading ? (
<>
<Skeleton className="h-9 flex-1" />
<Skeleton className="h-9 flex-1" />
</>
) : isAvailable && invitation ? (
<>
<Button
className="flex-1"
type="button"
variant="outline"
disabled={isAccepting || isRejecting}
onClick={() =>
rejectInvitation({ invitationId: invitation.id })
}
>
{isRejecting ? <Spinner /> : <X />}
{localization.rejectInvitation}
</Button>
<Button
className="flex-1"
type="button"
disabled={isAccepting || isRejecting}
onClick={() =>
acceptInvitation({ invitationId: invitation.id })
}
>
{isAccepting ? <Spinner /> : <Check />}
{localization.accept}
</Button>
</>
) : (
<Button
className="w-full"
type="button"
onClick={returnToApplication}
>
{localization.return}
</Button>
)}
</div>
</CardContent>
</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/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/dash-plugin.ts
import { createAuthPlugin } from "@better-auth-ui/core"
import {
dashPlugin as coreDashPlugin,
type DashPluginOptions
} from "@better-auth-ui/core/plugins/dash"
import { Activity } from "lucide-react"
import { createElement } from "react"
import {
AdminActivity,
AdminUserActivity,
OrganizationActivity,
UserActivity
} from "@/components/auth/dash/activity"
const activityLabel = (label: string) =>
createElement(
"span",
{ className: "inline-flex items-center gap-1" },
createElement(Activity, { className: "text-muted-foreground" }),
label
)
export const dashPlugin = createAuthPlugin(
coreDashPlugin.id,
(options: DashPluginOptions = {}) => {
const core = coreDashPlugin(options)
return {
...core,
...(core.admin
? {
adminTabs: [
{
id: "activity",
path: core.viewPaths.settings.activity,
label: activityLabel(core.localization.activity),
component: AdminActivity
}
],
adminUserTabs: [
{
id: "activity",
label: activityLabel(core.localization.activity),
component: AdminUserActivity
}
]
}
: {}),
...(core.user
? {
settingsTabs: [
{
view: "activity" as const,
label: activityLabel(core.localization.activity),
component: UserActivity
}
]
}
: {}),
...(core.organization
? {
organizationTabs: [
{
id: "activity",
path: core.viewPaths.settings.activity,
label: activityLabel(core.localization.activity),
component: OrganizationActivity
}
]
}
: {})
}
}
)
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
}
]
}
}
)