@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.
Files changed (145) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +52 -0
  3. package/dist/application/complete-integration-callback.d.ts +11 -0
  4. package/dist/application/complete-integration-callback.d.ts.map +1 -1
  5. package/dist/application/complete-integration-callback.js +38 -8
  6. package/dist/application/complete-integration-callback.js.map +1 -1
  7. package/dist/components/callback-status-shell.d.ts +4 -3
  8. package/dist/components/callback-status-shell.d.ts.map +1 -1
  9. package/dist/components/callback-status-shell.js +11 -11
  10. package/dist/components/callback-status-shell.js.map +1 -1
  11. package/dist/components/installed-integrations-section.d.ts +2 -1
  12. package/dist/components/installed-integrations-section.d.ts.map +1 -1
  13. package/dist/components/installed-integrations-section.js +7 -6
  14. package/dist/components/installed-integrations-section.js.map +1 -1
  15. package/dist/components/integration-gallery-for-workspace.d.ts.map +1 -1
  16. package/dist/components/integration-gallery-for-workspace.js +11 -2
  17. package/dist/components/integration-gallery-for-workspace.js.map +1 -1
  18. package/dist/components/integration-gallery.stories.js +10 -9
  19. package/dist/components/integration-gallery.stories.js.map +1 -1
  20. package/dist/components/provider-grid.d.ts +2 -2
  21. package/dist/components/provider-grid.d.ts.map +1 -1
  22. package/dist/components/provider-grid.js +4 -4
  23. package/dist/components/provider-grid.js.map +1 -1
  24. package/dist/components/redirect-install-page.d.ts +1 -1
  25. package/dist/components/redirect-install-page.js +2 -2
  26. package/dist/components/redirect-install-page.js.map +1 -1
  27. package/dist/components/webhook/webhook-modals.stories.js +2 -2
  28. package/dist/components/webhook/webhook-modals.stories.js.map +1 -1
  29. package/dist/core/models.d.ts +6 -0
  30. package/dist/core/models.d.ts.map +1 -1
  31. package/dist/core/models.js +12 -0
  32. package/dist/core/models.js.map +1 -1
  33. package/dist/feature.d.ts +15 -7
  34. package/dist/feature.d.ts.map +1 -1
  35. package/dist/feature.js +17 -7
  36. package/dist/feature.js.map +1 -1
  37. package/dist/hooks/api/integration-mapper.d.ts +3 -1
  38. package/dist/hooks/api/integration-mapper.d.ts.map +1 -1
  39. package/dist/hooks/api/integration-mapper.js +8 -0
  40. package/dist/hooks/api/integration-mapper.js.map +1 -1
  41. package/dist/hooks/api/integrations.d.ts +25 -1
  42. package/dist/hooks/api/integrations.d.ts.map +1 -1
  43. package/dist/hooks/api/integrations.js +45 -2
  44. package/dist/hooks/api/integrations.js.map +1 -1
  45. package/dist/index.d.ts +4 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +4 -0
  48. package/dist/index.js.map +1 -1
  49. package/dist/jira-callback-state.d.ts +9 -0
  50. package/dist/jira-callback-state.d.ts.map +1 -0
  51. package/dist/jira-callback-state.js +16 -0
  52. package/dist/jira-callback-state.js.map +1 -0
  53. package/dist/jira-callback.d.ts +11 -0
  54. package/dist/jira-callback.d.ts.map +1 -0
  55. package/dist/jira-callback.js +59 -0
  56. package/dist/jira-callback.js.map +1 -0
  57. package/dist/jira-form-errors.d.ts +8 -0
  58. package/dist/jira-form-errors.d.ts.map +1 -0
  59. package/dist/jira-form-errors.js +55 -0
  60. package/dist/jira-form-errors.js.map +1 -0
  61. package/dist/pages/gitea-install-page.js +4 -4
  62. package/dist/pages/gitea-install-page.js.map +1 -1
  63. package/dist/pages/jira-callback-page.d.ts +2 -0
  64. package/dist/pages/jira-callback-page.d.ts.map +1 -0
  65. package/dist/pages/jira-callback-page.js +341 -0
  66. package/dist/pages/jira-callback-page.js.map +1 -0
  67. package/dist/pages/jira-install-page.d.ts +2 -0
  68. package/dist/pages/jira-install-page.d.ts.map +1 -0
  69. package/dist/pages/jira-install-page.js +26 -0
  70. package/dist/pages/jira-install-page.js.map +1 -0
  71. package/dist/pages/linear-callback-page.d.ts.map +1 -1
  72. package/dist/pages/linear-callback-page.js +58 -12
  73. package/dist/pages/linear-callback-page.js.map +1 -1
  74. package/dist/pages/sentry-callback-page.d.ts.map +1 -1
  75. package/dist/pages/sentry-callback-page.js +22 -9
  76. package/dist/pages/sentry-callback-page.js.map +1 -1
  77. package/dist/pages/slack-callback-page.d.ts.map +1 -1
  78. package/dist/pages/slack-callback-page.js +50 -18
  79. package/dist/pages/slack-callback-page.js.map +1 -1
  80. package/dist/pages/source-control-onboarding-page.js +1 -1
  81. package/dist/pages/source-control-onboarding-page.js.map +1 -1
  82. package/dist/provider-catalog.d.ts +1 -1
  83. package/dist/provider-catalog.d.ts.map +1 -1
  84. package/dist/provider-catalog.js +10 -5
  85. package/dist/provider-catalog.js.map +1 -1
  86. package/dist/routes/jira-callback.d.ts +8 -0
  87. package/dist/routes/jira-callback.d.ts.map +1 -0
  88. package/dist/routes/jira-callback.js +9 -0
  89. package/dist/routes/jira-callback.js.map +1 -0
  90. package/dist/routes/jira.d.ts +6 -0
  91. package/dist/routes/jira.d.ts.map +1 -0
  92. package/dist/routes/jira.js +7 -0
  93. package/dist/routes/jira.js.map +1 -0
  94. package/dist/sentry-callback.js +2 -2
  95. package/dist/sentry-callback.js.map +1 -1
  96. package/dist/tsconfig.test.tsbuildinfo +1 -1
  97. package/dist/workspace-navigation.d.ts +13 -0
  98. package/dist/workspace-navigation.d.ts.map +1 -0
  99. package/dist/workspace-navigation.js +17 -0
  100. package/dist/workspace-navigation.js.map +1 -0
  101. package/package.json +14 -13
  102. package/src/application/complete-integration-callback.test.ts +44 -1
  103. package/src/application/complete-integration-callback.ts +61 -8
  104. package/src/components/callback-status-shell.tsx +17 -10
  105. package/src/components/installed-integrations-section.tsx +16 -9
  106. package/src/components/integration-gallery-for-workspace.tsx +20 -10
  107. package/src/components/integration-gallery.stories.tsx +10 -10
  108. package/src/components/integration-gallery.test.tsx +30 -15
  109. package/src/components/integration-usage-events.test.ts +13 -0
  110. package/src/components/provider-grid.tsx +6 -6
  111. package/src/components/redirect-install-page.test.tsx +3 -3
  112. package/src/components/redirect-install-page.tsx +2 -2
  113. package/src/components/webhook/webhook-modals.stories.tsx +2 -2
  114. package/src/core/models.ts +18 -0
  115. package/src/feature.ts +17 -7
  116. package/src/hooks/api/integration-mapper.ts +6 -0
  117. package/src/hooks/api/integrations.test.ts +92 -0
  118. package/src/hooks/api/integrations.ts +72 -1
  119. package/src/index.ts +4 -0
  120. package/src/jira-callback-state.ts +19 -0
  121. package/src/jira-callback.test.ts +76 -0
  122. package/src/jira-callback.ts +67 -0
  123. package/src/jira-form-errors.test.ts +57 -0
  124. package/src/jira-form-errors.ts +94 -0
  125. package/src/pages/gitea-install-page.tsx +2 -2
  126. package/src/pages/jira-callback-page.test.tsx +250 -0
  127. package/src/pages/jira-callback-page.tsx +366 -0
  128. package/src/pages/jira-install-page.tsx +28 -0
  129. package/src/pages/linear-callback-page.test.tsx +25 -12
  130. package/src/pages/linear-callback-page.tsx +73 -12
  131. package/src/pages/sentry-callback-page.test.tsx +8 -5
  132. package/src/pages/sentry-callback-page.tsx +32 -8
  133. package/src/pages/slack-callback-page.test.tsx +78 -0
  134. package/src/pages/slack-callback-page.tsx +56 -18
  135. package/src/pages/source-control-onboarding-page.test.tsx +4 -4
  136. package/src/pages/source-control-onboarding-page.tsx +1 -1
  137. package/src/provider-catalog.test.ts +10 -4
  138. package/src/provider-catalog.ts +16 -10
  139. package/src/routes/jira-callback.tsx +8 -0
  140. package/src/routes/jira.tsx +4 -0
  141. package/src/sentry-callback.ts +2 -2
  142. package/src/workspace-navigation.test.ts +43 -0
  143. package/src/workspace-navigation.ts +38 -0
  144. package/test/render.tsx +2 -1
  145. 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
