@shipfox/client-integrations 0.2.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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +50 -0
- package/dist/components/callback-status-shell.d.ts +9 -0
- package/dist/components/callback-status-shell.d.ts.map +1 -0
- package/dist/components/callback-status-shell.js +81 -0
- package/dist/components/callback-status-shell.js.map +1 -0
- package/dist/components/integration-gallery.stories.d.ts.map +1 -1
- package/dist/components/integration-gallery.stories.js +28 -0
- package/dist/components/integration-gallery.stories.js.map +1 -1
- package/dist/components/redirect-install-page.d.ts +2 -1
- package/dist/components/redirect-install-page.d.ts.map +1 -1
- package/dist/components/redirect-install-page.js +4 -2
- package/dist/components/redirect-install-page.js.map +1 -1
- package/dist/feature.d.ts +8 -0
- package/dist/feature.d.ts.map +1 -1
- package/dist/feature.js +10 -0
- package/dist/feature.js.map +1 -1
- package/dist/hooks/api/integrations.d.ts +41 -0
- package/dist/hooks/api/integrations.d.ts.map +1 -1
- package/dist/hooks/api/integrations.js +24 -0
- package/dist/hooks/api/integrations.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/pages/linear-callback-page.d.ts.map +1 -1
- package/dist/pages/linear-callback-page.js +11 -78
- package/dist/pages/linear-callback-page.js.map +1 -1
- package/dist/pages/linear-install-page.d.ts.map +1 -1
- package/dist/pages/linear-install-page.js +1 -0
- package/dist/pages/linear-install-page.js.map +1 -1
- package/dist/pages/sentry-install-page.d.ts.map +1 -1
- package/dist/pages/sentry-install-page.js +1 -0
- package/dist/pages/sentry-install-page.js.map +1 -1
- package/dist/pages/slack-callback-page.d.ts +2 -0
- package/dist/pages/slack-callback-page.d.ts.map +1 -0
- package/dist/pages/slack-callback-page.js +114 -0
- package/dist/pages/slack-callback-page.js.map +1 -0
- package/dist/pages/slack-install-page.d.ts +2 -0
- package/dist/pages/slack-install-page.d.ts.map +1 -0
- package/dist/pages/slack-install-page.js +19 -0
- package/dist/pages/slack-install-page.js.map +1 -0
- package/dist/provider-catalog.d.ts +1 -1
- package/dist/provider-catalog.d.ts.map +1 -1
- package/dist/provider-catalog.js +5 -0
- package/dist/provider-catalog.js.map +1 -1
- package/dist/routes/slack-callback.d.ts +6 -0
- package/dist/routes/slack-callback.d.ts.map +1 -0
- package/dist/routes/slack-callback.js +7 -0
- package/dist/routes/slack-callback.js.map +1 -0
- package/dist/routes/slack.d.ts +6 -0
- package/dist/routes/slack.d.ts.map +1 -0
- package/dist/routes/slack.js +7 -0
- package/dist/routes/slack.js.map +1 -0
- package/dist/slack-callback.d.ts +17 -0
- package/dist/slack-callback.d.ts.map +1 -0
- package/dist/slack-callback.js +99 -0
- package/dist/slack-callback.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +26 -48
- package/src/components/callback-status-shell.tsx +73 -0
- package/src/components/integration-gallery.stories.tsx +18 -0
- package/src/components/redirect-install-page.tsx +3 -1
- package/src/feature.ts +10 -0
- package/src/hooks/api/integrations.test.ts +30 -0
- package/src/hooks/api/integrations.ts +35 -0
- package/src/index.ts +3 -0
- package/src/pages/linear-callback-page.tsx +11 -52
- package/src/pages/linear-install-page.tsx +1 -0
- package/src/pages/sentry-install-page.tsx +1 -0
- package/src/pages/slack-callback-page.tsx +120 -0
- package/src/pages/slack-install-page.tsx +22 -0
- package/src/provider-catalog.ts +7 -1
- package/src/routes/slack-callback.tsx +3 -0
- package/src/routes/slack.tsx +3 -0
- package/src/slack-callback.test.ts +32 -0
- package/src/slack-callback.ts +141 -0
- 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": "
|
|
4
|
+
"version": "2.0.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -11,60 +11,38 @@
|
|
|
11
11
|
"main": "dist/index.js",
|
|
12
12
|
"types": "dist/index.d.ts",
|
|
13
13
|
"imports": {
|
|
14
|
-
"#*":
|
|
15
|
-
"types": "./src/*",
|
|
16
|
-
"workspace-source": "./src/*",
|
|
17
|
-
"development": "./src/*",
|
|
18
|
-
"default": "./dist/*"
|
|
19
|
-
},
|
|
14
|
+
"#*": "./dist/*",
|
|
20
15
|
"#test/*": "./test/*"
|
|
21
16
|
},
|
|
22
17
|
"exports": {
|
|
23
18
|
".": {
|
|
24
|
-
"
|
|
25
|
-
|
|
26
|
-
"default": "./src/index.ts"
|
|
27
|
-
},
|
|
28
|
-
"default": {
|
|
29
|
-
"types": "./dist/index.d.ts",
|
|
30
|
-
"default": "./dist/index.js"
|
|
31
|
-
}
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"default": "./dist/index.js"
|
|
32
21
|
},
|
|
33
22
|
"./feature": {
|
|
34
|
-
"
|
|
35
|
-
|
|
36
|
-
"default": "./src/feature.ts"
|
|
37
|
-
},
|
|
38
|
-
"default": {
|
|
39
|
-
"types": "./dist/feature.d.ts",
|
|
40
|
-
"default": "./dist/feature.js"
|
|
41
|
-
}
|
|
23
|
+
"types": "./dist/feature.d.ts",
|
|
24
|
+
"default": "./dist/feature.js"
|
|
42
25
|
},
|
|
43
26
|
"./routes/*": {
|
|
44
|
-
"
|
|
45
|
-
|
|
46
|
-
"default": "./src/routes/*.tsx"
|
|
47
|
-
},
|
|
48
|
-
"default": {
|
|
49
|
-
"types": "./dist/routes/*.d.ts",
|
|
50
|
-
"default": "./dist/routes/*.js"
|
|
51
|
-
}
|
|
27
|
+
"types": "./dist/routes/*.d.ts",
|
|
28
|
+
"default": "./dist/routes/*.js"
|
|
52
29
|
}
|
|
53
30
|
},
|
|
54
31
|
"dependencies": {
|
|
55
32
|
"@swc/helpers": "^0.5.17",
|
|
56
33
|
"@tanstack/react-form": "^1.32.0",
|
|
57
|
-
"@shipfox/api-integration-core-dto": "
|
|
58
|
-
"@shipfox/api-integration-gitea-dto": "
|
|
59
|
-
"@shipfox/api-integration-github-dto": "
|
|
60
|
-
"@shipfox/api-integration-
|
|
61
|
-
"@shipfox/api-integration-
|
|
62
|
-
"@shipfox/
|
|
63
|
-
"@shipfox/api-integration-webhook-dto": "
|
|
64
|
-
"@shipfox/client-auth": "
|
|
65
|
-
"@shipfox/client-
|
|
66
|
-
"@shipfox/
|
|
67
|
-
"@shipfox/
|
|
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",
|
|
42
|
+
"@shipfox/client-api": "1.0.0",
|
|
43
|
+
"@shipfox/client-shell": "2.0.0",
|
|
44
|
+
"@shipfox/react-ui": "0.3.3",
|
|
45
|
+
"@shipfox/client-ui": "2.0.0"
|
|
68
46
|
},
|
|
69
47
|
"peerDependencies": {
|
|
70
48
|
"@tanstack/react-query": "^5.101.0",
|
|
@@ -98,13 +76,13 @@
|
|
|
98
76
|
"tailwindcss": "^4.1.13",
|
|
99
77
|
"vite": "^8.0.3",
|
|
100
78
|
"vitest": "^4.1.5",
|
|
101
|
-
"@shipfox/biome": "1.8.
|
|
102
|
-
"@shipfox/
|
|
103
|
-
"@shipfox/
|
|
104
|
-
"@shipfox/typescript": "1.1.6",
|
|
79
|
+
"@shipfox/biome": "1.8.2",
|
|
80
|
+
"@shipfox/client-test-setup": "0.0.3",
|
|
81
|
+
"@shipfox/swc": "1.2.6",
|
|
105
82
|
"@shipfox/ts-config": "1.3.8",
|
|
106
|
-
"@shipfox/vite": "1.2.
|
|
107
|
-
"@shipfox/
|
|
83
|
+
"@shipfox/vite": "1.2.5",
|
|
84
|
+
"@shipfox/typescript": "1.1.7",
|
|
85
|
+
"@shipfox/vitest": "1.2.3"
|
|
108
86
|
},
|
|
109
87
|
"scripts": {
|
|
110
88
|
"build": "shipfox-swc",
|
|
@@ -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 {
|
|
10
|
-
import {useEffect, useMemo,
|
|
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
|
-
<
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
+
}
|
package/src/provider-catalog.ts
CHANGED
|
@@ -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',
|