@erikey/react 0.4.26 → 0.4.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/package.json +2 -1
  2. package/src/__tests__/auth-client.test.ts +105 -0
  3. package/src/__tests__/security/localStorage-encryption.test.ts +171 -0
  4. package/src/auth-client.ts +158 -0
  5. package/src/dashboard-client.ts +60 -0
  6. package/src/index.ts +88 -0
  7. package/src/kv-client.ts +316 -0
  8. package/src/lib/cross-origin-auth.ts +99 -0
  9. package/src/stubs/captcha.ts +24 -0
  10. package/src/stubs/hashes.ts +16 -0
  11. package/src/stubs/index.ts +17 -0
  12. package/src/stubs/passkey.ts +12 -0
  13. package/src/stubs/qr-code.ts +10 -0
  14. package/src/stubs/query.ts +16 -0
  15. package/src/stubs/realtime.ts +17 -0
  16. package/src/stubs/use-sync-external-store.ts +12 -0
  17. package/src/styles.css +141 -0
  18. package/src/types.ts +14 -0
  19. package/src/ui/components/auth/auth-callback.tsx +36 -0
  20. package/src/ui/components/auth/auth-form.tsx +310 -0
  21. package/src/ui/components/auth/auth-view.tsx +435 -0
  22. package/src/ui/components/auth/email-otp-button.tsx +53 -0
  23. package/src/ui/components/auth/forms/email-otp-form.tsx +312 -0
  24. package/src/ui/components/auth/forms/email-verification-form.tsx +271 -0
  25. package/src/ui/components/auth/forms/forgot-password-form.tsx +173 -0
  26. package/src/ui/components/auth/forms/magic-link-form.tsx +196 -0
  27. package/src/ui/components/auth/forms/recover-account-form.tsx +143 -0
  28. package/src/ui/components/auth/forms/reset-password-form.tsx +220 -0
  29. package/src/ui/components/auth/forms/sign-in-form.tsx +323 -0
  30. package/src/ui/components/auth/forms/sign-up-form.tsx +820 -0
  31. package/src/ui/components/auth/forms/two-factor-form.tsx +381 -0
  32. package/src/ui/components/auth/magic-link-button.tsx +54 -0
  33. package/src/ui/components/auth/one-tap.tsx +53 -0
  34. package/src/ui/components/auth/otp-input-group.tsx +65 -0
  35. package/src/ui/components/auth/passkey-button.tsx +91 -0
  36. package/src/ui/components/auth/provider-button.tsx +155 -0
  37. package/src/ui/components/auth/sign-out.tsx +25 -0
  38. package/src/ui/components/auth/wallet-button.tsx +192 -0
  39. package/src/ui/components/auth-loading.tsx +21 -0
  40. package/src/ui/components/captcha/captcha.tsx +91 -0
  41. package/src/ui/components/captcha/recaptcha-badge.tsx +61 -0
  42. package/src/ui/components/captcha/recaptcha-v2.tsx +58 -0
  43. package/src/ui/components/captcha/recaptcha-v3.tsx +73 -0
  44. package/src/ui/components/email/email-template.tsx +216 -0
  45. package/src/ui/components/form-error.tsx +27 -0
  46. package/src/ui/components/password-input.tsx +56 -0
  47. package/src/ui/components/provider-icons.tsx +404 -0
  48. package/src/ui/components/redirect-to-sign-in.tsx +16 -0
  49. package/src/ui/components/redirect-to-sign-up.tsx +16 -0
  50. package/src/ui/components/signed-in.tsx +20 -0
  51. package/src/ui/components/signed-out.tsx +20 -0
  52. package/src/ui/components/ui/alert.tsx +66 -0
  53. package/src/ui/components/ui/button.tsx +70 -0
  54. package/src/ui/components/ui/card.tsx +92 -0
  55. package/src/ui/components/ui/checkbox.tsx +66 -0
  56. package/src/ui/components/ui/field.tsx +248 -0
  57. package/src/ui/components/ui/form.tsx +165 -0
  58. package/src/ui/components/ui/input-otp.tsx +77 -0
  59. package/src/ui/components/ui/input.tsx +21 -0
  60. package/src/ui/components/ui/label.tsx +23 -0
  61. package/src/ui/components/ui/separator.tsx +34 -0
  62. package/src/ui/components/ui/skeleton.tsx +13 -0
  63. package/src/ui/components/ui/textarea.tsx +18 -0
  64. package/src/ui/components/user-avatar.tsx +151 -0
  65. package/src/ui/hooks/use-auth-data.ts +193 -0
  66. package/src/ui/hooks/use-authenticate.ts +64 -0
  67. package/src/ui/hooks/use-captcha.tsx +151 -0
  68. package/src/ui/hooks/use-hydrated.ts +13 -0
  69. package/src/ui/hooks/use-lang.ts +32 -0
  70. package/src/ui/hooks/use-success-transition.ts +41 -0
  71. package/src/ui/hooks/use-theme.ts +39 -0
  72. package/src/ui/index.ts +46 -0
  73. package/src/ui/instantdb.ts +1 -0
  74. package/src/ui/lib/auth-data-cache.ts +90 -0
  75. package/src/ui/lib/auth-ui-provider.tsx +769 -0
  76. package/src/ui/lib/gravatar-utils.ts +58 -0
  77. package/src/ui/lib/image-utils.ts +55 -0
  78. package/src/ui/lib/instantdb/model-names.ts +24 -0
  79. package/src/ui/lib/instantdb/use-instant-options.ts +98 -0
  80. package/src/ui/lib/instantdb/use-list-accounts.ts +38 -0
  81. package/src/ui/lib/instantdb/use-list-sessions.ts +53 -0
  82. package/src/ui/lib/instantdb/use-session.ts +55 -0
  83. package/src/ui/lib/social-providers.ts +150 -0
  84. package/src/ui/lib/tanstack/auth-ui-provider-tanstack.tsx +49 -0
  85. package/src/ui/lib/tanstack/use-tanstack-options.ts +112 -0
  86. package/src/ui/lib/triplit/model-names.ts +24 -0
  87. package/src/ui/lib/triplit/use-conditional-query.ts +82 -0
  88. package/src/ui/lib/triplit/use-list-accounts.ts +31 -0
  89. package/src/ui/lib/triplit/use-list-sessions.ts +33 -0
  90. package/src/ui/lib/triplit/use-session.ts +42 -0
  91. package/src/ui/lib/triplit/use-triplit-hooks.ts +68 -0
  92. package/src/ui/lib/triplit/use-triplit-token.ts +44 -0
  93. package/src/ui/lib/utils.ts +119 -0
  94. package/src/ui/lib/view-paths.ts +61 -0
  95. package/src/ui/lib/wallet.ts +129 -0
  96. package/src/ui/localization/admin-error-codes.ts +20 -0
  97. package/src/ui/localization/anonymous-error-codes.ts +6 -0
  98. package/src/ui/localization/api-key-error-codes.ts +32 -0
  99. package/src/ui/localization/auth-localization.ts +865 -0
  100. package/src/ui/localization/base-error-codes.ts +27 -0
  101. package/src/ui/localization/captcha-error-codes.ts +17 -0
  102. package/src/ui/localization/email-otp-error-codes.ts +7 -0
  103. package/src/ui/localization/generic-oauth-error-codes.ts +3 -0
  104. package/src/ui/localization/haveibeenpwned-error-codes.ts +4 -0
  105. package/src/ui/localization/multi-session-error-codes.ts +3 -0
  106. package/src/ui/localization/organization-error-codes.ts +57 -0
  107. package/src/ui/localization/passkey-error-codes.ts +10 -0
  108. package/src/ui/localization/phone-number-error-codes.ts +10 -0
  109. package/src/ui/localization/stripe-localization.ts +12 -0
  110. package/src/ui/localization/team-error-codes.ts +12 -0
  111. package/src/ui/localization/two-factor-error-codes.ts +12 -0
  112. package/src/ui/localization/username-error-codes.ts +9 -0
  113. package/src/ui/server.ts +4 -0
  114. package/src/ui/style.css +146 -0
  115. package/src/ui/tanstack.ts +1 -0
  116. package/src/ui/triplit.ts +1 -0
  117. package/src/ui/types/account-options.ts +35 -0
  118. package/src/ui/types/additional-fields.ts +21 -0
  119. package/src/ui/types/any-auth-client.ts +6 -0
  120. package/src/ui/types/api-key.ts +9 -0
  121. package/src/ui/types/auth-client.ts +41 -0
  122. package/src/ui/types/auth-hooks.ts +81 -0
  123. package/src/ui/types/auth-mutators.ts +21 -0
  124. package/src/ui/types/avatar-options.ts +29 -0
  125. package/src/ui/types/captcha-options.ts +32 -0
  126. package/src/ui/types/captcha-provider.ts +7 -0
  127. package/src/ui/types/credentials-options.ts +38 -0
  128. package/src/ui/types/delete-user-options.ts +7 -0
  129. package/src/ui/types/email-verification-options.ts +7 -0
  130. package/src/ui/types/fetch-error.ts +6 -0
  131. package/src/ui/types/generic-oauth-options.ts +16 -0
  132. package/src/ui/types/gravatar-options.ts +21 -0
  133. package/src/ui/types/image.ts +7 -0
  134. package/src/ui/types/invitation.ts +10 -0
  135. package/src/ui/types/link.ts +7 -0
  136. package/src/ui/types/organization-options.ts +106 -0
  137. package/src/ui/types/password-validation.ts +16 -0
  138. package/src/ui/types/profile.ts +15 -0
  139. package/src/ui/types/refetch.ts +1 -0
  140. package/src/ui/types/render-toast.ts +9 -0
  141. package/src/ui/types/sign-up-options.ts +7 -0
  142. package/src/ui/types/social-options.ts +16 -0
  143. package/src/ui/types/team-options.ts +47 -0
