| 1 | "use client"; |
| 2 | |
| 3 | import { |
| 4 | type AdditionalField as AdditionalFieldConfig |
| 5 | type AdditionalFieldFormValue, |
| 6 | getFormFieldErrors, |
| 7 | resolveInputType, |
| 8 | } from "@better-auth-ui/core"; |
| 9 | import { useAuth, useCopyToClipboard |
| 10 | import { format } |
| 11 | import { CalendarIcon, Check |
| 12 | import { type ComponentType, |
| 13 | import { toast } |
| 14 | |
| 15 | import { buttonVariants } |
| 16 | import { Calendar } |
| 17 | import { Checkbox } |
| 18 | import { |
| 19 | Combobox, |
| 20 | ComboboxContent, |
| 21 | ComboboxEmpty, |
| 22 | ComboboxInput, |
| 23 | ComboboxItem, |
| 24 | ComboboxList, |
| 25 | } from "@/components/ui/combobox"; |
| 26 | import { |
| 27 | Field, |
| 28 | FieldContent, |
| 29 | FieldError, |
| 30 | FieldLabel, |
| 31 | } from "@/components/ui/field"; |
| 32 | import { Input } |
| 33 | import { |
| 34 | InputGroup, |
| 35 | InputGroupAddon, |
| 36 | InputGroupButton, |
| 37 | InputGroupInput, |
| 38 | } from "@/components/ui/input-group"; |
| 39 | import { |
| 40 | Popover, |
| 41 | PopoverContent, |
| 42 | PopoverTrigger, |
| 43 | } from "@/components/ui/popover"; |
| 44 | import { |
| 45 | Select, |
| 46 | SelectContent, |
| 47 | SelectItem, |
| 48 | SelectTrigger, |
| 49 | SelectValue, |
| 50 | } from "@/components/ui/select"; |
| 51 | import { Slider } |
| 52 | import { Switch } |
| 53 | import { Textarea } |
| 54 | import { cn } |
| 55 | |
| 56 | export type AdditionalFieldProps |
| 57 | name: string |
| 58 | field: AdditionalFieldConfig |
| 59 | value: AdditionalFieldFormValue |
| 60 | onBlur: () |
| 61 | onChange: (value |
| 62 | isInvalid?: boolean |
| 63 | errors?: unknown |
| 64 | isPending?: boolean |
| 65 | /** Complete suffix appended to labels for fields that are not required. */ |
| 66 | optionalLabel?: string |
| 67 | }; |
| 68 | |
| 69 | function valueToString(value: |
| 70 | if (value == |
| 71 | return value instanceof |
| 72 | } |
| 73 | |
| 74 | /** Convert a `defaultValue` into a `Date` for the calendar. */ |
| 75 | function toDate(value: |
| 76 | if (value instanceof |
| 77 | if (typeof value |
| 78 | const parsed = |
| 79 | return Number.isNaN |
| 80 | } |
| 81 | return undefined; |
| 82 | } |
| 83 | |
| 84 | /** Format a Date as `HH:mm:ss` for an `<input type="time">`. */ |
| 85 | function formatTime(date: |
| 86 | const pad = |
| 87 | return `${pad( |
| 88 | } |
| 89 | |
| 90 | /** 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. */ |
| 91 | function CopyButton({ |
| 92 | getValue, |
| 93 | isDisabled, |
| 94 | }: { |
| 95 | getValue: () |
| 96 | isDisabled?: boolean |
| 97 | }) { |
| 98 | const { localization |
| 99 | const { copied, |
| 100 | onError: (error) |
| 101 | toast.error(error |
| 102 | }); |
| 103 | |
| 104 | async function handleCopy |
| 105 | const value = |
| 106 | if (!value) |
| 107 | |
| 108 | await copy(value |
| 109 | } |
| 110 | |
| 111 | return ( |
| 112 | <InputGroupButton |
| 113 | aria-label={ |
| 114 | copied |
| 115 | ? localization.settings. |
| 116 | : localization.settings. |
| 117 | } |
| 118 | title={ |
| 119 | copied |
| 120 | ? localization.settings. |
| 121 | : localization.settings. |
| 122 | } |
| 123 | onClick={handleCopy} |
| 124 | disabled={isDisabled} |
| 125 | > |
| 126 | {copied ? <Check |
| 127 | </InputGroupButton> |
| 128 | ); |
| 129 | } |
| 130 | |
| 131 | /** Renders a single additional user field via shadcn primitives. */ |
| 132 | export function AdditionalField({ |
| 133 | name, |
| 134 | field: configuredField, |
| 135 | value, |
| 136 | onBlur, |
| 137 | onChange, |
| 138 | isInvalid, |
| 139 | errors, |
| 140 | isPending, |
| 141 | optionalLabel, |
| 142 | }: AdditionalFieldProps) |
| 143 | const field = |
| 144 | optionalLabel && !configuredField |
| 145 | ? { |
| 146 | ...configuredField, |
| 147 | label: ( |
| 148 | <> |
| 149 | {configuredField.label} |
| 150 | {optionalLabel} |
| 151 | </> |
| 152 | ), |
| 153 | } |
| 154 | : configuredField; |
| 155 | const inputType = |
| 156 | const fieldErrors = |
| 157 | |
| 158 | if (field.render |
| 159 | const FieldRenderer = |
| 160 | return ( |
| 161 | <FieldRenderer |
| 162 | name={name} |
| 163 | field={field} |
| 164 | value={value} |
| 165 | onBlur={onBlur} |
| 166 | onChange={onChange} |
| 167 | isInvalid={isInvalid} |
| 168 | errors={errors} |
| 169 | isPending={isPending} |
| 170 | optionalLabel={optionalLabel} |
| 171 | /> |
| 172 | ); |
| 173 | } |
| 174 | |
| 175 | if (inputType === |
| 176 | return ( |
| 177 | <input type="hidden |
| 178 | ); |
| 179 | } |
| 180 | |
| 181 | if (inputType === |
| 182 | return ( |
| 183 | <Field data-invalid={ |
| 184 | <FieldLabel htmlFor={ |
| 185 | |
| 186 | <Textarea |
| 187 | id={name} |
| 188 | name={name} |
| 189 | value={valueToString(value |
| 190 | onBlur={onBlur} |
| 191 | onChange={(event) |
| 192 | placeholder={field.placeholder |
| 193 | required={field.required |
| 194 | readOnly={field.readOnly |
| 195 | disabled={isPending} |
| 196 | aria-invalid={isInvalid} |
| 197 | /> |
| 198 | |
| 199 | <FieldError errors={ |
| 200 | </Field> |
| 201 | ); |
| 202 | } |
| 203 | |
| 204 | if (inputType === |
| 205 | const maxFractionDigits = |
| 206 | |
| 207 | return ( |
| 208 | <Field data-invalid={ |
| 209 | <FieldLabel htmlFor={ |
| 210 | |
| 211 | <Input |
| 212 | id={name} |
| 213 | name={name} |
| 214 | type="number" |
| 215 | inputMode={maxFractionDigits ? |
| 216 | min={field.min |
| 217 | max={field.max |
| 218 | step={ |
| 219 | field.step ?? |
| 220 | (maxFractionDigits ? 1 |
| 221 | } |
| 222 | value={typeof value |
| 223 | onBlur={onBlur} |
| 224 | onChange={(event) |
| 225 | onChange( |
| 226 | event.target.value |
| 227 | ) |
| 228 | } |
| 229 | placeholder={field.placeholder |
| 230 | required={field.required |
| 231 | readOnly={field.readOnly |
| 232 | disabled={isPending} |
| 233 | aria-invalid={isInvalid} |
| 234 | /> |
| 235 | |
| 236 | <FieldError errors={ |
| 237 | </Field> |
| 238 | ); |
| 239 | } |
| 240 | |
| 241 | if (inputType === |
| 242 | return ( |
| 243 | <SliderField |
| 244 | name={name} |
| 245 | field={field} |
| 246 | value={value} |
| 247 | onBlur={onBlur} |
| 248 | onChange={onChange} |
| 249 | isInvalid={isInvalid} |
| 250 | errors={errors} |
| 251 | isPending={isPending} |
| 252 | /> |
| 253 | ); |
| 254 | } |
| 255 | |
| 256 | if (inputType === |
| 257 | return ( |
| 258 | <Field data-invalid={ |
| 259 | <Switch |
| 260 | id={name} |
| 261 | name={name} |
| 262 | checked={value === |
| 263 | onBlur={onBlur} |
| 264 | onCheckedChange={onChange} |
| 265 | disabled={isPending || |
| 266 | aria-invalid={isInvalid} |
| 267 | /> |
| 268 | |
| 269 | <FieldContent> |
| 270 | <FieldLabel htmlFor={ |
| 271 | </FieldContent> |
| 272 | <FieldError errors={ |
| 273 | </Field> |
| 274 | ); |
| 275 | } |
| 276 | |
| 277 | if (inputType === |
| 278 | return ( |
| 279 | <Field data-invalid={ |
| 280 | <Checkbox |
| 281 | id={name} |
| 282 | name={name} |
| 283 | checked={value === |
| 284 | onBlur={onBlur} |
| 285 | onCheckedChange={(checked) |
| 286 | required={field.required |
| 287 | disabled={isPending || |
| 288 | aria-invalid={isInvalid} |
| 289 | /> |
| 290 | |
| 291 | <FieldContent> |
| 292 | <FieldLabel htmlFor={ |
| 293 | </FieldContent> |
| 294 | <FieldError errors={ |
| 295 | </Field> |
| 296 | ); |
| 297 | } |
| 298 | |
| 299 | if (inputType === |
| 300 | return ( |
| 301 | <Field data-invalid={ |
| 302 | <FieldLabel htmlFor={ |
| 303 | |
| 304 | <Select |
| 305 | name={name} |
| 306 | value={valueToString(value |
| 307 | onValueChange={(nextValue) |
| 308 | required={field.required |
| 309 | disabled={isPending || |
| 310 | > |
| 311 | <SelectTrigger |
| 312 | id={name} |
| 313 | className="w-full" |
| 314 | onBlur={onBlur} |
| 315 | aria-invalid={isInvalid} |
| 316 | > |
| 317 | <SelectValue placeholder={ |
| 318 | </SelectTrigger> |
| 319 | |
| 320 | <SelectContent> |
| 321 | {field.options?.map |
| 322 | <SelectItem key={ |
| 323 | {option.label} |
| 324 | </SelectItem> |
| 325 | ))} |
| 326 | </SelectContent> |
| 327 | </Select> |
| 328 | |
| 329 | <FieldError errors={ |
| 330 | </Field> |
| 331 | ); |
| 332 | } |
| 333 | |
| 334 | if (inputType === |
| 335 | const selectedOption = |
| 336 | (option) => |
| 337 | ); |
| 338 | |
| 339 | return ( |
| 340 | <Field data-invalid={ |
| 341 | <FieldLabel htmlFor={ |
| 342 | |
| 343 | <Combobox |
| 344 | items={field.options |
| 345 | name={name} |
| 346 | value={selectedOption ?? |
| 347 | onValueChange={(option) |
| 348 | required={field.required |
| 349 | disabled={isPending || |
| 350 | > |
| 351 | <ComboboxInput |
| 352 | placeholder={field.placeholder |
| 353 | id={name} |
| 354 | onBlur={onBlur} |
| 355 | aria-invalid={isInvalid} |
| 356 | /> |
| 357 | |
| 358 | <ComboboxContent> |
| 359 | <ComboboxEmpty>No items found.</ComboboxEmpty |
| 360 | |
| 361 | <ComboboxList> |
| 362 | {(option) => |
| 363 | <ComboboxItem key={ |
| 364 | {option.label} |
| 365 | </ComboboxItem> |
| 366 | )} |
| 367 | </ComboboxList> |
| 368 | </ComboboxContent> |
| 369 | </Combobox> |
| 370 | |
| 371 | <FieldError errors={ |
| 372 | </Field> |
| 373 | ); |
| 374 | } |
| 375 | |
| 376 | if (inputType === |
| 377 | return ( |
| 378 | <DateInput |
| 379 | name={name} |
| 380 | field={field} |
| 381 | value={value} |
| 382 | onBlur={onBlur} |
| 383 | onChange={onChange} |
| 384 | isInvalid={isInvalid} |
| 385 | errors={errors} |
| 386 | isPending={isPending} |
| 387 | /> |
| 388 | ); |
| 389 | } |
| 390 | |
| 391 | return ( |
| 392 | <InputField |
| 393 | name={name} |
| 394 | field={field} |
| 395 | value={value} |
| 396 | onBlur={onBlur} |
| 397 | onChange={onChange} |
| 398 | isInvalid={isInvalid} |
| 399 | errors={errors} |
| 400 | isPending={isPending} |
| 401 | /> |
| 402 | ); |
| 403 | } |
| 404 | |
| 405 | function InputField({ |
| 406 | name, |
| 407 | field, |
| 408 | value, |
| 409 | onBlur, |
| 410 | onChange, |
| 411 | isInvalid, |
| 412 | errors, |
| 413 | isPending, |
| 414 | }: AdditionalFieldProps) |
| 415 | const inputRef = |
| 416 | const fieldErrors = |
| 417 | |
| 418 | const hasPrefix = |
| 419 | const hasSuffix = |
| 420 | |
| 421 | const isNumeric = |
| 422 | const maxFractionDigits = |
| 423 | const nativeInputType = |
| 424 | const nativeInputMode = |
| 425 | ? maxFractionDigits |
| 426 | ? "decimal" |
| 427 | : "numeric" |
| 428 | : undefined; |
| 429 | const nativeStep = |
| 430 | ? 1 / 10 ** |
| 431 | : undefined; |
| 432 | |
| 433 | if (hasPrefix || |
| 434 | return ( |
| 435 | <Field data-invalid={ |
| 436 | <FieldLabel htmlFor={ |
| 437 | |
| 438 | <InputGroup> |
| 439 | {hasPrefix && ( |
| 440 | <InputGroupAddon align=" |
| 441 | {field.prefix} |
| 442 | </InputGroupAddon> |
| 443 | )} |
| 444 | |
| 445 | <InputGroupInput |
| 446 | ref={inputRef} |
| 447 | id={name} |
| 448 | name={name} |
| 449 | type={nativeInputType} |
| 450 | inputMode={nativeInputMode} |
| 451 | step={nativeStep} |
| 452 | value={valueToString(value |
| 453 | onBlur={onBlur} |
| 454 | onChange={(event) |
| 455 | placeholder={field.placeholder |
| 456 | required={field.required |
| 457 | readOnly={field.readOnly |
| 458 | disabled={isPending} |
| 459 | aria-invalid={isInvalid} |
| 460 | /> |
| 461 | |
| 462 | {field.copyable ? |
| 463 | <InputGroupAddon align=" |
| 464 | <CopyButton |
| 465 | getValue={() => |
| 466 | isDisabled={isPending} |
| 467 | /> |
| 468 | </InputGroupAddon> |
| 469 | ) : ( |
| 470 | field.suffix != |
| 471 | <InputGroupAddon align=" |
| 472 | {field.suffix} |
| 473 | </InputGroupAddon> |
| 474 | ) |
| 475 | )} |
| 476 | </InputGroup> |
| 477 | |
| 478 | <FieldError errors={ |
| 479 | </Field> |
| 480 | ); |
| 481 | } |
| 482 | |
| 483 | return ( |
| 484 | <Field data-invalid={ |
| 485 | <FieldLabel htmlFor={ |
| 486 | |
| 487 | <Input |
| 488 | id={name} |
| 489 | name={name} |
| 490 | type={nativeInputType} |
| 491 | inputMode={nativeInputMode} |
| 492 | step={nativeStep} |
| 493 | value={valueToString(value |
| 494 | onBlur={onBlur} |
| 495 | onChange={(event) |
| 496 | placeholder={field.placeholder |
| 497 | required={field.required |
| 498 | readOnly={field.readOnly |
| 499 | disabled={isPending} |
| 500 | aria-invalid={isInvalid} |
| 501 | /> |
| 502 | |
| 503 | <FieldError errors={ |
| 504 | </Field> |
| 505 | ); |
| 506 | } |
| 507 | |
| 508 | /** 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. */ |
| 509 | function SliderField({ |
| 510 | name, |
| 511 | field, |
| 512 | value, |
| 513 | onBlur, |
| 514 | onChange, |
| 515 | isInvalid, |
| 516 | errors, |
| 517 | isPending, |
| 518 | }: AdditionalFieldProps) |
| 519 | const maxFractionDigits = |
| 520 | const min = |
| 521 | const max = |
| 522 | const step = |
| 523 | field.step ?? |
| 524 | const numericValue = |
| 525 | const fieldErrors = |
| 526 | |
| 527 | const formatter = |
| 528 | |
| 529 | return ( |
| 530 | <Field data-invalid={ |
| 531 | <div className="flex items-center justify-between gap-2 |
| 532 | <FieldLabel htmlFor={ |
| 533 | <span className="text-sm text-muted-foreground tabular-nums |
| 534 | {formatter.format(numericValue |
| 535 | </span> |
| 536 | </div> |
| 537 | |
| 538 | <Slider |
| 539 | id={name} |
| 540 | name={name} |
| 541 | value={[numericValue] |
| 542 | onBlur={onBlur} |
| 543 | onValueChange={(nextValue) |
| 544 | onChange((Array.isArray |
| 545 | } |
| 546 | min={min} |
| 547 | max={max} |
| 548 | step={step} |
| 549 | disabled={isPending || |
| 550 | aria-invalid={isInvalid} |
| 551 | /> |
| 552 | |
| 553 | <FieldError errors={ |
| 554 | </Field> |
| 555 | ); |
| 556 | } |
| 557 | |
| 558 | /** 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`. */ |
| 559 | function DateInput({ |
| 560 | name, |
| 561 | field, |
| 562 | value, |
| 563 | onBlur, |
| 564 | onChange, |
| 565 | isInvalid, |
| 566 | errors, |
| 567 | isPending, |
| 568 | }: AdditionalFieldProps) |
| 569 | const { localization |
| 570 | const inputType = |
| 571 | const isDateTime = |
| 572 | const fieldErrors = |
| 573 | |
| 574 | const date = |
| 575 | const [time, |
| 576 | isDateTime && date |
| 577 | ); |
| 578 | const [open, |
| 579 | |
| 580 | // Compose the hidden form value: ISO date for "date", ISO datetime for "datetime" (date + time). |
| 581 | return ( |
| 582 | <Field data-invalid={ |
| 583 | <FieldLabel htmlFor={ |
| 584 | |
| 585 | <div className="relative flex gap-2 |
| 586 | <Popover open={ |
| 587 | <PopoverTrigger |
| 588 | type="button" |
| 589 | id={`${name |
| 590 | data-empty={!date} |
| 591 | aria-invalid={isInvalid} |
| 592 | aria-required={field.required |
| 593 | onBlur={onBlur} |
| 594 | disabled={isPending || |
| 595 | className={cn( |
| 596 | buttonVariants({ variant: |
| 597 | "flex-1 justify-between font-normal", |
| 598 | "data-[empty=true]:text-muted-foreground", |
| 599 | )} |
| 600 | > |
| 601 | {date ? format |
| 602 | |
| 603 | {isDateTime ? <ChevronDownIcon |
| 604 | </PopoverTrigger> |
| 605 | |
| 606 | <PopoverContent className=" |
| 607 | <Calendar |
| 608 | mode="single" |
| 609 | selected={date} |
| 610 | defaultMonth={date} |
| 611 | captionLayout="dropdown" |
| 612 | onSelect={(value) |
| 613 | if (!value) { |
| 614 | onChange(null); |
| 615 | } else { |
| 616 | const nextValue = |
| 617 | if (isDateTime && time |
| 618 | const [hours = |
| 619 | time.split(": |
| 620 | nextValue.setHours( |
| 621 | Number(hours), |
| 622 | Number(minutes), |
| 623 | Number(seconds), |
| 624 | 0, |
| 625 | ); |
| 626 | } else { |
| 627 | nextValue.setHours(0, |
| 628 | } |
| 629 | onChange(nextValue); |
| 630 | } |
| 631 | if (!isDateTime) setOpen |
| 632 | }} |
| 633 | /> |
| 634 | </PopoverContent> |
| 635 | </Popover> |
| 636 | |
| 637 | {isDateTime && ( |
| 638 | <Field className=" |
| 639 | <FieldLabel htmlFor={ |
| 640 | {localization.settings.time |
| 641 | </FieldLabel> |
| 642 | |
| 643 | <Input |
| 644 | type="time" |
| 645 | id={`${name |
| 646 | step="1" |
| 647 | value={time} |
| 648 | onChange={(event) |
| 649 | const nextTime = |
| 650 | setTime(nextTime); |
| 651 | if (!date) return |
| 652 | const nextValue = |
| 653 | const [hours = |
| 654 | nextTime.split(": |
| 655 | nextValue.setHours( |
| 656 | Number(hours), |
| 657 | Number(minutes), |
| 658 | Number(seconds), |
| 659 | 0, |
| 660 | ); |
| 661 | onChange(nextValue); |
| 662 | }} |
| 663 | disabled={isPending || |
| 664 | className="appearance-none bg-background [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" |
| 665 | /> |
| 666 | </Field> |
| 667 | )} |
| 668 | </div> |
| 669 | |
| 670 | <FieldError errors={ |
| 671 | </Field> |
| 672 | ); |
| 673 | } |