@shipfox/client-integrations 1.0.0 → 2.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 (78) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +27 -0
  3. package/dist/components/callback-status-shell.d.ts +9 -0
  4. package/dist/components/callback-status-shell.d.ts.map +1 -0
  5. package/dist/components/callback-status-shell.js +81 -0
  6. package/dist/components/callback-status-shell.js.map +1 -0
  7. package/dist/components/integration-gallery.stories.d.ts.map +1 -1
  8. package/dist/components/integration-gallery.stories.js +28 -0
  9. package/dist/components/integration-gallery.stories.js.map +1 -1
  10. package/dist/components/redirect-install-page.d.ts +2 -1
  11. package/dist/components/redirect-install-page.d.ts.map +1 -1
  12. package/dist/components/redirect-install-page.js +4 -2
  13. package/dist/components/redirect-install-page.js.map +1 -1
  14. package/dist/feature.d.ts +8 -0
  15. package/dist/feature.d.ts.map +1 -1
  16. package/dist/feature.js +10 -0
  17. package/dist/feature.js.map +1 -1
  18. package/dist/hooks/api/integrations.d.ts +41 -0
  19. package/dist/hooks/api/integrations.d.ts.map +1 -1
  20. package/dist/hooks/api/integrations.js +24 -0
  21. package/dist/hooks/api/integrations.js.map +1 -1
  22. package/dist/index.d.ts +3 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +3 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/pages/linear-callback-page.d.ts.map +1 -1
  27. package/dist/pages/linear-callback-page.js +11 -78
  28. package/dist/pages/linear-callback-page.js.map +1 -1
  29. package/dist/pages/linear-install-page.d.ts.map +1 -1
  30. package/dist/pages/linear-install-page.js +1 -0
  31. package/dist/pages/linear-install-page.js.map +1 -1
  32. package/dist/pages/sentry-install-page.d.ts.map +1 -1
  33. package/dist/pages/sentry-install-page.js +1 -0
  34. package/dist/pages/sentry-install-page.js.map +1 -1
  35. package/dist/pages/slack-callback-page.d.ts +2 -0
  36. package/dist/pages/slack-callback-page.d.ts.map +1 -0
  37. package/dist/pages/slack-callback-page.js +114 -0
  38. package/dist/pages/slack-callback-page.js.map +1 -0
  39. package/dist/pages/slack-install-page.d.ts +2 -0
  40. package/dist/pages/slack-install-page.d.ts.map +1 -0
  41. package/dist/pages/slack-install-page.js +19 -0
  42. package/dist/pages/slack-install-page.js.map +1 -0
  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 -0
  46. package/dist/provider-catalog.js.map +1 -1
  47. package/dist/routes/slack-callback.d.ts +6 -0
  48. package/dist/routes/slack-callback.d.ts.map +1 -0
  49. package/dist/routes/slack-callback.js +7 -0
  50. package/dist/routes/slack-callback.js.map +1 -0
  51. package/dist/routes/slack.d.ts +6 -0
  52. package/dist/routes/slack.d.ts.map +1 -0
  53. package/dist/routes/slack.js +7 -0
  54. package/dist/routes/slack.js.map +1 -0
  55. package/dist/slack-callback.d.ts +17 -0
  56. package/dist/slack-callback.d.ts.map +1 -0
  57. package/dist/slack-callback.js +99 -0
  58. package/dist/slack-callback.js.map +1 -0
  59. package/dist/tsconfig.test.tsbuildinfo +1 -1
  60. package/package.json +13 -12
  61. package/src/components/callback-status-shell.tsx +73 -0
  62. package/src/components/integration-gallery.stories.tsx +18 -0
  63. package/src/components/redirect-install-page.tsx +3 -1
  64. package/src/feature.ts +10 -0
  65. package/src/hooks/api/integrations.test.ts +30 -0
  66. package/src/hooks/api/integrations.ts +35 -0
  67. package/src/index.ts +3 -0
  68. package/src/pages/linear-callback-page.tsx +11 -52
  69. package/src/pages/linear-install-page.tsx +1 -0
  70. package/src/pages/sentry-install-page.tsx +1 -0
  71. package/src/pages/slack-callback-page.tsx +120 -0
  72. package/src/pages/slack-install-page.tsx +22 -0
  73. package/src/provider-catalog.ts +7 -1
  74. package/src/routes/slack-callback.tsx +3 -0
  75. package/src/routes/slack.tsx +3 -0
  76. package/src/slack-callback.test.ts +32 -0
  77. package/src/slack-callback.ts +141 -0
  78. package/tsconfig.build.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/client-integrations",
