@erikey/react 0.4.30 → 0.4.32

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erikey/react",
3
- "version": "0.4.30",
3
+ "version": "0.4.32",
4
4
  "description": "React SDK for Erikey - B2B authentication and user management. UI components based on better-auth-ui.",
5
5
  "main": "./dist/index.mjs",
6
6
  "module": "./dist/index.mjs",
@@ -0,0 +1,254 @@
1
+ "use client"
2
+
3
+ import { type ReactNode, useCallback, useMemo, useState } from "react"
4
+
5
+ import { AuthUIContext, AuthUIProvider } from "../../lib/auth-ui-provider"
6
+ import type { AuthUIProviderProps } from "../../lib/auth-ui-provider"
7
+ import type {
8
+ AuthFlowEvent,
9
+ AuthFlowEventHandler,
10
+ AuthFlowView
11
+ } from "../../types/auth-flow-events"
12
+ import type { AuthViewPaths } from "../../lib/view-paths"
13
+ import type { AuthViewClassNames } from "./auth-view"
14
+ import { AuthView as AuthViewComponent } from "./auth-view"
15
+
16
+ export interface AuthFlowProps
17
+ extends Omit<AuthUIProviderProps, "children" | "navigate"> {
18
+ /**
19
+ * Navigation mode for auth flow:
20
+ * - "internal": Manages view state internally without URL changes (single page)
21
+ * - "route": Uses URL-based navigation (Better Auth native behavior)
22
+ * @default "internal"
23
+ */
24
+ mode?: "internal" | "route"
25
+ /**
26
+ * Event handler for auth flow events.
27
+ * Called with typed events during sign-in, sign-up, verification, etc.
28
+ */
29
+ onEvent?: AuthFlowEventHandler
30
+ /**
31
+ * Initial view to display
32
+ * @default "SIGN_IN"
33
+ */
34
+ initialView?: AuthFlowView
35
+ /**
36
+ * URL to redirect to after successful authentication.
37
+ * In "internal" mode, called after AUTH_SUCCESS event.
38
+ */
39
+ redirectTo?: string
40
+ /**
41
+ * Custom navigate function for "route" mode.
42
+ * Defaults to window.location.href change.
43
+ */
44
+ navigate?: (href: string) => void
45
+ /**
46
+ * Additional class names for the auth view
47
+ */
48
+ className?: string
49
+ /**
50
+ * Class name overrides for specific elements
51
+ */
52
+ classNames?: AuthViewClassNames
53
+ /**
54
+ * Custom card header content
55
+ */
56
+ cardHeader?: ReactNode
57
+ /**
58
+ * Custom card footer content
59
+ */
60
+ cardFooter?: ReactNode
61
+ /**
62
+ * Layout for social provider buttons
63
+ * @default "auto"
64
+ */
65
+ socialLayout?: "auto" | "horizontal" | "grid" | "vertical"
66
+ /**
67
+ * Number of visual separators in OTP inputs
68
+ * @default 0
69
+ */
70
+ otpSeparators?: 0 | 1 | 2
71
+ }
72
+
73
+ /**
74
+ * Mapping from AuthFlowView to AuthViewPaths key
75
+ */
76
+ const flowViewToPathKey: Record<AuthFlowView, keyof AuthViewPaths> = {
77
+ SIGN_IN: "SIGN_IN",
78
+ SIGN_UP: "SIGN_UP",
79
+ EMAIL_VERIFICATION: "EMAIL_VERIFICATION",
80
+ TWO_FACTOR: "TWO_FACTOR",
81
+ FORGOT_PASSWORD: "FORGOT_PASSWORD",
82
+ RESET_PASSWORD: "RESET_PASSWORD",
83
+ MAGIC_LINK: "MAGIC_LINK"
84
+ }
85
+
86
+ /**
87
+ * Parse a path to determine the view
88
+ */
89
+ function parsePathToView(path: string): { view: AuthFlowView; email?: string } {
90
+ const url = new URL(path, "http://localhost")
91
+ const pathname = url.pathname
92
+ const email = url.searchParams.get("email") || undefined
93
+
94
+ if (pathname.includes("email-verification")) {
95
+ return { view: "EMAIL_VERIFICATION", email }
96
+ }
97
+ if (pathname.includes("two-factor")) {
98
+ return { view: "TWO_FACTOR", email }
99
+ }
100
+ if (pathname.includes("forgot-password")) {
101
+ return { view: "FORGOT_PASSWORD", email }
102
+ }
103
+ if (pathname.includes("reset-password")) {
104
+ return { view: "RESET_PASSWORD", email }
105
+ }
106
+ if (pathname.includes("sign-up")) {
107
+ return { view: "SIGN_UP", email }
108
+ }
109
+ if (pathname.includes("magic-link")) {
110
+ return { view: "MAGIC_LINK", email }
111
+ }
112
+ return { view: "SIGN_IN", email }
113
+ }
114
+
115
+ /**
116
+ * AuthFlow - Unified authentication component
117
+ *
118
+ * Handles all auth states (sign-in, sign-up, verification, 2FA) with two modes:
119
+ * - "internal": Single-page experience with state-based view switching
120
+ * - "route": URL-based navigation (Better Auth native)
121
+ *
122
+ * Emits typed events via onEvent for full observability.
123
+ *
124
+ * @example
125
+ * // Internal mode (single page)
126
+ * <AuthFlow
127
+ * mode="internal"
128
+ * authClient={authClient}
129
+ * onEvent={(event) => {
130
+ * if (event.type === 'AUTH_SUCCESS') {
131
+ * router.push('/dashboard');
132
+ * }
133
+ * if (event.type === 'SIGN_UP_START') {
134
+ * analytics.track('signup_started');
135
+ * }
136
+ * }}
137
+ * />
138
+ *
139
+ * @example
140
+ * // Route mode (URL changes)
141
+ * <AuthFlow
142
+ * mode="route"
143
+ * authClient={authClient}
144
+ * basePath="/auth"
145
+ * />
146
+ */
147
+ export function AuthFlow({
148
+ mode = "internal",
149
+ onEvent,
150
+ initialView = "SIGN_IN",
151
+ redirectTo = "/",
152
+ navigate: externalNavigate,
153
+ className,
154
+ classNames,
155
+ cardHeader,
156
+ cardFooter,
157
+ socialLayout = "auto",
158
+ otpSeparators = 0,
159
+ // AuthUIProvider props
160
+ authClient,
161
+ basePath = "/auth",
162
+ ...providerProps
163
+ }: AuthFlowProps) {
164
+ // Internal state for "internal" mode
165
+ const [currentView, setCurrentView] = useState<AuthFlowView>(initialView)
166
+ const [verifyEmail, setVerifyEmail] = useState<string | undefined>()
167
+
168
+ // Handle navigation based on mode
169
+ const handleNavigate = useCallback(
170
+ (href: string) => {
171
+ if (mode === "route") {
172
+ // In route mode, use external navigate or default to location change
173
+ if (externalNavigate) {
174
+ externalNavigate(href)
175
+ } else {
176
+ window.location.href = href
177
+ }
178
+ return
179
+ }
180
+
181
+ // In internal mode, parse the path and update state
182
+ const { view, email } = parsePathToView(href)
183
+
184
+ // Emit view change event
185
+ onEvent?.({ type: "VIEW_CHANGE", view, email })
186
+
187
+ setCurrentView(view)
188
+ if (email) {
189
+ setVerifyEmail(email)
190
+ }
191
+ },
192
+ [mode, externalNavigate, onEvent]
193
+ )
194
+
195
+ // Combined event handler that wraps user's onEvent
196
+ const handleAuthEvent = useCallback(
197
+ (event: AuthFlowEvent) => {
198
+ // Forward to user's handler
199
+ onEvent?.(event)
200
+
201
+ // In internal mode, handle AUTH_SUCCESS by redirecting
202
+ if (mode === "internal" && event.type === "AUTH_SUCCESS") {
203
+ if (externalNavigate) {
204
+ externalNavigate(redirectTo)
205
+ } else {
206
+ window.location.href = redirectTo
207
+ }
208
+ }
209
+ },
210
+ [onEvent, mode, redirectTo, externalNavigate]
211
+ )
212
+
213
+ // Get the view key for AuthView component
214
+ const viewKey = useMemo(() => {
215
+ if (mode === "route") {
216
+ return undefined // Let AuthView determine from URL
217
+ }
218
+ return flowViewToPathKey[currentView]
219
+ }, [mode, currentView])
220
+
221
+ return (
222
+ <AuthUIProvider
223
+ authClient={authClient}
224
+ basePath={basePath}
225
+ navigate={handleNavigate}
226
+ onAuthEvent={handleAuthEvent}
227
+ redirectTo={redirectTo}
228
+ {...providerProps}
229
+ >
230
+ <AuthViewComponent
231
+ className={className}
232
+ classNames={classNames}
233
+ cardHeader={cardHeader}
234
+ cardFooter={cardFooter}
235
+ socialLayout={socialLayout}
236
+ otpSeparators={otpSeparators}
237
+ redirectTo={redirectTo}
238
+ view={viewKey}
239
+ />
240
+ </AuthUIProvider>
241
+ )
242
+ }
243
+
244
+ /**
245
+ * Hook to emit auth events from within the auth flow.
246
+ * Can be used by custom components within AuthFlow.
247
+ */
248
+ export function useAuthFlowEvent() {
249
+ const context = AuthUIContext
250
+ // This hook allows custom components to emit events
251
+ // Usage: const emitEvent = useAuthFlowEvent()
252
+ // emitEvent({ type: 'CUSTOM_EVENT', ... })
253
+ return context
254
+ }
@@ -26,6 +26,11 @@ export interface EmailVerificationFormProps {
26
26
  className?: string
27
27
  classNames?: AuthFormClassNames
28
28
  callbackURL?: string
29
+ /**
30
+ * Email address to verify. If not provided, reads from URL query params.
31
+ * Prop takes priority over URL params (for state-based navigation).
32
+ */
33
+ email?: string
29
34
  isSubmitting?: boolean
30
35
  localization: Partial<AuthLocalization>
31
36
  otpSeparators?: 0 | 1 | 2
@@ -41,6 +46,7 @@ export function EmailVerificationForm({
41
46
  classNames,
42
47
  otpSeparators,
43
48
  callbackURL,
49
+ email: emailProp,
44
50
  isSubmitting,
45
51
  redirectTo,
46
52
  setIsSubmitting
@@ -55,15 +61,22 @@ export function EmailVerificationForm({
55
61
  localizeErrors,
56
62
  navigate,
57
63
  basePath,
58
- viewPaths
64
+ viewPaths,
65
+ emailVerification,
66
+ onAuthEvent
59
67
  } = useContext(AuthUIContext)
60
68
 
61
69
  localization = { ...contextLocalization, ...localization }
62
70
 
63
- const email =
71
+ // Determine verification method from context (OTP vs Link)
72
+ const isOtpMethod = emailVerification?.otp ?? true
73
+
74
+ // Priority: prop > URL params (prop is explicit for state-based navigation)
75
+ const emailFromUrl =
64
76
  typeof window !== "undefined"
65
77
  ? new URLSearchParams(window.location.search).get("email") || ""
66
78
  : ""
79
+ const email = emailProp || emailFromUrl
67
80
 
68
81
  const { onSuccess, isPending: transitionPending } = useOnSuccessTransition({
69
82
  redirectTo
@@ -103,7 +116,21 @@ export function EmailVerificationForm({
103
116
  }
104
117
  }, [countdown])
105
118
 
119
+ // Emit verification start event on mount
120
+ useEffect(() => {
121
+ if (email) {
122
+ onAuthEvent?.({
123
+ type: "VERIFICATION_START",
124
+ email,
125
+ method: isOtpMethod ? "otp" : "link"
126
+ })
127
+ }
128
+ }, [email, isOtpMethod, onAuthEvent])
129
+
106
130
  async function verifyCode({ code }: z.infer<typeof formSchema>) {
131
+ // Emit code submitted event
132
+ onAuthEvent?.({ type: "VERIFICATION_CODE_SUBMITTED", email })
133
+
107
134
  try {
108
135
  const data = await authClient.emailOtp.verifyEmail({
109
136
  email,
@@ -111,9 +138,27 @@ export function EmailVerificationForm({
111
138
  fetchOptions: { throw: true }
112
139
  })
113
140
 
141
+ // Build user object for events
142
+ const user = (data as { user?: Record<string, unknown> }).user as {
143
+ id: string
144
+ email: string
145
+ name?: string | null
146
+ image?: string | null
147
+ emailVerified: boolean
148
+ }
149
+
114
150
  if ("token" in data && data.token) {
151
+ const session = {
152
+ token: data.token as string,
153
+ user
154
+ }
155
+ onAuthEvent?.({ type: "VERIFICATION_SUCCESS", user, session })
156
+ onAuthEvent?.({ type: "AUTH_SUCCESS", user, session })
115
157
  await onSuccess()
116
158
  } else {
159
+ // No token - verification succeeded but no session
160
+ const session = { user }
161
+ onAuthEvent?.({ type: "VERIFICATION_SUCCESS", user, session })
117
162
  navigate(
118
163
  `${basePath}/${viewPaths.SIGN_IN}${window.location.search}`
119
164
  )
@@ -123,13 +168,24 @@ export function EmailVerificationForm({
123
168
  })
124
169
  }
125
170
  } catch (error) {
171
+ const errorMessage = getLocalizedError({
172
+ error,
173
+ localization,
174
+ localizeErrors
175
+ })
176
+
177
+ const errorCode = (
178
+ error as { error?: { code?: string; message?: string } }
179
+ )?.error?.code
180
+
181
+ onAuthEvent?.({
182
+ type: "VERIFICATION_ERROR",
183
+ error: { message: errorMessage, code: errorCode }
184
+ })
185
+
126
186
  toast({
127
187
  variant: "error",
128
- message: getLocalizedError({
129
- error,
130
- localization,
131
- localizeErrors
132
- })
188
+ message: errorMessage
133
189
  })
134
190
 
135
191
  form.reset()
@@ -149,6 +205,8 @@ export function EmailVerificationForm({
149
205
  fetchOptions: { throw: true }
150
206
  })
151
207
 
208
+ onAuthEvent?.({ type: "VERIFICATION_CODE_RESENT", email })
209
+
152
210
  toast({
153
211
  variant: "success",
154
212
  message: localization.EMAIL_OTP_VERIFICATION_SENT!
@@ -167,6 +225,40 @@ export function EmailVerificationForm({
167
225
  }
168
226
  }
169
227
 
228
+ async function resendVerificationLink() {
229
+ if (resendDisabled) return
230
+
231
+ setResendDisabled(true)
232
+ setCountdown(30)
233
+
234
+ try {
235
+ await authClient.sendVerificationEmail({
236
+ email,
237
+ fetchOptions: { throw: true }
238
+ })
239
+
240
+ onAuthEvent?.({ type: "VERIFICATION_CODE_RESENT", email })
241
+
242
+ toast({
243
+ variant: "success",
244
+ message:
245
+ (localization as Record<string, string | undefined>)
246
+ .VERIFICATION_EMAIL_SENT || "Verification email sent!"
247
+ })
248
+ } catch (error) {
249
+ toast({
250
+ variant: "error",
251
+ message: getLocalizedError({
252
+ error,
253
+ localization,
254
+ localizeErrors
255
+ })
256
+ })
257
+ setResendDisabled(false)
258
+ setCountdown(0)
259
+ }
260
+ }
261
+
170
262
  if (!email) {
171
263
  return (
172
264
  <div className={cn("grid w-full gap-6", className)}>
@@ -183,6 +275,58 @@ export function EmailVerificationForm({
183
275
  )
184
276
  }
185
277
 
278
+ // Link-based verification: show "check your email" message
279
+ // Use type assertion for custom localization keys
280
+ const loc = localization as Record<string, string | undefined>
281
+
282
+ if (!isOtpMethod) {
283
+ return (
284
+ <div className={cn("grid w-full gap-6", className, classNames?.base)}>
285
+ <div className="text-center space-y-2">
286
+ <h2 className="font-semibold text-lg">
287
+ {loc.CHECK_YOUR_EMAIL || "Check your email"}
288
+ </h2>
289
+ <p className="text-muted-foreground text-sm">
290
+ {loc.VERIFICATION_LINK_SENT_TO ||
291
+ "We sent a verification link to"}{" "}
292
+ <strong>{email}</strong>
293
+ </p>
294
+ <p className="text-muted-foreground text-xs">
295
+ {loc.CLICK_LINK_TO_VERIFY ||
296
+ "Click the link in your email to verify your account."}
297
+ </p>
298
+ </div>
299
+
300
+ <div className="grid gap-4">
301
+ <Button
302
+ type="button"
303
+ variant="outline"
304
+ onClick={resendVerificationLink}
305
+ disabled={resendDisabled}
306
+ className={cn("w-full", classNames?.button)}
307
+ >
308
+ {resendDisabled
309
+ ? `${localization.RESEND_VERIFICATION_EMAIL || "Resend verification email"} (${countdown}s)`
310
+ : localization.RESEND_VERIFICATION_EMAIL ||
311
+ "Resend verification email"}
312
+ </Button>
313
+
314
+ {onCancel && (
315
+ <Button
316
+ type="button"
317
+ variant="ghost"
318
+ onClick={onCancel}
319
+ className="w-full"
320
+ >
321
+ {localization.CANCEL || "Cancel"}
322
+ </Button>
323
+ )}
324
+ </div>
325
+ </div>
326
+ )
327
+ }
328
+
329
+ // OTP-based verification: show OTP input form
186
330
  return (
187
331
  <Form {...form}>
188
332
  <form
@@ -68,7 +68,8 @@ export function SignInForm({
68
68
  toast,
69
69
  Link,
70
70
  localizeErrors,
71
- emailVerification
71
+ emailVerification,
72
+ onAuthEvent
72
73
  } = useContext(AuthUIContext)
73
74
 
74
75
  const rememberMeEnabled = credentials?.rememberMe
@@ -115,6 +116,9 @@ export function SignInForm({
115
116
  password,
116
117
  rememberMe
117
118
  }: z.infer<typeof formSchema>) {
119
+ // Emit start event
120
+ onAuthEvent?.({ type: "SIGN_IN_START", email })
121
+
118
122
  try {
119
123
  let response: Record<string, unknown> = {}
120
124
 
@@ -145,10 +149,26 @@ export function SignInForm({
145
149
  }
146
150
 
147
151
  if (response.twoFactorRedirect) {
152
+ onAuthEvent?.({ type: "SIGN_IN_REQUIRES_2FA", email })
148
153
  navigate(
149
154
  `${basePath}/${viewPaths.TWO_FACTOR}${window.location.search}`
150
155
  )
151
156
  } else {
157
+ // Build user and session for events
158
+ const user = response.user as {
159
+ id: string
160
+ email: string
161
+ name?: string | null
162
+ image?: string | null
163
+ emailVerified: boolean
164
+ }
165
+ const session = {
166
+ token: response.token as string | undefined,
167
+ user
168
+ }
169
+
170
+ onAuthEvent?.({ type: "SIGN_IN_SUCCESS", user, session })
171
+ onAuthEvent?.({ type: "AUTH_SUCCESS", user, session })
152
172
  await onSuccess()
153
173
  }
154
174
  } catch (error) {
@@ -161,6 +181,16 @@ export function SignInForm({
161
181
  localizeErrors
162
182
  })
163
183
 
184
+ const errorCode = (
185
+ error as { error?: { code?: string; message?: string } }
186
+ )?.error?.code
187
+
188
+ // Emit error event
189
+ onAuthEvent?.({
190
+ type: "SIGN_IN_ERROR",
191
+ error: { message: errorMessage, code: errorCode }
192
+ })
193
+
164
194
  // Set inline error for display
165
195
  form.setError("root", { message: errorMessage })
166
196
 
@@ -170,11 +200,8 @@ export function SignInForm({
170
200
  message: errorMessage
171
201
  })
172
202
 
173
- if (
174
- emailVerification?.otp &&
175
- (error as { error?: { code?: string; message?: string } })
176
- ?.error?.code === "EMAIL_NOT_VERIFIED"
177
- ) {
203
+ if (errorCode === "EMAIL_NOT_VERIFIED") {
204
+ onAuthEvent?.({ type: "SIGN_IN_REQUIRES_VERIFICATION", email })
178
205
  navigate(
179
206
  `${basePath}/${
180
207
  viewPaths.EMAIL_VERIFICATION
@@ -92,7 +92,8 @@ export function SignUpForm({
92
92
  toast,
93
93
  avatar,
94
94
  localizeErrors,
95
- emailVerification
95
+ emailVerification,
96
+ onAuthEvent
96
97
  } = useContext(AuthUIContext)
97
98
 
98
99
  const confirmPasswordEnabled = credentials?.confirmPassword
@@ -331,6 +332,13 @@ export function SignUpForm({
331
332
  image,
332
333
  ...additionalFieldValues
333
334
  }: z.infer<typeof formSchema>) {
335
+ // Emit start event
336
+ onAuthEvent?.({
337
+ type: "SIGN_UP_START",
338
+ email: email as string,
339
+ name: name as string | undefined
340
+ })
341
+
334
342
  try {
335
343
  // Validate additional fields with custom validators if provided
336
344
  for (const [field, value] of Object.entries(
@@ -384,18 +392,56 @@ export function SignUpForm({
384
392
  fetchOptions
385
393
  })
386
394
 
395
+ // Type the response for type safety
396
+ const response = data as {
397
+ user?: {
398
+ id: string
399
+ email: string
400
+ name?: string | null
401
+ image?: string | null
402
+ emailVerified: boolean
403
+ }
404
+ token?: string
405
+ twoFactorRedirect?: boolean
406
+ }
407
+
408
+ // Build user object for events
409
+ const user = response.user
410
+
411
+ // Emit sign up success (user created)
412
+ if (user) {
413
+ onAuthEvent?.({ type: "SIGN_UP_SUCCESS", user })
414
+ }
415
+
387
416
  // Check for 2FA redirect first (same pattern as sign-in form)
388
- if ((data as { twoFactorRedirect?: boolean }).twoFactorRedirect) {
417
+ if (response.twoFactorRedirect) {
389
418
  navigate(
390
419
  `${basePath}/${viewPaths.TWO_FACTOR}${window.location.search}`
391
420
  )
392
- } else if ("token" in data && data.token) {
421
+ } else if (response.token && user) {
422
+ // Token present = verified or no verification required
423
+ const session = {
424
+ token: response.token,
425
+ user
426
+ }
427
+ onAuthEvent?.({ type: "AUTH_SUCCESS", user, session })
393
428
  await onSuccess()
394
- } else if (emailVerification?.otp) {
429
+ } else if (user && user.emailVerified === false) {
430
+ // No token + emailVerified: false = needs verification
431
+ // This is response-based (Better Auth pattern), not config-based
432
+ onAuthEvent?.({
433
+ type: "SIGN_UP_REQUIRES_VERIFICATION",
434
+ email: email as string
435
+ })
395
436
  navigate(
396
437
  `${basePath}/${viewPaths.EMAIL_VERIFICATION}?email=${encodeURIComponent(email as string)}`
397
438
  )
398
439
  } else {
440
+ // Fallback: redirect to sign-in (e.g., link-based verification sent)
441
+ onAuthEvent?.({
442
+ type: "SIGN_UP_REQUIRES_VERIFICATION",
443
+ email: email as string
444
+ })
399
445
  navigate(
400
446
  `${basePath}/${viewPaths.SIGN_IN}${window.location.search}`
401
447
  )
@@ -411,6 +457,16 @@ export function SignUpForm({
411
457
  localizeErrors
412
458
  })
413
459
 
460
+ const errorCode = (
461
+ error as { error?: { code?: string; message?: string } }
462
+ )?.error?.code
463
+
464
+ // Emit error event
465
+ onAuthEvent?.({
466
+ type: "SIGN_UP_ERROR",
467
+ error: { message: errorMessage, code: errorCode }
468
+ })
469
+
414
470
  // Set inline error for display
415
471
  form.setError("root", { message: errorMessage })
416
472