@shipfox/client-auth 2.0.0 → 4.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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +40 -0
- package/dist/components/auth-shell.d.ts +6 -4
- package/dist/components/auth-shell.d.ts.map +1 -1
- package/dist/components/auth-shell.js +6 -1
- package/dist/components/auth-shell.js.map +1 -1
- package/dist/components/email-code-verification.d.ts +20 -0
- package/dist/components/email-code-verification.d.ts.map +1 -0
- package/dist/components/email-code-verification.js +136 -0
- package/dist/components/email-code-verification.js.map +1 -0
- package/dist/components/redirect-context.d.ts +11 -0
- package/dist/components/redirect-context.d.ts.map +1 -0
- package/dist/components/redirect-context.js +22 -0
- package/dist/components/redirect-context.js.map +1 -0
- package/dist/components/redirect-target.d.ts.map +1 -1
- package/dist/components/redirect-target.js +13 -12
- package/dist/components/redirect-target.js.map +1 -1
- package/dist/continuation.d.ts +4 -0
- package/dist/continuation.d.ts.map +1 -0
- package/dist/continuation.js +5 -0
- package/dist/continuation.js.map +1 -0
- package/dist/hooks/api/login-auth.d.ts.map +1 -1
- package/dist/hooks/api/login-auth.js +4 -28
- package/dist/hooks/api/login-auth.js.map +1 -1
- package/dist/hooks/api/logout-auth.d.ts.map +1 -1
- package/dist/hooks/api/logout-auth.js +4 -14
- package/dist/hooks/api/logout-auth.js.map +1 -1
- package/dist/hooks/api/password-reset-auth.d.ts.map +1 -1
- package/dist/hooks/api/password-reset-auth.js +4 -23
- package/dist/hooks/api/password-reset-auth.js.map +1 -1
- package/dist/hooks/api/verify-email-auth.d.ts.map +1 -1
- package/dist/hooks/api/verify-email-auth.js +4 -23
- package/dist/hooks/api/verify-email-auth.js.map +1 -1
- package/dist/pages/form-utils.js +1 -1
- package/dist/pages/form-utils.js.map +1 -1
- package/dist/pages/invitation-context.d.ts +0 -5
- package/dist/pages/invitation-context.d.ts.map +1 -1
- package/dist/pages/invitation-context.js +3 -19
- package/dist/pages/invitation-context.js.map +1 -1
- package/dist/pages/signup-page.d.ts.map +1 -1
- package/dist/pages/signup-page.js +50 -144
- package/dist/pages/signup-page.js.map +1 -1
- package/dist/state/auth.d.ts +1 -1
- package/dist/state/auth.d.ts.map +1 -1
- package/dist/state/auth.js +1 -1
- package/dist/state/auth.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +10 -40
- package/src/components/auth-provider.test.tsx +173 -3
- package/src/components/auth-shell.tsx +20 -5
- package/src/components/email-code-verification.test.tsx +159 -0
- package/src/components/email-code-verification.tsx +177 -0
- package/src/components/redirect-context.test.ts +51 -0
- package/src/components/redirect-context.ts +26 -0
- package/src/components/redirect-target.test.ts +8 -0
- package/src/components/redirect-target.ts +13 -12
- package/src/continuation.test.ts +15 -0
- package/src/continuation.ts +6 -0
- package/src/hooks/api/login-auth.ts +4 -28
- package/src/hooks/api/logout-auth.ts +4 -10
- package/src/hooks/api/password-reset-auth.ts +4 -23
- package/src/hooks/api/verify-email-auth.ts +4 -23
- package/src/pages/form-errors.test.ts +1 -1
- package/src/pages/form-utils.test.ts +1 -4
- package/src/pages/form-utils.ts +1 -1
- package/src/pages/invitation-context.ts +2 -20
- package/src/pages/signup-page.tsx +33 -129
- package/src/state/auth.ts +1 -0
- package/src/state/last-workspace.test.ts +16 -0
- package/tsconfig.build.tsbuildinfo +1 -1
- package/dist/pages/email-verification-resend-model.d.ts +0 -6
- package/dist/pages/email-verification-resend-model.d.ts.map +0 -1
- package/dist/pages/email-verification-resend-model.js +0 -11
- package/dist/pages/email-verification-resend-model.js.map +0 -1
- package/src/pages/email-verification-resend-model.test.ts +0 -42
- package/src/pages/email-verification-resend-model.ts +0 -15
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/client-auth",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "4.0.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -23,6 +23,10 @@
|
|
|
23
23
|
"types": "./dist/feature.d.ts",
|
|
24
24
|
"default": "./dist/feature.js"
|
|
25
25
|
},
|
|
26
|
+
"./continuation": {
|
|
27
|
+
"types": "./dist/continuation.d.ts",
|
|
28
|
+
"default": "./dist/continuation.js"
|
|
29
|
+
},
|
|
26
30
|
"./routes/*": {
|
|
27
31
|
"types": "./dist/routes/*.d.ts",
|
|
28
32
|
"default": "./dist/routes/*.js"
|
|
@@ -31,12 +35,12 @@
|
|
|
31
35
|
"dependencies": {
|
|
32
36
|
"@swc/helpers": "^0.5.17",
|
|
33
37
|
"@tanstack/react-form": "^1.32.0",
|
|
34
|
-
"@shipfox/api-auth-dto": "
|
|
35
|
-
"@shipfox/client-api": "1.0.0",
|
|
36
|
-
"@shipfox/client-shell": "2.0.0",
|
|
38
|
+
"@shipfox/api-auth-dto": "7.1.0",
|
|
37
39
|
"@shipfox/api-workspaces-dto": "6.0.0",
|
|
38
|
-
"@shipfox/client-
|
|
39
|
-
"@shipfox/
|
|
40
|
+
"@shipfox/client-api": "4.0.0",
|
|
41
|
+
"@shipfox/client-shell": "4.0.0",
|
|
42
|
+
"@shipfox/client-ui": "4.0.0",
|
|
43
|
+
"@shipfox/react-ui": "0.3.5"
|
|
40
44
|
},
|
|
41
45
|
"peerDependencies": {
|
|
42
46
|
"@tanstack/react-query": "^5.101.0",
|
|
@@ -45,40 +49,6 @@
|
|
|
45
49
|
"react": "^19.0.0",
|
|
46
50
|
"react-dom": "^19.0.0"
|
|
47
51
|
},
|
|
48
|
-
"devDependencies": {
|
|
49
|
-
"@argos-ci/cli": "^5.0.0",
|
|
50
|
-
"@argos-ci/storybook": "^6.0.0",
|
|
51
|
-
"@faker-js/faker": "^10.4.0",
|
|
52
|
-
"@storybook/addon-vitest": "^10.3.6",
|
|
53
|
-
"@storybook/react": "^10.0.0",
|
|
54
|
-
"@storybook/react-vite": "^10.0.0",
|
|
55
|
-
"@tailwindcss/vite": "^4.1.13",
|
|
56
|
-
"@tanstack/react-query": "^5.101.0",
|
|
57
|
-
"@tanstack/react-router": "^1.170.16",
|
|
58
|
-
"@testing-library/jest-dom": "^6.6.3",
|
|
59
|
-
"@testing-library/react": "^16.3.2",
|
|
60
|
-
"@testing-library/user-event": "^14.6.1",
|
|
61
|
-
"@types/react": "^19.1.11",
|
|
62
|
-
"@types/react-dom": "^19.1.7",
|
|
63
|
-
"@vitejs/plugin-react": "^6.0.0",
|
|
64
|
-
"@vitest/browser-playwright": "^4.1.5",
|
|
65
|
-
"fishery": "^2.4.0",
|
|
66
|
-
"jotai": "^2.19.1",
|
|
67
|
-
"jsdom": "^29.0.0",
|
|
68
|
-
"react": "^19.1.1",
|
|
69
|
-
"react-dom": "^19.1.1",
|
|
70
|
-
"storybook": "^10.0.0",
|
|
71
|
-
"storybook-addon-pseudo-states": "^10.0.0",
|
|
72
|
-
"tailwindcss": "^4.1.13",
|
|
73
|
-
"vite": "^8.0.3",
|
|
74
|
-
"vitest": "^4.1.5",
|
|
75
|
-
"@shipfox/client-test-setup": "0.0.3",
|
|
76
|
-
"@shipfox/swc": "1.2.6",
|
|
77
|
-
"@shipfox/vitest": "1.2.3",
|
|
78
|
-
"@shipfox/typescript": "1.1.7",
|
|
79
|
-
"@shipfox/ts-config": "1.3.8",
|
|
80
|
-
"@shipfox/biome": "1.8.2"
|
|
81
|
-
},
|
|
82
52
|
"scripts": {
|
|
83
53
|
"build": "shipfox-swc",
|
|
84
54
|
"check": "shipfox-biome-check",
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import {configureApiClient} from '@shipfox/client-api';
|
|
2
|
+
import {QueryClient, useQuery} from '@tanstack/react-query';
|
|
2
3
|
import {render, screen, waitFor} from '@testing-library/react';
|
|
3
4
|
import {useEffect, useRef} from 'react';
|
|
5
|
+
import {useLoginAuth} from '#hooks/api/login-auth.js';
|
|
4
6
|
import {useLogoutAuth} from '#hooks/api/logout-auth.js';
|
|
5
7
|
import {useRefreshAuth} from '#hooks/api/refresh-auth.js';
|
|
6
8
|
import {useAuthState} from '#hooks/use-auth-state.js';
|
|
@@ -16,6 +18,8 @@ const user = {
|
|
|
16
18
|
updated_at: '2026-04-27T00:00:00.000Z',
|
|
17
19
|
};
|
|
18
20
|
|
|
21
|
+
const otherUser = {...user, id: '22222222-2222-4222-8222-222222222222', email: 'other@example.com'};
|
|
22
|
+
|
|
19
23
|
function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
|
|
20
24
|
return new Response(JSON.stringify(body), {
|
|
21
25
|
headers: {'content-type': 'application/json'},
|
|
@@ -39,6 +43,28 @@ function StatusProbe() {
|
|
|
39
43
|
);
|
|
40
44
|
}
|
|
41
45
|
|
|
46
|
+
function PrivateDataProbe() {
|
|
47
|
+
const auth = useAuthState();
|
|
48
|
+
const privateData = useQuery({
|
|
49
|
+
queryKey: ['private-data'],
|
|
50
|
+
queryFn: () => new Promise<string>(() => undefined),
|
|
51
|
+
enabled: auth.isAuthenticated,
|
|
52
|
+
});
|
|
53
|
+
const login = useLoginAuth();
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div>
|
|
57
|
+
<span data-testid="private-data">{privateData.data ?? 'loading'}</span>
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
onClick={() => void login.mutateAsync({email: 'other@example.com', password: 'password'})}
|
|
61
|
+
>
|
|
62
|
+
Switch user
|
|
63
|
+
</button>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
42
68
|
describe('AuthProvider', () => {
|
|
43
69
|
beforeEach(() => {
|
|
44
70
|
configureApiClient({
|
|
@@ -50,7 +76,9 @@ describe('AuthProvider', () => {
|
|
|
50
76
|
});
|
|
51
77
|
|
|
52
78
|
test('boots from a successful refresh', async () => {
|
|
53
|
-
const fetchImpl = vi
|
|
79
|
+
const fetchImpl = vi
|
|
80
|
+
.fn()
|
|
81
|
+
.mockImplementation(() => Promise.resolve(jsonResponse({token: 'access-token', user})));
|
|
54
82
|
configureApiClient({fetchImpl});
|
|
55
83
|
|
|
56
84
|
render(
|
|
@@ -64,21 +92,26 @@ describe('AuthProvider', () => {
|
|
|
64
92
|
});
|
|
65
93
|
|
|
66
94
|
test('treats refresh 401 as a guest session', async () => {
|
|
95
|
+
const queryClient = new QueryClient();
|
|
96
|
+
const cancelQueries = vi.spyOn(queryClient, 'cancelQueries');
|
|
67
97
|
const fetchImpl = vi
|
|
68
98
|
.fn()
|
|
69
99
|
.mockResolvedValue(
|
|
70
100
|
jsonResponse({code: 'unauthorized', message: 'Unauthorized'}, {status: 401}),
|
|
71
101
|
);
|
|
72
102
|
configureApiClient({fetchImpl});
|
|
103
|
+
queryClient.setQueryData(['private-data'], 'private data');
|
|
73
104
|
|
|
74
105
|
render(
|
|
75
|
-
<AuthProvider>
|
|
106
|
+
<AuthProvider queryClient={queryClient}>
|
|
76
107
|
<StatusProbe />
|
|
77
108
|
</AuthProvider>,
|
|
78
109
|
);
|
|
79
110
|
|
|
80
111
|
await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('guest'));
|
|
81
112
|
expect(screen.getByTestId('email')).toHaveTextContent('none');
|
|
113
|
+
expect(cancelQueries).toHaveBeenCalledTimes(1);
|
|
114
|
+
expect(queryClient.getQueryData(['private-data'])).toBeUndefined();
|
|
82
115
|
});
|
|
83
116
|
|
|
84
117
|
test('keeps the current session when refresh fails with a server error', async () => {
|
|
@@ -116,6 +149,41 @@ describe('AuthProvider', () => {
|
|
|
116
149
|
expect(screen.getByTestId('email')).toHaveTextContent('noe@example.com');
|
|
117
150
|
});
|
|
118
151
|
|
|
152
|
+
test('preserves private cached data when refresh returns the same user', async () => {
|
|
153
|
+
const queryClient = new QueryClient();
|
|
154
|
+
const cancelQueries = vi.spyOn(queryClient, 'cancelQueries');
|
|
155
|
+
const fetchImpl = vi
|
|
156
|
+
.fn()
|
|
157
|
+
.mockImplementation(() => Promise.resolve(jsonResponse({token: 'access-token', user})));
|
|
158
|
+
configureApiClient({fetchImpl});
|
|
159
|
+
queryClient.setQueryData(['private-data'], 'current user private data');
|
|
160
|
+
const onDone = vi.fn();
|
|
161
|
+
|
|
162
|
+
function RefreshAfterBootProbe() {
|
|
163
|
+
const auth = useAuthState();
|
|
164
|
+
const refresh = useRefreshAuth();
|
|
165
|
+
const didRefresh = useRef(false);
|
|
166
|
+
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
if (auth.status !== 'authenticated' || didRefresh.current) return;
|
|
169
|
+
didRefresh.current = true;
|
|
170
|
+
void refresh().then(onDone);
|
|
171
|
+
}, [auth.status, refresh]);
|
|
172
|
+
|
|
173
|
+
return <StatusProbe />;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
render(
|
|
177
|
+
<AuthProvider queryClient={queryClient}>
|
|
178
|
+
<RefreshAfterBootProbe />
|
|
179
|
+
</AuthProvider>,
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1));
|
|
183
|
+
expect(cancelQueries).not.toHaveBeenCalled();
|
|
184
|
+
expect(queryClient.getQueryData(['private-data'])).toBe('current user private data');
|
|
185
|
+
});
|
|
186
|
+
|
|
119
187
|
test('deduplicates concurrent refresh calls', async () => {
|
|
120
188
|
const fetchImpl = vi
|
|
121
189
|
.fn()
|
|
@@ -144,14 +212,30 @@ describe('AuthProvider', () => {
|
|
|
144
212
|
});
|
|
145
213
|
|
|
146
214
|
test('logout clears local state even when the API call fails', async () => {
|
|
215
|
+
const queryClient = new QueryClient();
|
|
216
|
+
const cancelQueries = vi.spyOn(queryClient, 'cancelQueries');
|
|
147
217
|
const fetchImpl = vi
|
|
148
218
|
.fn()
|
|
149
219
|
.mockResolvedValueOnce(jsonResponse({token: 'access-token', user}))
|
|
150
220
|
.mockRejectedValueOnce(new Error('offline'));
|
|
151
221
|
configureApiClient({fetchImpl});
|
|
222
|
+
queryClient.setQueryData(['private-data'], 'private data');
|
|
223
|
+
const onQueryAbort = vi.fn();
|
|
224
|
+
void queryClient
|
|
225
|
+
.fetchQuery({
|
|
226
|
+
queryKey: ['in-flight-private-data'],
|
|
227
|
+
queryFn: ({signal}) =>
|
|
228
|
+
new Promise<string>((_, reject) => {
|
|
229
|
+
signal.addEventListener('abort', () => {
|
|
230
|
+
onQueryAbort();
|
|
231
|
+
reject(signal.reason);
|
|
232
|
+
});
|
|
233
|
+
}),
|
|
234
|
+
})
|
|
235
|
+
.catch(() => undefined);
|
|
152
236
|
|
|
153
237
|
render(
|
|
154
|
-
<AuthProvider>
|
|
238
|
+
<AuthProvider queryClient={queryClient}>
|
|
155
239
|
<StatusProbe />
|
|
156
240
|
</AuthProvider>,
|
|
157
241
|
);
|
|
@@ -160,5 +244,91 @@ describe('AuthProvider', () => {
|
|
|
160
244
|
screen.getByRole('button', {name: 'Logout'}).click();
|
|
161
245
|
|
|
162
246
|
await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('guest'));
|
|
247
|
+
expect(cancelQueries).toHaveBeenCalledTimes(1);
|
|
248
|
+
expect(onQueryAbort).toHaveBeenCalledTimes(1);
|
|
249
|
+
expect(queryClient.getQueryData(['private-data'])).toBeUndefined();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('does not restore a session when logout supersedes workspace hydration', async () => {
|
|
253
|
+
const workspaceRefreshStarted = vi.fn();
|
|
254
|
+
let workspaceRequestCount = 0;
|
|
255
|
+
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
256
|
+
const request = input as Request;
|
|
257
|
+
if (!request.url.endsWith('/workspaces'))
|
|
258
|
+
return Promise.resolve(jsonResponse({token: 'access-token', user}));
|
|
259
|
+
|
|
260
|
+
workspaceRequestCount += 1;
|
|
261
|
+
if (workspaceRequestCount === 1) return Promise.resolve(jsonResponse({memberships: []}));
|
|
262
|
+
|
|
263
|
+
workspaceRefreshStarted();
|
|
264
|
+
return new Promise<Response>((_, reject) => {
|
|
265
|
+
request.signal.addEventListener('abort', () => reject(request.signal.reason));
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
configureApiClient({fetchImpl});
|
|
269
|
+
|
|
270
|
+
function LogoutDuringRefreshProbe() {
|
|
271
|
+
const auth = useAuthState();
|
|
272
|
+
const logout = useLogoutAuth();
|
|
273
|
+
const refresh = useRefreshAuth();
|
|
274
|
+
const didRefresh = useRef(false);
|
|
275
|
+
|
|
276
|
+
useEffect(() => {
|
|
277
|
+
if (auth.status !== 'authenticated' || didRefresh.current) return;
|
|
278
|
+
didRefresh.current = true;
|
|
279
|
+
void refresh();
|
|
280
|
+
}, [auth.status, refresh]);
|
|
281
|
+
|
|
282
|
+
return (
|
|
283
|
+
<div>
|
|
284
|
+
<span data-testid="status">{auth.status}</span>
|
|
285
|
+
<button type="button" onClick={() => void logout.mutateAsync()}>
|
|
286
|
+
Logout
|
|
287
|
+
</button>
|
|
288
|
+
</div>
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
render(
|
|
293
|
+
<AuthProvider>
|
|
294
|
+
<LogoutDuringRefreshProbe />
|
|
295
|
+
</AuthProvider>,
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
await waitFor(() => expect(workspaceRefreshStarted).toHaveBeenCalledTimes(1));
|
|
299
|
+
screen.getByRole('button', {name: 'Logout'}).click();
|
|
300
|
+
|
|
301
|
+
await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('guest'));
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test('does not render cached private data after a user switch', async () => {
|
|
305
|
+
const queryClient = new QueryClient();
|
|
306
|
+
const cancelQueries = vi.spyOn(queryClient, 'cancelQueries');
|
|
307
|
+
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
308
|
+
const url = (input as Request).url;
|
|
309
|
+
if (url.endsWith('/auth/login'))
|
|
310
|
+
return Promise.resolve(jsonResponse({token: 'other-token', user: otherUser}));
|
|
311
|
+
if (url.endsWith('/workspaces')) return Promise.resolve(jsonResponse({memberships: []}));
|
|
312
|
+
return Promise.resolve(jsonResponse({token: 'access-token', user}));
|
|
313
|
+
});
|
|
314
|
+
configureApiClient({fetchImpl});
|
|
315
|
+
queryClient.setQueryData(['private-data'], 'User A private data');
|
|
316
|
+
|
|
317
|
+
render(
|
|
318
|
+
<AuthProvider queryClient={queryClient}>
|
|
319
|
+
<PrivateDataProbe />
|
|
320
|
+
</AuthProvider>,
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
await waitFor(() =>
|
|
324
|
+
expect(screen.getByTestId('private-data')).toHaveTextContent('User A private data'),
|
|
325
|
+
);
|
|
326
|
+
screen.getByRole('button', {name: 'Switch user'}).click();
|
|
327
|
+
|
|
328
|
+
await waitFor(() =>
|
|
329
|
+
expect(screen.getByTestId('private-data')).not.toHaveTextContent('User A private data'),
|
|
330
|
+
);
|
|
331
|
+
expect(cancelQueries).toHaveBeenCalledTimes(1);
|
|
332
|
+
expect(queryClient.getQueryData(['private-data'])).toBeUndefined();
|
|
163
333
|
});
|
|
164
334
|
});
|
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
import {Icon} from '@shipfox/react-ui/icon';
|
|
2
|
-
import {Header, Text} from '@shipfox/react-ui/typography';
|
|
3
|
-
import type {PropsWithChildren, ReactNode} from 'react';
|
|
2
|
+
import {Header, type HeaderProps, Text} from '@shipfox/react-ui/typography';
|
|
3
|
+
import type {PropsWithChildren, ReactNode, Ref} from 'react';
|
|
4
4
|
|
|
5
|
-
interface AuthShellProps {
|
|
5
|
+
export interface AuthShellProps {
|
|
6
6
|
title: string;
|
|
7
7
|
description: string;
|
|
8
8
|
children: ReactNode;
|
|
9
9
|
className?: string;
|
|
10
|
+
headingProps?: Omit<HeaderProps, 'children' | 'id'> | undefined;
|
|
11
|
+
headingRef?: Ref<HTMLHeadingElement> | undefined;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
|
-
export function AuthShell({
|
|
14
|
+
export function AuthShell({
|
|
15
|
+
title,
|
|
16
|
+
description,
|
|
17
|
+
children,
|
|
18
|
+
className,
|
|
19
|
+
headingProps,
|
|
20
|
+
headingRef,
|
|
21
|
+
}: AuthShellProps) {
|
|
13
22
|
return (
|
|
14
23
|
<main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background-subtle-base px-24 py-32 max-[520px]:items-start max-[520px]:px-20 max-[520px]:pb-32 max-[520px]:pt-56">
|
|
15
24
|
<div
|
|
@@ -34,7 +43,13 @@ export function AuthShell({title, description, children, className}: AuthShellPr
|
|
|
34
43
|
<Icon name="shipfox" className="size-42 text-background-highlight-interactive" />
|
|
35
44
|
</div>
|
|
36
45
|
<div className="flex min-w-[128px] flex-col items-center gap-4 text-center">
|
|
37
|
-
<Header
|
|
46
|
+
<Header
|
|
47
|
+
id="auth-title"
|
|
48
|
+
variant="h1"
|
|
49
|
+
tabIndex={-1}
|
|
50
|
+
{...headingProps}
|
|
51
|
+
{...(headingRef ? ({ref: headingRef} as HeaderProps) : {})}
|
|
52
|
+
>
|
|
38
53
|
{title}
|
|
39
54
|
</Header>
|
|
40
55
|
<Text size="sm" className="text-foreground-neutral-subtle">
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import {act, fireEvent, render, screen} from '@testing-library/react';
|
|
2
|
+
import {createRef} from 'react';
|
|
3
|
+
import {EmailCodeVerification} from './email-code-verification.js';
|
|
4
|
+
|
|
5
|
+
function submitVerificationCode() {
|
|
6
|
+
const form = screen.getByLabelText('Verification code').closest('form');
|
|
7
|
+
if (!form) throw new Error('Verification code input must be inside a form');
|
|
8
|
+
fireEvent.submit(form);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('EmailCodeVerification', () => {
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
vi.useRealTimers();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('submits only an eight-digit numeric code and focuses its heading', () => {
|
|
17
|
+
const onVerify = vi.fn();
|
|
18
|
+
const headingRef = createRef<HTMLHeadingElement>();
|
|
19
|
+
|
|
20
|
+
render(
|
|
21
|
+
<EmailCodeVerification
|
|
22
|
+
destination="person@example.com"
|
|
23
|
+
headingRef={headingRef}
|
|
24
|
+
onResend={vi.fn()}
|
|
25
|
+
onUseAnotherEmail={vi.fn()}
|
|
26
|
+
onVerify={onVerify}
|
|
27
|
+
/>,
|
|
28
|
+
);
|
|
29
|
+
fireEvent.change(screen.getByLabelText('Verification code'), {target: {value: '12a3456789'}});
|
|
30
|
+
submitVerificationCode();
|
|
31
|
+
|
|
32
|
+
expect(screen.getByLabelText('Verification code')).toHaveValue('12345678');
|
|
33
|
+
expect(onVerify).toHaveBeenCalledWith('12345678');
|
|
34
|
+
expect(headingRef.current).toHaveFocus();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('shows an accessible validation error for a short code', () => {
|
|
38
|
+
render(
|
|
39
|
+
<EmailCodeVerification
|
|
40
|
+
destination="person@example.com"
|
|
41
|
+
onResend={vi.fn()}
|
|
42
|
+
onUseAnotherEmail={vi.fn()}
|
|
43
|
+
onVerify={vi.fn()}
|
|
44
|
+
/>,
|
|
45
|
+
);
|
|
46
|
+
submitVerificationCode();
|
|
47
|
+
|
|
48
|
+
expect(screen.getByText('Enter the eight-digit verification code.')).toBeInTheDocument();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('validates the code on blur', () => {
|
|
52
|
+
render(
|
|
53
|
+
<EmailCodeVerification
|
|
54
|
+
destination="person@example.com"
|
|
55
|
+
onResend={vi.fn()}
|
|
56
|
+
onUseAnotherEmail={vi.fn()}
|
|
57
|
+
onVerify={vi.fn()}
|
|
58
|
+
/>,
|
|
59
|
+
);
|
|
60
|
+
fireEvent.blur(screen.getByLabelText('Verification code'));
|
|
61
|
+
|
|
62
|
+
expect(screen.getByText('Enter the eight-digit verification code.')).toBeInTheDocument();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('enforces the resend cooldown and resumes when it expires', async () => {
|
|
66
|
+
vi.useFakeTimers({toFake: ['Date', 'setInterval', 'clearInterval']});
|
|
67
|
+
const onResend = vi.fn();
|
|
68
|
+
const nextResendAvailableAt = new Date(Date.now() + 2_000).toISOString();
|
|
69
|
+
|
|
70
|
+
render(
|
|
71
|
+
<EmailCodeVerification
|
|
72
|
+
destination="person@example.com"
|
|
73
|
+
nextResendAvailableAt={nextResendAvailableAt}
|
|
74
|
+
onResend={onResend}
|
|
75
|
+
onUseAnotherEmail={vi.fn()}
|
|
76
|
+
onVerify={vi.fn()}
|
|
77
|
+
/>,
|
|
78
|
+
);
|
|
79
|
+
fireEvent.click(screen.getByRole('button', {name: 'Resend in 2s'}));
|
|
80
|
+
|
|
81
|
+
expect(onResend).not.toHaveBeenCalled();
|
|
82
|
+
await act(async () => {
|
|
83
|
+
await vi.advanceTimersByTimeAsync(2_000);
|
|
84
|
+
});
|
|
85
|
+
fireEvent.click(screen.getByRole('button', {name: 'Resend verification email'}));
|
|
86
|
+
|
|
87
|
+
expect(onResend).toHaveBeenCalledOnce();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('rounds a sub-second cooldown remainder up to the next whole second', () => {
|
|
91
|
+
vi.useFakeTimers({toFake: ['Date', 'setInterval', 'clearInterval']});
|
|
92
|
+
const nextResendAvailableAt = new Date(Date.now() + 1_001).toISOString();
|
|
93
|
+
|
|
94
|
+
render(
|
|
95
|
+
<EmailCodeVerification
|
|
96
|
+
destination="person@example.com"
|
|
97
|
+
nextResendAvailableAt={nextResendAvailableAt}
|
|
98
|
+
onResend={vi.fn()}
|
|
99
|
+
onUseAnotherEmail={vi.fn()}
|
|
100
|
+
onVerify={vi.fn()}
|
|
101
|
+
/>,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
expect(screen.getByRole('button', {name: 'Resend in 2s'})).toBeInTheDocument();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('ignores a malformed nextResendAvailableAt or expiresAt timestamp', () => {
|
|
108
|
+
render(
|
|
109
|
+
<EmailCodeVerification
|
|
110
|
+
destination="person@example.com"
|
|
111
|
+
expiresAt="not-a-date"
|
|
112
|
+
nextResendAvailableAt="not-a-date"
|
|
113
|
+
onResend={vi.fn()}
|
|
114
|
+
onUseAnotherEmail={vi.fn()}
|
|
115
|
+
onVerify={vi.fn()}
|
|
116
|
+
/>,
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
expect(screen.getByRole('button', {name: 'Resend verification email'})).toBeInTheDocument();
|
|
120
|
+
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('disables verification after the caller-provided expiry timestamp', () => {
|
|
124
|
+
render(
|
|
125
|
+
<EmailCodeVerification
|
|
126
|
+
destination="person@example.com"
|
|
127
|
+
expiresAt={new Date(Date.now() - 1_000).toISOString()}
|
|
128
|
+
onResend={vi.fn()}
|
|
129
|
+
onUseAnotherEmail={vi.fn()}
|
|
130
|
+
onVerify={vi.fn()}
|
|
131
|
+
/>,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
expect(screen.getByRole('alert')).toHaveTextContent('This verification code has expired.');
|
|
135
|
+
expect(screen.getByRole('button', {name: 'Verify email'})).toBeDisabled();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('renders caller-owned errors, pending states, and another-email action', () => {
|
|
139
|
+
const onUseAnotherEmail = vi.fn();
|
|
140
|
+
|
|
141
|
+
render(
|
|
142
|
+
<EmailCodeVerification
|
|
143
|
+
destination="person@example.com"
|
|
144
|
+
error="That code is no longer valid."
|
|
145
|
+
isResending
|
|
146
|
+
isVerifying
|
|
147
|
+
onResend={vi.fn()}
|
|
148
|
+
onUseAnotherEmail={onUseAnotherEmail}
|
|
149
|
+
onVerify={vi.fn()}
|
|
150
|
+
/>,
|
|
151
|
+
);
|
|
152
|
+
fireEvent.click(screen.getByRole('button', {name: 'Use another email'}));
|
|
153
|
+
|
|
154
|
+
expect(screen.getByRole('alert')).toHaveTextContent('That code is no longer valid.');
|
|
155
|
+
expect(screen.getByRole('button', {name: 'Sending email...'})).toBeInTheDocument();
|
|
156
|
+
expect(screen.getByRole('button', {name: 'Use another email'})).toBeDisabled();
|
|
157
|
+
expect(onUseAnotherEmail).not.toHaveBeenCalled();
|
|
158
|
+
});
|
|
159
|
+
});
|