@shipfox/client-auth 6.0.1 → 6.0.3

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 (42) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +20 -0
  3. package/dist/components/redirect-context.d.ts.map +1 -1
  4. package/dist/components/redirect-context.js +6 -9
  5. package/dist/components/redirect-context.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 +46 -12
  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/hooks/api/login-auth.js +1 -1
  14. package/dist/hooks/api/login-auth.js.map +1 -1
  15. package/dist/hooks/api/logout-auth.js +1 -1
  16. package/dist/hooks/api/logout-auth.js.map +1 -1
  17. package/dist/hooks/api/password-reset-auth.js +1 -1
  18. package/dist/hooks/api/password-reset-auth.js.map +1 -1
  19. package/dist/hooks/api/verify-email-auth.js +1 -1
  20. package/dist/hooks/api/verify-email-auth.js.map +1 -1
  21. package/dist/pages/workspace-onboarding-page.d.ts.map +1 -1
  22. package/dist/pages/workspace-onboarding-page.js +4 -1
  23. package/dist/pages/workspace-onboarding-page.js.map +1 -1
  24. package/dist/routes/index.d.ts.map +1 -1
  25. package/dist/routes/index.js +4 -3
  26. package/dist/routes/index.js.map +1 -1
  27. package/dist/tsconfig.test.tsbuildinfo +1 -1
  28. package/package.json +7 -7
  29. package/src/components/auth-provider.test.tsx +132 -19
  30. package/src/components/redirect-context.test.ts +13 -0
  31. package/src/components/redirect-context.ts +5 -8
  32. package/src/components/redirect-target.test.ts +47 -2
  33. package/src/components/redirect-target.ts +54 -12
  34. package/src/components/workspace-switcher.tsx +3 -2
  35. package/src/hooks/api/login-auth.ts +1 -1
  36. package/src/hooks/api/logout-auth.ts +1 -1
  37. package/src/hooks/api/password-reset-auth.ts +1 -1
  38. package/src/hooks/api/verify-email-auth.ts +1 -1
  39. package/src/pages/workspace-onboarding-page.tsx +4 -1
  40. package/src/routes/index.tsx +5 -3
  41. package/src/state/last-workspace.test.ts +21 -28
  42. package/tsconfig.build.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/client-auth",
3
3
  "license": "MIT",
4
- "version": "6.0.1",
4
+ "version": "6.0.3",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -31,13 +31,13 @@
31
31
  "dependencies": {
32
32
  "@swc/helpers": "^0.5.17",
33
33
  "@tanstack/react-form": "^1.32.0",
34
- "@shipfox/api-auth-dto": "9.0.1",
35
- "@shipfox/api-workspaces-dto": "9.0.1",
34
+ "@shipfox/api-auth-dto": "9.0.2",
35
+ "@shipfox/api-workspaces-dto": "9.0.2",
36
36
  "@shipfox/client-api": "6.0.1",
37
- "@shipfox/client-invitations": "6.0.1",
38
- "@shipfox/client-shell": "6.0.1",
39
- "@shipfox/client-ui": "6.0.1",
40
- "@shipfox/react-ui": "0.3.6"
37
+ "@shipfox/client-invitations": "6.0.2",
38
+ "@shipfox/client-shell": "6.0.2",
39
+ "@shipfox/client-ui": "6.0.2",
40
+ "@shipfox/react-ui": "0.3.7"
41
41
  },
