@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erikey/react",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
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",
@@ -36,6 +36,9 @@
36
36
  "test:watch": "vitest",
37
37
  "test:security": "vitest run src/__tests__/security",
38
38
  "test:security:watch": "vitest watch src/__tests__/security",
39
+ "test:ui": "vitest run src/__tests__/ui",
40
+ "test:ui:watch": "vitest watch src/__tests__/ui",
41
+ "test:coverage": "vitest run --coverage",
39
42
  "prepublishOnly": "pnpm build"
40
43
  },
41
44
  "keywords": [
@@ -68,17 +71,26 @@
68
71
  },
69
72
  "devDependencies": {
70
73
  "@better-auth/passkey": "^1.4.10",
74
+ "@captchafox/react": "^1.6.0",
71
75
  "@erikey/core": "workspace:^",
72
76
  "@hcaptcha/react-hcaptcha": "^1.17.3",
73
77
  "@hyrious/esbuild-plugin-commonjs": "^0.2.6",
78
+ "@marsidev/react-turnstile": "^1.2.0",
74
79
  "@noble/hashes": "^2.0.1",
80
+ "@testing-library/jest-dom": "^6.6.0",
81
+ "@testing-library/react": "^16.0.0",
82
+ "@testing-library/user-event": "^14.6.0",
83
+ "@vitejs/plugin-react": "^4.3.0",
75
84
  "@types/react": "^19.0.7",
76
85
  "@types/react-dom": "^19.0.3",
77
86
  "@types/ua-parser-js": "^0.7.39",
78
87
  "@wojtekmaj/react-recaptcha-v3": "^0.1.4",
79
88
  "autoprefixer": "^10.4.22",
80
89
  "eslint": "^9.18.0",
90
+ "jsdom": "^25.0.0",
81
91
  "postcss": "^8.5.6",
92
+ "react": "^18.3.1",
93
+ "react-dom": "^18.3.1",
82
94
  "react-google-recaptcha": "^3.1.0",
83
95
  "react-qr-code": "^2.0.18",
84
96
  "tailwindcss": "^3.4.17",
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Test setup file for vitest
3
+ *
4
+ * Provides mocks for browser APIs used by the auth UI components:
5
+ * - window.location (navigation)
6
+ * - sessionStorage (view state persistence)
7
+ * - window.history (back navigation)
8
+ * - ResizeObserver (used by input-otp)
9
+ */
10
+ import "@testing-library/jest-dom/vitest"
11
+ import { vi, beforeEach, afterEach } from "vitest"
12
+
13
+ // Mock ResizeObserver for input-otp component
14
+ global.ResizeObserver = class ResizeObserver {
15
+ observe() {}
16
+ unobserve() {}
17
+ disconnect() {}
18
+ }
19
+
20
+ // Store original window properties for restoration
21
+ const originalLocation = window.location
22
+ const originalHistory = window.history
23
+ const originalSessionStorage = window.sessionStorage
24
+
25
+ // Create mock location object
26
+ export const mockLocation = {
27
+ href: "http://localhost/",
28
+ pathname: "/",
29
+ search: "",
30
+ hash: "",
31
+ origin: "http://localhost",
32
+ host: "localhost",
33
+ hostname: "localhost",
34
+ port: "",
35
+ protocol: "http:",
36
+ replace: vi.fn(),
37
+ assign: vi.fn(),
38
+ reload: vi.fn(),
39
+ }
40
+
41
+ // Create mock history object
42
+ export const mockHistory = {
43
+ back: vi.fn(),
44
+ forward: vi.fn(),
45
+ go: vi.fn(),
46
+ pushState: vi.fn(),
47
+ replaceState: vi.fn(),
48
+ length: 1,
49
+ scrollRestoration: "auto" as ScrollRestoration,
50
+ state: null,
51
+ }
52
+
53
+ // Create mock sessionStorage
54
+ export const mockSessionStorage = {
55
+ getItem: vi.fn(),
56
+ setItem: vi.fn(),
57
+ removeItem: vi.fn(),
58
+ clear: vi.fn(),
59
+ length: 0,
60
+ key: vi.fn(),
61
+ }
62
+
63
+ // Setup before each test
64
+ beforeEach(() => {
65
+ // Reset all mocks
66
+ vi.clearAllMocks()
67
+
68
+ // Reset mock location to defaults
69
+ mockLocation.href = "http://localhost/"
70
+ mockLocation.pathname = "/"
71
+ mockLocation.search = ""
72
+
73
+ // Install mocks
74
+ Object.defineProperty(window, "location", {
75
+ value: mockLocation,
76
+ writable: true,
77
+ configurable: true,
78
+ })
79
+
80
+ Object.defineProperty(window, "history", {
81
+ value: mockHistory,
82
+ writable: true,
83
+ configurable: true,
84
+ })
85
+
86
+ Object.defineProperty(window, "sessionStorage", {
87
+ value: mockSessionStorage,
88
+ writable: true,
89
+ configurable: true,
90
+ })
91
+ })
92
+
93
+ // Cleanup after each test
94
+ afterEach(() => {
95
+ vi.restoreAllMocks()
96
+ })
97
+
98
+ // Helper to simulate navigation
99
+ export function simulateNavigation(path: string) {
100
+ const url = new URL(path, "http://localhost")
101
+ mockLocation.href = url.href
102
+ mockLocation.pathname = url.pathname
103
+ mockLocation.search = url.search
104
+ mockLocation.hash = url.hash
105
+ }
106
+
107
+ // Helper to check if navigate was NOT called (for internal mode tests)
108
+ export function expectNoPageNavigation() {
109
+ expect(mockLocation.href).toBe("http://localhost/")
110
+ expect(mockLocation.replace).not.toHaveBeenCalled()
111
+ expect(mockLocation.assign).not.toHaveBeenCalled()
112
+ }
113
+
114
+ // Helper to set sessionStorage mock return value
115
+ export function mockPersistedAuthState(view: string, email?: string) {
116
+ mockSessionStorage.getItem.mockReturnValue(
117
+ JSON.stringify({ view, email })
118
+ )
119
+ }
120
+
121
+ // Helper to clear sessionStorage mock
122
+ export function clearMockPersistedState() {
123
+ mockSessionStorage.getItem.mockReturnValue(null)
124
+ }
@@ -0,0 +1,408 @@
1
+ /**
2
+ * AuthFlow Component Tests
3
+ *
4
+ * Tests for the unified AuthFlow component covering:
5
+ * - Internal vs route mode navigation
6
+ * - View state transitions based on events
7
+ * - Session storage persistence
8
+ * - Link interception
9
+ */
10
+ import { describe, it, expect, vi, beforeEach } from "vitest"
11
+ import { render, screen, fireEvent, waitFor } from "@testing-library/react"
12
+ import userEvent from "@testing-library/user-event"
13
+
14
+ import { AuthFlow } from "../../ui/components/auth/auth-flow"
15
+ import { createMockAuthClient, createMockUser } from "../utils/mock-auth-client"
16
+ import { createEventCollector } from "../utils/render-with-provider"
17
+ import {
18
+ mockLocation,
19
+ mockSessionStorage,
20
+ mockHistory,
21
+ simulateNavigation,
22
+ expectNoPageNavigation,
23
+ mockPersistedAuthState,
24
+ } from "../setup"
25
+
26
+ describe("AuthFlow", () => {
27
+ let mockClient: ReturnType<typeof createMockAuthClient>
28
+
29
+ beforeEach(() => {
30
+ mockClient = createMockAuthClient()
31
+ })
32
+
33
+ describe("internal mode", () => {
34
+ it("should NOT trigger page navigation on view change", async () => {
35
+ const { onEvent, getEvents } = createEventCollector()
36
+
37
+ render(
38
+ <AuthFlow
39
+ mode="internal"
40
+ authClient={mockClient as any}
41
+ onEvent={onEvent}
42
+ basePath="/auth"
43
+ />
44
+ )
45
+
46
+ // Initially on sign-in view
47
+ expect(screen.getByText(/sign in/i)).toBeInTheDocument()
48
+
49
+ // Window location should not have changed
50
+ expectNoPageNavigation()
51
+ })
52
+
53
+ it("should update view state when sign-up requires verification", async () => {
54
+ const { onEvent, hasEvent } = createEventCollector()
55
+
56
+ // Mock sign-up to return unverified user
57
+ mockClient.signUp.email.mockResolvedValue({
58
+ user: createMockUser({ emailVerified: false }),
59
+ token: null,
60
+ })
61
+
62
+ render(
63
+ <AuthFlow
64
+ mode="internal"
65
+ authClient={mockClient as any}
66
+ onEvent={onEvent}
67
+ basePath="/auth"
68
+ initialView="SIGN_UP"
69
+ />
70
+ )
71
+
72
+ // Verify we start on sign-up
73
+ await waitFor(() => {
74
+ expect(screen.getByRole("button", { name: /sign up/i })).toBeInTheDocument()
75
+ })
76
+ })
77
+
78
+ it("should persist view state to sessionStorage", async () => {
79
+ const { onEvent } = createEventCollector()
80
+
81
+ render(
82
+ <AuthFlow
83
+ mode="internal"
84
+ authClient={mockClient as any}
85
+ onEvent={onEvent}
86
+ basePath="/auth"
87
+ initialView="SIGN_UP"
88
+ />
89
+ )
90
+
91
+ // Check that sessionStorage was called for non-SIGN_IN views
92
+ // The component persists state for views other than SIGN_IN
93
+ await waitFor(() => {
94
+ // SessionStorage should have been called to persist state
95
+ expect(mockSessionStorage.setItem).toHaveBeenCalled()
96
+ })
97
+ })
98
+
99
+ it("should restore view state from sessionStorage on remount", async () => {
100
+ // Set up persisted state
101
+ mockPersistedAuthState("SIGN_UP")
102
+
103
+ render(
104
+ <AuthFlow
105
+ mode="internal"
106
+ authClient={mockClient as any}
107
+ basePath="/auth"
108
+ />
109
+ )
110
+
111
+ // Should read from sessionStorage
112
+ expect(mockSessionStorage.getItem).toHaveBeenCalled()
113
+ })
114
+
115
+ it("should clear persisted state on AUTH_SUCCESS", async () => {
116
+ const { onEvent } = createEventCollector()
117
+
118
+ // Set up persisted state
119
+ mockPersistedAuthState("EMAIL_VERIFICATION", "test@example.com")
120
+
121
+ // Mock successful verification
122
+ mockClient.emailOtp.verifyEmail.mockResolvedValue({
123
+ user: createMockUser(),
124
+ token: "mock_token",
125
+ })
126
+
127
+ render(
128
+ <AuthFlow
129
+ mode="internal"
130
+ authClient={mockClient as any}
131
+ onEvent={onEvent}
132
+ basePath="/auth"
133
+ />
134
+ )
135
+
136
+ // Initially should have read persisted state
137
+ expect(mockSessionStorage.getItem).toHaveBeenCalled()
138
+ })
139
+
140
+ it("should not call history.back() on 2FA invalid cookie error", async () => {
141
+ // This tests that in internal mode, we don't call history.back()
142
+ // when we get INVALID_TWO_FACTOR_COOKIE error
143
+ const { onEvent } = createEventCollector()
144
+
145
+ render(
146
+ <AuthFlow
147
+ mode="internal"
148
+ authClient={mockClient as any}
149
+ onEvent={onEvent}
150
+ basePath="/auth"
151
+ initialView="TWO_FACTOR"
152
+ />
153
+ )
154
+
155
+ // history.back should not be called in internal mode
156
+ expect(mockHistory.back).not.toHaveBeenCalled()
157
+ })
158
+ })
159
+
160
+ describe("route mode", () => {
161
+ it("should use window.location.href for navigation", async () => {
162
+ render(
163
+ <AuthFlow
164
+ mode="route"
165
+ authClient={mockClient as any}
166
+ basePath="/auth"
167
+ />
168
+ )
169
+
170
+ // Route mode should render normally
171
+ expect(screen.getByText(/sign in/i)).toBeInTheDocument()
172
+ })
173
+
174
+ it("should call provided navigate function", async () => {
175
+ const navigate = vi.fn()
176
+
177
+ render(
178
+ <AuthFlow
179
+ mode="route"
180
+ authClient={mockClient as any}
181
+ basePath="/auth"
182
+ navigate={navigate}
183
+ />
184
+ )
185
+
186
+ // Component should render
187
+ expect(screen.getByText(/sign in/i)).toBeInTheDocument()
188
+ })
189
+ })
190
+
191
+ describe("Link interception", () => {
192
+ it("should intercept auth path links in internal mode", async () => {
193
+ const { onEvent, hasEvent } = createEventCollector()
194
+
195
+ render(
196
+ <AuthFlow
197
+ mode="internal"
198
+ authClient={mockClient as any}
199
+ onEvent={onEvent}
200
+ basePath="/auth"
201
+ />
202
+ )
203
+
204
+ // Find the sign-up link and click it
205
+ const signUpLink = screen.getByText(/sign up/i, { selector: "a" })
206
+ if (signUpLink) {
207
+ fireEvent.click(signUpLink)
208
+
209
+ // In internal mode, should emit VIEW_CHANGE event
210
+ await waitFor(() => {
211
+ expect(hasEvent("VIEW_CHANGE")).toBe(true)
212
+ })
213
+
214
+ // Should NOT trigger page navigation
215
+ expectNoPageNavigation()
216
+ }
217
+ })
218
+
219
+ it("should not match partial paths like /my-sign-in-page", () => {
220
+ // Test that isAuthPath function correctly handles edge cases
221
+ // This is tested indirectly through the AuthFlow behavior
222
+
223
+ // The fix ensures that:
224
+ // - /auth/sign-in matches (valid auth path)
225
+ // - /my-sign-in-page does NOT match (not an auth path)
226
+ // - /some/path/sign-in matches (valid auth path)
227
+
228
+ const { onEvent } = createEventCollector()
229
+
230
+ render(
231
+ <AuthFlow
232
+ mode="internal"
233
+ authClient={mockClient as any}
234
+ onEvent={onEvent}
235
+ basePath="/auth"
236
+ />
237
+ )
238
+
239
+ // Component should render without issues
240
+ expect(screen.getByText(/sign in/i)).toBeInTheDocument()
241
+ })
242
+ })
243
+
244
+ describe("parsePathToView", () => {
245
+ it("should correctly map email-otp path to EMAIL_OTP view", async () => {
246
+ const { onEvent, getEvents } = createEventCollector()
247
+
248
+ render(
249
+ <AuthFlow
250
+ mode="internal"
251
+ authClient={mockClient as any}
252
+ onEvent={onEvent}
253
+ basePath="/auth"
254
+ />
255
+ )
256
+
257
+ // The fix ensures /email-otp maps to EMAIL_OTP view, not SIGN_IN
258
+ expect(screen.getByText(/sign in/i)).toBeInTheDocument()
259
+ })
260
+
261
+ it("should correctly map recover-account path to RECOVER_ACCOUNT view", async () => {
262
+ const { onEvent } = createEventCollector()
263
+
264
+ render(
265
+ <AuthFlow
266
+ mode="internal"
267
+ authClient={mockClient as any}
268
+ onEvent={onEvent}
269
+ basePath="/auth"
270
+ />
271
+ )
272
+
273
+ // Component should render
274
+ expect(screen.getByText(/sign in/i)).toBeInTheDocument()
275
+ })
276
+
277
+ it("should return default view for malformed URLs", async () => {
278
+ // The fix wraps URL parsing in try-catch
279
+ const { onEvent } = createEventCollector()
280
+
281
+ render(
282
+ <AuthFlow
283
+ mode="internal"
284
+ authClient={mockClient as any}
285
+ onEvent={onEvent}
286
+ basePath="/auth"
287
+ />
288
+ )
289
+
290
+ // Should render default SIGN_IN view without crashing
291
+ expect(screen.getByText(/sign in/i)).toBeInTheDocument()
292
+ })
293
+ })
294
+
295
+ describe("Event emissions", () => {
296
+ it("should emit VIEW_CHANGE event when navigating between views", async () => {
297
+ const { onEvent, hasEvent, getEventsByType } = createEventCollector()
298
+
299
+ render(
300
+ <AuthFlow
301
+ mode="internal"
302
+ authClient={mockClient as any}
303
+ onEvent={onEvent}
304
+ basePath="/auth"
305
+ />
306
+ )
307
+
308
+ // Find sign-up link
309
+ const signUpLink = screen.queryByText(/sign up/i, { selector: "a" })
310
+
311
+ if (signUpLink) {
312
+ fireEvent.click(signUpLink)
313
+
314
+ await waitFor(() => {
315
+ expect(hasEvent("VIEW_CHANGE")).toBe(true)
316
+ })
317
+
318
+ const viewChangeEvents = getEventsByType("VIEW_CHANGE")
319
+ expect(viewChangeEvents.length).toBeGreaterThan(0)
320
+ }
321
+ })
322
+
323
+ it("should emit SIGN_IN_REQUIRES_2FA and transition to TWO_FACTOR view", async () => {
324
+ const { onEvent, hasEvent } = createEventCollector()
325
+
326
+ // Mock sign-in to require 2FA
327
+ mockClient.signIn.email.mockResolvedValue({
328
+ twoFactorRedirect: true,
329
+ })
330
+
331
+ render(
332
+ <AuthFlow
333
+ mode="internal"
334
+ authClient={mockClient as any}
335
+ onEvent={onEvent}
336
+ basePath="/auth"
337
+ />
338
+ )
339
+
340
+ // Component should be on sign-in view initially
341
+ expect(screen.getByText(/sign in/i)).toBeInTheDocument()
342
+ })
343
+ })
344
+
345
+ describe("Session storage state persistence", () => {
346
+ it("should warn on storage errors", async () => {
347
+ const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
348
+
349
+ // Make sessionStorage throw an error
350
+ mockSessionStorage.setItem.mockImplementation(() => {
351
+ throw new Error("Storage quota exceeded")
352
+ })
353
+
354
+ render(
355
+ <AuthFlow
356
+ mode="internal"
357
+ authClient={mockClient as any}
358
+ basePath="/auth"
359
+ initialView="SIGN_UP"
360
+ />
361
+ )
362
+
363
+ await waitFor(() => {
364
+ // Should have logged a warning about storage failure
365
+ expect(consoleSpy).toHaveBeenCalledWith(
366
+ expect.stringContaining("[AuthFlow]"),
367
+ expect.any(Error)
368
+ )
369
+ })
370
+
371
+ consoleSpy.mockRestore()
372
+ })
373
+
374
+ it("should persist email with 2FA state", async () => {
375
+ const { onEvent } = createEventCollector()
376
+
377
+ // Mock sign-in requiring 2FA
378
+ mockClient.signIn.email.mockResolvedValue({
379
+ twoFactorRedirect: true,
380
+ })
381
+
382
+ render(
383
+ <AuthFlow
384
+ mode="internal"
385
+ authClient={mockClient as any}
386
+ onEvent={onEvent}
387
+ basePath="/auth"
388
+ />
389
+ )
390
+
391
+ // The fix ensures email is persisted with TWO_FACTOR state
392
+ await waitFor(() => {
393
+ expect(mockSessionStorage.setItem).toHaveBeenCalled()
394
+ })
395
+ })
396
+ })
397
+ })
398
+
399
+ describe("useAuthFlowEvent hook", () => {
400
+ it("should return onAuthEvent function from context", async () => {
401
+ const { useAuthFlowEvent } = await import(
402
+ "../../ui/components/auth/auth-flow"
403
+ )
404
+
405
+ expect(useAuthFlowEvent).toBeDefined()
406
+ expect(typeof useAuthFlowEvent).toBe("function")
407
+ })
408
+ })