better-auth-ui
componentemail-otpsource: ok · refreshed
Email OTP
Email OTP plugin: passwordless sign-in with an emailed code, plus code-based replacements for email verification, password reset, and email changes.
npx shadcn add "https://better-auth-ui.com/r/{style}/email-otp.json"free
Where it goes
login
Visual style
terminal-mono
Use cases
saas-landing
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
Registry dependencies
alertbadgebuttoncalendarcardcheckboxcomboboxfieldhttps://better-auth-ui.com/r/radix-nova/account-settings.jsoninputinput-groupinput-otppopoverselectskeletonslidersonnerspinnerswitchtextareatooltip
Files21 · source cached 10/8/2026, 1:51:31 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/email-otp/change-email-otp.tsx
"use client"
import type { EmailOtpAuthClient } from "@better-auth-ui/core/plugins/email-otp"
import { useAuth, useAuthPlugin, useSession } from "@better-auth-ui/react"
import {
useChangeEmailOtp,
useRequestEmailChangeOtp,
useSendVerificationOtp
} from "@better-auth-ui/react/plugins/email-otp"
import { useSelector } from "@tanstack/react-form"
import { useEffect, useReducer } from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
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 { emailOtpPlugin } from "@/lib/auth/email-otp-plugin"
import { cn } from "cn"
import { submitAuthForm, useAuthForm } from "../auth-form"
import { OpenEmailButton } from "../open-email-button"
import { OtpField } from "../otp-field"
type ChangeEmailStep = "email" | "currentCode" | "newCode"
type ChangeEmailState = {
step: ChangeEmailStep
newEmail: string
}
type ChangeEmailAction =
| { type: "currentEmailChallenged"; newEmail: string }
| { type: "changeRequested"; newEmail: string }
| { type: "restarted" }
const initialChangeEmailState: ChangeEmailState = {
step: "email",
newEmail: ""
}
// Every step is reachable from the action alone, so the previous state never
// takes part in the transition.
function changeEmailReducer(
_state: ChangeEmailState,
action: ChangeEmailAction
): ChangeEmailState {
switch (action.type) {
case "currentEmailChallenged":
return { step: "currentCode", newEmail: action.newEmail }
case "changeRequested":
return { step: "newCode", newEmail: action.newEmail }
case "restarted":
return initialChangeEmailState
}
}
export type ChangeEmailOtpProps = {
className?: string
}
/**
* Change the account email with codes instead of a confirmation link.
*
* Replaces the built-in `<ChangeEmail />` card when the email-OTP plugin runs
* with `changeEmail: true`. With `verifyCurrentEmail` on it is a three-step
* flow — confirm the current address, then the new one — and two steps
* otherwise.
*
* @param className - Additional CSS classes applied to the card.
*/
export function ChangeEmailOtp({ className }: ChangeEmailOtpProps) {
const { authClient, localization } = useAuth()
const {
localization: emailOtpLocalization,
otpLength,
verifyCurrentEmail
} = useAuthPlugin(emailOtpPlugin)
const otpClient = authClient as EmailOtpAuthClient
const { data: session } = useSession(otpClient)
const currentEmail = session?.user.email
const [state, dispatch] = useReducer(
changeEmailReducer,
initialChangeEmailState
)
// The step transition is attached per call: the code goes to the current
// address while the pending change targets the new one, so the address to
// remember isn't in this mutation's variables.
const { mutateAsync: sendVerificationOtp, isPending: isSending } =
useSendVerificationOtp(otpClient)
const { mutateAsync: requestEmailChangeOtp, isPending: isRequesting } =
useRequestEmailChangeOtp(otpClient, {
onError: () => form.setFieldValue("code", ""),
onSuccess: (_data, { newEmail }) => {
form.setFieldValue("code", "")
dispatch({ type: "changeRequested", newEmail })
}
})
const { mutateAsync: changeEmailOtp, isPending: isChanging } =
useChangeEmailOtp(otpClient, {
onError: () => form.setFieldValue("code", ""),
onSuccess: () => {
toast.success(localization.settings.changeEmailSuccess)
resetFlow()
}
})
const isPending = isSending || isRequesting || isChanging
const submitCode = async (completedCode: string) => {
if (isPending || state.step === "email") return
if (state.step === "currentCode") {
await requestEmailChangeOtp({
newEmail: state.newEmail,
otp: completedCode
})
return
}
await changeEmailOtp({ newEmail: state.newEmail, otp: completedCode })
}
const form = useAuthForm({
defaultValues: { code: "", email: "" },
onSubmit: async ({ value }) => {
if (state.step === "email") {
const newEmail = value.email
if (verifyCurrentEmail && currentEmail) {
await sendVerificationOtp(
{ email: currentEmail, type: "change-email" },
{
onSuccess: () =>
dispatch({ type: "currentEmailChallenged", newEmail })
}
)
return
}
await requestEmailChangeOtp({ newEmail })
return
}
await submitCode(value.code)
}
})
const codeComplete = useSelector(
form.store,
(formState) => formState.values.code.length === otpLength
)
const resetFlow = () => {
form.reset()
if (currentEmail) form.setFieldValue("email", currentEmail)
dispatch({ type: "restarted" })
}
useEffect(() => {
if (currentEmail) form.setFieldValue("email", currentEmail)
}, [currentEmail, form])
const codeTarget =
state.step === "currentCode" ? currentEmail : state.newEmail
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">
{state.step === "email" ? (
<form.AppField name="email">
{(field) => (
<Field>
<FieldLabel htmlFor="email">
{localization.auth.email}
</FieldLabel>
{session ? (
<Input
key={currentEmail}
id="email"
name="email"
type="email"
autoComplete="email"
value={field.state.value}
placeholder={localization.auth.emailPlaceholder}
disabled={isPending}
required
onBlur={field.handleBlur}
onChange={(event) =>
field.handleChange(event.target.value)
}
/>
) : (
<Skeleton>
<Input className="invisible" />
</Skeleton>
)}
<field.AuthFormFieldError />
</Field>
)}
</form.AppField>
) : (
<div className="flex flex-col gap-3">
<p className="text-muted-foreground text-sm">
{emailOtpLocalization.confirmEmailDescription.replace(
"{{email}}",
codeTarget ?? ""
)}
</p>
<form.AppField name="code">
{(field) => (
<OtpField
autoFocus
disabled={isPending}
label={
state.step === "currentCode"
? emailOtpLocalization.confirmCurrentEmail
: emailOtpLocalization.confirmNewEmail
}
length={otpLength}
name="otp"
value={field.state.value}
onChange={field.handleChange}
onComplete={() => void submitAuthForm(form)}
/>
)}
</form.AppField>
<form.AuthFormServerError />
{codeTarget && (
<OpenEmailButton email={codeTarget} variant="secondary" />
)}
</div>
)}
</CardContent>
<CardFooter className="gap-3">
{state.step !== "email" && (
<Button
type="button"
size="sm"
variant="outline"
disabled={isPending}
onClick={resetFlow}
>
{localization.settings.cancel}
</Button>
)}
<form.AuthFormSubmitButton
isPending={isPending}
size="sm"
disabled={
isPending ||
!session ||
(state.step !== "email" && !codeComplete)
}
>
{state.step === "email"
? localization.settings.updateEmail
: emailOtpLocalization.verifyCode}
</form.AuthFormSubmitButton>
</CardFooter>
</Card>
</form.AuthFormRoot>
</form.AppForm>
</div>
)
}
examples/start-shadcn-example/src/components/auth/email-otp/email-otp-button.tsx
"use client"
import {
type AuthView,
authMutationKeys,
getAuthLinkURL
} from "@better-auth-ui/core"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import { useIsMutating } from "@tanstack/react-query"
import { KeyRound, Lock } from "lucide-react"
import { buttonVariants } from "@/components/ui/button"
import { emailOtpPlugin } from "@/lib/auth/email-otp-plugin"
import { cn } from "cn"
export type EmailOtpButtonProps = {
/** @remarks `AuthView` */
view?: AuthView
}
/**
* Toggle button between password sign-in and the emailed-code route.
*
* @param view - Current auth view. On `"emailOtp"` this links back to password sign-in.
*/
export function EmailOtpButton({ view }: EmailOtpButtonProps) {
const {
basePaths,
emailAndPassword,
localization,
redirectTo,
viewPaths,
Link
} = useAuth()
const { localization: emailOtpLocalization, viewPaths: emailOtpViewPaths } =
useAuthPlugin(emailOtpPlugin)
const signInMutating = useIsMutating({
mutationKey: authMutationKeys.signIn.all
})
const signUpMutating = useIsMutating({
mutationKey: authMutationKeys.signUp.all
})
const isPending = signInMutating + signUpMutating > 0
const isEmailOtpView = view === "emailOtp"
// On the code view this button switches back to password sign-in. With
// password auth disabled there's nowhere to switch to, so hide it.
if (isEmailOtpView && !emailAndPassword?.enabled) return null
return (
<Link
href={getAuthLinkURL(
`${basePaths.auth}/${isEmailOtpView ? viewPaths.auth.signIn : emailOtpViewPaths.auth.emailOtp}`,
redirectTo
)}
aria-disabled={isPending || undefined}
tabIndex={isPending ? -1 : undefined}
onClick={(event) => {
if (isPending) event.preventDefault()
}}
className={cn(
buttonVariants({ variant: "outline" }),
"w-full",
isPending && "opacity-50 pointer-events-none"
)}
>
{isEmailOtpView ? <Lock /> : <KeyRound />}
{localization.auth.continueWith.replace(
"{{provider}}",
isEmailOtpView
? localization.auth.password
: emailOtpLocalization.emailOtp
)}
</Link>
)
}
examples/start-shadcn-example/src/components/auth/email-otp/email-otp.tsx
"use client"
import { authMutationKeys } from "@better-auth-ui/core"
import type { EmailOtpAuthClient } from "@better-auth-ui/core/plugins/email-otp"
import { getSsoFallbackEmail } from "@better-auth-ui/core/plugins/sso"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import {
useSendVerificationOtp,
useSignInEmailOtp
} from "@better-auth-ui/react/plugins/email-otp"
import { useSelector } from "@tanstack/react-form"
import { useIsMutating } from "@tanstack/react-query"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@/components/ui/card"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSeparator
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { emailOtpPlugin } from "@/lib/auth/email-otp-plugin"
import { useResendCooldown } from "@/lib/auth/use-resend-cooldown"
import { useSignInContinuation } from "@/lib/auth/use-sign-in-continuation"
import { cn } from "cn"
import { runAuthFormAction, submitAuthForm, useAuthForm } from "../auth-form"
import { OpenEmailButton } from "../open-email-button"
import { OtpField } from "../otp-field"
import { ProviderButtons, type SocialLayout } from "../provider-buttons"
import { ReauthenticationNotice } from "../reauthentication"
export type EmailOtpProps = {
className?: string
socialLayout?: SocialLayout
socialPosition?: "top" | "bottom"
}
/**
* Passwordless sign-in with an emailed one-time code.
*
* Two steps on one route: enter an email, then enter the code that arrives.
* The email step never reveals whether an account exists — the server decides
* whether the code creates an account, mirroring `emailOTP({ disableSignUp })`.
*
* @param socialLayout - Provider button layout.
* @param socialPosition - `"top"` or `"bottom"`. Defaults to `"bottom"`.
*/
export function EmailOtp({
className,
socialLayout,
socialPosition = "bottom"
}: EmailOtpProps) {
const {
authClient,
basePaths,
emailAndPassword,
localization,
plugins,
socialProviders,
viewPaths,
Link
} = useAuth()
const { localization: emailOtpLocalization, otpLength } =
useAuthPlugin(emailOtpPlugin)
const otpClient = authClient as EmailOtpAuthClient
const continueSignIn = useSignInContinuation()
const { cooldown, isCoolingDown, startCooldown } = useResendCooldown()
const [codeSent, setCodeSent] = useState(false)
const { mutateAsync: sendVerificationOtp, isPending: isSending } =
useSendVerificationOtp(otpClient, {
onSuccess: () => {
setCodeSent(true)
startCooldown()
}
})
const { mutateAsync: signInEmailOtp, isPending: isSigningIn } =
useSignInEmailOtp(otpClient, {
onError: () => form.setFieldValue("code", ""),
onSuccess: (data) => continueSignIn(data)
})
const signInMutating = useIsMutating({
mutationKey: authMutationKeys.signIn.all
})
const signUpMutating = useIsMutating({
mutationKey: authMutationKeys.signUp.all
})
const isPending = signInMutating + signUpMutating > 0 || isSending
const sendCode = async () =>
await sendVerificationOtp({
email: form.state.values.email,
type: "sign-in"
})
const verifyCode = async (completedCode: string) => {
if (isPending || isSigningIn) return
await signInEmailOtp({
email: form.state.values.email,
otp: completedCode
})
}
const form = useAuthForm({
defaultValues: { code: "", email: getSsoFallbackEmail() },
onSubmit: async ({ value }) => {
if (!codeSent) {
await sendVerificationOtp({ email: value.email, type: "sign-in" })
return
}
await verifyCode(value.code)
}
})
const codeComplete = useSelector(
form.store,
(state) => state.values.code.length === otpLength
)
const email = useSelector(form.store, (state) => state.values.email)
const showSeparator = socialProviders && socialProviders.length > 0
return (
<Card className={cn("w-full max-w-sm", className)}>
<ReauthenticationNotice />
<CardHeader>
<CardTitle className="text-xl">{localization.auth.signIn}</CardTitle>
{codeSent && (
<CardDescription>
{emailOtpLocalization.codeSentTo.replace("{{email}}", email)}
</CardDescription>
)}
</CardHeader>
<CardContent>
<div className="flex flex-col gap-6">
{socialPosition === "top" && !codeSent && (
<>
{socialProviders && socialProviders.length > 0 && (
<ProviderButtons socialLayout={socialLayout} view="emailOtp" />
)}
{showSeparator && (
<FieldSeparator className="*:data-[slot=field-separator-content]:bg-card m-0 text-xs flex items-center">
{localization.auth.or}
</FieldSeparator>
)}
</>
)}
<form.AppForm>
<form.AuthFormRoot>
<FieldGroup>
{codeSent ? (
<form.AppField name="code">
{(field) => (
<OtpField
autoFocus
disabled={isPending || isSigningIn}
label={emailOtpLocalization.code}
length={otpLength}
name={field.name}
value={field.state.value}
onChange={field.handleChange}
onComplete={() => void submitAuthForm(form)}
/>
)}
</form.AppField>
) : (
<form.AppField name="email">
{(field) => (
<Field>
<FieldLabel htmlFor="email">
{localization.auth.email}
</FieldLabel>
<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}
required
disabled={isPending}
/>
<field.AuthFormFieldError />
</Field>
)}
</form.AppField>
)}
<form.AuthFormServerError />
<div className="flex flex-col gap-3">
<form.AuthFormSubmitButton
isPending={isSending || isSigningIn}
disabled={
isPending || isSigningIn || (codeSent && !codeComplete)
}
>
{codeSent
? emailOtpLocalization.verifyCode
: emailOtpLocalization.sendCode}
</form.AuthFormSubmitButton>
{codeSent ? (
<>
<OpenEmailButton email={email} variant="secondary" />
<Button
type="button"
variant="outline"
disabled={isPending || isSigningIn || isCoolingDown}
onClick={() => void runAuthFormAction(form, sendCode)}
>
{isCoolingDown
? localization.auth.resendIn.replace(
"{{seconds}}",
String(cooldown)
)
: localization.auth.resend}
</Button>
<Button
type="button"
variant="ghost"
disabled={isPending || isSigningIn}
onClick={() => {
setCodeSent(false)
form.setFieldValue("code", "")
}}
>
{emailOtpLocalization.useDifferentEmail}
</Button>
</>
) : (
plugins.flatMap((plugin) =>
(plugin.authButtons ?? []).map((AuthButton, index) => (
<AuthButton
key={`${plugin.id}-${index.toString()}`}
view="emailOtp"
/>
))
)
)}
</div>
</FieldGroup>
</form.AuthFormRoot>
</form.AppForm>
{socialPosition === "bottom" && !codeSent && (
<>
{showSeparator && (
<FieldSeparator className="*:data-[slot=field-separator-content]:bg-card text-xs flex items-center">
{localization.auth.or}
</FieldSeparator>
)}
{socialProviders && socialProviders.length > 0 && (
<ProviderButtons socialLayout={socialLayout} view="emailOtp" />
)}
</>
)}
</div>
{emailAndPassword?.enabled && (
<div className="flex flex-col gap-3 items-center w-full mt-4">
<FieldDescription className="text-center">
{localization.auth.needToCreateAnAccount}{" "}
<Link
href={`${basePaths.auth}/${viewPaths.auth.signUp}`}
className="underline underline-offset-4"
>
{localization.auth.signUp}
</Link>
</FieldDescription>
</div>
)}
</CardContent>
</Card>
)
}
examples/start-shadcn-example/src/components/auth/email-otp/forgot-password-otp.tsx
"use client"
import { getAuthLinkURL, validateEmailAddress } from "@better-auth-ui/core"
import type { EmailOtpAuthClient } from "@better-auth-ui/core/plugins/email-otp"
import { useAuth, useAuthPlugin, useFetchOptions } from "@better-auth-ui/react"
import { useRequestPasswordResetOtp } from "@better-auth-ui/react/plugins/email-otp"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { emailOtpPlugin } from "@/lib/auth/email-otp-plugin"
import { cn } from "cn"
import { isAuthFormFieldInvalid, useAuthForm } from "../auth-form"
/** `sessionStorage` key the reset-code form reads the pending address from. */
export const RESET_PASSWORD_OTP_STORAGE_KEY =
"better-auth-ui.reset-password-otp"
export type ForgotPasswordOtpProps = {
className?: string
}
/**
* Request a password-reset code instead of a reset link.
*
* Replaces the built-in `<ForgotPassword />` view when the email-OTP plugin
* runs with `passwordReset: true`. On success the address is stored and the
* user continues on `/auth/reset-password`, which asks for the code and the
* new password — the reset-link-sent view is skipped entirely.
*
* @param className - Additional CSS classes applied to the card.
*/
export function ForgotPasswordOtp({ className }: ForgotPasswordOtpProps) {
const {
authClient,
basePaths,
localization,
navigate,
plugins,
redirectTo,
viewPaths,
Link
} = useAuth()
const { localization: emailOtpLocalization } = useAuthPlugin(emailOtpPlugin)
const { fetchOptions, resetFetchOptions } = useFetchOptions()
const { mutateAsync: requestPasswordResetOtp, isPending } =
useRequestPasswordResetOtp(authClient as EmailOtpAuthClient, {
onError: () => resetFetchOptions(),
onSuccess: (_data, { email }) => {
sessionStorage.setItem(RESET_PASSWORD_OTP_STORAGE_KEY, email)
navigate({ to: `${basePaths.auth}/${viewPaths.auth.resetPassword}` })
}
})
const form = useAuthForm({
defaultValues: { email: "" },
onSubmit: async ({ value }) =>
await requestPasswordResetOtp({ email: value.email, fetchOptions })
})
const Captcha = plugins.find(
(plugin) => plugin.captchaComponent
)?.captchaComponent
return (
<Card className={cn("w-full max-w-sm", className)}>
<CardHeader>
<CardTitle className="text-xl">
{localization.auth.forgotPassword}
</CardTitle>
</CardHeader>
<CardContent>
<form.AppForm>
<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="email">
{localization.auth.email}
</FieldLabel>
<Input
id="email"
name={field.name}
type="email"
autoComplete="email"
placeholder={localization.auth.emailPlaceholder}
required
disabled={isPending}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) =>
field.handleChange(event.target.value)
}
aria-invalid={isInvalid}
/>
<field.AuthFormFieldError />
</Field>
)
}}
</form.AppField>
{Captcha && <div className="flex justify-center">{Captcha}</div>}
<form.AuthFormServerError />
<form.AuthFormSubmitButton
isPending={isPending}
disabled={isPending}
>
{emailOtpLocalization.sendCode}
</form.AuthFormSubmitButton>
</FieldGroup>
</form.AuthFormRoot>
</form.AppForm>
<div className="flex flex-col gap-3 items-center w-full mt-4">
<FieldDescription className="text-center">
{localization.auth.rememberYourPassword}{" "}
<Link
href={getAuthLinkURL(
`${basePaths.auth}/${viewPaths.auth.signIn}`,
redirectTo
)}
className="underline underline-offset-4"
>
{localization.auth.signIn}
</Link>
</FieldDescription>
</div>
</CardContent>
</Card>
)
}
examples/start-shadcn-example/src/components/auth/email-otp/reset-password-otp.tsx
"use client"
import {
getAuthLinkURL,
isPasswordCompromisedError,
validateEmailAddress,
validateMatchingValue,
validateStringLength
} from "@better-auth-ui/core"
import type { EmailOtpAuthClient } from "@better-auth-ui/core/plugins/email-otp"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import { useResetPasswordOtp } from "@better-auth-ui/react/plugins/email-otp"
import { useSelector } from "@tanstack/react-form"
import { Eye, EyeOff } from "lucide-react"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@/components/ui/card"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput
} from "@/components/ui/input-group"
import { emailOtpPlugin } from "@/lib/auth/email-otp-plugin"
import { cn } from "cn"
import {
isAuthFormFieldInvalid,
setAuthFormServerError,
submitAuthForm,
useAuthForm
} from "../auth-form"
import { OpenEmailButton } from "../open-email-button"
import { OtpField } from "../otp-field"
import { PasswordStrengthMeter } from "../password-strength-meter"
import { useIsHydrated } from "../use-is-hydrated"
import { RESET_PASSWORD_OTP_STORAGE_KEY } from "./forgot-password-otp"
export type ResetPasswordOtpProps = {
className?: string
}
/**
* Reset a password with an emailed code.
*
* Replaces the built-in `<ResetPassword />` view when the email-OTP plugin
* runs with `passwordReset: true`. There is no token in the URL — the code
* and the new password are submitted together. The address comes from the
* forgot-password step, and is asked for again when it isn't there (e.g. the
* user finishes on another tab).
*
* @param className - Additional CSS classes applied to the card.
*/
export function ResetPasswordOtp({ className }: ResetPasswordOtpProps) {
const {
authClient,
basePaths,
emailAndPassword,
localization,
navigate,
redirectTo,
viewPaths,
Link
} = useAuth()
const { localization: emailOtpLocalization, otpLength } =
useAuthPlugin(emailOtpPlugin)
const isHydrated = useIsHydrated()
const initialEmail =
(isHydrated && sessionStorage.getItem(RESET_PASSWORD_OTP_STORAGE_KEY)) || ""
const [hasStoredEmail, setHasStoredEmail] = useState(Boolean(initialEmail))
const [isPasswordVisible, setIsPasswordVisible] = useState(false)
const { mutateAsync: resetPasswordOtp, isPending } = useResetPasswordOtp(
authClient as EmailOtpAuthClient,
{
onError: (error) => {
// The haveIBeenPwned plugin rejects on the password itself, so it
// belongs against the field rather than in a toast.
if (isPasswordCompromisedError(error)) {
setAuthFormServerError(
form,
{ fields: { password: localization.auth.passwordCompromised } },
localization.auth.passwordCompromised
)
}
form.setFieldValue("code", "")
},
onSuccess: () => {
sessionStorage.removeItem(RESET_PASSWORD_OTP_STORAGE_KEY)
toast.success(localization.auth.passwordResetSuccess)
navigate({ to: `${basePaths.auth}/${viewPaths.auth.signIn}` })
}
}
)
const validatePassword = (value: string) =>
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
})
const form = useAuthForm({
defaultValues: {
code: "",
confirmPassword: "",
email: initialEmail,
password: ""
},
onSubmit: async ({ value }) => {
await resetPasswordOtp({
email: value.email,
otp: value.code,
password: value.password
})
}
})
const email = useSelector(form.store, (state) => state.values.email)
useEffect(() => {
const storedEmail =
sessionStorage.getItem(RESET_PASSWORD_OTP_STORAGE_KEY) ?? ""
form.setFieldValue("email", storedEmail)
setHasStoredEmail(Boolean(storedEmail))
}, [form.setFieldValue])
return (
<Card className={cn("w-full max-w-sm", className)}>
<CardHeader>
<CardTitle className="text-xl font-semibold">
{localization.auth.resetPassword}
</CardTitle>
{hasStoredEmail && email && (
<CardDescription>
{emailOtpLocalization.codeSentTo.replace("{{email}}", email)}
</CardDescription>
)}
</CardHeader>
<CardContent>
<form.AppForm>
<form.AuthFormRoot>
<FieldGroup>
{!hasStoredEmail && (
<form.AppField
name="email"
validators={{
onChange: ({ value }) =>
validateEmailAddress(value, {
invalidMessage: localization.auth.invalidEmail,
requiredMessage: localization.auth.fieldRequired
})
}}
>
{(field) => (
<field.AuthFormTextField
autoComplete="email"
disabled={isPending}
id="email"
label={localization.auth.email}
placeholder={localization.auth.emailPlaceholder}
required
type="email"
/>
)}
</form.AppField>
)}
<form.AppField
name="code"
validators={{
onChange: ({ value }) =>
value.length === otpLength
? undefined
: emailOtpLocalization.codeLengthMismatch.replace(
"{{length}}",
String(otpLength)
)
}}
>
{(field) => (
<OtpField
autoFocus={hasStoredEmail}
disabled={isPending}
label={emailOtpLocalization.code}
length={otpLength}
name="otp"
value={field.state.value}
onChange={field.handleChange}
onComplete={() => void submitAuthForm(form)}
/>
)}
</form.AppField>
<form.AppField
name="password"
validators={{
onChange: ({ value }) => validatePassword(value)
}}
>
{(field) => (
<Field
data-invalid={isAuthFormFieldInvalid(field.state.meta)}
>
<FieldLabel htmlFor="password">
{localization.auth.newPassword}
</FieldLabel>
<InputGroup>
<InputGroupInput
id="password"
name={field.name}
type={isPasswordVisible ? "text" : "password"}
autoComplete="new-password"
placeholder={localization.auth.newPasswordPlaceholder}
required
minLength={emailAndPassword?.minPasswordLength}
maxLength={emailAndPassword?.maxPasswordLength}
disabled={isPending}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) =>
field.handleChange(event.target.value)
}
aria-invalid={isAuthFormFieldInvalid(field.state.meta)}
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label={
isPasswordVisible
? localization.auth.hidePassword
: localization.auth.showPassword
}
title={
isPasswordVisible
? localization.auth.hidePassword
: localization.auth.showPassword
}
onClick={() =>
setIsPasswordVisible((visible) => !visible)
}
>
{isPasswordVisible ? <EyeOff /> : <Eye />}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<field.AuthFormFieldError />
<PasswordStrengthMeter password={field.state.value} />
</Field>
)}
</form.AppField>
{emailAndPassword?.confirmPassword && (
<form.AppField
name="confirmPassword"
validators={{
onChangeListenTo: ["password"],
onChange: ({ value, fieldApi }) =>
validateMatchingValue(
value,
fieldApi.form.getFieldValue("password"),
localization.auth.passwordsDoNotMatch
)
}}
>
{(field) => (
<Field
data-invalid={isAuthFormFieldInvalid(field.state.meta)}
>
<FieldLabel htmlFor="confirmPassword">
{localization.auth.confirmPassword}
</FieldLabel>
<Input
id="confirmPassword"
name={field.name}
type="password"
autoComplete="new-password"
placeholder={
localization.auth.confirmPasswordPlaceholder
}
required
minLength={emailAndPassword?.minPasswordLength}
maxLength={emailAndPassword?.maxPasswordLength}
disabled={isPending}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) =>
field.handleChange(event.target.value)
}
aria-invalid={isAuthFormFieldInvalid(field.state.meta)}
/>
<field.AuthFormFieldError />
</Field>
)}
</form.AppField>
)}
<div className="flex flex-col gap-3">
<form.AuthFormSubmitButton disabled={isPending}>
{localization.auth.resetPassword}
</form.AuthFormSubmitButton>
{email && <OpenEmailButton email={email} variant="secondary" />}
</div>
<form.AuthFormServerError />
</FieldGroup>
</form.AuthFormRoot>
</form.AppForm>
<div className="flex flex-col gap-3 items-center w-full mt-4">
<FieldDescription className="text-center">
{localization.auth.rememberYourPassword}{" "}
<Link
href={getAuthLinkURL(
`${basePaths.auth}/${viewPaths.auth.signIn}`,
redirectTo
)}
className="underline underline-offset-4"
>
{localization.auth.signIn}
</Link>
</FieldDescription>
</div>
</CardContent>
</Card>
)
}
examples/start-shadcn-example/src/components/auth/email-otp/verify-email-otp.tsx
"use client"
import { getAuthLinkURL } from "@better-auth-ui/core"
import type { EmailOtpAuthClient } from "@better-auth-ui/core/plugins/email-otp"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import {
useSendVerificationOtp,
useVerifyEmailOtp
} from "@better-auth-ui/react/plugins/email-otp"
import { useSelector } from "@tanstack/react-form"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@/components/ui/card"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { emailOtpPlugin } from "@/lib/auth/email-otp-plugin"
import {
RESEND_COOLDOWN_SECONDS,
useResendCooldown
} from "@/lib/auth/use-resend-cooldown"
import { cn } from "cn"
import { runAuthFormAction, submitAuthForm, useAuthForm } from "../auth-form"
import { OpenEmailButton } from "../open-email-button"
import { OtpField } from "../otp-field"
import { useIsHydrated } from "../use-is-hydrated"
/** `sessionStorage` key the sign-up and sign-in flows store the pending address under. */
export const VERIFY_EMAIL_STORAGE_KEY = "better-auth-ui.verify-email"
export type VerifyEmailOtpProps = {
className?: string
}
/**
* Verify an email address with a code instead of a link.
*
* Replaces the built-in `<VerifyEmail />` view when the email-OTP plugin runs
* with `emailVerification: true`. The address comes from session storage when
* sign-up or sign-in put it there; otherwise the user types it and requests a
* code. Sign-up already triggered a send, so the resend button starts on
* cooldown just like the link-based view.
*
* @param className - Additional CSS classes applied to the card.
*/
export function VerifyEmailOtp({ className }: VerifyEmailOtpProps) {
const {
authClient,
basePaths,
localization,
navigate,
redirectTo,
viewPaths,
Link
} = useAuth()
const { localization: emailOtpLocalization, otpLength } =
useAuthPlugin(emailOtpPlugin)
const otpClient = authClient as EmailOtpAuthClient
const isHydrated = useIsHydrated()
const [email, setEmail] = useState(
(isHydrated && sessionStorage.getItem(VERIFY_EMAIL_STORAGE_KEY)) || ""
)
const { cooldown, isCoolingDown, startCooldown } = useResendCooldown()
// Sign-up already sent a code to this address, so restoring it also starts
// the cooldown — otherwise the hydrated render would offer an immediate
// resend and walk straight into the server's rate limit.
useEffect(() => {
const pendingEmail = sessionStorage.getItem(VERIFY_EMAIL_STORAGE_KEY) ?? ""
setEmail(pendingEmail)
if (pendingEmail) startCooldown(RESEND_COOLDOWN_SECONDS)
}, [startCooldown])
const { mutateAsync: sendVerificationOtp, isPending: isSending } =
useSendVerificationOtp(otpClient, {
onSuccess: (_data, { email: sentTo }) => {
sessionStorage.setItem(VERIFY_EMAIL_STORAGE_KEY, sentTo)
setEmail(sentTo)
startCooldown()
toast.success(emailOtpLocalization.codeSent)
}
})
const { mutateAsync: verifyEmailOtp, isPending: isVerifying } =
useVerifyEmailOtp(otpClient, {
onError: () => form.setFieldValue("code", ""),
onSuccess: () => {
sessionStorage.removeItem(VERIFY_EMAIL_STORAGE_KEY)
toast.success(emailOtpLocalization.emailVerified)
navigate({ to: redirectTo })
}
})
const isPending = isSending || isVerifying
const verifyCode = async (completedCode: string) => {
if (isPending || !email) return
await verifyEmailOtp({ email, otp: completedCode })
}
const form = useAuthForm({
defaultValues: { code: "", email: "" },
onSubmit: async ({ value }) => {
if (!email) {
await sendVerificationOtp({
email: value.email,
type: "email-verification"
})
return
}
await verifyCode(value.code)
}
})
const codeComplete = useSelector(
form.store,
(state) => state.values.code.length === otpLength
)
return (
<Card className={cn("w-full max-w-sm", className)}>
<CardHeader>
<CardTitle className="text-xl">
{localization.auth.verifyEmail}
</CardTitle>
{email && (
<CardDescription>
{emailOtpLocalization.codeSentTo.replace("{{email}}", email)}
</CardDescription>
)}
</CardHeader>
<CardContent>
<form.AppForm>
<form.AuthFormRoot>
<FieldGroup>
{email ? (
<form.AppField name="code">
{(field) => (
<OtpField
autoFocus
disabled={isPending}
label={emailOtpLocalization.code}
length={otpLength}
name="otp"
value={field.state.value}
onChange={field.handleChange}
onComplete={() => void submitAuthForm(form)}
/>
)}
</form.AppField>
) : (
<form.AppField name="email">
{(field) => (
<Field>
<FieldLabel htmlFor="email">
{localization.auth.email}
</FieldLabel>
<Input
id="email"
name={field.name}
type="email"
autoComplete="email"
placeholder={localization.auth.emailPlaceholder}
required
disabled={isPending}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) =>
field.handleChange(event.target.value)
}
/>
<field.AuthFormFieldError />
</Field>
)}
</form.AppField>
)}
<form.AuthFormServerError />
<div className="flex flex-col gap-3">
<form.AuthFormSubmitButton
isPending={isPending}
disabled={isPending || (Boolean(email) && !codeComplete)}
>
{email
? emailOtpLocalization.verifyCode
: emailOtpLocalization.sendCode}
</form.AuthFormSubmitButton>
{email && <OpenEmailButton email={email} variant="secondary" />}
{email && (
<Button
type="button"
variant="outline"
disabled={isPending || isCoolingDown}
onClick={() =>
void runAuthFormAction(form, () =>
sendVerificationOtp({
email,
type: "email-verification"
})
)
}
>
{isCoolingDown
? localization.auth.resendIn.replace(
"{{seconds}}",
String(cooldown)
)
: localization.auth.resend}
</Button>
)}
</div>
</FieldGroup>
</form.AuthFormRoot>
</form.AppForm>
<div className="flex flex-col gap-3 items-center w-full mt-4">
<FieldDescription className="text-center">
{localization.auth.alreadyVerifiedYourEmail}{" "}
<Link
href={getAuthLinkURL(
`${basePaths.auth}/${viewPaths.auth.signIn}`,
redirectTo
)}
className="underline underline-offset-4"
>
{localization.auth.signIn}
</Link>
</FieldDescription>
</div>
</CardContent>
</Card>
)
}
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/open-email-button.tsx
"use client"
import { createQrCodeSvgData, getEmailProviderLink } from "@better-auth-ui/core"
import { useAuth } from "@better-auth-ui/react"
import type { VariantProps } from "class-variance-authority"
import { QrCode } from "lucide-react"
import { useMemo } from "react"
import { buttonVariants } from "@/components/ui/button"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@/components/ui/tooltip"
import { cn } from "cn"
export type OpenEmailButtonProps = {
/** Email address used to detect the provider, e.g. from the verify-email flow. */
email: string
className?: string
/**
* Button variant. Defaults to the primary style for dead-end views where
* opening the inbox is the only action; pass `"secondary"` where it sits
* beside a submit button that should stay the primary call to action.
*/
variant?: VariantProps<typeof buttonVariants>["variant"]
}
/**
* Render a button that opens the user's email provider login page in a new
* tab. Hovering or focusing the button reveals a QR code for opening the same
* provider on another device.
*
* The provider is resolved from the email domain via the curated
* `@mikkelscheike/email-provider-links` dataset (Gmail, Outlook, GMX, etc.).
* Renders nothing when the domain is empty or not a known provider.
*
* @param email - Email address to resolve the provider from.
* @param className - Additional CSS classes applied to the button.
* @param variant - Button variant. Defaults to the primary style.
* @returns The open-email button, or `null` when no provider matches.
*/
export function OpenEmailButton({
email,
className,
variant
}: OpenEmailButtonProps) {
const { localization } = useAuth()
const provider = getEmailProviderLink(email)
const loginUrl = provider?.loginUrl
const qrCode = useMemo(
() => (loginUrl ? createQrCodeSvgData(loginUrl) : null),
[loginUrl]
)
if (!provider || !qrCode) return null
const scanLabel = localization.auth.scanToOpenEmailProvider.replace(
"{{provider}}",
provider.companyProvider
)
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
type="button"
className={cn(buttonVariants({ variant }), "w-full", className)}
onClick={() =>
window.open(provider.loginUrl, "_blank", "noopener,noreferrer")
}
>
{localization.auth.openEmailProvider.replace(
"{{provider}}",
provider.companyProvider
)}
<QrCode data-icon="inline-end" />
</TooltipTrigger>
<TooltipContent
sideOffset={8}
className="flex-col items-center gap-2 p-3"
>
<svg
viewBox={`0 0 ${qrCode.size} ${qrCode.size}`}
aria-hidden="true"
focusable="false"
className="size-40"
>
<path fill="white" d={`M0 0h${qrCode.size}v${qrCode.size}H0z`} />
<path fill="black" d={qrCode.path} shapeRendering="crispEdges" />
</svg>
<p className="max-w-40 text-center leading-snug">{scanLabel}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
examples/start-shadcn-example/src/components/auth/otp-field.tsx
"use client"
import { REGEXP_ONLY_DIGITS } from "input-otp"
import { useId } from "react"
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import {
InputOTP,
InputOTPGroup,
InputOTPSlot
} from "@/components/ui/input-otp"
import { cn } from "cn"
export type OtpFieldProps = {
/** Visible label rendered above the slots. */
label: string
/** Number of slots — keep in sync with the server's code length. */
length: number
value: string
onChange: (value: string) => void
onComplete?: (value: string) => void
autoFocus?: boolean
className?: string
errorMessage?: string
disabled?: boolean
name?: string
}
/** Strip everything the numeric slots can't hold — pasted codes often carry spaces or dashes. */
function normalizeCode(value: string) {
return value.replace(/\D/g, "")
}
/**
* Labelled one-time-code input.
*
* Shared by every code-based flow (email OTP, two-factor challenge,
* two-factor enrollment) so slot sizing, pasting, and error wiring behave the
* same everywhere.
*
* @param label - Visible label, also used as the accessible name.
* @param length - Number of code characters.
* @param errorMessage - Rendered below the slots when set.
*/
export function OtpField({
autoFocus,
className,
disabled,
errorMessage,
label,
length,
name,
onChange,
onComplete,
value
}: OtpFieldProps) {
const inputId = useId()
return (
<Field className={cn(className)} data-invalid={!!errorMessage}>
<FieldLabel htmlFor={inputId}>{label}</FieldLabel>
<InputOTP
aria-invalid={!!errorMessage}
aria-label={label}
autoComplete="one-time-code"
autoFocus={autoFocus}
containerClassName="w-full justify-center"
disabled={disabled}
id={inputId}
inputMode="numeric"
maxLength={length}
name={name}
pasteTransformer={normalizeCode}
pattern={REGEXP_ONLY_DIGITS}
value={value}
onChange={(next) => onChange(normalizeCode(next))}
onComplete={(completedCode) =>
onComplete?.(normalizeCode(completedCode))
}
>
<InputOTPGroup>
{Array.from({ length }, (_, slotIndex) => (
<InputOTPSlot
index={slotIndex}
key={`otp-slot-${String(slotIndex + 1)}`}
/>
))}
</InputOTPGroup>
</InputOTP>
<FieldError>{errorMessage}</FieldError>
</Field>
)
}
examples/start-shadcn-example/src/components/auth/password-strength-meter.tsx
"use client"
import {
evaluatePasswordStrength,
type PasswordStrengthLevel
} from "@better-auth-ui/core"
import { useAuth } from "@better-auth-ui/react"
import { cn } from "cn"
/** Fixed segment identities, so the bars keep their own React keys. */
const STRENGTH_SEGMENTS = [1, 2, 3, 4] as const
type FilledLevel = Exclude<PasswordStrengthLevel, "empty">
const segmentColors: Record<FilledLevel, string> = {
weak: "bg-destructive",
fair: "bg-amber-500",
good: "bg-sky-500",
strong: "bg-emerald-500"
}
export type PasswordStrengthMeterProps = {
/** The password as typed. Renders nothing while it is empty. */
password: string
className?: string
}
/**
* Four-segment strength hint shown while someone picks a new password.
*
* Renders nothing when `emailAndPassword.strengthMeter` is off or the field is
* empty. The score never gates submission: your server rules stay the
* authority on what is acceptable.
*/
export function PasswordStrengthMeter({
password,
className
}: PasswordStrengthMeterProps) {
const { emailAndPassword, localization } = useAuth()
if (!emailAndPassword?.strengthMeter) return null
const { score, level } = evaluatePasswordStrength(password, {
minLength: emailAndPassword.minPasswordLength
})
if (level === "empty") return null
const levelLabels: Record<FilledLevel, string> = {
weak: localization.auth.passwordWeak,
fair: localization.auth.passwordFair,
good: localization.auth.passwordGood,
strong: localization.auth.passwordStrong
}
return (
<div className={cn("flex flex-col gap-1.5", className)}>
{/* Decorative: the live region below is what gets announced. */}
<div aria-hidden="true" className="flex gap-1">
{STRENGTH_SEGMENTS.map((segment) => (
<span
key={segment}
className={cn(
"h-1 flex-1 rounded-full bg-muted transition-colors",
segment <= score && segmentColors[level]
)}
/>
))}
</div>
<p aria-live="polite" className="text-muted-foreground text-xs">
{localization.auth.passwordStrength}:{" "}
<span className="font-medium text-foreground">
{levelLabels[level]}
</span>
</p>
</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/use-is-hydrated.ts
"use client"
import { useSyncExternalStore } from "react"
/**
* Returns `true` once the component is mounted on the client (hydrated) and
* `false` while rendering on the server, so client-only reads (e.g.
* `sessionStorage`) stay safe during SSR.
*
* @returns Whether the component has hydrated on the client.
*/
export function useIsHydrated() {
const subscribe = () => () => {}
return useSyncExternalStore(
subscribe,
() => true,
() => false
)
}
examples/start-shadcn-example/src/lib/auth/auth-plugin.ts
import type {
AuthPluginComponents,
AuthPlugin as AuthPluginPrimitive
} from "@better-auth-ui/react"
declare module "@better-auth-ui/core" {
/** Widens `useAuth().plugins` to the shadcn-typed `AuthPlugin`. */
interface AuthPluginRegister {
shadcn: AuthPlugin
}
}
/** Props the shadcn `<Auth>` router spreads onto plugin-contributed auth views. */
export type AuthViewProps = {
className?: string
socialLayout?: "auto" | "horizontal" | "vertical" | "grid"
socialPosition?: "top" | "bottom"
}
/** Props the shadcn `<Settings>` router spreads onto plugin-contributed settings views. */
export type SettingsViewProps = {
className?: string
}
/** Shadcn plugin type. Plugin authors import this from `@/lib/auth/auth-plugin`. */
export type AuthPlugin = AuthPluginPrimitive<
AuthPluginComponents,
AuthViewProps,
SettingsViewProps
>
examples/start-shadcn-example/src/lib/auth/email-otp-plugin.ts
import { createAuthPlugin } from "@better-auth-ui/core"
import {
emailOtpPlugin as coreEmailOtpPlugin,
type EmailOtpPluginOptions
} from "@better-auth-ui/core/plugins/email-otp"
import { ChangeEmailOtp } from "@/components/auth/email-otp/change-email-otp"
import { EmailOtp } from "@/components/auth/email-otp/email-otp"
import { EmailOtpButton } from "@/components/auth/email-otp/email-otp-button"
import { ForgotPasswordOtp } from "@/components/auth/email-otp/forgot-password-otp"
import { ResetPasswordOtp } from "@/components/auth/email-otp/reset-password-otp"
import { VerifyEmailOtp } from "@/components/auth/email-otp/verify-email-otp"
export const emailOtpPlugin = createAuthPlugin(
coreEmailOtpPlugin.id,
(options: EmailOtpPluginOptions = {}) => {
const plugin = coreEmailOtpPlugin(options)
return {
...plugin,
authButtons: plugin.signIn ? [EmailOtpButton] : [],
// Each flow is opt-in because it replaces a link-based view outright.
// Turning one on without the matching server option would leave the
// user waiting for a code that never arrives.
views: {
auth: {
...(plugin.signIn && { emailOtp: EmailOtp }),
...(plugin.emailVerification && { verifyEmail: VerifyEmailOtp }),
...(plugin.passwordReset && {
forgotPassword: ForgotPasswordOtp,
resetPassword: ResetPasswordOtp
})
}
},
// Conditional, not an override: when `emailAndPassword.enabled === false`
// the `<Auth>` router renders this at `/auth/sign-in` instead of the
// disabled password form.
...(plugin.signIn && {
fallbackViews: { auth: { signIn: EmailOtp } }
}),
...(plugin.changeEmail && {
cardOverrides: { account: { changeEmail: ChangeEmailOtp } }
})
}
}
)
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-resend-cooldown.ts
"use client"
import { useCallback, useEffect, useState } from "react"
/** Seconds a resend button stays disabled to keep users off the rate limit. */
export const RESEND_COOLDOWN_SECONDS = 60
/**
* Countdown state for "resend code" buttons.
*
* @param initialSeconds - Seconds to start at. Pass `0` when nothing has been
* sent yet, or the cooldown length when the flow arrives right after a send.
*/
export function useResendCooldown(initialSeconds = 0) {
const [cooldown, setCooldown] = useState(initialSeconds)
useEffect(() => {
if (cooldown <= 0) return
const interval = setInterval(() => {
setCooldown((current) => (current > 0 ? current - 1 : 0))
}, 1000)
return () => clearInterval(interval)
}, [cooldown])
// Stable so callers can start the cooldown from an effect without
// re-running it on every render.
const startCooldown = useCallback(
(seconds = RESEND_COOLDOWN_SECONDS) => setCooldown(seconds),
[]
)
return {
cooldown,
isCoolingDown: cooldown > 0,
startCooldown
}
}
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]
)
}