42
42
  "peerDependencies": {
43
43
  "@tanstack/react-query": "^5.101.0",
@@ -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(() =>
@@ -22,12 +22,25 @@ describe('parseRedirectContext', () => {
22
22
  expect(context).toEqual({invitationToken: 'raw-invitation-token'});
23
23
  });
24
24
 
25
+ test.each([
26
+ ['/%2569nvitations/accept?token=double-encoded-token', 'double-encoded-token'],
27
+ ['/%252569nvitations/accept?token=triple-encoded-token', 'triple-encoded-token'],
28
+ ])('separates an invitation token from a deeply encoded path: %s', (redirect, token) => {
29
+ const context = parseRedirectContext(redirect);
30
+
31
+ expect(context).toEqual({invitationToken: token});
32
+ expect(context.returnTo).toBeUndefined();
33
+ });
34
+
25
35
  test.each([
26
36
  'https://attacker.example',
27
37
  '//attacker.example',
28
38
  '/auth/login',
29
39
  '/%61uth/login',
40
+ '/%2561uth/login',
41
+ '/%252561uth/login',
30
42
  '/%E0%80%80',
43
+ '/safe/%25252e%25252e/auth/login',
31
44
  ])('rejects malformed or unsafe redirect %s', (redirect) => {
32
45
  const context = parseRedirectContext(redirect);
33
46
 
@@ -1,4 +1,4 @@
1
- import {sanitizeRedirectPath} from './redirect-target.js';
1
+ import {isAuthPath, resolveRedirectPath} from './redirect-target.js';
2
2
 
3
3
  const INVITATION_ACCEPT_PATH = '/invitations/accept';
4
4
 
@@ -13,14 +13,11 @@ export interface RedirectContext {
13
13
  * short-lived invitation flow instead of forwarding it through generic redirects.
14
14
  */
15
15
  export function parseRedirectContext(value: unknown): RedirectContext {
16
- const redirect = sanitizeRedirectPath(value);
17
- if (!redirect) return {};
16
+ const resolved = resolveRedirectPath(value);
17
+ if (!resolved || isAuthPath(resolved.pathname)) return {};
18
18
 
19
- const decoded = decodeURIComponent(redirect);
20
- const [path, queryString = ''] = decoded.split('?', 2);
21
- if (path !== INVITATION_ACCEPT_PATH) return {returnTo: redirect};
19
+ if (resolved.pathname !== INVITATION_ACCEPT_PATH) return {returnTo: resolved.redirect};
22
20
 
23
- const params = new URLSearchParams(queryString);
24
- const invitationToken = params.get('token');
21
+ const invitationToken = resolved.target.searchParams.get('token');
25
22
  return invitationToken ? {invitationToken} : {};
26
23
  }
@@ -1,5 +1,13 @@
1
1
  import {sanitizeLogoutRedirectPath, sanitizeRedirectPath} from './redirect-target.js';
2
2
 
3
+ function encodeRepeatedly(value: string, times: number): string {
4
+ let encoded = value;
5
+ for (let iteration = 0; iteration < times; iteration += 1) {
6
+ encoded = encodeURIComponent(encoded);
7
+ }
8
+ return encoded;
9
+ }
10
+
3
11
  describe('sanitizeRedirectPath', () => {
4
12
  describe.each([
5
13
  ['simple absolute path', '/foo'],
@@ -41,8 +49,12 @@ describe('sanitizeRedirectPath', () => {
41
49
  });
42
50
 
43
51
  describe('decode-then-check defenses', () => {
44
- test('rejects percent-encoded /auth/* path', () => {
45
- const result = sanitizeRedirectPath('/%61uth/login');
52
+ test.each([
53
+ ['single-encoded', '/%61uth/login'],
54
+ ['double-encoded', '/%2561uth/login'],
55
+ ['triple-encoded', '/%252561uth/login'],
56
+ ])('rejects %s /auth/* path', (_label, input) => {
57
+ const result = sanitizeRedirectPath(input);
46
58
 
47
59
  expect(result).toBeUndefined();
48
60
  });
@@ -64,6 +76,30 @@ describe('sanitizeRedirectPath', () => {
64
76
 
65
77
  expect(result).toBeUndefined();
66
78
  });
79
+
80
+ test('fails closed when the path exceeds the decode iteration cap', () => {
81
+ const deeplyEncodedAuthPath = `/${encodeRepeatedly('%61', 10)}uth/login`;
82
+
83
+ expect(sanitizeRedirectPath(deeplyEncodedAuthPath)).toBeUndefined();
84
+ });
85
+
86
+ test('rejects malformed percent-encoding that only surfaces after the first decode', () => {
87
+ const result = sanitizeRedirectPath('/%25E0%2580%2580');
88
+
89
+ expect(result).toBeUndefined();
90
+ });
91
+
92
+ test('rejects an /auth/* path disguised behind encoded dot segments', () => {
93
+ const result = sanitizeRedirectPath('/safe/%25252e%25252e/auth/login');
94
+
95
+ expect(result).toBeUndefined();
96
+ });
97
+
98
+ test('rejects a protocol-relative URL revealed only after multiple decodes', () => {
99
+ const result = sanitizeRedirectPath('/%25252fevil.com');
100
+
101
+ expect(result).toBeUndefined();
102
+ });
67
103
  });
68
104
  });
69
105
 
@@ -90,7 +126,16 @@ describe('sanitizeLogoutRedirectPath', () => {
90
126
  ['login route with a query', '/auth/login?redirect=/workspaces/abc'],
91
127
  ['raw invitation token', '/invitations/accept?token=sf_i_raw-token'],
92
128
  ['raw invitation token with trailing slash', '/invitations/accept/?token=sf_i_raw-token'],
129
+ ['double-encoded auth route', '/%2561uth/login'],
130
+ ['triple-encoded auth route', '/%252561uth/login'],
131
+ ['double-encoded invitation token', '/%2569nvitations/accept?token=sf_i_raw-token'],
132
+ ['triple-encoded invitation token', '/%252569nvitations/accept?token=sf_i_raw-token'],
93
133
  ['malformed percent encoding', '/%E0%80%80'],
134
+ [
135
+ 'invitation token disguised behind encoded dot segments',
136
+ '/safe/%25252e%25252e/invitations/accept?token=sf_i_raw-token',
137
+ ],
138
+ ['auth route disguised behind encoded dot segments', '/safe/%25252e%25252e/auth/login'],
94
139
  ])('falls back for %s', (_label, input) => {
95
140
  test('returns login without forwarding unsafe state', () => {
96
141
  expect(sanitizeLogoutRedirectPath(input)).toBe('/auth/login');
@@ -3,8 +3,38 @@ const LOGIN_PATH = '/auth/login';
3
3
  const INVITATION_ACCEPT_PATH = '/invitations/accept';
4
4
  const DEFAULT_LOGOUT_REDIRECT = LOGIN_PATH;
5
5
  const TRAILING_SLASHES = /\/+$/;
6
+ const MAX_PATH_DECODE_ITERATIONS = 10;
6
7
 
7
- function resolveRedirectPath(value: unknown): URL | undefined {
8
+ export interface ResolvedRedirectPath {
9
+ pathname: string;
10
+ redirect: string;
11
+ target: URL;
12
+ }
13
+
14
+ // Repeated decoding can reveal `.`/`..` segments or a `//host` prefix that were
15
+ // hidden behind extra encoding layers (e.g. `%25252e%25252e` -> `..`). Those only
16
+ // get collapsed and re-checked against the origin by feeding the fully-decoded,
17
+ // percent-free string back through the URL parser; without this, a multiply
18
+ // encoded `/safe/../auth/login` or `/safe/../invitations/accept?token=...`
19
+ // evades the pathname checks below even though this loop "sees" the real path.
20
+ function decodePathToFixedPoint(pathname: string): string | undefined {
21
+ let current = pathname;
22
+
23
+ for (let iteration = 0; iteration < MAX_PATH_DECODE_ITERATIONS; iteration += 1) {
24
+ let decoded: string;
25
+ try {
26
+ decoded = decodeURIComponent(current);
27
+ } catch {
28
+ return undefined;
29
+ }
30
+ if (decoded === current) return current;
31
+ current = decoded;
32
+ }
33
+
34
+ return undefined;
35
+ }
36
+
37
+ export function resolveRedirectPath(value: unknown): ResolvedRedirectPath | undefined {
8
38
  if (typeof value !== 'string' || !value.startsWith('/')) return undefined;
9
39
  let decoded: string;
10
40
  try {
@@ -19,27 +49,36 @@ function resolveRedirectPath(value: unknown): URL | undefined {
19
49
  return undefined;
20
50
  }
21
51
  if (target.origin !== REDIRECT_ORIGIN) return undefined;
22
- return target;
52
+ const decodedPathname = decodePathToFixedPoint(target.pathname);
53
+ if (decodedPathname === undefined) return undefined;
54
+ let canonical: URL;
55
+ try {
56
+ canonical = new URL(decodedPathname, REDIRECT_ORIGIN);
57
+ } catch {
58
+ return undefined;
59
+ }
60
+ if (canonical.origin !== REDIRECT_ORIGIN) return undefined;
61
+ return {pathname: canonical.pathname, redirect: formatRedirectPath(target), target};
23
62
  }
24
63
 
25
64
  function formatRedirectPath(target: URL): string {
26
65
  return `${target.pathname}${target.search}${target.hash}`;
27
66
  }
28
67
 
29
- function isAuthPath(pathname: string): boolean {
68
+ export function isAuthPath(pathname: string): boolean {
30
69
  return pathname === '/auth' || pathname.startsWith('/auth/');
31
70
  }
32
71
 
33
72
  // Resolves and canonicalizes an internal path before returning it, so browser URL
34
73
  // parsing cannot turn a seemingly safe path into an external or auth route.
35
74
  export function sanitizeRedirectPath(value: unknown): string | undefined {
36
- const target = resolveRedirectPath(value);
37
- if (!target || isAuthPath(target.pathname)) return undefined;
38
- return formatRedirectPath(target);
75
+ const resolved = resolveRedirectPath(value);
76
+ if (!resolved || isAuthPath(resolved.pathname)) return undefined;
77
+ return resolved.redirect;
39
78
  }
40
79
 
41
- function containsInvitationToken(target: URL): boolean {
42
- const normalizedPathname = target.pathname.replace(TRAILING_SLASHES, '') || '/';
80
+ function containsInvitationToken(target: URL, pathname: string): boolean {
81
+ const normalizedPathname = pathname.replace(TRAILING_SLASHES, '') || '/';
43
82
  return normalizedPathname === INVITATION_ACCEPT_PATH && target.searchParams.has('token');
44
83
  }
45
84
 
@@ -50,10 +89,13 @@ function containsInvitationToken(target: URL): boolean {
50
89
  * survive this boundary. Invalid values fail closed to login.
51
90
  */
52
91
  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)) {
92
+ const resolved = resolveRedirectPath(value);
93
+ if (!resolved) return DEFAULT_LOGOUT_REDIRECT;
94
+ if (
95
+ isAuthPath(resolved.pathname) ||
96
+ containsInvitationToken(resolved.target, resolved.pathname)
97
+ ) {
56
98
  return DEFAULT_LOGOUT_REDIRECT;
57
99
  }
58
- return formatRedirectPath(target);
100
+ return resolved.redirect;
59
101
  }
@@ -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
  }
@@ -18,6 +18,6 @@ export function useLoginAuth() {
18
18
 
19
19
  return useMutation({
20
20
  mutationFn: loginAuth,
21
- onSuccess: enterAuthenticated,
21
+ onSuccess: (session) => enterAuthenticated(session),
22
22
  });
23
23
  }
@@ -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
  }
@@ -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
  }
@@ -47,6 +47,6 @@ export function useVerifyEmailAuth() {
47
47
 
48
48
  return useMutation({
49
49
  mutationFn: verifyEmailAuth,
50
- onSuccess: enterAuthenticated,
50
+ onSuccess: (session) => enterAuthenticated(session),
51
51
  });
52
52
  }
@@ -12,7 +12,8 @@ import {useNavigate} from '@tanstack/react-router';
12
12
  import {useSetAtom} from 'jotai';
13
13
  import {useState} from 'react';
14
14
  import {useCreateWorkspaceAuth} from '#hooks/api/workspace-auth.js';
15
- import {lastWorkspaceIdAtom} from '#state/last-workspace.js';
15
+ import {useAuthState} from '#hooks/use-auth-state.js';
16
+ import {lastWorkspaceIdAtom, rememberLastWorkspaceId} from '#state/last-workspace.js';
16
17
  import {workspaceOnboardingErrorToFormError} from './form-errors.js';
17
18
 
18
19
  const previewMetrics = [
@@ -34,6 +35,7 @@ const previewBars = [
34
35
 
35
36
  export function WorkspaceOnboardingPage() {
36
37
  const createWorkspace = useCreateWorkspaceAuth();
38
+ const {user} = useAuthState();
37
39
  const navigate = useNavigate();
38
40
  const setLastWorkspaceId = useSetAtom(lastWorkspaceIdAtom);
39
41
  const [formError, setFormError] = useState<string | undefined>();
@@ -50,6 +52,7 @@ export function WorkspaceOnboardingPage() {
50
52
  // future visits to `/` land on it.
51
53
  try {
52
54
  setLastWorkspaceId(created.id);
55
+ if (user?.id) rememberLastWorkspaceId(user.id, created.id);
53
56
  } catch {
54
57
  // localStorage may throw in private browsing or quota-exceeded.
55
58
  }
@@ -10,9 +10,11 @@ export default defineRoute({
10
10
  if (!auth.isAuthenticated) throw redirect({to: '/auth/login'});
11
11
  const [first, ...rest] = auth.workspaces;
12
12
  if (!first) throw redirect({to: '/setup/workspaces/new'});
13
- const target =
14
- [first, ...rest].find((workspace) => workspace.id === getLastWorkspaceId()) ?? first;
15
- throw redirect({to: '/workspaces/$wid', params: {wid: target.id}});
13
+ const principalId = auth.user?.id;
14
+ const target = principalId
15
+ ? [first, ...rest].find((workspace) => workspace.id === getLastWorkspaceId(principalId))
16
+ : undefined;
17
+ throw redirect({to: '/workspaces/$wid', params: {wid: (target ?? first).id}});
16
18
  },
17
19
  component: FullPageLoader,
18
20
  });