@shipfox/client-integrations 13.0.0 → 14.0.1

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 (132) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +32 -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 +1 -1
  8. package/dist/components/callback-status-shell.d.ts.map +1 -1
  9. package/dist/components/callback-status-shell.js +3 -3
  10. package/dist/components/callback-status-shell.js.map +1 -1
  11. package/dist/components/connection-picker.js +3 -3
  12. package/dist/components/connection-picker.js.map +1 -1
  13. package/dist/components/installed-integrations-section.js +9 -9
  14. package/dist/components/installed-integrations-section.js.map +1 -1
  15. package/dist/components/integration-delete-confirm-modal.js +1 -1
  16. package/dist/components/integration-delete-confirm-modal.js.map +1 -1
  17. package/dist/components/integration-gallery-for-workspace.js +3 -3
  18. package/dist/components/integration-gallery-for-workspace.js.map +1 -1
  19. package/dist/components/integration-usage-modal.js +6 -6
  20. package/dist/components/integration-usage-modal.js.map +1 -1
  21. package/dist/components/provider-grid.d.ts +1 -1
  22. package/dist/components/provider-grid.d.ts.map +1 -1
  23. package/dist/components/provider-grid.js +10 -10
  24. package/dist/components/provider-grid.js.map +1 -1
  25. package/dist/components/redirect-install-page.js +1 -1
  26. package/dist/components/redirect-install-page.js.map +1 -1
  27. package/dist/components/repository-picker.js +4 -4
  28. package/dist/components/repository-picker.js.map +1 -1
  29. package/dist/components/webhook/copyable-value.js +3 -3
  30. package/dist/components/webhook/copyable-value.js.map +1 -1
  31. package/dist/components/webhook/webhook-create-modal.js +1 -1
  32. package/dist/components/webhook/webhook-create-modal.js.map +1 -1
  33. package/dist/components/webhook/webhook-usage-details.js +2 -2
  34. package/dist/components/webhook/webhook-usage-details.js.map +1 -1
  35. package/dist/core/models.d.ts +6 -0
  36. package/dist/core/models.d.ts.map +1 -1
  37. package/dist/core/models.js +12 -0
  38. package/dist/core/models.js.map +1 -1
  39. package/dist/feature.d.ts +8 -0
  40. package/dist/feature.d.ts.map +1 -1
  41. package/dist/feature.js +10 -0
  42. package/dist/feature.js.map +1 -1
  43. package/dist/hooks/api/integration-mapper.d.ts +3 -1
  44. package/dist/hooks/api/integration-mapper.d.ts.map +1 -1
  45. package/dist/hooks/api/integration-mapper.js +8 -0
  46. package/dist/hooks/api/integration-mapper.js.map +1 -1
  47. package/dist/hooks/api/integrations.d.ts +25 -1
  48. package/dist/hooks/api/integrations.d.ts.map +1 -1
  49. package/dist/hooks/api/integrations.js +44 -1
  50. package/dist/hooks/api/integrations.js.map +1 -1
  51. package/dist/index.d.ts +4 -0
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +4 -0
  54. package/dist/index.js.map +1 -1
  55. package/dist/jira-callback-state.d.ts +9 -0
  56. package/dist/jira-callback-state.d.ts.map +1 -0
  57. package/dist/jira-callback-state.js +16 -0
  58. package/dist/jira-callback-state.js.map +1 -0
  59. package/dist/jira-callback.d.ts +11 -0
  60. package/dist/jira-callback.d.ts.map +1 -0
  61. package/dist/jira-callback.js +59 -0
  62. package/dist/jira-callback.js.map +1 -0
  63. package/dist/jira-form-errors.d.ts +8 -0
  64. package/dist/jira-form-errors.d.ts.map +1 -0
  65. package/dist/jira-form-errors.js +55 -0
  66. package/dist/jira-form-errors.js.map +1 -0
  67. package/dist/pages/gitea-install-page.js +4 -4
  68. package/dist/pages/gitea-install-page.js.map +1 -1
  69. package/dist/pages/jira-callback-page.d.ts +2 -0
  70. package/dist/pages/jira-callback-page.d.ts.map +1 -0
  71. package/dist/pages/jira-callback-page.js +341 -0
  72. package/dist/pages/jira-callback-page.js.map +1 -0
  73. package/dist/pages/jira-install-page.d.ts +2 -0
  74. package/dist/pages/jira-install-page.d.ts.map +1 -0
  75. package/dist/pages/jira-install-page.js +26 -0
  76. package/dist/pages/jira-install-page.js.map +1 -0
  77. package/dist/pages/sentry-callback-page.js +7 -7
  78. package/dist/pages/sentry-callback-page.js.map +1 -1
  79. package/dist/pages/source-control-onboarding-page.js +2 -2
  80. package/dist/pages/source-control-onboarding-page.js.map +1 -1
  81. package/dist/provider-catalog.d.ts +1 -1
  82. package/dist/provider-catalog.d.ts.map +1 -1
  83. package/dist/provider-catalog.js +5 -0
  84. package/dist/provider-catalog.js.map +1 -1
  85. package/dist/routes/jira-callback.d.ts +8 -0
  86. package/dist/routes/jira-callback.d.ts.map +1 -0
  87. package/dist/routes/jira-callback.js +9 -0
  88. package/dist/routes/jira-callback.js.map +1 -0
  89. package/dist/routes/jira.d.ts +6 -0
  90. package/dist/routes/jira.d.ts.map +1 -0
  91. package/dist/routes/jira.js +7 -0
  92. package/dist/routes/jira.js.map +1 -0
  93. package/dist/tsconfig.test.tsbuildinfo +1 -1
  94. package/package.json +9 -8
  95. package/src/application/complete-integration-callback.test.ts +44 -1
  96. package/src/application/complete-integration-callback.ts +61 -8
  97. package/src/components/callback-status-shell.tsx +7 -4
  98. package/src/components/connection-picker.tsx +3 -3
  99. package/src/components/installed-integrations-section.tsx +9 -9
  100. package/src/components/integration-delete-confirm-modal.tsx +1 -1
  101. package/src/components/integration-gallery-for-workspace.tsx +3 -3
  102. package/src/components/integration-usage-events.test.ts +13 -0
  103. package/src/components/integration-usage-modal.tsx +6 -6
  104. package/src/components/provider-grid.tsx +10 -10
  105. package/src/components/redirect-install-page.tsx +1 -1
  106. package/src/components/repository-picker.test.tsx +2 -2
  107. package/src/components/repository-picker.tsx +6 -4
  108. package/src/components/webhook/copyable-value.tsx +3 -3
  109. package/src/components/webhook/webhook-create-modal.tsx +1 -1
  110. package/src/components/webhook/webhook-usage-details.tsx +2 -2
  111. package/src/core/models.ts +18 -0
  112. package/src/feature.ts +10 -0
  113. package/src/hooks/api/integration-mapper.ts +6 -0
  114. package/src/hooks/api/integrations.test.ts +92 -0
  115. package/src/hooks/api/integrations.ts +71 -0
  116. package/src/index.ts +4 -0
  117. package/src/jira-callback-state.ts +19 -0
  118. package/src/jira-callback.test.ts +76 -0
  119. package/src/jira-callback.ts +67 -0
  120. package/src/jira-form-errors.test.ts +57 -0
  121. package/src/jira-form-errors.ts +94 -0
  122. package/src/pages/gitea-install-page.tsx +4 -4
  123. package/src/pages/jira-callback-page.test.tsx +250 -0
  124. package/src/pages/jira-callback-page.tsx +366 -0
  125. package/src/pages/jira-install-page.tsx +28 -0
  126. package/src/pages/sentry-callback-page.tsx +7 -7
  127. package/src/pages/source-control-onboarding-page.tsx +2 -2
  128. package/src/provider-catalog.test.ts +6 -0
  129. package/src/provider-catalog.ts +7 -1
  130. package/src/routes/jira-callback.tsx +8 -0
  131. package/src/routes/jira.tsx +4 -0
  132. package/tsconfig.build.tsbuildinfo +1 -1
