@erikey/react 0.5.2 → 0.6.0
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/dist/index.mjs +19 -19
- package/dist/index.mjs.map +1 -1
- package/dist/styles.css +16 -0
- package/dist/styles.css.map +1 -1
- package/dist/ui/index.mjs +194 -104
- package/dist/ui/index.mjs.map +1 -1
- package/package.json +13 -1
- package/src/__tests__/setup.ts +124 -0
- package/src/__tests__/ui/auth-flow.test.tsx +408 -0
- package/src/__tests__/ui/forms/email-otp-form.test.tsx +190 -0
- package/src/__tests__/ui/forms/sign-in-form.test.tsx +273 -0
- package/src/__tests__/ui/ssr.test.tsx +102 -0
- package/src/__tests__/utils/mock-auth-client.ts +298 -0
- package/src/__tests__/utils/render-with-provider.tsx +129 -0
- package/src/ui/components/auth/auth-flow.tsx +93 -50
- package/src/ui/components/auth/auth-form.tsx +3 -0
- package/src/ui/components/auth/auth-view.tsx +3 -0
- package/src/ui/components/auth/forms/email-otp-form.tsx +58 -14
- package/src/ui/components/auth/forms/email-verification-form.tsx +5 -1
- package/src/ui/components/auth/forms/magic-link-form.tsx +19 -6
- package/src/ui/components/auth/forms/sign-up-form.tsx +13 -3
- package/src/ui/components/auth/forms/two-factor-form.tsx +9 -2
- package/src/ui/components/ui/form.tsx +6 -4
- package/src/ui/hooks/use-auth-data.ts +9 -3
- package/src/ui/hooks/use-authenticate.ts +2 -0
- package/src/ui/hooks/use-captcha.tsx +5 -5
- package/src/ui/lib/auth-ui-provider.tsx +8 -2
- package/src/ui/lib/image-utils.ts +7 -0
|
@@ -65,7 +65,8 @@ function EmailForm({
|
|
|
65
65
|
authClient,
|
|
66
66
|
localization: contextLocalization,
|
|
67
67
|
toast,
|
|
68
|
-
localizeErrors
|
|
68
|
+
localizeErrors,
|
|
69
|
+
onAuthEvent
|
|
69
70
|
} = useContext(AuthUIContext)
|
|
70
71
|
|
|
71
72
|
localization = { ...contextLocalization, ...localization }
|
|
@@ -90,6 +91,9 @@ function EmailForm({
|
|
|
90
91
|
}, [form.formState.isSubmitting, setIsSubmitting])
|
|
91
92
|
|
|
92
93
|
async function sendEmailOTP({ email }: z.infer<typeof formSchema>) {
|
|
94
|
+
// Emit start event
|
|
95
|
+
onAuthEvent?.({ type: "SIGN_IN_START", email })
|
|
96
|
+
|
|
93
97
|
const fetchOptions: BetterFetchOption = {
|
|
94
98
|
throw: true,
|
|
95
99
|
headers: await getCaptchaHeaders("/email-otp/send-verification-otp")
|
|
@@ -109,13 +113,22 @@ function EmailForm({
|
|
|
109
113
|
|
|
110
114
|
setEmail(email)
|
|
111
115
|
} catch (error) {
|
|
116
|
+
const errorMessage = getLocalizedError({
|
|
117
|
+
error,
|
|
118
|
+
localization,
|
|
119
|
+
localizeErrors
|
|
120
|
+
})
|
|
121
|
+
const errorCode = (error as { error?: { code?: string } })?.error
|
|
122
|
+
?.code
|
|
123
|
+
|
|
124
|
+
onAuthEvent?.({
|
|
125
|
+
type: "SIGN_IN_ERROR",
|
|
126
|
+
error: { message: errorMessage, code: errorCode }
|
|
127
|
+
})
|
|
128
|
+
|
|
112
129
|
toast({
|
|
113
130
|
variant: "error",
|
|
114
|
-
message:
|
|
115
|
-
error,
|
|
116
|
-
localization,
|
|
117
|
-
localizeErrors
|
|
118
|
-
})
|
|
131
|
+
message: errorMessage
|
|
119
132
|
})
|
|
120
133
|
}
|
|
121
134
|
}
|
|
@@ -193,7 +206,8 @@ export function OTPForm({
|
|
|
193
206
|
authClient,
|
|
194
207
|
localization: contextLocalization,
|
|
195
208
|
toast,
|
|
196
|
-
localizeErrors
|
|
209
|
+
localizeErrors,
|
|
210
|
+
onAuthEvent
|
|
197
211
|
} = useContext(AuthUIContext)
|
|
198
212
|
|
|
199
213
|
localization = { ...contextLocalization, ...localization }
|
|
@@ -229,21 +243,47 @@ export function OTPForm({
|
|
|
229
243
|
|
|
230
244
|
async function verifyCode({ code }: z.infer<typeof formSchema>) {
|
|
231
245
|
try {
|
|
232
|
-
await authClient.signIn.emailOtp({
|
|
246
|
+
const data = await authClient.signIn.emailOtp({
|
|
233
247
|
email,
|
|
234
248
|
otp: code,
|
|
235
249
|
fetchOptions: { throw: true }
|
|
236
250
|
})
|
|
237
251
|
|
|
252
|
+
// Build user and session for events
|
|
253
|
+
const user = (data as { user?: Record<string, unknown> }).user as {
|
|
254
|
+
id: string
|
|
255
|
+
email: string
|
|
256
|
+
name?: string | null
|
|
257
|
+
image?: string | null
|
|
258
|
+
emailVerified: boolean
|
|
259
|
+
}
|
|
260
|
+
const session = {
|
|
261
|
+
token: (data as { token?: string }).token,
|
|
262
|
+
user
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Emit success events
|
|
266
|
+
onAuthEvent?.({ type: "SIGN_IN_SUCCESS", user, session })
|
|
267
|
+
onAuthEvent?.({ type: "AUTH_SUCCESS", user, session })
|
|
268
|
+
|
|
238
269
|
await onSuccess()
|
|
239
270
|
} catch (error) {
|
|
271
|
+
const errorMessage = getLocalizedError({
|
|
272
|
+
error,
|
|
273
|
+
localization,
|
|
274
|
+
localizeErrors
|
|
275
|
+
})
|
|
276
|
+
const errorCode = (error as { error?: { code?: string } })?.error
|
|
277
|
+
?.code
|
|
278
|
+
|
|
279
|
+
onAuthEvent?.({
|
|
280
|
+
type: "SIGN_IN_ERROR",
|
|
281
|
+
error: { message: errorMessage, code: errorCode }
|
|
282
|
+
})
|
|
283
|
+
|
|
240
284
|
toast({
|
|
241
285
|
variant: "error",
|
|
242
|
-
message:
|
|
243
|
-
error,
|
|
244
|
-
localization,
|
|
245
|
-
localizeErrors
|
|
246
|
-
})
|
|
286
|
+
message: errorMessage
|
|
247
287
|
})
|
|
248
288
|
|
|
249
289
|
form.reset()
|
|
@@ -273,7 +313,11 @@ export function OTPForm({
|
|
|
273
313
|
field.onChange(value)
|
|
274
314
|
|
|
275
315
|
if (value.length === 6) {
|
|
276
|
-
form.handleSubmit(verifyCode)()
|
|
316
|
+
form.handleSubmit(verifyCode)().catch(
|
|
317
|
+
() => {
|
|
318
|
+
// Error already handled in verifyCode
|
|
319
|
+
}
|
|
320
|
+
)
|
|
277
321
|
}
|
|
278
322
|
}}
|
|
279
323
|
containerClassName={
|
|
@@ -354,7 +354,11 @@ export function EmailVerificationForm({
|
|
|
354
354
|
field.onChange(value)
|
|
355
355
|
|
|
356
356
|
if (value.length === 6) {
|
|
357
|
-
form.handleSubmit(verifyCode)()
|
|
357
|
+
form.handleSubmit(verifyCode)().catch(
|
|
358
|
+
() => {
|
|
359
|
+
// Error already handled in verifyCode
|
|
360
|
+
}
|
|
361
|
+
)
|
|
358
362
|
}
|
|
359
363
|
}}
|
|
360
364
|
containerClassName={
|
|
@@ -58,7 +58,8 @@ export function MagicLinkForm({
|
|
|
58
58
|
redirectTo: contextRedirectTo,
|
|
59
59
|
viewPaths,
|
|
60
60
|
toast,
|
|
61
|
-
localizeErrors
|
|
61
|
+
localizeErrors,
|
|
62
|
+
onAuthEvent
|
|
62
63
|
} = useContext(AuthUIContext)
|
|
63
64
|
|
|
64
65
|
localization = { ...contextLocalization, ...localization }
|
|
@@ -107,6 +108,9 @@ export function MagicLinkForm({
|
|
|
107
108
|
}, [form.formState.isSubmitting, setIsSubmitting])
|
|
108
109
|
|
|
109
110
|
async function sendMagicLink({ email }: z.infer<typeof formSchema>) {
|
|
111
|
+
// Emit start event
|
|
112
|
+
onAuthEvent?.({ type: "SIGN_IN_START", email })
|
|
113
|
+
|
|
110
114
|
try {
|
|
111
115
|
const fetchOptions: BetterFetchOption = {
|
|
112
116
|
throw: true,
|
|
@@ -126,13 +130,22 @@ export function MagicLinkForm({
|
|
|
126
130
|
|
|
127
131
|
form.reset()
|
|
128
132
|
} catch (error) {
|
|
133
|
+
const errorMessage = getLocalizedError({
|
|
134
|
+
error,
|
|
135
|
+
localization,
|
|
136
|
+
localizeErrors
|
|
137
|
+
})
|
|
138
|
+
const errorCode = (error as { error?: { code?: string } })?.error
|
|
139
|
+
?.code
|
|
140
|
+
|
|
141
|
+
onAuthEvent?.({
|
|
142
|
+
type: "SIGN_IN_ERROR",
|
|
143
|
+
error: { message: errorMessage, code: errorCode }
|
|
144
|
+
})
|
|
145
|
+
|
|
129
146
|
toast({
|
|
130
147
|
variant: "error",
|
|
131
|
-
message:
|
|
132
|
-
error,
|
|
133
|
-
localization,
|
|
134
|
-
localizeErrors
|
|
135
|
-
})
|
|
148
|
+
message: errorMessage
|
|
136
149
|
})
|
|
137
150
|
resetCaptcha()
|
|
138
151
|
}
|
|
@@ -268,11 +268,21 @@ export function SignUpForm({
|
|
|
268
268
|
})
|
|
269
269
|
|
|
270
270
|
isSubmitting =
|
|
271
|
-
isSubmitting ||
|
|
271
|
+
isSubmitting ||
|
|
272
|
+
form.formState.isSubmitting ||
|
|
273
|
+
transitionPending ||
|
|
274
|
+
uploadingAvatar
|
|
272
275
|
|
|
273
276
|
useEffect(() => {
|
|
274
|
-
setIsSubmitting?.(
|
|
275
|
-
|
|
277
|
+
setIsSubmitting?.(
|
|
278
|
+
form.formState.isSubmitting || transitionPending || uploadingAvatar
|
|
279
|
+
)
|
|
280
|
+
}, [
|
|
281
|
+
form.formState.isSubmitting,
|
|
282
|
+
transitionPending,
|
|
283
|
+
uploadingAvatar,
|
|
284
|
+
setIsSubmitting
|
|
285
|
+
])
|
|
276
286
|
|
|
277
287
|
const handleAvatarChange = async (file: File) => {
|
|
278
288
|
if (!avatar) return
|
|
@@ -54,6 +54,7 @@ export function TwoFactorForm({
|
|
|
54
54
|
|
|
55
55
|
const {
|
|
56
56
|
authClient,
|
|
57
|
+
authFlowMode,
|
|
57
58
|
basePath,
|
|
58
59
|
hooks: { useSession },
|
|
59
60
|
localization: contextLocalization,
|
|
@@ -156,7 +157,11 @@ export function TwoFactorForm({
|
|
|
156
157
|
(error as BetterFetchError).error.code ===
|
|
157
158
|
"INVALID_TWO_FACTOR_COOKIE"
|
|
158
159
|
) {
|
|
159
|
-
|
|
160
|
+
// In internal mode, AuthFlow handles navigation via events
|
|
161
|
+
// In route mode, use browser history
|
|
162
|
+
if (authFlowMode !== "internal") {
|
|
163
|
+
history.back()
|
|
164
|
+
}
|
|
160
165
|
}
|
|
161
166
|
}
|
|
162
167
|
|
|
@@ -291,7 +296,9 @@ export function TwoFactorForm({
|
|
|
291
296
|
if (value.length === 6) {
|
|
292
297
|
form.handleSubmit(
|
|
293
298
|
verifyCode
|
|
294
|
-
)()
|
|
299
|
+
)().catch(() => {
|
|
300
|
+
// Error already handled in verifyCode
|
|
301
|
+
})
|
|
295
302
|
}
|
|
296
303
|
}}
|
|
297
304
|
containerClassName={
|
|
@@ -43,14 +43,16 @@ const FormField = <
|
|
|
43
43
|
const useFormField = () => {
|
|
44
44
|
const fieldContext = React.useContext(FormFieldContext)
|
|
45
45
|
const itemContext = React.useContext(FormItemContext)
|
|
46
|
-
const { getFieldState } = useFormContext()
|
|
47
|
-
const formState = useFormState({ name: fieldContext.name })
|
|
48
|
-
const fieldState = getFieldState(fieldContext.name, formState)
|
|
49
46
|
|
|
50
|
-
|
|
47
|
+
// Check context before accessing its properties
|
|
48
|
+
if (!fieldContext?.name) {
|
|
51
49
|
throw new Error("useFormField should be used within <FormField>")
|
|
52
50
|
}
|
|
53
51
|
|
|
52
|
+
const { getFieldState } = useFormContext()
|
|
53
|
+
const formState = useFormState({ name: fieldContext.name })
|
|
54
|
+
const fieldState = getFieldState(fieldContext.name, formState)
|
|
55
|
+
|
|
54
56
|
const { id } = itemContext
|
|
55
57
|
|
|
56
58
|
return {
|
|
@@ -55,6 +55,11 @@ export function useAuthData<T>({
|
|
|
55
55
|
const previousUserId = useRef<string | undefined>(undefined)
|
|
56
56
|
const [error, setError] = useState<FetchError | null>(null)
|
|
57
57
|
|
|
58
|
+
// Use ref to access cacheEntry in refetch without creating dependency
|
|
59
|
+
// This prevents infinite loops from cacheEntry changes triggering refetch recreation
|
|
60
|
+
const cacheEntryRef = useRef(cacheEntry)
|
|
61
|
+
cacheEntryRef.current = cacheEntry
|
|
62
|
+
|
|
58
63
|
const refetch = useCallback(async () => {
|
|
59
64
|
// Check if there's already an in-flight request for this key
|
|
60
65
|
const existingRequest = authDataCache.getInFlightRequest<{
|
|
@@ -76,8 +81,8 @@ export function useAuthData<T>({
|
|
|
76
81
|
return
|
|
77
82
|
}
|
|
78
83
|
|
|
79
|
-
// Mark as refetching if we have cached data
|
|
80
|
-
if (
|
|
84
|
+
// Mark as refetching if we have cached data (use ref to avoid dependency)
|
|
85
|
+
if (cacheEntryRef.current?.data !== undefined) {
|
|
81
86
|
authDataCache.setRefetching(stableCacheKey, true)
|
|
82
87
|
}
|
|
83
88
|
|
|
@@ -121,7 +126,8 @@ export function useAuthData<T>({
|
|
|
121
126
|
authDataCache.setRefetching(stableCacheKey, false)
|
|
122
127
|
authDataCache.removeInFlightRequest(stableCacheKey)
|
|
123
128
|
}
|
|
124
|
-
|
|
129
|
+
// Note: cacheEntry is accessed via ref to avoid infinite loop
|
|
130
|
+
}, [stableCacheKey, toast, localization, localizeErrors])
|
|
125
131
|
|
|
126
132
|
useEffect(() => {
|
|
127
133
|
const currentUserId = sessionData?.user?.id
|
|
@@ -34,6 +34,8 @@ export function useAuthenticate<TAuthClient extends AnyAuthClient>(
|
|
|
34
34
|
| undefined
|
|
35
35
|
|
|
36
36
|
useEffect(() => {
|
|
37
|
+
// SSR guard - window not available on server
|
|
38
|
+
if (typeof window === "undefined") return
|
|
37
39
|
if (!enabled || isPending || sessionData) return
|
|
38
40
|
|
|
39
41
|
const searchParams = new URLSearchParams(window.location.search)
|
|
@@ -60,28 +60,28 @@ export function useCaptcha({
|
|
|
60
60
|
}
|
|
61
61
|
case "google-recaptcha-v2-checkbox": {
|
|
62
62
|
const recaptchaRef = captchaRef as RefObject<ReCAPTCHA>
|
|
63
|
-
response = recaptchaRef.current
|
|
63
|
+
response = recaptchaRef.current?.getValue?.()
|
|
64
64
|
break
|
|
65
65
|
}
|
|
66
66
|
case "google-recaptcha-v2-invisible": {
|
|
67
67
|
const recaptchaRef = captchaRef as RefObject<ReCAPTCHA>
|
|
68
|
-
response = await recaptchaRef.current
|
|
68
|
+
response = await recaptchaRef.current?.executeAsync?.()
|
|
69
69
|
break
|
|
70
70
|
}
|
|
71
71
|
case "cloudflare-turnstile": {
|
|
72
72
|
const turnstileRef = captchaRef as RefObject<TurnstileInstance>
|
|
73
|
-
response = turnstileRef.current
|
|
73
|
+
response = turnstileRef.current?.getResponse?.()
|
|
74
74
|
break
|
|
75
75
|
}
|
|
76
76
|
case "hcaptcha": {
|
|
77
77
|
const hcaptchaRef = captchaRef as RefObject<HCaptcha>
|
|
78
|
-
response = hcaptchaRef.current
|
|
78
|
+
response = hcaptchaRef.current?.getResponse?.()
|
|
79
79
|
break
|
|
80
80
|
}
|
|
81
81
|
case "captchafox": {
|
|
82
82
|
const captchafoxRef =
|
|
83
83
|
captchaRef as RefObject<CaptchaFoxInstance>
|
|
84
|
-
response = captchafoxRef.current
|
|
84
|
+
response = captchafoxRef.current?.getResponse?.()
|
|
85
85
|
break
|
|
86
86
|
}
|
|
87
87
|
}
|
|
@@ -48,11 +48,17 @@ const DefaultLink: Link = ({ href, className, children }) => (
|
|
|
48
48
|
)
|
|
49
49
|
|
|
50
50
|
const defaultNavigate = (href: string) => {
|
|
51
|
-
window
|
|
51
|
+
// SSR guard - window not available on server
|
|
52
|
+
if (typeof window !== "undefined") {
|
|
53
|
+
window.location.href = href
|
|
54
|
+
}
|
|
52
55
|
}
|
|
53
56
|
|
|
54
57
|
const defaultReplace = (href: string) => {
|
|
55
|
-
window
|
|
58
|
+
// SSR guard - window not available on server
|
|
59
|
+
if (typeof window !== "undefined") {
|
|
60
|
+
window.location.replace(href)
|
|
61
|
+
}
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
// Default toast is a no-op - users should provide their own via onError/onSuccess
|
|
@@ -4,6 +4,13 @@ export async function resizeAndCropImage(
|
|
|
4
4
|
size: number,
|
|
5
5
|
extension: string
|
|
6
6
|
): Promise<File> {
|
|
7
|
+
// SSR guard - image processing requires browser APIs
|
|
8
|
+
if (typeof window === "undefined" || typeof document === "undefined") {
|
|
9
|
+
throw new Error(
|
|
10
|
+
"resizeAndCropImage requires a browser environment (window and document must be defined)"
|
|
11
|
+
)
|
|
12
|
+
}
|
|
13
|
+
|
|
7
14
|
const image = await loadImage(file)
|
|
8
15
|
|
|
9
16
|
const canvas = document.createElement("canvas")
|