better-auth-ui
componentaccount-settingssource: ok · hit
Account Settings
A container component that displays account settings including user profile, change email, and plugin-contributed account cards (e.g. appearance from the theme plugin, multi-session accounts).
npx shadcn add "https://better-auth-ui.com/r/{style}/account-settings.json"free
Where it goes
settings-formprofile
Interaction
scroll-reveal
Use cases
dashboard
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
Registry dependencies
avatarbuttoncalendarcardcheckboxcomboboxdropdown-menufieldhttps://better-auth-ui.com/r/radix-nova/change-email.jsonhttps://better-auth-ui.com/r/radix-nova/user-profile.jsoninputinput-grouppopoverselectskeletonslidersonnerspinnerswitchtextarea
Files7 · source cached 10/9/2026, 3:26:17 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/settings/account/account-settings.tsx
"use client"
import { useAuth } from "@better-auth-ui/react"
import type { ComponentProps } from "react"
import { cn } from "cn"
import { ChangeEmail } from "./change-email"
import { UserProfile } from "./user-profile"
export type AccountSettingsProps = {
className?: string
}
/**
* Renders the account settings layout.
*
* Uses `emailAndPassword` and `plugins` from `useAuth()` to conditionally
* show sections:
* - `UserProfile` always renders.
* - The change-email card renders when `emailAndPassword?.enabled` is truthy
* or the `magicLink` plugin is registered, and a plugin may replace it via
* `cardOverrides.account.changeEmail` (the email-OTP plugin swaps in its
* code-based flow).
* - Plugin-contributed account cards are rendered via the plugins array
* (e.g. `Appearance` from the theme plugin, multi-session accounts).
*/
export function AccountSettings({
className,
...props
}: AccountSettingsProps & ComponentProps<"div">) {
const { emailAndPassword, plugins } = useAuth()
const hasMagicLink = plugins.some((plugin) => plugin.id === "magicLink")
const ChangeEmailOverride = plugins.find(
(plugin) => plugin.cardOverrides?.account?.changeEmail
)?.cardOverrides?.account?.changeEmail
const ChangeEmailCard = ChangeEmailOverride ?? ChangeEmail
// A plugin that replaces the card brings its own way to confirm the change,
// so it can stand on its own without password or magic-link auth.
const showChangeEmail =
emailAndPassword?.enabled || hasMagicLink || Boolean(ChangeEmailOverride)
return (
<div
className={cn("flex w-full flex-col gap-4 md:gap-6", className)}
{...props}
>
<UserProfile />
{showChangeEmail && <ChangeEmailCard />}
{plugins.flatMap(
(plugin) =>
plugin.accountCards?.map((Card, index) => (
<Card key={`${plugin.id}-${index.toString()}`} />
)) ?? []
)}
</div>
)
}
examples/start-shadcn-example/src/components/auth/settings/account/change-avatar.tsx
"use client"
import { fileToAvatarDataUrl } from "@better-auth-ui/core"
import { useAuth, useSession, useUpdateUser } from "@better-auth-ui/react"
import { Trash2, Upload } from "lucide-react"
import { type ChangeEvent, useRef, useState } from "react"
import { toast } from "sonner"
import { UserAvatar } from "@/components/auth/user/user-avatar"
import { Button, buttonVariants } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import { Field, FieldLabel } from "@/components/ui/field"
import { Spinner } from "@/components/ui/spinner"
import { cn } from "cn"
export type ChangeAvatarProps = {
className?: string
}
export function ChangeAvatar({ className }: ChangeAvatarProps) {
const { authClient, localization, avatar } = useAuth()
const { data: session } = useSession(authClient)
const { mutate: updateUser, isPending: updatePending } =
useUpdateUser(authClient)
const fileInputRef = useRef<HTMLInputElement>(null)
const [isUploading, setIsUploading] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const isPending = updatePending || isUploading || isDeleting
async function handleFileChange(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
e.target.value = ""
setIsUploading(true)
try {
const resized =
(await avatar.resize?.(file, avatar.size, avatar.extension)) || file
const image =
(await avatar.upload?.(resized)) || (await fileToAvatarDataUrl(resized))
updateUser(
{ image },
{
onSuccess: () =>
toast.success(localization.settings.avatarChangedSuccess)
}
)
} catch (error) {
console.error("[Better Auth UI] Image operation failed", error)
toast.error(localization.errors.imageUploadFailed)
}
setIsUploading(false)
}
async function handleDelete() {
const currentImage = session?.user.image
updateUser(
{ image: null },
{
onSuccess: async () => {
if (currentImage) {
setIsDeleting(true)
try {
await avatar.delete?.(currentImage)
} finally {
setIsDeleting(false)
}
}
toast.success(localization.settings.avatarDeletedSuccess)
}
}
)
}
return (
<Field className={className}>
<FieldLabel>{localization.settings.avatar}</FieldLabel>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
<div className="flex items-center gap-4">
<Button
type="button"
variant="ghost"
className="p-0 h-auto w-auto rounded-full"
disabled={isPending}
onClick={() => fileInputRef.current?.click()}
>
<UserAvatar className="size-12" isPending={isPending} />
</Button>
<DropdownMenu>
<DropdownMenuTrigger
className={cn(buttonVariants({ variant: "secondary", size: "sm" }))}
disabled={!session || isPending}
>
{isPending && <Spinner />}
{localization.settings.changeAvatar}
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-fit">
<DropdownMenuItem onClick={() => fileInputRef.current?.click()}>
<Upload className="text-muted-foreground" />
{localization.settings.uploadAvatar}
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
disabled={!session?.user.image}
onClick={handleDelete}
>
<Trash2 />
{localization.settings.deleteAvatar}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</Field>
)
}
examples/start-shadcn-example/src/components/auth/settings/account/change-email.tsx
"use client"
import { getViewURL, validateEmailAddress } from "@better-auth-ui/core"
import { useAuth, useChangeEmail, useSession } from "@better-auth-ui/react"
import { useEffect } from "react"
import { toast } from "sonner"
import { Card, CardContent, CardFooter } from "@/components/ui/card"
import { Field, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "cn"
import { isAuthFormFieldInvalid, useAuthForm } from "../../auth-form"
export type ChangeEmailProps = {
className?: string
}
/**
* Render a card containing a form to view and update the authenticated user's email.
*
* Shows a loading skeleton until session data is available, displays the current
* email as the form's default value, and sends a verification email to the
* new address upon successful submission.
*
* @returns A JSX element rendering the change-email card and form
*/
export function ChangeEmail({ className }: ChangeEmailProps) {
const { authClient, basePaths, baseURL, localization, viewPaths } = useAuth()
const { data: session } = useSession(authClient)
const { mutateAsync: changeEmail, isPending } = useChangeEmail(authClient, {
onSuccess: () => toast.success(localization.settings.changeEmailSuccess)
})
const form = useAuthForm({
defaultValues: { email: "" },
onSubmit: async ({ value }) =>
await changeEmail({
callbackURL: getViewURL(
baseURL,
basePaths.settings,
viewPaths.settings.account
),
newEmail: value.email
})
})
useEffect(() => {
if (session) form.reset({ email: session.user.email })
}, [form, session])
return (
<div>
<h2 className="text-sm font-semibold mb-3">
{localization.settings.changeEmail}
</h2>
<form.AppForm>
<form.AuthFormRoot>
<Card className={cn(className)}>
<CardContent className="flex flex-col gap-6">
<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="email">
{localization.auth.email}
</FieldLabel>
{session ? (
<Input
id="email"
name={field.name}
type="email"
autoComplete="email"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) =>
field.handleChange(event.target.value)
}
placeholder={localization.auth.emailPlaceholder}
disabled={isPending}
required
aria-invalid={isInvalid}
/>
) : (
<Skeleton>
<Input className="invisible" />
</Skeleton>
)}
<field.AuthFormFieldError />
</Field>
)
}}
</form.AppField>
</CardContent>
<CardFooter>
<form.AuthFormSubmitButton
isPending={isPending}
size="sm"
disabled={isPending || !session}
>
{localization.settings.updateEmail}
</form.AuthFormSubmitButton>
</CardFooter>
</Card>
</form.AuthFormRoot>
</form.AppForm>
</div>
)
}
examples/start-shadcn-example/src/components/auth/settings/account/user-profile.tsx
"use client"
import {
fieldsWithModelValues,
getAdditionalFieldDefaultValues,
getAdditionalFieldSubmitValues,
validateStringLength
} from "@better-auth-ui/core"
import type { UsernameAuthClient } from "@better-auth-ui/core/plugins/username"
import { useAuth, useSession, useUpdateUser } from "@better-auth-ui/react"
import { useEffect, useMemo } from "react"
import { toast } from "sonner"
import { Card, CardContent, CardFooter } from "@/components/ui/card"
import { Field, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "cn"
import {
getAuthAdditionalFieldValidators,
isAuthFormFieldInvalid,
useAuthForm
} from "../../auth-form"
import { ChangeAvatar } from "./change-avatar"
export type UserProfileProps = {
className?: string
}
/**
* Render a profile card that lets the authenticated user view and update their display name, username, and avatar.
*
* @param className - Optional additional CSS class names applied to the card container
* @returns A JSX element containing the profile card with avatar upload and editable name/username fields
*/
export function UserProfile({ className }: UserProfileProps) {
const { additionalFields, authClient, avatar, localization, profile } =
useAuth<UsernameAuthClient>()
const { data: session } = useSession(authClient)
const { mutateAsync: updateUser, isPending } = useUpdateUser(authClient, {
onSuccess: () => toast.success(localization.settings.profileUpdatedSuccess)
})
const profileFields = useMemo(
() => additionalFields?.filter((field) => field.profile !== false) ?? [],
[additionalFields]
)
const hasProfileFields =
profile.name || profileFields.some((field) => field.inputType !== "hidden")
const form = useAuthForm({
defaultValues: {
additionalFields: getAdditionalFieldDefaultValues(profileFields),
name: ""
},
onSubmit: async ({ value }) => {
await updateUser({
...(profile.name && { name: value.name }),
...getAdditionalFieldSubmitValues(profileFields, value.additionalFields)
})
}
})
useEffect(() => {
if (!session) return
form.reset({
additionalFields: getAdditionalFieldDefaultValues(
fieldsWithModelValues(
profileFields,
session.user as Record<string, unknown>
)
),
name: session.user.name
})
}, [form, profileFields, session])
if (!avatar.enabled && !hasProfileFields) return null
return (
<div>
<h2 className="text-sm font-semibold mb-3">
{localization.settings.userProfile}
</h2>
<form.AppForm>
<form.AuthFormRoot>
<Card className={cn(className)}>
<CardContent className="flex flex-col gap-6">
<ChangeAvatar />
{profile.name && (
<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="name">
{localization.auth.name}
</FieldLabel>
{session ? (
<Input
id="name"
name={field.name}
autoComplete="name"
placeholder={localization.auth.name}
disabled={isPending}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) =>
field.handleChange(event.target.value)
}
aria-invalid={isInvalid}
/>
) : (
<Skeleton>
<Input className="invisible" />
</Skeleton>
)}
<field.AuthFormFieldError />
</Field>
)
}}
</form.AppField>
)}
{profileFields.map((configuredField) => {
if (!session) {
if (configuredField.inputType === "hidden") {
return null
}
return (
<Skeleton key={configuredField.name}>
<Input className="invisible" />
</Skeleton>
)
}
return (
<form.AppField
key={configuredField.name}
name={`additionalFields.${configuredField.name}`}
validators={getAuthAdditionalFieldValidators(
configuredField,
localization.auth.fieldRequired
)}
>
{(field) => (
<field.AuthFormAdditionalField
field={configuredField}
isPending={isPending}
/>
)}
</form.AppField>
)
})}
</CardContent>
{hasProfileFields && (
<CardFooter>
<form.AuthFormSubmitButton
size="sm"
disabled={isPending || !session}
>
{localization.settings.saveChanges}
</form.AuthFormSubmitButton>
</CardFooter>
)}
</Card>
</form.AuthFormRoot>
</form.AppForm>
</div>
)
}
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>
)
}