@@ -0,0 +1,250 @@
1
+ // @vitest-environment jsdom
2
+ import '@testing-library/jest-dom/vitest';
3
+ import {ApiError} from '@shipfox/client-api';
4
+ import {fireEvent, screen, waitFor} from '@testing-library/react';
5
+ import {StrictMode} from 'react';
6
+ import {JIRA_INSTALL_WORKSPACE_KEY} from '#jira-callback.js';
7
+ import {resetJiraCallbackState} from '#jira-callback-state.js';
8
+ import {INTEGRATIONS_TEST_WID, renderIntegrationsPage, testWorkspace} from '#test/render.js';
9
+ import {JiraCallbackPage} from './jira-callback-page.js';
10
+
11
+ const {completeCallbackMock, completeSiteMock} = vi.hoisted(() => ({
12
+ completeCallbackMock: vi.fn(),
13
+ completeSiteMock: vi.fn(),
14
+ }));
15
+
16
+ vi.mock('@shipfox/client-auth', async (importOriginal) => {
17
+ const actual = await importOriginal<typeof import('@shipfox/client-auth')>();
18
+ return {
19
+ ...actual,
20
+ useRefreshAuth: () => () => Promise.resolve({accessToken: 'test-token'}),
21
+ };
22
+ });
23
+
24
+ vi.mock('#hooks/api/integrations.js', async (importOriginal) => {
25
+ const actual = await importOriginal<typeof import('#hooks/api/integrations.js')>();
26
+ return {
27
+ ...actual,
28
+ useCompleteJiraCallbackMutation: () => ({mutateAsync: completeCallbackMock}),
29
+ useCompleteJiraSiteSelectionMutation: () => ({mutateAsync: completeSiteMock}),
30
+ };
31
+ });
32
+
33
+ beforeEach(() => {
34
+ window.sessionStorage.clear();
35
+ resetJiraCallbackState();
36
+ completeCallbackMock.mockReset();
37
+ completeSiteMock.mockReset();
38
+ });
39
+
40
+ describe('JiraCallbackPage', () => {
41
+ test('lists sites from the callback and completes the selected site', async () => {
42
+ window.sessionStorage.setItem(JIRA_INSTALL_WORKSPACE_KEY, INTEGRATIONS_TEST_WID);
43
+ completeCallbackMock.mockResolvedValue({
44
+ sites: [
45
+ {
46
+ cloudId: 'cloud-1',
47
+ name: 'Acme',
48
+ url: 'https://acme.atlassian.net',
49
+ scopes: ['read:jira-work'],
50
+ },
51
+ {
52
+ cloudId: 'cloud-2',
53
+ name: 'Beta',
54
+ url: 'https://beta.atlassian.net',
55
+ scopes: ['read:jira-work'],
56
+ },
57
+ ],
58
+ });
59
+ completeSiteMock.mockResolvedValue({
60
+ id: 'connection-1',
61
+ workspaceId: INTEGRATIONS_TEST_WID,
62
+ provider: 'jira',
63
+ externalAccountId: 'cloud-1',
64
+ slug: 'jira_acme',
65
+ displayName: 'Jira Acme',
66
+ lifecycleStatus: 'active',
67
+ capabilities: ['agent_tools'],
68
+ createdAt: '2026-01-01T00:00:00.000Z',
69
+ updatedAt: '2026-01-01T00:00:00.000Z',
70
+ });
71
+
72
+ const rendered = renderIntegrationsPage({
73
+ path: '/integrations/jira/callback?code=grant-code-jira&state=signed-state-jira',
74
+ routePath: '/integrations/jira/callback',
75
+ element: <JiraCallbackPage />,
76
+ workspaces: [testWorkspace()],
77
+ extraRoutes: [
78
+ '/w/acme/settings/integrations',
79
+ '/w/$workspaceSlug/integrations/jira',
80
+ '/auth/login',
81
+ ],
82
+ });
83
+
84
+ expect(await screen.findByRole('heading', {name: 'Choose a Jira site'})).toBeVisible();
85
+ expect(screen.getByText('Acme')).toBeVisible();
86
+ expect(screen.getByText('Beta')).toBeVisible();
87
+ expect(completeCallbackMock).toHaveBeenCalledWith({
88
+ query: {code: 'grant-code-jira', state: 'signed-state-jira'},
89
+ token: 'test-token',
90
+ });
91
+ expect(completeCallbackMock).toHaveBeenCalledTimes(1);
92
+
93
+ const connectButtons = screen.getAllByRole('button', {name: 'Connect'});
94
+ expect(connectButtons).toHaveLength(2);
95
+ fireEvent.click(connectButtons[0] as HTMLElement);
96
+
97
+ await waitFor(() =>
98
+ expect(completeSiteMock).toHaveBeenCalledWith({
99
+ body: {cloud_id: 'cloud-1', state: 'signed-state-jira'},
100
+ token: 'test-token',
101
+ }),
102
+ );
103
+ await screen.findByTestId('route:/w/acme/settings/integrations');
104
+ expect(completeSiteMock).toHaveBeenCalledTimes(1);
105
+ expect(window.sessionStorage.getItem(JIRA_INSTALL_WORKSPACE_KEY)).toBeNull();
106
+ expect(screen.getByText('Jira installed.')).toBeInTheDocument();
107
+
108
+ rendered.unmount();
109
+ renderIntegrationsPage({
110
+ path: '/integrations/jira/callback?code=grant-code-jira&state=signed-state-jira',
111
+ routePath: '/integrations/jira/callback',
112
+ element: <JiraCallbackPage />,
113
+ workspaces: [testWorkspace()],
114
+ extraRoutes: [
115
+ '/w/acme/settings/integrations',
116
+ '/w/$workspaceSlug/integrations/jira',
117
+ '/auth/login',
118
+ ],
119
+ });
120
+ await waitFor(() => expect(completeCallbackMock).toHaveBeenCalledTimes(1));
121
+ expect(await screen.findByRole('heading', {name: 'Jira connected'})).toBeVisible();
122
+ });
123
+
124
+ test('completes a single-site callback in Strict Mode', async () => {
125
+ window.sessionStorage.setItem(JIRA_INSTALL_WORKSPACE_KEY, INTEGRATIONS_TEST_WID);
126
+ completeCallbackMock.mockResolvedValue({
127
+ id: 'connection-single',
128
+ workspaceId: INTEGRATIONS_TEST_WID,
129
+ provider: 'jira',
130
+ externalAccountId: 'cloud-single',
131
+ slug: 'jira_single',
132
+ displayName: 'Jira Single',
133
+ lifecycleStatus: 'active',
134
+ capabilities: ['agent_tools'],
135
+ createdAt: '2026-01-01T00:00:00.000Z',
136
+ updatedAt: '2026-01-01T00:00:00.000Z',
137
+ });
138
+
139
+ renderIntegrationsPage({
140
+ path: '/integrations/jira/callback?code=grant-code-single&state=signed-state-single',
141
+ routePath: '/integrations/jira/callback',
142
+ element: (
143
+ <StrictMode>
144
+ <JiraCallbackPage />
145
+ </StrictMode>
146
+ ),
147
+ workspaces: [testWorkspace()],
148
+ extraRoutes: [
149
+ '/w/acme/settings/integrations',
150
+ '/w/$workspaceSlug/integrations/jira',
151
+ '/auth/login',
152
+ ],
153
+ });
154
+
155
+ await waitFor(() =>
156
+ expect(completeCallbackMock).toHaveBeenCalledWith({
157
+ query: {code: 'grant-code-single', state: 'signed-state-single'},
158
+ token: 'test-token',
159
+ }),
160
+ );
161
+ expect(completeCallbackMock).toHaveBeenCalledTimes(1);
162
+ await screen.findByTestId('route:/w/acme/settings/integrations');
163
+ expect(screen.getByText('Jira installed.')).toBeInTheDocument();
164
+ });
165
+
166
+ test('renders recovery without submitting an invalid callback', async () => {
167
+ window.sessionStorage.setItem(JIRA_INSTALL_WORKSPACE_KEY, INTEGRATIONS_TEST_WID);
168
+
169
+ renderIntegrationsPage({
170
+ path: '/integrations/jira/callback?state=missing-code',
171
+ routePath: '/integrations/jira/callback',
172
+ element: <JiraCallbackPage />,
173
+ workspaces: [testWorkspace()],
174
+ extraRoutes: [
175
+ '/w/acme/settings/integrations',
176
+ '/w/$workspaceSlug/integrations/jira',
177
+ '/auth/login',
178
+ ],
179
+ });
180
+
181
+ expect(
182
+ await screen.findByRole('heading', {name: 'Jira install could not be completed'}),
183
+ ).toBeVisible();
184
+ expect(completeCallbackMock).not.toHaveBeenCalled();
185
+ expect(screen.getByRole('link', {name: 'Start over'})).toBeVisible();
186
+ });
187
+
188
+ test('allows retrying a failed site selection', async () => {
189
+ window.sessionStorage.setItem(JIRA_INSTALL_WORKSPACE_KEY, INTEGRATIONS_TEST_WID);
190
+ completeCallbackMock.mockResolvedValue({
191
+ sites: [
192
+ {
193
+ cloudId: 'cloud-retry',
194
+ name: 'Retry site',
195
+ url: 'https://retry.atlassian.net',
196
+ scopes: ['read:jira-work'],
197
+ },
198
+ ],
199
+ });
200
+ completeSiteMock
201
+ .mockRejectedValueOnce(
202
+ new ApiError({
203
+ code: 'jira-pending-selection-not-found',
204
+ message: 'selection expired',
205
+ status: 404,
206
+ }),
207
+ )
208
+ .mockResolvedValueOnce({
209
+ id: 'connection-retry',
210
+ workspaceId: INTEGRATIONS_TEST_WID,
211
+ provider: 'jira',
212
+ externalAccountId: 'cloud-retry',
213
+ slug: 'jira_retry',
214
+ displayName: 'Jira Retry',
215
+ lifecycleStatus: 'active',
216
+ capabilities: ['agent_tools'],
217
+ createdAt: '2026-01-01T00:00:00.000Z',
218
+ updatedAt: '2026-01-01T00:00:00.000Z',
219
+ });
220
+
221
+ renderIntegrationsPage({
222
+ path: '/integrations/jira/callback?code=grant-code-retry&state=signed-state-retry',
223
+ routePath: '/integrations/jira/callback',
224
+ element: <JiraCallbackPage />,
225
+ workspaces: [testWorkspace()],
226
+ extraRoutes: [
227
+ '/w/acme/settings/integrations',
228
+ '/w/$workspaceSlug/integrations/jira',
229
+ '/auth/login',
230
+ ],
231
+ });
232
+
233
+ expect(await screen.findByRole('heading', {name: 'Choose a Jira site'})).toBeVisible();
234
+ const connectButton = screen.getByRole('button', {name: 'Connect'});
235
+ fireEvent.click(connectButton);
236
+
237
+ await waitFor(() => expect(completeSiteMock).toHaveBeenCalledTimes(1));
238
+ await waitFor(() => {
239
+ expect(screen.getByRole('alert')).toHaveTextContent(
240
+ 'This Jira site selection is no longer valid. Start the install again from workspace settings.',
241
+ );
242
+ });
243
+ await waitFor(() => expect(connectButton).not.toBeDisabled());
244
+
245
+ fireEvent.click(connectButton);
246
+ await waitFor(() => expect(completeSiteMock).toHaveBeenCalledTimes(2));
247
+ await screen.findByTestId('route:/w/acme/settings/integrations');
248
+ expect(screen.getByText('Jira installed.')).toBeInTheDocument();
249
+ });
250
+ });
@@ -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-row py-frame">
304
+ <div className="mx-auto flex w-full max-w-[480px] flex-col justify-center gap-section">
305
+ <header className="flex flex-col gap-inline">
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-inline" aria-label="Choose a Jira site">
319
+ {sites.map((site) => (
320
+ <Card key={site.cloudId} className="p-panel-compact">
321
+ <div className="flex items-center justify-between gap-cluster">
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-inline 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
+ }
@@ -170,7 +170,7 @@ export function SentryCallbackPage() {
170
170
 
171
171
  return (
172
172
  <CallbackColumn>
173
- <header className="flex flex-col gap-8">
173
+ <header className="flex flex-col gap-inline">
174
174
  <Header variant="h2">Install Sentry</Header>
175
175
  <Text size="sm" className="text-foreground-neutral-muted">
176
176
  {params.orgSlug
@@ -181,7 +181,7 @@ export function SentryCallbackPage() {
181
181
 
182
182
  {failure ? (
183
183
  <Callout role="alert" type="error">
184
- <div className="flex flex-col gap-8">
184
+ <div className="flex flex-col gap-inline">
185
185
  <Text size="sm">{failure.failure.message}</Text>
186
186
  {failure.failure.kind === 'retryable' ? (
187
187
  <Button
@@ -210,10 +210,10 @@ export function SentryCallbackPage() {
210
210
  </Callout>
211
211
  ) : null}
212
212
 
213
- <section className="flex flex-col gap-8" aria-label="Choose a workspace">
213
+ <section className="flex flex-col gap-inline" aria-label="Choose a workspace">
214
214
  {orderedWorkspaces.map((workspace) => (
215
- <Card key={workspace.id} className="p-16">
216
- <div className="flex items-center justify-between gap-12">
215
+ <Card key={workspace.id} className="p-panel-compact">
216
+ <div className="flex items-center justify-between gap-cluster">
217
217
  <Text size="md" bold className="truncate">
218
218
  {workspace.name}
219
219
  </Text>
@@ -246,8 +246,8 @@ export function SentryCallbackPage() {
246
246
 
247
247
  function CallbackColumn({children}: {children: React.ReactNode}) {
248
248
  return (
249
- <main className="flex min-h-screen bg-background-subtle-base px-16 py-32">
250
- <div className="mx-auto flex w-full max-w-[480px] flex-col justify-center gap-20">
249
+ <main className="flex min-h-screen bg-background-subtle-base px-row py-frame">
250
+ <div className="mx-auto flex w-full max-w-[480px] flex-col justify-center gap-section">
251
251
  {children}
252
252
  </div>
253
253
  </main>