@workos-inc/authkit-nextjs 3.0.0-beta.1 → 3.0.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/README.md +276 -102
- package/dist/esm/actions.js +35 -4
- package/dist/esm/actions.js.map +1 -1
- package/dist/esm/auth.js +51 -20
- package/dist/esm/auth.js.map +1 -1
- package/dist/esm/authkit-callback-route.js +82 -93
- package/dist/esm/authkit-callback-route.js.map +1 -1
- package/dist/esm/components/authkit-provider.js +36 -15
- package/dist/esm/components/authkit-provider.js.map +1 -1
- package/dist/esm/components/impersonation.js +17 -15
- package/dist/esm/components/impersonation.js.map +1 -1
- package/dist/esm/components/min-max-button.js +1 -1
- package/dist/esm/components/min-max-button.js.map +1 -1
- package/dist/esm/components/tokenStore.js +28 -19
- package/dist/esm/components/tokenStore.js.map +1 -1
- package/dist/esm/components/useAccessToken.js +1 -1
- package/dist/esm/components/useAccessToken.js.map +1 -1
- package/dist/esm/components/useTokenClaims.js +1 -1
- package/dist/esm/components/useTokenClaims.js.map +1 -1
- package/dist/esm/cookie.js +16 -5
- package/dist/esm/cookie.js.map +1 -1
- package/dist/esm/env-variables.js +6 -6
- package/dist/esm/env-variables.js.map +1 -1
- package/dist/esm/errors.js +36 -0
- package/dist/esm/errors.js.map +1 -0
- package/dist/esm/get-authorization-url.js +51 -12
- package/dist/esm/get-authorization-url.js.map +1 -1
- package/dist/esm/index.js +5 -2
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/interfaces.js +7 -1
- package/dist/esm/interfaces.js.map +1 -1
- package/dist/esm/middleware-helpers.js +102 -0
- package/dist/esm/middleware-helpers.js.map +1 -0
- package/dist/esm/middleware.js +3 -1
- package/dist/esm/middleware.js.map +1 -1
- package/dist/esm/pkce.js +38 -0
- package/dist/esm/pkce.js.map +1 -0
- package/dist/esm/session.js +73 -35
- package/dist/esm/session.js.map +1 -1
- package/dist/esm/test-helpers.js +1 -1
- package/dist/esm/test-helpers.js.map +1 -1
- package/dist/esm/types/actions.d.ts +34 -5
- package/dist/esm/types/auth.d.ts +7 -15
- package/dist/esm/types/components/authkit-provider.d.ts +6 -2
- package/dist/esm/types/components/impersonation.d.ts +2 -1
- package/dist/esm/types/cookie.d.ts +8 -0
- package/dist/esm/types/env-variables.d.ts +2 -1
- package/dist/esm/types/errors.d.ts +15 -0
- package/dist/esm/types/get-authorization-url.d.ts +2 -2
- package/dist/esm/types/index.d.ts +5 -2
- package/dist/esm/types/interfaces.d.ts +12 -0
- package/dist/esm/types/jwt.d.ts +9 -9
- package/dist/esm/types/middleware-helpers.d.ts +27 -0
- package/dist/esm/types/middleware.d.ts +3 -1
- package/dist/esm/types/pkce.d.ts +12 -0
- package/dist/esm/types/session.d.ts +1 -1
- package/dist/esm/types/utils.d.ts +5 -0
- package/dist/esm/types/validate-api-key.d.ts +1 -0
- package/dist/esm/types/workos.d.ts +1 -1
- package/dist/esm/utils.js +10 -2
- package/dist/esm/utils.js.map +1 -1
- package/dist/esm/validate-api-key.js +16 -0
- package/dist/esm/validate-api-key.js.map +1 -0
- package/dist/esm/workos.js +1 -1
- package/package.json +32 -34
- package/src/actions.spec.ts +94 -17
- package/src/actions.ts +44 -5
- package/src/auth.spec.ts +60 -29
- package/src/auth.ts +55 -41
- package/src/authkit-callback-route.spec.ts +310 -58
- package/src/authkit-callback-route.ts +106 -103
- package/src/components/authkit-provider.spec.tsx +264 -70
- package/src/components/authkit-provider.tsx +40 -15
- package/src/components/button.spec.tsx +4 -6
- package/src/components/impersonation.spec.tsx +152 -35
- package/src/components/impersonation.tsx +37 -30
- package/src/components/min-max-button.spec.tsx +2 -1
- package/src/components/tokenStore.spec.ts +59 -44
- package/src/components/tokenStore.ts +11 -3
- package/src/components/useAccessToken.spec.tsx +82 -83
- package/src/components/useTokenClaims.spec.tsx +23 -22
- package/src/cookie.spec.ts +14 -9
- package/src/cookie.ts +29 -0
- package/src/env-variables.ts +2 -0
- package/src/errors.spec.ts +108 -0
- package/src/errors.ts +46 -0
- package/src/get-authorization-url.spec.ts +170 -15
- package/src/get-authorization-url.ts +69 -23
- package/src/index.ts +20 -2
- package/src/interfaces.ts +15 -0
- package/src/jwt.ts +9 -9
- package/src/middleware-helpers.spec.ts +238 -0
- package/src/middleware-helpers.ts +134 -0
- package/src/middleware.spec.ts +25 -0
- package/src/middleware.ts +4 -1
- package/src/pkce.spec.ts +125 -0
- package/src/pkce.ts +42 -0
- package/src/session.spec.ts +87 -89
- package/src/session.ts +91 -27
- package/src/test-helpers.ts +1 -1
- package/src/utils.spec.ts +14 -31
- package/src/utils.ts +9 -0
- package/src/validate-api-key.spec.ts +111 -0
- package/src/validate-api-key.ts +19 -0
- package/src/workos.spec.ts +2 -2
- package/src/workos.ts +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Mock } from 'vitest';
|
|
1
2
|
import { render, act, screen } from '@testing-library/react';
|
|
2
3
|
import '@testing-library/jest-dom';
|
|
3
4
|
import { Impersonation } from './impersonation.js';
|
|
@@ -7,39 +8,26 @@ import * as React from 'react';
|
|
|
7
8
|
import { handleSignOutAction } from '../actions.js';
|
|
8
9
|
|
|
9
10
|
// Mock the useAuth hook
|
|
10
|
-
|
|
11
|
-
useAuth:
|
|
11
|
+
vi.mock('./authkit-provider', () => ({
|
|
12
|
+
useAuth: vi.fn(),
|
|
12
13
|
}));
|
|
13
14
|
|
|
14
15
|
// Mock the getOrganizationAction
|
|
15
|
-
|
|
16
|
-
getOrganizationAction:
|
|
17
|
-
handleSignOutAction:
|
|
16
|
+
vi.mock('../actions', () => ({
|
|
17
|
+
getOrganizationAction: vi.fn(),
|
|
18
|
+
handleSignOutAction: vi.fn(),
|
|
18
19
|
}));
|
|
19
20
|
|
|
20
21
|
describe('Impersonation', () => {
|
|
21
22
|
beforeEach(() => {
|
|
22
|
-
|
|
23
|
+
vi.clearAllMocks();
|
|
23
24
|
});
|
|
24
25
|
|
|
25
26
|
it('should return null if not impersonating', () => {
|
|
26
|
-
(useAuth as
|
|
27
|
+
(useAuth as Mock).mockReturnValue({
|
|
27
28
|
impersonator: null,
|
|
28
29
|
user: { id: '123', email: 'user@example.com' },
|
|
29
30
|
organizationId: null,
|
|
30
|
-
loading: false,
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
const { container } = render(<Impersonation />);
|
|
34
|
-
expect(container).toBeEmptyDOMElement();
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it('should return null if loading', () => {
|
|
38
|
-
(useAuth as jest.Mock).mockReturnValue({
|
|
39
|
-
impersonator: { email: 'admin@example.com' },
|
|
40
|
-
user: { id: '123', email: 'user@example.com' },
|
|
41
|
-
organizationId: null,
|
|
42
|
-
loading: true,
|
|
43
31
|
});
|
|
44
32
|
|
|
45
33
|
const { container } = render(<Impersonation />);
|
|
@@ -47,11 +35,10 @@ describe('Impersonation', () => {
|
|
|
47
35
|
});
|
|
48
36
|
|
|
49
37
|
it('should render impersonation banner when impersonating', () => {
|
|
50
|
-
(useAuth as
|
|
38
|
+
(useAuth as Mock).mockReturnValue({
|
|
51
39
|
impersonator: { email: 'admin@example.com' },
|
|
52
40
|
user: { id: '123', email: 'user@example.com' },
|
|
53
41
|
organizationId: null,
|
|
54
|
-
loading: false,
|
|
55
42
|
});
|
|
56
43
|
|
|
57
44
|
const { container } = render(<Impersonation />);
|
|
@@ -59,14 +46,13 @@ describe('Impersonation', () => {
|
|
|
59
46
|
});
|
|
60
47
|
|
|
61
48
|
it('should render with organization info when organizationId is provided', async () => {
|
|
62
|
-
(useAuth as
|
|
49
|
+
(useAuth as Mock).mockReturnValue({
|
|
63
50
|
impersonator: { email: 'admin@example.com' },
|
|
64
51
|
user: { id: '123', email: 'user@example.com' },
|
|
65
52
|
organizationId: 'org_123',
|
|
66
|
-
loading: false,
|
|
67
53
|
});
|
|
68
54
|
|
|
69
|
-
(getOrganizationAction as
|
|
55
|
+
(getOrganizationAction as Mock).mockResolvedValue({
|
|
70
56
|
id: 'org_123',
|
|
71
57
|
name: 'Test Org',
|
|
72
58
|
});
|
|
@@ -79,11 +65,10 @@ describe('Impersonation', () => {
|
|
|
79
65
|
});
|
|
80
66
|
|
|
81
67
|
it('should render at the bottom by default', () => {
|
|
82
|
-
(useAuth as
|
|
68
|
+
(useAuth as Mock).mockReturnValue({
|
|
83
69
|
impersonator: { email: 'admin@example.com' },
|
|
84
70
|
user: { id: '123', email: 'user@example.com' },
|
|
85
71
|
organizationId: null,
|
|
86
|
-
loading: false,
|
|
87
72
|
});
|
|
88
73
|
|
|
89
74
|
const { container } = render(<Impersonation />);
|
|
@@ -92,11 +77,10 @@ describe('Impersonation', () => {
|
|
|
92
77
|
});
|
|
93
78
|
|
|
94
79
|
it('should render at the top when side prop is "top"', () => {
|
|
95
|
-
(useAuth as
|
|
80
|
+
(useAuth as Mock).mockReturnValue({
|
|
96
81
|
impersonator: { email: 'admin@example.com' },
|
|
97
82
|
user: { id: '123', email: 'user@example.com' },
|
|
98
83
|
organizationId: null,
|
|
99
|
-
loading: false,
|
|
100
84
|
});
|
|
101
85
|
|
|
102
86
|
const { container } = render(<Impersonation side="top" />);
|
|
@@ -105,30 +89,163 @@ describe('Impersonation', () => {
|
|
|
105
89
|
});
|
|
106
90
|
|
|
107
91
|
it('should merge custom styles with default styles', () => {
|
|
108
|
-
(useAuth as
|
|
92
|
+
(useAuth as Mock).mockReturnValue({
|
|
109
93
|
impersonator: { email: 'admin@example.com' },
|
|
110
94
|
user: { id: '123', email: 'user@example.com' },
|
|
111
95
|
organizationId: null,
|
|
112
|
-
loading: false,
|
|
113
96
|
});
|
|
114
97
|
|
|
115
98
|
const customStyle = { backgroundColor: 'red' };
|
|
116
99
|
const { container } = render(<Impersonation style={customStyle} />);
|
|
117
100
|
const root = container.querySelector('[data-workos-impersonation-root]');
|
|
118
|
-
expect(root).
|
|
101
|
+
expect((root as HTMLElement).style.backgroundColor).toBe('red');
|
|
119
102
|
});
|
|
120
103
|
|
|
121
104
|
it('should should sign out when the Stop button is called', async () => {
|
|
122
|
-
(useAuth as
|
|
105
|
+
(useAuth as Mock).mockReturnValue({
|
|
123
106
|
impersonator: { email: 'admin@example.com' },
|
|
124
107
|
user: { id: '123', email: 'user@example.com' },
|
|
125
108
|
organizationId: null,
|
|
126
|
-
loading: false,
|
|
127
109
|
});
|
|
128
110
|
|
|
129
111
|
render(<Impersonation />);
|
|
130
112
|
const stopButton = await screen.findByText('Stop');
|
|
131
113
|
stopButton.click();
|
|
132
|
-
expect(handleSignOutAction).
|
|
114
|
+
expect(handleSignOutAction).toHaveBeenCalledWith({});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should pass returnTo prop to handleSignOutAction when provided', async () => {
|
|
118
|
+
(useAuth as Mock).mockReturnValue({
|
|
119
|
+
impersonator: { email: 'admin@example.com' },
|
|
120
|
+
user: { id: '123', email: 'user@example.com' },
|
|
121
|
+
organizationId: null,
|
|
122
|
+
loading: false,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const returnTo = '/dashboard';
|
|
126
|
+
render(<Impersonation returnTo={returnTo} />);
|
|
127
|
+
const stopButton = await screen.findByText('Stop');
|
|
128
|
+
stopButton.click();
|
|
129
|
+
expect(handleSignOutAction).toHaveBeenCalledWith({ returnTo });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should not call getOrganizationAction when organizationId is not provided', () => {
|
|
133
|
+
(useAuth as Mock).mockReturnValue({
|
|
134
|
+
impersonator: { email: 'admin@example.com' },
|
|
135
|
+
user: { id: '123', email: 'user@example.com' },
|
|
136
|
+
organizationId: null,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
render(<Impersonation />);
|
|
140
|
+
expect(getOrganizationAction).not.toHaveBeenCalled();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('should not call getOrganizationAction when impersonator is not present', () => {
|
|
144
|
+
(useAuth as Mock).mockReturnValue({
|
|
145
|
+
impersonator: null,
|
|
146
|
+
user: { id: '123', email: 'user@example.com' },
|
|
147
|
+
organizationId: 'org_123',
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
render(<Impersonation />);
|
|
151
|
+
expect(getOrganizationAction).not.toHaveBeenCalled();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should not call getOrganizationAction when user is not present', () => {
|
|
155
|
+
(useAuth as Mock).mockReturnValue({
|
|
156
|
+
impersonator: { email: 'admin@example.com' },
|
|
157
|
+
user: null,
|
|
158
|
+
organizationId: 'org_123',
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
render(<Impersonation />);
|
|
162
|
+
expect(getOrganizationAction).not.toHaveBeenCalled();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should not call getOrganizationAction again when organization is already loaded with same ID', async () => {
|
|
166
|
+
const mockOrg = {
|
|
167
|
+
id: 'org_123',
|
|
168
|
+
name: 'Test Org',
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
(getOrganizationAction as Mock).mockResolvedValue(mockOrg);
|
|
172
|
+
|
|
173
|
+
const { rerender } = await act(async () => {
|
|
174
|
+
(useAuth as Mock).mockReturnValue({
|
|
175
|
+
impersonator: { email: 'admin@example.com' },
|
|
176
|
+
user: { id: '123', email: 'user@example.com' },
|
|
177
|
+
organizationId: 'org_123',
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
return render(<Impersonation />);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Wait for the initial call to complete
|
|
184
|
+
await act(async () => {
|
|
185
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
expect(getOrganizationAction).toHaveBeenCalledTimes(1);
|
|
189
|
+
|
|
190
|
+
// Rerender with the same organizationId
|
|
191
|
+
await act(async () => {
|
|
192
|
+
(useAuth as Mock).mockReturnValue({
|
|
193
|
+
impersonator: { email: 'admin@example.com' },
|
|
194
|
+
user: { id: '123', email: 'user@example.com' },
|
|
195
|
+
organizationId: 'org_123',
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
rerender(<Impersonation />);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// Should still be called only once
|
|
202
|
+
expect(getOrganizationAction).toHaveBeenCalledTimes(1);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('should call getOrganizationAction again when organizationId changes', async () => {
|
|
206
|
+
const mockOrg1 = {
|
|
207
|
+
id: 'org_123',
|
|
208
|
+
name: 'Test Org 1',
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const mockOrg2 = {
|
|
212
|
+
id: 'org_456',
|
|
213
|
+
name: 'Test Org 2',
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
(getOrganizationAction as Mock).mockResolvedValueOnce(mockOrg1).mockResolvedValueOnce(mockOrg2);
|
|
217
|
+
|
|
218
|
+
const { rerender } = await act(async () => {
|
|
219
|
+
(useAuth as Mock).mockReturnValue({
|
|
220
|
+
impersonator: { email: 'admin@example.com' },
|
|
221
|
+
user: { id: '123', email: 'user@example.com' },
|
|
222
|
+
organizationId: 'org_123',
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
return render(<Impersonation />);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// Wait for the initial call to complete
|
|
229
|
+
await act(async () => {
|
|
230
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
expect(getOrganizationAction).toHaveBeenCalledTimes(1);
|
|
234
|
+
expect(getOrganizationAction).toHaveBeenCalledWith('org_123');
|
|
235
|
+
|
|
236
|
+
// Rerender with a different organizationId
|
|
237
|
+
await act(async () => {
|
|
238
|
+
(useAuth as Mock).mockReturnValue({
|
|
239
|
+
impersonator: { email: 'admin@example.com' },
|
|
240
|
+
user: { id: '123', email: 'user@example.com' },
|
|
241
|
+
organizationId: 'org_456',
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
rerender(<Impersonation />);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// Should be called again with the new ID
|
|
248
|
+
expect(getOrganizationAction).toHaveBeenCalledTimes(2);
|
|
249
|
+
expect(getOrganizationAction).toHaveBeenCalledWith('org_456');
|
|
133
250
|
});
|
|
134
251
|
});
|
|
@@ -9,53 +9,60 @@ import { useAuth } from './authkit-provider.js';
|
|
|
9
9
|
|
|
10
10
|
interface ImpersonationProps extends React.ComponentPropsWithoutRef<'div'> {
|
|
11
11
|
side?: 'top' | 'bottom';
|
|
12
|
+
returnTo?: string;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
export function Impersonation({ side = 'bottom', ...props }: ImpersonationProps) {
|
|
15
|
-
const { user, impersonator, organizationId
|
|
15
|
+
export function Impersonation({ side = 'bottom', returnTo, ...props }: ImpersonationProps) {
|
|
16
|
+
const { user, impersonator, organizationId } = useAuth();
|
|
16
17
|
|
|
17
18
|
const [organization, setOrganization] = React.useState<Organization | null>(null);
|
|
18
19
|
|
|
19
20
|
React.useEffect(() => {
|
|
20
|
-
if (!organizationId) return;
|
|
21
|
+
if (!organizationId || !impersonator || !user) return;
|
|
22
|
+
if (organization && organization.id === organizationId) return;
|
|
21
23
|
getOrganizationAction(organizationId).then(setOrganization);
|
|
22
|
-
}, [organizationId]);
|
|
24
|
+
}, [organizationId, impersonator, user]);
|
|
23
25
|
|
|
24
|
-
if (
|
|
26
|
+
if (!impersonator || !user) return null;
|
|
25
27
|
|
|
26
28
|
return (
|
|
27
29
|
<div
|
|
28
30
|
{...props}
|
|
29
31
|
data-workos-impersonation-root=""
|
|
30
|
-
style={
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
32
|
+
style={
|
|
33
|
+
{
|
|
34
|
+
position: 'fixed',
|
|
35
|
+
inset: 0,
|
|
36
|
+
pointerEvents: 'none',
|
|
37
|
+
zIndex: 9999,
|
|
38
|
+
|
|
39
|
+
// short properties with defaults for authoring convenience
|
|
40
|
+
'--wi-minimized': '0',
|
|
41
|
+
'--wi-s': 'min(max(var(--workos-impersonation-size, 4px), 2px), 15px)',
|
|
42
|
+
'--wi-bgc': 'var(--workos-impersonation-background-color, #fce654)',
|
|
43
|
+
'--wi-c': 'var(--workos-impersonation-color, #1a1600)',
|
|
44
|
+
'--wi-bc': 'var(--workos-impersonation-border-color, #e0c36c)',
|
|
45
|
+
'--wi-bw': 'var(--workos-impersonation-border-width, 1px)',
|
|
46
|
+
|
|
47
|
+
...props.style,
|
|
48
|
+
} as React.CSSProperties
|
|
49
|
+
}
|
|
46
50
|
>
|
|
47
51
|
<div
|
|
48
|
-
style={
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
style={
|
|
53
|
+
{
|
|
54
|
+
'--wi-frame-size':
|
|
55
|
+
'calc(var(--wi-s) * (1 - var(--wi-minimized)) + var(--wi-minimized) * var(--wi-bw) * -1)',
|
|
56
|
+
position: 'absolute',
|
|
57
|
+
inset: 'calc(var(--wi-frame-size) * -1)',
|
|
58
|
+
borderRadius: 'calc(var(--wi-frame-size) * 3)',
|
|
59
|
+
boxShadow: `
|
|
54
60
|
inset 0 0 0 calc(var(--wi-frame-size) * 2) var(--wi-bgc),
|
|
55
61
|
inset 0 0 0 calc(var(--wi-frame-size) * 2 + var(--wi-bw)) var(--wi-bc)
|
|
56
62
|
`,
|
|
57
|
-
|
|
58
|
-
|
|
63
|
+
transition: 'all 500ms cubic-bezier(0.16, 1, 0.3, 1)',
|
|
64
|
+
} as React.CSSProperties
|
|
65
|
+
}
|
|
59
66
|
/>
|
|
60
67
|
|
|
61
68
|
<div
|
|
@@ -78,7 +85,7 @@ export function Impersonation({ side = 'bottom', ...props }: ImpersonationProps)
|
|
|
78
85
|
<form
|
|
79
86
|
onSubmit={async (event) => {
|
|
80
87
|
event.preventDefault();
|
|
81
|
-
await handleSignOutAction();
|
|
88
|
+
await handleSignOutAction({ returnTo });
|
|
82
89
|
}}
|
|
83
90
|
style={{
|
|
84
91
|
display: 'flex',
|
|
@@ -14,6 +14,7 @@ describe('MinMaxButton', () => {
|
|
|
14
14
|
afterEach(() => {
|
|
15
15
|
// Clean up after each test
|
|
16
16
|
document.body.innerHTML = '';
|
|
17
|
+
vi.restoreAllMocks();
|
|
17
18
|
});
|
|
18
19
|
|
|
19
20
|
it('sets minimized value when clicked', () => {
|
|
@@ -33,7 +34,7 @@ describe('MinMaxButton', () => {
|
|
|
33
34
|
const root = document.querySelector('[data-workos-impersonation-root]');
|
|
34
35
|
|
|
35
36
|
// Mock querySelector to return null for this test
|
|
36
|
-
|
|
37
|
+
vi.spyOn(document, 'querySelector').mockReturnValue(null);
|
|
37
38
|
|
|
38
39
|
act(() => {
|
|
39
40
|
getByRole('button').click();
|
|
@@ -1,20 +1,21 @@
|
|
|
1
|
+
import type { Mock } from 'vitest';
|
|
1
2
|
import { tokenStore, TokenStore } from './tokenStore.js';
|
|
2
3
|
import { getAccessTokenAction, refreshAccessTokenAction } from '../actions.js';
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
getAccessTokenAction:
|
|
6
|
-
refreshAccessTokenAction:
|
|
5
|
+
vi.mock('../actions.js', () => ({
|
|
6
|
+
getAccessTokenAction: vi.fn(),
|
|
7
|
+
refreshAccessTokenAction: vi.fn(),
|
|
7
8
|
}));
|
|
8
9
|
|
|
9
|
-
const mockGetAccessTokenAction = getAccessTokenAction as
|
|
10
|
-
const mockRefreshAccessTokenAction = refreshAccessTokenAction as
|
|
10
|
+
const mockGetAccessTokenAction = getAccessTokenAction as Mock;
|
|
11
|
+
const mockRefreshAccessTokenAction = refreshAccessTokenAction as Mock;
|
|
11
12
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
12
13
|
const _global = global as any;
|
|
13
14
|
|
|
14
15
|
describe('tokenStore', () => {
|
|
15
16
|
beforeEach(() => {
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
vi.useFakeTimers();
|
|
18
|
+
vi.resetAllMocks();
|
|
18
19
|
tokenStore.reset();
|
|
19
20
|
|
|
20
21
|
// Clean up DOM globals
|
|
@@ -23,10 +24,10 @@ describe('tokenStore', () => {
|
|
|
23
24
|
});
|
|
24
25
|
|
|
25
26
|
afterEach(() => {
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
vi.clearAllTimers();
|
|
28
|
+
vi.useRealTimers();
|
|
28
29
|
tokenStore.reset();
|
|
29
|
-
|
|
30
|
+
vi.restoreAllMocks();
|
|
30
31
|
|
|
31
32
|
// Clean up DOM globals
|
|
32
33
|
delete _global.document;
|
|
@@ -54,7 +55,7 @@ describe('tokenStore', () => {
|
|
|
54
55
|
resolvePromise = resolve;
|
|
55
56
|
});
|
|
56
57
|
|
|
57
|
-
mockRefreshAccessTokenAction.mockReturnValue(slowPromise);
|
|
58
|
+
mockRefreshAccessTokenAction.mockReturnValue(slowPromise.then((t) => ({ accessToken: t })));
|
|
58
59
|
|
|
59
60
|
expect(tokenStore.isRefreshing()).toBe(false);
|
|
60
61
|
|
|
@@ -124,18 +125,25 @@ describe('tokenStore', () => {
|
|
|
124
125
|
const refreshedToken =
|
|
125
126
|
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJyZWZyZXNoZWQiLCJzaWQiOiJzZXNzaW9uXzEyMyIsImV4cCI6OTk5OTk5OTk5OX0.mock-signature-2';
|
|
126
127
|
|
|
127
|
-
// Set expiring token first
|
|
128
|
+
// Set expiring token first — also set refresh mock since getAccessTokenSilently
|
|
129
|
+
// will trigger refresh for expiring tokens
|
|
128
130
|
mockGetAccessTokenAction.mockResolvedValue(expiringToken);
|
|
131
|
+
mockRefreshAccessTokenAction.mockResolvedValueOnce({ accessToken: expiringToken });
|
|
129
132
|
await tokenStore.getAccessTokenSilently();
|
|
130
133
|
|
|
131
|
-
//
|
|
132
|
-
|
|
134
|
+
// Clear mocks to track subsequent calls
|
|
135
|
+
mockGetAccessTokenAction.mockClear();
|
|
136
|
+
mockRefreshAccessTokenAction.mockClear();
|
|
133
137
|
|
|
134
|
-
//
|
|
138
|
+
// Setup refresh to return new token
|
|
139
|
+
mockRefreshAccessTokenAction.mockResolvedValue({ accessToken: refreshedToken });
|
|
140
|
+
|
|
141
|
+
// Now call getAccessToken - should trigger refresh due to expiring token
|
|
135
142
|
const token = await tokenStore.getAccessToken();
|
|
136
143
|
|
|
137
|
-
|
|
144
|
+
// Should have called refresh since existing token was expiring
|
|
138
145
|
expect(mockRefreshAccessTokenAction).toHaveBeenCalled();
|
|
146
|
+
expect(token).toBe(refreshedToken);
|
|
139
147
|
});
|
|
140
148
|
|
|
141
149
|
it('should refresh when no token exists', async () => {
|
|
@@ -192,7 +200,7 @@ describe('tokenStore', () => {
|
|
|
192
200
|
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJyZWZyZXNoZWQiLCJzaWQiOiJzZXNzaW9uXzEyMyIsImV4cCI6OTk5OTk5OTk5OX0.mock-signature-2';
|
|
193
201
|
|
|
194
202
|
mockGetAccessTokenAction.mockResolvedValue(expiredToken);
|
|
195
|
-
mockRefreshAccessTokenAction.mockResolvedValue(refreshedToken);
|
|
203
|
+
mockRefreshAccessTokenAction.mockResolvedValue({ accessToken: refreshedToken });
|
|
196
204
|
|
|
197
205
|
const token = await tokenStore.getAccessTokenSilently();
|
|
198
206
|
|
|
@@ -213,7 +221,7 @@ describe('tokenStore', () => {
|
|
|
213
221
|
};
|
|
214
222
|
const validToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(validPayload))}.mock-signature`;
|
|
215
223
|
|
|
216
|
-
const setTimeoutSpy =
|
|
224
|
+
const setTimeoutSpy = vi.spyOn(global, 'setTimeout');
|
|
217
225
|
mockGetAccessTokenAction.mockResolvedValue(validToken);
|
|
218
226
|
|
|
219
227
|
await tokenStore.getAccessTokenSilently();
|
|
@@ -227,7 +235,7 @@ describe('tokenStore', () => {
|
|
|
227
235
|
|
|
228
236
|
describe('subscriber management', () => {
|
|
229
237
|
it('should notify subscribers when state changes', () => {
|
|
230
|
-
const listener =
|
|
238
|
+
const listener = vi.fn();
|
|
231
239
|
const unsubscribe = tokenStore.subscribe(listener);
|
|
232
240
|
|
|
233
241
|
// Trigger a state change
|
|
@@ -256,14 +264,14 @@ describe('tokenStore', () => {
|
|
|
256
264
|
mockGetAccessTokenAction.mockResolvedValue(validToken);
|
|
257
265
|
|
|
258
266
|
// Subscribe to create a listener
|
|
259
|
-
const listener =
|
|
267
|
+
const listener = vi.fn();
|
|
260
268
|
const unsubscribe = tokenStore.subscribe(listener);
|
|
261
269
|
|
|
262
270
|
// Get token to schedule a refresh
|
|
263
271
|
await tokenStore.getAccessTokenSilently();
|
|
264
272
|
|
|
265
273
|
// Spy on clearTimeout
|
|
266
|
-
const clearTimeoutSpy =
|
|
274
|
+
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
|
|
267
275
|
|
|
268
276
|
// Unsubscribe the last (only) subscriber - should clear timeout
|
|
269
277
|
unsubscribe();
|
|
@@ -287,15 +295,18 @@ describe('tokenStore', () => {
|
|
|
287
295
|
const refreshedToken =
|
|
288
296
|
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJyZWZyZXNoZWQiLCJzaWQiOiJzZXNzaW9uXzEyMyIsImV4cCI6OTk5OTk5OTk5OX0.mock-signature-2';
|
|
289
297
|
|
|
290
|
-
// First set an expiring token
|
|
298
|
+
// First set an expiring token — also set refresh mock since getAccessTokenSilently
|
|
299
|
+
// will trigger refresh for expiring tokens
|
|
291
300
|
mockGetAccessTokenAction.mockResolvedValue(expiringToken);
|
|
301
|
+
mockRefreshAccessTokenAction.mockResolvedValueOnce({ accessToken: expiringToken });
|
|
292
302
|
await tokenStore.getAccessTokenSilently();
|
|
293
303
|
|
|
294
304
|
// Clear mocks
|
|
295
305
|
mockGetAccessTokenAction.mockClear();
|
|
306
|
+
mockRefreshAccessTokenAction.mockClear();
|
|
296
307
|
|
|
297
308
|
// Setup refresh to return new token
|
|
298
|
-
mockRefreshAccessTokenAction.mockResolvedValue(refreshedToken);
|
|
309
|
+
mockRefreshAccessTokenAction.mockResolvedValue({ accessToken: refreshedToken });
|
|
299
310
|
|
|
300
311
|
// Call getAccessToken again - should trigger refresh due to expiring token
|
|
301
312
|
const token = await tokenStore.getAccessToken();
|
|
@@ -354,7 +365,7 @@ describe('tokenStore', () => {
|
|
|
354
365
|
|
|
355
366
|
it('should consume eager auth cookie on first getAccessToken call', async () => {
|
|
356
367
|
const eagerToken = 'eager-auth-token';
|
|
357
|
-
const mockCookieSetter =
|
|
368
|
+
const mockCookieSetter = vi.fn();
|
|
358
369
|
|
|
359
370
|
// Mock document.cookie with both getter and setter
|
|
360
371
|
let cookieValue = `workos-access-token=${eagerToken};`;
|
|
@@ -404,7 +415,7 @@ describe('tokenStore', () => {
|
|
|
404
415
|
iat: now - 40,
|
|
405
416
|
};
|
|
406
417
|
const fastToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(fastPayload))}.mock-signature`;
|
|
407
|
-
const mockCookieSetter =
|
|
418
|
+
const mockCookieSetter = vi.fn();
|
|
408
419
|
|
|
409
420
|
let cookieValue = `workos-access-token=${fastToken};`;
|
|
410
421
|
|
|
@@ -433,7 +444,7 @@ describe('tokenStore', () => {
|
|
|
433
444
|
configurable: true,
|
|
434
445
|
});
|
|
435
446
|
|
|
436
|
-
const setTimeoutSpy =
|
|
447
|
+
const setTimeoutSpy = vi.spyOn(global, 'setTimeout');
|
|
437
448
|
|
|
438
449
|
// Call getAccessTokenSilently to trigger fast cookie consumption and refresh scheduling
|
|
439
450
|
const token = await tokenStore.getAccessTokenSilently();
|
|
@@ -480,7 +491,7 @@ describe('tokenStore', () => {
|
|
|
480
491
|
|
|
481
492
|
it('should handle HTTP protocol for cookie deletion', async () => {
|
|
482
493
|
const eagerToken = 'http-token';
|
|
483
|
-
const mockCookieSetter =
|
|
494
|
+
const mockCookieSetter = vi.fn();
|
|
484
495
|
|
|
485
496
|
let cookieValue = `workos-access-token=${eagerToken};`;
|
|
486
497
|
|
|
@@ -526,11 +537,14 @@ describe('tokenStore', () => {
|
|
|
526
537
|
await tokenStore.getAccessTokenSilently();
|
|
527
538
|
|
|
528
539
|
// Now simulate network error during refresh
|
|
529
|
-
mockRefreshAccessTokenAction.
|
|
540
|
+
mockRefreshAccessTokenAction.mockResolvedValue({
|
|
541
|
+
accessToken: undefined,
|
|
542
|
+
error: 'Failed to refresh access token',
|
|
543
|
+
});
|
|
530
544
|
|
|
531
545
|
try {
|
|
532
546
|
await tokenStore.refreshToken();
|
|
533
|
-
} catch
|
|
547
|
+
} catch {
|
|
534
548
|
// Expected to throw
|
|
535
549
|
}
|
|
536
550
|
|
|
@@ -543,11 +557,11 @@ describe('tokenStore', () => {
|
|
|
543
557
|
it('should convert non-Error objects to Error instances', async () => {
|
|
544
558
|
const errorString = 'network timeout';
|
|
545
559
|
|
|
546
|
-
mockRefreshAccessTokenAction.
|
|
560
|
+
mockRefreshAccessTokenAction.mockResolvedValue({ accessToken: undefined, error: errorString });
|
|
547
561
|
|
|
548
562
|
try {
|
|
549
563
|
await tokenStore.refreshToken();
|
|
550
|
-
} catch
|
|
564
|
+
} catch {
|
|
551
565
|
// Expected to throw
|
|
552
566
|
}
|
|
553
567
|
|
|
@@ -624,7 +638,7 @@ describe('tokenStore', () => {
|
|
|
624
638
|
await tokenStore.getAccessTokenSilently();
|
|
625
639
|
|
|
626
640
|
// Spy on clearTimeout
|
|
627
|
-
const clearTimeoutSpy =
|
|
641
|
+
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
|
|
628
642
|
|
|
629
643
|
// Clear token should clear the refresh timeout
|
|
630
644
|
tokenStore.clearToken();
|
|
@@ -649,7 +663,7 @@ describe('tokenStore', () => {
|
|
|
649
663
|
|
|
650
664
|
mockRefreshAccessTokenAction.mockImplementation(() => {
|
|
651
665
|
callCount++;
|
|
652
|
-
return slowPromise;
|
|
666
|
+
return slowPromise.then((t) => ({ accessToken: t }));
|
|
653
667
|
});
|
|
654
668
|
|
|
655
669
|
// Clear any existing refresh promise
|
|
@@ -691,21 +705,22 @@ describe('tokenStore', () => {
|
|
|
691
705
|
});
|
|
692
706
|
|
|
693
707
|
describe('refresh state management', () => {
|
|
694
|
-
it('should
|
|
695
|
-
const
|
|
708
|
+
it('should create Error from server action error string', async () => {
|
|
709
|
+
const errorMessage = 'actual error instance';
|
|
696
710
|
|
|
697
|
-
// Mock refresh to
|
|
698
|
-
mockRefreshAccessTokenAction.
|
|
711
|
+
// Mock refresh to return an error result (as server actions do)
|
|
712
|
+
mockRefreshAccessTokenAction.mockResolvedValue({ accessToken: undefined, error: errorMessage });
|
|
699
713
|
|
|
700
714
|
try {
|
|
701
715
|
await tokenStore.refreshToken();
|
|
702
|
-
} catch
|
|
716
|
+
} catch {
|
|
703
717
|
// Expected to throw
|
|
704
718
|
}
|
|
705
719
|
|
|
706
|
-
// Verify
|
|
720
|
+
// Verify an Error was created from the error string
|
|
707
721
|
const state = tokenStore.getSnapshot();
|
|
708
|
-
expect(state.error).
|
|
722
|
+
expect(state.error).toBeInstanceOf(Error);
|
|
723
|
+
expect(state.error?.message).toBe(errorMessage);
|
|
709
724
|
});
|
|
710
725
|
|
|
711
726
|
it('should update state for manual refresh', async () => {
|
|
@@ -717,7 +732,7 @@ describe('tokenStore', () => {
|
|
|
717
732
|
await tokenStore.getAccessTokenSilently();
|
|
718
733
|
|
|
719
734
|
// Mock refresh to return new token
|
|
720
|
-
mockRefreshAccessTokenAction.mockResolvedValue(newToken);
|
|
735
|
+
mockRefreshAccessTokenAction.mockResolvedValue({ accessToken: newToken });
|
|
721
736
|
|
|
722
737
|
// Call manual refresh which should update state
|
|
723
738
|
const result = await tokenStore.refreshToken();
|
|
@@ -736,9 +751,9 @@ describe('tokenStore', () => {
|
|
|
736
751
|
|
|
737
752
|
// Clear mocks and set up spy on setState
|
|
738
753
|
mockGetAccessTokenAction.mockClear();
|
|
739
|
-
mockRefreshAccessTokenAction.mockResolvedValue(existingToken); // Same token
|
|
754
|
+
mockRefreshAccessTokenAction.mockResolvedValue({ accessToken: existingToken }); // Same token
|
|
740
755
|
|
|
741
|
-
const listener =
|
|
756
|
+
const listener = vi.fn();
|
|
742
757
|
tokenStore.subscribe(listener);
|
|
743
758
|
|
|
744
759
|
// Force a silent refresh that returns the same token
|
|
@@ -752,7 +767,7 @@ describe('tokenStore', () => {
|
|
|
752
767
|
|
|
753
768
|
describe('TokenStore constructor', () => {
|
|
754
769
|
const setupMockEnv = (cookieValue = '', protocol = 'https:') => {
|
|
755
|
-
const mockCookieSetter =
|
|
770
|
+
const mockCookieSetter = vi.fn();
|
|
756
771
|
|
|
757
772
|
Object.defineProperty(_global, 'document', {
|
|
758
773
|
value: { cookie: cookieValue },
|