@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
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock auth client for testing
|
|
3
|
+
*
|
|
4
|
+
* Provides a fully-typed mock of the auth client that can be configured
|
|
5
|
+
* to return specific responses for testing different auth flows.
|
|
6
|
+
*/
|
|
7
|
+
import { vi } from "vitest"
|
|
8
|
+
|
|
9
|
+
export interface MockUser {
|
|
10
|
+
id: string
|
|
11
|
+
email: string
|
|
12
|
+
name?: string | null
|
|
13
|
+
image?: string | null
|
|
14
|
+
emailVerified: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface MockSession {
|
|
18
|
+
token?: string
|
|
19
|
+
user?: MockUser
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface MockSignUpResponse {
|
|
23
|
+
user?: MockUser
|
|
24
|
+
session?: MockSession
|
|
25
|
+
token?: string
|
|
26
|
+
twoFactorRedirect?: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface MockSignInResponse {
|
|
30
|
+
user?: MockUser
|
|
31
|
+
session?: MockSession
|
|
32
|
+
token?: string
|
|
33
|
+
twoFactorRedirect?: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Create a mock auth client with configurable responses
|
|
38
|
+
*/
|
|
39
|
+
export function createMockAuthClient(overrides: Record<string, unknown> = {}) {
|
|
40
|
+
const mockClient = {
|
|
41
|
+
// Sign in methods
|
|
42
|
+
signIn: {
|
|
43
|
+
email: vi.fn().mockResolvedValue({
|
|
44
|
+
data: { user: null, session: null },
|
|
45
|
+
error: null,
|
|
46
|
+
}),
|
|
47
|
+
social: vi.fn().mockResolvedValue({
|
|
48
|
+
data: null,
|
|
49
|
+
error: null,
|
|
50
|
+
}),
|
|
51
|
+
emailOtp: vi.fn().mockResolvedValue({
|
|
52
|
+
data: { user: null, session: null },
|
|
53
|
+
error: null,
|
|
54
|
+
}),
|
|
55
|
+
magicLink: vi.fn().mockResolvedValue({
|
|
56
|
+
data: null,
|
|
57
|
+
error: null,
|
|
58
|
+
}),
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
// Sign up methods
|
|
62
|
+
signUp: {
|
|
63
|
+
email: vi.fn().mockResolvedValue({
|
|
64
|
+
data: { user: null, session: null },
|
|
65
|
+
error: null,
|
|
66
|
+
}),
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
// Sign out
|
|
70
|
+
signOut: vi.fn().mockResolvedValue({
|
|
71
|
+
data: null,
|
|
72
|
+
error: null,
|
|
73
|
+
}),
|
|
74
|
+
|
|
75
|
+
// Session
|
|
76
|
+
useSession: vi.fn(() => ({
|
|
77
|
+
data: null,
|
|
78
|
+
isPending: false,
|
|
79
|
+
error: null,
|
|
80
|
+
})),
|
|
81
|
+
getSession: vi.fn().mockResolvedValue({
|
|
82
|
+
data: null,
|
|
83
|
+
error: null,
|
|
84
|
+
}),
|
|
85
|
+
|
|
86
|
+
// Email OTP
|
|
87
|
+
emailOtp: {
|
|
88
|
+
sendVerificationOtp: vi.fn().mockResolvedValue({
|
|
89
|
+
data: null,
|
|
90
|
+
error: null,
|
|
91
|
+
}),
|
|
92
|
+
verifyEmail: vi.fn().mockResolvedValue({
|
|
93
|
+
data: { user: null, session: null },
|
|
94
|
+
error: null,
|
|
95
|
+
}),
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
// Password reset
|
|
99
|
+
forgetPassword: vi.fn().mockResolvedValue({
|
|
100
|
+
data: null,
|
|
101
|
+
error: null,
|
|
102
|
+
}),
|
|
103
|
+
resetPassword: vi.fn().mockResolvedValue({
|
|
104
|
+
data: null,
|
|
105
|
+
error: null,
|
|
106
|
+
}),
|
|
107
|
+
|
|
108
|
+
// Two-factor
|
|
109
|
+
twoFactor: {
|
|
110
|
+
verifyTotp: vi.fn().mockResolvedValue({
|
|
111
|
+
data: { user: null, session: null },
|
|
112
|
+
error: null,
|
|
113
|
+
}),
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
// Email verification
|
|
117
|
+
sendVerificationEmail: vi.fn().mockResolvedValue({
|
|
118
|
+
data: null,
|
|
119
|
+
error: null,
|
|
120
|
+
}),
|
|
121
|
+
|
|
122
|
+
// Apply overrides
|
|
123
|
+
...overrides,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return mockClient
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Create a mock user object
|
|
131
|
+
*/
|
|
132
|
+
export function createMockUser(overrides: Partial<MockUser> = {}): MockUser {
|
|
133
|
+
return {
|
|
134
|
+
id: "user_123",
|
|
135
|
+
email: "test@example.com",
|
|
136
|
+
name: "Test User",
|
|
137
|
+
image: null,
|
|
138
|
+
emailVerified: true,
|
|
139
|
+
...overrides,
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Create a mock session object
|
|
145
|
+
*/
|
|
146
|
+
export function createMockSession(
|
|
147
|
+
overrides: Partial<MockSession> = {}
|
|
148
|
+
): MockSession {
|
|
149
|
+
return {
|
|
150
|
+
token: "mock_token_123",
|
|
151
|
+
user: createMockUser(),
|
|
152
|
+
...overrides,
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Configure mock client to return successful sign-up response
|
|
158
|
+
*/
|
|
159
|
+
export function mockSignUpSuccess(
|
|
160
|
+
client: ReturnType<typeof createMockAuthClient>,
|
|
161
|
+
response: MockSignUpResponse = {}
|
|
162
|
+
) {
|
|
163
|
+
const user = response.user ?? createMockUser()
|
|
164
|
+
const session = response.session ?? createMockSession({ user })
|
|
165
|
+
|
|
166
|
+
client.signUp.email.mockResolvedValue({
|
|
167
|
+
data: {
|
|
168
|
+
user,
|
|
169
|
+
session,
|
|
170
|
+
token: response.token ?? session.token,
|
|
171
|
+
},
|
|
172
|
+
error: null,
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Configure mock client to return sign-up requiring verification
|
|
178
|
+
*/
|
|
179
|
+
export function mockSignUpRequiresVerification(
|
|
180
|
+
client: ReturnType<typeof createMockAuthClient>,
|
|
181
|
+
email = "test@example.com"
|
|
182
|
+
) {
|
|
183
|
+
client.signUp.email.mockResolvedValue({
|
|
184
|
+
data: {
|
|
185
|
+
user: createMockUser({ email, emailVerified: false }),
|
|
186
|
+
session: null,
|
|
187
|
+
token: null,
|
|
188
|
+
},
|
|
189
|
+
error: null,
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Configure mock client to return sign-up error
|
|
195
|
+
*/
|
|
196
|
+
export function mockSignUpError(
|
|
197
|
+
client: ReturnType<typeof createMockAuthClient>,
|
|
198
|
+
errorCode: string,
|
|
199
|
+
errorMessage: string
|
|
200
|
+
) {
|
|
201
|
+
client.signUp.email.mockRejectedValue({
|
|
202
|
+
error: {
|
|
203
|
+
code: errorCode,
|
|
204
|
+
message: errorMessage,
|
|
205
|
+
},
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Configure mock client to return successful sign-in
|
|
211
|
+
*/
|
|
212
|
+
export function mockSignInSuccess(
|
|
213
|
+
client: ReturnType<typeof createMockAuthClient>,
|
|
214
|
+
response: MockSignInResponse = {}
|
|
215
|
+
) {
|
|
216
|
+
const user = response.user ?? createMockUser()
|
|
217
|
+
const session = response.session ?? createMockSession({ user })
|
|
218
|
+
|
|
219
|
+
client.signIn.email.mockResolvedValue({
|
|
220
|
+
data: {
|
|
221
|
+
user,
|
|
222
|
+
session,
|
|
223
|
+
token: response.token ?? session.token,
|
|
224
|
+
},
|
|
225
|
+
error: null,
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Configure mock client to return sign-in requiring 2FA
|
|
231
|
+
*/
|
|
232
|
+
export function mockSignInRequires2FA(
|
|
233
|
+
client: ReturnType<typeof createMockAuthClient>
|
|
234
|
+
) {
|
|
235
|
+
client.signIn.email.mockResolvedValue({
|
|
236
|
+
data: {
|
|
237
|
+
twoFactorRedirect: true,
|
|
238
|
+
user: null,
|
|
239
|
+
session: null,
|
|
240
|
+
},
|
|
241
|
+
error: null,
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Configure mock client to return sign-in requiring email verification
|
|
247
|
+
*/
|
|
248
|
+
export function mockSignInRequiresVerification(
|
|
249
|
+
client: ReturnType<typeof createMockAuthClient>,
|
|
250
|
+
email = "test@example.com"
|
|
251
|
+
) {
|
|
252
|
+
client.signIn.email.mockResolvedValue({
|
|
253
|
+
data: {
|
|
254
|
+
user: createMockUser({ email, emailVerified: false }),
|
|
255
|
+
session: null,
|
|
256
|
+
token: null,
|
|
257
|
+
},
|
|
258
|
+
error: null,
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Configure mock client to return successful email verification
|
|
264
|
+
*/
|
|
265
|
+
export function mockEmailVerificationSuccess(
|
|
266
|
+
client: ReturnType<typeof createMockAuthClient>,
|
|
267
|
+
response: { session?: MockSession } = {}
|
|
268
|
+
) {
|
|
269
|
+
const session = response.session ?? createMockSession()
|
|
270
|
+
|
|
271
|
+
client.emailOtp.verifyEmail.mockResolvedValue({
|
|
272
|
+
data: {
|
|
273
|
+
user: session.user,
|
|
274
|
+
session,
|
|
275
|
+
token: session.token,
|
|
276
|
+
},
|
|
277
|
+
error: null,
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Configure mock client to return successful 2FA verification
|
|
283
|
+
*/
|
|
284
|
+
export function mock2FASuccess(
|
|
285
|
+
client: ReturnType<typeof createMockAuthClient>,
|
|
286
|
+
response: { session?: MockSession } = {}
|
|
287
|
+
) {
|
|
288
|
+
const session = response.session ?? createMockSession()
|
|
289
|
+
|
|
290
|
+
client.twoFactor.verifyTotp.mockResolvedValue({
|
|
291
|
+
data: {
|
|
292
|
+
user: session.user,
|
|
293
|
+
session,
|
|
294
|
+
token: session.token,
|
|
295
|
+
},
|
|
296
|
+
error: null,
|
|
297
|
+
})
|
|
298
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test render utilities
|
|
3
|
+
*
|
|
4
|
+
* Provides helpers for rendering components with the AuthUIProvider context.
|
|
5
|
+
*/
|
|
6
|
+
import React, { type ReactElement, type ReactNode } from "react"
|
|
7
|
+
import { render, type RenderOptions } from "@testing-library/react"
|
|
8
|
+
import { vi } from "vitest"
|
|
9
|
+
|
|
10
|
+
import { AuthUIProvider } from "../../ui/lib/auth-ui-provider"
|
|
11
|
+
import type { AuthFlowEvent } from "../../ui/types/auth-flow-events"
|
|
12
|
+
import { createMockAuthClient } from "./mock-auth-client"
|
|
13
|
+
|
|
14
|
+
export interface TestProviderOptions {
|
|
15
|
+
authClient?: ReturnType<typeof createMockAuthClient>
|
|
16
|
+
basePath?: string
|
|
17
|
+
authFlowMode?: "internal" | "route"
|
|
18
|
+
navigate?: (href: string) => void
|
|
19
|
+
onAuthEvent?: (event: AuthFlowEvent) => void
|
|
20
|
+
redirectTo?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Create a wrapper component with AuthUIProvider
|
|
25
|
+
*/
|
|
26
|
+
function createWrapper(options: TestProviderOptions = {}) {
|
|
27
|
+
const {
|
|
28
|
+
authClient = createMockAuthClient(),
|
|
29
|
+
basePath = "/auth",
|
|
30
|
+
authFlowMode,
|
|
31
|
+
navigate = vi.fn(),
|
|
32
|
+
onAuthEvent,
|
|
33
|
+
redirectTo = "/",
|
|
34
|
+
} = options
|
|
35
|
+
|
|
36
|
+
return function Wrapper({ children }: { children: ReactNode }) {
|
|
37
|
+
return (
|
|
38
|
+
<AuthUIProvider
|
|
39
|
+
// biome-ignore lint/suspicious/noExplicitAny: Test mock client
|
|
40
|
+
authClient={authClient as any}
|
|
41
|
+
basePath={basePath}
|
|
42
|
+
authFlowMode={authFlowMode}
|
|
43
|
+
navigate={navigate}
|
|
44
|
+
onAuthEvent={onAuthEvent}
|
|
45
|
+
redirectTo={redirectTo}
|
|
46
|
+
credentials={true}
|
|
47
|
+
signUp={true}
|
|
48
|
+
>
|
|
49
|
+
{children}
|
|
50
|
+
</AuthUIProvider>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Render a component with AuthUIProvider context
|
|
57
|
+
*/
|
|
58
|
+
export function renderWithProvider(
|
|
59
|
+
ui: ReactElement,
|
|
60
|
+
options: TestProviderOptions & { renderOptions?: Omit<RenderOptions, "wrapper"> } = {}
|
|
61
|
+
) {
|
|
62
|
+
const { renderOptions, ...providerOptions } = options
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
...render(ui, {
|
|
66
|
+
wrapper: createWrapper(providerOptions),
|
|
67
|
+
...renderOptions,
|
|
68
|
+
}),
|
|
69
|
+
// Return the mocked functions for assertions
|
|
70
|
+
authClient: providerOptions.authClient ?? createMockAuthClient(),
|
|
71
|
+
navigate: providerOptions.navigate ?? vi.fn(),
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Event collector for testing event emissions
|
|
77
|
+
*/
|
|
78
|
+
export function createEventCollector() {
|
|
79
|
+
const events: AuthFlowEvent[] = []
|
|
80
|
+
|
|
81
|
+
const onEvent = (event: AuthFlowEvent) => {
|
|
82
|
+
events.push(event)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const getEvents = () => [...events]
|
|
86
|
+
|
|
87
|
+
const getEventsByType = (type: AuthFlowEvent["type"]) =>
|
|
88
|
+
events.filter((e) => e.type === type)
|
|
89
|
+
|
|
90
|
+
const hasEvent = (type: AuthFlowEvent["type"]) =>
|
|
91
|
+
events.some((e) => e.type === type)
|
|
92
|
+
|
|
93
|
+
const clear = () => {
|
|
94
|
+
events.length = 0
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const lastEvent = () => events[events.length - 1]
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
onEvent,
|
|
101
|
+
getEvents,
|
|
102
|
+
getEventsByType,
|
|
103
|
+
hasEvent,
|
|
104
|
+
clear,
|
|
105
|
+
lastEvent,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Helper to wait for async state updates
|
|
111
|
+
*/
|
|
112
|
+
export async function waitForStateUpdate() {
|
|
113
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Helper to fill form fields by label
|
|
118
|
+
*/
|
|
119
|
+
export async function fillFormField(
|
|
120
|
+
getByLabelText: (text: string | RegExp) => HTMLElement,
|
|
121
|
+
label: string | RegExp,
|
|
122
|
+
value: string
|
|
123
|
+
) {
|
|
124
|
+
const input = getByLabelText(label) as HTMLInputElement
|
|
125
|
+
input.focus()
|
|
126
|
+
input.value = value
|
|
127
|
+
input.dispatchEvent(new Event("input", { bubbles: true }))
|
|
128
|
+
input.dispatchEvent(new Event("change", { bubbles: true }))
|
|
129
|
+
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
type ReactNode,
|
|
5
5
|
type MouseEvent,
|
|
6
6
|
useCallback,
|
|
7
|
+
useContext,
|
|
7
8
|
useMemo,
|
|
8
9
|
useState
|
|
9
10
|
} from "react"
|
|
@@ -96,35 +97,56 @@ const flowViewToPathKey: Record<AuthFlowView, keyof AuthViewPaths> = {
|
|
|
96
97
|
* Parse a path to determine the view
|
|
97
98
|
*/
|
|
98
99
|
function parsePathToView(path: string): { view: AuthFlowView; email?: string } {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
try {
|
|
101
|
+
const url = new URL(path, "http://localhost")
|
|
102
|
+
const pathname = url.pathname
|
|
103
|
+
const email = url.searchParams.get("email") || undefined
|
|
102
104
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
105
|
+
// Use path segment matching to avoid false positives like "/my-sign-in-page"
|
|
106
|
+
const segments = pathname.split("/").filter(Boolean)
|
|
107
|
+
const lastSegment = segments[segments.length - 1] || ""
|
|
108
|
+
|
|
109
|
+
if (
|
|
110
|
+
lastSegment === "email-verification" ||
|
|
111
|
+
pathname.includes("/email-verification")
|
|
112
|
+
) {
|
|
113
|
+
return { view: "EMAIL_VERIFICATION", email }
|
|
114
|
+
}
|
|
115
|
+
if (lastSegment === "two-factor" || pathname.includes("/two-factor")) {
|
|
116
|
+
return { view: "TWO_FACTOR", email }
|
|
117
|
+
}
|
|
118
|
+
if (
|
|
119
|
+
lastSegment === "forgot-password" ||
|
|
120
|
+
pathname.includes("/forgot-password")
|
|
121
|
+
) {
|
|
122
|
+
return { view: "FORGOT_PASSWORD", email }
|
|
123
|
+
}
|
|
124
|
+
if (
|
|
125
|
+
lastSegment === "reset-password" ||
|
|
126
|
+
pathname.includes("/reset-password")
|
|
127
|
+
) {
|
|
128
|
+
return { view: "RESET_PASSWORD", email }
|
|
129
|
+
}
|
|
130
|
+
if (lastSegment === "sign-up" || pathname.includes("/sign-up")) {
|
|
131
|
+
return { view: "SIGN_UP", email }
|
|
132
|
+
}
|
|
133
|
+
if (lastSegment === "magic-link" || pathname.includes("/magic-link")) {
|
|
134
|
+
return { view: "MAGIC_LINK", email }
|
|
135
|
+
}
|
|
136
|
+
if (lastSegment === "email-otp" || pathname.includes("/email-otp")) {
|
|
137
|
+
return { view: "EMAIL_OTP", email }
|
|
138
|
+
}
|
|
139
|
+
if (
|
|
140
|
+
lastSegment === "recover-account" ||
|
|
141
|
+
pathname.includes("/recover-account")
|
|
142
|
+
) {
|
|
143
|
+
return { view: "RECOVER_ACCOUNT", email }
|
|
144
|
+
}
|
|
145
|
+
return { view: "SIGN_IN", email }
|
|
146
|
+
} catch {
|
|
147
|
+
// Return default view if URL parsing fails
|
|
148
|
+
return { view: "SIGN_IN" }
|
|
126
149
|
}
|
|
127
|
-
return { view: "SIGN_IN", email }
|
|
128
150
|
}
|
|
129
151
|
|
|
130
152
|
/**
|
|
@@ -162,19 +184,37 @@ function parsePathToView(path: string): { view: AuthFlowView; email?: string } {
|
|
|
162
184
|
/**
|
|
163
185
|
* Check if a path is an auth-related path that should be intercepted
|
|
164
186
|
* These match the viewPaths defined in view-paths.ts
|
|
187
|
+
* Uses path segment matching to avoid false positives like "/my-sign-in-page"
|
|
165
188
|
*/
|
|
166
189
|
function isAuthPath(href: string): boolean {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
190
|
+
// Auth path segments that should be intercepted
|
|
191
|
+
const authSegments = [
|
|
192
|
+
"sign-in",
|
|
193
|
+
"sign-up",
|
|
194
|
+
"email-verification",
|
|
195
|
+
"two-factor",
|
|
196
|
+
"forgot-password",
|
|
197
|
+
"reset-password",
|
|
198
|
+
"magic-link",
|
|
199
|
+
"email-otp",
|
|
200
|
+
"recover-account"
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const url = new URL(href, "http://localhost")
|
|
205
|
+
const segments = url.pathname.split("/").filter(Boolean)
|
|
206
|
+
|
|
207
|
+
// Check if any segment exactly matches an auth path
|
|
208
|
+
return segments.some((segment) => authSegments.includes(segment))
|
|
209
|
+
} catch {
|
|
210
|
+
// Fallback for invalid URLs - check with path boundary
|
|
211
|
+
return authSegments.some(
|
|
212
|
+
(authPath) =>
|
|
213
|
+
href.includes(`/${authPath}`) ||
|
|
214
|
+
href.endsWith(authPath) ||
|
|
215
|
+
href === authPath
|
|
216
|
+
)
|
|
217
|
+
}
|
|
178
218
|
}
|
|
179
219
|
|
|
180
220
|
// Session storage key for persisting view state across remounts
|
|
@@ -190,8 +230,8 @@ function getPersistedState(): {
|
|
|
190
230
|
if (stored) {
|
|
191
231
|
return JSON.parse(stored)
|
|
192
232
|
}
|
|
193
|
-
} catch {
|
|
194
|
-
|
|
233
|
+
} catch (error) {
|
|
234
|
+
console.warn("[AuthFlow] Failed to read persisted state:", error)
|
|
195
235
|
}
|
|
196
236
|
return null
|
|
197
237
|
}
|
|
@@ -203,8 +243,8 @@ function persistState(view: AuthFlowView, email?: string) {
|
|
|
203
243
|
AUTH_FLOW_STATE_KEY,
|
|
204
244
|
JSON.stringify({ view, email })
|
|
205
245
|
)
|
|
206
|
-
} catch {
|
|
207
|
-
|
|
246
|
+
} catch (error) {
|
|
247
|
+
console.warn("[AuthFlow] Failed to persist state:", error)
|
|
208
248
|
}
|
|
209
249
|
}
|
|
210
250
|
|
|
@@ -212,8 +252,8 @@ function clearPersistedState() {
|
|
|
212
252
|
if (typeof window === "undefined") return
|
|
213
253
|
try {
|
|
214
254
|
sessionStorage.removeItem(AUTH_FLOW_STATE_KEY)
|
|
215
|
-
} catch {
|
|
216
|
-
|
|
255
|
+
} catch (error) {
|
|
256
|
+
console.warn("[AuthFlow] Failed to clear persisted state:", error)
|
|
217
257
|
}
|
|
218
258
|
}
|
|
219
259
|
|
|
@@ -365,7 +405,7 @@ export function AuthFlow({
|
|
|
365
405
|
// Sign-in flow
|
|
366
406
|
case "SIGN_IN_REQUIRES_2FA":
|
|
367
407
|
setCurrentView("TWO_FACTOR")
|
|
368
|
-
persistState("TWO_FACTOR")
|
|
408
|
+
persistState("TWO_FACTOR", event.email)
|
|
369
409
|
break
|
|
370
410
|
case "SIGN_IN_REQUIRES_VERIFICATION":
|
|
371
411
|
setCurrentView("EMAIL_VERIFICATION")
|
|
@@ -450,11 +490,14 @@ export function AuthFlow({
|
|
|
450
490
|
/**
|
|
451
491
|
* Hook to emit auth events from within the auth flow.
|
|
452
492
|
* Can be used by custom components within AuthFlow.
|
|
493
|
+
*
|
|
494
|
+
* @returns The onAuthEvent function from context, or undefined if not in provider
|
|
495
|
+
*
|
|
496
|
+
* @example
|
|
497
|
+
* const emitEvent = useAuthFlowEvent()
|
|
498
|
+
* emitEvent?.({ type: 'AUTH_SUCCESS', user, session })
|
|
453
499
|
*/
|
|
454
500
|
export function useAuthFlowEvent() {
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
// Usage: const emitEvent = useAuthFlowEvent()
|
|
458
|
-
// emitEvent({ type: 'CUSTOM_EVENT', ... })
|
|
459
|
-
return context
|
|
501
|
+
const { onAuthEvent } = useContext(AuthUIContext)
|
|
502
|
+
return onAuthEvent
|
|
460
503
|
}
|
|
@@ -101,6 +101,9 @@ export function AuthForm({
|
|
|
101
101
|
localization = { ...contextLocalization, ...localization }
|
|
102
102
|
|
|
103
103
|
useEffect(() => {
|
|
104
|
+
// SSR guard and viewPaths null check
|
|
105
|
+
if (typeof window === "undefined" || !viewPaths) return
|
|
106
|
+
|
|
104
107
|
if (pathname && !getViewByPath(viewPaths, pathname)) {
|
|
105
108
|
console.error(`Invalid auth view: ${pathname}`)
|
|
106
109
|
replace(`${basePath}/${viewPaths.SIGN_IN}${window.location.search}`)
|
|
@@ -118,6 +118,9 @@ export function AuthView({
|
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
useEffect(() => {
|
|
121
|
+
// SSR guard - window not available on server
|
|
122
|
+
if (typeof window === "undefined") return
|
|
123
|
+
|
|
121
124
|
const handlePageHide = () => setIsSubmitting(false)
|
|
122
125
|
window.addEventListener("pagehide", handlePageHide)
|
|
123
126
|
return () => {
|