better-auth-ui
componentssosource: ok · refreshed
SSO and Email-First Sign In
Discovers organization SSO from an email address and manages organization identity providers and domain verification.
npx shadcn add "https://better-auth-ui.com/r/{style}/sso.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
Registry dependencies
alertalert-dialogavatarbadgebuttoncalendarcardcheckboxcomboboxdialogemptyfieldhttps://better-auth-ui.com/r/radix-nova/sign-in.jsoninputinput-groupitempopoverselectskeletonslidersonnerspinnerswitchtabstextarea
Files31 · source cached 10/8/2026, 7:44:33 AM
examples/start-shadcn-example/src/components/auth/additional-field.tsx
"use client"
import {
type AdditionalField as AdditionalFieldConfig,
type AdditionalFieldFormValue,
getFormFieldErrors,
resolveInputType
} from "@better-auth-ui/core"
import { useAuth, useCopyToClipboard } from "@better-auth-ui/react"
import { format } from "date-fns"
import { CalendarIcon, Check, ChevronDownIcon, Copy } from "lucide-react"
import { type ComponentType, useRef, useState } from "react"
import { toast } from "sonner"
import { buttonVariants } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import { Checkbox } from "@/components/ui/checkbox"
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList
} from "@/components/ui/combobox"
import {
Field,
FieldContent,
FieldError,
FieldLabel
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput
} from "@/components/ui/input-group"
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@/components/ui/popover"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select"
import { Slider } from "@/components/ui/slider"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { cn } from "cn"
export type AdditionalFieldProps = {
name: string
field: AdditionalFieldConfig
value: AdditionalFieldFormValue
onBlur: () => void
onChange: (value: AdditionalFieldFormValue) => void
isInvalid?: boolean
errors?: unknown[]
isPending?: boolean
/** Complete suffix appended to labels for fields that are not required. */
optionalLabel?: string
}
function valueToString(value: AdditionalFieldFormValue) {
if (value == null) return ""
return value instanceof Date ? value.toISOString() : String(value)
}
/** Convert a `defaultValue` into a `Date` for the calendar. */
function toDate(value: unknown): Date | undefined {
if (value instanceof Date) return value
if (typeof value === "string") {
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? undefined : parsed
}
return undefined
}
/** Format a Date as `HH:mm:ss` for an `<input type="time">`. */
function formatTime(date: Date) {
const pad = (n: number) => n.toString().padStart(2, "0")
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
/**
* Icon-only copy button used as an `InputGroupAddon`. `getValue` is invoked
* lazily on click so the button copies the input's *live* value rather than a
* stale snapshot — important when paired with editable inputs.
*/
function CopyButton({
getValue,
isDisabled
}: {
getValue: () => string | undefined
isDisabled?: boolean
}) {
const { localization } = useAuth()
const { copied, copy } = useCopyToClipboard({
onError: (error) => {
console.error("[Better Auth UI] Copy failed", error)
toast.error(localization.errors.copyFailed)
}
})
async function handleCopy() {
const value = getValue()
if (!value) return
await copy(value)
}
return (
<InputGroupButton
aria-label={
copied
? localization.settings.copiedToClipboard
: localization.settings.copyToClipboard
}
title={
copied
? localization.settings.copiedToClipboard
: localization.settings.copyToClipboard
}
onClick={handleCopy}
disabled={isDisabled}
>
{copied ? <Check /> : <Copy />}
</InputGroupButton>
)
}
/** Renders a single additional user field via shadcn primitives. */
export function AdditionalField({
name,
field: configuredField,
value,
onBlur,
onChange,
isInvalid,
errors,
isPending,
optionalLabel
}: AdditionalFieldProps) {
const field =
optionalLabel && !configuredField.required
? {
...configuredField,
label: (
<>
{configuredField.label}
{optionalLabel}
</>
)
}
: configuredField
const inputType = resolveInputType(field)
const fieldErrors = getFormFieldErrors(errors ?? [])
if (field.render) {
const FieldRenderer = field.render as ComponentType<AdditionalFieldProps>
return (
<FieldRenderer
name={name}
field={field}
value={value}
onBlur={onBlur}
onChange={onChange}
isInvalid={isInvalid}
errors={errors}
isPending={isPending}
optionalLabel={optionalLabel}
/>
)
}
if (inputType === "hidden") {
return (
<input type="hidden" name={name} value={valueToString(value)} readOnly />
)
}
if (inputType === "textarea") {
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={name}>{field.label}</FieldLabel>
<Textarea
id={name}
name={name}
value={valueToString(value)}
onBlur={onBlur}
onChange={(event) => onChange(event.target.value || null)}
placeholder={field.placeholder}
required={field.required}
readOnly={field.readOnly}
disabled={isPending}
aria-invalid={isInvalid}
/>
<FieldError errors={fieldErrors} />
</Field>
)
}
if (inputType === "number") {
const maxFractionDigits = field.formatOptions?.maximumFractionDigits
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={name}>{field.label}</FieldLabel>
<Input
id={name}
name={name}
type="number"
inputMode={maxFractionDigits ? "decimal" : "numeric"}
min={field.min}
max={field.max}
step={
field.step ??
(maxFractionDigits ? 1 / 10 ** maxFractionDigits : undefined)
}
value={typeof value === "number" ? value : ""}
onBlur={onBlur}
onChange={(event) =>
onChange(
event.target.value === "" ? null : event.target.valueAsNumber
)
}
placeholder={field.placeholder}
required={field.required}
readOnly={field.readOnly}
disabled={isPending}
aria-invalid={isInvalid}
/>
<FieldError errors={fieldErrors} />
</Field>
)
}
if (inputType === "slider") {
return (
<SliderField
name={name}
field={field}
value={value}
onBlur={onBlur}
onChange={onChange}
isInvalid={isInvalid}
errors={errors}
isPending={isPending}
/>
)
}
if (inputType === "switch") {
return (
<Field data-invalid={isInvalid} orientation="horizontal">
<Switch
id={name}
name={name}
checked={value === true}
onBlur={onBlur}
onCheckedChange={onChange}
disabled={isPending || field.readOnly}
aria-invalid={isInvalid}
/>
<FieldContent>
<FieldLabel htmlFor={name}>{field.label}</FieldLabel>
</FieldContent>
<FieldError errors={fieldErrors} />
</Field>
)
}
if (inputType === "checkbox") {
return (
<Field data-invalid={isInvalid} orientation="horizontal">
<Checkbox
id={name}
name={name}
checked={value === true}
onBlur={onBlur}
onCheckedChange={(checked) => onChange(checked === true)}
required={field.required}
disabled={isPending || field.readOnly}
aria-invalid={isInvalid}
/>
<FieldContent>
<FieldLabel htmlFor={name}>{field.label}</FieldLabel>
</FieldContent>
<FieldError errors={fieldErrors} />
</Field>
)
}
if (inputType === "select") {
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={name}>{field.label}</FieldLabel>
<Select
name={name}
value={valueToString(value) || undefined}
onValueChange={(nextValue) => onChange(nextValue)}
required={field.required}
disabled={isPending || field.readOnly}
>
<SelectTrigger
id={name}
className="w-full"
onBlur={onBlur}
aria-invalid={isInvalid}
>
<SelectValue placeholder={field.placeholder} />
</SelectTrigger>
<SelectContent>
{field.options?.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={fieldErrors} />
</Field>
)
}
if (inputType === "combobox") {
const selectedOption = field.options?.find(
(option) => option.value === valueToString(value)
)
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={name}>{field.label}</FieldLabel>
<Combobox
items={field.options ?? []}
name={name}
value={selectedOption ?? null}
onValueChange={(option) => onChange(option?.value ?? null)}
required={field.required}
disabled={isPending || field.readOnly}
>
<ComboboxInput
placeholder={field.placeholder}
id={name}
onBlur={onBlur}
aria-invalid={isInvalid}
/>
<ComboboxContent>
<ComboboxEmpty>No items found.</ComboboxEmpty>
<ComboboxList>
{(option) => (
<ComboboxItem key={option.value} value={option}>
{option.label}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<FieldError errors={fieldErrors} />
</Field>
)
}
if (inputType === "date" || inputType === "datetime") {
return (
<DateInput
name={name}
field={field}
value={value}
onBlur={onBlur}
onChange={onChange}
isInvalid={isInvalid}
errors={errors}
isPending={isPending}
/>
)
}
return (
<InputField
name={name}
field={field}
value={value}
onBlur={onBlur}
onChange={onChange}
isInvalid={isInvalid}
errors={errors}
isPending={isPending}
/>
)
}
function InputField({
name,
field,
value,
onBlur,
onChange,
isInvalid,
errors,
isPending
}: AdditionalFieldProps) {
const inputRef = useRef<HTMLInputElement>(null)
const fieldErrors = getFormFieldErrors(errors ?? [])
const hasPrefix = field.prefix != null
const hasSuffix = field.suffix != null || field.copyable
const isNumeric = field.type === "number"
const maxFractionDigits = field.formatOptions?.maximumFractionDigits
const nativeInputType = isNumeric ? "number" : undefined
const nativeInputMode = isNumeric
? maxFractionDigits
? "decimal"
: "numeric"
: undefined
const nativeStep = maxFractionDigits ? 1 / 10 ** maxFractionDigits : undefined
if (hasPrefix || hasSuffix) {
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={name}>{field.label}</FieldLabel>
<InputGroup>
{hasPrefix && (
<InputGroupAddon align="inline-start">
{field.prefix}
</InputGroupAddon>
)}
<InputGroupInput
ref={inputRef}
id={name}
name={name}
type={nativeInputType}
inputMode={nativeInputMode}
step={nativeStep}
value={valueToString(value)}
onBlur={onBlur}
onChange={(event) => onChange(event.target.value || null)}
placeholder={field.placeholder}
required={field.required}
readOnly={field.readOnly}
disabled={isPending}
aria-invalid={isInvalid}
/>
{field.copyable ? (
<InputGroupAddon align="inline-end">
<CopyButton
getValue={() => inputRef.current?.value}
isDisabled={isPending}
/>
</InputGroupAddon>
) : (
field.suffix != null && (
<InputGroupAddon align="inline-end">
{field.suffix}
</InputGroupAddon>
)
)}
</InputGroup>
<FieldError errors={fieldErrors} />
</Field>
)
}
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={name}>{field.label}</FieldLabel>
<Input
id={name}
name={name}
type={nativeInputType}
inputMode={nativeInputMode}
step={nativeStep}
value={valueToString(value)}
onBlur={onBlur}
onChange={(event) => onChange(event.target.value || null)}
placeholder={field.placeholder}
required={field.required}
readOnly={field.readOnly}
disabled={isPending}
aria-invalid={isInvalid}
/>
<FieldError errors={fieldErrors} />
</Field>
)
}
/**
* Slider field. Radix Slider doesn't render the current value, so we render
* it next to the label and control the state to keep the displayed value in
* sync. The selected value is submitted via the underlying Radix `name` prop.
*/
function SliderField({
name,
field,
value,
onBlur,
onChange,
isInvalid,
errors,
isPending
}: AdditionalFieldProps) {
const maxFractionDigits = field.formatOptions?.maximumFractionDigits
const min = field.min ?? 0
const max = field.max ?? 100
const step =
field.step ?? (maxFractionDigits ? 1 / 10 ** maxFractionDigits : 1)
const numericValue = typeof value === "number" ? value : min
const fieldErrors = getFormFieldErrors(errors ?? [])
const formatter = new Intl.NumberFormat(undefined, field.formatOptions)
return (
<Field data-invalid={isInvalid}>
<div className="flex items-center justify-between gap-2">
<FieldLabel htmlFor={name}>{field.label}</FieldLabel>
<span className="text-sm text-muted-foreground tabular-nums">
{formatter.format(numericValue)}
</span>
</div>
<Slider
id={name}
name={name}
value={[numericValue]}
onBlur={onBlur}
onValueChange={(nextValue) =>
onChange((Array.isArray(nextValue) ? nextValue[0] : nextValue) ?? min)
}
min={min}
max={max}
step={step}
disabled={isPending || field.readOnly}
aria-invalid={isInvalid}
/>
<FieldError errors={fieldErrors} />
</Field>
)
}
/**
* Date / datetime input. Composes `Popover` + `Calendar` for the date and
* (optionally) `<input type="time">` for the time. Submits the combined ISO
* value via a hidden `<input>` so it shows up in `FormData`.
*/
function DateInput({
name,
field,
value,
onBlur,
onChange,
isInvalid,
errors,
isPending
}: AdditionalFieldProps) {
const { localization } = useAuth()
const inputType = resolveInputType(field)
const isDateTime = inputType === "datetime"
const fieldErrors = getFormFieldErrors(errors ?? [])
const date = toDate(value)
const [time, setTime] = useState<string>(
isDateTime && date ? formatTime(date) : ""
)
const [open, setOpen] = useState(false)
// Compose the hidden form value: ISO date for "date", ISO datetime for
// "datetime" (date + time).
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={`${name}-date`}>{field.label}</FieldLabel>
<div className="relative flex gap-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
type="button"
id={`${name}-date`}
data-empty={!date}
aria-invalid={isInvalid}
aria-required={field.required}
onBlur={onBlur}
disabled={isPending || field.readOnly}
className={cn(
buttonVariants({ variant: "outline" }),
"flex-1 justify-between font-normal",
"data-[empty=true]:text-muted-foreground"
)}
>
{date ? format(date, "PPP") : <span>{field.placeholder}</span>}
{isDateTime ? <ChevronDownIcon /> : <CalendarIcon />}
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
<Calendar
mode="single"
selected={date}
defaultMonth={date}
captionLayout="dropdown"
onSelect={(value) => {
if (!value) {
onChange(null)
} else {
const nextValue = new Date(value)
if (isDateTime && time.trim()) {
const [hours = "0", minutes = "0", seconds = "0"] =
time.split(":")
nextValue.setHours(
Number(hours),
Number(minutes),
Number(seconds),
0
)
} else {
nextValue.setHours(0, 0, 0, 0)
}
onChange(nextValue)
}
if (!isDateTime) setOpen(false)
}}
/>
</PopoverContent>
</Popover>
{isDateTime && (
<Field className="w-32">
<FieldLabel htmlFor={`${name}-time`} className="sr-only">
{localization.settings.time}
</FieldLabel>
<Input
type="time"
id={`${name}-time`}
step="1"
value={time}
onChange={(event) => {
const nextTime = event.target.value
setTime(nextTime)
if (!date) return
const nextValue = new Date(date)
const [hours = "0", minutes = "0", seconds = "0"] =
nextTime.split(":")
nextValue.setHours(
Number(hours),
Number(minutes),
Number(seconds),
0
)
onChange(nextValue)
}}
disabled={isPending || field.readOnly}
className="appearance-none bg-background [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
/>
</Field>
)}
</div>
<FieldError errors={fieldErrors} />
</Field>
)
}
examples/start-shadcn-example/src/components/auth/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/last-login-method/last-used-badge.tsx
"use client"
import { useLastLoginMethod } from "@better-auth-ui/react"
import { Badge } from "@/components/ui/badge"
export type LastUsedBadgeProps = {
/** Login method IDs that should display the indicator. */
method: string | string[]
/** Use the shorter label in constrained layouts. */
compact?: boolean
/** Float the compact indicator over the top-right edge of its container. */
floating?: boolean
}
/**
* Displays an indicator when one of the supplied method IDs matches Better
* Auth's stored last login method.
*/
export function LastUsedBadge({
method,
compact,
floating
}: LastUsedBadgeProps) {
const { method: lastLoginMethod, localization } = useLastLoginMethod()
const methods = Array.isArray(method) ? method : [method]
if (!lastLoginMethod || !methods.includes(lastLoginMethod)) return null
return (
<Badge
className={
floating
? "pointer-events-none absolute top-0 right-0 z-10 translate-x-1/4 -translate-y-1/2 shadow-sm"
: undefined
}
variant="secondary"
>
{compact || floating ? localization.lastUsedShort : localization.lastUsed}
</Badge>
)
}
examples/start-shadcn-example/src/components/auth/organization/accept-invitation.tsx
"use client"
import { getSafeRedirectTo } from "@better-auth-ui/core"
import type { OrganizationAuthClient } from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthenticate, useAuthPlugin } from "@better-auth-ui/react"
import {
useAcceptInvitation,
useInvitation,
useRejectInvitation
} from "@better-auth-ui/react/plugins/organization"
import type { Invitation } from "better-auth/client"
import { BriefcaseBusiness } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle
} from "@/components/ui/card"
import { FieldDescription } from "@/components/ui/field"
import { Skeleton } from "@/components/ui/skeleton"
import { Spinner } from "@/components/ui/spinner"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { UserAvatar } from "../user/user-avatar"
import { cn } from "cn"
import { useIsHydrated } from "../use-is-hydrated"
type UserInvitation = Invitation & { organizationName?: string }
export type AcceptInvitationProps = {
className?: string
}
function isPendingInvitation(invitation: UserInvitation | undefined) {
if (invitation?.status !== "pending") return false
return new Date(invitation.expiresAt).getTime() > Date.now()
}
/**
* Render the organization invitation addressed by the `invitationId` query
* parameter and let the signed-in recipient accept or reject it directly.
*/
export function AcceptInvitation({ className }: AcceptInvitationProps) {
const { authClient, navigate, redirectTo } = useAuth()
const { localization, roles } = useAuthPlugin(organizationPlugin)
const organizationAuthClient = authClient as OrganizationAuthClient
const isHydrated = useIsHydrated()
const invitationId = isHydrated
? new URLSearchParams(window.location.search).get("invitationId")
: null
const session = useAuthenticate(organizationAuthClient)
const invitationQuery = useInvitation(organizationAuthClient, {
query: { id: invitationId ?? "" },
enabled: Boolean(invitationId)
})
const invitation = invitationQuery.data as UserInvitation | undefined
const returnToApplication = () => {
navigate({
to: getSafeRedirectTo(redirectTo, window.location.origin),
replace: true
})
}
const { mutate: acceptInvitation, isPending: isAccepting } =
useAcceptInvitation(organizationAuthClient, {
onSuccess: returnToApplication
})
const { mutate: rejectInvitation, isPending: isRejecting } =
useRejectInvitation(organizationAuthClient, {
onSuccess: returnToApplication
})
const isLoading =
!isHydrated ||
session.isPending ||
!session.data ||
(Boolean(invitationId) && invitationQuery.isPending)
const isAvailable = isPendingInvitation(invitation)
const organizationName =
invitation?.organizationName || localization.organization
const role = invitation
? (roles?.[invitation.role] ?? invitation.role)
: localization.member
return (
<Card
className={cn("w-full max-w-lg gap-0 overflow-hidden p-0", className)}
>
<CardHeader className="grid justify-items-center gap-5 p-6 text-center">
<div className="flex size-16 items-center justify-center rounded-full bg-muted/50">
<BriefcaseBusiness aria-hidden="true" className="size-7" />
</div>
<div className="grid gap-1">
<CardTitle className="max-w-full break-words text-xl font-semibold">
{isLoading ? (
<Skeleton className="h-6 w-48" />
) : isAvailable ? (
organizationName
) : (
localization.invitationUnavailable
)}
</CardTitle>
<CardDescription>
{localization.acceptInvitationTitle}
</CardDescription>
</div>
</CardHeader>
<CardContent className="px-4 pb-6 sm:px-6">
<div className="grid gap-5 rounded-lg bg-muted/50 p-5">
{isLoading ? (
<Skeleton className="h-10 w-full" />
) : isAvailable ? (
<>
<FieldDescription>
{localization.acceptInvitationDescription
.replace("{{organization}}", organizationName)
.replace("{{role}}", role)}
</FieldDescription>
<dl className="grid gap-1">
<dt className="text-sm text-muted-foreground">
{localization.role}
</dt>
<dd className="text-base font-medium">{role}</dd>
</dl>
</>
) : (
<FieldDescription>
{localization.invitationUnavailableDescription}
</FieldDescription>
)}
<div className="flex min-w-0 items-center gap-3">
<UserAvatar
user={session.data?.user}
isPending={session.isPending}
/>
{session.data ? (
<div className="grid min-w-0 gap-0.5">
<p className="break-words text-sm font-medium">
{session.data.user.name || session.data.user.email}
</p>
{session.data.user.name ? (
<p className="break-all text-sm text-muted-foreground">
{session.data.user.email}
</p>
) : null}
</div>
) : (
<Skeleton className="h-4 w-40" />
)}
</div>
</div>
</CardContent>
<CardFooter className="grid grid-cols-2 gap-3 p-4 sm:p-6">
{isLoading ? (
<>
<Skeleton className="h-11 w-full" />
<Skeleton className="h-11 w-full" />
</>
) : isAvailable && invitation ? (
<>
<Button
className="h-11 w-full"
variant="secondary"
disabled={isAccepting || isRejecting}
onClick={() => rejectInvitation({ invitationId: invitation.id })}
>
{isRejecting ? <Spinner /> : null}
{localization.rejectInvitation}
</Button>
<Button
className="h-11 w-full"
disabled={isAccepting || isRejecting}
onClick={() => acceptInvitation({ invitationId: invitation.id })}
>
{isAccepting ? <Spinner /> : null}
{localization.accept}
</Button>
</>
) : (
<Button
className="col-span-2 h-11 w-full"
onClick={returnToApplication}
>
{localization.return}
</Button>
)}
</CardFooter>
</Card>
)
}
examples/start-shadcn-example/src/components/auth/organization/create-organization-dialog.tsx
"use client"
import {
getAdditionalFieldDefaultValues,
getAdditionalFieldSubmitValues,
validateStringLength
} from "@better-auth-ui/core"
import type { OrganizationAuthClient } from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import { useCreateOrganization } from "@better-auth-ui/react/plugins/organization"
import { Briefcase } from "lucide-react"
import { useEffect, useRef, useState } from "react"
import { buttonVariants } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from "@/components/ui/dialog"
import { Field, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import {
getAuthAdditionalFieldValidators,
isAuthFormFieldInvalid,
useAuthForm
} from "../auth-form"
import { SlugField, sanitizeSlug } from "./slug-field"
/** Props for the `CreateOrganizationDialog` component. */
export type CreateOrganizationDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
hideSlug?: boolean
}
export function CreateOrganizationDialog({
open,
onOpenChange,
hideSlug: hideSlugProp
}: CreateOrganizationDialogProps) {
const { authClient, localization } = useAuth<OrganizationAuthClient>()
const {
additionalFields,
localization: organizationLocalization,
hideSlug: pluginHideSlug
} = useAuthPlugin(organizationPlugin)
const hideSlug = hideSlugProp ?? pluginHideSlug ?? false
const [slugEdited, setSlugEdited] = useState(false)
const submissionGeneration = useRef(0)
const submissionAttemptGeneration = useRef(0)
const { mutateAsync: createOrganization } = useCreateOrganization(authClient)
const form = useAuthForm({
defaultValues: {
additionalFields: getAdditionalFieldDefaultValues(additionalFields),
name: "",
slug: ""
},
onSubmit: async ({ value }) => {
const generation = submissionAttemptGeneration.current
if (generation !== submissionGeneration.current) return
await createOrganization({
...getAdditionalFieldSubmitValues(
additionalFields,
value.additionalFields
),
name: value.name,
slug: hideSlug ? undefined : value.slug
})
if (generation === submissionGeneration.current) onOpenChange(false)
}
})
useEffect(() => {
if (!open) {
submissionGeneration.current += 1
form.reset()
setSlugEdited(false)
}
}, [form, open])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<form.AppForm>
<form.AuthFormRoot
className="flex flex-col gap-6"
onBeforeSubmit={() => {
submissionAttemptGeneration.current = submissionGeneration.current
}}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Briefcase />
{organizationLocalization.createOrganization}
</DialogTitle>
<DialogDescription>
{organizationLocalization.organizationsDescription}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<form.AppField
name="name"
validators={{
onChange: ({ value }) =>
validateStringLength(value, {
requiredMessage: localization.auth.fieldRequired,
trim: true
})
}}
>
{(field) => {
const isInvalid = isAuthFormFieldInvalid(field.state.meta)
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor="create-organization-name">
{organizationLocalization.name}
</FieldLabel>
<Input
id="create-organization-name"
name={field.name}
autoFocus
placeholder={organizationLocalization.namePlaceholder}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => {
const value = event.target.value
field.handleChange(value)
if (!slugEdited) {
form.setFieldValue("slug", sanitizeSlug(value))
}
}}
aria-invalid={isInvalid}
/>
<field.AuthFormFieldError />
</Field>
)
}}
</form.AppField>
{!hideSlug && (
<form.AppField name="slug">
{(field) => (
<SlugField
id="create-organization-slug"
value={field.state.value}
onChange={(value) => {
field.handleChange(value)
setSlugEdited(true)
}}
/>
)}
</form.AppField>
)}
{additionalFields.map((configuredField) => (
<form.AppField
key={configuredField.name}
name={`additionalFields.${configuredField.name}`}
validators={getAuthAdditionalFieldValidators(
configuredField,
localization.auth.fieldRequired
)}
>
{(field) => (
<field.AuthFormAdditionalField
field={configuredField}
optionalLabel={localization.settings.optional}
/>
)}
</form.AppField>
))}
</div>
<DialogFooter>
<DialogClose
className={buttonVariants({ variant: "outline" })}
type="button"
>
{localization.settings.cancel}
</DialogClose>
<form.AuthFormSubmitButton>
{organizationLocalization.createOrganization}
</form.AuthFormSubmitButton>
</DialogFooter>
</form.AuthFormRoot>
</form.AppForm>
</DialogContent>
</Dialog>
)
}
examples/start-shadcn-example/src/components/auth/organization/organization-logo.tsx
"use client"
import type { Organization } from "better-auth/client"
import { Briefcase } from "lucide-react"
import type { ComponentProps, CSSProperties, ReactNode } from "react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "cn"
export type OrganizationLogoSize = "sm" | "md" | "lg"
export type OrganizationLogoProps = {
className?: string
fallback?: ReactNode
isPending?: boolean
organization?: Partial<Organization> | null
size?: OrganizationLogoSize
}
const sizeClasses: Record<OrganizationLogoSize, string> = {
sm: "size-8",
md: "size-10",
lg: "size-12"
}
const fallbackTextClasses: Record<OrganizationLogoSize, string> = {
sm: "text-sm",
md: "text-base",
lg: "text-xl"
}
export function OrganizationLogo({
className,
fallback,
isPending,
organization,
size = "sm",
style,
...props
}: OrganizationLogoProps &
Omit<ComponentProps<typeof Avatar>, "children" | "size" | "style"> & {
style?: CSSProperties
}) {
if (isPending && !organization) {
return (
<Skeleton
className={cn("rounded-full", sizeClasses[size], className)}
style={style}
/>
)
}
const initials = organization?.name?.slice(0, 2).toUpperCase()
const normalizedLogo = organization?.logo?.trim() || undefined
return (
<Avatar
className={cn("rounded-full", sizeClasses[size], className)}
style={style}
{...props}
>
<AvatarImage
alt={organization?.name ?? "Organization"}
src={normalizedLogo}
/>
<AvatarFallback
className={cn("text-muted-foreground!", fallbackTextClasses[size])}
>
{fallback || initials || <Briefcase className="size-4" />}
</AvatarFallback>
</Avatar>
)
}
examples/start-shadcn-example/src/components/auth/organization/organization-row.tsx
"use client"
import type { OrganizationAuthClient } from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import { useSetActiveOrganization } from "@better-auth-ui/react/plugins/organization"
import type { Organization } from "better-auth/client"
import { Settings as SettingsIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Item, ItemActions } from "@/components/ui/item"
import { Spinner } from "@/components/ui/spinner"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { OrganizationView } from "./organization-view"
export type OrganizationRowProps = {
organization: Organization
}
/**
* Single organization row: logo and labels via `OrganizationView`, plus a Manage action.
*/
export function OrganizationRow({ organization }: OrganizationRowProps) {
const { authClient, basePaths, navigate } = useAuth<OrganizationAuthClient>()
const {
localization: organizationLocalization,
viewPaths: organizationViewPaths,
slug,
slugPrefix
} = useAuthPlugin(organizationPlugin)
const { mutate: setActiveOrganization, isPending: setActivePending } =
useSetActiveOrganization(authClient, {
onSuccess: () => {
navigate({
to: `${basePaths.organization}/${organizationViewPaths.organization.settings}`
})
}
})
function manageOrganization() {
if (slug !== undefined) {
navigate({
to: `${basePaths.organization}/${slugPrefix}${organization.slug}/${organizationViewPaths.organization.settings}`
})
} else {
setActiveOrganization({ organizationId: organization.id })
}
}
return (
<Item>
<OrganizationView className="flex-1" organization={organization} />
<ItemActions>
<Button
variant="outline"
size="sm"
disabled={setActivePending}
onClick={manageOrganization}
aria-label={organizationLocalization.manage}
>
{setActivePending ? <Spinner /> : <SettingsIcon />}
{organizationLocalization.manage}
</Button>
</ItemActions>
</Item>
)
}
examples/start-shadcn-example/src/components/auth/organization/organization-view-skeleton.tsx
"use client"
import type { ComponentProps } from "react"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "cn"
import {
OrganizationLogo,
type OrganizationLogoSize
} from "./organization-logo"
export type OrganizationViewSkeletonProps = {
className?: string
hideSlug?: boolean
size?: OrganizationLogoSize
}
/**
* Placeholder matching `OrganizationView` while organization data loads.
*/
export function OrganizationViewSkeleton({
className,
hideSlug,
size = "md",
...props
}: OrganizationViewSkeletonProps & ComponentProps<"div">) {
return (
<div
className={cn("flex min-w-0 items-center gap-2", className)}
{...props}
>
<OrganizationLogo
isPending
className={size === "sm" ? "size-5" : undefined}
size={size === "lg" ? "md" : "sm"}
/>
<div className="flex flex-col min-w-0 gap-1">
<Skeleton className="h-3.5 w-20 rounded-md" />
{!hideSlug && <Skeleton className="h-3 w-28 rounded-md" />}
</div>
</div>
)
}
examples/start-shadcn-example/src/components/auth/organization/organization-view.tsx
"use client"
import type { OrganizationAuthClient } from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin, useSession } from "@better-auth-ui/react"
import {
useActiveOrganization,
useListOrganizationMembers
} from "@better-auth-ui/react/plugins/organization"
import type { Organization } from "better-auth/client"
import type { ComponentProps } from "react"
import { Badge } from "@/components/ui/badge"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { cn } from "cn"
import {
OrganizationLogo,
type OrganizationLogoSize
} from "./organization-logo"
import { OrganizationViewSkeleton } from "./organization-view-skeleton"
export type OrganizationViewProps = {
className?: string
isPending?: boolean
size?: OrganizationLogoSize
hideRole?: boolean
hideSlug?: boolean
organization?: Partial<Organization>
}
/**
* Compact organization row: logo, primary name, secondary slug — analogous to `UserView`.
*/
export function OrganizationView({
className,
isPending,
size = "md",
hideSlug: hideSlugProp,
hideRole,
organization,
...props
}: OrganizationViewProps & ComponentProps<"div">) {
const { authClient } = useAuth<OrganizationAuthClient>()
const {
roles,
slugPrefix,
hideSlug: pluginHideSlug
} = useAuthPlugin(organizationPlugin)
const hideSlug = hideSlugProp ?? pluginHideSlug ?? false
const { data: session } = useSession(authClient)
const { data: activeOrganization, isPending: activeOrganizationPending } =
useActiveOrganization(authClient, {
enabled: !organization && !isPending
})
const resolvedOrganization = organization ?? activeOrganization
const { data: membersList, isPending: membersPending } =
useListOrganizationMembers(authClient, {
query: {
organizationId: resolvedOrganization?.id
},
enabled: !!resolvedOrganization?.id && !hideRole
})
const membership = membersList?.members?.find(
(member) => member.userId === session?.user.id
)
if (
isPending ||
(!organization && activeOrganizationPending) ||
(!hideRole && !!resolvedOrganization?.id && membersPending)
) {
return (
<OrganizationViewSkeleton
className={className}
hideSlug={hideSlug}
size={size}
{...props}
/>
)
}
return (
<div
className={cn("flex min-w-0 items-center gap-2", className)}
{...props}
>
<OrganizationLogo
organization={resolvedOrganization}
className={size === "sm" ? "size-5" : undefined}
size={size === "lg" ? "md" : "sm"}
/>
<div className="flex min-w-0 flex-col">
<div className="flex min-w-0 items-center gap-2">
<p className="truncate text-sm font-medium leading-tight text-foreground">
{resolvedOrganization?.name}
</p>
{!hideRole && !!membership && (
<Badge variant="secondary" className="-my-0.5 shrink-0">
{roles?.[membership.role] ?? membership.role}
</Badge>
)}
</div>
{!hideSlug && !!resolvedOrganization?.slug && (
<p className="truncate overflow-x-hidden text-muted-foreground text-xs font-mono leading-tight">
{slugPrefix}
{resolvedOrganization.slug}
</p>
)}
</div>
</div>
)
}
examples/start-shadcn-example/src/components/auth/organization/organizations-empty.tsx
"use client"
import { useAuthPlugin } from "@better-auth-ui/react"
import { Briefcase } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle
} from "@/components/ui/empty"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
export type OrganizationsEmptyProps = {
onCreatePress: () => void
canCreate?: boolean
}
export function OrganizationsEmpty({
onCreatePress,
canCreate = true
}: OrganizationsEmptyProps) {
const { localization: organizationLocalization } =
useAuthPlugin(organizationPlugin)
return (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<Briefcase />
</EmptyMedia>
<EmptyTitle>{organizationLocalization.noOrganizations}</EmptyTitle>
<EmptyDescription>
{organizationLocalization.organizationsDescription}
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button size="sm" disabled={!canCreate} onClick={onCreatePress}>
{organizationLocalization.createOrganization}
</Button>
</EmptyContent>
</Empty>
)
}
examples/start-shadcn-example/src/components/auth/organization/organizations-settings.tsx
"use client"
import type { ComponentProps } from "react"
import { cn } from "cn"
import { Organizations } from "./organizations"
import { UserInvitations } from "./user-invitations"
export type OrganizationsSettingsProps = {
className?: string
}
/**
* Renders the organizations settings panel.
*
* Displays all organizations the user belongs to with an empty state and
* create button, followed by a card for invitations to the user.
*/
export function OrganizationsSettings({
className,
...props
}: OrganizationsSettingsProps & ComponentProps<"div">) {
return (
<div
className={cn("flex w-full flex-col gap-4 md:gap-6", className)}
{...props}
>
<Organizations />
<UserInvitations />
</div>
)
}
examples/start-shadcn-example/src/components/auth/organization/organizations.tsx
"use client"
import type { OrganizationAuthClient } from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import { useListOrganizations } from "@better-auth-ui/react/plugins/organization"
import { Fragment, useState } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Item, ItemGroup, ItemSeparator } from "@/components/ui/item"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { CreateOrganizationDialog } from "./create-organization-dialog"
import { OrganizationRow } from "./organization-row"
import { OrganizationViewSkeleton } from "./organization-view-skeleton"
import { OrganizationsEmpty } from "./organizations-empty"
export type OrganizationsProps = {
className?: string
}
/**
* Lists organizations the user belongs to (via `useListOrganizations`): loading skeleton,
* empty state with create, or a card of rows with a Manage control per organization.
* Owns `CreateOrganizationDialog` open state and the create actions.
*/
export function Organizations({ className }: OrganizationsProps) {
const { authClient } = useAuth<OrganizationAuthClient>()
const {
allowOrganizationCreation,
localization: organizationLocalization,
organizationLimit
} = useAuthPlugin(organizationPlugin)
const [createOpen, setCreateOpen] = useState(false)
const { data: organizations, isPending: organizationsPending } =
useListOrganizations(authClient)
const canCreate =
allowOrganizationCreation &&
(organizationLimit === undefined ||
(organizations?.length ?? 0) < organizationLimit)
return (
<>
<div className={className}>
<div className="flex flex-col gap-3">
<div className="flex items-end justify-between gap-3">
<h2 className="truncate text-sm font-semibold">
{organizationLocalization.organizations}
</h2>
{allowOrganizationCreation && (
<Button
className="shrink-0"
size="sm"
disabled={organizationsPending || !canCreate}
onClick={() => setCreateOpen(true)}
>
{organizationLocalization.createOrganization}
</Button>
)}
</div>
<Card className="p-0">
<CardContent className="p-0">
{organizationsPending ? (
<ItemGroup>
<Item>
<OrganizationViewSkeleton />
</Item>
</ItemGroup>
) : !organizations?.length ? (
<OrganizationsEmpty
canCreate={canCreate}
onCreatePress={() => setCreateOpen(true)}
/>
) : (
<ItemGroup className="gap-0">
{organizations.map((organization, index) => (
<Fragment key={organization.id}>
{index > 0 && <ItemSeparator />}
<OrganizationRow organization={organization} />
</Fragment>
))}
</ItemGroup>
)}
</CardContent>
</Card>
</div>
</div>
{canCreate && (
<CreateOrganizationDialog
open={createOpen}
onOpenChange={setCreateOpen}
/>
)}
</>
)
}
examples/start-shadcn-example/src/components/auth/organization/slug-field.tsx
"use client"
import type { OrganizationAuthClient } from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import { useCheckSlug } from "@better-auth-ui/react/plugins/organization"
import { useDebouncer } from "@tanstack/react-pacer"
import { Check, X } from "lucide-react"
import { useEffect, useState } from "react"
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import {
InputGroup,
InputGroupAddon,
InputGroupInput
} from "@/components/ui/input-group"
import { Spinner } from "@/components/ui/spinner"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
/** Props for the `SlugField` component. */
export type SlugFieldProps = {
value: string
onChange: (value: string) => void
currentSlug?: string
disabled?: boolean
id?: string
}
/**
* Sanitize a slug value so it only contains lowercase alphanumeric characters
* and dashes. Runs of disallowed characters are collapsed to a single dash, but
* leading/trailing dashes are preserved while the user is still typing.
*/
export function sanitizeSlug(value: string) {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-")
}
/**
* Organization slug field with debounced availability checking.
*/
export function SlugField({
value,
onChange,
currentSlug,
disabled,
id = "slug"
}: SlugFieldProps) {
const { authClient, localization: authLocalization } =
useAuth<OrganizationAuthClient>()
const {
localization,
checkSlug: checkSlugEnabled,
slugPrefix
} = useAuthPlugin(organizationPlugin)
const [slugError, setSlugError] = useState<string>()
const {
mutate: checkSlug,
data: checkSlugData,
error: checkSlugError,
reset: resetCheckSlug
} = useCheckSlug(authClient)
const debouncer = useDebouncer(
(next: string) => {
if (!checkSlugEnabled || !next.trim() || next.trim() === currentSlug)
return
checkSlug({ slug: next.trim() })
},
{ wait: 500 }
)
useEffect(() => {
// Clear stale validation errors when the controlled value changes
// externally (e.g. the parent resets the form), not just via this
// input's onChange.
setSlugError(undefined)
if (!checkSlugEnabled) return
resetCheckSlug()
debouncer.maybeExecute(value)
}, [checkSlugEnabled, value, debouncer.maybeExecute, resetCheckSlug])
return (
<Field data-invalid={!!slugError}>
<FieldLabel htmlFor={id}>{localization.slug}</FieldLabel>
<InputGroup>
{slugPrefix && (
<InputGroupAddon align="inline-start">{slugPrefix}</InputGroupAddon>
)}
<InputGroupInput
id={id}
name="slug"
value={value}
onChange={(e) => {
onChange(sanitizeSlug(e.target.value))
setSlugError(undefined)
}}
onInvalid={(e) => {
e.preventDefault()
setSlugError(authLocalization.auth.fieldRequired)
}}
aria-invalid={!!slugError}
placeholder={localization.slugPlaceholder}
required
disabled={disabled}
/>
{checkSlugEnabled && !!value.trim() && value.trim() !== currentSlug && (
<InputGroupAddon align="inline-end">
{checkSlugData?.status ? (
<Check className="size-4 text-foreground" />
) : checkSlugError ? (
<X className="size-4 text-destructive" />
) : (
<Spinner />
)}
</InputGroupAddon>
)}
</InputGroup>
<FieldError>{slugError}</FieldError>
</Field>
)
}
examples/start-shadcn-example/src/components/auth/organization/user-invitation-row-skeleton.tsx
"use client"
import { Item, ItemContent, ItemMedia } from "@/components/ui/item"
import { Skeleton } from "@/components/ui/skeleton"
/**
* Placeholder row matching `UserInvitationRow` while invitations load.
*/
export function UserInvitationRowSkeleton() {
return (
<Item>
<ItemMedia>
<Skeleton className="size-10 shrink-0 rounded-md" />
</ItemMedia>
<ItemContent>
<Skeleton className="h-4 w-40 rounded-md" />
<Skeleton className="h-3 w-28 rounded-md" />
</ItemContent>
</Item>
)
}
examples/start-shadcn-example/src/components/auth/organization/user-invitation-row.tsx
"use client"
import type { OrganizationAuthClient } from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import {
useAcceptInvitation,
useRejectInvitation
} from "@better-auth-ui/react/plugins/organization"
import type { Invitation } from "better-auth/client"
import { Check, Clock, X } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle
} from "@/components/ui/item"
import { Spinner } from "@/components/ui/spinner"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
export type UserInvitationRowProps = {
invitation: Invitation & { organizationName?: string }
}
/**
* Single invitation row with accept/reject actions for the current user.
*/
export function UserInvitationRow({ invitation }: UserInvitationRowProps) {
const { authClient } = useAuth<OrganizationAuthClient>()
const { localization: organizationLocalization, roles } =
useAuthPlugin(organizationPlugin)
const { mutate: acceptInvitation, isPending: isAccepting } =
useAcceptInvitation(authClient)
const { mutate: rejectInvitation, isPending: isRejecting } =
useRejectInvitation(authClient)
return (
<Item>
<ItemMedia variant="icon">
<Clock />
</ItemMedia>
<ItemContent>
<ItemTitle>
{invitation.organizationName}
<Badge variant="secondary">
{roles?.[invitation.role] ?? invitation.role}
</Badge>
</ItemTitle>
<ItemDescription>
{new Date(invitation.createdAt).toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short"
})}
</ItemDescription>
</ItemContent>
<ItemActions>
<Button
variant="outline"
size="sm"
disabled={isAccepting || isRejecting}
onClick={() => acceptInvitation({ invitationId: invitation.id })}
>
{isAccepting ? <Spinner /> : <Check />}
{organizationLocalization.accept}
</Button>
<Button
variant="outline"
size="icon"
className="size-8 text-destructive"
disabled={isAccepting || isRejecting}
onClick={() => rejectInvitation({ invitationId: invitation.id })}
aria-label={organizationLocalization.rejectInvitation}
>
{isRejecting ? <Spinner /> : <X />}
</Button>
</ItemActions>
</Item>
)
}
examples/start-shadcn-example/src/components/auth/organization/user-invitations-empty.tsx
"use client"
import { useAuthPlugin } from "@better-auth-ui/react"
import { MailWarning, Send } from "lucide-react"
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle
} from "@/components/ui/empty"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
/**
* Empty state for `UserInvitations`.
*/
export function UserInvitationsEmpty({
verificationRequired = false
}: {
verificationRequired?: boolean
}) {
const { localization: organizationLocalization } =
useAuthPlugin(organizationPlugin)
return (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
{verificationRequired ? <MailWarning /> : <Send />}
</EmptyMedia>
<EmptyTitle>
{verificationRequired
? organizationLocalization.verifyEmailToViewInvitations
: organizationLocalization.noInvitations}
</EmptyTitle>
<EmptyDescription>
{verificationRequired
? organizationLocalization.verifyEmailToViewInvitationsDescription
: organizationLocalization.userInvitationsEmptyDescription}
</EmptyDescription>
</EmptyHeader>
</Empty>
)
}
examples/start-shadcn-example/src/components/auth/organization/user-invitations.tsx
"use client"
import type { OrganizationAuthClient } from "@better-auth-ui/core/plugins/organization"
import { useAuth, useAuthPlugin, useSession } from "@better-auth-ui/react"
import { useListUserInvitations } from "@better-auth-ui/react/plugins/organization"
import { Fragment } from "react"
import { Card, CardContent } from "@/components/ui/card"
import { ItemGroup, ItemSeparator } from "@/components/ui/item"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { UserInvitationRow } from "./user-invitation-row"
import { UserInvitationRowSkeleton } from "./user-invitation-row-skeleton"
import { UserInvitationsEmpty } from "./user-invitations-empty"
export type UserInvitationsProps = {
className?: string
}
/**
* Organization invitations for the signed-in user. Always renders the section
* card; uses `UserInvitationsEmpty` when there are no pending invitations.
*/
export function UserInvitations({ className }: UserInvitationsProps) {
const { authClient } = useAuth<OrganizationAuthClient>()
const { localization: organizationLocalization } =
useAuthPlugin(organizationPlugin)
const session = useSession(authClient)
const emailVerified = session.data?.user.emailVerified === true
const { data: invitations, isPending } = useListUserInvitations(authClient, {
enabled: emailVerified
})
return (
<div className={className}>
<div className="flex flex-col gap-3">
<h2 className="truncate text-sm font-semibold">
{organizationLocalization.invitations}
</h2>
<Card className="p-0">
<CardContent className="p-0">
{session.isPending || (emailVerified && isPending) ? (
<ItemGroup>
<UserInvitationRowSkeleton />
</ItemGroup>
) : !invitations?.length ? (
<UserInvitationsEmpty verificationRequired={!emailVerified} />
) : (
<ItemGroup className="gap-0">
{invitations.map((invitation, index) => (
<Fragment key={invitation.id}>
{index > 0 && <ItemSeparator />}
<UserInvitationRow invitation={invitation} />
</Fragment>
))}
</ItemGroup>
)}
</CardContent>
</Card>
</div>
</div>
)
}
examples/start-shadcn-example/src/components/auth/provider-button.tsx
"use client"
import {
type AuthSocialProvider,
type AuthView,
authMutationKeys,
getProviderId,
getProviderName,
type OAuthPopupAuthClient
} from "@better-auth-ui/core"
import {
renderProviderIcon,
useAuth,
useFetchOptions,
useSignInOAuthPopup,
useSignInSocial
} from "@better-auth-ui/react"
import { useIsMutating } from "@tanstack/react-query"
import type { ComponentProps } from "react"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import { cn } from "cn"
import { LastUsedBadge } from "./last-login-method/last-used-badge"
export type ProviderButtonProps = {
provider: AuthSocialProvider
display?: "full" | "name" | "icon"
view?: AuthView
} & Omit<ComponentProps<typeof Button>, "onClick" | "children" | "disabled">
/**
* Social provider sign-in button.
*
* @param provider - Provider to sign in with.
* @param display - `"full"` (e.g. "Continue with Google"), `"name"` (just the provider name), or `"icon"` (icon only).
*/
export function ProviderButton({
provider,
display = "full",
view = "signIn",
variant = "outline",
className,
...props
}: ProviderButtonProps) {
const {
authClient,
baseURL,
localization,
navigate,
redirectTo,
socialSignInMode
} = useAuth()
const callbackURL = `${baseURL}${redirectTo}`
const { fetchOptions, resetFetchOptions } = useFetchOptions()
const { mutate: signInSocial, isPending: signInSocialPending } =
useSignInSocial(authClient, { onError: resetFetchOptions })
const { mutate: signInPopup, isPending: signInPopupPending } =
useSignInOAuthPopup(authClient as OAuthPopupAuthClient, {
onError: resetFetchOptions
})
const providerId = getProviderId(provider)
const providerIcon = renderProviderIcon(provider)
const signInMutating = useIsMutating({
mutationKey: authMutationKeys.signIn.all
})
const signUpMutating = useIsMutating({
mutationKey: authMutationKeys.signUp.all
})
const isPending = signInMutating + signUpMutating > 0
const handleSignIn = () => {
if (socialSignInMode === "popup") {
signInPopup(
{
provider: providerId,
callbackURL,
requestSignUp: view === "signUp"
},
{ onSuccess: () => navigate({ to: redirectTo }) }
)
return
}
signInSocial({ provider: providerId, callbackURL, fetchOptions })
}
return (
<Button
type="button"
variant={variant}
disabled={isPending}
onClick={handleSignIn}
className={cn("relative overflow-visible", className)}
{...props}
>
{signInSocialPending || signInPopupPending ? <Spinner /> : providerIcon}
{display === "full"
? localization.auth.continueWith.replace(
"{{provider}}",
getProviderName(provider)
)
: display === "name"
? getProviderName(provider)
: null}
{display === "icon" && (
<span className="sr-only">{getProviderName(provider)}</span>
)}
{view !== "signUp" && <LastUsedBadge method={providerId} floating />}
</Button>
)
}
examples/start-shadcn-example/src/components/auth/provider-buttons.tsx
"use client"
import { type AuthView, getProviderId } from "@better-auth-ui/core"
import { useAuth } from "@better-auth-ui/react"
import { useMemo } from "react"
import { cn } from "cn"
import { ProviderButton } from "./provider-button"
export type ProviderButtonsProps = {
socialLayout?: SocialLayout
view?: AuthView
}
export type SocialLayout = "auto" | "horizontal" | "vertical" | "grid"
/**
* Render sign-in buttons for configured social providers. Each button owns its own sign-in mutation
* and reads the shared sign-in pending state from React Query.
*
* @param socialLayout - Preferred layout for the provider buttons; `"auto"` chooses based on the number of providers.
*/
export function ProviderButtons({
socialLayout = "auto",
view = "signIn"
}: ProviderButtonsProps) {
const { socialProviders } = useAuth()
const resolvedSocialLayout = useMemo(() => {
if (socialLayout === "auto") {
if (socialProviders?.length && socialProviders.length >= 4) {
return "horizontal"
}
return "vertical"
}
return socialLayout
}, [socialLayout, socialProviders?.length])
return (
<div
className={cn(
"gap-3",
resolvedSocialLayout === "grid" && "grid grid-cols-2",
resolvedSocialLayout === "vertical" && "flex flex-col",
resolvedSocialLayout === "horizontal" && "flex flex-row flex-wrap"
)}
>
{socialProviders?.map((provider) => (
<ProviderButton
key={getProviderId(provider)}
provider={provider}
view={view}
display={
resolvedSocialLayout === "vertical"
? "full"
: resolvedSocialLayout === "grid"
? "name"
: "icon"
}
className={cn(resolvedSocialLayout === "horizontal" && "flex-1")}
/>
))}
</div>
)
}
examples/start-shadcn-example/src/components/auth/reauthentication.tsx
"use client"
import {
getReauthenticationSignInURL,
isReauthenticationSignInURL
} from "@better-auth-ui/core"
import { useAuth, useSignOut } from "@better-auth-ui/react"
import { useSyncExternalStore } from "react"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import { cn } from "cn"
const subscribeToLocation = () => () => undefined
function useIsReauthenticationSignIn() {
return useSyncExternalStore(
subscribeToLocation,
() => isReauthenticationSignInURL(new URL(window.location.href)),
() => false
)
}
export type ReauthenticationActionProps = {
className?: string
showTitle?: boolean
}
export function ReauthenticationAction({
className,
showTitle = true
}: ReauthenticationActionProps) {
const auth = useAuth()
const signOut = useSignOut(auth.authClient)
const handleReauthentication = () => {
const signInURL = getReauthenticationSignInURL(
new URL(window.location.href),
`${auth.basePaths.auth}/${auth.viewPaths.auth.signIn}`
)
signOut.mutate(undefined, {
onSuccess: () => auth.navigate({ to: signInURL })
})
}
return (
<div className={cn("flex flex-col items-start gap-3 p-4", className)}>
<div className="flex flex-col gap-1">
{showTitle ? (
<h3 className="text-sm font-medium">
{auth.localization.settings.reauthenticationTitle}
</h3>
) : null}
<p className="text-sm text-muted-foreground">
{auth.localization.settings.reauthenticationDescription}
</p>
</div>
<Button
disabled={signOut.isPending}
onClick={handleReauthentication}
size="sm"
>
{signOut.isPending ? <Spinner data-icon="inline-start" /> : null}
{auth.localization.settings.reauthenticationAction}
</Button>
</div>
)
}
export function ReauthenticationNotice() {
const auth = useAuth()
const isReauthenticationSignIn = useIsReauthenticationSignIn()
if (!isReauthenticationSignIn) return null
return (
<Alert className="mx-4 w-auto group-data-[size=sm]/card:mx-3">
<AlertTitle>
{auth.localization.settings.reauthenticationTitle}
</AlertTitle>
<AlertDescription>
{auth.localization.settings.reauthenticationDescription}
</AlertDescription>
</Alert>
)
}
examples/start-shadcn-example/src/components/auth/sso/email-first-sign-in.tsx
"use client"
import {
authMutationKeys,
validateEmailAddress,
validateStringLength
} from "@better-auth-ui/core"
import {
isPasskeyAutoFillEnabled,
withPasskeyAutoFill
} from "@better-auth-ui/core/plugins/passkey"
import {
type SsoAuthClient,
setSsoFallbackEmail
} from "@better-auth-ui/core/plugins/sso"
import {
AuthPrompts,
getAuthButtonKey,
useAuth,
useAuthPlugin,
useFetchOptions,
useSignInEmail
} from "@better-auth-ui/react"
import { useSignInSso } from "@better-auth-ui/react/plugins/sso"
import { useSelector } from "@tanstack/react-form"
import { useIsMutating } from "@tanstack/react-query"
import { Eye, EyeOff } from "lucide-react"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle
} from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSeparator
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput
} from "@/components/ui/input-group"
import { ssoPlugin } from "@/lib/auth/sso-plugin"
import { useSignInContinuation } from "@/lib/auth/use-sign-in-continuation"
import { cn } from "cn"
import { isAuthFormFieldInvalid, useAuthForm } from "../auth-form"
import { ProviderButtons } from "../provider-buttons"
import { ReauthenticationNotice } from "../reauthentication"
export type EmailFirstSignInProps = {
className?: string
socialLayout?: "auto" | "horizontal" | "vertical" | "grid"
socialPosition?: "top" | "bottom"
}
/** Discover organization SSO by email, then expose configured fallback methods. */
export function EmailFirstSignIn({
className,
socialLayout,
socialPosition = "bottom"
}: EmailFirstSignInProps) {
const {
authClient,
basePaths,
baseURL,
emailAndPassword,
localization,
navigate,
plugins,
redirectTo,
socialProviders,
viewPaths,
Link
} = useAuth()
const { localization: ssoLocalization } = useAuthPlugin(ssoPlugin)
const { fetchOptions, resetFetchOptions } = useFetchOptions()
const continueSignIn = useSignInContinuation()
const [step, setStep] = useState<"email" | "fallback">("email")
const [isPasswordVisible, setIsPasswordVisible] = useState(false)
const [discoveryError, setDiscoveryError] = useState("")
const { mutateAsync: signInSso, isPending: isDiscovering } = useSignInSso(
authClient as SsoAuthClient,
{
onError: (error) => {
if (error.status === 404) {
setSsoFallbackEmail(form.state.values.email)
setDiscoveryError(ssoLocalization.noProvider)
setStep("fallback")
return
}
setDiscoveryError(ssoLocalization.ssoUnavailable)
}
}
)
const { mutateAsync: signInEmail, isPending: isSigningIn } = useSignInEmail(
authClient,
{
onError: (error) => {
form.setFieldValue("password", "")
if (error.error?.code === "EMAIL_NOT_VERIFIED") {
sessionStorage.setItem(
"better-auth-ui.verify-email",
form.state.values.email
)
navigate({
to: `${basePaths.auth}/${viewPaths.auth.verifyEmail}`
})
}
resetFetchOptions()
},
onSuccess: (data) => continueSignIn(data)
}
)
const signInMutating = useIsMutating({
mutationKey: authMutationKeys.signIn.all
})
const isPending = signInMutating > 0
const Captcha = plugins.find(
(plugin) => plugin.captchaComponent
)?.captchaComponent
const passkeyAutoFill = isPasskeyAutoFillEnabled(plugins)
const showSocialSeparator =
emailAndPassword.enabled && !!socialProviders?.length
const form = useAuthForm({
defaultValues: { email: "", password: "", rememberMe: false },
onSubmit: async ({ value }) => {
if (step === "email") {
setDiscoveryError("")
setSsoFallbackEmail(value.email)
try {
await signInSso({
callbackURL: `${baseURL}${redirectTo}`,
email: value.email,
loginHint: value.email
})
} catch (error) {
if ((error as { status?: number }).status !== 404) throw error
}
return
}
await signInEmail({
email: value.email,
password: value.password,
...(emailAndPassword.rememberMe
? { rememberMe: value.rememberMe }
: {}),
fetchOptions
})
}
})
const email = useSelector(form.store, (state) => state.values.email)
const startOver = () => {
setStep("email")
form.setFieldValue("password", "")
setDiscoveryError("")
}
return (
<Card className={cn("w-full max-w-sm", className)}>
<AuthPrompts view="signIn" />
<ReauthenticationNotice />
<CardHeader>
<CardTitle className="text-xl font-semibold">
{localization.auth.signIn}
</CardTitle>
<CardDescription>
{step === "email" ? ssoLocalization.emailFirstDescription : email}
</CardDescription>
</CardHeader>
<CardContent>
<form.AppForm>
{step === "email" ? (
<form.AuthFormRoot>
<FieldGroup>
<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="sso-email">
{localization.auth.email}
</FieldLabel>
<Input
id="sso-email"
name={field.name}
type="email"
autoComplete={withPasskeyAutoFill(
"email",
passkeyAutoFill
)}
autoFocus
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) =>
field.handleChange(event.target.value)
}
placeholder={localization.auth.emailPlaceholder}
required
disabled={isPending}
aria-invalid={isInvalid}
/>
<field.AuthFormFieldError />
</Field>
)
}}
</form.AppField>
{discoveryError && (
<FieldDescription role="alert" className="text-destructive">
{discoveryError}
</FieldDescription>
)}
<form.AuthFormServerError />
<form.AuthFormSubmitButton
isPending={isDiscovering}
disabled={isPending}
>
{ssoLocalization.continueWithEmail}
</form.AuthFormSubmitButton>
</FieldGroup>
</form.AuthFormRoot>
) : (
<div className="flex flex-col gap-4">
{socialPosition === "top" && (
<>
{!!socialProviders?.length && (
<ProviderButtons
socialLayout={socialLayout}
view="signIn"
/>
)}
{showSocialSeparator && (
<FieldSeparator>{localization.auth.or}</FieldSeparator>
)}
</>
)}
{discoveryError && (
<FieldDescription role="status">
{discoveryError}
</FieldDescription>
)}
{emailAndPassword.enabled && (
<form.AuthFormRoot>
<FieldGroup>
<form.AppField
name="password"
validators={{
onChange: ({ value }) =>
validateStringLength(value, {
maxLength: emailAndPassword.maxPasswordLength,
maxLengthMessage: localization.auth.tooLong.replace(
"{{max}}",
String(emailAndPassword.maxPasswordLength)
),
minLength: emailAndPassword.minPasswordLength,
minLengthMessage:
localization.auth.tooShort.replace(
"{{min}}",
String(emailAndPassword.minPasswordLength)
),
requiredMessage: localization.auth.fieldRequired
})
}}
>
{(field) => {
const isInvalid = isAuthFormFieldInvalid(
field.state.meta
)
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor="sso-password">
{localization.auth.password}
</FieldLabel>
<InputGroup>
<InputGroupInput
id="sso-password"
name={field.name}
type={isPasswordVisible ? "text" : "password"}
autoComplete={withPasskeyAutoFill(
"current-password",
passkeyAutoFill
)}
autoFocus
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) =>
field.handleChange(event.target.value)
}
placeholder={
localization.auth.passwordPlaceholder
}
minLength={emailAndPassword.minPasswordLength}
maxLength={emailAndPassword.maxPasswordLength}
required
disabled={isPending}
aria-invalid={isInvalid}
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label={
isPasswordVisible
? localization.auth.hidePassword
: localization.auth.showPassword
}
onClick={() =>
setIsPasswordVisible((visible) => !visible)
}
>
{isPasswordVisible ? <EyeOff /> : <Eye />}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<field.AuthFormFieldError />
</Field>
)
}}
</form.AppField>
{emailAndPassword.rememberMe && (
<form.AppField name="rememberMe">
{(field) => (
<Field>
<div className="flex items-center gap-3">
<Checkbox
id="sso-remember-me"
name={field.name}
checked={field.state.value}
disabled={isPending}
onCheckedChange={(checked) =>
field.handleChange(checked === true)
}
/>
<FieldLabel
htmlFor="sso-remember-me"
className="cursor-pointer text-sm font-normal"
>
{localization.auth.rememberMe}
</FieldLabel>
</div>
</Field>
)}
</form.AppField>
)}
{Captcha && (
<div className="flex justify-center">{Captcha}</div>
)}
<form.AuthFormServerError />
<form.AuthFormSubmitButton
isPending={isSigningIn}
disabled={isPending}
>
{localization.auth.signIn}
</form.AuthFormSubmitButton>
</FieldGroup>
</form.AuthFormRoot>
)}
{plugins.flatMap((plugin) =>
(plugin.authButtons ?? []).map((AuthButton) => (
<AuthButton
key={getAuthButtonKey(plugin.id, AuthButton)}
view="signIn"
/>
))
)}
{socialPosition === "bottom" && (
<>
{showSocialSeparator && (
<FieldSeparator>{localization.auth.or}</FieldSeparator>
)}
{!!socialProviders?.length && (
<ProviderButtons
socialLayout={socialLayout}
view="signIn"
/>
)}
</>
)}
<Button variant="ghost" onClick={startOver}>
{ssoLocalization.useDifferentEmail}
</Button>
</div>
)}
</form.AppForm>
</CardContent>
{emailAndPassword.enabled && (
<CardFooter className="flex-col gap-3">
{step === "fallback" && emailAndPassword.forgotPassword && (
<Link
href={`${basePaths.auth}/${viewPaths.auth.forgotPassword}`}
className="text-sm underline-offset-4 hover:underline"
>
{localization.auth.forgotPasswordLink}
</Link>
)}
<FieldDescription className="text-center">
{localization.auth.needToCreateAnAccount}{" "}
<Link
href={`${basePaths.auth}/${viewPaths.auth.signUp}`}
className="underline underline-offset-4"
>
{localization.auth.signUp}
</Link>
</FieldDescription>
</CardFooter>
)}
</Card>
)
}
examples/start-shadcn-example/src/components/auth/sso/organization-sso-providers.tsx
"use client"
import { validateAbsoluteUrl, validateStringLength } from "@better-auth-ui/core"
import {
hasMemberRole,
type OrganizationAuthClient
} from "@better-auth-ui/core/plugins/organization"
import type {
SsoAuthClient,
SsoProvider,
UpdateSsoProviderParams
} from "@better-auth-ui/core/plugins/sso"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import { useActiveMemberRole } from "@better-auth-ui/react/plugins/organization"
import {
useDeleteSsoProvider,
useSsoProviders,
useUpdateSsoProvider
} from "@better-auth-ui/react/plugins/sso"
import type { BetterFetchError } from "better-auth/client"
import { PencilIcon, Trash2Icon } from "lucide-react"
import { useMemo, useState } from "react"
import { toast } from "sonner"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from "@/components/ui/dialog"
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle
} from "@/components/ui/empty"
import {
Field,
FieldError,
FieldGroup,
FieldLabel
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { Textarea } from "@/components/ui/textarea"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { ssoPlugin } from "@/lib/auth/sso-plugin"
import { cn } from "cn"
import { isAuthFormFieldInvalid, useAuthForm } from "../auth-form"
import { SsoDomainVerification } from "./sso-domain-verification"
import { SsoProviderSetup } from "./sso-provider-setup"
export type OrganizationSsoProvidersProps = {
className?: string
organizationId: string
organizationSlug: string
}
const providerSkeletonIds = ["sso-provider-1", "sso-provider-2"]
type SsoProviderEditorValues = {
clientId: string
clientSecret: string
discoveryEndpoint: string
domain: string
entryPoint: string
identityProviderMetadata: string
issuer: string
}
const getSsoProviderEditorValues = (
provider?: SsoProvider
): SsoProviderEditorValues => ({
clientId: "",
clientSecret: "",
discoveryEndpoint: provider?.oidcConfig?.discoveryEndpoint ?? "",
domain: provider?.domain ?? "",
entryPoint: provider?.samlConfig?.entryPoint ?? "",
identityProviderMetadata: "",
issuer: provider?.issuer ?? ""
})
const getErrorMessage = (error: Error | null) => {
const authError = error as BetterFetchError | null
return authError?.error?.message ?? authError?.message
}
/** Manage the SSO providers that belong to one explicit organization. */
export function OrganizationSsoProviders({
className,
organizationId,
organizationSlug: _organizationSlug
}: OrganizationSsoProvidersProps) {
const { authClient } = useAuth()
const { localization } = useAuthPlugin(ssoPlugin)
const { creatorRole } = useAuthPlugin(organizationPlugin)
const memberRole = useActiveMemberRole(authClient as OrganizationAuthClient, {
query: { organizationId }
})
const canManage =
hasMemberRole(memberRole.data?.role, creatorRole) ||
hasMemberRole(memberRole.data?.role, "admin")
const providersQuery = useSsoProviders(authClient as SsoAuthClient, {
enabled: !memberRole.isPending && canManage
})
const [creating, setCreating] = useState(false)
const [editing, setEditing] = useState<SsoProvider>()
const [verifying, setVerifying] = useState<SsoProvider>()
const [deleting, setDeleting] = useState<SsoProvider>()
const providers = useMemo(
() =>
providersQuery.data?.providers.filter(
(provider) => provider.organizationId === organizationId
) ?? [],
[organizationId, providersQuery.data?.providers]
)
return (
<Card className={cn(className)}>
<CardHeader>
<CardTitle>{localization.providerList}</CardTitle>
<CardDescription>
{localization.providerListDescription}
</CardDescription>
<CardAction>
<Button
disabled={memberRole.isPending || !canManage}
onClick={() => setCreating((open) => !open)}
size="sm"
>
{localization.addProvider}
</Button>
</CardAction>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{creating ? (
<SsoProviderSetup
className="max-w-none"
organizationId={organizationId}
onRegistered={() => setCreating(false)}
/>
) : null}
{memberRole.isPending || (canManage && providersQuery.isPending) ? (
<div className="flex flex-col gap-2">
{providerSkeletonIds.map((id) => (
<Skeleton className="h-24 w-full" key={id} />
))}
</div>
) : !canManage ? (
<div
className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-sm"
role="alert"
>
{localization.providerAccessDenied}
</div>
) : providersQuery.error ? (
<div
className="flex items-center justify-between gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm"
role="alert"
>
<span>{localization.providerLoadError}</span>
<Button
onClick={() => providersQuery.refetch()}
size="sm"
variant="outline"
>
{localization.retry}
</Button>
</div>
) : providers.length ? (
<div className="flex flex-col gap-2">
{providers.map((provider) => (
<div
className="flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between"
key={provider.providerId}
>
<div className="flex min-w-0 flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{provider.providerId}</span>
<Badge variant="outline">
{provider.type.toUpperCase()}
</Badge>
<Badge
variant={
provider.domainVerified ? "secondary" : "outline"
}
>
{provider.domainVerified
? localization.domainVerified
: localization.verifyDomain}
</Badge>
</div>
<span className="truncate text-sm text-muted-foreground">
{provider.domain} · {provider.issuer}
</span>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
{!provider.domainVerified ? (
<Button
onClick={() => setVerifying(provider)}
size="sm"
variant="outline"
>
{localization.verifyDomain}
</Button>
) : null}
<Button
aria-label={localization.editProvider}
onClick={() => setEditing(provider)}
size="icon-sm"
variant="ghost"
>
<PencilIcon />
</Button>
<Button
aria-label={localization.deleteProvider}
onClick={() => setDeleting(provider)}
size="icon-sm"
variant="destructive"
>
<Trash2Icon />
</Button>
</div>
</div>
))}
</div>
) : (
<Empty className="min-h-32 border">
<EmptyHeader>
<EmptyTitle>{localization.noProviders}</EmptyTitle>
<EmptyDescription>
{localization.noProvidersDescription}
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</CardContent>
<EditSsoProviderDialog
key={editing?.providerId ?? "closed"}
onOpenChange={setEditing}
provider={editing}
/>
<DeleteSsoProviderDialog onOpenChange={setDeleting} provider={deleting} />
<Dialog
open={Boolean(verifying)}
onOpenChange={(open) => !open && setVerifying(undefined)}
>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>{localization.domainVerification}</DialogTitle>
</DialogHeader>
{verifying ? (
<SsoDomainVerification
className="max-w-none shadow-none ring-0"
defaultProviderId={verifying.providerId}
/>
) : null}
</DialogContent>
</Dialog>
</Card>
)
}
function EditSsoProviderDialog({
onOpenChange,
provider
}: {
onOpenChange: (provider: SsoProvider | undefined) => void
provider?: SsoProvider
}) {
const { authClient, localization: authLocalization } = useAuth()
const { localization } = useAuthPlugin(ssoPlugin)
const update = useUpdateSsoProvider(authClient as SsoAuthClient)
const close = () => {
update.reset()
onOpenChange(undefined)
}
const form = useAuthForm({
defaultValues: getSsoProviderEditorValues(provider),
onSubmit: async ({ value }) => {
if (!provider) return
const clientId = value.clientId.trim()
const clientSecret = value.clientSecret.trim()
const identityProviderMetadata = value.identityProviderMetadata.trim()
const params = {
providerId: provider.providerId,
issuer: value.issuer.trim(),
domain: value.domain.trim(),
...(provider.oidcConfig
? {
oidcConfig: {
...(clientId ? { clientId } : {}),
...(clientSecret ? { clientSecret } : {}),
discoveryEndpoint: value.discoveryEndpoint.trim() || undefined
}
}
: {}),
...(provider.samlConfig
? {
samlConfig: {
entryPoint: value.entryPoint.trim(),
...(identityProviderMetadata
? { idpMetadata: { metadata: identityProviderMetadata } }
: {})
}
}
: {})
} as UpdateSsoProviderParams
try {
await update.mutateAsync(params)
toast.success(localization.providerUpdated)
close()
} catch {
// The mutation error is rendered below the fields.
}
}
})
return (
<Dialog
open={Boolean(provider)}
onOpenChange={(open) => {
if (!open && !update.isPending) close()
}}
>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
<form.AppForm>
<form.AuthFormRoot className="flex flex-col gap-4">
<DialogHeader>
<DialogTitle>{localization.editProvider}</DialogTitle>
<DialogDescription>{provider?.providerId}</DialogDescription>
</DialogHeader>
<FieldGroup>
<form.AppField
name="domain"
validators={{
onChange: ({ value }) =>
validateStringLength(value, {
requiredMessage: authLocalization.auth.fieldRequired,
trim: true
})
}}
>
{(field) => (
<Field
data-invalid={isAuthFormFieldInvalid(field.state.meta)}
>
<FieldLabel htmlFor="sso-edit-domain">
{localization.domain}
</FieldLabel>
<Input
id="sso-edit-domain"
name={field.name}
onBlur={field.handleBlur}
onChange={(event) =>
field.handleChange(event.target.value)
}
required
value={field.state.value}
aria-invalid={isAuthFormFieldInvalid(field.state.meta)}
/>
<field.AuthFormFieldError />
</Field>
)}
</form.AppField>
<form.AppField
name="issuer"
validators={{
onChange: ({ value }) =>
validateAbsoluteUrl(value, {
invalidMessage: localization.invalidUrl,
requiredMessage: authLocalization.auth.fieldRequired
})
}}
>
{(field) => (
<Field
data-invalid={isAuthFormFieldInvalid(field.state.meta)}
>
<FieldLabel htmlFor="sso-edit-issuer">
{localization.issuer}
</FieldLabel>
<Input
id="sso-edit-issuer"
name={field.name}
onBlur={field.handleBlur}
onChange={(event) =>
field.handleChange(event.target.value)
}
required
type="url"
value={field.state.value}
aria-invalid={isAuthFormFieldInvalid(field.state.meta)}
/>
<field.AuthFormFieldError />
</Field>
)}
</form.AppField>
{provider?.oidcConfig ? (
<>
<form.AppField
name="discoveryEndpoint"
validators={{
onChange: ({ value }) =>
validateAbsoluteUrl(value, {
invalidMessage: localization.invalidUrl
})
}}
>
{(field) => (
<Field
data-invalid={isAuthFormFieldInvalid(field.state.meta)}
>
<FieldLabel htmlFor="sso-edit-discovery">
{localization.discoveryEndpoint}
</FieldLabel>
<Input
id="sso-edit-discovery"
name={field.name}
onBlur={field.handleBlur}
onChange={(event) =>
field.handleChange(event.target.value)
}
type="url"
value={field.state.value}
aria-invalid={isAuthFormFieldInvalid(
field.state.meta
)}
/>
<field.AuthFormFieldError />
</Field>
)}
</form.AppField>
<div className="grid gap-4 sm:grid-cols-2">
{(
[
[
"clientId",
"sso-edit-client-id",
localization.clientId
],
[
"clientSecret",
"sso-edit-client-secret",
localization.clientSecret
]
] as const
).map(([name, id, label]) => (
<form.Field key={name} name={name}>
{(field) => (
<Field>
<FieldLabel htmlFor={id}>{label}</FieldLabel>
<Input
autoComplete={
name === "clientSecret"
? "new-password"
: undefined
}
id={id}
name={field.name}
onBlur={field.handleBlur}
onChange={(event) =>
field.handleChange(event.target.value)
}
placeholder={
name === "clientId"
? `••••${provider.oidcConfig?.clientIdLastFour}`
: undefined
}
type={
name === "clientSecret" ? "password" : "text"
}
value={field.state.value}
/>
</Field>
)}
</form.Field>
))}
</div>
</>
) : null}
{provider?.samlConfig ? (
<>
<form.AppField
name="entryPoint"
validators={{
onChange: ({ value }) =>
validateAbsoluteUrl(value, {
invalidMessage: localization.invalidUrl,
requiredMessage: authLocalization.auth.fieldRequired
})
}}
>
{(field) => (
<Field
data-invalid={isAuthFormFieldInvalid(field.state.meta)}
>
<FieldLabel htmlFor="sso-edit-entry-point">
{localization.entryPoint}
</FieldLabel>
<Input
id="sso-edit-entry-point"
name={field.name}
onBlur={field.handleBlur}
onChange={(event) =>
field.handleChange(event.target.value)
}
required
type="url"
value={field.state.value}
aria-invalid={isAuthFormFieldInvalid(
field.state.meta
)}
/>
<field.AuthFormFieldError />
</Field>
)}
</form.AppField>
<form.Field name="identityProviderMetadata">
{(field) => (
<Field>
<FieldLabel htmlFor="sso-edit-metadata">
{localization.identityProviderMetadata}
</FieldLabel>
<Textarea
className="font-mono text-xs"
id="sso-edit-metadata"
name={field.name}
onBlur={field.handleBlur}
onChange={(event) =>
field.handleChange(event.target.value)
}
rows={6}
value={field.state.value}
/>
</Field>
)}
</form.Field>
</>
) : null}
</FieldGroup>
<FieldError>{getErrorMessage(update.error)}</FieldError>
<DialogFooter>
<Button
disabled={update.isPending}
onClick={close}
type="button"
variant="outline"
>
{localization.cancel}
</Button>
<form.AuthFormSubmitButton disabled={update.isPending}>
{localization.saveProvider}
</form.AuthFormSubmitButton>
</DialogFooter>
</form.AuthFormRoot>
</form.AppForm>
</DialogContent>
</Dialog>
)
}
function DeleteSsoProviderDialog({
onOpenChange,
provider
}: {
onOpenChange: (provider: SsoProvider | undefined) => void
provider?: SsoProvider
}) {
const { authClient } = useAuth()
const { localization } = useAuthPlugin(ssoPlugin)
const remove = useDeleteSsoProvider(authClient as SsoAuthClient)
const close = () => {
remove.reset()
onOpenChange(undefined)
}
return (
<AlertDialog
open={Boolean(provider)}
onOpenChange={(open) => {
if (!open && !remove.isPending) close()
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{localization.deleteProvider}</AlertDialogTitle>
<AlertDialogDescription>
{localization.deleteProviderDescription}
</AlertDialogDescription>
</AlertDialogHeader>
<p className="font-mono text-xs">{provider?.providerId}</p>
<FieldError>{getErrorMessage(remove.error)}</FieldError>
<AlertDialogFooter>
<AlertDialogCancel disabled={remove.isPending}>
{localization.cancel}
</AlertDialogCancel>
<AlertDialogAction
disabled={remove.isPending}
onClick={(event) => {
event.preventDefault()
if (!provider) return
remove.mutate(
{ providerId: provider.providerId },
{
onSuccess: () => {
toast.success(localization.providerDeleted)
close()
}
}
)
}}
variant="destructive"
>
{localization.deleteProvider}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
examples/start-shadcn-example/src/components/auth/sso/sso-domain-verification.tsx
"use client"
import type { SsoAuthClient } from "@better-auth-ui/core/plugins/sso"
import {
useAuth,
useAuthPlugin,
useCopyToClipboard
} from "@better-auth-ui/react"
import {
useRequestSsoDomainVerification,
useVerifySsoDomain
} from "@better-auth-ui/react/plugins/sso"
import type { BetterFetchError } from "better-auth/client"
import { CheckIcon, CopyIcon } from "lucide-react"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@/components/ui/card"
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput
} from "@/components/ui/input-group"
import { Spinner } from "@/components/ui/spinner"
import { ssoPlugin } from "@/lib/auth/sso-plugin"
import { cn } from "cn"
import { useAuthForm } from "../auth-form"
export type SsoDomainVerificationProps = {
className?: string
defaultProviderId?: string
defaultToken?: string
tokenPrefix?: string
}
const getErrorMessage = (error: Error | null) => {
const authError = error as BetterFetchError | null
return authError?.error?.message ?? authError?.message
}
/** DNS token renewal and verification for an existing SSO provider. */
export function SsoDomainVerification({
className,
defaultProviderId = "",
defaultToken = "",
tokenPrefix = "better-auth-token"
}: SsoDomainVerificationProps) {
const { authClient, localization: authLocalization } = useAuth()
const { localization } = useAuthPlugin(ssoPlugin)
const [token, setToken] = useState(defaultToken)
const [verified, setVerified] = useState(false)
const [copyError, setCopyError] = useState("")
const requestToken = useRequestSsoDomainVerification(
authClient as SsoAuthClient,
{
onSuccess: (data) => setToken(data.domainVerificationToken)
}
)
const verify = useVerifySsoDomain(authClient as SsoAuthClient, {
onSuccess: () => setVerified(true)
})
const hostCopy = useCopyToClipboard({
onError: (error) => {
console.error("[Better Auth UI] Copy failed", error)
setCopyError(authLocalization.errors.copyFailed)
}
})
const tokenCopy = useCopyToClipboard({
onError: (error) => {
console.error("[Better Auth UI] Copy failed", error)
setCopyError(authLocalization.errors.copyFailed)
}
})
const error =
requestToken.submittedAt > verify.submittedAt
? requestToken.error
: verify.error
const form = useAuthForm({
defaultValues: { providerId: defaultProviderId },
onSubmit: async ({ value }) => {
setCopyError("")
setVerified(false)
await verify.mutateAsync({ providerId: value.providerId })
}
})
return (
<Card className={cn("w-full max-w-xl", className)}>
<CardHeader>
<CardTitle>{localization.domainVerification}</CardTitle>
<CardDescription>
{localization.domainVerificationDescription}
</CardDescription>
</CardHeader>
<CardContent>
<form.AppForm>
<form.AuthFormRoot>
<form.Subscribe selector={(state) => state.values.providerId}>
{(providerId) => {
const host = providerId ? `_${tokenPrefix}-${providerId}` : ""
return (
<FieldGroup>
<form.AppField name="providerId">
{(field) => (
<Field>
<FieldLabel htmlFor="sso-verification-provider-id">
{localization.providerId}
</FieldLabel>
<Input
id="sso-verification-provider-id"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => {
field.handleChange(event.target.value.trim())
setToken("")
setVerified(false)
}}
required
/>
</Field>
)}
</form.AppField>
{token ? (
<div className="grid gap-3 rounded-lg border p-3">
<Field>
<FieldLabel htmlFor="sso-dns-host">
{localization.txtRecordHost}
</FieldLabel>
<InputGroup>
<InputGroupInput
className="font-mono text-xs"
id="sso-dns-host"
readOnly
value={host}
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
aria-label={localization.copyDnsHost}
size="icon-xs"
onClick={() => {
setCopyError("")
void hostCopy.copy(host)
}}
>
{hostCopy.copied ? <CheckIcon /> : <CopyIcon />}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</Field>
<Field>
<FieldLabel htmlFor="sso-dns-value">
{localization.txtRecordValue}
</FieldLabel>
<InputGroup>
<InputGroupInput
className="font-mono text-xs"
id="sso-dns-value"
readOnly
value={token}
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
aria-label={localization.copyDnsValue}
size="icon-xs"
onClick={() => {
setCopyError("")
void tokenCopy.copy(token)
}}
>
{tokenCopy.copied ? (
<CheckIcon />
) : (
<CopyIcon />
)}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</Field>
</div>
) : null}
{error ? (
<FieldError>{getErrorMessage(error)}</FieldError>
) : null}
{copyError ? <FieldError>{copyError}</FieldError> : null}
{verified ? (
<FieldDescription
role="status"
className="text-foreground"
>
{localization.domainVerified}
</FieldDescription>
) : null}
<div className="flex flex-wrap justify-end gap-2">
<Button
type="button"
variant="outline"
disabled={!providerId || verify.isPending}
onClick={() => {
setCopyError("")
setVerified(false)
requestToken.mutate({ providerId })
}}
>
{requestToken.isPending ? (
<Spinner data-icon="inline-start" />
) : null}
{localization.requestNewToken}
</Button>
<form.AuthFormSubmitButton
isPending={verify.isPending}
disabled={!providerId || requestToken.isPending}
>
{localization.verifyDomain}
</form.AuthFormSubmitButton>
</div>
<span className="sr-only" aria-live="polite">
{token && !verified
? localization.domainVerificationRequested
: ""}
</span>
</FieldGroup>
)
}}
</form.Subscribe>
</form.AuthFormRoot>
</form.AppForm>
</CardContent>
</Card>
)
}
examples/start-shadcn-example/src/components/auth/sso/sso-provider-setup.tsx
"use client"
import { validateAbsoluteUrl, validateStringLength } from "@better-auth-ui/core"
import type {
RegisterSsoProviderData,
RegisterSsoProviderParams,
SsoAuthClient
} from "@better-auth-ui/core/plugins/sso"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import { useRegisterSsoProvider } from "@better-auth-ui/react/plugins/sso"
import type { BetterFetchError } from "better-auth/client"
import { FileCode2, KeyRound } from "lucide-react"
import { useState } from "react"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle
} from "@/components/ui/card"
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Textarea } from "@/components/ui/textarea"
import { ssoPlugin } from "@/lib/auth/sso-plugin"
import { cn } from "cn"
import { isAuthFormFieldInvalid, useAuthForm } from "../auth-form"
type SsoProtocol = "oidc" | "saml"
type SsoProviderFormValues = {
clientId: string
clientSecret: string
domain: string
entryPoint: string
identityProviderMetadata: string
issuer: string
organizationId: string
protocol: SsoProtocol
providerId: string
}
export type SsoProviderSetupProps = {
className?: string
defaultOrganizationId?: string
organizationId?: string
onRegistered?: (provider: RegisterSsoProviderData) => void
}
const getErrorMessage = (error: Error | null) => {
const authError = error as BetterFetchError | null
return authError?.error?.message ?? authError?.message
}
/** Self-service form for registering an OIDC or SAML SSO provider. */
export function SsoProviderSetup({
className,
defaultOrganizationId,
organizationId: fixedOrganizationId,
onRegistered
}: SsoProviderSetupProps) {
const { authClient, localization: authLocalization } = useAuth()
const { localization } = useAuthPlugin(ssoPlugin)
const [created, setCreated] = useState(false)
const register = useRegisterSsoProvider(authClient as SsoAuthClient)
const form = useAuthForm({
defaultValues: {
clientId: "",
clientSecret: "",
domain: "",
entryPoint: "",
identityProviderMetadata: "",
issuer: "",
organizationId: defaultOrganizationId ?? "",
protocol: "oidc" as SsoProtocol,
providerId: ""
} satisfies SsoProviderFormValues,
onSubmit: async ({ value }) => {
setCreated(false)
const common = {
providerId: value.providerId.trim(),
issuer: value.issuer.trim(),
domain: value.domain.trim(),
organizationId:
fixedOrganizationId || value.organizationId.trim() || undefined
}
const params =
value.protocol === "oidc"
? {
...common,
oidcConfig: {
clientId: value.clientId.trim(),
clientSecret: value.clientSecret.trim()
}
}
: {
...common,
samlConfig: {
entryPoint: value.entryPoint.trim(),
idpMetadata: {
metadata: value.identityProviderMetadata.trim()
}
}
}
try {
const provider = await register.mutateAsync(
params as RegisterSsoProviderParams<SsoAuthClient>
)
setCreated(true)
onRegistered?.(provider)
} catch {
// The mutation exposes its error below the fields.
}
}
})
return (
<form.AppForm>
<form.AuthFormRoot className={cn("w-full max-w-xl", className)}>
<Card>
<CardHeader>
<CardTitle>{localization.providerSetup}</CardTitle>
<CardDescription>
{localization.providerSetupDescription}
</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<div className="grid gap-4 sm:grid-cols-2">
<form.AppField
name="providerId"
validators={{
onChange: ({ value }) =>
validateStringLength(value, {
requiredMessage: authLocalization.auth.fieldRequired,
trim: true
})
}}
>
{(field) => (
<Field
data-invalid={isAuthFormFieldInvalid(field.state.meta)}
>
<FieldLabel htmlFor="sso-provider-id">
{localization.providerId}
</FieldLabel>
<Input
id="sso-provider-id"
name={field.name}
onBlur={field.handleBlur}
onChange={(event) =>
field.handleChange(event.target.value)
}
required
value={field.state.value}
aria-invalid={isAuthFormFieldInvalid(field.state.meta)}
/>
<field.AuthFormFieldError />
</Field>
)}
</form.AppField>
<form.AppField
name="domain"
validators={{
onChange: ({ value }) =>
validateStringLength(value, {
requiredMessage: authLocalization.auth.fieldRequired,
trim: true
})
}}
>
{(field) => (
<Field
data-invalid={isAuthFormFieldInvalid(field.state.meta)}
>
<FieldLabel htmlFor="sso-domain">
{localization.domain}
</FieldLabel>
<Input
id="sso-domain"
name={field.name}
onBlur={field.handleBlur}
onChange={(event) =>
field.handleChange(event.target.value)
}
placeholder="example.com"
required
value={field.state.value}
aria-invalid={isAuthFormFieldInvalid(field.state.meta)}
/>
<field.AuthFormFieldError />
</Field>
)}
</form.AppField>
</div>
<form.AppField
name="issuer"
validators={{
onChange: ({ value }) =>
validateAbsoluteUrl(value, {
invalidMessage: localization.invalidUrl,
requiredMessage: authLocalization.auth.fieldRequired
})
}}
>
{(field) => (
<Field
data-invalid={isAuthFormFieldInvalid(field.state.meta)}
>
<FieldLabel htmlFor="sso-issuer">
{localization.issuer}
</FieldLabel>
<Input
id="sso-issuer"
name={field.name}
onBlur={field.handleBlur}
onChange={(event) =>
field.handleChange(event.target.value)
}
placeholder="https://idp.example.com"
required
type="url"
value={field.state.value}
aria-invalid={isAuthFormFieldInvalid(field.state.meta)}
/>
<field.AuthFormFieldError />
</Field>
)}
</form.AppField>
{!fixedOrganizationId ? (
<form.Field name="organizationId">
{(field) => (
<Field>
<FieldLabel htmlFor="sso-organization-id">
{localization.organizationId}
</FieldLabel>
<Input
id="sso-organization-id"
name={field.name}
onBlur={field.handleBlur}
onChange={(event) =>
field.handleChange(event.target.value)
}
value={field.state.value}
/>
</Field>
)}
</form.Field>
) : null}
<form.Field name="protocol">
{(protocolField) => (
<Tabs
value={protocolField.state.value}
onValueChange={(value) =>
protocolField.handleChange(value as SsoProtocol)
}
>
<TabsList aria-label={localization.providerSetup}>
<TabsTrigger value="oidc">
<KeyRound
aria-hidden="true"
className="text-muted-foreground"
/>
{localization.oidc}
</TabsTrigger>
<TabsTrigger value="saml">
<FileCode2
aria-hidden="true"
className="text-muted-foreground"
/>
{localization.saml}
</TabsTrigger>
</TabsList>
<TabsContent
className="grid gap-4 sm:grid-cols-2"
value="oidc"
>
<form.AppField
name="clientId"
validators={{
onChange: ({ value }) =>
validateStringLength(value, {
requiredMessage:
authLocalization.auth.fieldRequired,
trim: true
})
}}
>
{(field) => (
<Field
data-invalid={isAuthFormFieldInvalid(
field.state.meta
)}
>
<FieldLabel htmlFor="sso-client-id">
{localization.clientId}
</FieldLabel>
<Input
autoComplete="off"
id="sso-client-id"
name={field.name}
onBlur={field.handleBlur}
onChange={(event) =>
field.handleChange(event.target.value)
}
required
value={field.state.value}
aria-invalid={isAuthFormFieldInvalid(
field.state.meta
)}
/>
<field.AuthFormFieldError />
</Field>
)}
</form.AppField>
<form.AppField
name="clientSecret"
validators={{
onChange: ({ value }) =>
validateStringLength(value, {
requiredMessage:
authLocalization.auth.fieldRequired,
trim: true
})
}}
>
{(field) => (
<Field
data-invalid={isAuthFormFieldInvalid(
field.state.meta
)}
>
<FieldLabel htmlFor="sso-client-secret">
{localization.clientSecret}
</FieldLabel>
<Input
autoComplete="new-password"
id="sso-client-secret"
name={field.name}
onBlur={field.handleBlur}
onChange={(event) =>
field.handleChange(event.target.value)
}
required
type="password"
value={field.state.value}
aria-invalid={isAuthFormFieldInvalid(
field.state.meta
)}
/>
<field.AuthFormFieldError />
</Field>
)}
</form.AppField>
</TabsContent>
<TabsContent className="flex flex-col gap-4" value="saml">
<form.AppField
name="entryPoint"
validators={{
onChange: ({ value }) =>
validateAbsoluteUrl(value, {
invalidMessage: localization.invalidUrl,
requiredMessage:
authLocalization.auth.fieldRequired
})
}}
>
{(field) => (
<Field
data-invalid={isAuthFormFieldInvalid(
field.state.meta
)}
>
<FieldLabel htmlFor="sso-entry-point">
{localization.entryPoint}
</FieldLabel>
<Input
id="sso-entry-point"
name={field.name}
onBlur={field.handleBlur}
onChange={(event) =>
field.handleChange(event.target.value)
}
required
type="url"
value={field.state.value}
aria-invalid={isAuthFormFieldInvalid(
field.state.meta
)}
/>
<field.AuthFormFieldError />
</Field>
)}
</form.AppField>
<form.AppField
name="identityProviderMetadata"
validators={{
onChange: ({ value }) =>
validateStringLength(value, {
requiredMessage:
authLocalization.auth.fieldRequired,
trim: true
})
}}
>
{(field) => (
<Field
data-invalid={isAuthFormFieldInvalid(
field.state.meta
)}
>
<FieldLabel htmlFor="sso-idp-metadata">
{localization.identityProviderMetadata}
</FieldLabel>
<Textarea
className="min-h-40 font-mono text-xs"
id="sso-idp-metadata"
name={field.name}
onBlur={field.handleBlur}
onChange={(event) =>
field.handleChange(event.target.value)
}
required
value={field.state.value}
aria-invalid={isAuthFormFieldInvalid(
field.state.meta
)}
/>
<field.AuthFormFieldError />
</Field>
)}
</form.AppField>
</TabsContent>
</Tabs>
)}
</form.Field>
{register.error ? (
<FieldError>{getErrorMessage(register.error)}</FieldError>
) : null}
{created ? (
<FieldDescription role="status" className="text-foreground">
{localization.providerCreated}
</FieldDescription>
) : null}
</FieldGroup>
</CardContent>
<CardFooter className="justify-end">
<form.AuthFormSubmitButton disabled={register.isPending}>
{localization.addProvider}
</form.AuthFormSubmitButton>
</CardFooter>
</Card>
</form.AuthFormRoot>
</form.AppForm>
)
}
examples/start-shadcn-example/src/components/auth/use-is-hydrated.ts
"use client"
import { useSyncExternalStore } from "react"
/**
* Returns `true` once the component is mounted on the client (hydrated) and
* `false` while rendering on the server, so client-only reads (e.g.
* `sessionStorage`) stay safe during SSR.
*
* @returns Whether the component has hydrated on the client.
*/
export function useIsHydrated() {
const subscribe = () => () => {}
return useSyncExternalStore(
subscribe,
() => true,
() => false
)
}
examples/start-shadcn-example/src/components/auth/user/user-avatar.tsx
"use client"
import type { UsernameAuthClient } from "@better-auth-ui/core/plugins/username"
import { useAuth, useSession } from "@better-auth-ui/react"
import type { User } from "better-auth"
import { User2 } from "lucide-react"
import type { ReactNode } from "react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "cn"
export type UserAvatarProps = {
className?: string
fallback?: ReactNode
isPending?: boolean
/** @remarks `User` */
user?: User & { username?: string | null; displayUsername?: string | null }
}
/**
* Display a user's avatar using session information or an explicit user prop.
*
* Renders a circular avatar that shows the user's image when available, a fallback node if provided, or the user's first two initials; while the session is loading (or when `isPending` is true) and no `user` prop is supplied, renders a skeleton placeholder.
*
* @param className - Additional CSS classes applied to the avatar container
* @param user - Optional user object to display instead of the session user
* @param isPending - When true, treat the component as loading and show the skeleton if no `user` is provided
* @param fallback - Node to render inside the avatar fallback area before initials or the default icon
* @returns The avatar element to render (JSX)
*/
export function UserAvatar({
className,
user,
isPending,
fallback
}: UserAvatarProps) {
const { authClient } = useAuth<UsernameAuthClient>()
const { data: session, isPending: sessionPending } = useSession(authClient, {
enabled: !user && !isPending
})
if ((isPending || sessionPending) && !user) {
return <Skeleton className={cn("size-8 rounded-full", className)} />
}
const resolvedUser = user ?? session?.user
const initials = (
resolvedUser?.username ||
resolvedUser?.name ||
resolvedUser?.email
)
?.slice(0, 2)
.toUpperCase()
return (
<Avatar
className={cn(
"size-8 bg-muted text-foreground text-sm rounded-full",
className
)}
>
<AvatarImage
src={resolvedUser?.image ?? undefined}
alt={
resolvedUser?.displayUsername ||
resolvedUser?.name ||
resolvedUser?.email
}
/>
<AvatarFallback className="text-muted-foreground!">
{fallback || initials || <User2 className="size-4" />}
</AvatarFallback>
</Avatar>
)
}
examples/start-shadcn-example/src/lib/auth/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
}
]
}
}
)
examples/start-shadcn-example/src/lib/auth/sso-plugin.ts
import {
type AuthPluginBase,
type AuthPluginLocalizationContext,
createAuthPlugin
} from "@better-auth-ui/core"
import {
ssoPlugin as coreSsoPlugin,
type SsoLocalization,
type SsoPluginOptions
} from "@better-auth-ui/core/plugins/sso"
import { ShieldCheck } from "lucide-react"
import { createElement } from "react"
import { EmailFirstSignIn } from "@/components/auth/sso/email-first-sign-in"
import { OrganizationSsoProviders } from "@/components/auth/sso/organization-sso-providers"
export const ssoPlugin = createAuthPlugin(
coreSsoPlugin.id,
(options: SsoPluginOptions = {}) => {
const plugin = coreSsoPlugin(options)
const localizedOrganizationTab = (localization: SsoLocalization) =>
plugin.organization
? {
organizationTabs: [
{
id: "sso",
path: plugin.path,
label: createElement(
"span",
{ className: "inline-flex items-center gap-1" },
createElement(ShieldCheck, {
"aria-hidden": true,
className: "text-muted-foreground"
}),
localization.providerList
),
component: OrganizationSsoProviders
}
]
}
: {}
return {
...plugin,
...localizedOrganizationTab(plugin.localization),
...(plugin.emailFirst && {
views: { auth: { signIn: EmailFirstSignIn } }
}),
_localizationResolver: (
resolvedPlugin: AuthPluginBase,
context: AuthPluginLocalizationContext
) => ({
...resolvedPlugin,
...localizedOrganizationTab(context.localization as SsoLocalization)
})
}
}
)
examples/start-shadcn-example/src/lib/auth/two-factor-methods.ts
/**
* Second-factor methods a user can complete the challenge with.
*
* `totp` and `otp` come from the server's `twoFactorMethods` list. Backup
* codes are always available when the server has them enabled, so they are
* never part of that list.
*/
export type TwoFactorMethod = "totp" | "otp"
const TWO_FACTOR_METHODS: TwoFactorMethod[] = ["totp", "otp"]
/** Auth plugin id used by Better Auth UI's two-factor integration. */
export const TWO_FACTOR_PLUGIN_ID = "twoFactor"
/**
* `sessionStorage` key holding the methods reported by the sign-in response.
*
* Only the non-sensitive method names are stored, never a code, token, or the
* two-factor cookie, which stays HTTP-only.
*/
export const TWO_FACTOR_METHODS_STORAGE_KEY =
"better-auth-ui.two-factor-methods"
type TwoFactorRedirect = {
twoFactorRedirect: true
twoFactorMethods?: unknown
}
/** Detect the redirect payload Better Auth returns before a second factor. */
export function isTwoFactorRedirect(data: unknown): data is TwoFactorRedirect {
return (
typeof data === "object" &&
data !== null &&
(data as { twoFactorRedirect?: unknown }).twoFactorRedirect === true
)
}
/** Narrow arbitrary method names to the challenge views this UI supports. */
export function parseTwoFactorMethods(methods?: unknown): TwoFactorMethod[] {
if (!Array.isArray(methods)) return []
return TWO_FACTOR_METHODS.filter((method) => methods.includes(method))
}
/** Persist the enabled method names without blocking sign-in on storage errors. */
export function storeTwoFactorMethods(methods?: unknown) {
if (typeof sessionStorage === "undefined") return
try {
sessionStorage.setItem(
TWO_FACTOR_METHODS_STORAGE_KEY,
JSON.stringify(parseTwoFactorMethods(methods))
)
} catch {
// The challenge falls back to every method when storage is unavailable.
}
}
/** Read the stored methods, falling back to every supported challenge. */
export function readTwoFactorMethods(): TwoFactorMethod[] {
if (typeof sessionStorage === "undefined") return TWO_FACTOR_METHODS
try {
const stored = sessionStorage.getItem(TWO_FACTOR_METHODS_STORAGE_KEY)
if (!stored) return TWO_FACTOR_METHODS
const methods = parseTwoFactorMethods(JSON.parse(stored))
return methods.length ? methods : TWO_FACTOR_METHODS
} catch {
return TWO_FACTOR_METHODS
}
}
/** Clear stored method hints after the challenge finishes or is abandoned. */
export function clearTwoFactorMethods() {
if (typeof sessionStorage === "undefined") return
try {
sessionStorage.removeItem(TWO_FACTOR_METHODS_STORAGE_KEY)
} catch {
// Stale method hints are harmless and must not block navigation.
}
}
examples/start-shadcn-example/src/lib/auth/use-sign-in-continuation.ts
"use client"
import { useAuth } from "@better-auth-ui/react"
import { useCallback } from "react"
import {
isTwoFactorRedirect,
storeTwoFactorMethods,
TWO_FACTOR_PLUGIN_ID
} from "./two-factor-methods"
/**
* Resolve what happens after a sign-in request succeeds.
*
* Better Auth withholds the session when a second factor is required and
* answers with `{ twoFactorRedirect: true, twoFactorMethods }` instead, so no
* sign-in strategy may navigate to `redirectTo` unconditionally. This hook is
* the single place that decision lives — every password-based form calls it
* from `onSuccess`.
*
* The enabled methods are stashed in session storage (names only, never a
* code or token) and `redirectTo` rides along in the query string so the
* challenge view can finish the original navigation.
*
* The two-factor plugin is looked up by its stable id, so sign-in forms stay
* installable without either the two-factor components or a matching release
* of `@better-auth-ui/core`.
*
* @returns A callback taking the resolved data of a sign-in mutation.
*/
export function useSignInContinuation() {
const { basePaths, navigate, plugins, redirectTo } = useAuth()
const twoFactorPath = plugins.find(
(plugin) => plugin.id === TWO_FACTOR_PLUGIN_ID
)?.viewPaths?.auth?.twoFactor
return useCallback(
(data: unknown) => {
if (twoFactorPath && isTwoFactorRedirect(data)) {
storeTwoFactorMethods(data.twoFactorMethods)
navigate({
to: `${basePaths.auth}/${twoFactorPath}?redirectTo=${encodeURIComponent(redirectTo)}`
})
return
}
navigate({ to: redirectTo })
},
[basePaths.auth, navigate, redirectTo, twoFactorPath]
)
}