3
3
  "license": "MIT",
4
- "version": "1.0.0",
4
+ "version": "2.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -31,17 +31,18 @@
31
31
  "dependencies": {
32
32
  "@swc/helpers": "^0.5.17",
33
33
  "@tanstack/react-form": "^1.32.0",
34
- "@shipfox/api-integration-core-dto": "5.0.0",
35
- "@shipfox/api-integration-gitea-dto": "5.0.0",
36
- "@shipfox/api-integration-github-dto": "5.0.0",
37
- "@shipfox/api-integration-linear-dto": "5.0.0",
38
- "@shipfox/api-integration-sentry-dto": "5.0.0",
39
- "@shipfox/api-integration-webhook-dto": "5.0.0",
34
+ "@shipfox/api-integration-core-dto": "6.0.0",
35
+ "@shipfox/api-integration-gitea-dto": "6.0.0",
36
+ "@shipfox/api-integration-github-dto": "6.0.0",
37
+ "@shipfox/api-integration-linear-dto": "6.0.0",
38
+ "@shipfox/api-integration-slack-dto": "6.0.0",
39
+ "@shipfox/api-integration-sentry-dto": "6.0.0",
40
+ "@shipfox/api-integration-webhook-dto": "6.0.0",
41
+ "@shipfox/client-auth": "2.0.0",
40
42
  "@shipfox/client-api": "1.0.0",
41
- "@shipfox/client-auth": "1.0.0",
42
- "@shipfox/client-shell": "1.0.0",
43
- "@shipfox/client-ui": "1.0.0",
44
- "@shipfox/react-ui": "0.3.2"
43
+ "@shipfox/client-shell": "2.0.0",
44
+ "@shipfox/react-ui": "0.3.3",
45
+ "@shipfox/client-ui": "2.0.0"
45
46
  },
46
47
  "peerDependencies": {
47
48
  "@tanstack/react-query": "^5.101.0",
@@ -79,8 +80,8 @@
79
80
  "@shipfox/client-test-setup": "0.0.3",
80
81
  "@shipfox/swc": "1.2.6",
81
82
  "@shipfox/ts-config": "1.3.8",
82
- "@shipfox/typescript": "1.1.7",
83
83
  "@shipfox/vite": "1.2.5",
84
+ "@shipfox/typescript": "1.1.7",
84
85
  "@shipfox/vitest": "1.2.3"
85
86
  },
