@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@erikey/react",
|
|
3
|
-
"version": "0.
|
|
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
|
+
})
|