@shipfox/client-integrations 12.0.2 → 14.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +52 -0
- package/dist/application/complete-integration-callback.d.ts +11 -0
- package/dist/application/complete-integration-callback.d.ts.map +1 -1
- package/dist/application/complete-integration-callback.js +38 -8
- package/dist/application/complete-integration-callback.js.map +1 -1
- package/dist/components/callback-status-shell.d.ts +4 -3
- package/dist/components/callback-status-shell.d.ts.map +1 -1
- package/dist/components/callback-status-shell.js +11 -11
- package/dist/components/callback-status-shell.js.map +1 -1
- package/dist/components/installed-integrations-section.d.ts +2 -1
- package/dist/components/installed-integrations-section.d.ts.map +1 -1
- package/dist/components/installed-integrations-section.js +7 -6
- package/dist/components/installed-integrations-section.js.map +1 -1
- package/dist/components/integration-gallery-for-workspace.d.ts.map +1 -1
- package/dist/components/integration-gallery-for-workspace.js +11 -2
- package/dist/components/integration-gallery-for-workspace.js.map +1 -1
- package/dist/components/integration-gallery.stories.js +10 -9
- package/dist/components/integration-gallery.stories.js.map +1 -1
- package/dist/components/provider-grid.d.ts +2 -2
- package/dist/components/provider-grid.d.ts.map +1 -1
- package/dist/components/provider-grid.js +4 -4
- package/dist/components/provider-grid.js.map +1 -1
- package/dist/components/redirect-install-page.d.ts +1 -1
- package/dist/components/redirect-install-page.js +2 -2
- package/dist/components/redirect-install-page.js.map +1 -1
- package/dist/components/webhook/webhook-modals.stories.js +2 -2
- package/dist/components/webhook/webhook-modals.stories.js.map +1 -1
- package/dist/core/models.d.ts +6 -0
- package/dist/core/models.d.ts.map +1 -1
- package/dist/core/models.js +12 -0
- package/dist/core/models.js.map +1 -1
- package/dist/feature.d.ts +15 -7
- package/dist/feature.d.ts.map +1 -1
- package/dist/feature.js +17 -7
- package/dist/feature.js.map +1 -1
- package/dist/hooks/api/integration-mapper.d.ts +3 -1
- package/dist/hooks/api/integration-mapper.d.ts.map +1 -1
- package/dist/hooks/api/integration-mapper.js +8 -0
- package/dist/hooks/api/integration-mapper.js.map +1 -1
- package/dist/hooks/api/integrations.d.ts +25 -1
- package/dist/hooks/api/integrations.d.ts.map +1 -1
- package/dist/hooks/api/integrations.js +45 -2
- package/dist/hooks/api/integrations.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/jira-callback-state.d.ts +9 -0
- package/dist/jira-callback-state.d.ts.map +1 -0
- package/dist/jira-callback-state.js +16 -0
- package/dist/jira-callback-state.js.map +1 -0
- package/dist/jira-callback.d.ts +11 -0
- package/dist/jira-callback.d.ts.map +1 -0
- package/dist/jira-callback.js +59 -0
- package/dist/jira-callback.js.map +1 -0
- package/dist/jira-form-errors.d.ts +8 -0
- package/dist/jira-form-errors.d.ts.map +1 -0
- package/dist/jira-form-errors.js +55 -0
- package/dist/jira-form-errors.js.map +1 -0
- package/dist/pages/gitea-install-page.js +4 -4
- package/dist/pages/gitea-install-page.js.map +1 -1
- package/dist/pages/jira-callback-page.d.ts +2 -0
- package/dist/pages/jira-callback-page.d.ts.map +1 -0
- package/dist/pages/jira-callback-page.js +341 -0
- package/dist/pages/jira-callback-page.js.map +1 -0
- package/dist/pages/jira-install-page.d.ts +2 -0
- package/dist/pages/jira-install-page.d.ts.map +1 -0
- package/dist/pages/jira-install-page.js +26 -0
- package/dist/pages/jira-install-page.js.map +1 -0
- package/dist/pages/linear-callback-page.d.ts.map +1 -1
- package/dist/pages/linear-callback-page.js +58 -12
- package/dist/pages/linear-callback-page.js.map +1 -1
- package/dist/pages/sentry-callback-page.d.ts.map +1 -1
- package/dist/pages/sentry-callback-page.js +22 -9
- package/dist/pages/sentry-callback-page.js.map +1 -1
- package/dist/pages/slack-callback-page.d.ts.map +1 -1
- package/dist/pages/slack-callback-page.js +50 -18
- package/dist/pages/slack-callback-page.js.map +1 -1
- package/dist/pages/source-control-onboarding-page.js +1 -1
- package/dist/pages/source-control-onboarding-page.js.map +1 -1
- package/dist/provider-catalog.d.ts +1 -1
- package/dist/provider-catalog.d.ts.map +1 -1
- package/dist/provider-catalog.js +10 -5
- package/dist/provider-catalog.js.map +1 -1
- package/dist/routes/jira-callback.d.ts +8 -0
- package/dist/routes/jira-callback.d.ts.map +1 -0
- package/dist/routes/jira-callback.js +9 -0
- package/dist/routes/jira-callback.js.map +1 -0
- package/dist/routes/jira.d.ts +6 -0
- package/dist/routes/jira.d.ts.map +1 -0
- package/dist/routes/jira.js +7 -0
- package/dist/routes/jira.js.map +1 -0
- package/dist/sentry-callback.js +2 -2
- package/dist/sentry-callback.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/dist/workspace-navigation.d.ts +13 -0
- package/dist/workspace-navigation.d.ts.map +1 -0
- package/dist/workspace-navigation.js +17 -0
- package/dist/workspace-navigation.js.map +1 -0
- package/package.json +14 -13
- package/src/application/complete-integration-callback.test.ts +44 -1
- package/src/application/complete-integration-callback.ts +61 -8
- package/src/components/callback-status-shell.tsx +17 -10
- package/src/components/installed-integrations-section.tsx +16 -9
- package/src/components/integration-gallery-for-workspace.tsx +20 -10
- package/src/components/integration-gallery.stories.tsx +10 -10
- package/src/components/integration-gallery.test.tsx +30 -15
- package/src/components/integration-usage-events.test.ts +13 -0
- package/src/components/provider-grid.tsx +6 -6
- package/src/components/redirect-install-page.test.tsx +3 -3
- package/src/components/redirect-install-page.tsx +2 -2
- package/src/components/webhook/webhook-modals.stories.tsx +2 -2
- package/src/core/models.ts +18 -0
- package/src/feature.ts +17 -7
- package/src/hooks/api/integration-mapper.ts +6 -0
- package/src/hooks/api/integrations.test.ts +92 -0
- package/src/hooks/api/integrations.ts +72 -1
- package/src/index.ts +4 -0
- package/src/jira-callback-state.ts +19 -0
- package/src/jira-callback.test.ts +76 -0
- package/src/jira-callback.ts +67 -0
- package/src/jira-form-errors.test.ts +57 -0
- package/src/jira-form-errors.ts +94 -0
- package/src/pages/gitea-install-page.tsx +2 -2
- package/src/pages/jira-callback-page.test.tsx +250 -0
- package/src/pages/jira-callback-page.tsx +366 -0
- package/src/pages/jira-install-page.tsx +28 -0
- package/src/pages/linear-callback-page.test.tsx +25 -12
- package/src/pages/linear-callback-page.tsx +73 -12
- package/src/pages/sentry-callback-page.test.tsx +8 -5
- package/src/pages/sentry-callback-page.tsx +32 -8
- package/src/pages/slack-callback-page.test.tsx +78 -0
- package/src/pages/slack-callback-page.tsx +56 -18
- package/src/pages/source-control-onboarding-page.test.tsx +4 -4
- package/src/pages/source-control-onboarding-page.tsx +1 -1
- package/src/provider-catalog.test.ts +10 -4
- package/src/provider-catalog.ts +16 -10
- package/src/routes/jira-callback.tsx +8 -0
- package/src/routes/jira.tsx +4 -0
- package/src/sentry-callback.ts +2 -2
- package/src/workspace-navigation.test.ts +43 -0
- package/src/workspace-navigation.ts +38 -0
- package/test/render.tsx +2 -1
- package/tsconfig.build.tsbuildinfo +1 -1
|
@@ -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
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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' &&
|
|
197
|
+
{failure.failure.kind === 'terminal' &&
|
|
198
|
+
failure.failure.startOver &&
|
|
199
|
+
failureWorkspace ? (
|
|
182
200
|
<Button asChild size="sm" variant="secondary" className="w-fit">
|
|
183
|
-
<Link
|
|
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
|
-
{
|
|
233
|
+
{failureWorkspace ? (
|
|
213
234
|
<ButtonLink asChild variant="muted" className="w-fit">
|
|
214
|
-
<Link
|
|
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 [
|
|
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
|
-
|
|
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
|
|
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
|
|
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: '/
|
|
73
|
-
params: {
|
|
96
|
+
to: '/w/$workspaceSlug/settings/integrations',
|
|
97
|
+
params: {workspaceSlug},
|
|
74
98
|
replace: true,
|
|
75
99
|
});
|
|
76
100
|
} catch {
|
|
77
|
-
if (!disposed)
|
|
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
|
-
}, [
|
|
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
|
-
|
|
96
|
-
installPath="/
|
|
128
|
+
workspaceSlug={workspaces.find(({id}) => id === workspaceId)?.slug}
|
|
129
|
+
installPath="/w/$workspaceSlug/integrations/slack"
|
|
97
130
|
/>
|
|
98
131
|
);
|
|
99
|
-
if (
|
|
132
|
+
if (completedWorkspace)
|
|
100
133
|
return (
|
|
101
134
|
<CallbackStatusShell
|
|
102
135
|
title="Slack connected"
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
150
|
+
workspaceSlug={workspaces.find(({id}) => id === workspaceId)?.slug}
|
|
113
151
|
switchAccount={failure.signIn}
|
|
114
|
-
installPath="/
|
|
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 {
|
|
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:
|
|
28
|
-
routePath: '/
|
|
27
|
+
path: '/w/acme/integrations',
|
|
28
|
+
routePath: '/w/$workspaceSlug/integrations',
|
|
29
29
|
element: <SourceControlOnboardingPage />,
|
|
30
|
-
extraRoutes: ['/
|
|
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>
|
|
@@ -4,20 +4,25 @@ describe('PROVIDER_CATALOG', () => {
|
|
|
4
4
|
test('contains route entries for github, sentry, and gitea', () => {
|
|
5
5
|
expect(PROVIDER_CATALOG.github).toMatchObject({
|
|
6
6
|
kind: 'redirect-install',
|
|
7
|
-
setupPath: '/
|
|
7
|
+
setupPath: '/w/$workspaceSlug/integrations/github',
|
|
8
8
|
});
|
|
9
9
|
expect(PROVIDER_CATALOG.sentry).toMatchObject({
|
|
10
10
|
kind: 'redirect-install',
|
|
11
|
-
setupPath: '/
|
|
11
|
+
setupPath: '/w/$workspaceSlug/integrations/sentry',
|
|
12
12
|
});
|
|
13
13
|
expect(PROVIDER_CATALOG.linear).toMatchObject({
|
|
14
14
|
kind: 'redirect-install',
|
|
15
15
|
iconName: 'linear',
|
|
16
|
-
setupPath: '/
|
|
16
|
+
setupPath: '/w/$workspaceSlug/integrations/linear',
|
|
17
|
+
});
|
|
18
|
+
expect(PROVIDER_CATALOG.jira).toMatchObject({
|
|
19
|
+
kind: 'redirect-install',
|
|
20
|
+
iconName: 'jira',
|
|
21
|
+
setupPath: '/w/$workspaceSlug/integrations/jira',
|
|
17
22
|
});
|
|
18
23
|
expect(PROVIDER_CATALOG.gitea).toMatchObject({
|
|
19
24
|
kind: 'direct-connect',
|
|
20
|
-
setupPath: '/
|
|
25
|
+
setupPath: '/w/$workspaceSlug/integrations/gitea',
|
|
21
26
|
});
|
|
22
27
|
});
|
|
23
28
|
|
|
@@ -25,6 +30,7 @@ describe('PROVIDER_CATALOG', () => {
|
|
|
25
30
|
expect(PROVIDER_CATALOG.github?.kind).toBe('redirect-install');
|
|
26
31
|
expect(PROVIDER_CATALOG.sentry?.kind).toBe('redirect-install');
|
|
27
32
|
expect(PROVIDER_CATALOG.linear?.kind).toBe('redirect-install');
|
|
33
|
+
expect(PROVIDER_CATALOG.jira?.kind).toBe('redirect-install');
|
|
28
34
|
expect(PROVIDER_CATALOG.gitea?.kind).toBe('direct-connect');
|
|
29
35
|
expect(PROVIDER_CATALOG.webhook?.kind).toBe('modal-connect');
|
|
30
36
|
});
|
package/src/provider-catalog.ts
CHANGED
|
@@ -4,11 +4,12 @@ import type {IconName} from '@shipfox/react-ui/icon';
|
|
|
4
4
|
// Literal union (not string) so `<Link to={catalog.setupPath}>` stays typed
|
|
5
5
|
// against TanStack Router's route tree.
|
|
6
6
|
export type ProviderSetupPath =
|
|
7
|
-
| '/
|
|
8
|
-
| '/
|
|
9
|
-
| '/
|
|
10
|
-
| '/
|
|
11
|
-
| '/
|
|
7
|
+
| '/w/$workspaceSlug/integrations/github'
|
|
8
|
+
| '/w/$workspaceSlug/integrations/gitea'
|
|
9
|
+
| '/w/$workspaceSlug/integrations/sentry'
|
|
10
|
+
| '/w/$workspaceSlug/integrations/linear'
|
|
11
|
+
| '/w/$workspaceSlug/integrations/slack'
|
|
12
|
+
| '/w/$workspaceSlug/integrations/jira';
|
|
12
13
|
|
|
13
14
|
interface RouteProviderCatalogEntry {
|
|
14
15
|
kind: 'redirect-install' | 'direct-connect';
|
|
@@ -27,27 +28,32 @@ export const PROVIDER_CATALOG: Record<string, ProviderCatalogEntry> = {
|
|
|
27
28
|
github: {
|
|
28
29
|
kind: 'redirect-install',
|
|
29
30
|
iconName: PROVIDER_ICONS.github,
|
|
30
|
-
setupPath: '/
|
|
31
|
+
setupPath: '/w/$workspaceSlug/integrations/github',
|
|
31
32
|
},
|
|
32
33
|
sentry: {
|
|
33
34
|
kind: 'redirect-install',
|
|
34
35
|
iconName: PROVIDER_ICONS.sentry,
|
|
35
|
-
setupPath: '/
|
|
36
|
+
setupPath: '/w/$workspaceSlug/integrations/sentry',
|
|
36
37
|
},
|
|
37
38
|
linear: {
|
|
38
39
|
kind: 'redirect-install',
|
|
39
40
|
iconName: PROVIDER_ICONS.linear,
|
|
40
|
-
setupPath: '/
|
|
41
|
+
setupPath: '/w/$workspaceSlug/integrations/linear',
|
|
41
42
|
},
|
|
42
43
|
slack: {
|
|
43
44
|
kind: 'redirect-install',
|
|
44
45
|
iconName: PROVIDER_ICONS.slack,
|
|
45
|
-
setupPath: '/
|
|
46
|
+
setupPath: '/w/$workspaceSlug/integrations/slack',
|
|
47
|
+
},
|
|
48
|
+
jira: {
|
|
49
|
+
kind: 'redirect-install',
|
|
50
|
+
iconName: PROVIDER_ICONS.jira,
|
|
51
|
+
setupPath: '/w/$workspaceSlug/integrations/jira',
|
|
46
52
|
},
|
|
47
53
|
gitea: {
|
|
48
54
|
kind: 'direct-connect',
|
|
49
55
|
iconName: PROVIDER_ICONS.gitea,
|
|
50
|
-
setupPath: '/
|
|
56
|
+
setupPath: '/w/$workspaceSlug/integrations/gitea',
|
|
51
57
|
},
|
|
52
58
|
webhook: {
|
|
53
59
|
kind: 'modal-connect',
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import {defineRoute} from '@shipfox/client-shell/runtime';
|
|
2
|
+
import {JiraCallbackPage} from '#pages/jira-callback-page.js';
|
|
3
|
+
import {parseJiraCallbackQuery} from '../jira-callback.js';
|
|
4
|
+
|
|
5
|
+
export default defineRoute({
|
|
6
|
+
validateSearch: parseJiraCallbackQuery,
|
|
7
|
+
component: JiraCallbackPage,
|
|
8
|
+
});
|
package/src/sentry-callback.ts
CHANGED
|
@@ -20,7 +20,7 @@ const sentryInstallWorkspaceStorageKey = {
|
|
|
20
20
|
} satisfies BrowserStorageKey<string>;
|
|
21
21
|
|
|
22
22
|
// Storage helpers swallow failures (quota, private mode, disabled storage):
|
|
23
|
-
// the handoff is an optimization, never a requirement
|
|
23
|
+
// the handoff is an optimization, never a requirement: the callback always
|
|
24
24
|
// asks for explicit confirmation and only uses the stored id to pre-select.
|
|
25
25
|
export function saveSentryInstallWorkspace(storage: WorkspaceStorage, workspaceId: string): void {
|
|
26
26
|
installWorkspaceStorage(storage).write(workspaceId);
|
|
@@ -67,7 +67,7 @@ export type SentryWorkspacePreselection =
|
|
|
67
67
|
| {kind: 'none'};
|
|
68
68
|
|
|
69
69
|
// Sentry's redirect carries no state token, so the callback can never prove it
|
|
70
|
-
// belongs to an install the user started
|
|
70
|
+
// belongs to an install the user started. Installation always requires an
|
|
71
71
|
// explicit click. The stored id (and a sole workspace) only pre-select.
|
|
72
72
|
export function preselectSentryWorkspace(
|
|
73
73
|
storedId: string | undefined,
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import {configureApiClient} from '@shipfox/client-api';
|
|
2
|
+
import {userWorkspacesQueryKey} from '@shipfox/client-shell/runtime';
|
|
3
|
+
import {QueryClient} from '@tanstack/react-query';
|
|
4
|
+
import {resolveWorkspaceSlug} from './workspace-navigation.js';
|
|
5
|
+
|
|
6
|
+
function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
|
|
7
|
+
return new Response(JSON.stringify(body), {
|
|
8
|
+
headers: {'content-type': 'application/json'},
|
|
9
|
+
status: 200,
|
|
10
|
+
...init,
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('resolveWorkspaceSlug', () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl: undefined});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('uses refreshed cached memberships when the follow-up list request fails', async () => {
|
|
20
|
+
configureApiClient({
|
|
21
|
+
fetchImpl: vi
|
|
22
|
+
.fn()
|
|
23
|
+
.mockResolvedValue(
|
|
24
|
+
jsonResponse(
|
|
25
|
+
{code: 'server-error', message: 'Workspace list unavailable'},
|
|
26
|
+
{status: 500},
|
|
27
|
+
),
|
|
28
|
+
),
|
|
29
|
+
});
|
|
30
|
+
const queryClient = new QueryClient();
|
|
31
|
+
queryClient.setQueryData(userWorkspacesQueryKey, {
|
|
32
|
+
memberships: [{id: 'response-workspace', slug: 'response-workspace'}],
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
await expect(
|
|
36
|
+
resolveWorkspaceSlug({
|
|
37
|
+
workspaceId: 'response-workspace',
|
|
38
|
+
fallbackWorkspaces: [{id: 'response-workspace', slug: 'old-slug'}],
|
|
39
|
+
queryClient,
|
|
40
|
+
}),
|
|
41
|
+
).resolves.toBe('response-workspace');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import {listUserWorkspaces, userWorkspacesQueryKey} from '@shipfox/client-shell/runtime';
|
|
2
|
+
import type {QueryClient} from '@tanstack/react-query';
|
|
3
|
+
|
|
4
|
+
interface WorkspaceSlugCandidate {
|
|
5
|
+
id: string;
|
|
6
|
+
slug: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface UserWorkspacesQueryData {
|
|
10
|
+
memberships: WorkspaceSlugCandidate[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function rememberCallbackKey(keys: Set<string>, key: string, maxSize = 32): void {
|
|
14
|
+
keys.delete(key);
|
|
15
|
+
keys.add(key);
|
|
16
|
+
if (keys.size <= maxSize) return;
|
|
17
|
+
const oldest = keys.values().next().value;
|
|
18
|
+
if (typeof oldest === 'string') keys.delete(oldest);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function resolveWorkspaceSlug({
|
|
22
|
+
workspaceId,
|
|
23
|
+
fallbackWorkspaces,
|
|
24
|
+
queryClient,
|
|
25
|
+
}: {
|
|
26
|
+
workspaceId: string;
|
|
27
|
+
fallbackWorkspaces: readonly WorkspaceSlugCandidate[];
|
|
28
|
+
queryClient: QueryClient;
|
|
29
|
+
}): Promise<string | undefined> {
|
|
30
|
+
const currentWorkspaces = await listUserWorkspaces()
|
|
31
|
+
.then(({memberships}) => memberships)
|
|
32
|
+
.catch(() => {
|
|
33
|
+
const refreshedWorkspaces =
|
|
34
|
+
queryClient.getQueryData<UserWorkspacesQueryData>(userWorkspacesQueryKey);
|
|
35
|
+
return refreshedWorkspaces?.memberships ?? fallbackWorkspaces;
|
|
36
|
+
});
|
|
37
|
+
return currentWorkspaces.find(({id}) => id === workspaceId)?.slug;
|
|
38
|
+
}
|
package/test/render.tsx
CHANGED
|
@@ -19,11 +19,12 @@ export const INTEGRATIONS_TEST_WID = '11111111-1111-4111-8111-111111111111';
|
|
|
19
19
|
export interface TestWorkspace {
|
|
20
20
|
id: string;
|
|
21
21
|
name: string;
|
|
22
|
+
slug: string;
|
|
22
23
|
membershipId: string;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
export function testWorkspace(overrides: Partial<TestWorkspace> = {}): TestWorkspace {
|
|
26
|
-
return {id: INTEGRATIONS_TEST_WID, name: 'Acme', membershipId: 'm-1', ...overrides};
|
|
27
|
+
return {id: INTEGRATIONS_TEST_WID, name: 'Acme', slug: 'acme', membershipId: 'm-1', ...overrides};
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
export function jsonResponse(body: unknown, init: ResponseInit = {}) {
|