@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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +14 -0
- package/dist/components/workspace-switcher.d.ts.map +1 -1
- package/dist/components/workspace-switcher.js +3 -2
- package/dist/components/workspace-switcher.js.map +1 -1
- package/dist/hooks/api/login-auth.js +1 -1
- package/dist/hooks/api/login-auth.js.map +1 -1
- package/dist/hooks/api/logout-auth.js +1 -1
- package/dist/hooks/api/logout-auth.js.map +1 -1
- package/dist/hooks/api/password-reset-auth.js +1 -1
- package/dist/hooks/api/password-reset-auth.js.map +1 -1
- package/dist/hooks/api/verify-email-auth.js +1 -1
- package/dist/hooks/api/verify-email-auth.js.map +1 -1
- package/dist/pages/workspace-onboarding-page.d.ts.map +1 -1
- package/dist/pages/workspace-onboarding-page.js +4 -1
- package/dist/pages/workspace-onboarding-page.js.map +1 -1
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/index.js +4 -3
- package/dist/routes/index.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +7 -7
- package/src/components/auth-provider.test.tsx +132 -19
- package/src/components/workspace-switcher.tsx +3 -2
- package/src/hooks/api/login-auth.ts +1 -1
- package/src/hooks/api/logout-auth.ts +1 -1
- package/src/hooks/api/password-reset-auth.ts +1 -1
- package/src/hooks/api/verify-email-auth.ts +1 -1
- package/src/pages/workspace-onboarding-page.tsx +4 -1
- package/src/routes/index.tsx +5 -3
- package/src/state/last-workspace.test.ts +21 -28
- 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.
|
|
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.
|
|
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.
|
|
38
|
-
"@shipfox/client-shell": "6.0.
|
|
39
|
-
"@shipfox/
|
|
40
|
-
"@shipfox/
|
|
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
|
|
191
|
+
const queryClient = useQueryClient();
|
|
192
|
+
const didSeed = useRef(false);
|
|
166
193
|
|
|
167
194
|
useEffect(() => {
|
|
168
|
-
if (auth.status !== 'authenticated' ||
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
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
|
}
|
|
@@ -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 {
|
|
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
|
}
|
package/src/routes/index.tsx
CHANGED
|
@@ -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
|
|
14
|
-
|
|
15
|
-
|
|
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('
|
|
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('
|
|
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('
|
|
49
|
-
|
|
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(
|
|
61
|
-
|
|
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
|
});
|