shadimage
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 · light
iconslucide-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
        }
      ]
    }
  }
)

More from better-auth-ui

Browse all components in this library →