@shipfox/client-auth 6.0.1 → 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.
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.2",
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",
36
35
  "@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"
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"
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(() =>
@@ -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
  });
@@ -5,7 +5,7 @@ import {
5
5
  rememberLastWorkspaceId,
6
6
  } from './last-workspace.js';
7
7
 
8
- describe('lastWorkspaceIdAtom', () => {
8
+ describe('last workspace state', () => {
9
9
  beforeEach(() => {
10
10
  window.localStorage.clear();
11
11
  });
@@ -14,21 +14,13 @@ describe('lastWorkspaceIdAtom', () => {
14
14
  window.localStorage.clear();
15
15
  });
16
16
 
17
- test('initial value is undefined when storage is empty', () => {
18
- const store = createStore();
19
-
20
- expect(store.get(lastWorkspaceIdAtom)).toBeUndefined();
21
- });
22
-
23
- test('write persists to localStorage and round-trips through the atom', () => {
17
+ test('atom stores current UI selection without unscoped persistence', () => {
24
18
  const store = createStore();
25
19
 
26
20
  store.set(lastWorkspaceIdAtom, 'workspace-1');
27
21
 
28
- expect(JSON.parse(window.localStorage.getItem('shipfox.lastWorkspaceId') ?? 'null')).toBe(
29
- 'workspace-1',
30
- );
31
22
  expect(store.get(lastWorkspaceIdAtom)).toBe('workspace-1');
23
+ expect(window.localStorage.getItem('shipfox.lastWorkspaceId')).toBeNull();
32
24
  });
33
25
 
34
26
  test('subscribers receive updates when the atom is set', () => {
@@ -45,34 +37,35 @@ describe('lastWorkspaceIdAtom', () => {
45
37
  unsubscribe();
46
38
  });
47
39
 
48
- test('syncs the atom when another tab updates last workspace storage', () => {
49
- const store = createStore();
50
- const unsubscribe = store.sub(lastWorkspaceIdAtom, () => undefined);
51
- window.localStorage.setItem('shipfox.lastWorkspaceId', JSON.stringify('workspace-2'));
52
-
53
- window.dispatchEvent(
54
- new StorageEvent('storage', {
55
- key: 'shipfox.lastWorkspaceId',
56
- storageArea: window.localStorage,
57
- }),
58
- );
40
+ test('write persists to localStorage and can be read for the same principal', () => {
41
+ rememberLastWorkspaceId('principal-1', 'workspace-1');
59
42
 
60
- expect(store.get(lastWorkspaceIdAtom)).toBe('workspace-2');
61
- unsubscribe();
43
+ expect(
44
+ JSON.parse(
45
+ window.localStorage.getItem('shipfox.lastWorkspaceId.principal.principal-1') ?? 'null',
46
+ ),
47
+ ).toBe('workspace-1');
48
+ expect(getLastWorkspaceId('principal-1')).toBe('workspace-1');
62
49
  });
63
50
 
64
51
  test('rememberLastWorkspaceId persists a root redirect target', () => {
65
- rememberLastWorkspaceId('workspace-1');
52
+ rememberLastWorkspaceId('principal-1', 'workspace-1');
66
53
 
67
- const result = getLastWorkspaceId();
54
+ const result = getLastWorkspaceId('principal-1');
68
55
 
69
56
  expect(result).toBe('workspace-1');
70
57
  });
71
58
 
59
+ test("does not reuse a different principal's root redirect target", () => {
60
+ rememberLastWorkspaceId('principal-a', 'workspace-a');
61
+
62
+ expect(getLastWorkspaceId('principal-b')).toBeUndefined();
63
+ });
64
+
72
65
  test('getLastWorkspaceId ignores malformed storage', () => {
73
- window.localStorage.setItem('shipfox.lastWorkspaceId', '{');
66
+ window.localStorage.setItem('shipfox.lastWorkspaceId.principal.principal-1', '{');
74
67
 
75
- const result = getLastWorkspaceId();
68
+ const result = getLastWorkspaceId('principal-1');
76
69
 
77
70
  expect(result).toBeUndefined();
78
71
  });