@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.
@@ -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
- const url = new URL(path, "http://localhost")
100
- const pathname = url.pathname
101
- const email = url.searchParams.get("email") || undefined
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
- if (pathname.includes("email-verification")) {
104
- return { view: "EMAIL_VERIFICATION", email }
105
- }
106
- if (pathname.includes("two-factor")) {
107
- return { view: "TWO_FACTOR", email }
108
- }
109
- if (pathname.includes("forgot-password")) {
110
- return { view: "FORGOT_PASSWORD", email }
111
- }
112
- if (pathname.includes("reset-password")) {
113
- return { view: "RESET_PASSWORD", email }
114
- }
115
- if (pathname.includes("sign-up")) {
116
- return { view: "SIGN_UP", email }
117
- }
118
- if (pathname.includes("magic-link")) {
119
- return { view: "MAGIC_LINK", email }
120
- }
121
- if (pathname.includes("email-otp")) {
122
- return { view: "SIGN_IN", email } // EMAIL_OTP is a sign-in variant
123
- }
124
- if (pathname.includes("recover-account")) {
125
- return { view: "TWO_FACTOR", email } // Recover account is part of 2FA flow
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
- return (
168
- href.includes("sign-in") ||
169
- href.includes("sign-up") ||
170
- href.includes("email-verification") ||
171
- href.includes("two-factor") ||
172
- href.includes("forgot-password") ||
173
- href.includes("reset-password") ||
174
- href.includes("magic-link") ||
175
- href.includes("email-otp") ||
176
- href.includes("recover-account")
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
- // Ignore parse errors
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
- // Ignore storage errors
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
- // Ignore storage errors
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 context = AuthUIContext
456
- // This hook allows custom components to emit events
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 () => {