@shipfox/client-auth 5.0.0 → 6.0.2

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 (120) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +57 -0
  3. package/dist/components/auth-guard.d.ts.map +1 -1
  4. package/dist/components/auth-guard.js +4 -4
  5. package/dist/components/auth-guard.js.map +1 -1
  6. package/dist/components/redirect-target.d.ts +7 -0
  7. package/dist/components/redirect-target.d.ts.map +1 -1
  8. package/dist/components/redirect-target.js +35 -4
  9. package/dist/components/redirect-target.js.map +1 -1
  10. package/dist/components/workspace-switcher.d.ts.map +1 -1
  11. package/dist/components/workspace-switcher.js +3 -2
  12. package/dist/components/workspace-switcher.js.map +1 -1
  13. package/dist/core/auth.d.ts +57 -0
  14. package/dist/core/auth.d.ts.map +1 -0
  15. package/dist/core/auth.js +3 -0
  16. package/dist/core/auth.js.map +1 -0
  17. package/dist/hooks/api/auth-mapper.d.ts +2 -17
  18. package/dist/hooks/api/auth-mapper.d.ts.map +1 -1
  19. package/dist/hooks/api/auth-mapper.js.map +1 -1
  20. package/dist/hooks/api/login-auth.d.ts +3 -6
  21. package/dist/hooks/api/login-auth.d.ts.map +1 -1
  22. package/dist/hooks/api/login-auth.js +3 -3
  23. package/dist/hooks/api/login-auth.js.map +1 -1
  24. package/dist/hooks/api/logout-auth.js +3 -3
  25. package/dist/hooks/api/logout-auth.js.map +1 -1
  26. package/dist/hooks/api/password-reset-auth.d.ts +5 -10
  27. package/dist/hooks/api/password-reset-auth.d.ts.map +1 -1
  28. package/dist/hooks/api/password-reset-auth.js +10 -7
  29. package/dist/hooks/api/password-reset-auth.js.map +1 -1
  30. package/dist/hooks/api/refresh-auth.d.ts +1 -1
  31. package/dist/hooks/api/refresh-auth.d.ts.map +1 -1
  32. package/dist/hooks/api/refresh-auth.js +1 -1
  33. package/dist/hooks/api/refresh-auth.js.map +1 -1
  34. package/dist/hooks/api/signup-auth.d.ts +2 -6
  35. package/dist/hooks/api/signup-auth.d.ts.map +1 -1
  36. package/dist/hooks/api/signup-auth.js +9 -2
  37. package/dist/hooks/api/signup-auth.js.map +1 -1
  38. package/dist/hooks/api/verify-email-auth.d.ts +4 -10
  39. package/dist/hooks/api/verify-email-auth.d.ts.map +1 -1
  40. package/dist/hooks/api/verify-email-auth.js +16 -6
  41. package/dist/hooks/api/verify-email-auth.js.map +1 -1
  42. package/dist/hooks/api/workspace-auth.d.ts +5 -7
  43. package/dist/hooks/api/workspace-auth.d.ts.map +1 -1
  44. package/dist/hooks/api/workspace-auth.js +6 -4
  45. package/dist/hooks/api/workspace-auth.js.map +1 -1
  46. package/dist/hooks/api/workspace-mapper.d.ts +1 -5
  47. package/dist/hooks/api/workspace-mapper.d.ts.map +1 -1
  48. package/dist/hooks/api/workspace-mapper.js.map +1 -1
  49. package/dist/pages/login-page.d.ts.map +1 -1
  50. package/dist/pages/login-page.js +4 -5
  51. package/dist/pages/login-page.js.map +1 -1
  52. package/dist/pages/logout-page.d.ts.map +1 -1
  53. package/dist/pages/logout-page.js +8 -18
  54. package/dist/pages/logout-page.js.map +1 -1
  55. package/dist/pages/password-reset-page.d.ts.map +1 -1
  56. package/dist/pages/password-reset-page.js +8 -7
  57. package/dist/pages/password-reset-page.js.map +1 -1
  58. package/dist/pages/signup-page.d.ts.map +1 -1
  59. package/dist/pages/signup-page.js +16 -10
  60. package/dist/pages/signup-page.js.map +1 -1
  61. package/dist/pages/workspace-onboarding-page.d.ts.map +1 -1
  62. package/dist/pages/workspace-onboarding-page.js +6 -3
  63. package/dist/pages/workspace-onboarding-page.js.map +1 -1
  64. package/dist/routes/index.d.ts.map +1 -1
  65. package/dist/routes/index.js +4 -3
  66. package/dist/routes/index.js.map +1 -1
  67. package/dist/routes/inputs.d.ts +9 -0
  68. package/dist/routes/inputs.d.ts.map +1 -0
  69. package/dist/routes/inputs.js +17 -0
  70. package/dist/routes/inputs.js.map +1 -0
  71. package/dist/routes/login.d.ts +2 -0
  72. package/dist/routes/login.d.ts.map +1 -1
  73. package/dist/routes/login.js +2 -0
  74. package/dist/routes/login.js.map +1 -1
  75. package/dist/routes/logout.d.ts +2 -0
  76. package/dist/routes/logout.d.ts.map +1 -1
  77. package/dist/routes/logout.js +2 -0
  78. package/dist/routes/logout.js.map +1 -1
  79. package/dist/routes/reset.d.ts +2 -0
  80. package/dist/routes/reset.d.ts.map +1 -1
  81. package/dist/routes/reset.js +2 -0
  82. package/dist/routes/reset.js.map +1 -1
  83. package/dist/routes/signup.d.ts +2 -0
  84. package/dist/routes/signup.d.ts.map +1 -1
  85. package/dist/routes/signup.js +2 -0
  86. package/dist/routes/signup.js.map +1 -1
  87. package/dist/tsconfig.test.tsbuildinfo +1 -1
  88. package/package.json +11 -12
  89. package/src/components/auth-guard.tsx +4 -2
  90. package/src/components/auth-provider.test.tsx +132 -19
  91. package/src/components/redirect-target.test.ts +32 -1
  92. package/src/components/redirect-target.ts +41 -4
  93. package/src/components/workspace-switcher.tsx +3 -2
  94. package/src/core/auth.ts +55 -0
  95. package/src/hooks/api/auth-mapper.ts +2 -12
  96. package/src/hooks/api/login-auth.ts +5 -4
  97. package/src/hooks/api/logout-auth.ts +3 -3
  98. package/src/hooks/api/password-reset-auth.test.ts +2 -2
  99. package/src/hooks/api/password-reset-auth.ts +11 -11
  100. package/src/hooks/api/refresh-auth.ts +1 -0
  101. package/src/hooks/api/signup-auth.ts +9 -3
  102. package/src/hooks/api/verify-email-auth.ts +20 -10
  103. package/src/hooks/api/workspace-auth.ts +10 -5
  104. package/src/hooks/api/workspace-mapper.ts +1 -6
  105. package/src/pages/login-page.tsx +4 -3
  106. package/src/pages/logout-page.test.tsx +75 -0
  107. package/src/pages/logout-page.tsx +9 -13
  108. package/src/pages/password-reset-page.tsx +5 -5
  109. package/src/pages/signup-page.tsx +14 -8
  110. package/src/pages/workspace-onboarding-page.tsx +6 -3
  111. package/src/routes/index.tsx +5 -3
  112. package/src/routes/inputs.test.ts +17 -0
  113. package/src/routes/inputs.ts +21 -0
  114. package/src/routes/login.tsx +2 -0
  115. package/src/routes/logout.tsx +2 -1
  116. package/src/routes/reset.tsx +2 -0
  117. package/src/routes/signup.tsx +2 -0
  118. package/src/state/last-workspace.test.ts +21 -28
  119. package/tsconfig.build.tsbuildinfo +1 -1
  120. package/vercel.json +5 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/client-auth",