@@ -0,0 +1,820 @@
1
+ "use client"
2
+
3
+ import { zodResolver } from "@hookform/resolvers/zod"
4
+ import type { BetterFetchOption } from "better-auth/react"
5
+ import { Loader2, Trash2Icon, UploadCloudIcon } from "lucide-react"
6
+ import { useCallback, useContext, useEffect, useRef, useState } from "react"
7
+ import { useForm } from "react-hook-form"
8
+ import * as z from "zod"
9
+
10
+ import { useCaptcha } from "../../../hooks/use-captcha"
11
+ import { useIsHydrated } from "../../../hooks/use-hydrated"
12
+ import { useOnSuccessTransition } from "../../../hooks/use-success-transition"
13
+ import { AuthUIContext } from "../../../lib/auth-ui-provider"
14
+ import { fileToBase64, resizeAndCropImage } from "../../../lib/image-utils"
15
+ import {
16
+ cn,
17
+ getLocalizedError,
18
+ getPasswordSchema,
19
+ getSearchParam
20
+ } from "../../../lib/utils"
21
+ import type { AuthLocalization } from "../../../localization/auth-localization"
22
+ import type { PasswordValidation } from "../../../types/password-validation"
23
+ import { Captcha } from "../../captcha/captcha"
24
+ import { PasswordInput } from "../../password-input"
25
+ import { Button } from "../../ui/button"
26
+ import { Checkbox } from "../../ui/checkbox"
27
+ import {
28
+ Form,
29
+ FormControl,
30
+ FormField,
31
+ FormItem,
32
+ FormLabel,
33
+ FormMessage
34
+ } from "../../ui/form"
35
+ import { Input } from "../../ui/input"
36
+ import { Textarea } from "../../ui/textarea"
37
+ import { UserAvatar } from "../../user-avatar"
38
+ import type { AuthFormClassNames } from "../auth-form"
39
+
40
+ export interface SignUpFormProps {
41
+ className?: string
42
+ classNames?: AuthFormClassNames
43
+ callbackURL?: string
44
+ isSubmitting?: boolean
45
+ localization: Partial<AuthLocalization>
46
+ redirectTo?: string
47
+ setIsSubmitting?: (value: boolean) => void
48
+ passwordValidation?: PasswordValidation
49
+ }
50
+
51
+ /**
52
+ * Render a configurable sign-up form that handles standard and dynamic additional fields, avatar upload, CAPTCHA integration, validation, and submission flow.
53
+ *
54
+ * @param className - Additional container className applied to the form element
55
+ * @param classNames - Optional className overrides for specific form elements (labels, inputs, buttons, etc.)
56
+ * @param callbackURL - Optional explicit callback URL to include in the sign-up request; if omitted a callback is derived from app configuration and redirectTo
57
+ * @param isSubmitting - External submitting state to disable inputs and show loading UI
58
+ * @param localization - Localization overrides for labels, placeholders, and messages used by the form
59
+ * @param redirectTo - Optional URL to redirect to after successful sign-up (overrides configured redirect)
60
+ * @param setIsSubmitting - Optional callback invoked with the form's submitting state (useful for parent components)
61
+ * @param passwordValidation - Optional password validation rules to customize password constraints and messages
62
+ * @returns A JSX element that renders the fully wired sign-up form UI
63
+ */
64
+ export function SignUpForm({
65
+ className,
66
+ classNames,
67
+ callbackURL,
68
+ isSubmitting,
69
+ localization,
70
+ redirectTo,
71
+ setIsSubmitting,
72
+ passwordValidation
73
+ }: SignUpFormProps) {
74
+ const isHydrated = useIsHydrated()
75
+ const { captchaRef, getCaptchaHeaders, resetCaptcha } = useCaptcha({
76
+ localization
77
+ })
78
+
79
+ const {
80
+ additionalFields,
81
+ authClient,
82
+ basePath,
83
+ baseURL,
84
+ credentials,
85
+ localization: contextLocalization,
86
+ nameRequired,
87
+ persistClient,
88
+ redirectTo: contextRedirectTo,
89
+ signUp: signUpOptions,
90
+ viewPaths,
91
+ navigate,
92
+ toast,
93
+ avatar,
94
+ localizeErrors,
95
+ emailVerification
96
+ } = useContext(AuthUIContext)
97
+
98
+ const confirmPasswordEnabled = credentials?.confirmPassword
99
+ const usernameEnabled = credentials?.username
100
+ const usernameRequired = credentials?.usernameRequired ?? true
101
+ const contextPasswordValidation = credentials?.passwordValidation
102
+ const signUpFields = signUpOptions?.fields
103
+
104
+ localization = { ...contextLocalization, ...localization }
105
+ passwordValidation = { ...contextPasswordValidation, ...passwordValidation }
106
+
107
+ // Avatar upload state
108
+ const fileInputRef = useRef<HTMLInputElement>(null)
109
+ const [avatarImage, setAvatarImage] = useState<string | null>(null)
110
+ const [uploadingAvatar, setUploadingAvatar] = useState(false)
111
+
112
+ const getRedirectTo = useCallback(
113
+ () => redirectTo || getSearchParam("redirectTo") || contextRedirectTo,
114
+ [redirectTo, contextRedirectTo]
115
+ )
116
+
117
+ const getCallbackURL = useCallback(
118
+ () =>
119
+ `${baseURL}${
120
+ callbackURL ||
121
+ (persistClient
122
+ ? `${basePath}/${viewPaths.CALLBACK}?redirectTo=${encodeURIComponent(getRedirectTo())}`
123
+ : getRedirectTo())
124
+ }`,
125
+ [
126
+ callbackURL,
127
+ persistClient,
128
+ basePath,
129
+ viewPaths,
130
+ baseURL,
131
+ getRedirectTo
132
+ ]
133
+ )
134
+
135
+ const { onSuccess, isPending: transitionPending } = useOnSuccessTransition({
136
+ redirectTo
137
+ })
138
+
139
+ // Create the base schema for standard fields
140
+ const defaultFields = {
141
+ email: z.string().email({
142
+ message: `${localization.EMAIL} ${localization.IS_INVALID}`
143
+ }),
144
+ password: getPasswordSchema(passwordValidation, localization),
145
+ name:
146
+ signUpFields?.includes("name") && nameRequired
147
+ ? z.string().min(1, {
148
+ message: `${localization.NAME} ${localization.IS_REQUIRED}`
149
+ })
150
+ : z.string().optional(),
151
+ image: z.string().optional(),
152
+ username: usernameEnabled
153
+ ? usernameRequired
154
+ ? z.string().min(1, {
155
+ message: `${localization.USERNAME} ${localization.IS_REQUIRED}`
156
+ })
157
+ : z.string().optional()
158
+ : z.string().optional(),
159
+ confirmPassword: confirmPasswordEnabled
160
+ ? getPasswordSchema(passwordValidation, {
161
+ PASSWORD_REQUIRED: localization.CONFIRM_PASSWORD_REQUIRED,
162
+ PASSWORD_TOO_SHORT: localization.PASSWORD_TOO_SHORT,
163
+ PASSWORD_TOO_LONG: localization.PASSWORD_TOO_LONG,
164
+ INVALID_PASSWORD: localization.INVALID_PASSWORD
165
+ })
166
+ : z.string().optional()
167
+ }
168
+
169
+ const schemaFields: Record<string, z.ZodTypeAny> = {}
170
+
171
+ // Add additional fields from signUpFields
172
+ if (signUpFields) {
173
+ for (const field of signUpFields) {
174
+ if (field === "name") continue // Already handled above
175
+ if (field === "image") continue // Already handled above
176
+
177
+ const additionalField = additionalFields?.[field]
178
+ if (!additionalField) continue
179
+
180
+ let fieldSchema: z.ZodTypeAny
181
+
182
+ // Create the appropriate schema based on field type
183
+ if (additionalField.type === "number") {
184
+ fieldSchema = additionalField.required
185
+ ? z.preprocess(
186
+ (val) => (!val ? undefined : Number(val)),
187
+ z.number({
188
+ message: `${additionalField.label} ${localization.IS_INVALID}`
189
+ })
190
+ )
191
+ : z.coerce
192
+ .number({
193
+ message: `${additionalField.label} ${localization.IS_INVALID}`
194
+ })
195
+ .optional()
196
+ } else if (additionalField.type === "boolean") {
197
+ fieldSchema = additionalField.required
198
+ ? z.coerce
199
+ .boolean({
200
+ message: `${additionalField.label} ${localization.IS_INVALID}`
201
+ })
202
+ .refine((val) => val === true, {
203
+ message: `${additionalField.label} ${localization.IS_REQUIRED}`
204
+ })
205
+ : z.coerce
206
+ .boolean({
207
+ message: `${additionalField.label} ${localization.IS_INVALID}`
208
+ })
209
+ .optional()
210
+ } else {
211
+ fieldSchema = additionalField.required
212
+ ? z
213
+ .string()
214
+ .min(
215
+ 1,
216
+ `${additionalField.label} ${localization.IS_REQUIRED}`
217
+ )
218
+ : z.string().optional()
219
+ }
220
+
221
+ schemaFields[field] = fieldSchema
222
+ }
223
+ }
224
+
225
+ const formSchema = z
226
+ .object(defaultFields)
227
+ .extend(schemaFields)
228
+ .refine(
229
+ (data) => {
230
+ // Skip validation if confirmPassword is not enabled
231
+ if (!confirmPasswordEnabled) return true
232
+ return data.password === data.confirmPassword
233
+ },
234
+ {
235
+ message: localization.PASSWORDS_DO_NOT_MATCH!,
236
+ path: ["confirmPassword"]
237
+ }
238
+ )
239
+
240
+ // Create default values for the form
241
+ const defaultValues: Record<string, unknown> = {
242
+ email: "",
243
+ password: "",
244
+ ...(confirmPasswordEnabled && { confirmPassword: "" }),
245
+ ...(signUpFields?.includes("name") ? { name: "" } : {}),
246
+ ...(usernameEnabled ? { username: "" } : {}),
247
+ ...(signUpFields?.includes("image") && avatar ? { image: "" } : {})
248
+ }
249
+
250
+ // Add default values for additional fields
251
+ if (signUpFields) {
252
+ for (const field of signUpFields) {
253
+ if (field === "name") continue
254
+ if (field === "image") continue
255
+ const additionalField = additionalFields?.[field]
256
+ if (!additionalField) continue
257
+
258
+ defaultValues[field] =
259
+ additionalField.type === "boolean" ? false : ""
260
+ }
261
+ }
262
+
263
+ const form = useForm<z.infer<typeof formSchema>>({
264
+ resolver: zodResolver(formSchema),
265
+ defaultValues
266
+ })
267
+
268
+ isSubmitting =
269
+ isSubmitting || form.formState.isSubmitting || transitionPending
270
+
271
+ useEffect(() => {
272
+ setIsSubmitting?.(form.formState.isSubmitting || transitionPending)
273
+ }, [form.formState.isSubmitting, transitionPending, setIsSubmitting])
274
+
275
+ const handleAvatarChange = async (file: File) => {
276
+ if (!avatar) return
277
+
278
+ setUploadingAvatar(true)
279
+
280
+ try {
281
+ const resizedFile = await resizeAndCropImage(
282
+ file,
283
+ crypto.randomUUID(),
284
+ avatar.size,
285
+ avatar.extension
286
+ )
287
+
288
+ let image: string | undefined | null
289
+
290
+ if (avatar.upload) {
291
+ image = await avatar.upload(resizedFile)
292
+ } else {
293
+ image = await fileToBase64(resizedFile)
294
+ }
295
+
296
+ if (image) {
297
+ setAvatarImage(image)
298
+ form.setValue("image", image)
299
+ } else {
300
+ setAvatarImage(null)
301
+ form.setValue("image", "")
302
+ }
303
+ } catch (error) {
304
+ console.error(error)
305
+ toast({
306
+ variant: "error",
307
+ message: getLocalizedError({
308
+ error,
309
+ localization,
310
+ localizeErrors
311
+ })
312
+ })
313
+ }
314
+
315
+ setUploadingAvatar(false)
316
+ }
317
+
318
+ const handleDeleteAvatar = () => {
319
+ setAvatarImage(null)
320
+ form.setValue("image", "")
321
+ }
322
+
323
+ const openFileDialog = () => fileInputRef.current?.click()
324
+
325
+ async function signUp({
326
+ email,
327
+ password,
328
+ name,
329
+ username,
330
+ confirmPassword,
331
+ image,
332
+ ...additionalFieldValues
333
+ }: z.infer<typeof formSchema>) {
334
+ try {
335
+ // Validate additional fields with custom validators if provided
336
+ for (const [field, value] of Object.entries(
337
+ additionalFieldValues
338
+ )) {
339
+ const additionalField = additionalFields?.[field]
340
+ if (!additionalField?.validate) continue
341
+
342
+ if (
343
+ typeof value === "string" &&
344
+ !(await additionalField.validate(value))
345
+ ) {
346
+ form.setError(field, {
347
+ message: `${additionalField.label} ${localization.IS_INVALID}`
348
+ })
349
+ return
350
+ }
351
+ }
352
+
353
+ const fetchOptions: BetterFetchOption = {
354
+ throw: true,
355
+ headers: await getCaptchaHeaders("/sign-up/email")
356
+ }
357
+
358
+ const additionalParams: Record<string, unknown> = {}
359
+
360
+ if (username !== undefined) {
361
+ if (
362
+ !usernameRequired &&
363
+ (username === null ||
364
+ username === "" ||
365
+ (typeof username === "string" &&
366
+ username.trim() === ""))
367
+ ) {
368
+ } else {
369
+ additionalParams.username = username
370
+ }
371
+ }
372
+
373
+ if (image !== undefined) {
374
+ additionalParams.image = image
375
+ }
376
+
377
+ const data = await authClient.signUp.email({
378
+ email: email as string,
379
+ password: password as string,
380
+ name: (name as string) || "",
381
+ ...additionalParams,
382
+ ...additionalFieldValues,
383
+ callbackURL: getCallbackURL(),
384
+ fetchOptions
385
+ })
386
+
387
+ if ("token" in data && data.token) {
388
+ await onSuccess()
389
+ } else if (emailVerification?.otp) {
390
+ navigate(
391
+ `${basePath}/${viewPaths.EMAIL_VERIFICATION}?email=${encodeURIComponent(email as string)}`
392
+ )
393
+ } else {
394
+ navigate(
395
+ `${basePath}/${viewPaths.SIGN_IN}${window.location.search}`
396
+ )
397
+ toast({
398
+ variant: "success",
399
+ message: localization.SIGN_UP_EMAIL!
400
+ })
401
+ }
402
+ } catch (error) {
403
+ const errorMessage = getLocalizedError({
404
+ error,
405
+ localization,
406
+ localizeErrors
407
+ })
408
+
409
+ // Set inline error for display
410
+ form.setError("root", { message: errorMessage })
411
+
412
+ // Also call toast for users who provide custom toast handler
413
+ toast({
414
+ variant: "error",
415
+ message: errorMessage
416
+ })
417
+
418
+ form.resetField("password")
419
+ form.resetField("confirmPassword")
420
+ resetCaptcha()
421
+ }
422
+ }
423
+
424
+ return (
425
+ <Form {...form}>
426
+ <form
427
+ onSubmit={form.handleSubmit(signUp)}
428
+ noValidate={isHydrated}
429
+ className={cn("grid w-full gap-6", className, classNames?.base)}
430
+ >
431
+ {signUpFields?.includes("image") && avatar && (
432
+ <>
433
+ <input
434
+ ref={fileInputRef}
435
+ accept="image/*"
436
+ disabled={uploadingAvatar}
437
+ hidden
438
+ type="file"
439
+ onChange={(e) => {
440
+ const file = e.target.files?.item(0)
441
+ if (file) handleAvatarChange(file)
442
+ e.target.value = ""
443
+ }}
444
+ />
445
+
446
+ <FormField
447
+ control={form.control}
448
+ name="image"
449
+ render={() => (
450
+ <FormItem>
451
+ <FormLabel>{localization.AVATAR}</FormLabel>
452
+
453
+ <div className="flex items-center gap-4">
454
+ <button
455
+ type="button"
456
+ onClick={openFileDialog}
457
+ disabled={uploadingAvatar}
458
+ className="size-fit rounded-full focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
459
+ >
460
+ <UserAvatar
461
+ isPending={uploadingAvatar}
462
+ className="size-16"
463
+ user={
464
+ avatarImage
465
+ ? {
466
+ name: form.watch(
467
+ "name"
468
+ ) as string,
469
+ email: form.watch(
470
+ "email"
471
+ ) as string,
472
+ image: avatarImage
473
+ }
474
+ : null
475
+ }
476
+ localization={localization}
477
+ />
478
+ </button>
479
+
480
+ <div className="flex gap-2">
481
+ <Button
482
+ type="button"
483
+ variant="outline"
484
+ onClick={openFileDialog}
485
+ disabled={uploadingAvatar}
486
+ >
487
+ {uploadingAvatar ? (
488
+ <Loader2 className="animate-spin" />
489
+ ) : (
490
+ <UploadCloudIcon className="size-4" />
491
+ )}
492
+ {localization.UPLOAD}
493
+ </Button>
494
+
495
+ {avatarImage && (
496
+ <Button
497
+ type="button"
498
+ variant="outline"
499
+ onClick={handleDeleteAvatar}
500
+ disabled={uploadingAvatar}
501
+ >
502
+ <Trash2Icon className="size-4" />
503
+ </Button>
504
+ )}
505
+ </div>
506
+ </div>
507
+
508
+ <FormMessage />
509
+ </FormItem>
510
+ )}
511
+ />
512
+ </>
513
+ )}
514
+
515
+ {signUpFields?.includes("name") && (
516
+ <FormField
517
+ control={form.control}
518
+ name="name"
519
+ render={({ field }) => (
520
+ <FormItem>
521
+ <FormLabel className={classNames?.label}>
522
+ {localization.NAME}
523
+ {!nameRequired && (
524
+ <span className="ml-1 text-muted-foreground">
525
+ {localization.OPTIONAL_BRACKETS}
526
+ </span>
527
+ )}
528
+ </FormLabel>
529
+
530
+ <FormControl>
531
+ <Input
532
+ autoComplete="name"
533
+ className={classNames?.input}
534
+ placeholder={
535
+ localization.NAME_PLACEHOLDER
536
+ }
537
+ disabled={isSubmitting}
538
+ {...field}
539
+ value={field.value as string}
540
+ />
541
+ </FormControl>
542
+
543
+ <FormMessage className={classNames?.error} />
544
+ </FormItem>
545
+ )}
546
+ />
547
+ )}
548
+
549
+ {usernameEnabled && (
550
+ <FormField
551
+ control={form.control}
552
+ name="username"
553
+ render={({ field }) => (
554
+ <FormItem>
555
+ <FormLabel className={classNames?.label}>
556
+ {localization.USERNAME}
557
+ {!usernameRequired && (
558
+ <span className="ml-1 text-muted-foreground">
559
+ {localization.OPTIONAL_BRACKETS}
560
+ </span>
561
+ )}
562
+ </FormLabel>
563
+
564
+ <FormControl>
565
+ <Input
566
+ autoComplete="username"
567
+ className={classNames?.input}
568
+ placeholder={
569
+ localization.USERNAME_PLACEHOLDER
570
+ }
571
+ disabled={isSubmitting}
572
+ {...field}
573
+ value={field.value as string}
574
+ />
575
+ </FormControl>
576
+
577
+ <FormMessage className={classNames?.error} />
578
+ </FormItem>
579
+ )}
580
+ />
581
+ )}
582
+
583
+ <FormField
584
+ control={form.control}
585
+ name="email"
586
+ render={({ field }) => (
587
+ <FormItem>
588
+ <FormLabel className={classNames?.label}>
589
+ {localization.EMAIL}
590
+ </FormLabel>
591
+
592
+ <FormControl>
593
+ <Input
594
+ autoComplete="email"
595
+ className={classNames?.input}
596
+ type="email"
597
+ placeholder={localization.EMAIL_PLACEHOLDER}
598
+ disabled={isSubmitting}
599
+ {...field}
600
+ value={field.value as string}
601
+ />
602
+ </FormControl>
603
+
604
+ <FormMessage className={classNames?.error} />
605
+ </FormItem>
606
+ )}
607
+ />
608
+
609
+ <FormField
610
+ control={form.control}
611
+ name="password"
612
+ render={({ field }) => (
613
+ <FormItem>
614
+ <FormLabel className={classNames?.label}>
615
+ {localization.PASSWORD}
616
+ </FormLabel>
617
+
618
+ <FormControl>
619
+ <PasswordInput
620
+ autoComplete="new-password"
621
+ className={classNames?.input}
622
+ placeholder={
623
+ localization.PASSWORD_PLACEHOLDER
624
+ }
625
+ disabled={isSubmitting}
626
+ enableToggle
627
+ {...field}
628
+ value={field.value as string}
629
+ />
630
+ </FormControl>
631
+
632
+ <FormMessage className={classNames?.error} />
633
+ </FormItem>
634
+ )}
635
+ />
636
+
637
+ {confirmPasswordEnabled && (
638
+ <FormField
639
+ control={form.control}
640
+ name="confirmPassword"
641
+ render={({ field }) => (
642
+ <FormItem>
643
+ <FormLabel className={classNames?.label}>
644
+ {localization.CONFIRM_PASSWORD}
645
+ </FormLabel>
646
+
647
+ <FormControl>
648
+ <PasswordInput
649
+ autoComplete="new-password"
650
+ className={classNames?.input}
651
+ placeholder={
652
+ localization.CONFIRM_PASSWORD_PLACEHOLDER
653
+ }
654
+ disabled={isSubmitting}
655
+ enableToggle
656
+ {...field}
657
+ value={field.value as string}
658
+ />
659
+ </FormControl>
660
+
661
+ <FormMessage className={classNames?.error} />
662
+ </FormItem>
663
+ )}
664
+ />
665
+ )}
666
+
667
+ {signUpFields
668
+ ?.filter((field) => field !== "name" && field !== "image")
669
+ .map((field) => {
670
+ const additionalField = additionalFields?.[field]
671
+ if (!additionalField) {
672
+ console.error(`Additional field ${field} not found`)
673
+ return null
674
+ }
675
+
676
+ return additionalField.type === "boolean" ? (
677
+ <FormField
678
+ key={field}
679
+ control={form.control}
680
+ name={field}
681
+ render={({ field: formField }) => (
682
+ <FormItem className="flex">
683
+ <FormControl>
684
+ <Checkbox
685
+ checked={
686
+ formField.value as boolean
687
+ }
688
+ onCheckedChange={
689
+ formField.onChange
690
+ }
691
+ disabled={isSubmitting}
692
+ />
693
+ </FormControl>
694
+
695
+ <FormLabel
696
+ className={classNames?.label}
697
+ >
698
+ {additionalField.label}
699
+ </FormLabel>
700
+
701
+ <FormMessage
702
+ className={classNames?.error}
703
+ />
704
+ </FormItem>
705
+ )}
706
+ />
707
+ ) : (
708
+ <FormField
709
+ key={field}
710
+ control={form.control}
711
+ name={field}
712
+ render={({ field: formField }) => (
713
+ <FormItem>
714
+ <FormLabel
715
+ className={classNames?.label}
716
+ >
717
+ {additionalField.label}
718
+ </FormLabel>
719
+
720
+ <FormControl>
721
+ {additionalField.type ===
722
+ "number" ? (
723
+ <Input
724
+ className={
725
+ classNames?.input
726
+ }
727
+ type="number"
728
+ placeholder={
729
+ additionalField.placeholder ||
730
+ (typeof additionalField.label ===
731
+ "string"
732
+ ? additionalField.label
733
+ : "")
734
+ }
735
+ disabled={isSubmitting}
736
+ {...formField}
737
+ value={
738
+ formField.value as number
739
+ }
740
+ />
741
+ ) : additionalField.multiline ? (
742
+ <Textarea
743
+ className={
744
+ classNames?.input
745
+ }
746
+ placeholder={
747
+ additionalField.placeholder ||
748
+ (typeof additionalField.label ===
749
+ "string"
750
+ ? additionalField.label
751
+ : "")
752
+ }
753
+ disabled={isSubmitting}
754
+ {...formField}
755
+ value={
756
+ formField.value as string
757
+ }
758
+ />
759
+ ) : (
760
+ <Input
761
+ className={
762
+ classNames?.input
763
+ }
764
+ type="text"
765
+ placeholder={
766
+ additionalField.placeholder ||
767
+ (typeof additionalField.label ===
768
+ "string"
769
+ ? additionalField.label
770
+ : "")
771
+ }
772
+ disabled={isSubmitting}
773
+ {...formField}
774
+ value={
775
+ formField.value as string
776
+ }
777
+ />
778
+ )}
779
+ </FormControl>
780
+
781
+ <FormMessage
782
+ className={classNames?.error}
783
+ />
784
+ </FormItem>
785
+ )}
786
+ />
787
+ )
788
+ })}
789
+
790
+ <Captcha
791
+ ref={captchaRef}
792
+ localization={localization}
793
+ action="/sign-up/email"
794
+ />
795
+
796
+ {form.formState.errors.root && (
797
+ <div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
798
+ {form.formState.errors.root.message}
799
+ </div>
800
+ )}
801
+
802
+ <Button
803
+ type="submit"
804
+ disabled={isSubmitting}
805
+ className={cn(
806
+ "w-full",
807
+ classNames?.button,
808
+ classNames?.primaryButton
809
+ )}
810
+ >
811
+ {isSubmitting ? (
812
+ <Loader2 className="animate-spin" />
813
+ ) : (
814
+ localization.SIGN_UP_ACTION
815
+ )}
816
+ </Button>
817
+ </form>
818
+ </Form>
819
+ )
820
+ }