@shipfox/client-auth 3.0.1 → 5.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.
Files changed (116) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +32 -0
  3. package/dist/components/email-code-verification.d.ts +20 -0
  4. package/dist/components/email-code-verification.d.ts.map +1 -0
  5. package/dist/components/email-code-verification.js +136 -0
  6. package/dist/components/email-code-verification.js.map +1 -0
  7. package/dist/components/redirect-context.d.ts +11 -0
  8. package/dist/components/redirect-context.d.ts.map +1 -0
  9. package/dist/components/redirect-context.js +22 -0
  10. package/dist/components/redirect-context.js.map +1 -0
  11. package/dist/components/redirect-target.d.ts.map +1 -1
  12. package/dist/components/redirect-target.js +13 -12
  13. package/dist/components/redirect-target.js.map +1 -1
  14. package/dist/components/workspace-switcher.stories.d.ts +1 -1
  15. package/dist/continuation.d.ts +4 -0
  16. package/dist/continuation.d.ts.map +1 -0
  17. package/dist/continuation.js +5 -0
  18. package/dist/continuation.js.map +1 -0
  19. package/dist/hooks/api/auth-mapper.d.ts +21 -0
  20. package/dist/hooks/api/auth-mapper.d.ts.map +1 -0
  21. package/dist/hooks/api/auth-mapper.js +25 -0
  22. package/dist/hooks/api/auth-mapper.js.map +1 -0
  23. package/dist/hooks/api/login-auth.d.ts +3 -25
  24. package/dist/hooks/api/login-auth.d.ts.map +1 -1
  25. package/dist/hooks/api/login-auth.js +9 -30
  26. package/dist/hooks/api/login-auth.js.map +1 -1
  27. package/dist/hooks/api/logout-auth.d.ts.map +1 -1
  28. package/dist/hooks/api/logout-auth.js +4 -14
  29. package/dist/hooks/api/logout-auth.js.map +1 -1
  30. package/dist/hooks/api/password-reset-auth.d.ts +3 -25
  31. package/dist/hooks/api/password-reset-auth.d.ts.map +1 -1
  32. package/dist/hooks/api/password-reset-auth.js +9 -25
  33. package/dist/hooks/api/password-reset-auth.js.map +1 -1
  34. package/dist/hooks/api/signup-auth.d.ts +1 -25
  35. package/dist/hooks/api/signup-auth.d.ts.map +1 -1
  36. package/dist/hooks/api/signup-auth.js +5 -2
  37. package/dist/hooks/api/signup-auth.js.map +1 -1
  38. package/dist/hooks/api/verify-email-auth.d.ts +1 -12
  39. package/dist/hooks/api/verify-email-auth.d.ts.map +1 -1
  40. package/dist/hooks/api/verify-email-auth.js +10 -26
  41. package/dist/hooks/api/verify-email-auth.js.map +1 -1
  42. package/dist/hooks/api/workspace-auth.d.ts +3 -17
  43. package/dist/hooks/api/workspace-auth.d.ts.map +1 -1
  44. package/dist/hooks/api/workspace-auth.js +5 -2
  45. package/dist/hooks/api/workspace-auth.js.map +1 -1
  46. package/dist/hooks/api/workspace-mapper.d.ts +8 -0
  47. package/dist/hooks/api/workspace-mapper.d.ts.map +1 -0
  48. package/dist/hooks/api/workspace-mapper.js +9 -0
  49. package/dist/hooks/api/workspace-mapper.js.map +1 -0
  50. package/dist/index.d.ts +0 -1
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +0 -1
  53. package/dist/index.js.map +1 -1
  54. package/dist/pages/invitation-context.d.ts +9 -19
  55. package/dist/pages/invitation-context.d.ts.map +1 -1
  56. package/dist/pages/invitation-context.js +7 -46
  57. package/dist/pages/invitation-context.js.map +1 -1
  58. package/dist/pages/login-page.js +2 -2
  59. package/dist/pages/login-page.js.map +1 -1
  60. package/dist/pages/logout-page.js +1 -1
  61. package/dist/pages/logout-page.js.map +1 -1
  62. package/dist/pages/password-reset-page.d.ts.map +1 -1
  63. package/dist/pages/password-reset-page.js +1 -4
  64. package/dist/pages/password-reset-page.js.map +1 -1
  65. package/dist/pages/signup-page.d.ts.map +1 -1
  66. package/dist/pages/signup-page.js +58 -152
  67. package/dist/pages/signup-page.js.map +1 -1
  68. package/dist/state/auth.d.ts +1 -1
  69. package/dist/state/auth.d.ts.map +1 -1
  70. package/dist/state/auth.js +1 -1
  71. package/dist/state/auth.js.map +1 -1
  72. package/dist/tsconfig.test.tsbuildinfo +1 -1
  73. package/package.json +10 -39
  74. package/src/components/auth-provider.test.tsx +173 -3
  75. package/src/components/email-code-verification.test.tsx +159 -0
  76. package/src/components/email-code-verification.tsx +177 -0
  77. package/src/components/redirect-context.test.ts +51 -0
  78. package/src/components/redirect-context.ts +26 -0
  79. package/src/components/redirect-target.test.ts +8 -0
  80. package/src/components/redirect-target.ts +13 -12
  81. package/src/continuation.test.ts +15 -0
  82. package/src/continuation.ts +6 -0
  83. package/src/hooks/api/auth-mapper.test.ts +77 -0
  84. package/src/hooks/api/auth-mapper.ts +39 -0
  85. package/src/hooks/api/login-auth.test.ts +1 -1
  86. package/src/hooks/api/login-auth.ts +12 -31
  87. package/src/hooks/api/logout-auth.ts +4 -10
  88. package/src/hooks/api/password-reset-auth.test.ts +1 -1
  89. package/src/hooks/api/password-reset-auth.ts +19 -32
  90. package/src/hooks/api/signup-auth.ts +8 -3
  91. package/src/hooks/api/verify-email-auth.ts +21 -34
  92. package/src/hooks/api/workspace-auth.ts +8 -3
  93. package/src/hooks/api/workspace-mapper.test.ts +21 -0
  94. package/src/hooks/api/workspace-mapper.ts +11 -0
  95. package/src/index.ts +0 -1
  96. package/src/pages/invitation-context.ts +10 -51
  97. package/src/pages/login-page.test.tsx +1 -1
  98. package/src/pages/login-page.tsx +2 -2
  99. package/src/pages/logout-page.tsx +1 -1
  100. package/src/pages/password-reset-page.tsx +1 -4
  101. package/src/pages/signup-page.test.tsx +1 -1
  102. package/src/pages/signup-page.tsx +41 -137
  103. package/src/state/auth.ts +1 -0
  104. package/src/state/last-workspace.test.ts +16 -0
  105. package/tsconfig.build.tsbuildinfo +1 -1
  106. package/dist/components/auth-shell.d.ts +0 -11
  107. package/dist/components/auth-shell.d.ts.map +0 -1
  108. package/dist/components/auth-shell.js +0 -63
  109. package/dist/components/auth-shell.js.map +0 -1
  110. package/dist/pages/email-verification-resend-model.d.ts +0 -6
  111. package/dist/pages/email-verification-resend-model.d.ts.map +0 -1
  112. package/dist/pages/email-verification-resend-model.js +0 -11
  113. package/dist/pages/email-verification-resend-model.js.map +0 -1
  114. package/src/components/auth-shell.tsx +0 -53
  115. package/src/pages/email-verification-resend-model.test.ts +0 -42
  116. 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": "3.0.1",