86
87
  "scripts": {
@@ -0,0 +1,73 @@
1
+ import {ButtonLink} from '@shipfox/react-ui/button';
2
+ import {Callout} from '@shipfox/react-ui/callout';
3
+ import {Text} from '@shipfox/react-ui/typography';
4
+ import {Link} from '@tanstack/react-router';
5
+ import {useEffect, useRef} from 'react';
6
+
7
+ export function CallbackStatusShell({
8
+ title,
9
+ message,
10
+ startOver,
11
+ switchAccount,
12
+ workspaceId,
13
+ installPath,
14
+ }: {
15
+ title: string;
16
+ message: string;
17
+ startOver?: boolean;
18
+ switchAccount?: boolean;
19
+ workspaceId?: string | undefined;
20
+ installPath: '/workspaces/$wid/integrations/linear' | '/workspaces/$wid/integrations/slack';
21
+ }) {
22
+ const headingRef = useRef<HTMLHeadingElement>(null);
23
+ useEffect(() => headingRef.current?.focus(), []);
24
+ const recoveryVariant = startOver || switchAccount ? 'muted' : 'base';
25
+ const settings = workspaceId ? (
26
+ <ButtonLink asChild variant={recoveryVariant} className="min-h-44 w-full sm:w-fit">
27
+ <Link to="/workspaces/$wid/settings/integrations" params={{wid: workspaceId}}>
28
+ Back to integrations
29
+ </Link>
30
+ </ButtonLink>
31
+ ) : (
32
+ <ButtonLink asChild variant={recoveryVariant} className="min-h-44 w-full sm:w-fit">
33
+ <Link to="/">Back to Shipfox</Link>
34
+ </ButtonLink>
35
+ );
36
+ const logoutRedirect = workspaceId ? installPath.replace('$wid', workspaceId) : undefined;
37
+
38
+ return (
39
+ <main className="flex min-h-screen bg-background-subtle-base px-16 py-32">
40
+ <div className="mx-auto flex w-full max-w-[480px] flex-col justify-center gap-20">
41
+ <h2 ref={headingRef} tabIndex={-1} className="text-24 font-semibold outline-none">
42
+ {title}
43
+ </h2>
44
+ <Callout role="alert" type="error">
45
+ <Text size="sm">{message}</Text>
46
+ </Callout>
47
+ <div className="flex flex-col gap-8 sm:flex-row sm:items-center">
48
+ {switchAccount ? (
49
+ <ButtonLink asChild className="min-h-44 w-full sm:w-fit">
50
+ <Link
51
+ to={
52
+ workspaceId
53
+ ? `/auth/logout?redirect=${encodeURIComponent(logoutRedirect ?? '/')}`
54
+ : '/auth/logout'
55
+ }
56
+ >
57
+ Switch account
58
+ </Link>
59
+ </ButtonLink>
60
+ ) : null}
61
+ {startOver && workspaceId ? (
62
+ <ButtonLink asChild className="min-h-44 w-full sm:w-fit">
63
+ <Link to={installPath} params={{wid: workspaceId}}>
64
+ Start over
65
+ </Link>
66
+ </ButtonLink>
67
+ ) : null}
68
+ {settings}
69
+ </div>
70
+ </div>
71
+ </main>
72
+ );
73
+ }
@@ -25,6 +25,7 @@ const SETUP_PATHS = [
25
25
  '/workspaces/$wid/integrations/github',
26
26
  '/workspaces/$wid/integrations/sentry',
27
27
  '/workspaces/$wid/integrations/linear',
28
+ '/workspaces/$wid/integrations/slack',
28
29
  '/workspaces/$wid/integrations/gitea',
29
30
  '/workspaces/$wid/settings/events',
30
31
  ] as const;
@@ -46,6 +47,7 @@ const PROVIDERS: IntegrationProviderDto[] = [
46
47
  {provider: 'github', display_name: 'GitHub', capabilities: ['source_control']},
47
48
  {provider: 'sentry', display_name: 'Sentry', capabilities: []},
48
49
  {provider: 'linear', display_name: 'Linear', capabilities: ['agent_tools']},
50
+ {provider: 'slack', display_name: 'Slack', capabilities: ['agent_tools']},
49
51
  {provider: 'gitea', display_name: 'Gitea', capabilities: ['source_control']},
50
52
  {provider: 'webhook', display_name: 'Webhook', capabilities: []},
51
53
  ];
@@ -230,6 +232,22 @@ function connectionsForScenario(scenario: Scenario): IntegrationConnectionDto[]
230
232
  }
