@shipfox/client-integrations 12.0.1 → 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.
Files changed (81) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +41 -0
  3. package/dist/components/callback-status-shell.d.ts +4 -3
  4. package/dist/components/callback-status-shell.d.ts.map +1 -1
  5. package/dist/components/callback-status-shell.js +11 -11
  6. package/dist/components/callback-status-shell.js.map +1 -1
  7. package/dist/components/installed-integrations-section.d.ts +2 -1
  8. package/dist/components/installed-integrations-section.d.ts.map +1 -1
  9. package/dist/components/installed-integrations-section.js +7 -6
  10. package/dist/components/installed-integrations-section.js.map +1 -1
  11. package/dist/components/integration-gallery-for-workspace.d.ts.map +1 -1
  12. package/dist/components/integration-gallery-for-workspace.js +11 -2
  13. package/dist/components/integration-gallery-for-workspace.js.map +1 -1
  14. package/dist/components/integration-gallery.stories.js +10 -9
  15. package/dist/components/integration-gallery.stories.js.map +1 -1
  16. package/dist/components/provider-grid.d.ts +2 -2
  17. package/dist/components/provider-grid.d.ts.map +1 -1
  18. package/dist/components/provider-grid.js +4 -4
  19. package/dist/components/provider-grid.js.map +1 -1
  20. package/dist/components/redirect-install-page.d.ts +1 -1
  21. package/dist/components/redirect-install-page.js +2 -2
  22. package/dist/components/redirect-install-page.js.map +1 -1
  23. package/dist/components/webhook/webhook-modals.stories.js +2 -2
  24. package/dist/components/webhook/webhook-modals.stories.js.map +1 -1
  25. package/dist/feature.d.ts +7 -7
  26. package/dist/feature.js +7 -7
  27. package/dist/feature.js.map +1 -1
  28. package/dist/hooks/api/integrations.js +1 -1
  29. package/dist/hooks/api/integrations.js.map +1 -1
  30. package/dist/pages/gitea-install-page.js +4 -4
  31. package/dist/pages/gitea-install-page.js.map +1 -1
  32. package/dist/pages/linear-callback-page.d.ts.map +1 -1
  33. package/dist/pages/linear-callback-page.js +58 -12
  34. package/dist/pages/linear-callback-page.js.map +1 -1
  35. package/dist/pages/sentry-callback-page.d.ts.map +1 -1
  36. package/dist/pages/sentry-callback-page.js +22 -9
  37. package/dist/pages/sentry-callback-page.js.map +1 -1
  38. package/dist/pages/slack-callback-page.d.ts.map +1 -1
  39. package/dist/pages/slack-callback-page.js +50 -18
  40. package/dist/pages/slack-callback-page.js.map +1 -1
  41. package/dist/pages/source-control-onboarding-page.js +1 -1
  42. package/dist/pages/source-control-onboarding-page.js.map +1 -1
  43. package/dist/provider-catalog.d.ts +1 -1
  44. package/dist/provider-catalog.d.ts.map +1 -1
  45. package/dist/provider-catalog.js +5 -5
  46. package/dist/provider-catalog.js.map +1 -1
  47. package/dist/sentry-callback.js +2 -2
  48. package/dist/sentry-callback.js.map +1 -1
  49. package/dist/tsconfig.test.tsbuildinfo +1 -1
  50. package/dist/workspace-navigation.d.ts +13 -0
  51. package/dist/workspace-navigation.d.ts.map +1 -0
  52. package/dist/workspace-navigation.js +17 -0
  53. package/dist/workspace-navigation.js.map +1 -0
  54. package/package.json +13 -13
  55. package/src/components/callback-status-shell.tsx +14 -10
  56. package/src/components/installed-integrations-section.tsx +16 -9
  57. package/src/components/integration-gallery-for-workspace.tsx +20 -10
  58. package/src/components/integration-gallery.stories.tsx +10 -10
  59. package/src/components/integration-gallery.test.tsx +30 -15
  60. package/src/components/provider-grid.tsx +6 -6
  61. package/src/components/redirect-install-page.test.tsx +3 -3
  62. package/src/components/redirect-install-page.tsx +2 -2
  63. package/src/components/webhook/webhook-modals.stories.tsx +2 -2
  64. package/src/feature.ts +7 -7
  65. package/src/hooks/api/integrations.ts +1 -1
  66. package/src/pages/gitea-install-page.tsx +2 -2
  67. package/src/pages/linear-callback-page.test.tsx +25 -12
  68. package/src/pages/linear-callback-page.tsx +73 -12
  69. package/src/pages/sentry-callback-page.test.tsx +8 -5
  70. package/src/pages/sentry-callback-page.tsx +32 -8
  71. package/src/pages/slack-callback-page.test.tsx +78 -0
  72. package/src/pages/slack-callback-page.tsx +56 -18
  73. package/src/pages/source-control-onboarding-page.test.tsx +4 -4
  74. package/src/pages/source-control-onboarding-page.tsx +1 -1
  75. package/src/provider-catalog.test.ts +4 -4
  76. package/src/provider-catalog.ts +10 -10
  77. package/src/sentry-callback.ts +2 -2
  78. package/src/workspace-navigation.test.ts +43 -0
  79. package/src/workspace-navigation.ts +38 -0
  80. package/test/render.tsx +2 -1
  81. package/tsconfig.build.tsbuildinfo +1 -1
