@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
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import {useAuthState, useRefreshAuth} from '@shipfox/client-auth';
|
|
2
|
+
import {useRouteSearch} from '@shipfox/client-shell/runtime';
|
|
3
|
+
import {sessionStorageOrUndefined} from '@shipfox/client-ui';
|
|
4
|
+
import {Button, ButtonLink} from '@shipfox/react-ui/button';
|
|
5
|
+
import {Callout} from '@shipfox/react-ui/callout';
|
|
6
|
+
import {Card} from '@shipfox/react-ui/card';
|
|
7
|
+
import {FullPageLoader} from '@shipfox/react-ui/loader';
|
|
8
|
+
import {toast} from '@shipfox/react-ui/toast';
|
|
9
|
+
import {Header, Text} from '@shipfox/react-ui/typography';
|
|
10
|
+
import {useQueryClient} from '@tanstack/react-query';
|
|
11
|
+
import {Link, useNavigate} from '@tanstack/react-router';
|
|
12
|
+
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
|
13
|
+
import {
|
|
14
|
+
useCompleteIntegrationCallback,
|
|
15
|
+
useCompleteIntegrationCallbackResult,
|
|
16
|
+
} from '#application/complete-integration-callback.js';
|
|
17
|
+
import {CallbackStatusShell} from '#components/callback-status-shell.js';
|
|
18
|
+
import type {IntegrationConnection, JiraSite} from '#core/models.js';
|
|
19
|
+
import {
|
|
20
|
+
useCompleteJiraCallbackMutation,
|
|
21
|
+
useCompleteJiraSiteSelectionMutation,
|
|
22
|
+
} from '#hooks/api/integrations.js';
|
|
23
|
+
import {
|
|
24
|
+
clearJiraInstallWorkspace,
|
|
25
|
+
parseJiraCallbackQuery,
|
|
26
|
+
readJiraInstallWorkspace,
|
|
27
|
+
serializeJiraCallbackQuery,
|
|
28
|
+
} from '#jira-callback.js';
|
|
29
|
+
import {
|
|
30
|
+
JIRA_CALLBACK_CACHE_SIZE,
|
|
31
|
+
jiraCallbackRequests,
|
|
32
|
+
jiraCompletedConnections,
|
|
33
|
+
jiraSiteSelectionRequests,
|
|
34
|
+
jiraToastedCallbacks,
|
|
35
|
+
} from '#jira-callback-state.js';
|
|
36
|
+
import {classifyJiraCallbackError, type JiraCallbackFailure} from '#jira-form-errors.js';
|
|
37
|
+
import {rememberCallbackKey, resolveWorkspaceSlug} from '#workspace-navigation.js';
|
|
38
|
+
|
|
39
|
+
export function JiraCallbackPage() {
|
|
40
|
+
const navigate = useNavigate();
|
|
41
|
+
const queryClient = useQueryClient();
|
|
42
|
+
const refreshAuth = useRefreshAuth();
|
|
43
|
+
const {workspaces, isLoading} = useAuthState();
|
|
44
|
+
const completeIntegrationCallback = useCompleteIntegrationCallback();
|
|
45
|
+
const completeIntegrationCallbackResult = useCompleteIntegrationCallbackResult();
|
|
46
|
+
const {mutateAsync: completeJiraCallback} = useCompleteJiraCallbackMutation();
|
|
47
|
+
const {mutateAsync: completeJiraSiteSelection} = useCompleteJiraSiteSelectionMutation();
|
|
48
|
+
const params = useRouteSearch(parseJiraCallbackQuery);
|
|
49
|
+
const callbackKey = params ? serializeJiraCallbackQuery(params) : undefined;
|
|
50
|
+
const workspaceId = useMemo(() => readJiraInstallWorkspace(sessionStorageOrUndefined()), []);
|
|
51
|
+
const [sites, setSites] = useState<JiraSite[] | undefined>();
|
|
52
|
+
const [failure, setFailure] = useState<JiraCallbackFailure | undefined>();
|
|
53
|
+
const [selectedCloudId, setSelectedCloudId] = useState<string | undefined>();
|
|
54
|
+
const [completedWorkspace, setCompletedWorkspace] = useState<{
|
|
55
|
+
slug?: string | undefined;
|
|
56
|
+
}>();
|
|
57
|
+
const disposedRef = useRef(false);
|
|
58
|
+
const callbackKeyRef = useRef(callbackKey);
|
|
59
|
+
callbackKeyRef.current = callbackKey;
|
|
60
|
+
const [callbackStateKey, setCallbackStateKey] = useState(callbackKey);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
disposedRef.current = false;
|
|
64
|
+
return () => {
|
|
65
|
+
disposedRef.current = true;
|
|
66
|
+
};
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
const completeConnection = useCallback(
|
|
70
|
+
async (
|
|
71
|
+
connection: IntegrationConnection,
|
|
72
|
+
callbackKey: string,
|
|
73
|
+
isActive: () => boolean = () => true,
|
|
74
|
+
) => {
|
|
75
|
+
if (disposedRef.current || !isActive()) return;
|
|
76
|
+
const completedConnection = jiraCompletedConnections.get(callbackKey);
|
|
77
|
+
if (completedConnection) {
|
|
78
|
+
const workspaceSlug = await resolveWorkspaceSlug({
|
|
79
|
+
workspaceId: completedConnection.workspaceId,
|
|
80
|
+
fallbackWorkspaces: workspaces,
|
|
81
|
+
queryClient,
|
|
82
|
+
});
|
|
83
|
+
if (!disposedRef.current && isActive())
|
|
84
|
+
setCompletedWorkspace(workspaceSlug ? {slug: workspaceSlug} : {});
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
rememberCompletedConnection(callbackKey, connection);
|
|
88
|
+
try {
|
|
89
|
+
clearJiraInstallWorkspace(sessionStorageOrUndefined());
|
|
90
|
+
} catch {
|
|
91
|
+
// The successful API response remains the source of truth for navigation.
|
|
92
|
+
}
|
|
93
|
+
if (disposedRef.current || !isActive()) return;
|
|
94
|
+
if (!jiraToastedCallbacks.has(callbackKey)) {
|
|
95
|
+
rememberCallbackKey(jiraToastedCallbacks, callbackKey);
|
|
96
|
+
toast.success('Jira installed.');
|
|
97
|
+
}
|
|
98
|
+
let workspaceSlug: string | undefined;
|
|
99
|
+
try {
|
|
100
|
+
workspaceSlug = await resolveWorkspaceSlug({
|
|
101
|
+
workspaceId: connection.workspaceId,
|
|
102
|
+
fallbackWorkspaces: workspaces,
|
|
103
|
+
queryClient,
|
|
104
|
+
});
|
|
105
|
+
if (disposedRef.current || !isActive()) return;
|
|
106
|
+
if (!workspaceSlug) {
|
|
107
|
+
setCompletedWorkspace({});
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
setCompletedWorkspace({slug: workspaceSlug});
|
|
111
|
+
await navigate({
|
|
112
|
+
to: '/w/$workspaceSlug/settings/integrations',
|
|
113
|
+
params: {workspaceSlug},
|
|
114
|
+
replace: true,
|
|
115
|
+
});
|
|
116
|
+
} catch {
|
|
117
|
+
if (!disposedRef.current && isActive()) setCompletedWorkspace({slug: workspaceSlug});
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
[navigate, queryClient, workspaces],
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
if (callbackKey === undefined) return;
|
|
125
|
+
setCallbackStateKey(callbackKey);
|
|
126
|
+
setSites(undefined);
|
|
127
|
+
setSelectedCloudId(undefined);
|
|
128
|
+
setFailure(undefined);
|
|
129
|
+
setCompletedWorkspace(undefined);
|
|
130
|
+
}, [callbackKey]);
|
|
131
|
+
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
if (!params || !callbackKey || isLoading) return;
|
|
134
|
+
|
|
135
|
+
let active = true;
|
|
136
|
+
|
|
137
|
+
const completedConnection = jiraCompletedConnections.get(callbackKey);
|
|
138
|
+
if (completedConnection) {
|
|
139
|
+
void completeConnection(completedConnection, callbackKey, () => active);
|
|
140
|
+
return () => {
|
|
141
|
+
active = false;
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const request = jiraCallbackRequests.run(callbackKey, async () => {
|
|
146
|
+
return await completeIntegrationCallbackResult({
|
|
147
|
+
input: params,
|
|
148
|
+
refreshAuth,
|
|
149
|
+
complete: async (query, token) => await completeJiraCallback({query, token}),
|
|
150
|
+
getConnection: (result) => ('sites' in result ? undefined : result),
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
request.then(
|
|
155
|
+
async (result) => {
|
|
156
|
+
if (disposedRef.current || !active) return;
|
|
157
|
+
if ('sites' in result) {
|
|
158
|
+
setFailure(undefined);
|
|
159
|
+
setSites(result.sites);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
await completeConnection(result, callbackKey, () => active);
|
|
163
|
+
},
|
|
164
|
+
(error: unknown) => {
|
|
165
|
+
if (!disposedRef.current && active) setFailure(classifyJiraCallbackError(error));
|
|
166
|
+
},
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
return () => {
|
|
170
|
+
active = false;
|
|
171
|
+
};
|
|
172
|
+
}, [
|
|
173
|
+
callbackKey,
|
|
174
|
+
completeIntegrationCallbackResult,
|
|
175
|
+
completeJiraCallback,
|
|
176
|
+
completeConnection,
|
|
177
|
+
isLoading,
|
|
178
|
+
params,
|
|
179
|
+
refreshAuth,
|
|
180
|
+
]);
|
|
181
|
+
|
|
182
|
+
function selectSite(site: JiraSite) {
|
|
183
|
+
if (!params || !callbackKey || selectedCloudId) return;
|
|
184
|
+
setSelectedCloudId(site.cloudId);
|
|
185
|
+
setFailure(undefined);
|
|
186
|
+
const selectionKey = callbackKey;
|
|
187
|
+
const request = jiraSiteSelectionRequests.run(params.state, async () => {
|
|
188
|
+
return await completeIntegrationCallback({
|
|
189
|
+
input: {cloud_id: site.cloudId, state: params.state},
|
|
190
|
+
refreshAuth,
|
|
191
|
+
complete: async (body, token) => await completeJiraSiteSelection({body, token}),
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
request.then(
|
|
195
|
+
async (connection) => {
|
|
196
|
+
if (disposedRef.current || callbackKeyRef.current !== selectionKey) return;
|
|
197
|
+
jiraCallbackRequests.clear(selectionKey);
|
|
198
|
+
await completeConnection(
|
|
199
|
+
connection,
|
|
200
|
+
selectionKey,
|
|
201
|
+
() => callbackKeyRef.current === selectionKey,
|
|
202
|
+
);
|
|
203
|
+
},
|
|
204
|
+
(error: unknown) => {
|
|
205
|
+
if (disposedRef.current || callbackKeyRef.current !== selectionKey) return;
|
|
206
|
+
setSelectedCloudId(undefined);
|
|
207
|
+
setFailure(classifyJiraCallbackError(error));
|
|
208
|
+
},
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (!params) {
|
|
213
|
+
return (
|
|
214
|
+
<JiraCallbackFailurePage
|
|
215
|
+
failure={classifyJiraCallbackError(new Error('invalid callback'))}
|
|
216
|
+
workspaceSlug={workspaces.find(({id}) => id === workspaceId)?.slug}
|
|
217
|
+
/>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (isLoading) return <FullPageLoader aria-label="Connecting Jira" />;
|
|
222
|
+
|
|
223
|
+
if (callbackStateKey === callbackKey && completedWorkspace)
|
|
224
|
+
return (
|
|
225
|
+
<CallbackStatusShell
|
|
226
|
+
title="Jira connected"
|
|
227
|
+
status="success"
|
|
228
|
+
message={
|
|
229
|
+
completedWorkspace.slug
|
|
230
|
+
? 'Jira is connected. Continue in integrations settings.'
|
|
231
|
+
: 'Jira is connected. Return to Shipfox to continue.'
|
|
232
|
+
}
|
|
233
|
+
workspaceSlug={completedWorkspace.slug}
|
|
234
|
+
installPath="/w/$workspaceSlug/integrations/jira"
|
|
235
|
+
/>
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
if (callbackStateKey === callbackKey && sites)
|
|
239
|
+
return (
|
|
240
|
+
<JiraSiteSelectionPage
|
|
241
|
+
sites={sites}
|
|
242
|
+
selectedCloudId={selectedCloudId}
|
|
243
|
+
failure={failure}
|
|
244
|
+
workspaceSlug={workspaces.find(({id}) => id === workspaceId)?.slug}
|
|
245
|
+
onSelect={selectSite}
|
|
246
|
+
/>
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
if (callbackStateKey === callbackKey && failure)
|
|
250
|
+
return (
|
|
251
|
+
<JiraCallbackFailurePage
|
|
252
|
+
failure={failure}
|
|
253
|
+
workspaceSlug={workspaces.find(({id}) => id === workspaceId)?.slug}
|
|
254
|
+
/>
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
return <FullPageLoader aria-label="Connecting Jira" />;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function rememberCompletedConnection(key: string, connection: IntegrationConnection): void {
|
|
261
|
+
jiraCompletedConnections.delete(key);
|
|
262
|
+
jiraCompletedConnections.set(key, connection);
|
|
263
|
+
while (jiraCompletedConnections.size > JIRA_CALLBACK_CACHE_SIZE) {
|
|
264
|
+
const oldestKey = jiraCompletedConnections.keys().next().value;
|
|
265
|
+
if (oldestKey === undefined) return;
|
|
266
|
+
jiraCompletedConnections.delete(oldestKey);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function JiraCallbackFailurePage({
|
|
271
|
+
failure,
|
|
272
|
+
workspaceSlug,
|
|
273
|
+
}: {
|
|
274
|
+
failure: JiraCallbackFailure;
|
|
275
|
+
workspaceSlug: string | undefined;
|
|
276
|
+
}) {
|
|
277
|
+
return (
|
|
278
|
+
<CallbackStatusShell
|
|
279
|
+
title={failure.title}
|
|
280
|
+
message={failure.message}
|
|
281
|
+
startOver={failure.startOver}
|
|
282
|
+
switchAccount={failure.signIn}
|
|
283
|
+
workspaceSlug={workspaceSlug}
|
|
284
|
+
installPath="/w/$workspaceSlug/integrations/jira"
|
|
285
|
+
/>
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function JiraSiteSelectionPage({
|
|
290
|
+
sites,
|
|
291
|
+
selectedCloudId,
|
|
292
|
+
failure,
|
|
293
|
+
workspaceSlug,
|
|
294
|
+
onSelect,
|
|
295
|
+
}: {
|
|
296
|
+
sites: JiraSite[];
|
|
297
|
+
selectedCloudId: string | undefined;
|
|
298
|
+
failure: JiraCallbackFailure | undefined;
|
|
299
|
+
workspaceSlug: string | undefined;
|
|
300
|
+
onSelect: (site: JiraSite) => void;
|
|
301
|
+
}) {
|
|
302
|
+
return (
|
|
303
|
+
<main className="flex min-h-screen bg-background-subtle-base px-16 py-32">
|
|
304
|
+
<div className="mx-auto flex w-full max-w-[480px] flex-col justify-center gap-20">
|
|
305
|
+
<header className="flex flex-col gap-8">
|
|
306
|
+
<Header variant="h2">Choose a Jira site</Header>
|
|
307
|
+
<Text size="sm" className="text-foreground-neutral-muted">
|
|
308
|
+
Select the Jira site to connect to this Shipfox workspace.
|
|
309
|
+
</Text>
|
|
310
|
+
</header>
|
|
311
|
+
|
|
312
|
+
{failure ? (
|
|
313
|
+
<Callout role="alert" type="error">
|
|
314
|
+
<Text size="sm">{failure.message}</Text>
|
|
315
|
+
</Callout>
|
|
316
|
+
) : null}
|
|
317
|
+
|
|
318
|
+
<section className="flex flex-col gap-8" aria-label="Choose a Jira site">
|
|
319
|
+
{sites.map((site) => (
|
|
320
|
+
<Card key={site.cloudId} className="p-16">
|
|
321
|
+
<div className="flex items-center justify-between gap-12">
|
|
322
|
+
<div className="min-w-0">
|
|
323
|
+
<Text size="md" bold className="truncate">
|
|
324
|
+
{site.name}
|
|
325
|
+
</Text>
|
|
326
|
+
<Text size="sm" className="truncate text-foreground-neutral-muted">
|
|
327
|
+
{site.url}
|
|
328
|
+
</Text>
|
|
329
|
+
</div>
|
|
330
|
+
<Button
|
|
331
|
+
variant="secondary"
|
|
332
|
+
disabled={selectedCloudId !== undefined}
|
|
333
|
+
isLoading={selectedCloudId === site.cloudId}
|
|
334
|
+
onClick={() => onSelect(site)}
|
|
335
|
+
>
|
|
336
|
+
Connect
|
|
337
|
+
</Button>
|
|
338
|
+
</div>
|
|
339
|
+
</Card>
|
|
340
|
+
))}
|
|
341
|
+
</section>
|
|
342
|
+
|
|
343
|
+
<div className="flex flex-col gap-8 sm:flex-row sm:items-center">
|
|
344
|
+
{failure?.startOver && workspaceSlug ? (
|
|
345
|
+
<ButtonLink asChild variant="muted" className="min-h-44 w-full sm:w-fit">
|
|
346
|
+
<Link to="/w/$workspaceSlug/integrations/jira" params={{workspaceSlug}}>
|
|
347
|
+
Start over
|
|
348
|
+
</Link>
|
|
349
|
+
</ButtonLink>
|
|
350
|
+
) : null}
|
|
351
|
+
{workspaceSlug ? (
|
|
352
|
+
<ButtonLink asChild variant="muted" className="min-h-44 w-full sm:w-fit">
|
|
353
|
+
<Link to="/w/$workspaceSlug/settings/integrations" params={{workspaceSlug}}>
|
|
354
|
+
Back to integrations
|
|
355
|
+
</Link>
|
|
356
|
+
</ButtonLink>
|
|
357
|
+
) : (
|
|
358
|
+
<ButtonLink asChild variant="muted" className="min-h-44 w-full sm:w-fit">
|
|
359
|
+
<Link to="/">Back to Shipfox</Link>
|
|
360
|
+
</ButtonLink>
|
|
361
|
+
)}
|
|
362
|
+
</div>
|
|
363
|
+
</div>
|
|
364
|
+
</main>
|
|
365
|
+
);
|
|
366
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import {sessionStorageOrUndefined} from '@shipfox/client-ui';
|
|
2
|
+
import {useCallback} from 'react';
|
|
3
|
+
import {RedirectInstallPage} from '#components/redirect-install-page.js';
|
|
4
|
+
import {useCreateJiraInstallMutation} from '#hooks/api/integrations.js';
|
|
5
|
+
import {saveJiraInstallWorkspace} from '#jira-callback.js';
|
|
6
|
+
|
|
7
|
+
export function JiraInstallPage() {
|
|
8
|
+
const createInstall = useCreateJiraInstallMutation();
|
|
9
|
+
const installRequest = useCallback(
|
|
10
|
+
async (body: {workspace_id: string}) => await createInstall.mutateAsync(body),
|
|
11
|
+
[createInstall],
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<RedirectInstallPage
|
|
16
|
+
installRequest={installRequest}
|
|
17
|
+
errorFallbackMessage="Could not start Jira install."
|
|
18
|
+
loadingLabel="Connecting Jira"
|
|
19
|
+
beforeRedirect={(workspaceId) => {
|
|
20
|
+
try {
|
|
21
|
+
saveJiraInstallWorkspace(sessionStorageOrUndefined(), workspaceId);
|
|
22
|
+
} catch {
|
|
23
|
+
// Storage is only a navigation hint; the signed state is authoritative.
|
|
24
|
+
}
|
|
25
|
+
}}
|
|
26
|
+
/>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -4,7 +4,7 @@ import {ApiError} from '@shipfox/client-api';
|
|
|
4
4
|
import {screen, waitFor} from '@testing-library/react';
|
|
5
5
|
import {StrictMode} from 'react';
|
|
6
6
|
import {LINEAR_INSTALL_WORKSPACE_KEY} from '#linear-callback.js';
|
|
7
|
-
import {INTEGRATIONS_TEST_WID, renderIntegrationsPage} from '#test/render.js';
|
|
7
|
+
import {INTEGRATIONS_TEST_WID, renderIntegrationsPage, testWorkspace} from '#test/render.js';
|
|
8
8
|
import {LinearCallbackPage} from './linear-callback-page.js';
|
|
9
9
|
|
|
10
10
|
const {completeCallbackMock} = vi.hoisted(() => ({completeCallbackMock: vi.fn()}));
|
|
@@ -37,8 +37,8 @@ function renderCallback(search: string, options?: {strict?: boolean}) {
|
|
|
37
37
|
<LinearCallbackPage />
|
|
38
38
|
),
|
|
39
39
|
extraRoutes: [
|
|
40
|
-
'/
|
|
41
|
-
'/
|
|
40
|
+
'/w/$workspaceSlug/settings/integrations',
|
|
41
|
+
'/w/$workspaceSlug/integrations/linear',
|
|
42
42
|
'/auth/login',
|
|
43
43
|
],
|
|
44
44
|
});
|
|
@@ -70,7 +70,7 @@ describe('LinearCallbackPage', () => {
|
|
|
70
70
|
window.sessionStorage.setItem(LINEAR_INSTALL_WORKSPACE_KEY, INTEGRATIONS_TEST_WID);
|
|
71
71
|
completeCallbackMock.mockResolvedValue({
|
|
72
72
|
id: 'connection-1',
|
|
73
|
-
|
|
73
|
+
workspaceId: responseWorkspaceId,
|
|
74
74
|
provider: 'linear',
|
|
75
75
|
external_account_id: 'linear-org',
|
|
76
76
|
slug: 'linear_org',
|
|
@@ -81,7 +81,24 @@ describe('LinearCallbackPage', () => {
|
|
|
81
81
|
updated_at: '2026-01-01T00:00:00.000Z',
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
-
|
|
84
|
+
renderIntegrationsPage({
|
|
85
|
+
path: '/integrations/linear/callback?code=grant-code-success&state=signed-state-success',
|
|
86
|
+
routePath: '/integrations/linear/callback',
|
|
87
|
+
element: (
|
|
88
|
+
<StrictMode>
|
|
89
|
+
<LinearCallbackPage />
|
|
90
|
+
</StrictMode>
|
|
91
|
+
),
|
|
92
|
+
workspaces: [
|
|
93
|
+
testWorkspace(),
|
|
94
|
+
testWorkspace({id: responseWorkspaceId, slug: 'response-workspace'}),
|
|
95
|
+
],
|
|
96
|
+
extraRoutes: [
|
|
97
|
+
'/w/response-workspace/settings/integrations',
|
|
98
|
+
'/w/$workspaceSlug/integrations/linear',
|
|
99
|
+
'/auth/login',
|
|
100
|
+
],
|
|
101
|
+
});
|
|
85
102
|
|
|
86
103
|
await waitFor(() =>
|
|
87
104
|
expect(completeCallbackMock).toHaveBeenCalledWith({
|
|
@@ -92,7 +109,7 @@ describe('LinearCallbackPage', () => {
|
|
|
92
109
|
expect(completeCallbackMock).toHaveBeenCalledTimes(1);
|
|
93
110
|
await waitFor(() =>
|
|
94
111
|
expect(
|
|
95
|
-
screen.getByTestId('route:/
|
|
112
|
+
screen.getByTestId('route:/w/response-workspace/settings/integrations'),
|
|
96
113
|
).toBeInTheDocument(),
|
|
97
114
|
);
|
|
98
115
|
expect(window.sessionStorage.getItem(LINEAR_INSTALL_WORKSPACE_KEY)).toBeNull();
|
|
@@ -136,14 +153,10 @@ describe('LinearCallbackPage', () => {
|
|
|
136
153
|
|
|
137
154
|
expect(switchAccountLink).toHaveAttribute(
|
|
138
155
|
'href',
|
|
139
|
-
`/auth/logout?redirect=${encodeURIComponent(
|
|
140
|
-
`/workspaces/${INTEGRATIONS_TEST_WID}/integrations/linear`,
|
|
141
|
-
)}`,
|
|
156
|
+
`/auth/logout?redirect=${encodeURIComponent('/w/acme/integrations/linear')}`,
|
|
142
157
|
);
|
|
143
158
|
expect(switchAccountUrl.pathname).toBe('/auth/logout');
|
|
144
|
-
expect(switchAccountUrl.searchParams.get('redirect')).toBe(
|
|
145
|
-
`/workspaces/${INTEGRATIONS_TEST_WID}/integrations/linear`,
|
|
146
|
-
);
|
|
159
|
+
expect(switchAccountUrl.searchParams.get('redirect')).toBe('/w/acme/integrations/linear');
|
|
147
160
|
expect(screen.getByRole('link', {name: 'Start over'})).toBeVisible();
|
|
148
161
|
});
|
|
149
162
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {useRefreshAuth} from '@shipfox/client-auth';
|
|
1
|
+
import {useAuthState, useRefreshAuth} from '@shipfox/client-auth';
|
|
2
2
|
import {useRouteSearch} from '@shipfox/client-shell/runtime';
|
|
3
3
|
import {createSingleFlight, sessionStorageOrUndefined} from '@shipfox/client-ui';
|
|
4
4
|
import {FullPageLoader} from '@shipfox/react-ui/loader';
|
|
5
5
|
import {toast} from '@shipfox/react-ui/toast';
|
|
6
|
+
import {useQueryClient} from '@tanstack/react-query';
|
|
6
7
|
import {useNavigate} from '@tanstack/react-router';
|
|
7
8
|
import {useEffect, useMemo, useState} from 'react';
|
|
8
9
|
import {useCompleteIntegrationCallback} from '#application/complete-integration-callback.js';
|
|
@@ -17,26 +18,33 @@ import {
|
|
|
17
18
|
readLinearInstallWorkspace,
|
|
18
19
|
serializeLinearCallbackQuery,
|
|
19
20
|
} from '#linear-callback.js';
|
|
21
|
+
import {rememberCallbackKey, resolveWorkspaceSlug} from '#workspace-navigation.js';
|
|
20
22
|
|
|
21
23
|
// Retain only recent completions: this bounds long-lived callback pages while
|
|
22
24
|
// still covering StrictMode and immediate Back/Forward remounts.
|
|
23
25
|
const callbackRequests = createSingleFlight<string, IntegrationConnection>({
|
|
24
26
|
maxTerminalResults: 32,
|
|
25
27
|
});
|
|
28
|
+
const completedCallbacks = new Set<string>();
|
|
26
29
|
// Keeps the success toast firing once per distinct callback even though the
|
|
27
30
|
// effect re-runs against the cached request as the mutation identity churns.
|
|
28
31
|
const toastedCallbacks = new Set<string>();
|
|
29
32
|
|
|
30
33
|
export function LinearCallbackPage() {
|
|
31
34
|
const navigate = useNavigate();
|
|
35
|
+
const queryClient = useQueryClient();
|
|
32
36
|
const refreshAuth = useRefreshAuth();
|
|
33
37
|
const completeIntegrationCallback = useCompleteIntegrationCallback();
|
|
34
38
|
const {mutateAsync: completeLinearCallback} = useCompleteLinearCallbackMutation();
|
|
39
|
+
const {workspaces, isLoading} = useAuthState();
|
|
35
40
|
const params = useRouteSearch(parseLinearCallbackQuery);
|
|
36
41
|
const workspaceId = useMemo(() => readLinearInstallWorkspace(sessionStorageOrUndefined()), []);
|
|
37
42
|
const [failure, setFailure] = useState<LinearCallbackFailure | undefined>();
|
|
43
|
+
const [completedWorkspace, setCompletedWorkspace] = useState<{
|
|
44
|
+
slug?: string | undefined;
|
|
45
|
+
}>();
|
|
38
46
|
useEffect(() => {
|
|
39
|
-
if (!params) return;
|
|
47
|
+
if (!params || isLoading) return;
|
|
40
48
|
|
|
41
49
|
let disposed = false;
|
|
42
50
|
const key = serializeLinearCallbackQuery(params);
|
|
@@ -53,6 +61,16 @@ export function LinearCallbackPage() {
|
|
|
53
61
|
request.then(
|
|
54
62
|
async (connection) => {
|
|
55
63
|
if (disposed) return;
|
|
64
|
+
if (completedCallbacks.has(key)) {
|
|
65
|
+
const workspaceSlug = await resolveWorkspaceSlug({
|
|
66
|
+
workspaceId: connection.workspaceId,
|
|
67
|
+
fallbackWorkspaces: workspaces,
|
|
68
|
+
queryClient,
|
|
69
|
+
});
|
|
70
|
+
if (!disposed) setCompletedWorkspace(workspaceSlug ? {slug: workspaceSlug} : {});
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
rememberCallbackKey(completedCallbacks, key);
|
|
56
74
|
try {
|
|
57
75
|
clearLinearInstallWorkspace(sessionStorageOrUndefined());
|
|
58
76
|
} catch {
|
|
@@ -60,17 +78,31 @@ export function LinearCallbackPage() {
|
|
|
60
78
|
}
|
|
61
79
|
if (disposed) return;
|
|
62
80
|
if (!toastedCallbacks.has(key)) {
|
|
63
|
-
toastedCallbacks
|
|
81
|
+
rememberCallbackKey(toastedCallbacks, key);
|
|
64
82
|
toast.success('Linear installed.');
|
|
65
83
|
}
|
|
84
|
+
let workspaceSlug: string | undefined;
|
|
66
85
|
try {
|
|
86
|
+
if (disposed) return;
|
|
87
|
+
workspaceSlug = await resolveWorkspaceSlug({
|
|
88
|
+
workspaceId: connection.workspaceId,
|
|
89
|
+
fallbackWorkspaces: workspaces,
|
|
90
|
+
queryClient,
|
|
91
|
+
});
|
|
92
|
+
if (disposed) return;
|
|
93
|
+
if (!workspaceSlug) {
|
|
94
|
+
setCompletedWorkspace({});
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
setCompletedWorkspace({slug: workspaceSlug});
|
|
67
98
|
await navigate({
|
|
68
|
-
to: '/
|
|
69
|
-
params: {
|
|
99
|
+
to: '/w/$workspaceSlug/settings/integrations',
|
|
100
|
+
params: {workspaceSlug},
|
|
70
101
|
replace: true,
|
|
71
102
|
});
|
|
72
103
|
} catch {
|
|
73
104
|
// Keep the completed callback page visible if client navigation is interrupted.
|
|
105
|
+
if (!disposed) setCompletedWorkspace({slug: workspaceSlug});
|
|
74
106
|
}
|
|
75
107
|
},
|
|
76
108
|
(error: unknown) => {
|
|
@@ -82,7 +114,16 @@ export function LinearCallbackPage() {
|
|
|
82
114
|
return () => {
|
|
83
115
|
disposed = true;
|
|
84
116
|
};
|
|
85
|
-
}, [
|
|
117
|
+
}, [
|
|
118
|
+
completeIntegrationCallback,
|
|
119
|
+
completeLinearCallback,
|
|
120
|
+
isLoading,
|
|
121
|
+
navigate,
|
|
122
|
+
params,
|
|
123
|
+
queryClient,
|
|
124
|
+
refreshAuth,
|
|
125
|
+
workspaces,
|
|
126
|
+
]);
|
|
86
127
|
|
|
87
128
|
if (!params) {
|
|
88
129
|
return (
|
|
@@ -93,22 +134,42 @@ export function LinearCallbackPage() {
|
|
|
93
134
|
startOver: true,
|
|
94
135
|
signIn: false,
|
|
95
136
|
}}
|
|
96
|
-
|
|
137
|
+
workspaceSlug={workspaces.find(({id}) => id === workspaceId)?.slug}
|
|
97
138
|
/>
|
|
98
139
|
);
|
|
99
140
|
}
|
|
100
141
|
|
|
142
|
+
if (completedWorkspace)
|
|
143
|
+
return (
|
|
144
|
+
<CallbackStatusShell
|
|
145
|
+
title="Linear connected"
|
|
146
|
+
status="success"
|
|
147
|
+
message={
|
|
148
|
+
completedWorkspace.slug
|
|
149
|
+
? 'Linear is connected. Continue in integrations settings.'
|
|
150
|
+
: 'Linear is connected. Return to Shipfox to continue.'
|
|
151
|
+
}
|
|
152
|
+
workspaceSlug={completedWorkspace.slug}
|
|
153
|
+
installPath="/w/$workspaceSlug/integrations/linear"
|
|
154
|
+
/>
|
|
155
|
+
);
|
|
156
|
+
|
|
101
157
|
if (!failure) return <FullPageLoader aria-label="Connecting Linear" />;
|
|
102
158
|
|
|
103
|
-
return
|
|
159
|
+
return (
|
|
160
|
+
<LinearCallbackFailurePage
|
|
161
|
+
failure={failure}
|
|
162
|
+
workspaceSlug={workspaces.find(({id}) => id === workspaceId)?.slug}
|
|
163
|
+
/>
|
|
164
|
+
);
|
|
104
165
|
}
|
|
105
166
|
|
|
106
167
|
function LinearCallbackFailurePage({
|
|
107
168
|
failure,
|
|
108
|
-
|
|
169
|
+
workspaceSlug,
|
|
109
170
|
}: {
|
|
110
171
|
failure: LinearCallbackFailure;
|
|
111
|
-
|
|
172
|
+
workspaceSlug: string | undefined;
|
|
112
173
|
}) {
|
|
113
174
|
return (
|
|
114
175
|
<CallbackStatusShell
|
|
@@ -116,8 +177,8 @@ function LinearCallbackFailurePage({
|
|
|
116
177
|
message={failure.message}
|
|
117
178
|
startOver={failure.startOver}
|
|
118
179
|
switchAccount={failure.signIn}
|
|
119
|
-
|
|
120
|
-
installPath="/
|
|
180
|
+
workspaceSlug={workspaceSlug}
|
|
181
|
+
installPath="/w/$workspaceSlug/integrations/linear"
|
|
121
182
|
/>
|
|
122
183
|
);
|
|
123
184
|
}
|
|
@@ -41,7 +41,10 @@ function renderCallback(options: {
|
|
|
41
41
|
path: `/integrations/sentry/callback${search}`,
|
|
42
42
|
routePath: '/integrations/sentry/callback',
|
|
43
43
|
element: <SentryCallbackPage />,
|
|
44
|
-
extraRoutes: [
|
|
44
|
+
extraRoutes: [
|
|
45
|
+
'/w/$workspaceSlug/settings/integrations',
|
|
46
|
+
'/w/$workspaceSlug/integrations/sentry',
|
|
47
|
+
],
|
|
45
48
|
...(options.workspaces ? {workspaces: options.workspaces} : {}),
|
|
46
49
|
...(options.loadingAuth ? {loadingAuth: true} : {}),
|
|
47
50
|
});
|
|
@@ -104,7 +107,7 @@ describe('SentryCallbackPage', () => {
|
|
|
104
107
|
|
|
105
108
|
fireEvent.click(await screen.findByRole('button', {name: 'Install'}));
|
|
106
109
|
|
|
107
|
-
await screen.findByTestId('route:/
|
|
110
|
+
await screen.findByTestId('route:/w/$workspaceSlug/settings/integrations');
|
|
108
111
|
expect(connectSentryMock).toHaveBeenCalledWith({
|
|
109
112
|
body: {
|
|
110
113
|
workspace_id: testWorkspace().id,
|
|
@@ -126,7 +129,7 @@ describe('SentryCallbackPage', () => {
|
|
|
126
129
|
fireEvent.click(await screen.findByRole('button', {name: 'Install'}));
|
|
127
130
|
fireEvent.click(await screen.findByRole('button', {name: 'Retry'}));
|
|
128
131
|
|
|
129
|
-
await screen.findByTestId('route:/
|
|
132
|
+
await screen.findByTestId('route:/w/$workspaceSlug/settings/integrations');
|
|
130
133
|
expect(connectSentryMock).toHaveBeenCalledTimes(2);
|
|
131
134
|
});
|
|
132
135
|
|
|
@@ -149,7 +152,7 @@ describe('SentryCallbackPage', () => {
|
|
|
149
152
|
await waitFor(() => expect(screen.getByRole('button', {name: 'Retry'})).toBeDisabled());
|
|
150
153
|
|
|
151
154
|
// A second attempt (via the still-enabled workspace Install) fails with no
|
|
152
|
-
// backoff hint
|
|
155
|
+
// backoff hint: the lock must clear so the user is not stranded.
|
|
153
156
|
fireEvent.click(screen.getByRole('button', {name: 'Install'}));
|
|
154
157
|
await waitFor(() => expect(screen.getByRole('button', {name: 'Retry'})).toBeEnabled());
|
|
155
158
|
});
|
|
@@ -203,7 +206,7 @@ describe('SentryCallbackPage', () => {
|
|
|
203
206
|
renderCallback({installationId: 'install-remount'});
|
|
204
207
|
fireEvent.click(await screen.findByRole('button', {name: 'Install'}));
|
|
205
208
|
|
|
206
|
-
await screen.findByTestId('route:/
|
|
209
|
+
await screen.findByTestId('route:/w/$workspaceSlug/settings/integrations');
|
|
207
210
|
await waitFor(() => expect(connectSentryMock).toHaveBeenCalledTimes(2));
|
|
208
211
|
});
|
|
209
212
|
});
|