231
233
  return [
232
234
  connection({display_name: 'acme-corp', lifecycle_status: 'active'}),
235
+ connection({
236
+ id: '99999999-9999-4999-8999-999999999999',
237
+ provider: 'slack',
238
+ display_name: 'Slack Acme',
239
+ lifecycle_status: 'active',
240
+ capabilities: ['agent_tools'],
241
+ external_url: 'https://app.slack.com/client/T123',
242
+ }),
243
+ connection({
244
+ id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
245
+ provider: 'slack',
246
+ display_name: 'Slack disabled',
247
+ lifecycle_status: 'disabled',
248
+ capabilities: ['agent_tools'],
249
+ external_url: undefined,
250
+ }),
233
251
  connection({
234
252
  id: '88888888-8888-4888-8888-888888888888',
235
253
  provider: 'linear',
@@ -10,6 +10,7 @@ import {useEffect, useRef, useState} from 'react';
10
10
  interface RedirectInstallPageProps {
11
11
  installRequest: (body: {workspace_id: string}) => Promise<{install_url: string}>;
12
12
  errorFallbackMessage: string;
13
+ loadingLabel?: string;
13
14
  /**
14
15
  * Runs before leaving the app (e.g. to persist the workspace id for a
15
16
  * state-less provider callback). Must not throw — a failed side effect
@@ -23,6 +24,7 @@ interface RedirectInstallPageProps {
23
24
  export function RedirectInstallPage({
24
25
  installRequest,
25
26
  errorFallbackMessage,
27
+ loadingLabel = 'Starting installation',
26
28
  beforeRedirect,
27
29
  assignLocation = (url) => window.location.assign(url),
28
30
  }: RedirectInstallPageProps) {
@@ -64,5 +66,5 @@ export function RedirectInstallPage({
64
66
  );
65
67
  }
66
68
 
67
- return <FullPageLoader />;
69
+ return <FullPageLoader aria-label={loadingLabel} />;
68
70
  }
package/src/feature.ts CHANGED
@@ -18,6 +18,11 @@ export const integrationsFeature = defineClientFeature({
18
18
  parent: 'root',
19
19
  impl: '@shipfox/client-integrations/routes/sentry-callback',
20
20
  },
21
+ {
22
+ path: '/integrations/slack/callback',
23
+ parent: 'root',
24
+ impl: '@shipfox/client-integrations/routes/slack-callback',
25
+ },
21
26
  {
22
27
  path: '/workspaces/$wid/integrations',
23
28
  parent: 'workspaceLayout',
@@ -43,5 +48,10 @@ export const integrationsFeature = defineClientFeature({
43
48
  parent: 'workspaceLayout',
44
49
  impl: '@shipfox/client-integrations/routes/sentry',
45
50
  },
51
+ {
52
+ path: '/workspaces/$wid/integrations/slack',
53
+ parent: 'workspaceLayout',
54
+ impl: '@shipfox/client-integrations/routes/slack',
55
+ },
46
56
  ],
47
57
  });
@@ -2,7 +2,9 @@ import type {IntegrationConnectionDto} from '@shipfox/api-integration-core-dto';
2
2
  import {configureApiClient} from '@shipfox/client-api';
3
3
  import {
4
4
  completeLinearCallback,
5
+ completeSlackCallback,
5
6
  createLinearInstall,
7
+ createSlackInstall,
6
8
  listSourceConnections,
7
9
  } from './integrations.js';
8
10
 
@@ -54,6 +56,34 @@ describe('listSourceConnections', () => {
54
56
  });
55
57
  });
56
58
 
59
+ describe('Slack transport', () => {
60
+ it('posts the install workspace and forwards an authenticated callback query', async () => {
61
+ const requests: Request[] = [];
62
+ configureApiClient({
63
+ baseUrl: 'https://api.example.test',
64
+ fetchImpl: vi.fn((input, init) => {
65
+ requests.push(new Request(input, init));
66
+ return Promise.resolve(jsonResponse(connection({provider: 'slack'})));
67
+ }),
68
+ });
69
+
70
+ await createSlackInstall({workspace_id: '11111111-1111-4111-8111-111111111111'});
71
+ await completeSlackCallback({
72
+ query: {code: 'grant code', state: 'signed state'},
73
+ token: 'session-token',
74
+ });
75
+
76
+ expect(requests[0]?.url).toBe('https://api.example.test/integrations/slack/install');
77
+ expect(await requests[0]?.json()).toEqual({
78
+ workspace_id: '11111111-1111-4111-8111-111111111111',
79
+ });
80
+ expect(requests[1]?.url).toBe(
81
+ 'https://api.example.test/integrations/slack/callback/api?code=grant+code&state=signed+state',
82
+ );
83
+ expect(requests[1]?.headers.get('authorization')).toBe('Bearer session-token');
84
+ });
85
+ });
86
+
57
87
  describe('Linear transport', () => {
58
88
  it('posts the install workspace and forwards an authenticated callback query', async () => {
59
89
  const requests: Request[] = [];
@@ -26,9 +26,16 @@ import type {
26
26
  SentryConnectBodyDto,
27
27
  SentryConnectResponseDto,
28
28
  } from '@shipfox/api-integration-sentry-dto';
29
+ import type {
30
+ CreateSlackInstallBodyDto,
31
+ CreateSlackInstallResponseDto,
32
+ SlackCallbackQueryDto,
33
+ SlackCallbackResponseDto,
34
+ } from '@shipfox/api-integration-slack-dto';
29
35
  import {apiRequest} from '@shipfox/client-api';
30
36
  import {useInfiniteQuery, useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
31
37
  import {serializeLinearCallbackQuery} from '#linear-callback.js';
38
+ import {serializeSlackCallbackQuery} from '#slack-callback.js';
32
39
 
33
40
  export const integrationsQueryKeys = {
34
41
  all: ['integrations'] as const,
@@ -129,6 +136,13 @@ export async function createLinearInstall(body: CreateLinearInstallBodyDto) {
129
136
  });
130
137
  }
131
138
 
139
+ export async function createSlackInstall(body: CreateSlackInstallBodyDto) {
140
+ return await apiRequest<CreateSlackInstallResponseDto>('/integrations/slack/install', {
141
+ method: 'POST',
142
+ body,
143
+ });
144
+ }
145
+
132
146
  export async function completeLinearCallback({
133
147
  query,
134
148
  token,
@@ -142,6 +156,19 @@ export async function completeLinearCallback({
142
156
  );
143
157
  }
144
158
 
159
+ export async function completeSlackCallback({
160
+ query,
161
+ token,
162
+ }: {
163
+ query: SlackCallbackQueryDto;
164
+ token: string;
165
+ }) {
166
+ return await apiRequest<SlackCallbackResponseDto>(
167
+ `/integrations/slack/callback/api?${serializeSlackCallbackQuery(query)}`,
168
+ {headers: {authorization: `Bearer ${token}`}},
169
+ );
170
+ }
171
+
145
172
  // Called from the callback route with an explicit bearer (same as the GitHub
146
173
  // callback): the route refreshes auth itself before forwarding the grant code.
147
174
  export async function connectSentry({body, token}: {body: SentryConnectBodyDto; token: string}) {
@@ -252,10 +279,18 @@ export function useCreateLinearInstallMutation() {
252
279
  return useMutation({mutationFn: createLinearInstall});
253
280
  }
254
281
 
282
+ export function useCreateSlackInstallMutation() {
283
+ return useMutation({mutationFn: createSlackInstall});
284
+ }
285
+
255
286
  export function useCompleteLinearCallbackMutation() {
256
287
  return useMutation({mutationFn: completeLinearCallback});
257
288
  }
258
289
 
290
+ export function useCompleteSlackCallbackMutation() {
291
+ return useMutation({mutationFn: completeSlackCallback});
292
+ }
293
+
259
294
  export function useUpdateIntegrationConnectionMutation() {
260
295
  const queryClient = useQueryClient();
261
296
  return useMutation({
package/src/index.ts CHANGED
@@ -11,6 +11,9 @@ export * from './pages/linear-callback-page.js';
11
11
  export * from './pages/linear-install-page.js';
12
12
  export * from './pages/sentry-callback-page.js';
13
13
  export * from './pages/sentry-install-page.js';
14
+ export * from './pages/slack-callback-page.js';
15
+ export * from './pages/slack-install-page.js';
14
16
  export * from './pages/source-control-onboarding-page.js';
15
17
  export * from './provider-catalog.js';
16
18
  export * from './sentry-callback.js';
19
+ export * from './slack-callback.js';
@@ -1,13 +1,11 @@
1
1
  import type {LinearCallbackResponseDto} from '@shipfox/api-integration-linear-dto';
2
2
  import {useRefreshAuth} from '@shipfox/client-auth';
3
- import {ButtonLink} from '@shipfox/react-ui/button';
4
- import {Callout} from '@shipfox/react-ui/callout';
5
3
  import {FullPageLoader} from '@shipfox/react-ui/loader';
6
4
  import {toast} from '@shipfox/react-ui/toast';
7
- import {Text} from '@shipfox/react-ui/typography';
8
5
  import {useQueryClient} from '@tanstack/react-query';
9
- import {Link, useNavigate, useSearch} from '@tanstack/react-router';
10
- import {useEffect, useMemo, useRef, useState} from 'react';
6
+ import {useNavigate, useSearch} from '@tanstack/react-router';
7
+ import {useEffect, useMemo, useState} from 'react';
8
+ import {CallbackStatusShell} from '#components/callback-status-shell.js';
11
9
  import {integrationsQueryKeys, useCompleteLinearCallbackMutation} from '#hooks/api/integrations.js';
12
10
  import {
13
11
  classifyLinearCallbackError,
@@ -123,53 +121,14 @@ function LinearCallbackFailurePage({
123
121
  failure: LinearCallbackFailure;
124
122
  workspaceId: string | undefined;
125
123
  }) {
126
- const headingRef = useRef<HTMLHeadingElement>(null);
127
-
128
- useEffect(() => {
129
- headingRef.current?.focus();
130
- }, []);
131
-
132
- const recoveryVariant = failure.startOver || failure.signIn ? 'muted' : 'base';
133
- const switchAccountHref = workspaceId
134
- ? `/auth/logout?redirect=${encodeURIComponent(`/workspaces/${workspaceId}/integrations/linear`)}`
135
- : '/auth/logout';
136
- const settingsLink = workspaceId ? (
137
- <ButtonLink asChild variant={recoveryVariant} className="min-h-44 w-full sm:w-fit">
138
- <Link to="/workspaces/$wid/settings/integrations" params={{wid: workspaceId}}>
139
- Back to integrations
140
- </Link>
141
- </ButtonLink>
142
- ) : (
143
- <ButtonLink asChild variant={recoveryVariant} className="min-h-44 w-full sm:w-fit">
144
- <Link to="/">Back to Shipfox</Link>
145
- </ButtonLink>
146
- );
147
-
148
124
  return (
149
- <main className="flex min-h-screen bg-background-subtle-base px-16 py-32">
150
- <div className="mx-auto flex w-full max-w-[480px] flex-col justify-center gap-20">
151
- <h2 ref={headingRef} tabIndex={-1} className="text-24 font-semibold outline-none">
152
- {failure.title ?? 'Linear install could not be completed'}
153
- </h2>
154
- <Callout role="alert" type="error">
155
- <Text size="sm">{failure.message}</Text>
156
- </Callout>
157
- <div className="flex flex-col gap-8 sm:flex-row sm:items-center">
158
- {failure.signIn ? (
159
- <ButtonLink asChild className="min-h-44 w-full sm:w-fit">
160
- <Link to={switchAccountHref}>Switch account</Link>
161
- </ButtonLink>
162
- ) : null}
163
- {failure.startOver && workspaceId ? (
164
- <ButtonLink asChild className="min-h-44 w-full sm:w-fit">
165
- <Link to="/workspaces/$wid/integrations/linear" params={{wid: workspaceId}}>
166
- Start over
167
- </Link>
168
- </ButtonLink>
169
- ) : null}
170
- {settingsLink}
171
- </div>
172
- </div>
173
- </main>
125
+ <CallbackStatusShell
126
+ title={failure.title ?? 'Linear install could not be completed'}
127
+ message={failure.message}
128
+ startOver={failure.startOver}
129
+ switchAccount={failure.signIn}
130
+ workspaceId={workspaceId}
131
+ installPath="/workspaces/$wid/integrations/linear"
132
+ />
174
133
  );
175
134
  }
@@ -14,6 +14,7 @@ export function LinearInstallPage() {
14
14
  <RedirectInstallPage
15
15
  installRequest={installRequest}
16
16
  errorFallbackMessage="Could not start Linear install."
17
+ loadingLabel="Connecting Linear"
17
18
  beforeRedirect={(workspaceId) => {
18
19
  try {
19
20
  saveLinearInstallWorkspace(window.sessionStorage, workspaceId);
@@ -7,6 +7,7 @@ export function SentryInstallPage() {
7
7
  <RedirectInstallPage
8
8
  installRequest={createSentryInstall}
9
9
  errorFallbackMessage="Could not start Sentry install."
10
+ loadingLabel="Connecting Sentry"
10
11
  // Sentry's redirect has no state param; the stored id lets the callback
11
12
  // pre-select this workspace. Saving never throws (see helper).
12
13
  beforeRedirect={(workspaceId) => {
@@ -0,0 +1,120 @@
1
+ import type {SlackCallbackResponseDto} from '@shipfox/api-integration-slack-dto';
2
+ import {useRefreshAuth} from '@shipfox/client-auth';
3
+ import {FullPageLoader} from '@shipfox/react-ui/loader';
4
+ import {toast} from '@shipfox/react-ui/toast';
5
+ import {useQueryClient} from '@tanstack/react-query';
6
+ import {useNavigate, useSearch} from '@tanstack/react-router';
7
+ import {useEffect, useMemo, useState} from 'react';
8
+ import {CallbackStatusShell} from '#components/callback-status-shell.js';
9
+ import {integrationsQueryKeys, useCompleteSlackCallbackMutation} from '#hooks/api/integrations.js';
10
+ import {
11
+ classifySlackCallbackError,
12
+ clearSlackInstallWorkspace,
13
+ parseSlackCallbackQuery,
14
+ readSlackInstallWorkspace,
15
+ type SlackCallbackFailure,
16
+ serializeSlackCallbackQuery,
17
+ } from '#slack-callback.js';
18
+
19
+ // Requests and committed keys remain for the document lifetime so Strict Mode,
20
+ // browser Back, and remounts never replay Slack's single-use grant code.
21
+ const callbackRequests = new Map<string, Promise<SlackCallbackResponseDto>>();
22
+ const completedCallbacks = new Set<string>();
23
+ const toastedCallbacks = new Set<string>();
24
+
25
+ export function SlackCallbackPage() {
26
+ const search = useSearch({strict: false});
27
+ const navigate = useNavigate();
28
+ const refreshAuth = useRefreshAuth();
29
+ const queryClient = useQueryClient();
30
+ const {mutateAsync: completeSlackCallback} = useCompleteSlackCallbackMutation();
31
+ const params = useMemo(() => parseSlackCallbackQuery(search), [search]);
32
+ const workspaceId = useMemo(() => readSlackInstallWorkspace(window.sessionStorage), []);
33
+ const [failure, setFailure] = useState<SlackCallbackFailure>();
34
+ const [completedWorkspaceId, setCompletedWorkspaceId] = useState<string>();
35
+
36
+ useEffect(() => {
37
+ if (!params) return;
38
+ let disposed = false;
39
+ const key = serializeSlackCallbackQuery(params);
40
+ let request = callbackRequests.get(key);
41
+ if (!request) {
42
+ request = refreshAuth().then(
43
+ async (session) => await completeSlackCallback({query: params, token: session.token}),
44
+ );
45
+ callbackRequests.set(key, request);
46
+ }
47
+ request.then(
48
+ async (connection) => {
49
+ if (disposed) return;
50
+ if (completedCallbacks.has(key)) {
51
+ setCompletedWorkspaceId(connection.workspace_id);
52
+ return;
53
+ }
54
+ completedCallbacks.add(key);
55
+ try {
56
+ clearSlackInstallWorkspace(window.sessionStorage);
57
+ } catch {
58
+ // The successful API response remains the source of truth.
59
+ }
60
+ try {
61
+ await queryClient.invalidateQueries({
62
+ queryKey: integrationsQueryKeys.connectionsByWorkspace(connection.workspace_id),
63
+ });
64
+ } catch {
65
+ // Navigation can continue when cache refresh is unavailable.
66
+ }
67
+ if (disposed) return;
68
+ if (!toastedCallbacks.has(key)) {
69
+ toastedCallbacks.add(key);
70
+ toast.success('Slack installed.');
71
+ }
72
+ try {
73
+ await navigate({
74
+ to: '/workspaces/$wid/settings/integrations',
75
+ params: {wid: connection.workspace_id},
76
+ replace: true,
77
+ });
78
+ } catch {
79
+ if (!disposed) setCompletedWorkspaceId(connection.workspace_id);
80
+ }
81
+ },
82
+ (error: unknown) => {
83
+ if (!disposed) setFailure(classifySlackCallbackError(error));
84
+ },
85
+ );
86
+ return () => {
87
+ disposed = true;
88
+ };
89
+ }, [completeSlackCallback, navigate, params, queryClient, refreshAuth]);
90
+
91
+ if (!params)
92
+ return (
93
+ <CallbackStatusShell
94
+ title="Invalid Slack callback"
95
+ message="This Slack link is missing required parameters. Start the install again from workspace settings."
96
+ startOver
97
+ workspaceId={workspaceId}
98
+ installPath="/workspaces/$wid/integrations/slack"
99
+ />
100
+ );
101
+ if (completedWorkspaceId)
102
+ return (
103
+ <CallbackStatusShell
104
+ title="Slack connected"
105
+ message="Slack is connected. Continue in integrations settings."
106
+ workspaceId={completedWorkspaceId}
107
+ installPath="/workspaces/$wid/integrations/slack"
108
+ />
109
+ );
110
+ if (failure)
111
+ return (
112
+ <CallbackStatusShell
113
+ {...failure}
114
+ workspaceId={workspaceId}
115
+ switchAccount={failure.signIn}
116
+ installPath="/workspaces/$wid/integrations/slack"
117
+ />
118
+ );
119
+ return <FullPageLoader aria-label="Connecting Slack" />;
120
+ }
@@ -0,0 +1,22 @@
1
+ import {useCallback} from 'react';
2
+ import {RedirectInstallPage} from '#components/redirect-install-page.js';
3
+ import {useCreateSlackInstallMutation} from '#hooks/api/integrations.js';
4
+ import {saveSlackInstallWorkspace} from '#slack-callback.js';
5
+
6
+ export function SlackInstallPage() {
7
+ const createInstall = useCreateSlackInstallMutation();
8
+ const installRequest = useCallback(
9
+ async (body: {workspace_id: string}) => await createInstall.mutateAsync(body),
10
+ [createInstall],
11
+ );
12
+ return (
13
+ <RedirectInstallPage
14
+ installRequest={installRequest}
15
+ errorFallbackMessage="Could not start Slack install."
16
+ loadingLabel="Connecting Slack"
17
+ beforeRedirect={(workspaceId) =>
18
+ saveSlackInstallWorkspace(window.sessionStorage, workspaceId)
19
+ }
20
+ />
21
+ );
22
+ }
@@ -6,7 +6,8 @@ export type ProviderSetupPath =
6
6
  | '/workspaces/$wid/integrations/github'
7
7
  | '/workspaces/$wid/integrations/gitea'
8
8
  | '/workspaces/$wid/integrations/sentry'
9
- | '/workspaces/$wid/integrations/linear';
9
+ | '/workspaces/$wid/integrations/linear'
10
+ | '/workspaces/$wid/integrations/slack';
10
11
 
11
12
  interface RouteProviderCatalogEntry {
12
13
  kind: 'redirect-install' | 'direct-connect';
@@ -37,6 +38,11 @@ export const PROVIDER_CATALOG: Record<string, ProviderCatalogEntry> = {
37
38
  iconName: 'linear',
38
39
  setupPath: '/workspaces/$wid/integrations/linear',
39
40
  },
41
+ slack: {
42
+ kind: 'redirect-install',
43
+ iconName: 'slack',
44
+ setupPath: '/workspaces/$wid/integrations/slack',
45
+ },
40
46
  gitea: {
41
47
  kind: 'direct-connect',
42
48
  iconName: 'gitea',
@@ -0,0 +1,3 @@
1
+ import {defineRoute} from '@shipfox/client-shell/runtime';
2
+ import {SlackCallbackPage} from '#pages/slack-callback-page.js';
3
+ export default defineRoute({component: SlackCallbackPage});
@@ -0,0 +1,3 @@
1
+ import {defineRoute} from '@shipfox/client-shell/runtime';
2
+ import {SlackInstallPage} from '#pages/slack-install-page.js';
3
+ export default defineRoute({component: SlackInstallPage});
@@ -0,0 +1,32 @@
1
+ import {ApiError} from '@shipfox/client-api';
2
+ import {
3
+ classifySlackCallbackError,
4
+ parseSlackCallbackQuery,
5
+ serializeSlackCallbackQuery,
6
+ } from './slack-callback.js';
7
+
8
+ describe('Slack callback helpers', () => {
9
+ it('parses and serializes callback input', () => {
10
+ const query = parseSlackCallbackQuery({code: 'grant code', state: 'signed state'});
11
+ expect(query).toEqual({code: 'grant code', state: 'signed state'});
12
+ expect(query && serializeSlackCallbackQuery(query)).toBe('code=grant+code&state=signed+state');
13
+ expect(parseSlackCallbackQuery({state: 'signed'})).toBeUndefined();
14
+ });
15
+
16
+ it.each([
17
+ ['invalid-slack-install-state', 400, 'Slack install link expired', true],
18
+ ['slack-install-state-actor-mismatch', 403, 'Different Shipfox account', true],
19
+ ['forbidden', 403, 'Workspace access changed', false],
20
+ ['workspace-inactive', 403, 'Workspace access changed', false],
21
+ ['slack-installation-already-linked', 409, 'Slack already linked', false],
22
+ ['slack-authorization-scope-mismatch', 422, 'Slack permissions needed', true],
23
+ ['slack-enterprise-install-unsupported', 422, 'Slack install unsupported', false],
24
+ ['network-error', 0, 'Could not reach Shipfox', true],
25
+ ['rate-limited', 429, 'Slack is temporarily unavailable', true],
26
+ ['unknown-error', 400, 'Slack install could not be completed', true],
27
+ ])('classifies %s', (code, status, title, startOver) => {
28
+ expect(
29
+ classifySlackCallbackError(new ApiError({code, status, message: 'failed'})),
30
+ ).toMatchObject({title, startOver});
31
+ });
32
+ });