better-auth-ui
componentorganizationsource: ok · hit
Organization
Organization plugin: multi-tenant organization management with members, invitations, and roles. Registers direct invitation acceptance, an organizations settings tab, a full `<Organization />` shell, and an `<OrganizationSwitcher />` dropdown.
npx shadcn add "https://better-auth-ui.com/r/{style}/organization.json"free
Where it goes
signupsettings-form
Interaction
dropdown
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-dialogavatarbadgebuttoncalendarcardcheckboxcomboboxdialogdropdown-menuemptyfieldhttps://better-auth-ui.com/r/radix-nova/additional-field.jsonhttps://better-auth-ui.com/r/radix-nova/user-view.jsoninputinput-groupitemlabelpopoverradio-groupselectseparatorskeletonslidersonnerspinnerswitchtabletabstextarea
Files53 · source cached 10/8/2026, 7:45:13 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/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/change-organization-logo.tsx
"use client"
import { fileToBase64 } from "@better-auth-ui/core"
import type { OrganizationAuthClient } from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import {
useActiveOrganization,
useHasPermission,
useUpdateOrganization
} from "@better-auth-ui/react/plugins/organization"
import { Trash2, Upload } from "lucide-react"
import { type ChangeEvent, useRef, useState } from "react"
import { toast } from "sonner"
import { Button, buttonVariants } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import { Label } from "@/components/ui/label"
import { Spinner } from "@/components/ui/spinner"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { cn } from "cn"
import { OrganizationLogo } from "./organization-logo"
export type ChangeOrganizationLogoProps = {
className?: string
}
export function ChangeOrganizationLogo({
className
}: ChangeOrganizationLogoProps) {
const { authClient, localization } = useAuth<OrganizationAuthClient>()
const { logo, localization: organizationLocalization } =
useAuthPlugin(organizationPlugin)
const { data: activeOrganization, isPending: activeOrganizationPending } =
useActiveOrganization(authClient)
const canUpdate = useHasPermission(authClient, {
permissions: { organization: ["update"] }
})
const { mutate: updateOrganization, isPending: updatePending } =
useUpdateOrganization(authClient)
const fileInputRef = useRef<HTMLInputElement>(null)
const [isUploading, setIsUploading] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const isPending = updatePending || isUploading || isDeleting
async function handleFileChange(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file || !activeOrganization || !canUpdate.data?.success) return
e.target.value = ""
setIsUploading(true)
try {
const resized =
(await logo.resize?.(file, logo.size, logo.extension)) || file
const image =
(await logo.upload?.(resized)) || (await fileToBase64(resized))
updateOrganization(
{ data: { logo: image } },
{
onSuccess: () =>
toast.success(organizationLocalization.logoChangedSuccess),
onSettled: () => setIsUploading(false)
}
)
} catch (error) {
setIsUploading(false)
console.error("[Better Auth UI] Image operation failed", error)
toast.error(localization.errors.imageUploadFailed)
}
}
async function handleDelete() {
if (!canUpdate.data?.success) return
const currentLogo = activeOrganization?.logo
updateOrganization(
{ data: { logo: "" } },
{
onSuccess: async () => {
if (!currentLogo) {
toast.success(organizationLocalization.logoDeletedSuccess)
return
}
setIsDeleting(true)
try {
await logo.delete?.(currentLogo)
toast.success(organizationLocalization.logoDeletedSuccess)
} catch (error) {
console.error("[Better Auth UI] Image operation failed", error)
toast.error(localization.errors.imageDeleteFailed)
} finally {
setIsDeleting(false)
}
}
}
)
}
if (!logo.enabled) {
return null
}
return (
<div className={cn("flex flex-col gap-1", className)}>
<Label aria-disabled={!activeOrganization}>
{organizationLocalization.logo}
</Label>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
<div className="flex items-center gap-4">
{canUpdate.data?.success ? (
<Button
type="button"
variant="ghost"
size="icon"
className="h-auto w-auto rounded-full p-0"
disabled={!activeOrganization || isPending}
onClick={() => fileInputRef.current?.click()}
>
<OrganizationLogo
size="lg"
isPending={activeOrganizationPending}
organization={activeOrganization}
/>
</Button>
) : (
<OrganizationLogo
size="lg"
isPending={activeOrganizationPending}
organization={activeOrganization}
/>
)}
{(canUpdate.isPending || canUpdate.data?.success) && (
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
buttonVariants({ size: "sm", variant: "secondary" })
)}
disabled={!activeOrganization || isPending || canUpdate.isPending}
>
{isPending && <Spinner />}
{organizationLocalization.changeLogo}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-fit">
<DropdownMenuItem onClick={() => fileInputRef.current?.click()}>
<Upload className="text-muted-foreground" />
{organizationLocalization.uploadLogo}
</DropdownMenuItem>
<DropdownMenuItem
disabled={!activeOrganization?.logo}
onClick={handleDelete}
variant="destructive"
>
<Trash2 />
{organizationLocalization.deleteLogo}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
)
}
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/delete-organization-dialog.tsx
"use client"
import type { OrganizationAuthClient } from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import { useDeleteOrganization } from "@better-auth-ui/react/plugins/organization"
import type { Organization } from "better-auth/client"
import { TriangleAlert } from "lucide-react"
import { toast } from "sonner"
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle
} from "@/components/ui/alert-dialog"
import { Card, CardContent } from "@/components/ui/card"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { useAuthForm } from "../auth-form"
import { OrganizationView } from "./organization-view"
export type DeleteOrganizationDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
organization: Organization
}
export function DeleteOrganizationDialog({
open,
onOpenChange,
organization
}: DeleteOrganizationDialogProps) {
const { authClient, basePaths, localization, navigate } =
useAuth<OrganizationAuthClient>()
const {
localization: organizationLocalization,
viewPaths: organizationPluginViewPaths
} = useAuthPlugin(organizationPlugin)
const { mutateAsync: deleteOrganization, isPending } = useDeleteOrganization(
authClient,
{
onSuccess: () => {
onOpenChange(false)
toast.success(organizationLocalization.organizationDeleted)
navigate({
to: `${basePaths.settings}/${organizationPluginViewPaths.settings.organizations}`,
replace: true
})
}
}
)
const form = useAuthForm({
defaultValues: {},
onSubmit: async () => {
await deleteOrganization({ organizationId: organization.id })
}
})
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<form.AppForm>
<form.AuthFormRoot className="flex flex-col gap-6">
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive">
<TriangleAlert />
</AlertDialogMedia>
<AlertDialogTitle>
{organizationLocalization.deleteOrganization}
</AlertDialogTitle>
<AlertDialogDescription>
{organizationLocalization.deleteOrganizationDescription}
</AlertDialogDescription>
</AlertDialogHeader>
<Card>
<CardContent>
<OrganizationView organization={organization} hideRole />
</CardContent>
</Card>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>
{localization.settings.cancel}
</AlertDialogCancel>
<form.AuthFormSubmitButton
isPending={isPending}
variant="destructive"
disabled={isPending}
>
{organizationLocalization.deleteOrganization}
</form.AuthFormSubmitButton>
</AlertDialogFooter>
</form.AuthFormRoot>
</form.AppForm>
</AlertDialogContent>
</AlertDialog>
)
}
examples/start-shadcn-example/src/components/auth/organization/delete-organization-skeleton.tsx
"use client"
import { Skeleton } from "@/components/ui/skeleton"
/**
* Placeholder matching `DeleteOrganization` while the delete permission resolves.
*/
export function DeleteOrganizationSkeleton() {
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-1">
<Skeleton className="h-3.5 w-40 rounded-md" />
<Skeleton className="h-3 w-64 rounded-md" />
</div>
<Skeleton className="h-8 w-36 shrink-0 rounded-md" />
</div>
)
}
examples/start-shadcn-example/src/components/auth/organization/delete-organization.tsx
"use client"
import type { OrganizationAuthClient } from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import {
useActiveOrganization,
useHasPermission
} from "@better-auth-ui/react/plugins/organization"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { DeleteOrganizationDialog } from "./delete-organization-dialog"
import { DeleteOrganizationSkeleton } from "./delete-organization-skeleton"
/**
* Danger-zone row to delete the active organization. Hidden for members without
* the `organization:delete` permission.
*/
export function DeleteOrganization() {
const { authClient } = useAuth<OrganizationAuthClient>()
const { localization: organizationLocalization } =
useAuthPlugin(organizationPlugin)
const { data: activeOrganization } = useActiveOrganization(authClient)
const { data: permission, isPending: permissionPending } = useHasPermission(
authClient,
{
permissions: { organization: ["delete"] }
}
)
const [confirmOpen, setConfirmOpen] = useState(false)
if (permissionPending) {
return <DeleteOrganizationSkeleton />
}
if (!permission?.success) {
return null
}
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-medium leading-tight">
{organizationLocalization.deleteOrganization}
</p>
<p className="text-muted-foreground mt-0.5 text-xs">
{organizationLocalization.deleteOrganizationDescription}
</p>
</div>
<Button
disabled={!activeOrganization}
size="sm"
variant="outline"
className="text-destructive"
onClick={() => setConfirmOpen(true)}
>
{organizationLocalization.deleteOrganization}
</Button>
{activeOrganization && (
<DeleteOrganizationDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
organization={activeOrganization}
/>
)}
</div>
)
}
examples/start-shadcn-example/src/components/auth/organization/edit-member-roles-dialog.tsx
"use client"
import { validateMinimumItems } from "@better-auth-ui/core"
import type { OrganizationAuthClient } from "@better-auth-ui/core/plugins/organization"
import { parseMemberRoles } from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import { useUpdateMemberRole } from "@better-auth-ui/react/plugins/organization"
import { ShieldCheck } from "lucide-react"
import { useEffect } from "react"
import { toast } from "sonner"
import { buttonVariants } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from "@/components/ui/dialog"
import {
Field,
FieldContent,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
FieldTitle
} from "@/components/ui/field"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { useAuthForm } from "../auth-form"
export type EditMemberRolesDialogProps = {
member: {
id: string
role?: string | null
}
onOpenChange: (open: boolean) => void
open: boolean
organizationId: string
roles: Array<[string, string]>
protectedRole?: string
protectedRoleRemovalDisabled?: boolean
}
const selectedMemberRoles = (
memberRole: string | null | undefined,
allowMultipleRoles: boolean,
protectedRole?: string
) => {
const parsedRoles = parseMemberRoles(memberRole)
if (allowMultipleRoles) return parsedRoles
const selectedRole =
protectedRole && parsedRoles.includes(protectedRole)
? protectedRole
: parsedRoles[0]
return selectedRole ? [selectedRole] : []
}
export function EditMemberRolesDialog({
member,
onOpenChange,
open,
organizationId,
roles,
protectedRole,
protectedRoleRemovalDisabled
}: EditMemberRolesDialogProps) {
const { authClient, localization } = useAuth<OrganizationAuthClient>()
const { allowMultipleRoles, localization: organizationLocalization } =
useAuthPlugin(organizationPlugin)
const { mutateAsync: updateMemberRole, isPending } = useUpdateMemberRole(
authClient,
{
onSuccess: () => {
toast.success(organizationLocalization.memberRoleUpdated)
onOpenChange(false)
}
}
)
const form = useAuthForm({
defaultValues: {
roles: selectedMemberRoles(member.role, allowMultipleRoles, protectedRole)
},
onSubmit: async ({ value }) => {
if (value.roles.length === 0) return
try {
await updateMemberRole({
memberId: member.id,
organizationId,
role: value.roles
})
} catch {
// The mutation reports the error through its configured handler.
}
}
})
useEffect(() => {
if (open)
form.reset({
roles: selectedMemberRoles(
member.role,
allowMultipleRoles,
protectedRole
)
})
}, [allowMultipleRoles, form.reset, member.role, open, protectedRole])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<form.AppForm>
<form.AuthFormRoot className="flex flex-col gap-6">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShieldCheck />
{organizationLocalization.changeMemberRole}
</DialogTitle>
<DialogDescription>
{organizationLocalization.changeMemberRoleDescription}
</DialogDescription>
</DialogHeader>
<form.AppField
name="roles"
mode="array"
validators={{
onChange: ({ value }) =>
validateMinimumItems(
value,
1,
organizationLocalization.selectAtLeastOneRole
)
}}
>
{(field) => {
const selectedRoles = field.state.value
const protectedRoleSelected =
!allowMultipleRoles &&
protectedRoleRemovalDisabled &&
protectedRole !== undefined &&
selectedRoles.includes(protectedRole)
const roleOptions = roles.map(([role, label]) => {
const checked = selectedRoles.includes(role)
const disabled =
isPending ||
(allowMultipleRoles &&
checked &&
selectedRoles.length === 1) ||
(protectedRoleSelected && role !== protectedRole) ||
(role === protectedRole &&
checked &&
protectedRoleRemovalDisabled)
const id = `member-${member.id}-role-${role}`
return (
<FieldLabel htmlFor={id} key={role}>
<Field orientation="horizontal" data-disabled={disabled}>
<FieldContent>
<FieldTitle>{label}</FieldTitle>
</FieldContent>
{allowMultipleRoles ? (
<Checkbox
checked={checked}
disabled={disabled}
id={id}
onCheckedChange={(next) => {
if (next === true && !checked) {
field.pushValue(role)
return
}
const index = selectedRoles.indexOf(role)
if (index >= 0) field.removeValue(index)
}}
/>
) : (
<RadioGroupItem
disabled={disabled}
id={id}
value={role}
/>
)}
</Field>
</FieldLabel>
)
})
return (
<>
{allowMultipleRoles ? (
<FieldSet>
<FieldLegend className="sr-only" variant="label">
{organizationLocalization.changeMemberRole}
</FieldLegend>
<FieldGroup className="gap-2">{roleOptions}</FieldGroup>
</FieldSet>
) : (
<RadioGroup
disabled={isPending}
onValueChange={(role) => field.handleChange([role])}
value={selectedRoles[0] ?? ""}
>
{roleOptions}
</RadioGroup>
)}
<field.AuthFormFieldError />
<DialogFooter>
<DialogClose
className={buttonVariants({ variant: "outline" })}
disabled={isPending}
type="button"
>
{localization.settings.cancel}
</DialogClose>
<form.AuthFormSubmitButton disabled={isPending}>
{localization.settings.saveChanges}
</form.AuthFormSubmitButton>
</DialogFooter>
</>
)
}}
</form.AppField>
</form.AuthFormRoot>
</form.AppForm>
</DialogContent>
</Dialog>
)
}
examples/start-shadcn-example/src/components/auth/organization/invite-member-dialog.tsx
"use client"
import {
getAdditionalFieldDefaultValues,
getAdditionalFieldSubmitValues,
validateEmailAddress
} from "@better-auth-ui/core"
import {
mergeOrganizationRoleLabels,
type OrganizationAuthClient,
type OrganizationRolesAuthClient,
type OrganizationTeamsAuthClient
} from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import {
useActiveOrganization,
useHasPermission,
useInviteMember,
useListOrganizationInvitations,
useListRoles,
useListTeams
} from "@better-auth-ui/react/plugins/organization"
import { ChevronDown, UserPlus } from "lucide-react"
import { useEffect, useMemo, useRef } from "react"
import { toast } from "sonner"
import { buttonVariants } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from "@/components/ui/dialog"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import { Field, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { cn } from "cn"
import {
getAuthAdditionalFieldValidators,
isAuthFormFieldInvalid,
useAuthForm
} from "../auth-form"
/** Props for the `InviteMemberDialog` component. */
export type InviteMemberDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
}
const pickDefaultRole = (keys: string[]) =>
keys.includes("member") ? "member" : (keys.at(-1) ?? "")
/**
* Render a dialog for inviting a member to the organization.
*/
export function InviteMemberDialog({
open,
onOpenChange
}: InviteMemberDialogProps) {
const { authClient, localization } = useAuth<OrganizationAuthClient>()
const {
allowMultipleRoles,
modelFields: { invitation: invitationFields },
dynamicAccessControl,
invitationLimit,
localization: organizationLocalization,
roles,
teams: teamsEnabled
} = useAuthPlugin(organizationPlugin)
const { data: activeOrganization } = useActiveOrganization(authClient)
const teams = useListTeams(authClient as OrganizationTeamsAuthClient, {
query: { organizationId: activeOrganization?.id },
enabled: teamsEnabled
})
const invitations = useListOrganizationInvitations(authClient)
const canInvite = useHasPermission(authClient, {
organizationId: activeOrganization?.id,
permissions: { invitation: ["create"] }
})
const canReadRoles = useHasPermission(authClient, {
organizationId: activeOrganization?.id,
permissions: { ac: ["read"] }
})
const dynamicRoles = useListRoles(authClient as OrganizationRolesAuthClient, {
query: { organizationId: activeOrganization?.id },
enabled:
dynamicAccessControl?.enabled === true &&
canReadRoles.data?.success === true
})
const assignableRoles = useMemo(
() => mergeOrganizationRoleLabels(roles, dynamicRoles.data),
[dynamicRoles.data, roles]
)
const activeOrganizationId = activeOrganization?.id
const previousOrganizationId = useRef(activeOrganizationId)
const { mutateAsync: inviteMember, isPending: isInviting } = useInviteMember(
authClient as OrganizationTeamsAuthClient,
{
onSuccess: () => {
onOpenChange(false)
toast.success(organizationLocalization.inviteMemberSuccess)
}
}
)
const atInvitationLimit =
invitationLimit !== undefined &&
(invitations.data?.filter((invitation) => invitation.status === "pending")
.length ?? 0) >= invitationLimit
const form = useAuthForm({
defaultValues: {
additionalFields: getAdditionalFieldDefaultValues(invitationFields),
email: "",
roles: [] as string[],
teamId: ""
},
onSubmit: async ({ value }) => {
if (
!activeOrganizationId ||
!canInvite.data?.success ||
value.roles.length === 0 ||
atInvitationLimit
)
return
const teamId = teams.data?.some((team) => team.id === value.teamId)
? value.teamId
: undefined
try {
await inviteMember({
...getAdditionalFieldSubmitValues(
invitationFields,
value.additionalFields
),
email: value.email.trim(),
organizationId: activeOrganizationId,
role: value.roles as Parameters<typeof inviteMember>[0]["role"],
teamId
})
} catch {
// The mutation reports the error through its configured handler.
}
}
})
useEffect(() => {
const keys = Object.keys(assignableRoles)
const current = form.getFieldValue("roles")
const kept = current.filter((entry) => keys.includes(entry))
const roles =
kept.length > 0
? allowMultipleRoles
? kept
: kept.slice(0, 1)
: (() => {
const fallback = pickDefaultRole(keys)
return fallback ? [fallback] : []
})()
form.setFieldValue("roles", roles)
}, [allowMultipleRoles, assignableRoles, form])
useEffect(() => {
const organizationChanged =
previousOrganizationId.current !== activeOrganizationId
if (open || organizationChanged) {
const fallback = pickDefaultRole(Object.keys(assignableRoles))
form.reset({
additionalFields: getAdditionalFieldDefaultValues(invitationFields),
email: "",
roles: fallback ? [fallback] : [],
teamId: ""
})
}
previousOrganizationId.current = activeOrganizationId
}, [activeOrganizationId, assignableRoles, form, invitationFields, open])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<form.AppForm>
<form.AuthFormRoot className="flex flex-col gap-6">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UserPlus />
{organizationLocalization.inviteMember}
</DialogTitle>
<DialogDescription>
{organizationLocalization.inviteMemberDescription}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<form.AppField
name="email"
validators={{
onChange: ({ value }) =>
validateEmailAddress(value, {
invalidMessage: localization.auth.invalidEmail,
requiredMessage: localization.auth.fieldRequired
})
}}
>
{(field) => {
const isInvalid = isAuthFormFieldInvalid(field.state.meta)
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor="invite-member-email">
{localization.auth.email}
</FieldLabel>
<Input
id="invite-member-email"
name={field.name}
type="email"
autoFocus
placeholder={localization.auth.email}
disabled={isInviting}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) =>
field.handleChange(event.target.value)
}
aria-invalid={isInvalid}
/>
<field.AuthFormFieldError />
</Field>
)
}}
</form.AppField>
<form.AppField
name="roles"
validators={{
onChange: ({ value }) =>
value.length > 0
? undefined
: localization.auth.fieldRequired
}}
>
{(field) => {
const selectedRoles = field.state.value
const roleSummary = selectedRoles
.map((entry) => assignableRoles[entry] ?? entry)
.join(", ")
const toggleRole = (role: string) =>
field.handleChange(
selectedRoles.includes(role)
? selectedRoles.filter((entry) => entry !== role)
: [...selectedRoles, role]
)
return (
<Field
data-invalid={isAuthFormFieldInvalid(field.state.meta)}
>
<FieldLabel htmlFor="invite-member-role">
{organizationLocalization.role}
</FieldLabel>
{allowMultipleRoles ? (
<DropdownMenu>
<DropdownMenuTrigger
id="invite-member-role"
disabled={isInviting}
className={cn(
buttonVariants({ variant: "outline" }),
"w-full justify-between font-normal"
)}
>
<span
className={cn(
!roleSummary && "text-muted-foreground"
)}
>
{roleSummary ||
organizationLocalization.selectRoles}
</span>
<ChevronDown className="opacity-50" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="w-(--radix-dropdown-menu-trigger-width)"
>
{Object.entries(assignableRoles).map(
([key, label]) => {
const checked = selectedRoles.includes(key)
return (
<DropdownMenuCheckboxItem
key={key}
checked={checked}
disabled={
checked && selectedRoles.length === 1
}
onSelect={(event) => {
event.preventDefault()
toggleRole(key)
}}
>
{label}
</DropdownMenuCheckboxItem>
)
}
)}
</DropdownMenuContent>
</DropdownMenu>
) : (
<Select
disabled={isInviting}
onValueChange={(role) => field.handleChange([role])}
value={selectedRoles[0] ?? ""}
>
<SelectTrigger
id="invite-member-role"
className="w-full"
>
<SelectValue
placeholder={organizationLocalization.selectRoles}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{Object.entries(assignableRoles).map(
([key, label]) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
)
)}
</SelectGroup>
</SelectContent>
</Select>
)}
<field.AuthFormFieldError />
</Field>
)
}}
</form.AppField>
{teamsEnabled && (
<form.AppField name="teamId">
{(field) => (
<Field>
<FieldLabel htmlFor="invite-member-team">
{organizationLocalization.team}
</FieldLabel>
<Select
value={field.state.value}
onValueChange={(value) =>
field.handleChange(value ?? "")
}
disabled={isInviting}
>
<SelectTrigger
id="invite-member-team"
className="w-full"
>
<SelectValue
placeholder={organizationLocalization.selectTeam}
/>
</SelectTrigger>
<SelectContent>
{teams.data?.map((team) => (
<SelectItem key={team.id} value={team.id}>
{team.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
</form.AppField>
)}
{invitationFields.map((configuredField) => (
<form.AppField
key={configuredField.name}
name={`additionalFields.${configuredField.name}`}
validators={getAuthAdditionalFieldValidators(
configuredField,
localization.auth.fieldRequired
)}
>
{(field) => (
<field.AuthFormAdditionalField
field={configuredField}
isPending={isInviting}
optionalLabel={localization.settings.optional}
/>
)}
</form.AppField>
))}
</div>
<DialogFooter>
<DialogClose
className={buttonVariants({ variant: "outline" })}
disabled={isInviting}
type="button"
>
{localization.settings.cancel}
</DialogClose>
<form.AuthFormSubmitButton
disabled={
isInviting ||
atInvitationLimit ||
canInvite.isPending ||
!canInvite.data?.success
}
>
{organizationLocalization.inviteMember}
</form.AuthFormSubmitButton>
</DialogFooter>
</form.AuthFormRoot>
</form.AppForm>
</DialogContent>
</Dialog>
)
}
examples/start-shadcn-example/src/components/auth/organization/leave-organization-dialog.tsx
"use client"
import type { OrganizationAuthClient } from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import { useLeaveOrganization } from "@better-auth-ui/react/plugins/organization"
import type { Organization } from "better-auth/client"
import { LogOut } from "lucide-react"
import { toast } from "sonner"
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Spinner } from "@/components/ui/spinner"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { OrganizationView } from "./organization-view"
export type LeaveOrganizationDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
organization: Organization
}
export function LeaveOrganizationDialog({
open,
onOpenChange,
organization
}: LeaveOrganizationDialogProps) {
const { authClient, basePaths, localization, navigate } =
useAuth<OrganizationAuthClient>()
const {
localization: organizationLocalization,
viewPaths: organizationPluginViewPaths
} = useAuthPlugin(organizationPlugin)
const { mutate: leaveOrganization, isPending } = useLeaveOrganization(
authClient,
{
onSuccess: () => {
onOpenChange(false)
toast.success(organizationLocalization.leftOrganization)
navigate({
to: `${basePaths.settings}/${organizationPluginViewPaths.settings.organizations}`,
replace: true
})
}
}
)
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive">
<LogOut />
</AlertDialogMedia>
<AlertDialogTitle>
{organizationLocalization.leaveOrganization}
</AlertDialogTitle>
<AlertDialogDescription>
{organizationLocalization.leaveOrganizationDescription}
</AlertDialogDescription>
</AlertDialogHeader>
<Card>
<CardContent>
<OrganizationView organization={organization} hideRole />
</CardContent>
</Card>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>
{localization.settings.cancel}
</AlertDialogCancel>
<Button
variant="destructive"
disabled={isPending}
onClick={() =>
leaveOrganization({ organizationId: organization.id })
}
>
{isPending && <Spinner />}
{organizationLocalization.leaveOrganization}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
examples/start-shadcn-example/src/components/auth/organization/leave-organization.tsx
"use client"
import type { OrganizationAuthClient } from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import { useActiveOrganization } from "@better-auth-ui/react/plugins/organization"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { LeaveOrganizationDialog } from "./leave-organization-dialog"
/**
* Danger-zone row to leave the active organization.
*/
export function LeaveOrganization() {
const { authClient } = useAuth<OrganizationAuthClient>()
const { localization: organizationLocalization } =
useAuthPlugin(organizationPlugin)
const { data: activeOrganization } = useActiveOrganization(authClient)
const [confirmOpen, setConfirmOpen] = useState(false)
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-medium leading-tight">
{organizationLocalization.leaveOrganization}
</p>
<p className="text-muted-foreground mt-0.5 text-xs">
{organizationLocalization.leaveOrganizationDescription}
</p>
</div>
<Button
disabled={!activeOrganization}
size="sm"
variant="outline"
className="text-destructive"
onClick={() => setConfirmOpen(true)}
>
{organizationLocalization.leaveOrganization}
</Button>
{activeOrganization && (
<LeaveOrganizationDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
organization={activeOrganization}
/>
)}
</div>
)
}
examples/start-shadcn-example/src/components/auth/organization/organization-danger-zone.tsx
"use client"
import type { OrganizationAuthClient } from "@better-auth-ui/core/plugins/organization"
import { useAuth } from "@better-auth-ui/react"
import { useHasPermission } from "@better-auth-ui/react/plugins/organization"
import type { ComponentProps } from "react"
import { Card, CardContent } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
import { cn } from "cn"
import { DeleteOrganization } from "./delete-organization"
import { DeleteOrganizationSkeleton } from "./delete-organization-skeleton"
import { LeaveOrganization } from "./leave-organization"
export type OrganizationDangerZoneProps = {
className?: string
}
/**
* Danger zone heading with `LeaveOrganization` and `DeleteOrganization`
* for the active organization in a single card.
*
* Resolves the `organization:delete` permission before rendering anything to
* avoid flashing `LeaveOrganization` (and a stray separator) before the
* delete row appears or disappears.
*/
export function OrganizationDangerZone({
className,
...props
}: OrganizationDangerZoneProps & ComponentProps<"div">) {
const { authClient, localization } = useAuth<OrganizationAuthClient>()
const { data: deletePermission, isPending: deletePermissionPending } =
useHasPermission(authClient, {
permissions: { organization: ["delete"] }
})
const canDelete = !!deletePermission?.success
return (
<div className={cn("flex w-full flex-col", className)} {...props}>
<h2 className="mb-3 text-sm font-semibold text-destructive">
{localization.settings.dangerZone}
</h2>
<Card>
<CardContent>
{deletePermissionPending ? (
<DeleteOrganizationSkeleton />
) : (
<>
<LeaveOrganization />
{canDelete && (
<>
<Separator className="my-4" />
<DeleteOrganization />
</>
)}
</>
)}
</CardContent>
</Card>
</div>
)
}
examples/start-shadcn-example/src/components/auth/organization/organization-invitation-row-skeleton.tsx
"use client"
import { Skeleton } from "@/components/ui/skeleton"
import { TableCell, TableRow } from "@/components/ui/table"
/**
* Placeholder row matching `OrganizationInvitationRow` while invitations load.
*/
export function OrganizationInvitationRowSkeleton() {
return (
<TableRow>
<TableCell>
<Skeleton className="h-4 w-48 rounded-md" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-36 rounded-md" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16 rounded-md" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-14 rounded-full" />
</TableCell>
<TableCell />
</TableRow>
)
}
examples/start-shadcn-example/src/components/auth/organization/organization-invitation-row.tsx
"use client"
import { formatAdditionalFieldValue } from "@better-auth-ui/core"
import {
memberRoleLabels,
type OrganizationAuthClient
} from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import {
useCancelInvitation,
useHasPermission,
useInviteMember
} from "@better-auth-ui/react/plugins/organization"
import type { Invitation } from "better-auth/client"
import { Send, X } from "lucide-react"
import { toast } from "sonner"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import { TableCell, TableRow } from "@/components/ui/table"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { cn } from "cn"
import { OrganizationInvitationRowSkeleton } from "./organization-invitation-row-skeleton"
import {
type OrganizationSelectableRow,
OrganizationTableSelectRow
} from "./organization-table-selection"
export type OrganizationInvitationRowProps = {
invitation: Invitation
selectableRow?: OrganizationSelectableRow<Invitation>
showCreatedAt?: boolean
showEmail?: boolean
showRole?: boolean
showStatus?: boolean
}
const statusBadgeClasses: Record<string, string> = {
pending: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
accepted: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
rejected: "bg-destructive/10 text-destructive",
canceled: "bg-muted text-muted-foreground"
}
export function OrganizationInvitationRow({
invitation,
selectableRow,
showCreatedAt = true,
showEmail = true,
showRole = true,
showStatus = true
}: OrganizationInvitationRowProps) {
const { authClient } = useAuth<OrganizationAuthClient>()
const {
modelFields: { invitation: invitationFields },
localization: organizationLocalization,
roles
} = useAuthPlugin(organizationPlugin)
const {
data: cancelInvitationPermission,
isPending: cancelPermissionPending
} = useHasPermission(authClient, {
permissions: { invitation: ["cancel"] }
})
const { mutate: cancelInvitation, isPending: cancelPending } =
useCancelInvitation(authClient)
const { data: inviteMemberPermission, isPending: invitePermissionPending } =
useHasPermission(authClient, {
permissions: { invitation: ["create"] }
})
// Better Auth treats a re-invite as a resend: it extends the existing
// invitation's expiry and sends the email again rather than creating a
// second row.
const { mutate: resendInvitation, isPending: resendPending } =
useInviteMember(authClient, {
onSuccess: () => toast.success(organizationLocalization.invitationResent)
})
const roleLabel = memberRoleLabels(invitation.role, roles).join(", ")
const statusLabel =
organizationLocalization[
invitation.status as keyof typeof organizationLocalization
] ?? invitation.status
if (cancelPermissionPending || invitePermissionPending) {
return <OrganizationInvitationRowSkeleton />
}
const isPending = invitation.status === "pending"
return (
<TableRow
data-state={selectableRow?.getIsSelected() ? "selected" : undefined}
>
{selectableRow && (
<TableCell>
<OrganizationTableSelectRow
localization={organizationLocalization}
row={selectableRow}
/>
</TableCell>
)}
{showEmail && (
<TableCell>
<div className="flex flex-col gap-1">
<span className="font-medium text-sm">{invitation.email}</span>
{invitationFields.map((field) => {
const value = formatAdditionalFieldValue(
(invitation as unknown as Record<string, unknown>)[field.name]
)
return value ? (
<span
className="text-xs text-muted-foreground"
key={field.name}
>
{field.label}: {value}
</span>
) : null
})}
</div>
</TableCell>
)}
{showCreatedAt && (
<TableCell className="text-muted-foreground text-xs tabular-nums whitespace-nowrap">
{new Date(invitation.createdAt).toLocaleString(undefined, {
dateStyle: "short",
timeStyle: "short"
})}
</TableCell>
)}
{showRole && <TableCell className="text-sm">{roleLabel}</TableCell>}
{showStatus && (
<TableCell className="text-sm">
<Badge
variant="secondary"
className={cn(statusBadgeClasses[invitation.status])}
>
{String(statusLabel)}
</Badge>
</TableCell>
)}
<TableCell className="text-end">
<div className="flex justify-end gap-2">
{inviteMemberPermission?.success && isPending && (
<Button
size="icon"
variant="outline"
className="size-8"
disabled={resendPending}
onClick={() =>
resendInvitation({
...Object.fromEntries(
invitationFields.flatMap((field) => {
const value = (
invitation as unknown as Record<string, unknown>
)[field.name]
return value === undefined ? [] : [[field.name, value]]
})
),
email: invitation.email,
organizationId: invitation.organizationId,
role: invitation.role as Parameters<
typeof resendInvitation
>[0]["role"],
resend: true
})
}
aria-label={organizationLocalization.resendInvitation}
>
{resendPending ? <Spinner /> : <Send />}
</Button>
)}
{cancelInvitationPermission?.success && isPending && (
<Button
size="icon"
variant="outline"
className="size-8 text-destructive"
disabled={cancelPending}
onClick={() => cancelInvitation({ invitationId: invitation.id })}
aria-label={organizationLocalization.cancelInvitation}
>
{cancelPending ? <Spinner /> : <X />}
</Button>
)}
</div>
</TableCell>
</TableRow>
)
}
examples/start-shadcn-example/src/components/auth/organization/organization-invitations-empty.tsx
"use client"
import { useAuthPlugin } from "@better-auth-ui/react"
import { Send } 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 OrganizationInvitationsEmptyProps = {
isInvitePending?: boolean
onInvitePress?: () => void
}
/**
* Empty state for `OrganizationInvitations`.
*/
export function OrganizationInvitationsEmpty({
isInvitePending,
onInvitePress
}: OrganizationInvitationsEmptyProps) {
const { localization: organizationLocalization } =
useAuthPlugin(organizationPlugin)
return (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<Send />
</EmptyMedia>
<EmptyTitle>{organizationLocalization.noInvitations}</EmptyTitle>
<EmptyDescription>
{organizationLocalization.organizationInvitationsEmptyDescription}
</EmptyDescription>
</EmptyHeader>
{(isInvitePending || onInvitePress) && (
<EmptyContent>
<Button disabled={isInvitePending} size="sm" onClick={onInvitePress}>
{organizationLocalization.inviteMember}
</Button>
</EmptyContent>
)}
</Empty>
)
}
examples/start-shadcn-example/src/components/auth/organization/organization-invitations.tsx
"use client"
import {
hasMemberRole,
type OrganizationAuthClient,
type OrganizationLocalization
} from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import {
useCancelInvitation,
useHasPermission,
useListOrganizationInvitations
} from "@better-auth-ui/react/plugins/organization"
import type { Invitation } from "better-auth/client"
import { Filter, Search, X } from "lucide-react"
import { type ComponentProps, useState } from "react"
import { toast } from "sonner"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import {
InputGroup,
InputGroupAddon,
InputGroupInput
} from "@/components/ui/input-group"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { cn } from "cn"
import { InviteMemberDialog } from "./invite-member-dialog"
import { OrganizationInvitationRow } from "./organization-invitation-row"
import { OrganizationInvitationRowSkeleton } from "./organization-invitation-row-skeleton"
import { OrganizationInvitationsEmpty } from "./organization-invitations-empty"
import { OrganizationSortableTableHead } from "./organization-sortable-table-head"
import {
createOrganizationColumnHelper,
ORGANIZATION_TABLE_PAGE_SIZE,
useOrganizationTable
} from "./organization-table"
import { OrganizationTableBulkAction } from "./organization-table-bulk-action"
import { OrganizationTablePagination } from "./organization-table-pagination"
import { OrganizationTableSelectAll } from "./organization-table-selection"
import { useOrganizationTableState } from "./organization-table-state"
import { OrganizationTableViewOptions } from "./organization-table-view-options"
const invitationColumnHelper = createOrganizationColumnHelper<Invitation>()
const invitationColumns = invitationColumnHelper.columns([
invitationColumnHelper.accessor("email", {
enableHiding: false,
filterFn: "includesString"
}),
invitationColumnHelper.accessor(
(invitation) => new Date(invitation.createdAt).getTime(),
{ id: "createdAt", enableGlobalFilter: false }
),
invitationColumnHelper.accessor("role", {
enableGlobalFilter: false,
filterFn: (row, columnId, value) =>
hasMemberRole(row.getValue<string>(columnId), String(value))
}),
invitationColumnHelper.accessor("status", {
enableGlobalFilter: false,
filterFn: (row, columnId, value) => row.getValue(columnId) === String(value)
})
])
const INVITATION_COLUMN_IDS = ["email", "createdAt", "role", "status"] as const
const EMPTY_INVITATIONS: Invitation[] = []
/** Props for the `OrganizationInvitations` component. */
export type OrganizationInvitationsProps = {
className?: string
}
/**
* Organization invitations table with invite control and per-row actions.
*/
export function OrganizationInvitations({
className,
...props
}: OrganizationInvitationsProps & ComponentProps<"div">) {
const { authClient, localization } = useAuth<OrganizationAuthClient>()
const { localization: organizationLocalization, roles } =
useAuthPlugin(organizationPlugin)
const { data: invitations, isPending: invitationsPending } =
useListOrganizationInvitations(authClient)
const canInvite = useHasPermission(authClient, {
permissions: { invitation: ["create"] }
})
const canCancel = useHasPermission(authClient, {
permissions: { invitation: ["cancel"] }
})
const isPending = invitationsPending || canCancel.isPending
const tableState = useOrganizationTableState(
"organizationInvitations",
ORGANIZATION_TABLE_PAGE_SIZE,
INVITATION_COLUMN_IDS
)
const { globalFilter, pagination } = tableState
const table = useOrganizationTable(
{
atoms: tableState.atoms,
columns: invitationColumns,
data: invitations ?? EMPTY_INVITATIONS,
enableRowSelection: (row) =>
canCancel.data?.success === true && row.original.status === "pending",
globalFilterFn: "includesString",
getRowId: (invitation) => invitation.id
},
() => null
)
const cancelInvitations = useCancelInvitation(authClient)
const roleFilter = String(table.getColumn("role")?.getFilterValue() ?? "all")
const statusFilter = String(
table.getColumn("status")?.getFilterValue() ?? "all"
)
const roleFacetRows = table.getColumn("role")?.getFacetedRowModel().flatRows
const statusCounts = table.getColumn("status")?.getFacetedUniqueValues()
const selectedInvitations = table.getSelectedRowModel().rows
const showSelection = canCancel.data?.success === true
const visibleColumnCount = table.getVisibleLeafColumns().length
async function cancelSelectedInvitations() {
const results = await Promise.allSettled(
selectedInvitations.map((row) =>
cancelInvitations.mutateAsync({ invitationId: row.original.id })
)
)
const canceledCount = results.filter(
(result) => result.status === "fulfilled"
).length
if (canceledCount > 0) {
toast.success(
organizationLocalization.invitationsCanceled.replace(
"{{count}}",
String(canceledCount)
)
)
}
table.resetRowSelection(true)
}
const [inviteOpen, setInviteOpen] = useState(false)
return (
<div className={cn("flex flex-col gap-3", className)} {...props}>
<h3 className="truncate text-sm font-semibold">
{organizationLocalization.invitations}
</h3>
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-3">
<InputGroup className="min-w-0 sm:w-[220px]">
<InputGroupInput
type="search"
value={globalFilter}
onChange={(event) => table.setGlobalFilter(event.target.value)}
aria-label={organizationLocalization.search}
placeholder={organizationLocalization.search}
disabled={isPending}
/>
<InputGroupAddon>
<Search className="text-muted-foreground" />
</InputGroupAddon>
</InputGroup>
<DropdownMenu>
<DropdownMenuTrigger
className={cn(buttonVariants({ size: "sm", variant: "outline" }))}
disabled={isPending}
>
<Filter />
{organizationLocalization.role}
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuRadioGroup
value={roleFilter}
onValueChange={(value) =>
table
.getColumn("role")
?.setFilterValue(value === "all" ? undefined : value)
}
>
<DropdownMenuRadioItem value="all">
{organizationLocalization.all}
</DropdownMenuRadioItem>
{Object.entries(roles).map(([key, label]) => (
<DropdownMenuRadioItem key={key} value={key}>
{label} (
{roleFacetRows?.filter((row) =>
hasMemberRole(row.original.role, key)
).length ?? 0}
)
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger
className={cn(buttonVariants({ size: "sm", variant: "outline" }))}
disabled={isPending}
>
<Filter />
{organizationLocalization.status}
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuRadioGroup
value={statusFilter}
onValueChange={(value) =>
table
.getColumn("status")
?.setFilterValue(value === "all" ? undefined : value)
}
>
<DropdownMenuRadioItem value="all">
{organizationLocalization.all}
</DropdownMenuRadioItem>
{(["pending", "accepted", "rejected", "canceled"] as const).map(
(status) => (
<DropdownMenuRadioItem key={status} value={status}>
{organizationLocalization[
status as keyof OrganizationLocalization
] ?? status}{" "}
({statusCounts?.get(status) ?? 0})
</DropdownMenuRadioItem>
)
)}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<div className="ms-auto">
<OrganizationTableViewOptions
columns={[
{
id: "createdAt",
label: organizationLocalization.invitedAt,
visible: table.getColumn("createdAt")?.getIsVisible() ?? true,
onVisibleChange: (visible) =>
table.getColumn("createdAt")?.toggleVisibility(visible)
},
{
id: "role",
label: organizationLocalization.role,
visible: table.getColumn("role")?.getIsVisible() ?? true,
onVisibleChange: (visible) =>
table.getColumn("role")?.toggleVisibility(visible)
},
{
id: "status",
label: organizationLocalization.status,
visible: table.getColumn("status")?.getIsVisible() ?? true,
onVisibleChange: (visible) =>
table.getColumn("status")?.toggleVisibility(visible)
}
]}
disabled={isPending}
localization={organizationLocalization}
/>
</div>
</div>
{(roleFilter !== "all" || statusFilter !== "all") && (
<div className="flex flex-wrap gap-2">
{roleFilter !== "all" && (
<Badge variant="secondary" className="gap-1">
{organizationLocalization.role}:{" "}
<span className="capitalize">
{roles?.[roleFilter] ?? roleFilter}
</span>
<Button
aria-label={organizationLocalization.clear}
className="size-4 rounded-sm text-muted-foreground"
onClick={() =>
table.getColumn("role")?.setFilterValue(undefined)
}
size="icon-xs"
type="button"
variant="ghost"
>
<X className="size-3" />
</Button>
</Badge>
)}
{statusFilter !== "all" && (
<Badge variant="secondary" className="gap-1">
{organizationLocalization.status}:{" "}
{organizationLocalization[
statusFilter as keyof OrganizationLocalization
] ?? statusFilter}
<Button
aria-label={organizationLocalization.clear}
className="size-4 rounded-sm text-muted-foreground"
onClick={() =>
table.getColumn("status")?.setFilterValue(undefined)
}
size="icon-xs"
type="button"
variant="ghost"
>
<X className="size-3" />
</Button>
</Badge>
)}
</div>
)}
{selectedInvitations.length > 0 && (
<OrganizationTableBulkAction
actionLabel={organizationLocalization.cancelSelectedInvitations}
cancelLabel={localization.settings.cancel}
count={selectedInvitations.length}
description={
organizationLocalization.cancelSelectedInvitationsDescription
}
isPending={cancelInvitations.isPending}
onConfirm={cancelSelectedInvitations}
selectedLabel={organizationLocalization.selectedCount}
/>
)}
<Card className="p-0">
<Table aria-label={organizationLocalization.invitations}>
<TableHeader>
<TableRow>
{showSelection && (
<TableHead className="w-10">
<OrganizationTableSelectAll
allSelected={table.getIsAllPageRowsSelected()}
disabled={isPending}
localization={organizationLocalization}
onCheckedChange={(checked) =>
table.toggleAllPageRowsSelected(checked)
}
someSelected={table.getIsSomePageRowsSelected()}
/>
</TableHead>
)}
<OrganizationSortableTableHead
column={table.getColumn("email")}
>
{localization.auth.email}
</OrganizationSortableTableHead>
{table.getColumn("createdAt")?.getIsVisible() && (
<OrganizationSortableTableHead
column={table.getColumn("createdAt")}
>
{organizationLocalization.invitedAt}
</OrganizationSortableTableHead>
)}
{table.getColumn("role")?.getIsVisible() && (
<OrganizationSortableTableHead
column={table.getColumn("role")}
>
{organizationLocalization.role}
</OrganizationSortableTableHead>
)}
{table.getColumn("status")?.getIsVisible() && (
<OrganizationSortableTableHead
column={table.getColumn("status")}
>
{organizationLocalization.status}
</OrganizationSortableTableHead>
)}
<TableHead className="text-end">
{organizationLocalization.actions}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isPending ? (
<OrganizationInvitationRowSkeleton />
) : !table.getRowModel().rows.length ? (
<TableRow>
<TableCell
colSpan={visibleColumnCount + 1 + Number(showSelection)}
>
<OrganizationInvitationsEmpty
isInvitePending={canInvite.isPending}
onInvitePress={
canInvite.data?.success
? () => setInviteOpen(true)
: undefined
}
/>
</TableCell>
</TableRow>
) : (
table
.getRowModel()
.rows.map((row) => (
<OrganizationInvitationRow
key={row.original.id}
invitation={row.original}
selectableRow={showSelection ? row : undefined}
showCreatedAt={table
.getColumn("createdAt")
?.getIsVisible()}
showRole={table.getColumn("role")?.getIsVisible()}
showStatus={table.getColumn("status")?.getIsVisible()}
/>
))
)}
</TableBody>
</Table>
</Card>
<OrganizationTablePagination
canNextPage={table.getCanNextPage()}
canPreviousPage={table.getCanPreviousPage()}
disabled={isPending}
localization={organizationLocalization}
onFirstPage={() => table.firstPage()}
onLastPage={() => table.lastPage()}
onNextPage={() => table.nextPage()}
onPageSizeChange={(pageSize) => table.setPageSize(pageSize)}
onPreviousPage={() => table.previousPage()}
pageCount={table.getPageCount()}
pageIndex={pagination.pageIndex}
pageSize={pagination.pageSize}
rowCount={table.getRowCount()}
visibleRowCount={table.getRowModel().rows.length}
/>
</div>
{canInvite.data?.success && (
<InviteMemberDialog open={inviteOpen} onOpenChange={setInviteOpen} />
)}
</div>
)
}
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-member-row-skeleton.tsx
"use client"
import { Skeleton } from "@/components/ui/skeleton"
import { TableCell, TableRow } from "@/components/ui/table"
import { UserView } from "../user/user-view"
/**
* Placeholder row matching `OrganizationMemberRow` while members load.
*/
export function OrganizationMemberRowSkeleton({
showTeams
}: {
showTeams?: boolean
}) {
return (
<TableRow>
<TableCell>
<UserView isPending />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-18 rounded-md" />
</TableCell>
{showTeams && (
<TableCell>
<Skeleton className="h-4 w-24 rounded-md" />
</TableCell>
)}
<TableCell className="flex justify-end">
<Skeleton className="size-8 rounded-md" />
</TableCell>
</TableRow>
)
}
examples/start-shadcn-example/src/components/auth/organization/organization-member-row.tsx
"use client"
import { formatAdditionalFieldValue } from "@better-auth-ui/core"
import {
hasMemberRole,
memberRoleLabels,
mergeOrganizationRoleLabels,
type OrganizationAuthClient,
type OrganizationRolesAuthClient,
type OrganizationTeamsAuthClient
} from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin, useSession } from "@better-auth-ui/react"
import {
useHasPermission,
useListRoles,
useListUserTeams
} from "@better-auth-ui/react/plugins/organization"
import type { Member, Organization, User } from "better-auth/client"
import { LogOut, Pencil, Trash2 } from "lucide-react"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { TableCell, TableRow } from "@/components/ui/table"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { UserView } from "../user/user-view"
import { EditMemberRolesDialog } from "./edit-member-roles-dialog"
import { LeaveOrganizationDialog } from "./leave-organization-dialog"
import {
type OrganizationSelectableRow,
OrganizationTableSelectRow
} from "./organization-table-selection"
import { RemoveMemberDialog } from "./remove-member-dialog"
export type OrganizationMemberRowProps = {
member: Member & { user: Partial<User> }
isOwner?: boolean
ownerCount?: number
organization: Organization
selectableRow?: OrganizationSelectableRow<Member & { user: Partial<User> }>
showRole?: boolean
showTeams?: boolean
}
export function OrganizationMemberRow({
member,
isOwner,
ownerCount,
organization,
selectableRow,
showRole = true,
showTeams
}: OrganizationMemberRowProps) {
const { authClient } = useAuth<OrganizationAuthClient>()
const {
modelFields: { member: memberFields },
dynamicAccessControl,
creatorRole,
localization: organizationLocalization,
roles
} = useAuthPlugin(organizationPlugin)
const { data: session } = useSession(authClient)
const canReadRoles = useHasPermission(authClient, {
organizationId: organization.id,
permissions: { ac: ["read"] }
})
const dynamicRoles = useListRoles(authClient as OrganizationRolesAuthClient, {
query: { organizationId: organization.id },
enabled:
dynamicAccessControl?.enabled === true &&
canReadRoles.data?.success === true
})
const memberTeams = useListUserTeams(
authClient as OrganizationTeamsAuthClient,
{
query: {
organizationId: organization.id,
userId: member.userId
},
enabled: showTeams === true
}
)
const { data: hasUpdatePermission, isPending: updatePermissionPending } =
useHasPermission(authClient, {
organizationId: organization.id,
permissions: { member: ["update"] }
})
const { data: hasDeletePermission, isPending: deletePermissionPending } =
useHasPermission(authClient, {
organizationId: organization.id,
permissions: { member: ["delete"] }
})
const mergedRoles = mergeOrganizationRoleLabels(roles, dynamicRoles.data)
const roleLabel = memberRoleLabels(member.role, mergedRoles).join(", ")
const teamNames = memberTeams.data?.map((team) => team.name).join(", ")
const assignableRoles = Object.entries(mergedRoles).filter(
([key]) => isOwner || key !== creatorRole
)
const isCurrentUser = session?.user.id === member.userId
const targetIsOwner = hasMemberRole(member.role, creatorRole)
const canManageTarget = isOwner || !targetIsOwner
const onlyOwnerActionDisabled =
targetIsOwner && (ownerCount === undefined || ownerCount <= 1)
const [removeOpen, setRemoveOpen] = useState(false)
const [leaveOpen, setLeaveOpen] = useState(false)
const [roleEditorOpen, setRoleEditorOpen] = useState(false)
return (
<TableRow
data-state={selectableRow?.getIsSelected() ? "selected" : undefined}
>
{selectableRow && (
<TableCell>
<OrganizationTableSelectRow
localization={organizationLocalization}
row={selectableRow}
/>
</TableCell>
)}
<TableCell>
<div className="flex flex-col gap-1">
<UserView user={member.user} />
{memberFields.map((field) => {
const value = formatAdditionalFieldValue(
(member as unknown as Record<string, unknown>)[field.name]
)
return value ? (
<span className="text-xs text-muted-foreground" key={field.name}>
{field.label}: {value}
</span>
) : null
})}
</div>
</TableCell>
{showRole && <TableCell>{roleLabel}</TableCell>}
{showTeams && (
<TableCell className="text-sm">
{memberTeams.isPending ? (
<Skeleton className="h-4 w-24 rounded-md" />
) : memberTeams.isError ? null : teamNames ? (
teamNames
) : (
<span className="text-muted-foreground">
{organizationLocalization.noTeams}
</span>
)}
</TableCell>
)}
<TableCell>
<div className="flex items-center justify-end gap-1">
{canManageTarget && updatePermissionPending && (
<Button
aria-label={organizationLocalization.changeMemberRole}
className="size-8"
disabled
size="icon"
variant="ghost"
>
<Pencil />
</Button>
)}
{canManageTarget && hasUpdatePermission?.success && (
<Button
className="size-8"
onClick={() => setRoleEditorOpen(true)}
size="icon"
variant="ghost"
>
<Pencil />
<span className="sr-only">
{organizationLocalization.changeMemberRole}
</span>
</Button>
)}
{canManageTarget && hasUpdatePermission?.success && (
<EditMemberRolesDialog
member={member}
onOpenChange={setRoleEditorOpen}
open={roleEditorOpen}
organizationId={organization.id}
protectedRole={creatorRole}
protectedRoleRemovalDisabled={onlyOwnerActionDisabled}
roles={assignableRoles}
/>
)}
{isCurrentUser ? (
<Button
size="icon"
variant="outline"
className="size-8 text-destructive"
aria-label={organizationLocalization.leaveOrganization}
disabled={onlyOwnerActionDisabled}
title={
onlyOwnerActionDisabled
? organizationLocalization.onlyOwnerActionDisabled
: undefined
}
onClick={() => setLeaveOpen(true)}
>
<LogOut />
</Button>
) : canManageTarget && deletePermissionPending ? (
<Button
aria-label={organizationLocalization.removeMember}
className="size-8 text-destructive"
disabled
size="icon"
variant="outline"
>
<Trash2 />
</Button>
) : canManageTarget && hasDeletePermission?.success ? (
<Button
size="icon"
variant="outline"
className="size-8 text-destructive"
aria-label={organizationLocalization.removeMember}
disabled={onlyOwnerActionDisabled}
title={
onlyOwnerActionDisabled
? organizationLocalization.onlyOwnerActionDisabled
: undefined
}
onClick={() => setRemoveOpen(true)}
>
<Trash2 />
</Button>
) : null}
</div>
{isCurrentUser && organization && !onlyOwnerActionDisabled ? (
<LeaveOrganizationDialog
open={leaveOpen}
onOpenChange={setLeaveOpen}
organization={organization}
/>
) : (
canManageTarget &&
hasDeletePermission?.success &&
!onlyOwnerActionDisabled && (
<RemoveMemberDialog
open={removeOpen}
onOpenChange={setRemoveOpen}
member={member}
/>
)
)}
</TableCell>
</TableRow>
)
}
examples/start-shadcn-example/src/components/auth/organization/organization-members.tsx
"use client"
import { getClampedTablePageIndex } from "@better-auth-ui/core"
import {
hasMemberRole,
type OrganizationAuthClient
} from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin, useSession } from "@better-auth-ui/react"
import {
useActiveMemberRole,
useActiveOrganization,
useHasPermission,
useListOrganizationMembers,
useRemoveMember
} from "@better-auth-ui/react/plugins/organization"
import { keepPreviousData } from "@tanstack/react-query"
import type { Member, User } from "better-auth/client"
import { Filter, Search, X } from "lucide-react"
import { type ComponentProps, useEffect, useRef, useState } from "react"
import { toast } from "sonner"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import {
InputGroup,
InputGroupAddon,
InputGroupInput
} from "@/components/ui/input-group"
import {
Table,
TableBody,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { cn } from "cn"
import { InviteMemberDialog } from "./invite-member-dialog"
import { OrganizationMemberRow } from "./organization-member-row"
import { OrganizationMemberRowSkeleton } from "./organization-member-row-skeleton"
import { OrganizationSortableTableHead } from "./organization-sortable-table-head"
import {
createOrganizationColumnHelper,
ORGANIZATION_TABLE_PAGE_SIZE,
useOrganizationTable
} from "./organization-table"
import { OrganizationTableBulkAction } from "./organization-table-bulk-action"
import { OrganizationTablePagination } from "./organization-table-pagination"
import { OrganizationTableSelectAll } from "./organization-table-selection"
import { useOrganizationTableState } from "./organization-table-state"
import { OrganizationTableViewOptions } from "./organization-table-view-options"
type MemberRow = Member & { user: Partial<User> }
const memberColumnHelper = createOrganizationColumnHelper<MemberRow>()
const memberColumns = memberColumnHelper.columns([
memberColumnHelper.accessor(
(member) => member.user.name || member.user.email || "",
{ id: "user", enableHiding: false, filterFn: "includesString" }
),
memberColumnHelper.accessor("role", {
enableGlobalFilter: false,
filterFn: (row, columnId, value) =>
hasMemberRole(row.getValue<string>(columnId), String(value))
}),
memberColumnHelper.display({
id: "teams",
enableGlobalFilter: false,
enableSorting: false
})
])
const memberColumnsWithoutTeams = memberColumns.filter(
(column) => column.id !== "teams"
)
const EMPTY_MEMBERS: MemberRow[] = []
const MEMBER_COLUMN_IDS = ["user", "role", "teams"] as const
/** Props for the `OrganizationMembers` component. */
export type OrganizationMembersProps = {
className?: string
/**
* Number of rows per page. This value must be a positive integer. Setting it
* moves paging, role filtering, and role sorting
* onto the server, which is what large organizations want: without it the
* endpoint caps the response at 100 members with no indication.
*
* Leave it unset to keep the whole list in memory and filter it in the
* browser.
*/
pageSize?: number
}
function validatePageSize(pageSize?: number) {
if (
pageSize !== undefined &&
(!Number.isInteger(pageSize) || pageSize <= 0)
) {
throw new RangeError("pageSize must be a positive integer")
}
return pageSize
}
/**
* Organization members table with title, invite control, and per-row actions.
*/
export function OrganizationMembers({
className,
pageSize,
...props
}: OrganizationMembersProps & ComponentProps<"div">) {
const validatedPageSize = validatePageSize(pageSize)
const { authClient, localization } = useAuth<OrganizationAuthClient>()
const {
localization: organizationLocalization,
membershipLimit,
roles,
creatorRole,
teams
} = useAuthPlugin(organizationPlugin)
const { data: activeOrganization, isPending: activeOrganizationPending } =
useActiveOrganization(authClient)
const paged = validatedPageSize !== undefined
const tableState = useOrganizationTableState(
"organizationMembers",
validatedPageSize ?? ORGANIZATION_TABLE_PAGE_SIZE,
MEMBER_COLUMN_IDS
)
const { columnFilters, globalFilter, pagination, sorting } = tableState
const roleFilter = String(
columnFilters.find((filter) => filter.id === "role")?.value ?? "all"
)
const previousOrganizationId = useRef<string | undefined>(undefined)
useEffect(() => {
const organizationId = activeOrganization?.id
if (!organizationId) return
if (
previousOrganizationId.current &&
previousOrganizationId.current !== organizationId
) {
tableState.setPagination((current) => ({ ...current, pageIndex: 0 }))
}
previousOrganizationId.current = organizationId
}, [activeOrganization?.id, tableState.setPagination])
const { data: membersData, isPending: membersPending } =
useListOrganizationMembers(authClient, {
enabled: !paged || tableState.ready,
placeholderData: paged ? keepPreviousData : undefined,
query: paged
? {
limit: pagination.pageSize,
offset: pagination.pageIndex * pagination.pageSize,
...(roleFilter === "all"
? {}
: {
filterField: "role",
filterValue: roleFilter,
// Roles are stored comma-joined, so an exact match would
// drop anyone holding more than one.
filterOperator: "contains" as const
}),
...(sorting[0]?.id === "role"
? {
sortBy: "role",
sortDirection: sorting[0].desc
? ("desc" as const)
: ("asc" as const)
}
: {})
}
: undefined
})
// The signed-in user need not be on the loaded page, so their own role comes
// from a dedicated endpoint rather than from the member list.
const { data: activeMemberRole } = useActiveMemberRole(authClient)
const { data: session } = useSession(authClient)
const owners = useListOrganizationMembers(authClient, {
query: {
organizationId: activeOrganization?.id,
filterField: "role",
filterValue: creatorRole,
filterOperator: "contains",
limit: 1
},
enabled: Boolean(activeOrganization?.id)
})
const canInvite = useHasPermission(authClient, {
permissions: { invitation: ["create"] }
})
const canListMemberTeams = useHasPermission(authClient, {
organizationId: activeOrganization?.id,
permissions: { member: ["update"] },
enabled: teams && Boolean(activeOrganization?.id)
})
const canDeleteMembers = useHasPermission(authClient, {
organizationId: activeOrganization?.id,
permissions: { member: ["delete"] },
enabled: Boolean(activeOrganization?.id)
})
const isPending =
activeOrganizationPending ||
membersPending ||
owners.isPending ||
canDeleteMembers.isPending ||
(teams && canListMemberTeams.isPending)
const [inviteOpen, setInviteOpen] = useState(false)
const isOwner = hasMemberRole(activeMemberRole?.role, creatorRole)
const ownerCount = owners.data?.total ?? owners.data?.members.length
const showTeams = teams && canListMemberTeams.data?.success === true
const total = membersData?.total ?? membersData?.members.length ?? 0
useEffect(() => {
if (!paged || !tableState.ready || !membersData) return
const lastPageIndex = getClampedTablePageIndex(
pagination.pageIndex,
pagination.pageSize,
total
)
if (pagination.pageIndex > lastPageIndex) {
tableState.setPagination((current) => ({
...current,
pageIndex: lastPageIndex
}))
}
}, [
membersData,
paged,
pagination.pageIndex,
pagination.pageSize,
tableState.ready,
tableState.setPagination,
total
])
const table = useOrganizationTable<MemberRow, null>(
{
atoms: tableState.atoms,
columns: showTeams ? memberColumns : memberColumnsWithoutTeams,
data: membersData?.members ?? EMPTY_MEMBERS,
enableRowSelection: (row) => {
const targetIsOwner = hasMemberRole(row.original.role, creatorRole)
return (
canDeleteMembers.data?.success === true &&
row.original.userId !== session?.user.id &&
(isOwner || !targetIsOwner) &&
!(targetIsOwner && (ownerCount === undefined || ownerCount <= 1))
)
},
globalFilterFn: "includesString",
getRowId: (member) => member.id,
manualFiltering: paged,
manualPagination: paged,
manualSorting: paged,
rowCount: paged ? total : undefined
},
() => null
)
const removeMembers = useRemoveMember(authClient)
const roleFacetRows = table.getColumn("role")?.getFacetedRowModel().flatRows
const selectedMembers = table.getSelectedRowModel().rows
const showSelection = canDeleteMembers.data?.success === true
async function removeSelectedMembers() {
if (!activeOrganization) return
const results = await Promise.allSettled(
selectedMembers.map((row) =>
removeMembers.mutateAsync({
memberIdOrEmail: row.original.id,
organizationId: activeOrganization.id
})
)
)
const removedCount = results.filter(
(result) => result.status === "fulfilled"
).length
if (removedCount > 0) {
toast.success(
organizationLocalization.membersRemoved.replace(
"{{count}}",
String(removedCount)
)
)
}
table.resetRowSelection(true)
}
const atMembershipLimit =
membershipLimit !== undefined && total >= membershipLimit
return (
<div className={cn("flex flex-col gap-3", className)} {...props}>
<div className="flex items-end justify-between gap-3">
<h3 className="truncate text-sm font-semibold">
{organizationLocalization.members}
</h3>
{(canInvite.isPending || canInvite.data?.success) && (
<Button
className="shrink-0"
size="sm"
disabled={canInvite.isPending || atMembershipLimit}
onClick={() => setInviteOpen(true)}
>
{organizationLocalization.inviteMember}
</Button>
)}
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-3">
{/* list-members has no search parameter, so a search box would
only ever filter the page in front of you. */}
{!paged && (
<InputGroup className="min-w-0 sm:w-[220px]">
<InputGroupInput
type="search"
value={globalFilter}
onChange={(event) => table.setGlobalFilter(event.target.value)}
aria-label={organizationLocalization.search}
placeholder={organizationLocalization.search}
disabled={isPending}
/>
<InputGroupAddon>
<Search className="text-muted-foreground" />
</InputGroupAddon>
</InputGroup>
)}
<DropdownMenu>
<DropdownMenuTrigger
className={cn(buttonVariants({ size: "sm", variant: "outline" }))}
disabled={isPending}
>
<Filter />
{organizationLocalization.role}
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuRadioGroup
value={roleFilter}
onValueChange={(value) =>
table
.getColumn("role")
?.setFilterValue(value === "all" ? undefined : value)
}
>
<DropdownMenuRadioItem value="all">
{organizationLocalization.all}
</DropdownMenuRadioItem>
{Object.entries(roles).map(([role, label]) => (
<DropdownMenuRadioItem key={role} value={role}>
{label}
{!paged &&
` (${
roleFacetRows?.filter((row) =>
hasMemberRole(row.original.role, role)
).length ?? 0
})`}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<div className="ms-auto">
<OrganizationTableViewOptions
columns={[
{
id: "role",
label: organizationLocalization.role,
visible: table.getColumn("role")?.getIsVisible() ?? true,
onVisibleChange: (visible) =>
table.getColumn("role")?.toggleVisibility(visible)
},
...(showTeams
? [
{
id: "teams",
label: organizationLocalization.teams,
visible:
table.getColumn("teams")?.getIsVisible() ?? true,
onVisibleChange: (visible: boolean) =>
table.getColumn("teams")?.toggleVisibility(visible)
}
]
: [])
]}
disabled={isPending}
localization={organizationLocalization}
/>
</div>
</div>
{roleFilter !== "all" && (
<Badge variant="secondary" className="w-fit gap-1">
{organizationLocalization.role}:{" "}
<span className="capitalize">
{roles?.[roleFilter] ?? roleFilter}
</span>
<Button
aria-label={organizationLocalization.clear}
className="size-4 rounded-sm text-muted-foreground"
onClick={() => table.getColumn("role")?.setFilterValue(undefined)}
size="icon-xs"
type="button"
variant="ghost"
>
<X className="size-3" />
</Button>
</Badge>
)}
{selectedMembers.length > 0 && (
<OrganizationTableBulkAction
actionLabel={organizationLocalization.removeSelectedMembers}
cancelLabel={localization.settings.cancel}
count={selectedMembers.length}
description={
organizationLocalization.removeSelectedMembersDescription
}
isPending={removeMembers.isPending}
onConfirm={removeSelectedMembers}
selectedLabel={organizationLocalization.selectedCount}
/>
)}
<Card className="p-0">
<Table aria-label={organizationLocalization.members}>
<TableHeader>
<TableRow>
{showSelection && (
<TableHead className="w-10">
<OrganizationTableSelectAll
allSelected={table.getIsAllPageRowsSelected()}
disabled={isPending}
localization={organizationLocalization}
onCheckedChange={(checked) =>
table.toggleAllPageRowsSelected(checked)
}
someSelected={table.getIsSomePageRowsSelected()}
/>
</TableHead>
)}
{/* Name and email live on the joined user row, which
list-members cannot sort by. */}
{paged ? (
<TableHead>{organizationLocalization.member}</TableHead>
) : (
<OrganizationSortableTableHead
column={table.getColumn("user")}
>
{organizationLocalization.member}
</OrganizationSortableTableHead>
)}
{table.getColumn("role")?.getIsVisible() && (
<OrganizationSortableTableHead
column={table.getColumn("role")}
>
{organizationLocalization.role}
</OrganizationSortableTableHead>
)}
{showTeams && table.getColumn("teams")?.getIsVisible() && (
<TableHead>{organizationLocalization.teams}</TableHead>
)}
<TableHead className="text-end">
{organizationLocalization.actions}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isPending ? (
<OrganizationMemberRowSkeleton showTeams={showTeams} />
) : (
!!activeOrganization &&
table
.getRowModel()
.rows.map((row) => (
<OrganizationMemberRow
key={row.original.id}
member={row.original}
isOwner={isOwner}
ownerCount={ownerCount}
organization={activeOrganization}
selectableRow={showSelection ? row : undefined}
showRole={table.getColumn("role")?.getIsVisible()}
showTeams={
showTeams &&
table.getColumn("teams")?.getIsVisible() === true
}
/>
))
)}
</TableBody>
</Table>
</Card>
<OrganizationTablePagination
canNextPage={table.getCanNextPage()}
canPreviousPage={table.getCanPreviousPage()}
disabled={isPending}
localization={organizationLocalization}
onFirstPage={() => table.firstPage()}
onLastPage={() => table.lastPage()}
onNextPage={() => table.nextPage()}
onPageSizeChange={(pageSize) => table.setPageSize(pageSize)}
onPreviousPage={() => table.previousPage()}
pageCount={table.getPageCount()}
pageIndex={pagination.pageIndex}
pageSize={pagination.pageSize}
rowCount={table.getRowCount()}
visibleRowCount={table.getRowModel().rows.length}
/>
</div>
{canInvite.data?.success && (
<InviteMemberDialog open={inviteOpen} onOpenChange={setInviteOpen} />
)}
</div>
)
}
examples/start-shadcn-example/src/components/auth/organization/organization-people.tsx
"use client"
import type { ComponentProps } from "react"
import { cn } from "cn"
import { OrganizationInvitations } from "./organization-invitations"
import { OrganizationMembers } from "./organization-members"
/** Props for the `OrganizationPeople` component. */
export type OrganizationPeopleProps = {
className?: string
}
/**
* Organization people UI: members table (see `OrganizationMembers`), then org
* invitations.
*/
export function OrganizationPeople({
className,
...props
}: OrganizationPeopleProps & ComponentProps<"div">) {
return (
<div className={cn("flex flex-col gap-4 md:gap-6", className)} {...props}>
<OrganizationMembers />
<OrganizationInvitations />
</div>
)
}
examples/start-shadcn-example/src/components/auth/organization/organization-profile.tsx
"use client"
import {
fieldsWithModelValues,
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 {
useActiveOrganization,
useHasPermission,
useUpdateOrganization
} from "@better-auth-ui/react/plugins/organization"
import { useEffect } from "react"
import { toast } from "sonner"
import { Card, CardContent } from "@/components/ui/card"
import { Field, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { cn } from "cn"
import {
getAuthAdditionalFieldValidators,
isAuthFormFieldInvalid,
useAuthForm
} from "../auth-form"
import { ChangeOrganizationLogo } from "./change-organization-logo"
import { SlugField } from "./slug-field"
export type OrganizationProfileProps = {
className?: string
hideSlug?: boolean
}
/**
* Profile card for the active organization: logo (when enabled), display name, and slug.
*/
export function OrganizationProfile({
className,
hideSlug: hideSlugProp
}: OrganizationProfileProps) {
const { authClient, localization } = useAuth<OrganizationAuthClient>()
const {
additionalFields,
localization: organizationLocalization,
hideSlug: pluginHideSlug
} = useAuthPlugin(organizationPlugin)
const hideSlug = hideSlugProp ?? pluginHideSlug ?? false
const { data: activeOrganization } = useActiveOrganization(authClient)
const canUpdate = useHasPermission(authClient, {
permissions: { organization: ["update"] }
})
const { mutateAsync: commitOrganizationUpdate, isPending } =
useUpdateOrganization(authClient, {
onSuccess: () =>
toast.success(organizationLocalization.organizationUpdatedSuccess)
})
const form = useAuthForm({
defaultValues: {
additionalFields: getAdditionalFieldDefaultValues(additionalFields),
name: "",
slug: ""
},
onSubmit: async ({ value }) => {
if (!activeOrganization || !canUpdate.data?.success) return
await commitOrganizationUpdate({
data: {
...getAdditionalFieldSubmitValues(
additionalFields,
value.additionalFields
),
name: value.name,
...(!hideSlug && { slug: value.slug })
}
})
}
})
useEffect(() => {
if (!activeOrganization) return
form.reset({
additionalFields: getAdditionalFieldDefaultValues(
fieldsWithModelValues(
additionalFields,
activeOrganization as Record<string, unknown>
)
),
name: activeOrganization.name,
slug: activeOrganization.slug
})
}, [activeOrganization, additionalFields, form])
const nameInputId = `${activeOrganization?.id ?? "org"}-name`
const slugInputId = `${activeOrganization?.id ?? "org"}-slug`
const formDisabled =
isPending || canUpdate.isPending || !canUpdate.data?.success
return (
<div>
<h2 className={cn("mb-3 text-sm font-semibold")}>
{organizationLocalization.organizationProfile}
</h2>
<Card className={className}>
<CardContent>
<form.AppForm>
<form.AuthFormRoot className="flex flex-col gap-4">
<ChangeOrganizationLogo />
<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={nameInputId}>
{organizationLocalization.name}
</FieldLabel>
{activeOrganization ? (
<Input
id={nameInputId}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) =>
field.handleChange(event.target.value)
}
autoComplete="organization"
placeholder={organizationLocalization.namePlaceholder}
disabled={formDisabled}
aria-invalid={isInvalid}
/>
) : (
<Skeleton className="h-8 w-full rounded-md" />
)}
<field.AuthFormFieldError />
</Field>
)
}}
</form.AppField>
{!hideSlug &&
(activeOrganization ? (
<form.AppField name="slug">
{(field) => (
<SlugField
id={slugInputId}
value={field.state.value}
onChange={field.handleChange}
currentSlug={activeOrganization.slug}
disabled={formDisabled}
/>
)}
</form.AppField>
) : (
<Field>
<FieldLabel>{organizationLocalization.slug}</FieldLabel>
<Skeleton className="h-8 w-full rounded-md" />
</Field>
))}
{activeOrganization &&
additionalFields.map((configuredField) => (
<form.AppField
key={configuredField.name}
name={`additionalFields.${configuredField.name}`}
validators={getAuthAdditionalFieldValidators(
configuredField,
localization.auth.fieldRequired
)}
>
{(field) => (
<field.AuthFormAdditionalField
field={configuredField}
isPending={formDisabled}
optionalLabel={localization.settings.optional}
/>
)}
</form.AppField>
))}
{(canUpdate.isPending || canUpdate.data?.success) && (
<form.AuthFormSubmitButton
disabled={formDisabled || !activeOrganization}
size="sm"
className="mt-1 w-fit"
>
{localization.settings.saveChanges}
</form.AuthFormSubmitButton>
)}
</form.AuthFormRoot>
</form.AppForm>
</CardContent>
</Card>
</div>
)
}
examples/start-shadcn-example/src/components/auth/organization/organization-roles.tsx
"use client"
import {
type AdditionalFields,
fieldsWithModelValues,
getAdditionalFieldDefaultValues,
getAdditionalFieldSubmitValues,
validateStringLength
} from "@better-auth-ui/core"
import type { OrganizationRolesAuthClient } from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import {
useCreateRole,
useDeleteRole,
useHasPermission,
useListOrganizationMembers,
useListRoles,
useUpdateRole
} from "@better-auth-ui/react/plugins/organization"
import { Filter, Pencil, Plus, Search, Trash2, X } from "lucide-react"
import { useEffect, useMemo, useState } from "react"
import { toast } from "sonner"
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
AlertDialogTrigger
} from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from "@/components/ui/dialog"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import { Field, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
InputGroup,
InputGroupAddon,
InputGroupInput
} from "@/components/ui/input-group"
import { Spinner } from "@/components/ui/spinner"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { cn } from "cn"
import {
getAuthAdditionalFieldValidators,
isAuthFormFieldInvalid,
useAuthForm
} from "../auth-form"
import { OrganizationSortableTableHead } from "./organization-sortable-table-head"
import {
createOrganizationColumnHelper,
ORGANIZATION_TABLE_PAGE_SIZE,
useOrganizationTable
} from "./organization-table"
import { OrganizationTableBulkAction } from "./organization-table-bulk-action"
import { OrganizationTablePagination } from "./organization-table-pagination"
import {
type OrganizationSelectableRow,
OrganizationTableSelectAll,
OrganizationTableSelectRow
} from "./organization-table-selection"
import { useOrganizationTableState } from "./organization-table-state"
import { OrganizationTableViewOptions } from "./organization-table-view-options"
type Role = {
id: string
role: string
permission: Record<string, string[]>
[key: string]: unknown
}
const roleColumnHelper = createOrganizationColumnHelper<Role>()
const roleColumns = roleColumnHelper.columns([
roleColumnHelper.accessor("role", {
enableHiding: false,
filterFn: "includesString"
}),
roleColumnHelper.accessor(
(role) =>
Object.values(role.permission).reduce(
(total, actions) => total + actions.length,
0
),
{ id: "permissions", enableGlobalFilter: false }
),
roleColumnHelper.accessor((role) => Object.keys(role.permission), {
id: "permissionResources",
enableGlobalFilter: false,
enableHiding: false,
enableSorting: false,
filterFn: (row, columnId, value) =>
row.getValue<string[]>(columnId).includes(String(value))
})
])
const ROLE_COLUMN_IDS = ["role", "permissions", "permissionResources"] as const
const EMPTY_ROLES: Role[] = []
export function OrganizationRoles({
organizationId
}: {
organizationId: string
}) {
const { authClient, localization: authLocalization } =
useAuth<OrganizationRolesAuthClient>()
const { dynamicAccessControl, localization, modelFields } =
useAuthPlugin(organizationPlugin)
const canRead = useHasPermission(authClient, {
organizationId,
permissions: { ac: ["read"] }
})
const roles = useListRoles(authClient, {
query: { organizationId },
enabled: !!organizationId && canRead.data?.success === true
})
const canCreate = useHasPermission(authClient, {
organizationId,
permissions: { ac: ["create"] }
})
const canUpdate = useHasPermission(authClient, {
organizationId,
permissions: { ac: ["update"] }
})
const canDelete = useHasPermission(authClient, {
organizationId,
permissions: { ac: ["delete"] }
})
const [editingRole, setEditingRole] = useState<Role | null>()
const tableState = useOrganizationTableState(
"organizationRoles",
ORGANIZATION_TABLE_PAGE_SIZE,
ROLE_COLUMN_IDS
)
const { globalFilter, pagination } = tableState
useEffect(() => {
tableState.setColumnVisibility((current) =>
current.permissionResources === false
? current
: { ...current, permissionResources: false }
)
}, [tableState.setColumnVisibility])
const table = useOrganizationTable(
{
atoms: tableState.atoms,
columns: roleColumns,
data: roles.data ?? EMPTY_ROLES,
enableRowSelection: canDelete.data?.success === true,
globalFilterFn: (row, _columnId, value) => {
const query = String(value).toLowerCase()
return (
row.original.role.toLowerCase().includes(query) ||
Object.entries(row.original.permission).some(
([resource, actions]) =>
resource.toLowerCase().includes(query) ||
actions.some((action) => action.toLowerCase().includes(query))
)
)
},
getRowId: (role) => role.id
},
() => null
)
const deleteRoles = useDeleteRole(authClient, organizationId)
const permissionFilter = String(
table.getColumn("permissionResources")?.getFilterValue() ?? "all"
)
const permissionFacetRows = table
.getColumn("permissionResources")
?.getFacetedRowModel().flatRows
const permissionResources = Array.from(
new Set(
(roles.data ?? EMPTY_ROLES).flatMap((role) =>
Object.keys(role.permission)
)
)
).sort()
const selectedRoles = table.getSelectedRowModel().rows
const showSelection = canDelete.data?.success === true
async function deleteSelectedRoles() {
const results = await Promise.allSettled(
selectedRoles.map((row) =>
deleteRoles.mutateAsync({ roleId: row.original.id, organizationId })
)
)
const deletedCount = results.filter(
(result) => result.status === "fulfilled"
).length
if (deletedCount > 0) {
toast.success(
localization.rolesDeleted.replace("{{count}}", String(deletedCount))
)
}
table.resetRowSelection(true)
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-end justify-between gap-3">
<div>
<h2 className="text-sm font-semibold">{localization.roles}</h2>
<p className="text-sm text-muted-foreground">
{localization.rolesDescription}
</p>
</div>
{(canCreate.isPending || canCreate.data?.success) && (
<Button
disabled={canCreate.isPending}
onClick={() => setEditingRole(null)}
>
<Plus />
{localization.createRole}
</Button>
)}
</div>
<div className="flex flex-wrap items-center gap-3">
<InputGroup className="min-w-0 sm:w-[220px]">
<InputGroupInput
aria-label={localization.search}
disabled={roles.isLoading}
onChange={(event) => table.setGlobalFilter(event.target.value)}
placeholder={localization.search}
type="search"
value={globalFilter}
/>
<InputGroupAddon>
<Search className="text-muted-foreground" />
</InputGroupAddon>
</InputGroup>
<DropdownMenu>
<DropdownMenuTrigger
className={cn(buttonVariants({ size: "sm", variant: "outline" }))}
disabled={roles.isLoading}
>
<Filter />
{localization.permissions}
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuRadioGroup
onValueChange={(value) =>
table
.getColumn("permissionResources")
?.setFilterValue(value === "all" ? undefined : value)
}
value={permissionFilter}
>
<DropdownMenuRadioItem value="all">
{localization.all}
</DropdownMenuRadioItem>
{permissionResources.map((resource) => (
<DropdownMenuRadioItem key={resource} value={resource}>
{dynamicAccessControl?.permissions[resource]?.label ??
resource}{" "}
(
{permissionFacetRows?.filter((row) =>
Object.hasOwn(row.original.permission, resource)
).length ?? 0}
)
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<div className="ms-auto">
<OrganizationTableViewOptions
columns={[
{
id: "permissions",
label: localization.permissions,
visible: table.getColumn("permissions")?.getIsVisible() ?? true,
onVisibleChange: (visible) =>
table.getColumn("permissions")?.toggleVisibility(visible)
}
]}
disabled={roles.isLoading}
localization={localization}
/>
</div>
</div>
{permissionFilter !== "all" && (
<Badge className="w-fit gap-1" variant="secondary">
{dynamicAccessControl?.permissions[permissionFilter]?.label ??
permissionFilter}
<Button
aria-label={localization.clear}
className="size-4 rounded-sm text-muted-foreground"
onClick={() =>
table.getColumn("permissionResources")?.setFilterValue(undefined)
}
size="icon-xs"
type="button"
variant="ghost"
>
<X className="size-3" />
</Button>
</Badge>
)}
{selectedRoles.length > 0 && (
<OrganizationTableBulkAction
actionLabel={localization.deleteSelectedRoles}
cancelLabel={authLocalization.settings.cancel}
count={selectedRoles.length}
description={localization.deleteSelectedRolesDescription}
isPending={deleteRoles.isPending}
onConfirm={deleteSelectedRoles}
selectedLabel={localization.selectedCount}
/>
)}
{canRead.isPending || roles.isLoading ? (
<Spinner />
) : !canRead.data?.success ? null : roles.data?.length ? (
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
{showSelection && (
<TableHead className="w-10">
<OrganizationTableSelectAll
allSelected={table.getIsAllPageRowsSelected()}
disabled={roles.isLoading}
localization={localization}
onCheckedChange={(checked) =>
table.toggleAllPageRowsSelected(checked)
}
someSelected={table.getIsSomePageRowsSelected()}
/>
</TableHead>
)}
<OrganizationSortableTableHead
column={table.getColumn("role")}
>
{localization.roleName}
</OrganizationSortableTableHead>
{table.getColumn("permissions")?.getIsVisible() && (
<OrganizationSortableTableHead
column={table.getColumn("permissions")}
>
{localization.permissions}
</OrganizationSortableTableHead>
)}
<TableHead className="text-right">
{localization.actions}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<OrganizationRoleRow
key={row.original.id}
authClient={authClient}
canDelete={canDelete.data?.success === true}
canDeletePending={canDelete.isPending}
canUpdate={canUpdate.data?.success === true}
canUpdatePending={canUpdate.isPending}
onEdit={() => setEditingRole(row.original)}
organizationId={organizationId}
role={row.original}
selectableRow={showSelection ? row : undefined}
showPermissions={
table.getColumn("permissions")?.getIsVisible() === true
}
/>
))}
</TableBody>
</Table>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="flex flex-col gap-1">
<p className="text-sm font-medium">{localization.noRoles}</p>
<p className="text-sm text-muted-foreground">
{localization.noRolesDescription}
</p>
</CardContent>
</Card>
)}
<OrganizationTablePagination
canNextPage={table.getCanNextPage()}
canPreviousPage={table.getCanPreviousPage()}
disabled={roles.isLoading}
localization={localization}
onFirstPage={() => table.firstPage()}
onLastPage={() => table.lastPage()}
onNextPage={() => table.nextPage()}
onPageSizeChange={(pageSize) => table.setPageSize(pageSize)}
onPreviousPage={() => table.previousPage()}
pageCount={table.getPageCount()}
pageIndex={pagination.pageIndex}
pageSize={pagination.pageSize}
rowCount={table.getRowCount()}
visibleRowCount={table.getRowModel().rows.length}
/>
<RoleDialog
organizationId={organizationId}
open={editingRole !== undefined}
role={editingRole ?? undefined}
registry={dynamicAccessControl?.permissions ?? {}}
roleFields={modelFields.role}
onOpenChange={(open) => !open && setEditingRole(undefined)}
/>
</div>
)
}
function OrganizationRoleRow({
authClient,
canDelete,
canDeletePending,
canUpdate,
canUpdatePending,
onEdit,
organizationId,
role,
selectableRow,
showPermissions
}: {
authClient: OrganizationRolesAuthClient
canDelete: boolean
canDeletePending: boolean
canUpdate: boolean
canUpdatePending: boolean
onEdit: () => void
organizationId: string
role: Role
selectableRow?: OrganizationSelectableRow<Role>
showPermissions: boolean
}) {
const { localization: authLocalization } = useAuth()
const { localization } = useAuthPlugin(organizationPlugin)
const [deleteOpen, setDeleteOpen] = useState(false)
const deleteRole = useDeleteRole(authClient, organizationId, {
onSuccess: () => {
setDeleteOpen(false)
toast.success(localization.roleDeleted)
}
})
const assignments = useListOrganizationMembers(authClient, {
query: {
organizationId,
filterField: "role",
filterOperator: "contains",
filterValue: role.role,
limit: 1
},
enabled: Boolean(organizationId && canDelete)
})
const assignedCount =
assignments.data?.total ?? assignments.data?.members.length ?? 0
const assignmentUnknown = canDelete && !assignments.data
const deleteDisabled =
assignmentUnknown || assignedCount > 0 || deleteRole.isPending
return (
<TableRow
data-state={selectableRow?.getIsSelected() ? "selected" : undefined}
>
{selectableRow && (
<TableCell>
<OrganizationTableSelectRow
localization={localization}
row={selectableRow}
/>
</TableCell>
)}
<TableCell className="font-medium">{role.role}</TableCell>
{showPermissions && (
<TableCell>
{Object.values(role.permission).reduce(
(total, actions) => total + actions.length,
0
)}
</TableCell>
)}
<TableCell>
<div className="flex justify-end gap-1">
{canUpdatePending && (
<Button
aria-label={localization.editRole}
disabled
size="icon"
variant="ghost"
>
<Pencil />
</Button>
)}
{canUpdate && (
<Button
size="icon"
variant="ghost"
onClick={onEdit}
aria-label={localization.editRole}
>
<Pencil />
</Button>
)}
{canDelete && (
<AlertDialog
open={deleteOpen}
onOpenChange={(open) => {
if (!deleteRole.isPending) setDeleteOpen(open)
}}
>
<AlertDialogTrigger
className={buttonVariants({
size: "icon",
variant: "ghost",
className: "text-destructive"
})}
disabled={deleteDisabled}
title={
assignedCount > 0
? localization.roleInUse.replace(
"{{count}}",
String(assignedCount)
)
: localization.deleteRole
}
aria-label={localization.deleteRole}
>
<Trash2 />
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogMedia>
<Trash2 />
</AlertDialogMedia>
<AlertDialogTitle>{localization.deleteRole}</AlertDialogTitle>
<AlertDialogDescription>
{localization.deleteRoleDescription}
</AlertDialogDescription>
</AlertDialogHeader>
<p className="break-words text-sm font-medium">{role.role}</p>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteRole.isPending}>
{authLocalization.settings.cancel}
</AlertDialogCancel>
<Button
variant="destructive"
disabled={deleteDisabled}
onClick={() =>
deleteRole.mutate({ roleId: role.id, organizationId })
}
>
{deleteRole.isPending && <Spinner />}
{localization.deleteRole}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{canDeletePending && (
<Button
aria-label={localization.deleteRole}
className="text-destructive"
disabled
size="icon"
variant="ghost"
>
<Trash2 />
</Button>
)}
</div>
</TableCell>
</TableRow>
)
}
function RoleDialog({
onOpenChange,
open,
organizationId,
registry,
role,
roleFields
}: {
onOpenChange: (open: boolean) => void
open: boolean
organizationId: string
registry: Record<string, { label?: string; actions: Record<string, string> }>
role?: Role
roleFields: AdditionalFields
}) {
const { authClient, localization: authLocalization } =
useAuth<OrganizationRolesAuthClient>()
const { localization } = useAuthPlugin(organizationPlugin)
const createRole = useCreateRole(authClient, organizationId, {
onSuccess: () => {
toast.success(localization.roleCreated)
onOpenChange(false)
}
})
const updateRole = useUpdateRole(authClient, organizationId, {
onSuccess: () => {
toast.success(localization.roleUpdated)
onOpenChange(false)
}
})
const configuredRoleFields = useMemo(
() => fieldsWithModelValues(roleFields, role ?? {}),
[role, roleFields]
)
const form = useAuthForm({
defaultValues: {
additionalFields: getAdditionalFieldDefaultValues(configuredRoleFields),
name: role?.role ?? "",
permission: role?.permission ?? ({} as Record<string, string[]>)
},
onSubmit: async ({ value }) => {
const roleName = value.name.trim()
if (!roleName) return
try {
if (
Object.values(value.permission).some((actions) => actions.length > 0)
) {
const access = await authClient.organization.hasPermission({
organizationId,
permissions: value.permission as Parameters<
OrganizationRolesAuthClient["organization"]["hasPermission"]
>[0]["permissions"]
})
if (access.error || !access.data?.success) {
toast.error(localization.permissionsLimitedDescription)
return
}
}
const additionalFields = getAdditionalFieldSubmitValues(
configuredRoleFields,
value.additionalFields
)
if (role) {
await updateRole.mutateAsync({
organizationId,
roleId: role.id,
data: {
...additionalFields,
roleName,
permission: value.permission
}
})
} else {
await createRole.mutateAsync({
organizationId,
role: roleName,
permission: value.permission,
additionalFields
})
}
} catch {
// The mutation reports the error through its configured handler.
}
}
})
useEffect(() => {
if (!open) return
form.reset({
additionalFields: getAdditionalFieldDefaultValues(configuredRoleFields),
name: role?.role ?? "",
permission: role?.permission ?? {}
})
}, [configuredRoleFields, form, open, role?.permission, role?.role])
const pending = createRole.isPending || updateRole.isPending
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
<form.AppForm>
<form.AuthFormRoot className="flex flex-col gap-6">
<DialogHeader>
<DialogTitle>
{role ? localization.editRole : localization.createRole}
</DialogTitle>
<DialogDescription>
{localization.rolesDescription}
</DialogDescription>
</DialogHeader>
<form.AppField
name="name"
validators={{
onChange: ({ value }) =>
validateStringLength(value, {
requiredMessage: authLocalization.auth.fieldRequired,
trim: true
})
}}
>
{(field) => {
const isInvalid = isAuthFormFieldInvalid(field.state.meta)
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor="organization-role-name">
{localization.roleName}
</FieldLabel>
<Input
aria-invalid={isInvalid}
disabled={pending}
id="organization-role-name"
name={field.name}
onBlur={field.handleBlur}
onChange={(event) =>
field.handleChange(event.target.value)
}
placeholder={localization.roleNamePlaceholder}
value={field.state.value}
/>
<field.AuthFormFieldError />
</Field>
)
}}
</form.AppField>
{configuredRoleFields.map((configuredField) => (
<form.AppField
key={configuredField.name}
name={`additionalFields.${configuredField.name}`}
validators={getAuthAdditionalFieldValidators(
configuredField,
authLocalization.auth.fieldRequired
)}
>
{(field) => (
<field.AuthFormAdditionalField
field={configuredField}
isPending={pending}
optionalLabel={authLocalization.settings.optional}
/>
)}
</form.AppField>
))}
<form.AppField name="permission">
{(field) => (
<fieldset className="flex flex-col gap-4">
<legend className="text-sm font-medium">
{localization.permissions}
</legend>
{Object.entries(registry).map(([resource, definition]) => (
<div className="flex flex-col gap-2" key={resource}>
<p className="text-sm font-medium">
{definition.label ?? resource}
</p>
<div className="grid gap-3 sm:grid-cols-2">
{Object.entries(definition.actions).map(
([action, label]) => (
<RolePermissionCheckbox
action={action}
checked={
field.state.value[resource]?.includes(action) ??
false
}
key={action}
label={label}
onCheckedChange={(selected) =>
field.handleChange({
...field.state.value,
[resource]: selected
? [
...(field.state.value[resource] ?? []),
action
]
: (
field.state.value[resource] ?? []
).filter((entry) => entry !== action)
})
}
organizationId={organizationId}
pending={pending}
resource={resource}
/>
)
)}
</div>
</div>
))}
</fieldset>
)}
</form.AppField>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={pending}
onClick={() => onOpenChange(false)}
>
{authLocalization.settings.cancel}
</Button>
<form.AuthFormSubmitButton disabled={pending}>
{authLocalization.settings.saveChanges}
</form.AuthFormSubmitButton>
</DialogFooter>
</form.AuthFormRoot>
</form.AppForm>
</DialogContent>
</Dialog>
)
}
function RolePermissionCheckbox({
action,
checked,
label,
onCheckedChange,
organizationId,
pending,
resource
}: {
action: string
checked: boolean
label: string
onCheckedChange: (checked: boolean) => void
organizationId: string
pending: boolean
resource: string
}) {
const { authClient } = useAuth<OrganizationRolesAuthClient>()
const canAssign = useHasPermission(authClient, {
organizationId,
permissions: { [resource]: [action] } as Parameters<
OrganizationRolesAuthClient["organization"]["hasPermission"]
>[0]["permissions"]
})
const id = `role-permission-${resource}-${action}`
const disabled =
pending || canAssign.isPending || (!checked && !canAssign.data?.success)
return (
<label
className="flex items-center gap-2 text-sm"
data-disabled={disabled || undefined}
htmlFor={id}
>
<Checkbox
checked={checked}
disabled={disabled}
id={id}
onCheckedChange={(selected) => onCheckedChange(selected === true)}
/>
{label}
</label>
)
}
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-settings.tsx
"use client"
import { getOrganizationCardKey, useAuth } from "@better-auth-ui/react"
import type { ComponentProps } from "react"
import { cn } from "cn"
import { OrganizationDangerZone } from "./organization-danger-zone"
import { OrganizationProfile } from "./organization-profile"
export type OrganizationSettingsProps = {
className?: string
organizationId: string
organizationSlug: string
}
/**
* Organization settings UI: profile card, plugin-contributed cards
* (`organizationCards`), then danger zone.
*/
export function OrganizationSettings({
className,
organizationId,
organizationSlug,
...props
}: OrganizationSettingsProps & ComponentProps<"div">) {
const { plugins } = useAuth()
return (
<div className={cn("flex flex-col gap-4 md:gap-6", className)} {...props}>
<OrganizationProfile />
{plugins.flatMap((plugin) =>
plugin.organizationCards?.map((Card) => (
<Card
key={getOrganizationCardKey(plugin.id, Card)}
organizationId={organizationId}
organizationSlug={organizationSlug}
/>
))
)}
<OrganizationDangerZone />
</div>
)
}
examples/start-shadcn-example/src/components/auth/organization/organization-sortable-table-head.tsx
"use client"
import { type Column, type RowData, Subscribe } from "@tanstack/react-table"
import { ChevronUp } from "lucide-react"
import type { MouseEvent, ReactNode } from "react"
import { Button } from "@/components/ui/button"
import { TableHead } from "@/components/ui/table"
import { cn } from "cn"
import type { organizationTableFeatures } from "./organization-table"
export function OrganizationSortableTableHead<TData extends RowData>({
children,
column
}: {
children: ReactNode
column?: Column<typeof organizationTableFeatures, TData>
}) {
if (!column) return <TableHead>{children}</TableHead>
const onClick = column.getToggleSortingHandler()
return (
<Subscribe
source={column.table.atoms.sorting}
selector={() => [column.getIsSorted(), column.getSortIndex()] as const}
>
{([sortDirection, sortIndex]) => (
<TableHead
aria-sort={
sortDirection === "asc"
? "ascending"
: sortDirection === "desc"
? "descending"
: "none"
}
>
<Button
className="h-auto w-full justify-start p-0 font-medium hover:bg-transparent"
onClick={(event: MouseEvent<HTMLButtonElement>) => onClick?.(event)}
size="sm"
type="button"
variant="ghost"
>
{children}
{sortDirection ? (
<>
<ChevronUp
className={cn(
"size-3 transition-transform duration-100 ease-out",
sortDirection === "desc" && "rotate-180"
)}
/>
<span className="text-muted-foreground text-[10px] tabular-nums">
{sortIndex + 1}
</span>
</>
) : null}
</Button>
</TableHead>
)}
</Subscribe>
)
}
examples/start-shadcn-example/src/components/auth/organization/organization-switcher.tsx
"use client"
import type { OrganizationAuthClient } from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin, useSession } from "@better-auth-ui/react"
import {
useActiveOrganization,
useListOrganizations,
useSetActiveOrganization
} from "@better-auth-ui/react/plugins/organization"
import type { Organization } from "better-auth/client"
import {
ChevronsUpDown,
PlusCircle,
Settings as SettingsIcon
} from "lucide-react"
import { type ComponentProps, type ReactElement, useState } from "react"
import { buttonVariants } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { cn } from "cn"
import { UserView } from "../user/user-view"
import { CreateOrganizationDialog } from "./create-organization-dialog"
import { OrganizationView } from "./organization-view"
/** Props for the `OrganizationSwitcher` component. */
export type OrganizationSwitcherProps = {
className?: string
align?: "center" | "end" | "start"
side?: "top" | "right" | "bottom" | "left"
sideOffset?: number
trigger?: ReactElement<ComponentProps<typeof DropdownMenuTrigger>>
hideCreate?: boolean
hidePersonal?: boolean
hideSettings?: boolean
hideSlug?: boolean
setActive?: (organization: Organization | null) => void
}
/**
* Renders an organizations dropdown with a trigger button,
* header summary, and a menu of organizations to switch to.
*/
export function OrganizationSwitcher({
className,
align,
side,
sideOffset,
hideCreate,
hidePersonal,
hideSettings,
hideSlug: hideSlugProp,
setActive,
trigger
}: OrganizationSwitcherProps) {
const { authClient, navigate, basePaths, localization, viewPaths, Link } =
useAuth<OrganizationAuthClient>()
const { data: session, isPending: sessionPending } = useSession(authClient)
const {
localization: organizationLocalization,
viewPaths: organizationViewPaths,
slug,
slugPrefix,
hideSlug: pluginHideSlug
} = useAuthPlugin(organizationPlugin)
const hideSlug = hideSlugProp ?? pluginHideSlug ?? true
const { data: activeOrganization, isPending: activeOrganizationPending } =
useActiveOrganization(authClient)
const { data: organizations, isPending: organizationsPending } =
useListOrganizations(authClient)
const { mutate: setActiveOrganization } = useSetActiveOrganization(authClient)
const isPending =
sessionPending ||
(!!session && (organizationsPending || activeOrganizationPending))
const [createOpen, setCreateOpen] = useState(false)
const [dropdownOpen, setDropdownOpen] = useState(false)
const otherOrganizations =
organizations?.filter(
(organization) => organization.id !== activeOrganization?.id
) ?? []
const hasOtherEntries =
otherOrganizations.length > 0 || (!!activeOrganization && !hidePersonal)
function handleSetActive(organization: Organization | null) {
setDropdownOpen(false)
if (setActive) {
setActive(organization)
} else if (slug !== undefined) {
navigate({
to: organization
? `${basePaths.organization}/${slugPrefix}${organization.slug}/${organizationViewPaths.organization.settings}`
: `${basePaths.settings}/${viewPaths.settings.account}`
})
} else {
setActiveOrganization({ organizationId: organization?.id ?? null })
}
}
return (
<>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
{trigger ?? (
<DropdownMenuTrigger
className={cn(
buttonVariants({ variant: "ghost" }),
"h-auto px-2 py-2 text-left",
className
)}
disabled={!session || isPending}
>
{isPending ? (
<OrganizationView isPending hideRole hideSlug={hideSlug} />
) : activeOrganization ? (
<OrganizationView hideRole hideSlug={hideSlug} />
) : session && !hidePersonal ? (
<UserView hideSubtitle={hideSlug} />
) : (
<OrganizationView
hideRole
hideSlug={hideSlug}
organization={{ name: organizationLocalization.organization }}
/>
)}
<ChevronsUpDown className="size-4 shrink-0 text-muted-foreground" />
</DropdownMenuTrigger>
)}
<DropdownMenuContent
align={align}
side={side}
sideOffset={sideOffset}
className="min-w-64 max-w-svw"
>
{activeOrganization ? (
<div className="flex items-center justify-between gap-4 px-2 py-2">
<OrganizationView
hideRole
hideSlug={hideSlug}
organization={activeOrganization}
/>
{!hideSettings && (
<Link
href={
slug
? `${basePaths.organization}/${slugPrefix}${slug}/${organizationViewPaths.organization.settings}`
: `${basePaths.organization}/${organizationViewPaths.organization.settings}`
}
className={cn(
buttonVariants({ variant: "outline", size: "sm" })
)}
>
<SettingsIcon className="text-muted-foreground" />
{organizationLocalization.manage}
</Link>
)}
</div>
) : !isPending && session?.user && !hidePersonal ? (
<div className="flex items-center justify-between gap-4 px-2 py-2">
<UserView hideSubtitle={hideSlug} />
{!hideSettings && (
<Link
href={`${basePaths.settings}/${viewPaths.settings.account}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" })
)}
>
<SettingsIcon className="text-muted-foreground" />
{localization.settings.settings}
</Link>
)}
</div>
) : null}
<DropdownMenuSeparator />
{!!activeOrganization && !hidePersonal && (
<DropdownMenuItem onClick={() => handleSetActive(null)}>
<UserView hideSubtitle={hideSlug} />
</DropdownMenuItem>
)}
{otherOrganizations.map((organization) => (
<DropdownMenuItem
key={organization.id}
onClick={() => handleSetActive(organization)}
>
<OrganizationView
hideRole
hideSlug={hideSlug}
organization={organization}
/>
</DropdownMenuItem>
))}
{!hideCreate && (
<>
{hasOtherEntries && <DropdownMenuSeparator />}
<DropdownMenuItem
onClick={() => {
setDropdownOpen(false)
setCreateOpen(true)
}}
>
<PlusCircle className="text-muted-foreground" />
{organizationLocalization.createOrganization}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<CreateOrganizationDialog
hideSlug={hideSlug}
open={createOpen}
onOpenChange={setCreateOpen}
/>
</>
)
}
examples/start-shadcn-example/src/components/auth/organization/organization-table-bulk-action.tsx
"use client"
import { Trash2 } from "lucide-react"
import { useState } from "react"
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
AlertDialogTrigger
} from "@/components/ui/alert-dialog"
import { Button, buttonVariants } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
export function OrganizationTableBulkAction({
actionLabel,
cancelLabel,
count,
description,
disabled,
isPending,
onConfirm,
selectedLabel
}: {
actionLabel: string
cancelLabel: string
count: number
description: string
disabled?: boolean
isPending: boolean
onConfirm: () => Promise<void>
selectedLabel: string
}) {
const [open, setOpen] = useState(false)
return (
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground text-sm tabular-nums">
{selectedLabel.replace("{{count}}", String(count))}
</span>
<AlertDialog
open={open}
onOpenChange={(nextOpen) => !isPending && setOpen(nextOpen)}
>
<AlertDialogTrigger
className={buttonVariants({ size: "sm", variant: "destructive" })}
disabled={disabled || count === 0}
>
<Trash2 data-icon="inline-start" />
{actionLabel}
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogMedia>
<Trash2 />
</AlertDialogMedia>
<AlertDialogTitle>{actionLabel}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>
{cancelLabel}
</AlertDialogCancel>
<Button
disabled={isPending}
onClick={async () => {
await onConfirm()
setOpen(false)
}}
variant="destructive"
>
{isPending && <Spinner data-icon="inline-start" />}
{actionLabel}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
examples/start-shadcn-example/src/components/auth/organization/organization-table-pagination.tsx
"use client"
import type { OrganizationLocalization } from "@better-auth-ui/core/plugins/organization"
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight
} from "lucide-react"
import { Button, buttonVariants } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import { cn } from "cn"
import { ORGANIZATION_TABLE_PAGE_SIZE_OPTIONS } from "./organization-table-state"
export function OrganizationTablePagination({
disabled,
onFirstPage,
onLastPage,
onNextPage,
onPageSizeChange,
onPreviousPage,
pageCount,
pageIndex,
pageSize,
rowCount,
visibleRowCount,
canNextPage,
canPreviousPage,
localization
}: {
disabled?: boolean
onFirstPage: () => void
onLastPage: () => void
onNextPage: () => void
onPageSizeChange: (pageSize: number) => void
onPreviousPage: () => void
pageCount: number
pageIndex: number
pageSize: number
rowCount: number
visibleRowCount: number
canNextPage: boolean
canPreviousPage: boolean
localization: OrganizationLocalization
}) {
if (rowCount === 0) return null
const pageStart = pageIndex * pageSize
const from = pageStart + 1
const to = Math.min(pageStart + visibleRowCount, rowCount)
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-muted-foreground text-sm tabular-nums">
{localization.paginationRange
.replace("{{from}}", String(from))
.replace("{{to}}", String(to))
.replace("{{total}}", String(rowCount))}
</p>
<div className="flex flex-wrap items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger
className={cn(buttonVariants({ size: "sm", variant: "outline" }))}
disabled={disabled}
>
{localization.rowsPerPage}: {pageSize}
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup
value={String(pageSize)}
onValueChange={(value) => onPageSizeChange(Number(value))}
>
{Array.from(
new Set([...ORGANIZATION_TABLE_PAGE_SIZE_OPTIONS, pageSize])
)
.sort((left, right) => left - right)
.map((size) => (
<DropdownMenuRadioItem key={size} value={String(size)}>
{size}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<span className="text-muted-foreground text-sm tabular-nums">
{localization.pageOf
.replace("{{page}}", String(pageIndex + 1))
.replace("{{pages}}", String(Math.max(1, pageCount)))}
</span>
<Button
aria-label={localization.firstPage}
size="icon-sm"
variant="outline"
disabled={disabled || !canPreviousPage}
onClick={onFirstPage}
>
<ChevronsLeft />
</Button>
<Button
aria-label={localization.previousPage}
size="icon-sm"
variant="outline"
disabled={disabled || !canPreviousPage}
onClick={onPreviousPage}
>
<ChevronLeft />
</Button>
<Button
aria-label={localization.nextPage}
size="icon-sm"
variant="outline"
disabled={disabled || !canNextPage}
onClick={onNextPage}
>
<ChevronRight />
</Button>
<Button
aria-label={localization.lastPage}
size="icon-sm"
variant="outline"
disabled={disabled || !canNextPage}
onClick={onLastPage}
>
<ChevronsRight />
</Button>
</div>
</div>
)
}
examples/start-shadcn-example/src/components/auth/organization/organization-table-selection.tsx
"use client"
import type { OrganizationLocalization } from "@better-auth-ui/core/plugins/organization"
import { type Row, type RowData, Subscribe } from "@tanstack/react-table"
import type { MouseEvent } from "react"
import { Checkbox } from "@/components/ui/checkbox"
import type { organizationTableFeatures } from "./organization-table"
export type OrganizationSelectableRow<TData extends RowData> = Row<
typeof organizationTableFeatures,
TData
>
export function OrganizationTableSelectAll({
allSelected,
disabled,
onCheckedChange,
someSelected,
localization
}: {
allSelected: boolean
disabled?: boolean
onCheckedChange: (checked: boolean) => void
someSelected: boolean
localization: OrganizationLocalization
}) {
return (
<Checkbox
aria-label={localization.selectAllRows}
aria-checked={someSelected && !allSelected ? "mixed" : allSelected}
checked={allSelected}
disabled={disabled}
onCheckedChange={(checked) => onCheckedChange(checked === true)}
/>
)
}
export function OrganizationTableSelectRow<TData extends RowData>({
disabled,
localization,
row
}: {
disabled?: boolean
localization: OrganizationLocalization
row: OrganizationSelectableRow<TData>
}) {
return (
<Subscribe
source={row.table.atoms.rowSelection}
selector={(selection) => selection[row.id] === true}
>
{(selected) => (
<Checkbox
aria-label={localization.selectRow}
checked={selected}
disabled={disabled || !row.getCanSelect()}
onClick={(event: MouseEvent<HTMLElement>) =>
row.getToggleSelectedHandler()({
nativeEvent: event.nativeEvent,
shiftKey: event.shiftKey,
target: { checked: !selected }
})
}
/>
)}
</Subscribe>
)
}
examples/start-shadcn-example/src/components/auth/organization/organization-table-state.ts
"use client"
import {
createBrowserTablePersistenceAdapters,
parseTableColumnVisibility,
parseTableUrlState,
serializeTableColumnVisibility,
serializeTableUrlState,
type TablePersistenceAdapters
} from "@better-auth-ui/core"
import { useCreateAtom, useSelector } from "@tanstack/react-store"
import {
type ColumnFiltersState,
type ColumnVisibilityState,
functionalUpdate,
type PaginationState,
type RowSelectionState,
type SortingState,
type Updater
} from "@tanstack/react-table"
import {
type MutableRefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState
} from "react"
import { ORGANIZATION_TABLE_PAGE_SIZE } from "./organization-table"
const TABLE_STATE_STORAGE_PREFIX = "better-auth-ui:organization-table"
type OrganizationTableUrlState = {
columnFilters: ColumnFiltersState
globalFilter: string
pagination: PaginationState
sorting: SortingState
}
function readColumnVisibility(
adapters: TablePersistenceAdapters,
stateKey: string,
allowedColumnIds?: readonly string[]
): ColumnVisibilityState {
try {
const value =
adapters.storage?.read(
`${TABLE_STATE_STORAGE_PREFIX}:${stateKey}:columns`
) ?? null
return parseTableColumnVisibility(value, allowedColumnIds)
} catch {
return {}
}
}
function writeUrlState(
adapters: TablePersistenceAdapters,
stateKey: string,
defaultPageSize: number,
state: OrganizationTableUrlState,
syncedSearch: MutableRefObject<string>
) {
const next = serializeTableUrlState(
adapters.search.read(),
stateKey,
defaultPageSize,
state
)
const nextSearch = next.toString()
if (nextSearch === syncedSearch.current) return
syncedSearch.current = nextSearch
adapters.search.replace(next)
}
export function useOrganizationTableState(
stateKey: string,
defaultPageSize = ORGANIZATION_TABLE_PAGE_SIZE,
allowedColumnIds?: readonly string[],
persistenceAdapters?: TablePersistenceAdapters
) {
const allowedColumnIdsKey = allowedColumnIds?.join("\u0000")
const stableAllowedColumnIds = useMemo(
() => allowedColumnIdsKey?.split("\u0000"),
[allowedColumnIdsKey]
)
const adapters = useMemo(
() => persistenceAdapters ?? createBrowserTablePersistenceAdapters(),
[persistenceAdapters]
)
const urlStateToken = useMemo(
() => ({ adapters, defaultPageSize, stableAllowedColumnIds, stateKey }),
[adapters, defaultPageSize, stableAllowedColumnIds, stateKey]
)
const columnFiltersAtom = useCreateAtom<ColumnFiltersState>([])
const columnVisibilityAtom = useCreateAtom<ColumnVisibilityState>({})
const globalFilterAtom = useCreateAtom("")
const paginationAtom = useCreateAtom<PaginationState>({
pageIndex: 0,
pageSize: defaultPageSize
})
const rowSelectionAtom = useCreateAtom<RowSelectionState>({})
const sortingAtom = useCreateAtom<SortingState>([])
const columnFilters = useSelector(columnFiltersAtom)
const columnVisibility = useSelector(columnVisibilityAtom)
const globalFilter = useSelector(globalFilterAtom)
const pagination = useSelector(paginationAtom)
const rowSelection = useSelector(rowSelectionAtom)
const sorting = useSelector(sortingAtom)
const [restoredUrlStateToken, setRestoredUrlStateToken] =
useState<typeof urlStateToken>()
const [visibilityReady, setVisibilityReady] = useState(false)
const urlReady = restoredUrlStateToken === urlStateToken
const restoringUrlState = useRef(false)
const syncedSearch = useRef("")
const atoms = useMemo(
() => ({
columnFilters: columnFiltersAtom,
columnVisibility: columnVisibilityAtom,
globalFilter: globalFilterAtom,
pagination: paginationAtom,
rowSelection: rowSelectionAtom,
sorting: sortingAtom
}),
[
columnFiltersAtom,
columnVisibilityAtom,
globalFilterAtom,
paginationAtom,
rowSelectionAtom,
sortingAtom
]
)
const setPagination = useCallback(
(updater: Updater<PaginationState>) => {
paginationAtom.set((current) => functionalUpdate(updater, current))
},
[paginationAtom]
)
const setColumnVisibility = useCallback(
(updater: Updater<ColumnVisibilityState>) => {
columnVisibilityAtom.set((current) => functionalUpdate(updater, current))
},
[columnVisibilityAtom]
)
const setColumnFilters = useCallback(
(updater: Updater<ColumnFiltersState>) => {
columnFiltersAtom.set((current) => functionalUpdate(updater, current))
},
[columnFiltersAtom]
)
const setGlobalFilter = useCallback(
(updater: Updater<string>) => {
globalFilterAtom.set((current) => functionalUpdate(updater, current))
},
[globalFilterAtom]
)
const setSorting = useCallback(
(updater: Updater<SortingState>) => {
sortingAtom.set((current) => functionalUpdate(updater, current))
},
[sortingAtom]
)
useEffect(() => {
const resetPage = () => {
if (restoringUrlState.current) return
const current = paginationAtom.get()
if (current.pageIndex === 0) rowSelectionAtom.set({})
else paginationAtom.set({ ...current, pageIndex: 0 })
}
const subscriptions = [
columnFiltersAtom.subscribe(resetPage),
globalFilterAtom.subscribe(resetPage),
sortingAtom.subscribe(resetPage),
paginationAtom.subscribe(() => rowSelectionAtom.set({}))
]
return () => {
for (const subscription of subscriptions) subscription.unsubscribe()
}
}, [
columnFiltersAtom,
globalFilterAtom,
paginationAtom,
rowSelectionAtom,
sortingAtom
])
useEffect(() => {
if (!urlReady) return
writeUrlState(
adapters,
stateKey,
defaultPageSize,
{
columnFilters,
globalFilter,
pagination,
sorting
},
syncedSearch
)
}, [
adapters,
columnFilters,
defaultPageSize,
globalFilter,
pagination,
sorting,
stateKey,
urlReady
])
useEffect(() => {
if (!urlReady || !visibilityReady) return
try {
adapters.storage?.write(
`${TABLE_STATE_STORAGE_PREFIX}:${stateKey}:columns`,
serializeTableColumnVisibility(columnVisibility)
)
} catch {
// Browsers can disable storage while still allowing the table to work.
}
}, [adapters, columnVisibility, stateKey, urlReady, visibilityReady])
useEffect(() => {
setRestoredUrlStateToken(undefined)
setVisibilityReady(false)
columnVisibilityAtom.set(
readColumnVisibility(adapters, stateKey, stableAllowedColumnIds)
)
setVisibilityReady(true)
const restoreUrlState = () => {
restoringUrlState.current = true
const search = adapters.search.read()
syncedSearch.current = search.toString()
const next = parseTableUrlState(
search,
stateKey,
defaultPageSize,
ORGANIZATION_TABLE_PAGE_SIZE_OPTIONS,
stableAllowedColumnIds
)
columnFiltersAtom.set(next.columnFilters)
globalFilterAtom.set(next.globalFilter)
sortingAtom.set(next.sorting)
paginationAtom.set(next.pagination)
restoringUrlState.current = false
setRestoredUrlStateToken(urlStateToken)
}
restoreUrlState()
return adapters.search.subscribe(restoreUrlState)
}, [
adapters,
columnFiltersAtom,
columnVisibilityAtom,
defaultPageSize,
globalFilterAtom,
paginationAtom,
sortingAtom,
stableAllowedColumnIds,
stateKey,
urlStateToken
])
return {
atoms,
columnFilters,
columnVisibility,
globalFilter,
pagination,
ready: urlReady && visibilityReady,
rowSelection,
sorting,
setColumnFilters,
setColumnVisibility,
setGlobalFilter,
setPagination,
setRowSelection: rowSelectionAtom.set,
setSorting
}
}
export const ORGANIZATION_TABLE_PAGE_SIZE_OPTIONS = [10, 20, 50] as const
examples/start-shadcn-example/src/components/auth/organization/organization-table-view-options.tsx
"use client"
import type { OrganizationLocalization } from "@better-auth-ui/core/plugins/organization"
import { Settings2 } from "lucide-react"
import { buttonVariants } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import { cn } from "cn"
export type OrganizationTableViewColumn = {
id: string
label: string
visible: boolean
onVisibleChange: (visible: boolean) => void
}
export function OrganizationTableViewOptions({
columns,
disabled,
localization
}: {
columns: OrganizationTableViewColumn[]
disabled?: boolean
localization: OrganizationLocalization
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger
className={cn(buttonVariants({ size: "sm", variant: "outline" }))}
disabled={disabled}
>
<Settings2 data-icon="inline-start" />
{localization.columns}
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
{columns.map((column) => (
<DropdownMenuCheckboxItem
checked={column.visible}
key={column.id}
onCheckedChange={(checked) =>
column.onVisibleChange(checked === true)
}
>
{column.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
examples/start-shadcn-example/src/components/auth/organization/organization-table.ts
"use client"
import {
columnFacetingFeature,
columnFilteringFeature,
columnVisibilityFeature,
createFacetedRowModel,
createFacetedUniqueValues,
createFilteredRowModel,
createPaginatedRowModel,
createSortedRowModel,
createTableHook,
filterFn_includesString,
globalFilteringFeature,
rowPaginationFeature,
rowSelectionFeature,
rowSortingFeature,
tableFeatures
} from "@tanstack/react-table"
export const ORGANIZATION_TABLE_PAGE_SIZE = 10
export const organizationTableFeatures = tableFeatures({
columnFilteringFeature,
globalFilteringFeature,
filteredRowModel: createFilteredRowModel(),
filterFns: { includesString: filterFn_includesString },
columnFacetingFeature,
facetedRowModel: createFacetedRowModel(),
facetedUniqueValues: createFacetedUniqueValues(),
columnVisibilityFeature,
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
rowPaginationFeature,
paginatedRowModel: createPaginatedRowModel(),
rowSelectionFeature
})
export const {
createAppColumnHelper: createOrganizationColumnHelper,
useAppTable: useOrganizationTable
} = createTableHook({
enableMultiSort: true,
sortDescFirst: false,
features: organizationTableFeatures
})
examples/start-shadcn-example/src/components/auth/organization/organization-teams.tsx
"use client"
import {
type AdditionalFields,
fieldsWithModelValues,
getAdditionalFieldDefaultValues,
getAdditionalFieldSubmitValues,
validateStringLength
} from "@better-auth-ui/core"
import type { OrganizationTeamsAuthClient } from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin, useSession } from "@better-auth-ui/react"
import {
useActiveOrganization,
useAddTeamMember,
useCreateTeam,
useHasPermission,
useListOrganizationMembers,
useListTeamMembers,
useListTeams,
useListUserTeams,
useRemoveTeam,
useRemoveTeamMember,
useUpdateTeam
} from "@better-auth-ui/react/plugins/organization"
import {
Pencil,
Plus,
Trash2,
UserPlus,
UserRoundMinus,
Users
} from "lucide-react"
import { useEffect, useMemo, useState } from "react"
import { toast } from "sonner"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger
} from "@/components/ui/alert-dialog"
import { Button, buttonVariants } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select"
import { Spinner } from "@/components/ui/spinner"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import {
getAuthAdditionalFieldValidators,
isAuthFormFieldInvalid,
useAuthForm
} from "../auth-form"
type Team = { id: string; name: string; [key: string]: unknown }
export function OrganizationTeams() {
const { authClient } = useAuth<OrganizationTeamsAuthClient>()
const { data: activeOrganization } = useActiveOrganization(authClient)
const { data: session } = useSession(authClient)
const { localization, modelFields, teamPolicy } =
useAuthPlugin(organizationPlugin)
const teams = useListTeams(authClient, {
query: { organizationId: activeOrganization?.id }
})
const members = useListOrganizationMembers(authClient)
const userTeams = useListUserTeams(authClient, {
query: { organizationId: activeOrganization?.id ?? "" }
})
const userTeamIds = new Set(userTeams.data?.map((team) => team.id))
const activeTeamId = (
session?.session as { activeTeamId?: string | null } | undefined
)?.activeTeamId
const canCreate = useHasPermission(authClient, {
organizationId: activeOrganization?.id,
permissions: { team: ["create"] }
})
const teamLimitReached =
teamPolicy.maximumTeams !== undefined &&
(teams.data?.length ?? 0) >= teamPolicy.maximumTeams
const [dialogTeam, setDialogTeam] = useState<Team | null>()
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col justify-between gap-3 sm:flex-row sm:items-end">
<div>
<h2 className="text-sm font-semibold">{localization.teams}</h2>
<p className="text-sm text-muted-foreground">
{localization.teamsDescription}
</p>
</div>
{(canCreate.isPending || canCreate.data?.success) && (
<Button
disabled={canCreate.isPending || teamLimitReached}
onClick={() => setDialogTeam(null)}
title={teamLimitReached ? localization.teamLimitReached : undefined}
>
<Plus data-icon="inline-start" />
{localization.createTeam}
</Button>
)}
</div>
{teamLimitReached && canCreate.data?.success && (
<p className="text-sm text-destructive" role="alert">
{localization.teamLimitReached}
</p>
)}
{teams.isPending ? (
<Spinner />
) : teams.data?.length ? (
<Card>
<CardContent className="p-0">
{teams.data.map((team) => (
<div
className="flex items-center justify-between gap-4 border-b px-4 py-3 last:border-b-0"
key={team.id}
>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{team.name}</p>
<p className="text-sm text-muted-foreground">
{localization.team}
</p>
</div>
<Button
onClick={() => setDialogTeam(team)}
size="sm"
variant="outline"
>
<Pencil data-icon="inline-start" />
{localization.manage}
</Button>
</div>
))}
</CardContent>
</Card>
) : (
<Card>
<CardContent className="flex flex-col gap-1">
<p className="text-sm font-medium">{localization.noTeams}</p>
<p className="text-sm text-muted-foreground">
{localization.noTeamsDescription}
</p>
</CardContent>
</Card>
)}
<TeamDialog
activeTeamId={activeTeamId}
allowRemovingAllTeams={teamPolicy.allowRemovingAllTeams}
canListMembers={dialogTeam ? userTeamIds.has(dialogTeam.id) : false}
maximumMembersPerTeam={teamPolicy.maximumMembersPerTeam}
onOpenChange={(open) => !open && setDialogTeam(undefined)}
open={dialogTeam !== undefined}
organizationId={activeOrganization?.id ?? ""}
organizationMembers={members.data?.members ?? []}
team={dialogTeam ?? undefined}
teamCount={teams.data?.length ?? 0}
teamFields={modelFields.team}
teamLimitReached={teamLimitReached}
/>
</div>
)
}
function TeamDialog({
activeTeamId,
allowRemovingAllTeams,
canListMembers,
maximumMembersPerTeam,
onOpenChange,
open,
organizationId,
organizationMembers,
team,
teamCount,
teamFields,
teamLimitReached
}: {
activeTeamId?: string | null
allowRemovingAllTeams: boolean
canListMembers: boolean
maximumMembersPerTeam?: number
onOpenChange: (open: boolean) => void
open: boolean
organizationId: string
organizationMembers: Array<{
userId: string
user: { name: string; email: string }
}>
team?: Team
teamCount: number
teamFields: AdditionalFields
teamLimitReached: boolean
}) {
const { authClient, localization: authLocalization } =
useAuth<OrganizationTeamsAuthClient>()
const { localization } = useAuthPlugin(organizationPlugin)
const teamMembers = useListTeamMembers(authClient, {
query: { teamId: team?.id ?? "" },
enabled: open && !!team && canListMembers
})
const canUpdate = useHasPermission(authClient, {
organizationId,
permissions: { team: ["update"] }
})
const canDelete = useHasPermission(authClient, {
organizationId,
permissions: { team: ["delete"] }
})
const canAddMember = useHasPermission(authClient, {
organizationId,
permissions: { member: ["update"] }
})
const canRemoveMember = useHasPermission(authClient, {
organizationId,
permissions: { member: ["delete"] }
})
const [userId, setUserId] = useState("")
const createTeam = useCreateTeam(authClient, {
onSuccess: () => {
toast.success(localization.teamCreated)
onOpenChange(false)
}
})
const updateTeam = useUpdateTeam(authClient, {
onSuccess: () => {
toast.success(localization.teamUpdated)
onOpenChange(false)
}
})
const removeTeam = useRemoveTeam(authClient, {
onSuccess: () => {
toast.success(localization.teamDeleted)
onOpenChange(false)
}
})
const addMember = useAddTeamMember(authClient, {
onSuccess: () => setUserId("")
})
const removeMember = useRemoveTeamMember(authClient)
const memberIds = new Set(teamMembers.data?.map((member) => member.userId))
const memberLimitReached =
maximumMembersPerTeam !== undefined &&
(teamMembers.data?.length ?? 0) >= maximumMembersPerTeam
const isActiveTeam = activeTeamId === team?.id
const canRemoveFinalTeam = allowRemovingAllTeams || teamCount > 1
const canRemoveTeam = canRemoveFinalTeam && !isActiveTeam
const teamRemovalDisabledReason = isActiveTeam
? localization.activeTeamRemovalDisabled
: localization.lastTeamRemovalDisabled
const configuredTeamFields = useMemo(
() => fieldsWithModelValues(teamFields, team ?? {}),
[team, teamFields]
)
const form = useAuthForm({
defaultValues: {
additionalFields: getAdditionalFieldDefaultValues(configuredTeamFields),
name: team?.name ?? ""
},
onSubmit: async ({ value }) => {
const name = value.name.trim()
if (!name || !organizationId || (!team && teamLimitReached)) return
if (team && !canUpdate.data?.success) return
const data = {
...getAdditionalFieldSubmitValues(
configuredTeamFields,
value.additionalFields
),
name,
organizationId
}
try {
if (team) await updateTeam.mutateAsync({ teamId: team.id, data })
else await createTeam.mutateAsync(data)
} catch {
// The mutation reports the error through its configured handler.
}
}
})
useEffect(() => {
if (!open) return
form.reset({
additionalFields: getAdditionalFieldDefaultValues(configuredTeamFields),
name: team?.name ?? ""
})
setUserId("")
}, [configuredTeamFields, form, open, team?.name])
const pending = createTeam.isPending || updateTeam.isPending
const canEdit = !team || canUpdate.data?.success === true
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
<form.AppForm>
<form.AuthFormRoot className="flex flex-col gap-6">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Users />
{team ? localization.renameTeam : localization.createTeam}
</DialogTitle>
<DialogDescription>
{localization.teamsDescription}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<form.AppField
name="name"
validators={{
onChange: ({ value }) =>
validateStringLength(value, {
requiredMessage: authLocalization.auth.fieldRequired,
trim: true
})
}}
>
{(field) => {
const isInvalid = isAuthFormFieldInvalid(field.state.meta)
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor="organization-team-name">
{localization.name}
</FieldLabel>
<Input
aria-invalid={isInvalid}
autoFocus
disabled={pending || !canEdit}
id="organization-team-name"
name={field.name}
onBlur={field.handleBlur}
onChange={(event) =>
field.handleChange(event.target.value)
}
value={field.state.value}
/>
<field.AuthFormFieldError />
</Field>
)
}}
</form.AppField>
{configuredTeamFields.map((configuredField) => (
<form.AppField
key={configuredField.name}
name={`additionalFields.${configuredField.name}`}
validators={getAuthAdditionalFieldValidators(
configuredField,
authLocalization.auth.fieldRequired
)}
>
{(field) => (
<field.AuthFormAdditionalField
field={configuredField}
isPending={pending || !canEdit}
optionalLabel={authLocalization.settings.optional}
/>
)}
</form.AppField>
))}
</div>
{team && canListMembers && (
<div className="flex flex-col gap-4 border-t pt-5">
<div>
<h3 className="text-sm font-medium">
{localization.teamMembers}
</h3>
<p className="text-sm text-muted-foreground">
{localization.addTeamMember}
</p>
</div>
{(canAddMember.isPending || canAddMember.data?.success) && (
<div className="flex flex-col items-stretch gap-2 sm:flex-row sm:items-end">
<Field className="flex-1">
<FieldLabel>{localization.addTeamMember}</FieldLabel>
<Select
disabled={canAddMember.isPending || memberLimitReached}
onValueChange={(value) => setUserId(value ?? "")}
value={userId}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={localization.selectMember}
/>
</SelectTrigger>
<SelectContent>
{organizationMembers
.filter((member) => !memberIds.has(member.userId))
.map((member) => (
<SelectItem
key={member.userId}
value={member.userId}
>
{member.user.name || member.user.email}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Button
disabled={
canAddMember.isPending ||
!userId ||
addMember.isPending ||
memberLimitReached
}
onClick={() => {
if (!canAddMember.data?.success || !userId) return
addMember.mutate({
teamId: team.id,
userId,
organizationId
})
}}
type="button"
>
<UserPlus data-icon="inline-start" />
{localization.addTeamMember}
</Button>
</div>
)}
{canAddMember.data?.success && memberLimitReached && (
<p className="text-sm text-destructive" role="alert">
{localization.teamMemberLimitReached}
</p>
)}
<div className="flex flex-col gap-2">
{teamMembers.isPending && <Spinner />}
{teamMembers.data?.map((teamMember) => {
const member = organizationMembers.find(
(candidate) => candidate.userId === teamMember.userId
)
return (
<div
className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"
key={teamMember.id}
>
<span className="truncate text-sm">
{member?.user.name ||
member?.user.email ||
teamMember.userId}
</span>
{(canRemoveMember.isPending ||
canRemoveMember.data?.success) && (
<Button
aria-label={localization.removeTeamMember}
disabled={
canRemoveMember.isPending ||
removeMember.isPending
}
onClick={() => {
if (!canRemoveMember.data?.success) return
removeMember.mutate({
teamId: team.id,
userId: teamMember.userId,
organizationId
})
}}
size="icon-sm"
type="button"
variant="ghost"
>
<UserRoundMinus />
</Button>
)}
</div>
)
})}
</div>
</div>
)}
{team && !canRemoveTeam && canDelete.data?.success && (
<p className="text-sm text-muted-foreground">
{teamRemovalDisabledReason}
</p>
)}
<DialogFooter className="sm:justify-between">
{team && (canDelete.isPending || canDelete.data?.success) && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
disabled={
canDelete.isPending ||
removeTeam.isPending ||
!canRemoveTeam
}
type="button"
variant="destructive"
>
<Trash2 data-icon="inline-start" />
{localization.deleteTeam}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{localization.deleteTeam}
</AlertDialogTitle>
<AlertDialogDescription>
{localization.deleteTeamDescription}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{authLocalization.settings.cancel}
</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={() =>
removeTeam.mutate({
teamId: team.id,
organizationId
})
}
>
<Trash2 data-icon="inline-start" />
{localization.deleteTeam}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
<div className="flex flex-col-reverse gap-2 sm:flex-row">
<DialogClose
className={buttonVariants({ variant: "outline" })}
disabled={pending}
type="button"
>
{authLocalization.settings.cancel}
</DialogClose>
{canEdit && (
<form.AuthFormSubmitButton
disabled={pending || (teamLimitReached && !team)}
>
{team
? authLocalization.settings.saveChanges
: localization.createTeam}
</form.AuthFormSubmitButton>
)}
</div>
</DialogFooter>
</form.AuthFormRoot>
</form.AppForm>
</DialogContent>
</Dialog>
)
}
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/organization.tsx
"use client"
import type {
OrganizationAuthClient,
OrganizationView
} from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthenticate, useAuthPlugin } from "@better-auth-ui/react"
import {
useActiveOrganization,
useHasPermission
} from "@better-auth-ui/react/plugins/organization"
import {
ShieldCheck as RolesIcon,
Settings as SettingsIcon,
UsersRound as TeamsIcon,
User2 as UserIcon
} from "lucide-react"
import { useEffect, useMemo } from "react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { cn } from "cn"
import { OrganizationPeople } from "./organization-people"
import { OrganizationRoles } from "./organization-roles"
import { OrganizationSettings } from "./organization-settings"
import { OrganizationTeams } from "./organization-teams"
export type OrganizationProps = {
className?: string
hideNav?: boolean
path?: string
/** @remarks `OrganizationView` */
view?: OrganizationView | string
}
/**
* Organization management shell: tabs for profile / danger zone and for
* people (members / invitations). Path segments come from
* `useAuthPlugin(organizationPlugin).viewPaths.organization`.
*/
export function Organization({
className,
hideNav,
path,
view
}: OrganizationProps) {
if (!view && !path) {
throw new Error("[Better Auth UI] Either `view` or `path` must be provided")
}
const { authClient, basePaths, localization, navigate, plugins } =
useAuth<OrganizationAuthClient>()
useAuthenticate(authClient)
const {
localization: organizationLocalization,
viewPaths: organizationViewPaths,
slug,
slugPrefix,
teams,
dynamicAccessControl
} = useAuthPlugin(organizationPlugin)
const { data: activeOrganization, isPending } =
useActiveOrganization(authClient)
const extensionTabs = useMemo(
() => plugins.flatMap((plugin) => plugin.organizationTabs ?? []),
[plugins]
)
const rolesEnabled = dynamicAccessControl?.enabled === true
const canReadRoles = useHasPermission(authClient, {
organizationId: activeOrganization?.id,
permissions: { ac: ["read"] },
enabled: rolesEnabled && Boolean(activeOrganization?.id)
})
useEffect(() => {
if (!isPending && !activeOrganization) {
navigate({
to: `${basePaths.settings}/${organizationViewPaths.settings?.organizations}`,
replace: true
})
}
}, [
basePaths.settings,
isPending,
navigate,
organizationViewPaths.settings?.organizations,
activeOrganization
])
const currentView = useMemo(() => {
if (view) return view === "roles" && !rolesEnabled ? undefined : view
const match = [
...Object.entries(organizationViewPaths.organization).filter(
([name]) => rolesEnabled || name !== "roles"
),
...extensionTabs.map((tab) => [tab.id, tab.path] as const)
].find(([, segment]) => segment === path)
return match?.[0] as OrganizationView | undefined
}, [
extensionTabs,
view,
path,
organizationViewPaths.organization,
rolesEnabled
])
if (!currentView) {
const validPaths = Object.entries(organizationViewPaths.organization)
.filter(([name]) => rolesEnabled || name !== "roles")
.map(([, segment]) => segment)
.join(", ")
throw new Error(
`[Better Auth UI] Unknown organization path "${path}". Valid paths are: ${validPaths}`
)
}
if (!isPending && !activeOrganization) {
return null
}
const selectedView =
currentView === "roles" &&
!canReadRoles.isPending &&
!canReadRoles.data?.success
? "settings"
: currentView
return (
<Tabs
value={selectedView}
className={cn("w-full gap-4 md:gap-6", className)}
>
<div className={cn(hideNav && "hidden")}>
<TabsList aria-label={localization.settings.settings}>
<TabsTrigger
value="settings"
className="gap-1"
onClick={() =>
navigate({
to: slug
? `${basePaths.organization}/${slugPrefix}${slug}/${organizationViewPaths.organization.settings}`
: `${basePaths.organization}/${organizationViewPaths.organization.settings}`
})
}
>
<SettingsIcon className="text-muted-foreground" />
{localization.settings.settings}
</TabsTrigger>
{teams && (
<TabsTrigger
value="teams"
className="gap-1"
onClick={() =>
navigate({
to: slug
? `${basePaths.organization}/${slugPrefix}${slug}/${organizationViewPaths.organization.teams}`
: `${basePaths.organization}/${organizationViewPaths.organization.teams}`
})
}
>
<TeamsIcon className="text-muted-foreground" />
{organizationLocalization.teams}
</TabsTrigger>
)}
{rolesEnabled && (
<TabsTrigger
disabled={canReadRoles.isPending || !canReadRoles.data?.success}
value="roles"
className="gap-1"
onClick={() =>
navigate({
to: slug
? `${basePaths.organization}/${slugPrefix}${slug}/${organizationViewPaths.organization.roles}`
: `${basePaths.organization}/${organizationViewPaths.organization.roles}`
})
}
>
<RolesIcon className="text-muted-foreground" />
{organizationLocalization.roles}
</TabsTrigger>
)}
{extensionTabs.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
onClick={() =>
navigate({
to: slug
? `${basePaths.organization}/${slugPrefix}${slug}/${tab.path}`
: `${basePaths.organization}/${tab.path}`
})
}
>
{tab.label}
</TabsTrigger>
))}
<TabsTrigger
value="people"
className="gap-1"
onClick={() =>
navigate({
to: slug
? `${basePaths.organization}/${slugPrefix}${slug}/${organizationViewPaths.organization.people}`
: `${basePaths.organization}/${organizationViewPaths.organization.people}`
})
}
>
<UserIcon className="text-muted-foreground" />
{organizationLocalization.people}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="settings" tabIndex={-1}>
{activeOrganization && (
<OrganizationSettings
organizationId={activeOrganization.id}
organizationSlug={activeOrganization.slug}
/>
)}
</TabsContent>
<TabsContent value="people" tabIndex={-1}>
<OrganizationPeople />
</TabsContent>
{teams && (
<TabsContent value="teams" tabIndex={-1}>
<OrganizationTeams />
</TabsContent>
)}
{rolesEnabled && (
<TabsContent value="roles" tabIndex={-1}>
{canReadRoles.data?.success ? (
<OrganizationRoles organizationId={activeOrganization?.id ?? ""} />
) : null}
</TabsContent>
)}
{extensionTabs.map((tab) => {
const Extension = tab.component
return (
<TabsContent key={tab.id} value={tab.id} tabIndex={-1}>
<Extension
organizationId={activeOrganization?.id ?? ""}
organizationSlug={activeOrganization?.slug ?? ""}
/>
</TabsContent>
)
})}
</Tabs>
)
}
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/remove-member-dialog.tsx
"use client"
import type { OrganizationAuthClient } from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import { useRemoveMember } from "@better-auth-ui/react/plugins/organization"
import type { Member, User } from "better-auth/client"
import { Trash2 } from "lucide-react"
import { toast } from "sonner"
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle
} from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Spinner } from "@/components/ui/spinner"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { UserView } from "../user/user-view"
export type RemoveMemberDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
member: Member & { user: Partial<User> }
}
export function RemoveMemberDialog({
open,
onOpenChange,
member
}: RemoveMemberDialogProps) {
const { authClient, localization } = useAuth<OrganizationAuthClient>()
const { localization: organizationLocalization, roles } =
useAuthPlugin(organizationPlugin)
const { mutate: removeMember, isPending } = useRemoveMember(authClient, {
onSuccess: () => {
onOpenChange(false)
toast.success(organizationLocalization.memberRemoved)
}
})
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive">
<Trash2 />
</AlertDialogMedia>
<AlertDialogTitle>
{organizationLocalization.removeMember}
</AlertDialogTitle>
<AlertDialogDescription>
{organizationLocalization.removeMemberWarning}
</AlertDialogDescription>
</AlertDialogHeader>
<Card>
<CardContent className="flex flex-row items-center justify-between gap-2">
<UserView user={member.user} />
<Badge variant="outline">
{roles?.[member.role] ?? member.role}
</Badge>
</CardContent>
</Card>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>
{localization.settings.cancel}
</AlertDialogCancel>
<Button
variant="destructive"
disabled={isPending}
onClick={() =>
removeMember({
memberIdOrEmail: member.id,
organizationId: member.organizationId
})
}
>
{isPending && <Spinner />}
{organizationLocalization.removeMember}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
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/team-switcher.tsx
"use client"
import type {
ListedUserTeam,
OrganizationTeamsAuthClient
} from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import {
useListUserTeams,
useSetActiveTeam
} from "@better-auth-ui/react/plugins/organization"
import { Check, ChevronsUpDown, Users } from "lucide-react"
import { type ReactElement, useState } from "react"
import { buttonVariants } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import { Skeleton } from "@/components/ui/skeleton"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { cn } from "cn"
export type TeamSwitcherProps = {
organizationId: string
teamId?: string | null
onTeamChange?: (team: ListedUserTeam | null) => void
syncSession?: boolean
allowClear?: boolean
className?: string
align?: "center" | "end" | "start"
trigger?: ReactElement
}
export function TeamSwitcher({
organizationId,
teamId,
onTeamChange,
syncSession = false,
allowClear = true,
className,
align,
trigger
}: TeamSwitcherProps) {
const { authClient } = useAuth<OrganizationTeamsAuthClient>()
const { localization } = useAuthPlugin(organizationPlugin)
const [open, setOpen] = useState(false)
const teams = useListUserTeams(authClient, {
query: { organizationId }
})
const setActiveTeam = useSetActiveTeam(authClient)
const selectedTeam = teams.data?.find((team) => team.id === teamId)
function selectTeam(team: ListedUserTeam | null) {
onTeamChange?.(team)
setOpen(false)
if (syncSession) {
setActiveTeam.mutate({ organizationId, teamId: team?.id ?? null })
}
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
{trigger ? (
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
) : (
<DropdownMenuTrigger
className={cn(
buttonVariants({ variant: "outline" }),
"justify-between gap-3",
className
)}
disabled={teams.isPending || setActiveTeam.isPending}
>
<span className="flex min-w-0 items-center gap-2">
<Users className="size-4 shrink-0 text-muted-foreground" />
{teams.isPending ? (
<Skeleton className="h-4 w-24" />
) : (
<span className="truncate">
{selectedTeam?.name ?? localization.selectTeam}
</span>
)}
</span>
<ChevronsUpDown className="size-4 shrink-0 text-muted-foreground" />
</DropdownMenuTrigger>
)}
<DropdownMenuContent align={align} className="min-w-56">
{allowClear && (
<DropdownMenuItem onClick={() => selectTeam(null)}>
<span className="min-w-0 flex-1 truncate">
{localization.allTeams}
</span>
{!teamId && <Check className="size-4" />}
</DropdownMenuItem>
)}
{teams.data?.map((team) => (
<DropdownMenuItem key={team.id} onClick={() => selectTeam(team)}>
<span className="min-w-0 flex-1 truncate">{team.name}</span>
{team.id === teamId && <Check className="size-4" />}
</DropdownMenuItem>
))}
{!teams.isPending && teams.data?.length === 0 && (
<div className="px-2 py-1.5 text-sm text-muted-foreground">
{localization.noTeams}
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}
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/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/components/auth/user/user-view.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 { Skeleton } from "@/components/ui/skeleton"
import { cn } from "cn"
import { UserAvatar } from "./user-avatar"
export type UserViewProps = {
className?: string
isPending?: boolean
/**
* When true, the subtitle line (email when name/username is shown) is hidden.
* @default false
*/
hideSubtitle?: boolean
/** @remarks `User` */
user?: Partial<User> & {
username?: string | null
displayUsername?: string | null
}
}
/**
* Render a compact user item with an avatar, a primary label (display username, name, or email), and an optional subtitle (email).
*
* @param isPending - If true and no `user` prop is provided, renders a loading skeleton instead of user details
* @param className - Additional CSS classes applied to the outer container
* @param hideSubtitle - When true, omits the muted subtitle row under the primary label
* @param user - Optional user object to display; when omitted the current session user is used
* @returns A React element showing the user's avatar with their identifying information
*/
export function UserView({
className,
isPending,
hideSubtitle = false,
user
}: UserViewProps) {
const { authClient } = useAuth<UsernameAuthClient>()
const { data: session, isPending: sessionPending } = useSession(authClient, {
enabled: !user && !isPending
})
const resolvedUser = user ?? session?.user
if ((isPending || sessionPending) && !user) {
return (
<div className={cn("flex items-center gap-2 min-w-0", className)}>
<UserAvatar isPending />
<div className="grid flex-1 gap-1 text-left text-sm">
<Skeleton className="h-4 w-24" />
{!hideSubtitle && <Skeleton className="h-3 w-32" />}
</div>
</div>
)
}
return (
<div className={cn("flex items-center gap-2 min-w-0", className)}>
<UserAvatar user={resolvedUser as User | undefined} />
<div className="grid min-w-0 flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium text-foreground">
{resolvedUser?.displayUsername ||
resolvedUser?.name ||
resolvedUser?.email}
</span>
{!hideSubtitle &&
(resolvedUser?.displayUsername || resolvedUser?.name) && (
<span className="text-muted-foreground truncate text-xs">
{resolvedUser?.email}
</span>
)}
</div>
</div>
)
}
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
}
]
}
}
)