@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,57 @@
1
+ import {ApiError} from '@shipfox/client-api';
2
+ import {classifyJiraCallbackError} from './jira-form-errors.js';
3
+
4
+ function apiError(code: string, status: number) {
5
+ return new ApiError({code, status, message: `${code} server message`});
6
+ }
7
+
8
+ describe('classifyJiraCallbackError', () => {
9
+ it.each([
10
+ ['invalid-jira-install-state', 'Jira install link expired', true, false],
11
+ ['jira-install-state-actor-mismatch', 'Different Shipfox account', true, true],
12
+ ['unauthorized', 'Different Shipfox account', true, true],
13
+ ['not-found', 'Workspace access changed', false, false],
14
+ ['forbidden', 'Workspace access changed', false, false],
15
+ ['workspace-inactive', 'Workspace access changed', false, false],
16
+ ['jira-installation-already-linked', 'Jira already linked', false, false],
17
+ ['jira-connection-already-linked', 'Jira already linked', false, false],
18
+ ['jira-authorization-scope-mismatch', 'Jira permissions needed', true, false],
19
+ ['jira-offline-access-not-granted', 'Jira permissions needed', true, false],
20
+ ['jira-oauth-callback-error', 'Jira permissions needed', true, false],
21
+ ['access-denied', 'Jira permissions needed', true, false],
22
+ ['jira-site-selection-mismatch', 'Jira site selection expired', true, false],
23
+ ['jira-pending-selection-not-found', 'Jira site selection expired', true, false],
24
+ ['network-error', 'Could not reach Shipfox', true, false],
25
+ ['rate-limited', 'Jira is temporarily unavailable', true, false],
26
+ ['timeout', 'Jira is temporarily unavailable', true, false],
27
+ ['provider-unavailable', 'Jira is temporarily unavailable', true, false],
28
+ ['malformed-provider-response', 'Jira is temporarily unavailable', true, false],
29
+ ['slug-conflict', 'Jira install could not be completed', true, false],
30
+ ] as const)('maps %s without surfacing the server message', (code, title, startOver, signIn) => {
31
+ const failure = classifyJiraCallbackError(apiError(code, 400));
32
+ expect(failure).toMatchObject({
33
+ title,
34
+ startOver,
35
+ signIn,
36
+ });
37
+ expect(failure.message).not.toContain('server message');
38
+ });
39
+
40
+ it('uses the generic recovery for unknown ApiError codes', () => {
41
+ expect(classifyJiraCallbackError(apiError('unknown-error', 400))).toEqual({
42
+ title: 'Jira install could not be completed',
43
+ message: 'Could not complete the Jira install. Start again from workspace settings.',
44
+ startOver: true,
45
+ signIn: false,
46
+ });
47
+ });
48
+
49
+ it('uses the generic recovery for unexpected errors', () => {
50
+ expect(classifyJiraCallbackError(new Error('network down'))).toEqual({
51
+ title: 'Jira install could not be completed',
52
+ message: 'Could not complete the Jira install. Start again from workspace settings.',
53
+ startOver: true,
54
+ signIn: false,
55
+ });
56
+ });
57
+ });
@@ -0,0 +1,94 @@
1
+ import {ApiError} from '@shipfox/client-api';
2
+
3
+ export interface JiraCallbackFailure {
4
+ title: string;
5
+ message: string;
6
+ startOver: boolean;
7
+ signIn: boolean;
8
+ }
9
+
10
+ export function classifyJiraCallbackError(error: unknown): JiraCallbackFailure {
11
+ if (error instanceof ApiError) {
12
+ switch (error.code) {
13
+ case 'invalid-jira-install-state':
14
+ return failure(
15
+ 'Jira install link expired',
16
+ 'Jira install link expired. Start again from workspace settings.',
17
+ true,
18
+ );
19
+ case 'jira-install-state-actor-mismatch':
20
+ case 'unauthorized':
21
+ return {
22
+ ...failure(
23
+ 'Different Shipfox account',
24
+ 'Different Shipfox account. Sign in with the account that started this install.',
25
+ true,
26
+ ),
27
+ signIn: true,
28
+ };
29
+ case 'not-found':
30
+ case 'forbidden':
31
+ case 'workspace-inactive':
32
+ return failure(
33
+ 'Workspace access changed',
34
+ 'You no longer have access to this workspace. Return to Shipfox to continue.',
35
+ false,
36
+ );
37
+ case 'jira-installation-already-linked':
38
+ case 'jira-connection-already-linked':
39
+ return failure(
40
+ 'Jira already linked',
41
+ 'This Jira site is already linked to another workspace.',
42
+ false,
43
+ );
44
+ case 'jira-authorization-scope-mismatch':
45
+ case 'jira-offline-access-not-granted':
46
+ case 'jira-oauth-callback-error':
47
+ case 'access-denied':
48
+ return failure(
49
+ 'Jira permissions needed',
50
+ 'Jira did not authorize the permissions Shipfox needs. Review the consent and start again.',
51
+ true,
52
+ );
53
+ case 'jira-site-selection-mismatch':
54
+ case 'jira-pending-selection-not-found':
55
+ return failure(
56
+ 'Jira site selection expired',
57
+ 'This Jira site selection is no longer valid. Start the install again from workspace settings.',
58
+ true,
59
+ );
60
+ case 'network-error':
61
+ return failure('Could not reach Shipfox', 'Check your connection and start again.', true);
62
+ case 'rate-limited':
63
+ case 'timeout':
64
+ case 'provider-unavailable':
65
+ case 'malformed-provider-response':
66
+ return failure(
67
+ 'Jira is temporarily unavailable',
68
+ 'Start a new install when Jira is available.',
69
+ true,
70
+ );
71
+ case 'slug-conflict':
72
+ return failure(
73
+ 'Jira install could not be completed',
74
+ 'Could not complete the Jira install. Start again from workspace settings.',
75
+ true,
76
+ );
77
+ default:
78
+ return fallback();
79
+ }
80
+ }
81
+ return fallback();
82
+ }
83
+
84
+ function failure(title: string, message: string, startOver: boolean): JiraCallbackFailure {
85
+ return {title, message, startOver, signIn: false};
86
+ }
87
+
88
+ function fallback(): JiraCallbackFailure {
89
+ return failure(
90
+ 'Jira install could not be completed',
91
+ 'Could not complete the Jira install. Start again from workspace settings.',
92
+ true,
93
+ );
94
+ }
@@ -24,7 +24,7 @@ export function GiteaInstallPage() {
24
24
  try {
25
25
  await connect.mutateAsync({workspace_id: workspaceId, org: value.org.trim()});
26
26
  toast.success('Gitea organization installed.');
27
- await navigate({to: '/workspaces/$wid', params: {wid: workspaceId}});
27
+ await navigate({to: '/w/$workspaceSlug', params: {workspaceSlug: workspace.slug}});
28
28
  } catch (error) {
29
29
  const mapped = giteaConnectErrorToFormError(error);
30
30
  if (mapped.kind === 'field') {
@@ -88,7 +88,7 @@ export function GiteaInstallPage() {
88
88
  Install
89
89
  </Button>
90
90
  <ButtonLink asChild variant="muted">
91
- <Link to="/workspaces/$wid/integrations" params={{wid: workspace.id}}>
91
+ <Link to="/w/$workspaceSlug/integrations" params={{workspaceSlug: workspace.slug}}>
92
92
  Cancel
93
93
  </Link>
94
94
  </ButtonLink>
@@ -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
+ });