@shipfox/client-invitations 12.0.2 → 13.0.0
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 +24 -0
- package/dist/complete-acceptance.d.ts +3 -2
- package/dist/complete-acceptance.d.ts.map +1 -1
- package/dist/complete-acceptance.js +10 -9
- package/dist/complete-acceptance.js.map +1 -1
- package/dist/pages/invitation-accept-page.d.ts.map +1 -1
- package/dist/pages/invitation-accept-page.js +76 -4
- package/dist/pages/invitation-accept-page.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/src/complete-acceptance.test.ts +13 -15
- package/src/complete-acceptance.ts +11 -9
- package/src/pages/invitation-accept-page.tsx +70 -4
- package/tsconfig.build.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/client-invitations",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "13.0.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -27,12 +27,12 @@
|
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"@swc/helpers": "^0.5.17",
|
|
29
29
|
"@tanstack/react-query": "^5.101.0",
|
|
30
|
-
"@shipfox/api-
|
|
30
|
+
"@shipfox/api-workspaces-dto": "12.0.0",
|
|
31
|
+
"@shipfox/client-shell": "13.0.0",
|
|
31
32
|
"@shipfox/client-api": "6.0.1",
|
|
32
|
-
"@shipfox/client-ui": "
|
|
33
|
-
"@shipfox/api-
|
|
34
|
-
"@shipfox/
|
|
35
|
-
"@shipfox/react-ui": "0.3.7"
|
|
33
|
+
"@shipfox/client-ui": "13.0.0",
|
|
34
|
+
"@shipfox/api-auth-dto": "12.0.0",
|
|
35
|
+
"@shipfox/react-ui": "0.4.0"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
38
|
"@tanstack/react-query": "^5.101.0",
|
|
@@ -22,34 +22,32 @@ describe('completeInvitationAcceptance', () => {
|
|
|
22
22
|
await completeInvitationAcceptance({
|
|
23
23
|
navigate,
|
|
24
24
|
refreshAuth,
|
|
25
|
+
userId: 'user-1',
|
|
25
26
|
workspaceId: 'workspace-1',
|
|
26
27
|
workspaceName: 'Acme',
|
|
27
28
|
});
|
|
28
29
|
|
|
29
30
|
expect(refreshAuth).toHaveBeenCalledTimes(1);
|
|
30
31
|
expect(toast.success).toHaveBeenCalledWith('You joined Acme.');
|
|
31
|
-
expect(navigate).toHaveBeenCalledWith({
|
|
32
|
-
params: {wid: 'workspace-1'},
|
|
33
|
-
to: '/workspaces/$wid',
|
|
34
|
-
});
|
|
32
|
+
expect(navigate).toHaveBeenCalledWith({to: '/'});
|
|
35
33
|
expect(calls).toEqual(['refreshAuth', 'navigate']);
|
|
36
34
|
});
|
|
37
35
|
|
|
38
|
-
it('
|
|
36
|
+
it('does not navigate through the stale root route when auth refresh fails', async () => {
|
|
39
37
|
const refreshAuth = vi.fn(() => Promise.reject(new Error('refresh failed')));
|
|
40
38
|
const navigate = vi.fn();
|
|
41
39
|
|
|
42
|
-
await
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
40
|
+
await expect(
|
|
41
|
+
completeInvitationAcceptance({
|
|
42
|
+
navigate,
|
|
43
|
+
refreshAuth,
|
|
44
|
+
userId: 'user-1',
|
|
45
|
+
workspaceId: 'workspace-1',
|
|
46
|
+
workspaceName: 'Acme',
|
|
47
|
+
}),
|
|
48
|
+
).resolves.toBe(false);
|
|
48
49
|
|
|
49
50
|
expect(toast.success).toHaveBeenCalledWith('You joined Acme.');
|
|
50
|
-
expect(navigate).
|
|
51
|
-
params: {wid: 'workspace-1'},
|
|
52
|
-
to: '/workspaces/$wid',
|
|
53
|
-
});
|
|
51
|
+
expect(navigate).not.toHaveBeenCalled();
|
|
54
52
|
});
|
|
55
53
|
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {rememberLastWorkspaceId} from '@shipfox/client-shell/runtime';
|
|
1
2
|
import {toast} from '@shipfox/react-ui/toast';
|
|
2
3
|
import type {NavigateOptions} from '@tanstack/react-router';
|
|
3
4
|
|
|
@@ -5,29 +6,30 @@ import type {NavigateOptions} from '@tanstack/react-router';
|
|
|
5
6
|
* Final step of every successful invitation accept path (existing-user match,
|
|
6
7
|
* signup-with-invitation success, login-then-accept). Refreshes the auth
|
|
7
8
|
* session so the JWT carries the new membership before navigating, then
|
|
8
|
-
* routes the user into the workspace home.
|
|
9
|
+
* routes the user into the workspace home when the refreshed session is ready.
|
|
9
10
|
*
|
|
10
11
|
* `refreshAuth` is passed in so this helper stays a plain function (no hook).
|
|
11
12
|
* Call sites construct it via `useRefreshAuth()` from `@shipfox/client-shell/runtime`.
|
|
12
13
|
*/
|
|
13
14
|
export async function completeInvitationAcceptance(params: {
|
|
15
|
+
userId: string;
|
|
14
16
|
workspaceId: string;
|
|
15
17
|
workspaceName: string;
|
|
16
18
|
refreshAuth: () => Promise<unknown>;
|
|
17
19
|
navigate: (opts: NavigateOptions) => Promise<void> | void;
|
|
18
|
-
}): Promise<
|
|
20
|
+
}): Promise<boolean> {
|
|
19
21
|
// Access tokens embed memberships at issue time, so refresh before AuthGuard
|
|
20
22
|
// reads the accepted workspace.
|
|
23
|
+
let refreshed = true;
|
|
21
24
|
try {
|
|
22
25
|
await params.refreshAuth();
|
|
23
26
|
} catch {
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
+
// The membership is real in the DB, but the root route cannot resolve the
|
|
28
|
+
// joined workspace until the auth workspace list has been refreshed.
|
|
29
|
+
refreshed = false;
|
|
27
30
|
}
|
|
31
|
+
rememberLastWorkspaceId(params.userId, params.workspaceId);
|
|
28
32
|
toast.success(`You joined ${params.workspaceName}.`);
|
|
29
|
-
await params.navigate({
|
|
30
|
-
|
|
31
|
-
params: {wid: params.workspaceId},
|
|
32
|
-
});
|
|
33
|
+
if (refreshed) await params.navigate({to: '/'});
|
|
34
|
+
return refreshed;
|
|
33
35
|
}
|
|
@@ -12,7 +12,7 @@ import {toast} from '@shipfox/react-ui/toast';
|
|
|
12
12
|
import {Text} from '@shipfox/react-ui/typography';
|
|
13
13
|
import {formatDate} from '@shipfox/react-ui/utils';
|
|
14
14
|
import {Link, useNavigate} from '@tanstack/react-router';
|
|
15
|
-
import {useCallback, useEffect, useRef} from 'react';
|
|
15
|
+
import {useCallback, useEffect, useRef, useState} from 'react';
|
|
16
16
|
import {completeInvitationAcceptance} from '#complete-acceptance.js';
|
|
17
17
|
import {useAcceptInvitation} from '#hooks/api/accept-invitation.js';
|
|
18
18
|
import {usePreviewInvitation} from '#hooks/api/preview-invitation.js';
|
|
@@ -30,8 +30,42 @@ export function InvitationAcceptPage() {
|
|
|
30
30
|
const preview = usePreviewInvitation(token);
|
|
31
31
|
const accept = useAcceptInvitation();
|
|
32
32
|
const hasKickedAccept = useRef(false);
|
|
33
|
+
const hasRetriedWorkspaceHydration = useRef(false);
|
|
34
|
+
const [authRefreshFailed, setAuthRefreshFailed] = useState(false);
|
|
35
|
+
const [pendingWorkspaceId, setPendingWorkspaceId] = useState<string>();
|
|
33
36
|
|
|
34
37
|
useEffect(() => {
|
|
38
|
+
if (!pendingWorkspaceId || auth.isLoading || !auth.isAuthenticated) return;
|
|
39
|
+
if (!auth.workspaces.some(({id}) => id === pendingWorkspaceId)) {
|
|
40
|
+
if (hasRetriedWorkspaceHydration.current) {
|
|
41
|
+
setPendingWorkspaceId(undefined);
|
|
42
|
+
setAuthRefreshFailed(true);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
hasRetriedWorkspaceHydration.current = true;
|
|
46
|
+
void refreshAuth().catch(() => {
|
|
47
|
+
setPendingWorkspaceId(undefined);
|
|
48
|
+
setAuthRefreshFailed(true);
|
|
49
|
+
});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
setPendingWorkspaceId(undefined);
|
|
54
|
+
void navigate({to: '/', replace: true});
|
|
55
|
+
}, [
|
|
56
|
+
auth.isAuthenticated,
|
|
57
|
+
auth.isLoading,
|
|
58
|
+
auth.workspaces,
|
|
59
|
+
navigate,
|
|
60
|
+
pendingWorkspaceId,
|
|
61
|
+
refreshAuth,
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
hasKickedAccept.current = false;
|
|
66
|
+
hasRetriedWorkspaceHydration.current = false;
|
|
67
|
+
setAuthRefreshFailed(false);
|
|
68
|
+
setPendingWorkspaceId(undefined);
|
|
35
69
|
if (!token) {
|
|
36
70
|
toast.error('This invitation link is missing a token.');
|
|
37
71
|
const timeout = window.setTimeout(() => {
|
|
@@ -44,14 +78,20 @@ export function InvitationAcceptPage() {
|
|
|
44
78
|
|
|
45
79
|
const completeAccept = useCallback(
|
|
46
80
|
async (workspaceId: string, workspaceName: string) => {
|
|
47
|
-
|
|
81
|
+
const userId = auth.user?.id;
|
|
82
|
+
if (!userId) throw new Error('Cannot complete invitation without an authenticated user.');
|
|
83
|
+
const refreshed = await completeInvitationAcceptance({
|
|
84
|
+
userId,
|
|
48
85
|
workspaceId,
|
|
49
86
|
workspaceName,
|
|
50
87
|
refreshAuth,
|
|
51
|
-
|
|
88
|
+
// Wait for the refreshed workspace list to reach the router context
|
|
89
|
+
// before resolving the root route.
|
|
90
|
+
navigate: () => setPendingWorkspaceId(workspaceId),
|
|
52
91
|
});
|
|
92
|
+
if (!refreshed) setAuthRefreshFailed(true);
|
|
53
93
|
},
|
|
54
|
-
[
|
|
94
|
+
[auth.user?.id, refreshAuth],
|
|
55
95
|
);
|
|
56
96
|
|
|
57
97
|
const runAccept = useCallback(
|
|
@@ -223,6 +263,32 @@ export function InvitationAcceptPage() {
|
|
|
223
263
|
);
|
|
224
264
|
}
|
|
225
265
|
|
|
266
|
+
if (authRefreshFailed) {
|
|
267
|
+
return (
|
|
268
|
+
<AuthShell title={data.workspaceName} description={inviterLine}>
|
|
269
|
+
<Callout role="alert" type="error">
|
|
270
|
+
You joined {data.workspaceName}, but we couldn't refresh your session. Retry to continue
|
|
271
|
+
to the workspace.
|
|
272
|
+
</Callout>
|
|
273
|
+
<Button
|
|
274
|
+
className="w-full"
|
|
275
|
+
onClick={async () => {
|
|
276
|
+
try {
|
|
277
|
+
await refreshAuth();
|
|
278
|
+
setAuthRefreshFailed(false);
|
|
279
|
+
hasRetriedWorkspaceHydration.current = false;
|
|
280
|
+
setPendingWorkspaceId(data.workspaceId);
|
|
281
|
+
} catch {
|
|
282
|
+
setAuthRefreshFailed(true);
|
|
283
|
+
}
|
|
284
|
+
}}
|
|
285
|
+
>
|
|
286
|
+
Retry
|
|
287
|
+
</Button>
|
|
288
|
+
</AuthShell>
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
226
292
|
// Authenticated + matches — auto-accept is in flight or about to render its
|
|
227
293
|
// result. Show either the pending state or the error state.
|
|
228
294
|
if (accept.isError) {
|