4
+ "version": "5.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,11 +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": "6.0.0",
38
+ "@shipfox/api-auth-dto": "7.1.0",
39
+ "@shipfox/client-api": "4.0.0",
35
40
  "@shipfox/api-workspaces-dto": "6.0.0",
36
- "@shipfox/client-api": "1.0.0",
37
- "@shipfox/client-shell": "3.0.1",
38
- "@shipfox/client-ui": "3.0.1",
41
+ "@shipfox/client-invitations": "5.0.0",
42
+ "@shipfox/client-shell": "5.0.0",
43
+ "@shipfox/client-ui": "4.0.0",
39
44
  "@shipfox/react-ui": "0.3.5"
40
45
  },
41
46
  "peerDependencies": {
@@ -45,40 +50,6 @@
45
50
  "react": "^19.0.0",
46
51
  "react-dom": "^19.0.0"
47
52
  },
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/biome": "1.8.2",
76
- "@shipfox/client-test-setup": "0.0.3",
77
- "@shipfox/swc": "1.2.6",
78
- "@shipfox/ts-config": "1.3.8",
79
- "@shipfox/typescript": "1.1.7",
80
- "@shipfox/vitest": "1.2.3"
81
- },
82
53
  "scripts": {
83
54
  "build": "shipfox-swc",
84
55
  "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.fn().mockResolvedValue(jsonResponse({token: 'access-token', user}));
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
  });
