@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.
- package/package.json +2 -1
- package/src/__tests__/auth-client.test.ts +105 -0
- package/src/__tests__/security/localStorage-encryption.test.ts +171 -0
- package/src/auth-client.ts +158 -0
- package/src/dashboard-client.ts +60 -0
- package/src/index.ts +88 -0
- package/src/kv-client.ts +316 -0
- package/src/lib/cross-origin-auth.ts +99 -0
- package/src/stubs/captcha.ts +24 -0
- package/src/stubs/hashes.ts +16 -0
- package/src/stubs/index.ts +17 -0
- package/src/stubs/passkey.ts +12 -0
- package/src/stubs/qr-code.ts +10 -0
- package/src/stubs/query.ts +16 -0
- package/src/stubs/realtime.ts +17 -0
- package/src/stubs/use-sync-external-store.ts +12 -0
- package/src/styles.css +141 -0
- package/src/types.ts +14 -0
- package/src/ui/components/auth/auth-callback.tsx +36 -0
- package/src/ui/components/auth/auth-form.tsx +310 -0
- package/src/ui/components/auth/auth-view.tsx +435 -0
- package/src/ui/components/auth/email-otp-button.tsx +53 -0
- package/src/ui/components/auth/forms/email-otp-form.tsx +312 -0
- package/src/ui/components/auth/forms/email-verification-form.tsx +271 -0
- package/src/ui/components/auth/forms/forgot-password-form.tsx +173 -0
- package/src/ui/components/auth/forms/magic-link-form.tsx +196 -0
- package/src/ui/components/auth/forms/recover-account-form.tsx +143 -0
- package/src/ui/components/auth/forms/reset-password-form.tsx +220 -0
- package/src/ui/components/auth/forms/sign-in-form.tsx +323 -0
- package/src/ui/components/auth/forms/sign-up-form.tsx +820 -0
- package/src/ui/components/auth/forms/two-factor-form.tsx +381 -0
- package/src/ui/components/auth/magic-link-button.tsx +54 -0
- package/src/ui/components/auth/one-tap.tsx +53 -0
- package/src/ui/components/auth/otp-input-group.tsx +65 -0
- package/src/ui/components/auth/passkey-button.tsx +91 -0
- package/src/ui/components/auth/provider-button.tsx +155 -0
- package/src/ui/components/auth/sign-out.tsx +25 -0
- package/src/ui/components/auth/wallet-button.tsx +192 -0
- package/src/ui/components/auth-loading.tsx +21 -0
- package/src/ui/components/captcha/captcha.tsx +91 -0
- package/src/ui/components/captcha/recaptcha-badge.tsx +61 -0
- package/src/ui/components/captcha/recaptcha-v2.tsx +58 -0
- package/src/ui/components/captcha/recaptcha-v3.tsx +73 -0
- package/src/ui/components/email/email-template.tsx +216 -0
- package/src/ui/components/form-error.tsx +27 -0
- package/src/ui/components/password-input.tsx +56 -0
- package/src/ui/components/provider-icons.tsx +404 -0
- package/src/ui/components/redirect-to-sign-in.tsx +16 -0
- package/src/ui/components/redirect-to-sign-up.tsx +16 -0
- package/src/ui/components/signed-in.tsx +20 -0
- package/src/ui/components/signed-out.tsx +20 -0
- package/src/ui/components/ui/alert.tsx +66 -0
- package/src/ui/components/ui/button.tsx +70 -0
- package/src/ui/components/ui/card.tsx +92 -0
- package/src/ui/components/ui/checkbox.tsx +66 -0
- package/src/ui/components/ui/field.tsx +248 -0
- package/src/ui/components/ui/form.tsx +165 -0
- package/src/ui/components/ui/input-otp.tsx +77 -0
- package/src/ui/components/ui/input.tsx +21 -0
- package/src/ui/components/ui/label.tsx +23 -0
- package/src/ui/components/ui/separator.tsx +34 -0
- package/src/ui/components/ui/skeleton.tsx +13 -0
- package/src/ui/components/ui/textarea.tsx +18 -0
- package/src/ui/components/user-avatar.tsx +151 -0
- package/src/ui/hooks/use-auth-data.ts +193 -0
- package/src/ui/hooks/use-authenticate.ts +64 -0
- package/src/ui/hooks/use-captcha.tsx +151 -0
- package/src/ui/hooks/use-hydrated.ts +13 -0
- package/src/ui/hooks/use-lang.ts +32 -0
- package/src/ui/hooks/use-success-transition.ts +41 -0
- package/src/ui/hooks/use-theme.ts +39 -0
- package/src/ui/index.ts +46 -0
- package/src/ui/instantdb.ts +1 -0
- package/src/ui/lib/auth-data-cache.ts +90 -0
- package/src/ui/lib/auth-ui-provider.tsx +769 -0
- package/src/ui/lib/gravatar-utils.ts +58 -0
- package/src/ui/lib/image-utils.ts +55 -0
- package/src/ui/lib/instantdb/model-names.ts +24 -0
- package/src/ui/lib/instantdb/use-instant-options.ts +98 -0
- package/src/ui/lib/instantdb/use-list-accounts.ts +38 -0
- package/src/ui/lib/instantdb/use-list-sessions.ts +53 -0
- package/src/ui/lib/instantdb/use-session.ts +55 -0
- package/src/ui/lib/social-providers.ts +150 -0
- package/src/ui/lib/tanstack/auth-ui-provider-tanstack.tsx +49 -0
- package/src/ui/lib/tanstack/use-tanstack-options.ts +112 -0
- package/src/ui/lib/triplit/model-names.ts +24 -0
- package/src/ui/lib/triplit/use-conditional-query.ts +82 -0
- package/src/ui/lib/triplit/use-list-accounts.ts +31 -0
- package/src/ui/lib/triplit/use-list-sessions.ts +33 -0
- package/src/ui/lib/triplit/use-session.ts +42 -0
- package/src/ui/lib/triplit/use-triplit-hooks.ts +68 -0
- package/src/ui/lib/triplit/use-triplit-token.ts +44 -0
- package/src/ui/lib/utils.ts +119 -0
- package/src/ui/lib/view-paths.ts +61 -0
- package/src/ui/lib/wallet.ts +129 -0
- package/src/ui/localization/admin-error-codes.ts +20 -0
- package/src/ui/localization/anonymous-error-codes.ts +6 -0
- package/src/ui/localization/api-key-error-codes.ts +32 -0
- package/src/ui/localization/auth-localization.ts +865 -0
- package/src/ui/localization/base-error-codes.ts +27 -0
- package/src/ui/localization/captcha-error-codes.ts +17 -0
- package/src/ui/localization/email-otp-error-codes.ts +7 -0
- package/src/ui/localization/generic-oauth-error-codes.ts +3 -0
- package/src/ui/localization/haveibeenpwned-error-codes.ts +4 -0
- package/src/ui/localization/multi-session-error-codes.ts +3 -0
- package/src/ui/localization/organization-error-codes.ts +57 -0
- package/src/ui/localization/passkey-error-codes.ts +10 -0
- package/src/ui/localization/phone-number-error-codes.ts +10 -0
- package/src/ui/localization/stripe-localization.ts +12 -0
- package/src/ui/localization/team-error-codes.ts +12 -0
- package/src/ui/localization/two-factor-error-codes.ts +12 -0
- package/src/ui/localization/username-error-codes.ts +9 -0
- package/src/ui/server.ts +4 -0
- package/src/ui/style.css +146 -0
- package/src/ui/tanstack.ts +1 -0
- package/src/ui/triplit.ts +1 -0
- package/src/ui/types/account-options.ts +35 -0
- package/src/ui/types/additional-fields.ts +21 -0
- package/src/ui/types/any-auth-client.ts +6 -0
- package/src/ui/types/api-key.ts +9 -0
- package/src/ui/types/auth-client.ts +41 -0
- package/src/ui/types/auth-hooks.ts +81 -0
- package/src/ui/types/auth-mutators.ts +21 -0
- package/src/ui/types/avatar-options.ts +29 -0
- package/src/ui/types/captcha-options.ts +32 -0
- package/src/ui/types/captcha-provider.ts +7 -0
- package/src/ui/types/credentials-options.ts +38 -0
- package/src/ui/types/delete-user-options.ts +7 -0
- package/src/ui/types/email-verification-options.ts +7 -0
- package/src/ui/types/fetch-error.ts +6 -0
- package/src/ui/types/generic-oauth-options.ts +16 -0
- package/src/ui/types/gravatar-options.ts +21 -0
- package/src/ui/types/image.ts +7 -0
- package/src/ui/types/invitation.ts +10 -0
- package/src/ui/types/link.ts +7 -0
- package/src/ui/types/organization-options.ts +106 -0
- package/src/ui/types/password-validation.ts +16 -0
- package/src/ui/types/profile.ts +15 -0
- package/src/ui/types/refetch.ts +1 -0
- package/src/ui/types/render-toast.ts +9 -0
- package/src/ui/types/sign-up-options.ts +7 -0
- package/src/ui/types/social-options.ts +16 -0
- 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
|
+
}
|