@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/dist/styles.css +5 -0
- package/dist/styles.css.map +1 -1
- package/dist/ui/index.mjs +6122 -5832
- package/dist/ui/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/ui/components/auth/auth-flow.tsx +254 -0
- package/src/ui/components/auth/forms/email-verification-form.tsx +151 -7
- package/src/ui/components/auth/forms/sign-in-form.tsx +33 -6
- package/src/ui/components/auth/forms/sign-up-form.tsx +60 -4
- package/src/ui/components/auth/forms/two-factor-form.tsx +43 -7
- package/src/ui/components/auth/provider-button.tsx +16 -1
- package/src/ui/index.ts +2 -0
- package/src/ui/lib/auth-ui-provider.tsx +6 -0
- package/src/ui/types/auth-flow-events.ts +125 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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 (
|
|
417
|
+
if (response.twoFactorRedirect) {
|
|
389
418
|
navigate(
|
|
390
419
|
`${basePath}/${viewPaths.TWO_FACTOR}${window.location.search}`
|
|
391
420
|
)
|
|
392
|
-
} else if (
|
|
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 (
|
|
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
|
|