@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +57 -0
- package/dist/components/auth-guard.d.ts.map +1 -1
- package/dist/components/auth-guard.js +4 -4
- package/dist/components/auth-guard.js.map +1 -1
- package/dist/components/redirect-target.d.ts +7 -0
- package/dist/components/redirect-target.d.ts.map +1 -1
- package/dist/components/redirect-target.js +35 -4
- package/dist/components/redirect-target.js.map +1 -1
- 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/core/auth.d.ts +57 -0
- package/dist/core/auth.d.ts.map +1 -0
- package/dist/core/auth.js +3 -0
- package/dist/core/auth.js.map +1 -0
- package/dist/hooks/api/auth-mapper.d.ts +2 -17
- package/dist/hooks/api/auth-mapper.d.ts.map +1 -1
- package/dist/hooks/api/auth-mapper.js.map +1 -1
- package/dist/hooks/api/login-auth.d.ts +3 -6
- package/dist/hooks/api/login-auth.d.ts.map +1 -1
- package/dist/hooks/api/login-auth.js +3 -3
- package/dist/hooks/api/login-auth.js.map +1 -1
- package/dist/hooks/api/logout-auth.js +3 -3
- package/dist/hooks/api/logout-auth.js.map +1 -1
- package/dist/hooks/api/password-reset-auth.d.ts +5 -10
- package/dist/hooks/api/password-reset-auth.d.ts.map +1 -1
- package/dist/hooks/api/password-reset-auth.js +10 -7
- package/dist/hooks/api/password-reset-auth.js.map +1 -1
- package/dist/hooks/api/refresh-auth.d.ts +1 -1
- package/dist/hooks/api/refresh-auth.d.ts.map +1 -1
- package/dist/hooks/api/refresh-auth.js +1 -1
- package/dist/hooks/api/refresh-auth.js.map +1 -1
- package/dist/hooks/api/signup-auth.d.ts +2 -6
- package/dist/hooks/api/signup-auth.d.ts.map +1 -1
- package/dist/hooks/api/signup-auth.js +9 -2
- package/dist/hooks/api/signup-auth.js.map +1 -1
- package/dist/hooks/api/verify-email-auth.d.ts +4 -10
- package/dist/hooks/api/verify-email-auth.d.ts.map +1 -1
- package/dist/hooks/api/verify-email-auth.js +16 -6
- package/dist/hooks/api/verify-email-auth.js.map +1 -1
- package/dist/hooks/api/workspace-auth.d.ts +5 -7
- package/dist/hooks/api/workspace-auth.d.ts.map +1 -1
- package/dist/hooks/api/workspace-auth.js +6 -4
- package/dist/hooks/api/workspace-auth.js.map +1 -1
- package/dist/hooks/api/workspace-mapper.d.ts +1 -5
- package/dist/hooks/api/workspace-mapper.d.ts.map +1 -1
- package/dist/hooks/api/workspace-mapper.js.map +1 -1
- package/dist/pages/login-page.d.ts.map +1 -1
- package/dist/pages/login-page.js +4 -5
- package/dist/pages/login-page.js.map +1 -1
- package/dist/pages/logout-page.d.ts.map +1 -1
- package/dist/pages/logout-page.js +8 -18
- package/dist/pages/logout-page.js.map +1 -1
- package/dist/pages/password-reset-page.d.ts.map +1 -1
- package/dist/pages/password-reset-page.js +8 -7
- package/dist/pages/password-reset-page.js.map +1 -1
- package/dist/pages/signup-page.d.ts.map +1 -1
- package/dist/pages/signup-page.js +16 -10
- package/dist/pages/signup-page.js.map +1 -1
- package/dist/pages/workspace-onboarding-page.d.ts.map +1 -1
- package/dist/pages/workspace-onboarding-page.js +6 -3
- 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/routes/inputs.d.ts +9 -0
- package/dist/routes/inputs.d.ts.map +1 -0
- package/dist/routes/inputs.js +17 -0
- package/dist/routes/inputs.js.map +1 -0
- package/dist/routes/login.d.ts +2 -0
- package/dist/routes/login.d.ts.map +1 -1
- package/dist/routes/login.js +2 -0
- package/dist/routes/login.js.map +1 -1
- package/dist/routes/logout.d.ts +2 -0
- package/dist/routes/logout.d.ts.map +1 -1
- package/dist/routes/logout.js +2 -0
- package/dist/routes/logout.js.map +1 -1
- package/dist/routes/reset.d.ts +2 -0
- package/dist/routes/reset.d.ts.map +1 -1
- package/dist/routes/reset.js +2 -0
- package/dist/routes/reset.js.map +1 -1
- package/dist/routes/signup.d.ts +2 -0
- package/dist/routes/signup.d.ts.map +1 -1
- package/dist/routes/signup.js +2 -0
- package/dist/routes/signup.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +11 -12
- package/src/components/auth-guard.tsx +4 -2
- package/src/components/auth-provider.test.tsx +132 -19
- package/src/components/redirect-target.test.ts +32 -1
- package/src/components/redirect-target.ts +41 -4
- package/src/components/workspace-switcher.tsx +3 -2
- package/src/core/auth.ts +55 -0
- package/src/hooks/api/auth-mapper.ts +2 -12
- package/src/hooks/api/login-auth.ts +5 -4
- package/src/hooks/api/logout-auth.ts +3 -3
- package/src/hooks/api/password-reset-auth.test.ts +2 -2
- package/src/hooks/api/password-reset-auth.ts +11 -11
- package/src/hooks/api/refresh-auth.ts +1 -0
- package/src/hooks/api/signup-auth.ts +9 -3
- package/src/hooks/api/verify-email-auth.ts +20 -10
- package/src/hooks/api/workspace-auth.ts +10 -5
- package/src/hooks/api/workspace-mapper.ts +1 -6
- package/src/pages/login-page.tsx +4 -3
- package/src/pages/logout-page.test.tsx +75 -0
- package/src/pages/logout-page.tsx +9 -13
- package/src/pages/password-reset-page.tsx +5 -5
- package/src/pages/signup-page.tsx +14 -8
- package/src/pages/workspace-onboarding-page.tsx +6 -3
- package/src/routes/index.tsx +5 -3
- package/src/routes/inputs.test.ts +17 -0
- package/src/routes/inputs.ts +21 -0
- package/src/routes/login.tsx +2 -0
- package/src/routes/logout.tsx +2 -1
- package/src/routes/reset.tsx +2 -0
- package/src/routes/signup.tsx +2 -0
- package/src/state/last-workspace.test.ts +21 -28
- package/tsconfig.build.tsbuildinfo +1 -1
- 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": "
|
|
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": "
|
|
39
|
-
"@shipfox/client-api": "
|
|
40
|
-
"@shipfox/
|
|
41
|
-
"@shipfox/client-
|
|
42
|
-
"@shipfox/
|
|
43
|
-
"@shipfox/client-ui": "
|
|
44
|
-
"@shipfox/react-ui": "0.3.
|
|
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
|
|
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 =
|
|
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
|
|
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(() =>
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/core/auth.ts
ADDED
|
@@ -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
|
-
|
|
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 {
|
|
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(
|
|
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 {
|
|
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
|
|
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',
|
|
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
|
-
|
|
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(
|
|
12
|
-
await
|
|
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(
|
|
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,12 +1,18 @@
|
|
|
1
|
-
import {
|
|
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(
|
|
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
|
}
|