@@ -4,7 +4,7 @@ import {ApiError} from '@shipfox/client-api';
4
4
  import {screen, waitFor} from '@testing-library/react';
5
5
  import {StrictMode} from 'react';
6
6
  import {LINEAR_INSTALL_WORKSPACE_KEY} from '#linear-callback.js';
7
- import {INTEGRATIONS_TEST_WID, renderIntegrationsPage} from '#test/render.js';
7
+ import {INTEGRATIONS_TEST_WID, renderIntegrationsPage, testWorkspace} from '#test/render.js';
8
8
  import {LinearCallbackPage} from './linear-callback-page.js';
9
9
 
10
10
  const {completeCallbackMock} = vi.hoisted(() => ({completeCallbackMock: vi.fn()}));
@@ -37,8 +37,8 @@ function renderCallback(search: string, options?: {strict?: boolean}) {
37
37
  <LinearCallbackPage />
38
38
  ),
39
39
  extraRoutes: [
40
- '/workspaces/$wid/settings/integrations',
41
- '/workspaces/$wid/integrations/linear',
40
+ '/w/$workspaceSlug/settings/integrations',
41
+ '/w/$workspaceSlug/integrations/linear',
42
42
  '/auth/login',
43
43
  ],
44
44
  });
@@ -70,7 +70,7 @@ describe('LinearCallbackPage', () => {
70
70
  window.sessionStorage.setItem(LINEAR_INSTALL_WORKSPACE_KEY, INTEGRATIONS_TEST_WID);
71
71
  completeCallbackMock.mockResolvedValue({
72
72
  id: 'connection-1',
73
- workspace_id: responseWorkspaceId,
73
+ workspaceId: responseWorkspaceId,
74
74
  provider: 'linear',
75
75
  external_account_id: 'linear-org',
76
76
  slug: 'linear_org',
@@ -81,7 +81,24 @@ describe('LinearCallbackPage', () => {
81
81
  updated_at: '2026-01-01T00:00:00.000Z',
82
82
  });
83
83
 
84
- renderCallback('?code=grant-code-success&state=signed-state-success', {strict: true});
84
+ renderIntegrationsPage({
85
+ path: '/integrations/linear/callback?code=grant-code-success&state=signed-state-success',
86
+ routePath: '/integrations/linear/callback',
87
+ element: (
88
+ <StrictMode>
89
+ <LinearCallbackPage />
90
+ </StrictMode>
91
+ ),
92
+ workspaces: [
93
+ testWorkspace(),
94
+ testWorkspace({id: responseWorkspaceId, slug: 'response-workspace'}),
95
+ ],
96
+ extraRoutes: [
97
+ '/w/response-workspace/settings/integrations',
98
+ '/w/$workspaceSlug/integrations/linear',
99
+ '/auth/login',
100
+ ],
101
+ });
85
102
 
86
103
  await waitFor(() =>
87
104
  expect(completeCallbackMock).toHaveBeenCalledWith({
@@ -92,7 +109,7 @@ describe('LinearCallbackPage', () => {
92
109
  expect(completeCallbackMock).toHaveBeenCalledTimes(1);
93
110
  await waitFor(() =>
94
111
  expect(
95
- screen.getByTestId('route:/workspaces/$wid/settings/integrations'),
112
+ screen.getByTestId('route:/w/response-workspace/settings/integrations'),
96
113
  ).toBeInTheDocument(),
97
114
  );
98
115
  expect(window.sessionStorage.getItem(LINEAR_INSTALL_WORKSPACE_KEY)).toBeNull();
@@ -136,14 +153,10 @@ describe('LinearCallbackPage', () => {
136
153
 
137
154
  expect(switchAccountLink).toHaveAttribute(
138
155
  'href',
139
- `/auth/logout?redirect=${encodeURIComponent(
140
- `/workspaces/${INTEGRATIONS_TEST_WID}/integrations/linear`,
141
- )}`,
156
+ `/auth/logout?redirect=${encodeURIComponent('/w/acme/integrations/linear')}`,
142
157
  );
143
158
  expect(switchAccountUrl.pathname).toBe('/auth/logout');
144
- expect(switchAccountUrl.searchParams.get('redirect')).toBe(
145
- `/workspaces/${INTEGRATIONS_TEST_WID}/integrations/linear`,
146
- );
159
+ expect(switchAccountUrl.searchParams.get('redirect')).toBe('/w/acme/integrations/linear');
147
160
  expect(screen.getByRole('link', {name: 'Start over'})).toBeVisible();
148
161
  });
149
162
 
@@ -1,8 +1,9 @@
1
- import {useRefreshAuth} from '@shipfox/client-auth';
1
+ import {useAuthState, useRefreshAuth} from '@shipfox/client-auth';
2
2
  import {useRouteSearch} from '@shipfox/client-shell/runtime';
3
3
  import {createSingleFlight, sessionStorageOrUndefined} from '@shipfox/client-ui';
4
4
  import {FullPageLoader} from '@shipfox/react-ui/loader';
5
5
  import {toast} from '@shipfox/react-ui/toast';
6
+ import {useQueryClient} from '@tanstack/react-query';
6
7
  import {useNavigate} from '@tanstack/react-router';
7
8
  import {useEffect, useMemo, useState} from 'react';
8
9
  import {useCompleteIntegrationCallback} from '#application/complete-integration-callback.js';
@@ -17,26 +18,33 @@ import {
17
18
  readLinearInstallWorkspace,
18
19
  serializeLinearCallbackQuery,
19
20
  } from '#linear-callback.js';
21
+ import {rememberCallbackKey, resolveWorkspaceSlug} from '#workspace-navigation.js';
20
22
 
21
23
  // Retain only recent completions: this bounds long-lived callback pages while
22
24
  // still covering StrictMode and immediate Back/Forward remounts.
23
25
  const callbackRequests = createSingleFlight<string, IntegrationConnection>({
24
26
  maxTerminalResults: 32,
25
27
  });
28
+ const completedCallbacks = new Set<string>();
26
29
  // Keeps the success toast firing once per distinct callback even though the
27
30
  // effect re-runs against the cached request as the mutation identity churns.
28
31
  const toastedCallbacks = new Set<string>();
29
32
 
30
33
  export function LinearCallbackPage() {
31
34
  const navigate = useNavigate();
35
+ const queryClient = useQueryClient();
32
36
  const refreshAuth = useRefreshAuth();
33
37
  const completeIntegrationCallback = useCompleteIntegrationCallback();
34
38
  const {mutateAsync: completeLinearCallback} = useCompleteLinearCallbackMutation();
39
+ const {workspaces, isLoading} = useAuthState();
35
40
  const params = useRouteSearch(parseLinearCallbackQuery);
36
41
  const workspaceId = useMemo(() => readLinearInstallWorkspace(sessionStorageOrUndefined()), []);
37
42
  const [failure, setFailure] = useState<LinearCallbackFailure | undefined>();
43
+ const [completedWorkspace, setCompletedWorkspace] = useState<{
44
+ slug?: string | undefined;
45
+ }>();
38
46
  useEffect(() => {
39
- if (!params) return;
47
+ if (!params || isLoading) return;
40
48
 
41
49
  let disposed = false;
42
50
  const key = serializeLinearCallbackQuery(params);
@@ -53,6 +61,16 @@ export function LinearCallbackPage() {
53
61
  request.then(
54
62
  async (connection) => {
55
63
  if (disposed) return;
64
+ if (completedCallbacks.has(key)) {
65
+ const workspaceSlug = await resolveWorkspaceSlug({
66
+ workspaceId: connection.workspaceId,
67
+ fallbackWorkspaces: workspaces,
68
+ queryClient,
69
+ });
70
+ if (!disposed) setCompletedWorkspace(workspaceSlug ? {slug: workspaceSlug} : {});
71
+ return;
72
+ }
73
+ rememberCallbackKey(completedCallbacks, key);
56
74
  try {
57
75
  clearLinearInstallWorkspace(sessionStorageOrUndefined());
58
76
  } catch {
@@ -60,17 +78,31 @@ export function LinearCallbackPage() {
60
78
  }
61
79
  if (disposed) return;
62
80
  if (!toastedCallbacks.has(key)) {
63
- toastedCallbacks.add(key);
81
+ rememberCallbackKey(toastedCallbacks, key);
64
82
  toast.success('Linear installed.');
65
83
  }
84
+ let workspaceSlug: string | undefined;
66
85
  try {
86
+ if (disposed) return;
87
+ workspaceSlug = await resolveWorkspaceSlug({
88
+ workspaceId: connection.workspaceId,
89
+ fallbackWorkspaces: workspaces,
90
+ queryClient,
91
+ });
92
+ if (disposed) return;
93
+ if (!workspaceSlug) {
94
+ setCompletedWorkspace({});
95
+ return;
96
+ }
97
+ setCompletedWorkspace({slug: workspaceSlug});
67
98
  await navigate({
68
- to: '/workspaces/$wid/settings/integrations',
69
- params: {wid: connection.workspaceId},
99
+ to: '/w/$workspaceSlug/settings/integrations',
100
+ params: {workspaceSlug},
70
101
  replace: true,
71
102
  });
72
103
  } catch {
73
104
  // Keep the completed callback page visible if client navigation is interrupted.
105
+ if (!disposed) setCompletedWorkspace({slug: workspaceSlug});
74
106
  }
75
107
  },
76
108
  (error: unknown) => {
@@ -82,7 +114,16 @@ export function LinearCallbackPage() {
82
114
  return () => {
83
115
  disposed = true;
84
116
  };
85
- }, [completeIntegrationCallback, completeLinearCallback, navigate, params, refreshAuth]);
117
+ }, [
118
+ completeIntegrationCallback,
119
+ completeLinearCallback,
120
+ isLoading,
121
+ navigate,
122
+ params,
123
+ queryClient,
124
+ refreshAuth,
125
+ workspaces,
126
+ ]);
86
127
 
87
128
  if (!params) {
88
129
  return (
@@ -93,22 +134,42 @@ export function LinearCallbackPage() {
93
134
  startOver: true,
94
135
  signIn: false,
95
136
  }}
96
- workspaceId={workspaceId}
137
+ workspaceSlug={workspaces.find(({id}) => id === workspaceId)?.slug}
97
138
  />
98
139
  );
99
140
  }
100
141
 
142
+ if (completedWorkspace)
143
+ return (
144
+ <CallbackStatusShell
145
+ title="Linear connected"
146
+ status="success"
147
+ message={
148
+ completedWorkspace.slug
149
+ ? 'Linear is connected. Continue in integrations settings.'
150
+ : 'Linear is connected. Return to Shipfox to continue.'
151
+ }
152
+ workspaceSlug={completedWorkspace.slug}
153
+ installPath="/w/$workspaceSlug/integrations/linear"
154
+ />
155
+ );
156
+
101
157
  if (!failure) return <FullPageLoader aria-label="Connecting Linear" />;
102
158
 
103
- return <LinearCallbackFailurePage failure={failure} workspaceId={workspaceId} />;
159
+ return (
160
+ <LinearCallbackFailurePage
161
+ failure={failure}
162
+ workspaceSlug={workspaces.find(({id}) => id === workspaceId)?.slug}
163
+ />
164
+ );
104
165
  }
105
166
 
106
167
  function LinearCallbackFailurePage({
107
168
  failure,
108
- workspaceId,
169
+ workspaceSlug,
109
170
  }: {
110
171
  failure: LinearCallbackFailure;
111
- workspaceId: string | undefined;
172
+ workspaceSlug: string | undefined;
112
173
  }) {
113
174
  return (
114
175
  <CallbackStatusShell
@@ -116,8 +177,8 @@ function LinearCallbackFailurePage({
116
177
  message={failure.message}
117
178
  startOver={failure.startOver}
118
179
  switchAccount={failure.signIn}
119
- workspaceId={workspaceId}
120
- installPath="/workspaces/$wid/integrations/linear"
180
+ workspaceSlug={workspaceSlug}
181
+ installPath="/w/$workspaceSlug/integrations/linear"
121
182
  />
122
183
  );
123
184
  }
@@ -41,7 +41,10 @@ function renderCallback(options: {
41
41
  path: `/integrations/sentry/callback${search}`,
42
42
  routePath: '/integrations/sentry/callback',
43
43
  element: <SentryCallbackPage />,
44
- extraRoutes: ['/workspaces/$wid/settings/integrations', '/workspaces/$wid/integrations/sentry'],
44
+ extraRoutes: [
45
+ '/w/$workspaceSlug/settings/integrations',
46
+ '/w/$workspaceSlug/integrations/sentry',
47
+ ],
45
48
  ...(options.workspaces ? {workspaces: options.workspaces} : {}),
46
49
  ...(options.loadingAuth ? {loadingAuth: true} : {}),
47
50
  });
@@ -104,7 +107,7 @@ describe('SentryCallbackPage', () => {
104
107
 
105
108
  fireEvent.click(await screen.findByRole('button', {name: 'Install'}));
106
109
 
107
- await screen.findByTestId('route:/workspaces/$wid/settings/integrations');
110
+ await screen.findByTestId('route:/w/$workspaceSlug/settings/integrations');
108
111
  expect(connectSentryMock).toHaveBeenCalledWith({
109
112
  body: {
110
113
  workspace_id: testWorkspace().id,
@@ -126,7 +129,7 @@ describe('SentryCallbackPage', () => {
126
129
  fireEvent.click(await screen.findByRole('button', {name: 'Install'}));
127
130
  fireEvent.click(await screen.findByRole('button', {name: 'Retry'}));
128
131
 
129
- await screen.findByTestId('route:/workspaces/$wid/settings/integrations');
132
+ await screen.findByTestId('route:/w/$workspaceSlug/settings/integrations');
130
133
  expect(connectSentryMock).toHaveBeenCalledTimes(2);
131
134
  });
132
135
 
@@ -149,7 +152,7 @@ describe('SentryCallbackPage', () => {
149
152
  await waitFor(() => expect(screen.getByRole('button', {name: 'Retry'})).toBeDisabled());
150
153
 
151
154
  // A second attempt (via the still-enabled workspace Install) fails with no
152
- // backoff hint the lock must clear so the user is not stranded.
155
+ // backoff hint: the lock must clear so the user is not stranded.
153
156
  fireEvent.click(screen.getByRole('button', {name: 'Install'}));
154
157
  await waitFor(() => expect(screen.getByRole('button', {name: 'Retry'})).toBeEnabled());
155
158
  });
@@ -203,7 +206,7 @@ describe('SentryCallbackPage', () => {
203
206
  renderCallback({installationId: 'install-remount'});
204
207
  fireEvent.click(await screen.findByRole('button', {name: 'Install'}));
205
208
 
206
- await screen.findByTestId('route:/workspaces/$wid/settings/integrations');
209
+ await screen.findByTestId('route:/w/$workspaceSlug/settings/integrations');
207
210
  await waitFor(() => expect(connectSentryMock).toHaveBeenCalledTimes(2));
208
211
  });
209
212
  });
@@ -7,6 +7,7 @@ import {Card} from '@shipfox/react-ui/card';
7
7
  import {FullPageLoader} from '@shipfox/react-ui/loader';
8
8
  import {toast} from '@shipfox/react-ui/toast';
9
9
  import {Header, Text} from '@shipfox/react-ui/typography';
10
+ import {useQueryClient} from '@tanstack/react-query';
10
11
  import {Link, useNavigate} from '@tanstack/react-router';
11
12
  import {useEffect, useMemo, useRef, useState} from 'react';
12
13
  import {useCompleteIntegrationCallback} from '#application/complete-integration-callback.js';
@@ -20,11 +21,13 @@ import {
20
21
  readSentryInstallWorkspace,
21
22
  type SentryConnectFailure,
22
23
  } from '#sentry-callback.js';
24
+ import {resolveWorkspaceSlug} from '#workspace-navigation.js';
23
25
 
24
26
  const connectRequests = createSingleFlight<string, IntegrationConnection>();
25
27
 
26
28
  export function SentryCallbackPage() {
27
29
  const navigate = useNavigate();
30
+ const queryClient = useQueryClient();
28
31
  const refreshAuth = useRefreshAuth();
29
32
  const {workspaces, isLoading} = useAuthState();
30
33
  const completeIntegrationCallback = useCompleteIntegrationCallback();
@@ -124,11 +127,21 @@ export function SentryCallbackPage() {
124
127
  clearSentryInstallWorkspace(sessionStorageOrUndefined());
125
128
  if (disposedRef.current) return;
126
129
  toast.success('Sentry installed.');
127
- await navigate({
128
- to: '/workspaces/$wid/settings/integrations',
129
- params: {wid: workspaceId},
130
- replace: true,
130
+ const workspaceSlug = await resolveWorkspaceSlug({
131
+ workspaceId,
132
+ fallbackWorkspaces: workspaces,
133
+ queryClient,
131
134
  });
135
+ if (disposedRef.current) return;
136
+ await navigate(
137
+ workspaceSlug
138
+ ? {
139
+ to: '/w/$workspaceSlug/settings/integrations',
140
+ params: {workspaceSlug},
141
+ replace: true,
142
+ }
143
+ : {to: '/', replace: true},
144
+ );
132
145
  })
133
146
  .catch((error: unknown) => {
134
147
  if (disposedRef.current) return;
@@ -151,6 +164,9 @@ export function SentryCallbackPage() {
151
164
  : workspaces;
152
165
 
153
166
  const failureWorkspaceId = failure?.workspaceId ?? preselectedId ?? workspaces[0]?.id;
167
+ const failureWorkspace = failureWorkspaceId
168
+ ? workspaces.find(({id}) => id === failureWorkspaceId)
169
+ : undefined;
154
170
 
155
171
  return (
156
172
  <CallbackColumn>
@@ -178,9 +194,14 @@ export function SentryCallbackPage() {
178
194
  Retry
179
195
  </Button>
180
196
  ) : null}
181
- {failure.failure.kind === 'terminal' && failure.failure.startOver ? (
197
+ {failure.failure.kind === 'terminal' &&
198
+ failure.failure.startOver &&
199
+ failureWorkspace ? (
182
200
  <Button asChild size="sm" variant="secondary" className="w-fit">
183
- <Link to="/workspaces/$wid/integrations/sentry" params={{wid: failure.workspaceId}}>
201
+ <Link
202
+ to="/w/$workspaceSlug/integrations/sentry"
203
+ params={{workspaceSlug: failureWorkspace.slug}}
204
+ >
184
205
  Start over
185
206
  </Link>
186
207
  </Button>
@@ -209,9 +230,12 @@ export function SentryCallbackPage() {
209
230
  ))}
210
231
  </section>
211
232
 
212
- {failureWorkspaceId ? (
233
+ {failureWorkspace ? (
213
234
  <ButtonLink asChild variant="muted" className="w-fit">
214
- <Link to="/workspaces/$wid/settings/integrations" params={{wid: failureWorkspaceId}}>
235
+ <Link
236
+ to="/w/$workspaceSlug/settings/integrations"
237
+ params={{workspaceSlug: failureWorkspace.slug}}
238
+ >
215
239
  Back to settings
216
240
  </Link>
217
241
  </ButtonLink>
@@ -0,0 +1,78 @@
1
+ // @vitest-environment jsdom
2
+ import '@testing-library/jest-dom/vitest';
3
+ import {screen, waitFor} from '@testing-library/react';
4
+ import {StrictMode} from 'react';
5
+ import {SLACK_INSTALL_WORKSPACE_KEY} from '#slack-callback.js';
6
+ import {INTEGRATIONS_TEST_WID, renderIntegrationsPage, testWorkspace} from '#test/render.js';
7
+ import {SlackCallbackPage} from './slack-callback-page.js';
8
+
9
+ const {completeCallbackMock} = vi.hoisted(() => ({completeCallbackMock: vi.fn()}));
10
+
11
+ vi.mock('@shipfox/client-auth', async (importOriginal) => {
12
+ const actual = await importOriginal<typeof import('@shipfox/client-auth')>();
13
+ return {
14
+ ...actual,
15
+ useRefreshAuth: () => () => Promise.resolve({accessToken: 'test-token'}),
16
+ };
17
+ });
18
+
19
+ vi.mock('#hooks/api/integrations.js', async (importOriginal) => {
20
+ const actual = await importOriginal<typeof import('#hooks/api/integrations.js')>();
21
+ return {
22
+ ...actual,
23
+ useCompleteSlackCallbackMutation: () => ({mutateAsync: completeCallbackMock}),
24
+ };
25
+ });
26
+
27
+ beforeEach(() => {
28
+ window.sessionStorage.clear();
29
+ completeCallbackMock.mockReset();
30
+ });
31
+
32
+ describe('SlackCallbackPage', () => {
33
+ test('completes a callback once in Strict Mode and navigates to its workspace', async () => {
34
+ const responseWorkspaceId = '22222222-2222-4222-8222-222222222222';
35
+ window.sessionStorage.setItem(SLACK_INSTALL_WORKSPACE_KEY, INTEGRATIONS_TEST_WID);
36
+ completeCallbackMock.mockResolvedValue({
37
+ id: 'connection-1',
38
+ workspaceId: responseWorkspaceId,
39
+ provider: 'slack',
40
+ slug: 'slack_org',
41
+ displayName: 'Slack org',
42
+ capabilities: [],
43
+ });
44
+
45
+ renderIntegrationsPage({
46
+ path: '/integrations/slack/callback?code=grant-code-success&state=signed-state-success',
47
+ routePath: '/integrations/slack/callback',
48
+ element: (
49
+ <StrictMode>
50
+ <SlackCallbackPage />
51
+ </StrictMode>
52
+ ),
53
+ workspaces: [
54
+ testWorkspace(),
55
+ testWorkspace({id: responseWorkspaceId, slug: 'response-workspace'}),
56
+ ],
57
+ extraRoutes: [
58
+ '/w/response-workspace/settings/integrations',
59
+ '/w/$workspaceSlug/integrations/slack',
60
+ '/auth/login',
61
+ ],
62
+ });
63
+
64
+ await waitFor(() =>
65
+ expect(completeCallbackMock).toHaveBeenCalledWith({
66
+ query: {code: 'grant-code-success', state: 'signed-state-success'},
67
+ token: 'test-token',
68
+ }),
69
+ );
70
+ expect(completeCallbackMock).toHaveBeenCalledTimes(1);
71
+ await waitFor(() =>
72
+ expect(
73
+ screen.getByTestId('route:/w/response-workspace/settings/integrations'),
74
+ ).toBeInTheDocument(),
75
+ );
76
+ expect(screen.getByText('Slack installed.')).toBeInTheDocument();
77
+ });
78
+ });
@@ -1,8 +1,9 @@
1
- import {useRefreshAuth} from '@shipfox/client-auth';
1
+ import {useAuthState, useRefreshAuth} from '@shipfox/client-auth';
2
2
  import {useRouteSearch} from '@shipfox/client-shell/runtime';
3
3
  import {createSingleFlight, sessionStorageOrUndefined} from '@shipfox/client-ui';
4
4
  import {FullPageLoader} from '@shipfox/react-ui/loader';
5
5
  import {toast} from '@shipfox/react-ui/toast';
6
+ import {useQueryClient} from '@tanstack/react-query';
6
7
  import {useNavigate} from '@tanstack/react-router';
7
8
  import {useEffect, useMemo, useState} from 'react';
8
9
  import {useCompleteIntegrationCallback} from '#application/complete-integration-callback.js';
@@ -17,6 +18,7 @@ import {
17
18
  type SlackCallbackFailure,
18
19
  serializeSlackCallbackQuery,
19
20
  } from '#slack-callback.js';
21
+ import {rememberCallbackKey, resolveWorkspaceSlug} from '#workspace-navigation.js';
20
22
 
21
23
  // Retain only recent completions: this bounds long-lived callback pages while
22
24
  // still covering StrictMode and immediate Back/Forward remounts.
@@ -28,16 +30,20 @@ const toastedCallbacks = new Set<string>();
28
30
 
29
31
  export function SlackCallbackPage() {
30
32
  const navigate = useNavigate();
33
+ const queryClient = useQueryClient();
31
34
  const refreshAuth = useRefreshAuth();
32
35
  const completeIntegrationCallback = useCompleteIntegrationCallback();
33
36
  const {mutateAsync: completeSlackCallback} = useCompleteSlackCallbackMutation();
37
+ const {workspaces, isLoading} = useAuthState();
34
38
  const params = useRouteSearch(parseSlackCallbackQuery);
35
39
  const workspaceId = useMemo(() => readSlackInstallWorkspace(sessionStorageOrUndefined()), []);
36
40
  const [failure, setFailure] = useState<SlackCallbackFailure>();
37
- const [completedWorkspaceId, setCompletedWorkspaceId] = useState<string>();
41
+ const [completedWorkspace, setCompletedWorkspace] = useState<{
42
+ slug?: string | undefined;
43
+ }>();
38
44
 
39
45
  useEffect(() => {
40
- if (!params) return;
46
+ if (!params || isLoading) return;
41
47
  let disposed = false;
42
48
  const key = serializeSlackCallbackQuery(params);
43
49
  const request = callbackRequests.run(
@@ -53,10 +59,15 @@ export function SlackCallbackPage() {
53
59
  async (connection) => {
54
60
  if (disposed) return;
55
61
  if (completedCallbacks.has(key)) {
56
- setCompletedWorkspaceId(connection.workspaceId);
62
+ const workspaceSlug = await resolveWorkspaceSlug({
63
+ workspaceId: connection.workspaceId,
64
+ fallbackWorkspaces: workspaces,
65
+ queryClient,
66
+ });
67
+ if (!disposed) setCompletedWorkspace(workspaceSlug ? {slug: workspaceSlug} : {});
57
68
  return;
58
69
  }
59
- completedCallbacks.add(key);
70
+ rememberCallbackKey(completedCallbacks, key);
60
71
  try {
61
72
  clearSlackInstallWorkspace(sessionStorageOrUndefined());
62
73
  } catch {
@@ -64,17 +75,30 @@ export function SlackCallbackPage() {
64
75
  }
65
76
  if (disposed) return;
66
77
  if (!toastedCallbacks.has(key)) {
67
- toastedCallbacks.add(key);
78
+ rememberCallbackKey(toastedCallbacks, key);
68
79
  toast.success('Slack installed.');
69
80
  }
81
+ let workspaceSlug: string | undefined;
70
82
  try {
83
+ if (disposed) return;
84
+ workspaceSlug = await resolveWorkspaceSlug({
85
+ workspaceId: connection.workspaceId,
86
+ fallbackWorkspaces: workspaces,
87
+ queryClient,
88
+ });
89
+ if (disposed) return;
90
+ if (!workspaceSlug) {
91
+ setCompletedWorkspace({});
92
+ return;
93
+ }
94
+ setCompletedWorkspace({slug: workspaceSlug});
71
95
  await navigate({
72
- to: '/workspaces/$wid/settings/integrations',
73
- params: {wid: connection.workspaceId},
96
+ to: '/w/$workspaceSlug/settings/integrations',
97
+ params: {workspaceSlug},
74
98
  replace: true,
75
99
  });
76
100
  } catch {
77
- if (!disposed) setCompletedWorkspaceId(connection.workspaceId);
101
+ if (!disposed) setCompletedWorkspace({slug: workspaceSlug});
78
102
  }
79
103
  },
80
104
  (error: unknown) => {
@@ -84,7 +108,16 @@ export function SlackCallbackPage() {
84
108
  return () => {
85
109
  disposed = true;
86
110
  };
87
- }, [completeIntegrationCallback, completeSlackCallback, navigate, params, refreshAuth]);
111
+ }, [
112
+ completeIntegrationCallback,
113
+ completeSlackCallback,
114
+ isLoading,
115
+ navigate,
116
+ params,
117
+ queryClient,
118
+ refreshAuth,
119
+ workspaces,
120
+ ]);
88
121
 
89
122
  if (!params)
90
123
  return (
@@ -92,26 +125,31 @@ export function SlackCallbackPage() {
92
125
  title="Invalid Slack callback"
93
126
  message="This Slack link is missing required parameters. Start the install again from workspace settings."
94
127
  startOver
95
- workspaceId={workspaceId}
96
- installPath="/workspaces/$wid/integrations/slack"
128
+ workspaceSlug={workspaces.find(({id}) => id === workspaceId)?.slug}
129
+ installPath="/w/$workspaceSlug/integrations/slack"
97
130
  />
98
131
  );
99
- if (completedWorkspaceId)
132
+ if (completedWorkspace)
100
133
  return (
101
134
  <CallbackStatusShell
102
135
  title="Slack connected"
103
- message="Slack is connected. Continue in integrations settings."
104
- workspaceId={completedWorkspaceId}
105
- installPath="/workspaces/$wid/integrations/slack"
136
+ status="success"
137
+ message={
138
+ completedWorkspace.slug
139
+ ? 'Slack is connected. Continue in integrations settings.'
140
+ : 'Slack is connected. Return to Shipfox to continue.'
141
+ }
142
+ workspaceSlug={completedWorkspace.slug}
143
+ installPath="/w/$workspaceSlug/integrations/slack"
106
144
  />
107
145
  );
108
146
  if (failure)
109
147
  return (
110
148
  <CallbackStatusShell
111
149
  {...failure}
112
- workspaceId={workspaceId}
150
+ workspaceSlug={workspaces.find(({id}) => id === workspaceId)?.slug}
113
151
  switchAccount={failure.signIn}
114
- installPath="/workspaces/$wid/integrations/slack"
152
+ installPath="/w/$workspaceSlug/integrations/slack"
115
153
  />
116
154
  );
117
155
  return <FullPageLoader aria-label="Connecting Slack" />;
@@ -2,7 +2,7 @@
2
2
  import '@testing-library/jest-dom/vitest';
3
3
  import {configureApiClient} from '@shipfox/client-api';
4
4
  import {screen} from '@testing-library/react';
5
- import {INTEGRATIONS_TEST_WID, jsonResponse, renderIntegrationsPage} from '#test/render.js';
5
+ import {jsonResponse, renderIntegrationsPage} from '#test/render.js';
6
6
  import {SourceControlOnboardingPage} from './source-control-onboarding-page.js';
7
7
 
8
8
  describe('SourceControlOnboardingPage', () => {
@@ -24,10 +24,10 @@ describe('SourceControlOnboardingPage', () => {
24
24
  configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
25
25
 
26
26
  renderIntegrationsPage({
27
- path: `/workspaces/${INTEGRATIONS_TEST_WID}/integrations`,
28
- routePath: '/workspaces/$wid/integrations',
27
+ path: '/w/acme/integrations',
28
+ routePath: '/w/$workspaceSlug/integrations',
29
29
  element: <SourceControlOnboardingPage />,
30
- extraRoutes: ['/workspaces/$wid/integrations/github'],
30
+ extraRoutes: ['/w/$workspaceSlug/integrations/github'],
31
31
  });
32
32
 
33
33
  expect(await screen.findByRole('heading', {name: 'Install source control'})).toBeVisible();
@@ -17,12 +17,12 @@ export function SourceControlOnboardingPage() {
17
17
  </header>
18
18
 
19
19
  <ProviderGrid
20
+ workspaceSlug={workspace.slug}
20
21
  providers={providersQuery.data ?? []}
21
22
  isPending={providersQuery.isPending}
22
23
  isFetching={providersQuery.isFetching}
23
24
  error={providersQuery.isError ? providersQuery.error : undefined}
24
25
  onRetry={() => void providersQuery.refetch()}
25
- workspaceId={workspace.id}
26
26
  emptyMessage="Enable at least one source-control provider in the application settings."
27
27
  />
28
28
  </div>