3
3
  "license": "MIT",
4
- "version": "5.0.0",
4
+ "version": "6.0.2",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -10,10 +10,6 @@
10
10
  "type": "module",
11
11
  "main": "dist/index.js",
12
12
  "types": "dist/index.d.ts",
13
- "imports": {
14
- "#*": "./dist/*",
15
- "#test/*": "./test/*"
16
- },
17
13
  "exports": {
18
14
  ".": {
19
15
  "types": "./dist/index.d.ts",
@@ -35,13 +31,13 @@
35
31
  "dependencies": {
36
32
  "@swc/helpers": "^0.5.17",
37
33
  "@tanstack/react-form": "^1.32.0",
38
- "@shipfox/api-auth-dto": "7.1.0",
39
- "@shipfox/client-api": "4.0.0",
40
- "@shipfox/api-workspaces-dto": "6.0.0",
41
- "@shipfox/client-invitations": "5.0.0",
42
- "@shipfox/client-shell": "5.0.0",
43
- "@shipfox/client-ui": "4.0.0",
44
- "@shipfox/react-ui": "0.3.5"
34
+ "@shipfox/api-auth-dto": "9.0.2",
35
+ "@shipfox/client-api": "6.0.1",
36
+ "@shipfox/client-invitations": "6.0.2",
37
+ "@shipfox/client-shell": "6.0.2",
38
+ "@shipfox/api-workspaces-dto": "9.0.2",
39
+ "@shipfox/client-ui": "6.0.2",
40
+ "@shipfox/react-ui": "0.3.7"
45
41
  },
46
42
  "peerDependencies": {
47
43
  "@tanstack/react-query": "^5.101.0",
@@ -50,6 +46,9 @@
50
46
  "react": "^19.0.0",
51
47
  "react-dom": "^19.0.0"
52
48
  },
49
+ "imports": {
50
+ "#*": "./dist/*"
51
+ },
53
52
  "scripts": {
54
53
  "build": "shipfox-swc",
55
54
  "check": "shipfox-biome-check",
@@ -1,8 +1,10 @@
1
+ import {useRouteSearch} from '@shipfox/client-shell/runtime';
1
2
  import {FullPageLoader} from '@shipfox/react-ui/loader';
2
- import {Navigate, useRouter, useSearch} from '@tanstack/react-router';
3
+ import {Navigate, useRouter} from '@tanstack/react-router';
3
4
  import {type PropsWithChildren, useEffect} from 'react';
4
5
  import {useAuthState} from '#hooks/use-auth-state.js';
5
6
  import {WorkspaceOnboardingPage} from '#pages/workspace-onboarding-page.js';
7
+ import {validateRedirectSearch} from '../routes/inputs.js';
6
8
  import {sanitizeRedirectPath} from './redirect-target.js';
7
9
 
8
10
  export function AuthGuard({children}: PropsWithChildren) {
@@ -21,7 +23,7 @@ export function AuthGuard({children}: PropsWithChildren) {
21
23
 
22
24
  export function GuestGuard({children}: PropsWithChildren) {
23
25
  const auth = useAuthState();
24
- const search = useSearch({strict: false}) as {redirect?: unknown};
26
+ const search = useRouteSearch(validateRedirectSearch);
25
27
  const router = useRouter();
26
28
  const target = sanitizeRedirectPath(search.redirect);
27
29
 
@@ -1,5 +1,5 @@
1
- import {configureApiClient} from '@shipfox/client-api';
2
- import {QueryClient, useQuery} from '@tanstack/react-query';
1
+ import {ApiError, configureApiClient} from '@shipfox/client-api';
2
+ import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query';
3
3
  import {render, screen, waitFor} from '@testing-library/react';
4
4
  import {useEffect, useRef} from 'react';
5
5
  import {useLoginAuth} from '#hooks/api/login-auth.js';
@@ -45,6 +45,8 @@ function StatusProbe() {
45
45
 
46
46
  function PrivateDataProbe() {
47
47
  const auth = useAuthState();
48
+ const queryClient = useQueryClient();
49
+ const seeded = useRef(false);
48
50
  const privateData = useQuery({
49
51
  queryKey: ['private-data'],
50
52
  queryFn: () => new Promise<string>(() => undefined),
@@ -52,6 +54,12 @@ function PrivateDataProbe() {
52
54
  });
53
55
  const login = useLoginAuth();
54
56
 
57
+ useEffect(() => {
58
+ if (!auth.isAuthenticated || seeded.current) return;
59
+ seeded.current = true;
60
+ queryClient.setQueryData(['private-data'], 'User A private data');
61
+ }, [auth.isAuthenticated, queryClient]);
62
+
55
63
  return (
56
64
  <div>
57
65
  <span data-testid="private-data">{privateData.data ?? 'loading'}</span>
@@ -114,6 +122,24 @@ describe('AuthProvider', () => {
114
122
  expect(queryClient.getQueryData(['private-data'])).toBeUndefined();
115
123
  });
116
124
 
125
+ test('clears a hydrated private cache before accepting the first principal', async () => {
126
+ const queryClient = new QueryClient();
127
+ const fetchImpl = vi
128
+ .fn()
129
+ .mockResolvedValue(jsonResponse({token: 'other-token', user: otherUser}));
130
+ configureApiClient({fetchImpl});
131
+ queryClient.setQueryData(['private-data'], 'previous principal private data');
132
+
133
+ render(
134
+ <AuthProvider queryClient={queryClient}>
135
+ <StatusProbe />
136
+ </AuthProvider>,
137
+ );
138
+
139
+ await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('authenticated'));
140
+ expect(queryClient.getQueryData(['private-data'])).toBeUndefined();
141
+ });
142
+
117
143
  test('keeps the current session when refresh fails with a server error', async () => {
118
144
  const fetchImpl = vi
119
145
  .fn()
@@ -156,21 +182,30 @@ describe('AuthProvider', () => {
156
182
  .fn()
157
183
  .mockImplementation(() => Promise.resolve(jsonResponse({token: 'access-token', user})));
158
184
  configureApiClient({fetchImpl});
159
- queryClient.setQueryData(['private-data'], 'current user private data');
160
185
  const onDone = vi.fn();
186
+ const onReady = vi.fn();
161
187
 
162
188
  function RefreshAfterBootProbe() {
163
189
  const auth = useAuthState();
164
190
  const refresh = useRefreshAuth();
165
- const didRefresh = useRef(false);
191
+ const queryClient = useQueryClient();
192
+ const didSeed = useRef(false);
166
193
 
167
194
  useEffect(() => {
168
- if (auth.status !== 'authenticated' || didRefresh.current) return;
169
- didRefresh.current = true;
170
- void refresh().then(onDone);
171
- }, [auth.status, refresh]);
195
+ if (auth.status !== 'authenticated' || didSeed.current) return;
196
+ didSeed.current = true;
197
+ queryClient.setQueryData(['private-data'], 'current user private data');
198
+ onReady();
199
+ }, [auth.status, queryClient]);
172
200
 
173
- return <StatusProbe />;
201
+ return (
202
+ <>
203
+ <StatusProbe />
204
+ <button type="button" onClick={() => void refresh().then(onDone)}>
205
+ Refresh
206
+ </button>
207
+ </>
208
+ );
174
209
  }
175
210
 
176
211
  render(
@@ -179,6 +214,9 @@ describe('AuthProvider', () => {
179
214
  </AuthProvider>,
180
215
  );
181
216
 
217
+ await waitFor(() => expect(onReady).toHaveBeenCalledTimes(1));
218
+ cancelQueries.mockClear();
219
+ screen.getByRole('button', {name: 'Refresh'}).click();
182
220
  await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1));
183
221
  expect(cancelQueries).not.toHaveBeenCalled();
184
222
  expect(queryClient.getQueryData(['private-data'])).toBe('current user private data');
@@ -211,6 +249,80 @@ describe('AuthProvider', () => {
211
249
  expect(fetchImpl).toHaveBeenCalledTimes(2);
212
250
  });
213
251
 
252
+ test('does not restore a stale refresh result after a principal transition', async () => {
253
+ let refreshRequestCount = 0;
254
+ let workspaceRequestCount = 0;
255
+ const initialWorkspaceRefresh = new Promise<Response>(() => undefined);
256
+ const onFreshRefresh = vi.fn();
257
+ const onRefreshError = vi.fn();
258
+ const fetchImpl = vi.fn((input: RequestInfo | URL) => {
259
+ const url = (input as Request).url;
260
+ if (url.endsWith('/auth/refresh')) {
261
+ refreshRequestCount += 1;
262
+ return refreshRequestCount === 1
263
+ ? Promise.resolve(jsonResponse({token: 'access-token', user}))
264
+ : Promise.resolve(jsonResponse({token: 'other-token-2', user: otherUser}));
265
+ }
266
+ if (url.endsWith('/auth/login'))
267
+ return Promise.resolve(jsonResponse({token: 'other-token', user: otherUser}));
268
+ if (url.endsWith('/workspaces')) {
269
+ workspaceRequestCount += 1;
270
+ return workspaceRequestCount === 1
271
+ ? initialWorkspaceRefresh
272
+ : Promise.resolve(jsonResponse({memberships: []}));
273
+ }
274
+ return Promise.resolve(jsonResponse({token: 'access-token', user}));
275
+ });
276
+ configureApiClient({fetchImpl});
277
+
278
+ function RefreshAndLoginProbe() {
279
+ const auth = useAuthState();
280
+ const login = useLoginAuth();
281
+ const refresh = useRefreshAuth();
282
+
283
+ useEffect(() => {
284
+ void refresh().catch(onRefreshError);
285
+ }, [refresh]);
286
+
287
+ return (
288
+ <div>
289
+ <span data-testid="email">{auth.user?.email ?? 'none'}</span>
290
+ <button
291
+ type="button"
292
+ onClick={() =>
293
+ void login.mutateAsync({email: 'other@example.com', password: 'password'})
294
+ }
295
+ >
296
+ Login as other user
297
+ </button>
298
+ <button type="button" onClick={() => void refresh().then(onFreshRefresh)}>
299
+ Refresh current principal
300
+ </button>
301
+ </div>
302
+ );
303
+ }
304
+
305
+ render(
306
+ <AuthProvider>
307
+ <RefreshAndLoginProbe />
308
+ </AuthProvider>,
309
+ );
310
+
311
+ await waitFor(() => expect(workspaceRequestCount).toBe(1));
312
+ screen.getByRole('button', {name: 'Login as other user'}).click();
313
+ await waitFor(() => expect(screen.getByTestId('email')).toHaveTextContent('other@example.com'));
314
+ await waitFor(() => expect(onRefreshError).toHaveBeenCalledTimes(1));
315
+ const refreshError = onRefreshError.mock.calls[0]?.[0];
316
+ expect(refreshError).toBeInstanceOf(ApiError);
317
+ expect(refreshError).toMatchObject({code: 'unauthorized', status: 401});
318
+
319
+ screen.getByRole('button', {name: 'Refresh current principal'}).click();
320
+ await waitFor(() => expect(onFreshRefresh).toHaveBeenCalledTimes(1));
321
+ expect(refreshRequestCount).toBe(2);
322
+
323
+ expect(screen.getByTestId('email')).toHaveTextContent('other@example.com');
324
+ });
325
+
214
326
  test('logout clears local state even when the API call fails', async () => {
215
327
  const queryClient = new QueryClient();
216
328
  const cancelQueries = vi.spyOn(queryClient, 'cancelQueries');
@@ -219,8 +331,16 @@ describe('AuthProvider', () => {
219
331
  .mockResolvedValueOnce(jsonResponse({token: 'access-token', user}))
220
332
  .mockRejectedValueOnce(new Error('offline'));
221
333
  configureApiClient({fetchImpl});
222
- queryClient.setQueryData(['private-data'], 'private data');
223
334
  const onQueryAbort = vi.fn();
335
+
336
+ render(
337
+ <AuthProvider queryClient={queryClient}>
338
+ <StatusProbe />
339
+ </AuthProvider>,
340
+ );
341
+
342
+ await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('authenticated'));
343
+ queryClient.setQueryData(['private-data'], 'private data');
224
344
  void queryClient
225
345
  .fetchQuery({
226
346
  queryKey: ['in-flight-private-data'],
@@ -233,14 +353,7 @@ describe('AuthProvider', () => {
233
353
  }),
234
354
  })
235
355
  .catch(() => undefined);
236
-
237
- render(
238
- <AuthProvider queryClient={queryClient}>
239
- <StatusProbe />
240
- </AuthProvider>,
241
- );
242
-
243
- await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('authenticated'));
356
+ cancelQueries.mockClear();
244
357
  screen.getByRole('button', {name: 'Logout'}).click();
245
358
 
246
359
  await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('guest'));
@@ -312,7 +425,6 @@ describe('AuthProvider', () => {
312
425
  return Promise.resolve(jsonResponse({token: 'access-token', user}));
313
426
  });
314
427
  configureApiClient({fetchImpl});
315
- queryClient.setQueryData(['private-data'], 'User A private data');
316
428
 
317
429
  render(
318
430
  <AuthProvider queryClient={queryClient}>
@@ -323,6 +435,7 @@ describe('AuthProvider', () => {
323
435
  await waitFor(() =>
324
436
  expect(screen.getByTestId('private-data')).toHaveTextContent('User A private data'),
325
437
  );
438
+ cancelQueries.mockClear();
326
439
  screen.getByRole('button', {name: 'Switch user'}).click();
327
440
 
328
441
  await waitFor(() =>
@@ -1,4 +1,4 @@
1
- import {sanitizeRedirectPath} from './redirect-target.js';
1
+ import {sanitizeLogoutRedirectPath, sanitizeRedirectPath} from './redirect-target.js';
2
2
 
3
3
  describe('sanitizeRedirectPath', () => {
4
4
  describe.each([
@@ -66,3 +66,34 @@ describe('sanitizeRedirectPath', () => {
66
66
  });
67
67
  });
68
68
  });
69
+
70
+ describe('sanitizeLogoutRedirectPath', () => {
71
+ describe.each([
72
+ ['explicit login fallback', '/auth/login', '/auth/login'],
73
+ ['same-origin workspace path', '/workspaces/abc', '/workspaces/abc'],
74
+ [
75
+ 'same-origin path with search and hash',
76
+ '/workspaces/abc?tab=runs#header',
77
+ '/workspaces/abc?tab=runs#header',
78
+ ],
79
+ ])('accepts %s', (_label, input, expected) => {
80
+ test('returns the safe destination', () => {
81
+ expect(sanitizeLogoutRedirectPath(input)).toBe(expected);
82
+ });
83
+ });
84
+
85
+ describe.each([
86
+ ['missing redirect', undefined],
87
+ ['external URL', 'https://attacker.example'],
88
+ ['protocol-relative URL', '//attacker.example'],
89
+ ['auth route other than login', '/auth/reset'],
90
+ ['login route with a query', '/auth/login?redirect=/workspaces/abc'],
91
+ ['raw invitation token', '/invitations/accept?token=sf_i_raw-token'],
92
+ ['raw invitation token with trailing slash', '/invitations/accept/?token=sf_i_raw-token'],
93
+ ['malformed percent encoding', '/%E0%80%80'],
94
+ ])('falls back for %s', (_label, input) => {
95
+ test('returns login without forwarding unsafe state', () => {
96
+ expect(sanitizeLogoutRedirectPath(input)).toBe('/auth/login');
97
+ });
98
+ });
99
+ });
@@ -1,8 +1,10 @@
1
1
  const REDIRECT_ORIGIN = 'https://shipfox-redirect.invalid';
2
+ const LOGIN_PATH = '/auth/login';
3
+ const INVITATION_ACCEPT_PATH = '/invitations/accept';
4
+ const DEFAULT_LOGOUT_REDIRECT = LOGIN_PATH;
5
+ const TRAILING_SLASHES = /\/+$/;
2
6
 
3
- // Resolves and canonicalizes an internal path before returning it, so browser URL
4
- // parsing cannot turn a seemingly safe path into an external or auth route.
5
- export function sanitizeRedirectPath(value: unknown): string | undefined {
7
+ function resolveRedirectPath(value: unknown): URL | undefined {
6
8
  if (typeof value !== 'string' || !value.startsWith('/')) return undefined;
7
9
  let decoded: string;
8
10
  try {
@@ -17,6 +19,41 @@ export function sanitizeRedirectPath(value: unknown): string | undefined {
17
19
  return undefined;
18
20
  }
19
21
  if (target.origin !== REDIRECT_ORIGIN) return undefined;
20
- if (target.pathname === '/auth' || target.pathname.startsWith('/auth/')) return undefined;
22
+ return target;
23
+ }
24
+
25
+ function formatRedirectPath(target: URL): string {
21
26
  return `${target.pathname}${target.search}${target.hash}`;
22
27
  }
28
+
29
+ function isAuthPath(pathname: string): boolean {
30
+ return pathname === '/auth' || pathname.startsWith('/auth/');
31
+ }
32
+
33
+ // Resolves and canonicalizes an internal path before returning it, so browser URL
34
+ // parsing cannot turn a seemingly safe path into an external or auth route.
35
+ export function sanitizeRedirectPath(value: unknown): string | undefined {
36
+ const target = resolveRedirectPath(value);
37
+ if (!target || isAuthPath(target.pathname)) return undefined;
38
+ return formatRedirectPath(target);
39
+ }
40
+
41
+ function containsInvitationToken(target: URL): boolean {
42
+ const normalizedPathname = target.pathname.replace(TRAILING_SLASHES, '') || '/';
43
+ return normalizedPathname === INVITATION_ACCEPT_PATH && target.searchParams.has('token');
44
+ }
45
+
46
+ /**
47
+ * Returns the destination used by the shared logout route.
48
+ *
49
+ * Login is the only auth destination allowed, and invitation tokens never
50
+ * survive this boundary. Invalid values fail closed to login.
51
+ */
52
+ export function sanitizeLogoutRedirectPath(value: unknown): string {
53
+ const target = resolveRedirectPath(value);
54
+ if (!target) return DEFAULT_LOGOUT_REDIRECT;
55
+ if (isAuthPath(target.pathname) || containsInvitationToken(target)) {
56
+ return DEFAULT_LOGOUT_REDIRECT;
57
+ }
58
+ return formatRedirectPath(target);
59
+ }
@@ -11,7 +11,7 @@ import {Icon} from '@shipfox/react-ui/icon';
11
11
  import {useNavigate} from '@tanstack/react-router';
12
12
  import {useSetAtom} from 'jotai';
13
13
  import {useAuthState} from '#hooks/use-auth-state.js';
14
- import {lastWorkspaceIdAtom} from '#state/last-workspace.js';
14
+ import {lastWorkspaceIdAtom, rememberLastWorkspaceId} from '#state/last-workspace.js';
15
15
 
16
16
  export interface WorkspaceSwitcherProps {
17
17
  activeWorkspaceId: string | undefined;
@@ -19,13 +19,14 @@ export interface WorkspaceSwitcherProps {
19
19
  }
20
20
 
21
21
  export function WorkspaceSwitcher({activeWorkspaceId, onSelect}: WorkspaceSwitcherProps) {
22
- const {workspaces} = useAuthState();
22
+ const {user, workspaces} = useAuthState();
23
23
  const navigate = useNavigate();
24
24
  const setLastWorkspaceId = useSetAtom(lastWorkspaceIdAtom);
25
25
 
26
26
  const handleSelect = (workspaceId: string) => {
27
27
  try {
28
28
  setLastWorkspaceId(workspaceId);
29
+ if (user?.id) rememberLastWorkspaceId(user.id, workspaceId);
29
30
  } catch {
30
31
  // localStorage may throw in private browsing or quota-exceeded; persistence is best-effort.
31
32
  }
@@ -0,0 +1,55 @@
1
+ export interface LoginCommand {
2
+ email: string;
3
+ password: string;
4
+ }
5
+
6
+ export interface SignupCommand extends LoginCommand {
7
+ name: string;
8
+ invitationToken?: string;
9
+ }
10
+
11
+ export interface PasswordResetRequestCommand {
12
+ email: string;
13
+ }
14
+
15
+ export interface PasswordResetConfirmCommand {
16
+ token: string;
17
+ newPassword: string;
18
+ }
19
+
20
+ export interface VerifyEmailCommand {
21
+ email: string;
22
+ challengeId: string;
23
+ code: string;
24
+ }
25
+
26
+ export interface ResendEmailVerificationCommand {
27
+ email: string;
28
+ challengeId: string;
29
+ }
30
+
31
+ export interface WorkspaceCreateCommand {
32
+ name: string;
33
+ }
34
+
35
+ export interface Workspace {
36
+ id: string;
37
+ name: string;
38
+ status: 'active' | 'suspended' | 'deleted';
39
+ }
40
+
41
+ export interface SignupResult {
42
+ user: {
43
+ id: string;
44
+ email: string;
45
+ name?: string;
46
+ emailVerifiedAt?: string;
47
+ };
48
+ emailChallenge?: {id: string; nextResendAvailableAt: string};
49
+ membership?: {id: string; userId: string; workspaceId: string};
50
+ acceptError?: {code: string; message: string};
51
+ }
52
+
53
+ export interface EmailVerificationResendResult {
54
+ nextResendAvailableAt: string;
55
+ }
@@ -1,19 +1,9 @@
1
1
  import type {SignupResponseDto} from '@shipfox/api-auth-dto';
2
- import {
3
- toAuthenticatedSession,
4
- toUserIdentity,
5
- type UserIdentity,
6
- } from '@shipfox/client-shell/runtime';
2
+ import {toAuthenticatedSession, toUserIdentity} from '@shipfox/client-shell/runtime';
3
+ import type {SignupResult} from '#core/auth.js';
7
4
 
8
5
  export {toAuthenticatedSession};
9
6
 
10
- export interface SignupResult {
11
- user: UserIdentity;
12
- emailChallenge?: {id: string; nextResendAvailableAt: string};
13
- membership?: {id: string; userId: string; workspaceId: string};
14
- acceptError?: {code: string; message: string};
15
- }
16
-
17
7
  export function toSignupResult(dto: SignupResponseDto): SignupResult {
18
8
  return {
19
9
  user: toUserIdentity(dto.user),
@@ -1,13 +1,14 @@
1
- import {type LoginBodyDto, loginResponseSchema} from '@shipfox/api-auth-dto';
1
+ import {loginResponseSchema} from '@shipfox/api-auth-dto';
2
2
  import {checkedApiRequest} from '@shipfox/client-api';
3
3
  import {useMutation} from '@tanstack/react-query';
4
+ import type {LoginCommand} from '#core/auth.js';
4
5
  import {useAuthTransition} from '#state/auth.js';
5
6
  import {toAuthenticatedSession} from './auth-mapper.js';
6
7
 
7
- export async function loginAuth(body: LoginBodyDto) {
8
+ export async function loginAuth(command: LoginCommand) {
8
9
  const response = await checkedApiRequest(loginResponseSchema, '/auth/login', {
9
10
  method: 'POST',
10
- body,
11
+ body: command,
11
12
  });
12
13
  return toAuthenticatedSession(response);
13
14
  }
@@ -17,6 +18,6 @@ export function useLoginAuth() {
17
18
 
18
19
  return useMutation({
19
20
  mutationFn: loginAuth,
20
- onSuccess: enterAuthenticated,
21
+ onSuccess: (session) => enterAuthenticated(session),
21
22
  });
22
23
  }
@@ -1,10 +1,10 @@
1
- import {apiRequest} from '@shipfox/client-api';
1
+ import {checkedApiRequest, emptyResponseSchema} from '@shipfox/client-api';
2
2
  import {useMutation} from '@tanstack/react-query';
3
3
  import {useAuthTransition} from '#state/auth.js';
4
4
 
5
5
  async function logoutAuth() {
6
6
  try {
7
- await apiRequest<void>('/auth/logout', {method: 'POST', body: {}});
7
+ await checkedApiRequest(emptyResponseSchema, '/auth/logout', {method: 'POST', body: {}});
8
8
  } catch {
9
9
  // Logout is best-effort: local session state must clear even if the API is offline.
10
10
  }
@@ -15,6 +15,6 @@ export function useLogoutAuth() {
15
15
 
16
16
  return useMutation({
17
17
  mutationFn: logoutAuth,
18
- onSettled: enterGuest,
18
+ onSettled: () => enterGuest(),
19
19
  });
20
20
  }
@@ -55,7 +55,7 @@ describe('confirmPasswordReset', () => {
55
55
  });
56
56
  });
57
57
  configureApiClient({fetchImpl});
58
- const body = {token: 'reset-token', new_password: 'new password is long'};
58
+ const body = {token: 'reset-token', newPassword: 'new password is long'};
59
59
 
60
60
  const result = await confirmPasswordReset(body);
61
61
 
@@ -63,6 +63,6 @@ describe('confirmPasswordReset', () => {
63
63
  expect(result.accessToken).toBe('reset-access-token');
64
64
  expect(request.url).toBe('https://api.example.test/auth/password-reset/confirm');
65
65
  expect(request.method).toBe('POST');
66
- expect(requestBody).toEqual(body);
66
+ expect(requestBody).toEqual({token: body.token, new_password: body.newPassword});
67
67
  });
68
68
  });
@@ -1,24 +1,24 @@
1
- import {
2
- type PasswordResetConfirmBodyDto,
3
- type PasswordResetRequestBodyDto,
4
- passwordResetConfirmResponseSchema,
5
- } from '@shipfox/api-auth-dto';
6
- import {apiRequest, checkedApiRequest} from '@shipfox/client-api';
1
+ import {passwordResetConfirmResponseSchema} from '@shipfox/api-auth-dto';
2
+ import {checkedApiRequest, emptyResponseSchema} from '@shipfox/client-api';
7
3
  import {useMutation} from '@tanstack/react-query';
4
+ import type {PasswordResetConfirmCommand, PasswordResetRequestCommand} from '#core/auth.js';
8
5
  import {useAuthTransition} from '#state/auth.js';
9
6
  import {toAuthenticatedSession} from './auth-mapper.js';
10
7
 
11
- export async function requestPasswordReset(body: PasswordResetRequestBodyDto) {
12
- await apiRequest<void>('/auth/password-reset', {method: 'POST', body});
8
+ export async function requestPasswordReset(command: PasswordResetRequestCommand) {
9
+ await checkedApiRequest(emptyResponseSchema, '/auth/password-reset', {
10
+ method: 'POST',
11
+ body: command,
12
+ });
13
13
  }
14
14
 
15
- export async function confirmPasswordReset(body: PasswordResetConfirmBodyDto) {
15
+ export async function confirmPasswordReset(command: PasswordResetConfirmCommand) {
16
16
  const response = await checkedApiRequest(
17
17
  passwordResetConfirmResponseSchema,
18
18
  '/auth/password-reset/confirm',
19
19
  {
20
20
  method: 'POST',
21
- body,
21
+ body: {token: command.token, new_password: command.newPassword},
22
22
  },
23
23
  );
24
24
  return toAuthenticatedSession(response);
@@ -33,6 +33,6 @@ export function useConfirmPasswordResetAuth() {
33
33
 
34
34
  return useMutation({
35
35
  mutationFn: confirmPasswordReset,
36
- onSuccess: enterAuthenticated,
36
+ onSuccess: (session) => enterAuthenticated(session),
37
37
  });
38
38
  }
@@ -1,5 +1,6 @@
1
1
  export {
2
2
  authRefreshQueryKey,
3
+ authRefreshQueryOptions,
3
4
  getAuthRefreshDelayMs,
4
5
  useRefreshAuth,
5
6
  } from '@shipfox/client-shell/runtime';
@@ -1,12 +1,18 @@
1
- import {type SignupBodyDto, signupResponseSchema} from '@shipfox/api-auth-dto';
1
+ import {signupResponseSchema} from '@shipfox/api-auth-dto';
2
2
  import {checkedApiRequest} from '@shipfox/client-api';
3
3
  import {useMutation} from '@tanstack/react-query';
4
+ import type {SignupCommand} from '#core/auth.js';
4
5
  import {toSignupResult} from './auth-mapper.js';
5
6
 
6
- async function signupAuth(body: SignupBodyDto) {
7
+ async function signupAuth(command: SignupCommand) {
7
8
  const response = await checkedApiRequest(signupResponseSchema, '/auth/signup', {
8
9
  method: 'POST',
9
- body,
10
+ body: {
11
+ email: command.email,
12
+ password: command.password,
13
+ name: command.name,
14
+ ...(command.invitationToken ? {invitation_token: command.invitationToken} : {}),
15
+ },
10
16
  });
11
17
  return toSignupResult(response);
12
18
  }