- '/workspaces/$wid/settings/integrations',
41
- '/workspaces/$wid/integrations/linear',
40
+ '/w/$workspaceSlug/settings/integrations',
41
+ '/w/$workspaceSlug/integrations/linear',
42
42
  '/auth/login',
43
43
  ],
44
44
  });
@@ -70,7 +70,7 @@ describe('LinearCallbackPage', () => {
70
70
  window.sessionStorage.setItem(LINEAR_INSTALL_WORKSPACE_KEY, INTEGRATIONS_TEST_WID);
71
71
  completeCallbackMock.mockResolvedValue({
72
72
  id: 'connection-1',
73
- workspace_id: responseWorkspaceId,
73
+ workspaceId: responseWorkspaceId,
74
74
  provider: 'linear',
75
75
  external_account_id: 'linear-org',
76
76
  slug: 'linear_org',
@@ -81,7 +81,24 @@ describe('LinearCallbackPage', () => {
81
81
  updated_at: '2026-01-01T00:00:00.000Z',
82
82
  });
83
83
 
84
- renderCallback('?code=grant-code-success&state=signed-state-success', {strict: true});
84
+ renderIntegrationsPage({
85
+ path: '/integrations/linear/callback?code=grant-code-success&state=signed-state-success',
86
+ routePath: '/integrations/linear/callback',
87
+ element: (
88
+ <StrictMode>
89
+ <LinearCallbackPage />
90
+ </StrictMode>
91
+ ),
92
+ workspaces: [
93
+ testWorkspace(),
94
+ testWorkspace({id: responseWorkspaceId, slug: 'response-workspace'}),
95
+ ],
96
+ extraRoutes: [
97
+ '/w/response-workspace/settings/integrations',
98
+ '/w/$workspaceSlug/integrations/linear',
99
+ '/auth/login',
100
+ ],
101
+ });
85
102
 
86
103
  await waitFor(() =>
87
104
  expect(completeCallbackMock).toHaveBeenCalledWith({
@@ -92,7 +109,7 @@ describe('LinearCallbackPage', () => {
92
109
  expect(completeCallbackMock).toHaveBeenCalledTimes(1);
93
110
  await waitFor(() =>
94
111
  expect(
95
- screen.getByTestId('route:/workspaces/$wid/settings/integrations'),
112
+ screen.getByTestId('route:/w/response-workspace/settings/integrations'),
96
113
  ).toBeInTheDocument(),
97
114
  );
98
115
  expect(window.sessionStorage.getItem(LINEAR_INSTALL_WORKSPACE_KEY)).toBeNull();
@@ -136,14 +153,10 @@ describe('LinearCallbackPage', () => {
136
153
 
137
154
  expect(switchAccountLink).toHaveAttribute(
138
155
  'href',
139
- `/auth/logout?redirect=${encodeURIComponent(
140
- `/workspaces/${INTEGRATIONS_TEST_WID}/integrations/linear`,
141
- )}`,
156
+ `/auth/logout?redirect=${encodeURIComponent('/w/acme/integrations/linear')}`,
142
157
  );
143
158
  expect(switchAccountUrl.pathname).toBe('/auth/logout');
144
- expect(switchAccountUrl.searchParams.get('redirect')).toBe(
145
- `/workspaces/${INTEGRATIONS_TEST_WID}/integrations/linear`,
146
- );
159
+ expect(switchAccountUrl.searchParams.get('redirect')).toBe('/w/acme/integrations/linear');
147
160
  expect(screen.getByRole('link', {name: 'Start over'})).toBeVisible();
148
161
  });
149
162
 
@@ -1,8 +1,9 @@
1
- import {useRefreshAuth} from '@shipfox/client-auth';
1
+ import {useAuthState, useRefreshAuth} from '@shipfox/client-auth';
2
2
  import {useRouteSearch} from '@shipfox/client-shell/runtime';
3
3
  import {createSingleFlight, sessionStorageOrUndefined} from '@shipfox/client-ui';
4
4
  import {FullPageLoader} from '@shipfox/react-ui/loader';
5
5
  import {toast} from '@shipfox/react-ui/toast';
6
+ import {useQueryClient} from '@tanstack/react-query';
6
7
  import {useNavigate} from '@tanstack/react-router';
7
8
  import {useEffect, useMemo, useState} from 'react';
8
9
  import {useCompleteIntegrationCallback} from '#application/complete-integration-callback.js';
@@ -17,26 +18,33 @@ import {
17
18
  readLinearInstallWorkspace,
18
19
  serializeLinearCallbackQuery,
19
20
  } from '#linear-callback.js';
21
+ import {rememberCallbackKey, resolveWorkspaceSlug} from '#workspace-navigation.js';
20
22
 
21
23
  // Retain only recent completions: this bounds long-lived callback pages while
22
24
  // still covering StrictMode and immediate Back/Forward remounts.
23
25
  const callbackRequests = createSingleFlight<string, IntegrationConnection>({
24
26
  maxTerminalResults: 32,
25
27
  });
28
+ const completedCallbacks = new Set<string>();
26
29
  // Keeps the success toast firing once per distinct callback even though the
27
30
  // effect re-runs against the cached request as the mutation identity churns.
28
31
  const toastedCallbacks = new Set<string>();
29
32
 
30
33
  export function LinearCallbackPage() {
31
34
  const navigate = useNavigate();
35
+ const queryClient = useQueryClient();
32
36
  const refreshAuth = useRefreshAuth();
33
37
  const completeIntegrationCallback = useCompleteIntegrationCallback();
34
38
  const {mutateAsync: completeLinearCallback} = useCompleteLinearCallbackMutation();
39
+ const {workspaces, isLoading} = useAuthState();
35
40
  const params = useRouteSearch(parseLinearCallbackQuery);
36
41
  const workspaceId = useMemo(() => readLinearInstallWorkspace(sessionStorageOrUndefined()), []);
37
42
  const [failure, setFailure] = useState<LinearCallbackFailure | undefined>();
43
+ const [completedWorkspace, setCompletedWorkspace] = useState<{
44
+ slug?: string | undefined;
45
+ }>();
38
46
  useEffect(() => {
39
- if (!params) return;
47
+ if (!params || isLoading) return;
40
48
 
41
49
  let disposed = false;
42
50
  const key = serializeLinearCallbackQuery(params);
@@ -53,6 +61,16 @@ export function LinearCallbackPage() {
53
61
  request.then(
54
62
  async (connection) => {
55
63
  if (disposed) return;
64
+ if (completedCallbacks.has(key)) {
65
+ const workspaceSlug = await resolveWorkspaceSlug({
66
+ workspaceId: connection.workspaceId,
67
+ fallbackWorkspaces: workspaces,
68
+ queryClient,
69
+ });
70
+ if (!disposed) setCompletedWorkspace(workspaceSlug ? {slug: workspaceSlug} : {});
71
+ return;
72
+ }
73
+ rememberCallbackKey(completedCallbacks, key);
56
74
  try {
57
75
  clearLinearInstallWorkspace(sessionStorageOrUndefined());
58
76
  } catch {
@@ -60,17 +78,31 @@ export function LinearCallbackPage() {
60
78
  }
61
79
  if (disposed) return;
62
80
  if (!toastedCallbacks.has(key)) {
63
- toastedCallbacks.add(key);
81
+ rememberCallbackKey(toastedCallbacks, key);
64
82
  toast.success('Linear installed.');
65
83
  }
84
+ let workspaceSlug: string | undefined;
66
85
  try {
86
+ if (disposed) return;
87
+ workspaceSlug = await resolveWorkspaceSlug({
88
+ workspaceId: connection.workspaceId,
89
+ fallbackWorkspaces: workspaces,
90
+ queryClient,
91
+ });
92
+ if (disposed) return;
93
+ if (!workspaceSlug) {
94
+ setCompletedWorkspace({});
95
+ return;
96
+ }
97
+ setCompletedWorkspace({slug: workspaceSlug});
67
98
  await navigate({
68
- to: '/workspaces/$wid/settings/integrations',
69
- params: {wid: connection.workspaceId},
99
+ to: '/w/$workspaceSlug/settings/integrations',
100
+ params: {workspaceSlug},
70
101
  replace: true,
71
102
  });
72
103
  } catch {
73
104
  // Keep the completed callback page visible if client navigation is interrupted.
105
+ if (!disposed) setCompletedWorkspace({slug: workspaceSlug});
74
106
  }
75
107
  },
76
108
  (error: unknown) => {
@@ -82,7 +114,16 @@ export function LinearCallbackPage() {
82
114
  return () => {
83
115
  disposed = true;
84
116
  };
85
- }, [completeIntegrationCallback, completeLinearCallback, navigate, params, refreshAuth]);
117
+ }, [
118
+ completeIntegrationCallback,
119
+ completeLinearCallback,
120
+ isLoading,
121
+ navigate,
122
+ params,
123
+ queryClient,
124
+ refreshAuth,
125
+ workspaces,
126
+ ]);
86
127
 
87
128
  if (!params) {
88
129
  return (
@@ -93,22 +134,42 @@ export function LinearCallbackPage() {
93
134
  startOver: true,
94
135
  signIn: false,
95
136
  }}
96
- workspaceId={workspaceId}
137
+ workspaceSlug={workspaces.find(({id}) => id === workspaceId)?.slug}
97
138
  />
98
139
  );
99
140
  }
100
141
 
142
+ if (completedWorkspace)
143
+ return (
144
+ <CallbackStatusShell
145
+ title="Linear connected"
146
+ status="success"
147
+ message={
148
+ completedWorkspace.slug
149
+ ? 'Linear is connected. Continue in integrations settings.'
150
+ : 'Linear is connected. Return to Shipfox to continue.'
151
+ }
152
+ workspaceSlug={completedWorkspace.slug}
153
+ installPath="/w/$workspaceSlug/integrations/linear"
154
+ />
155
+ );
156
+
101
157
  if (!failure) return <FullPageLoader aria-label="Connecting Linear" />;
102
158
 
103
- return <LinearCallbackFailurePage failure={failure} workspaceId={workspaceId} />;
159
+ return (
160
+ <LinearCallbackFailurePage
161
+ failure={failure}
162
+ workspaceSlug={workspaces.find(({id}) => id === workspaceId)?.slug}
163
+ />
164
+ );
104
165
  }
105
166
 
106
167
  function LinearCallbackFailurePage({
107
168
  failure,
108
- workspaceId,
169
+ workspaceSlug,
109
170
  }: {
110
171
  failure: LinearCallbackFailure;
111
- workspaceId: string | undefined;
172
+ workspaceSlug: string | undefined;
112
173
  }) {
113
174
  return (
114
175
  <CallbackStatusShell
@@ -116,8 +177,8 @@ function LinearCallbackFailurePage({
116
177
  message={failure.message}
117
178
  startOver={failure.startOver}
118
179
  switchAccount={failure.signIn}
119
- workspaceId={workspaceId}
120
- installPath="/workspaces/$wid/integrations/linear"
180
+ workspaceSlug={workspaceSlug}
181
+ installPath="/w/$workspaceSlug/integrations/linear"
121
182
  />
122
183
  );
123
184
  }
@@ -41,7 +41,10 @@ function renderCallback(options: {
41
41
  path: `/integrations/sentry/callback${search}`,
42
42
  routePath: '/integrations/sentry/callback',
43
43
  element: <SentryCallbackPage />,
44
- extraRoutes: ['/workspaces/$wid/settings/integrations', '/workspaces/$wid/integrations/sentry'],
44
+ extraRoutes: [
45
+ '/w/$workspaceSlug/settings/integrations',
46
+ '/w/$workspaceSlug/integrations/sentry',
47
+ ],
45
48
  ...(options.workspaces ? {workspaces: options.workspaces} : {}),
46
49
  ...(options.loadingAuth ? {loadingAuth: true} : {}),
47
50
  });
@@ -104,7 +107,7 @@ describe('SentryCallbackPage', () => {
104
107
 
105
108
  fireEvent.click(await screen.findByRole('button', {name: 'Install'}));
106
109
 
107
- await screen.findByTestId('route:/workspaces/$wid/settings/integrations');
110
+ await screen.findByTestId('route:/w/$workspaceSlug/settings/integrations');
108
111
  expect(connectSentryMock).toHaveBeenCalledWith({
109
112
  body: {
110
113
  workspace_id: testWorkspace().id,
@@ -126,7 +129,7 @@ describe('SentryCallbackPage', () => {
126
129
  fireEvent.click(await screen.findByRole('button', {name: 'Install'}));
127
130
  fireEvent.click(await screen.findByRole('button', {name: 'Retry'}));
128
131
 
129
- await screen.findByTestId('route:/workspaces/$wid/settings/integrations');
132
+ await screen.findByTestId('route:/w/$workspaceSlug/settings/integrations');
130
133
  expect(connectSentryMock).toHaveBeenCalledTimes(2);
131
134
  });
132
135
 
@@ -149,7 +152,7 @@ describe('SentryCallbackPage', () => {
149
152
  await waitFor(() => expect(screen.getByRole('button', {name: 'Retry'})).toBeDisabled());
150
153
 
151
154
  // A second attempt (via the still-enabled workspace Install) fails with no
152
- // backoff hint the lock must clear so the user is not stranded.
155
+ // backoff hint: the lock must clear so the user is not stranded.
153
156
  fireEvent.click(screen.getByRole('button', {name: 'Install'}));
154
157
  await waitFor(() => expect(screen.getByRole('button', {name: 'Retry'})).toBeEnabled());
155
158
  });
@@ -203,7 +206,7 @@ describe('SentryCallbackPage', () => {
203
206
  renderCallback({installationId: 'install-remount'});
204
207
  fireEvent.click(await screen.findByRole('button', {name: 'Install'}));
205
208
 
206
- await screen.findByTestId('route:/workspaces/$wid/settings/integrations');
209
+ await screen.findByTestId('route:/w/$workspaceSlug/settings/integrations');
207
210
  await waitFor(() => expect(connectSentryMock).toHaveBeenCalledTimes(2));
208
211
  });
209
212
  });