@shipfox/client-invitations 12.0.2 → 14.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/client-invitations",
3
3
  "license": "MIT",
4
- "version": "12.0.2",
4
+ "version": "14.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-auth-dto": "10.2.0",
30
+ "@shipfox/api-auth-dto": "12.0.0",
31
+ "@shipfox/api-workspaces-dto": "12.0.0",
31
32
  "@shipfox/client-api": "6.0.1",
32
- "@shipfox/client-ui": "6.0.2",
33
- "@shipfox/api-workspaces-dto": "11.0.0",
34
- "@shipfox/client-shell": "12.0.2",
35
- "@shipfox/react-ui": "0.3.7"
33
+ "@shipfox/client-shell": "14.0.0",
34
+ "@shipfox/client-ui": "14.0.0",
35
+ "@shipfox/react-ui": "0.5.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('still navigates when auth refresh fails', async () => {
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 completeInvitationAcceptance({
43
- navigate,
44
- refreshAuth,
45
- workspaceId: 'workspace-1',
46
- workspaceName: 'Acme',
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).toHaveBeenCalledWith({
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<void> {
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
- // Even if refresh fails the membership is real in the DB; surface the
25
- // success toast and let the user re-auth if their session has fully
26
- // expired. The next API call will redirect to login as usual.
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
- to: '/workspaces/$wid',
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
- await completeInvitationAcceptance({
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
- navigate,
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
- [navigate, refreshAuth],
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) {