@@ -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
+ });
@@ -0,0 +1,177 @@
1
+ import {AuthShell} from '@shipfox/client-shell/runtime';
2
+ import {Button} from '@shipfox/react-ui/button';
3
+ import {Callout} from '@shipfox/react-ui/callout';
4
+ import {FormField, FormFieldInput} from '@shipfox/react-ui/form-field';
5
+ import type {HeaderProps} from '@shipfox/react-ui/typography';
6
+ import type {FormEvent, ReactNode, Ref} from 'react';
7
+ import {useCallback, useEffect, useRef, useState} from 'react';
8
+
9
+ const EIGHT_DIGIT_CODE_RE = /^\d{8}$/u;
10
+
11
+ export interface EmailCodeVerificationProps {
12
+ destination: string;
13
+ expiresAt?: string | undefined;
14
+ nextResendAvailableAt?: string | undefined;
15
+ isResending?: boolean;
16
+ isVerifying?: boolean;
17
+ error?: string | undefined;
18
+ title?: string;
19
+ description?: string;
20
+ children?: ReactNode;
21
+ headingProps?: Omit<HeaderProps, 'children' | 'id'> | undefined;
22
+ headingRef?: Ref<HTMLHeadingElement> | undefined;
23
+ onResend: () => void | Promise<void>;
24
+ onUseAnotherEmail: () => void;
25
+ onVerify: (code: string) => void | Promise<void>;
26
+ }
27
+
28
+ function timestamp(value: string | undefined): number | undefined {
29
+ if (!value) return undefined;
30
+ const parsed = Date.parse(value);
31
+ return Number.isNaN(parsed) ? undefined : parsed;
32
+ }
33
+
34
+ export function EmailCodeVerification({
35
+ destination,
36
+ expiresAt,
37
+ nextResendAvailableAt,
38
+ isResending = false,
39
+ isVerifying = false,
40
+ error,
41
+ title = 'Check your email',
42
+ description = `We sent an eight-digit verification code to ${destination}.`,
43
+ children,
44
+ headingProps,
45
+ headingRef,
46
+ onResend,
47
+ onUseAnotherEmail,
48
+ onVerify,
49
+ }: EmailCodeVerificationProps) {
50
+ const [code, setCode] = useState('');
51
+ const [now, setNow] = useState(() => Date.now());
52
+ const [codeError, setCodeError] = useState<string>();
53
+ const localHeadingRef = useRef<HTMLHeadingElement>(null);
54
+ const resendAvailableAt = timestamp(nextResendAvailableAt);
55
+ const expiration = timestamp(expiresAt);
56
+ const resendRemainingSeconds = resendAvailableAt
57
+ ? Math.max(0, Math.ceil((resendAvailableAt - now) / 1_000))
58
+ : 0;
59
+ const isResendCoolingDown = resendRemainingSeconds > 0;
60
+ const isExpired = expiration !== undefined && expiration <= now;
61
+ const isPending = isResending || isVerifying;
62
+
63
+ useEffect(() => {
64
+ const isPending = (tickNow: number) =>
65
+ (resendAvailableAt !== undefined && resendAvailableAt > tickNow) ||
66
+ (expiration !== undefined && expiration > tickNow);
67
+
68
+ const tickNow = Date.now();
69
+ setNow(tickNow);
70
+ if (!isPending(tickNow)) return;
71
+
72
+ const interval = window.setInterval(() => {
73
+ const nextTick = Date.now();
74
+ setNow(nextTick);
75
+ if (!isPending(nextTick)) {
76
+ window.clearInterval(interval);
77
+ }
78
+ }, 1_000);
79
+ return () => window.clearInterval(interval);
80
+ }, [expiration, resendAvailableAt]);
81
+
82
+ useEffect(() => {
83
+ localHeadingRef.current?.focus();
84
+ }, []);
85
+
86
+ const setHeadingRef = useCallback(
87
+ (heading: HTMLHeadingElement | null) => {
88
+ localHeadingRef.current = heading;
89
+ if (typeof headingRef === 'function') {
90
+ headingRef(heading);
91
+ } else if (headingRef) {
92
+ headingRef.current = heading;
93
+ }
94
+ },
95
+ [headingRef],
96
+ );
97
+
98
+ function submit(event: FormEvent<HTMLFormElement>) {
99
+ event.preventDefault();
100
+ if (isExpired || isVerifying) return;
101
+ if (!validateCode()) return;
102
+ void onVerify(code);
103
+ }
104
+
105
+ function validateCode() {
106
+ const error = EIGHT_DIGIT_CODE_RE.test(code)
107
+ ? undefined
108
+ : 'Enter the eight-digit verification code.';
109
+ setCodeError(error);
110
+ return error === undefined;
111
+ }
112
+
113
+ return (
114
+ <AuthShell
115
+ title={title}
116
+ description={description}
117
+ headingProps={headingProps}
118
+ headingRef={setHeadingRef}
119
+ >
120
+ {error ? (
121
+ <Callout role="alert" type="error">
122
+ {error}
123
+ </Callout>
124
+ ) : null}
125
+ {isExpired ? (
126
+ <Callout role="alert" type="error">
127
+ This verification code has expired. Use another email to try again.
128
+ </Callout>
129
+ ) : null}
130
+ <form className="flex flex-col gap-8" noValidate onSubmit={submit}>
131
+ <FormField label="Verification code" id="verification-code" error={codeError}>
132
+ <FormFieldInput
133
+ autoComplete="one-time-code"
134
+ inputMode="numeric"
135
+ maxLength={8}
136
+ pattern="[0-9]{8}"
137
+ value={code}
138
+ onChange={(event) => {
139
+ setCode(event.target.value.replace(/\D/gu, '').slice(0, 8));
140
+ setCodeError(undefined);
141
+ }}
142
+ onBlur={validateCode}
143
+ />
144
+ </FormField>
145
+ <Button className="w-full" type="submit" disabled={isExpired} isLoading={isVerifying}>
146
+ Verify email
147
+ </Button>
148
+ <Button
149
+ aria-disabled={isResendCoolingDown ? true : undefined}
150
+ className="w-full aria-disabled:cursor-not-allowed aria-disabled:opacity-70"
151
+ variant="secondary"
152
+ type="button"
153
+ isLoading={isResending}
154
+ onClick={() => {
155
+ if (!isResendCoolingDown && !isResending) void onResend();
156
+ }}
157
+ >
158
+ {isResending
159
+ ? 'Sending email...'
160
+ : isResendCoolingDown
161
+ ? `Resend in ${resendRemainingSeconds}s`
162
+ : 'Resend verification email'}
163
+ </Button>
164
+ </form>
165
+ <Button
166
+ className="w-full"
167
+ variant="transparent"
168
+ type="button"
169
+ disabled={isPending}
170
+ onClick={onUseAnotherEmail}
171
+ >
172
+ Use another email
173
+ </Button>
174
+ {children}
175
+ </AuthShell>
176
+ );
177
+ }