@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
@@ -11,12 +11,12 @@ import type {IntegrationProvider} from '#core/models.js';
11
11
  import {PROVIDER_CATALOG} from '#provider-catalog.js';
12
12
 
13
13
  export interface ProviderGridProps {
14
+ workspaceSlug: string;
14
15
  providers: IntegrationProvider[];
15
16
  isPending: boolean;
16
17
  isFetching?: boolean;
17
18
  error?: Error | null | undefined;
18
19
  onRetry?: () => void;
19
- workspaceId: string;
20
20
  emptyMessage: string;
21
21
  loadingLabel?: string;
22
22
  errorSubject?: string;
@@ -29,12 +29,12 @@ export const PROVIDER_SURFACE_CLASS =
29
29
  'overflow-hidden rounded-8 border border-border-neutral-base bg-background-neutral-base';
30
30
 
31
31
  export function ProviderGrid({
32
+ workspaceSlug,
32
33
  providers,
33
34
  isPending,
34
35
  isFetching = false,
35
36
  error,
36
37
  onRetry,
37
- workspaceId,
38
38
  emptyMessage,
39
39
  loadingLabel = 'Loading providers',
40
40
  errorSubject = 'available integrations',
@@ -79,7 +79,7 @@ export function ProviderGrid({
79
79
  <li key={provider.provider}>
80
80
  <ProviderCard
81
81
  provider={provider}
82
- workspaceId={workspaceId}
82
+ workspaceSlug={workspaceSlug}
83
83
  onOpenProvider={onOpenProvider}
84
84
  />
85
85
  </li>
@@ -90,11 +90,11 @@ export function ProviderGrid({
90
90
 
91
91
  function ProviderCard({
92
92
  provider,
93
- workspaceId,
93
+ workspaceSlug,
94
94
  onOpenProvider,
95
95
  }: {
96
96
  provider: IntegrationProvider;
97
- workspaceId: string;
97
+ workspaceSlug: string;
98
98
  onOpenProvider?: ((provider: string) => void) | undefined;
99
99
  }) {
100
100
  const catalog = PROVIDER_CATALOG[provider.provider];
@@ -116,7 +116,7 @@ function ProviderCard({
116
116
  return (
117
117
  <Link
118
118
  to={catalog.setupPath}
119
- params={{wid: workspaceId}}
119
+ params={{workspaceSlug}}
120
120
  aria-label={`Install ${provider.displayName}`}
121
121
  className="group flex h-full min-w-0 items-center justify-between gap-12 rounded-8 border border-border-neutral-base bg-background-neutral-base p-16 transition-colors hover:bg-background-components-hover focus-visible:shadow-button-neutral-focus focus-visible:outline-none"
122
122
  >
@@ -11,8 +11,8 @@ function renderInstallPage(
11
11
  options?: {strict?: boolean},
12
12
  ) {
13
13
  return renderIntegrationsPage({
14
- path: `/workspaces/${INTEGRATIONS_TEST_WID}/integrations/github`,
15
- routePath: '/workspaces/$wid/integrations/github',
14
+ path: '/w/acme/integrations/github',
15
+ routePath: '/w/$workspaceSlug/integrations/github',
16
16
  element: options?.strict ? (
17
17
  <StrictMode>
18
18
  <RedirectInstallPage {...props} />
@@ -20,7 +20,7 @@ function renderInstallPage(
20
20
  ) : (
21
21
  <RedirectInstallPage {...props} />
22
22
  ),
23
- extraRoutes: ['/workspaces/$wid/integrations'],
23
+ extraRoutes: ['/w/$workspaceSlug/integrations'],
24
24
  });
25
25
  }
26
26
 
@@ -14,7 +14,7 @@ interface RedirectInstallPageProps {
14
14
  loadingLabel?: string;
15
15
  /**
16
16
  * Runs before leaving the app (e.g. to persist the workspace id for a
17
- * state-less provider callback). Must not throw a failed side effect
17
+ * state-less provider callback. It must not throw. A failed side effect
18
18
  * should never block the redirect.
19
19
  */
20
20
  beforeRedirect?: (workspaceId: string) => void;
@@ -59,7 +59,7 @@ export function RedirectInstallPage({
59
59
  <Text size="sm">{errorMessage}</Text>
60
60
  </Callout>
61
61
  <ButtonLink asChild variant="muted" className="w-fit">
62
- <Link to="/workspaces/$wid/integrations" params={{wid: workspace.id}}>
62
+ <Link to="/w/$workspaceSlug/integrations" params={{workspaceSlug: workspace.slug}}>
63
63
  Back to integrations
64
64
  </Link>
65
65
  </ButtonLink>
@@ -176,7 +176,7 @@ function createStoryRouter(scenario: Scenario) {
176
176
  const rootRoute = createRootRoute({component: Outlet});
177
177
  const workspaceRoute = createRoute({
178
178
  getParentRoute: () => rootRoute,
179
- path: '/workspaces/$wid',
179
+ path: '/w/$workspaceSlug',
180
180
  component: Outlet,
181
181
  });
182
182
  const integrationsRoute = createRoute({
@@ -192,7 +192,7 @@ function createStoryRouter(scenario: Scenario) {
192
192
 
193
193
  return createRouter({
194
194
  history: createMemoryHistory({
195
- initialEntries: [`/workspaces/${WORKSPACE_ID}/settings/integrations`],
195
+ initialEntries: ['/w/acme/settings/integrations'],
196
196
  }),
197
197
  routeTree: rootRoute.addChildren([
198
198
  workspaceRoute.addChildren([integrationsRoute, eventsRoute]),
@@ -7,6 +7,13 @@ export interface IntegrationProvider {
7
7
  capabilities: IntegrationCapability[];
8
8
  }
9
9
 
10
+ export interface JiraSite {
11
+ cloudId: string;
12
+ name: string;
13
+ url: string;
14
+ scopes: string[];
15
+ }
16
+
10
17
  export interface IntegrationConnection {
11
18
  id: string;
12
19
  workspaceId: string;
@@ -163,6 +170,15 @@ const linearUsageEvents = [
163
170
 
164
171
  const sentryIssueActions = ['created', 'resolved', 'assigned', 'archived', 'unresolved'] as const;
165
172
 
173
+ const jiraUsageEvents = [
174
+ 'jira:issue_created',
175
+ 'jira:issue_updated',
176
+ 'jira:issue_deleted',
177
+ 'comment_created',
178
+ 'comment_updated',
179
+ 'comment_deleted',
180
+ ] as const;
181
+
166
182
  export function usageEventsForConnection(
167
183
  connection: Pick<IntegrationConnection, 'provider' | 'capabilities'>,
168
184
  ): IntegrationUsageEvent[] {
@@ -174,6 +190,8 @@ export function usageEventsForConnection(
174
190
  value: `issue.${action}`,
175
191
  label: `issue.${action}`,
176
192
  }));
193
+ if (connection.provider === 'jira')
194
+ return jiraUsageEvents.map((value) => ({value, label: value}));
177
195
  if (connection.provider === 'linear')
178
196
  return linearUsageEvents.map((value) => ({value, label: value}));
179
197
  if (connection.capabilities.includes('source_control')) return [{value: 'push', label: 'push'}];
package/src/feature.ts CHANGED
@@ -23,6 +23,11 @@ export const integrationsFeature = defineClientFeature({
23
23
  parent: 'root',
24
24
  impl: '@shipfox/client-integrations/routes/linear-callback',
25
25
  },
26
+ {
27
+ path: '/integrations/jira/callback',
28
+ parent: 'root',
29
+ impl: '@shipfox/client-integrations/routes/jira-callback',
30
+ },
26
31
  {
27
32
  path: '/integrations/sentry/callback',
28
33
  parent: 'root',
@@ -34,37 +39,42 @@ export const integrationsFeature = defineClientFeature({
34
39
  impl: '@shipfox/client-integrations/routes/slack-callback',
35
40
  },
36
41
  {
37
- path: '/workspaces/$wid/integrations',
42
+ path: '/w/$workspaceSlug/integrations',
38
43
  parent: 'workspaceLayout',
39
44
  impl: '@shipfox/client-integrations/routes/integrations',
40
45
  },
41
46
  {
42
- path: '/workspaces/$wid/integrations/gitea',
47
+ path: '/w/$workspaceSlug/integrations/gitea',
43
48
  parent: 'workspaceLayout',
44
49
  impl: '@shipfox/client-integrations/routes/gitea',
45
50
  },
46
51
  {
47
- path: '/workspaces/$wid/integrations/github',
52
+ path: '/w/$workspaceSlug/integrations/github',
48
53
  parent: 'workspaceLayout',
49
54
  impl: '@shipfox/client-integrations/routes/github',
50
55
  },
51
56
  {
52
- path: '/workspaces/$wid/integrations/linear',
57
+ path: '/w/$workspaceSlug/integrations/linear',
53
58
  parent: 'workspaceLayout',
54
59
  impl: '@shipfox/client-integrations/routes/linear',
55
60
  },
56
61
  {
57
- path: '/workspaces/$wid/integrations/sentry',
62
+ path: '/w/$workspaceSlug/integrations/jira',
63
+ parent: 'workspaceLayout',
64
+ impl: '@shipfox/client-integrations/routes/jira',
65
+ },
66
+ {
67
+ path: '/w/$workspaceSlug/integrations/sentry',
58
68
  parent: 'workspaceLayout',
59
69
  impl: '@shipfox/client-integrations/routes/sentry',
60
70
  },
61
71
  {
62
- path: '/workspaces/$wid/integrations/slack',
72
+ path: '/w/$workspaceSlug/integrations/slack',
63
73
  parent: 'workspaceLayout',
64
74
  impl: '@shipfox/client-integrations/routes/slack',
65
75
  },
66
76
  {
67
- path: '/workspaces/$wid/settings/integrations',
77
+ path: '/w/$workspaceSlug/settings/integrations',
68
78
  parent: 'workspaceSettings',
69
79
  impl: '@shipfox/client-integrations/routes/integrations-settings',
70
80
  },
@@ -3,11 +3,13 @@ import type {
3
3
  IntegrationProviderDto,
4
4
  RepositoryDto,
5
5
  } from '@shipfox/api-integration-core-dto';
6
+ import type {JiraAccessibleResourceDto} from '@shipfox/api-integration-jira-dto';
6
7
  import type {WebhookConnectionDto} from '@shipfox/api-integration-webhook-dto';
7
8
  import type {
8
9
  InstallRedirect,
9
10
  IntegrationConnection,
10
11
  IntegrationProvider,
12
+ JiraSite,
11
13
  Repository,
12
14
  WebhookConnection,
13
15
  } from '#core/models.js';
@@ -20,6 +22,10 @@ export function toInstallRedirect(dto: {install_url: string}): InstallRedirect {
20
22
  return {installUrl: dto.install_url};
21
23
  }
22
24
 
25
+ export function toJiraSite(dto: JiraAccessibleResourceDto): JiraSite {
26
+ return {cloudId: dto.cloud_id, name: dto.name, url: dto.url, scopes: dto.scopes};
27
+ }
28
+
23
29
  export function toIntegrationConnection(dto: IntegrationConnectionDto): IntegrationConnection {
24
30
  return {
25
31
  id: dto.id,
@@ -1,8 +1,11 @@
1
1
  import type {IntegrationConnectionDto} from '@shipfox/api-integration-core-dto';
2
2
  import {configureApiClient} from '@shipfox/client-api';
3
3
  import {
4
+ completeJiraCallback,
5
+ completeJiraSiteSelection,
4
6
  completeLinearCallback,
5
7
  completeSlackCallback,
8
+ createJiraInstall,
6
9
  createLinearInstall,
7
10
  createSlackInstall,
8
11
  listSourceConnections,
@@ -155,3 +158,92 @@ describe('Linear transport', () => {
155
158
  );
156
159
  });
157
160
  });
161
+
162
+ describe('Jira transport', () => {
163
+ it('posts the install workspace, maps multi-site callbacks, and completes a site selection', async () => {
164
+ const requests: Request[] = [];
165
+ const connectionBody = {
166
+ id: '33333333-3333-4333-8333-333333333333',
167
+ workspace_id: '11111111-1111-4111-8111-111111111111',
168
+ provider: 'jira',
169
+ external_account_id: 'cloud-1',
170
+ slug: 'jira_acme',
171
+ display_name: 'Jira Acme',
172
+ lifecycle_status: 'active',
173
+ capabilities: ['agent_tools'],
174
+ created_at: '2026-01-01T00:00:00.000Z',
175
+ updated_at: '2026-01-01T00:00:00.000Z',
176
+ };
177
+ configureApiClient({
178
+ baseUrl: 'https://api.example.test',
179
+ fetchImpl: vi.fn((input, init) => {
180
+ const request = new Request(input, init);
181
+ requests.push(request);
182
+ if (request.url.endsWith('/install'))
183
+ return Promise.resolve(jsonResponse({install_url: 'https://jira.example.test/install'}));
184
+ if (request.url.endsWith('/callback/site'))
185
+ return Promise.resolve(jsonResponse(connectionBody));
186
+ return Promise.resolve(
187
+ jsonResponse({
188
+ sites: [
189
+ {
190
+ cloud_id: 'cloud-1',
191
+ name: 'Acme',
192
+ url: 'https://acme.atlassian.net',
193
+ scopes: ['read:jira-work'],
194
+ },
195
+ {
196
+ cloud_id: 'cloud-2',
197
+ name: 'Beta',
198
+ url: 'https://beta.atlassian.net',
199
+ scopes: ['read:jira-work'],
200
+ },
201
+ ],
202
+ }),
203
+ );
204
+ }),
205
+ });
206
+
207
+ const install = await createJiraInstall({
208
+ workspace_id: '11111111-1111-4111-8111-111111111111',
209
+ });
210
+ const sites = await completeJiraCallback({
211
+ query: {code: 'grant code', state: 'signed state'},
212
+ token: 'session-token',
213
+ });
214
+ const connection = await completeJiraSiteSelection({
215
+ body: {cloud_id: 'cloud-1', state: 'signed state'},
216
+ token: 'session-token',
217
+ });
218
+
219
+ expect(install).toEqual({installUrl: 'https://jira.example.test/install'});
220
+ expect(sites).toEqual({
221
+ sites: [
222
+ {
223
+ cloudId: 'cloud-1',
224
+ name: 'Acme',
225
+ url: 'https://acme.atlassian.net',
226
+ scopes: ['read:jira-work'],
227
+ },
228
+ {
229
+ cloudId: 'cloud-2',
230
+ name: 'Beta',
231
+ url: 'https://beta.atlassian.net',
232
+ scopes: ['read:jira-work'],
233
+ },
234
+ ],
235
+ });
236
+ expect(connection.provider).toBe('jira');
237
+ expect(requests[0]?.url).toBe('https://api.example.test/integrations/jira/install');
238
+ expect(await requests[0]?.json()).toEqual({
239
+ workspace_id: '11111111-1111-4111-8111-111111111111',
240
+ });
241
+ expect(requests[1]?.url).toBe(
242
+ 'https://api.example.test/integrations/jira/callback/api?code=grant+code&state=signed+state',
243
+ );
244
+ expect(requests[1]?.headers.get('authorization')).toBe('Bearer session-token');
245
+ expect(requests[2]?.url).toBe('https://api.example.test/integrations/jira/callback/site');
246
+ expect(await requests[2]?.json()).toEqual({cloud_id: 'cloud-1', state: 'signed state'});
247
+ expect(requests[2]?.headers.get('authorization')).toBe('Bearer session-token');
248
+ });
249
+ });
@@ -15,6 +15,15 @@ import {
15
15
  createGithubInstallResponseSchema,
16
16
  githubCallbackResponseSchema,
17
17
  } from '@shipfox/api-integration-github-dto';
18
+ import type {
19
+ CompleteJiraSiteSelectionBodyDto,
20
+ CreateJiraInstallBodyDto,
21
+ JiraCallbackQueryDto,
22
+ } from '@shipfox/api-integration-jira-dto';
23
+ import {
24
+ createJiraInstallResponseSchema,
25
+ jiraCallbackResponseSchema,
26
+ } from '@shipfox/api-integration-jira-dto';
18
27
  import type {
19
28
  CreateLinearInstallBodyDto,
20
29
  LinearCallbackQueryDto,
@@ -56,14 +65,17 @@ import {
56
65
  type IntegrationConnection,
57
66
  type IntegrationProvider,
58
67
  isUsableConnection,
68
+ type JiraSite,
59
69
  type RepositoryPage,
60
70
  } from '#core/models.js';
71
+ import {serializeJiraCallbackQuery} from '#jira-callback.js';
61
72
  import {serializeLinearCallbackQuery} from '#linear-callback.js';
62
73
  import {serializeSlackCallbackQuery} from '#slack-callback.js';
63
74
  import {
64
75
  toInstallRedirect,
65
76
  toIntegrationConnection,
66
77
  toIntegrationProvider,
78
+ toJiraSite,
67
79
  toRepository,
68
80
  } from './integration-mapper.js';
69
81
 
@@ -175,7 +187,7 @@ export async function listSourceConnections({
175
187
  });
176
188
  // The endpoint returns every lifecycle status (the settings hub needs that),
177
189
  // but source-control consumers (onboarding redirect, project creation) only
178
- // act on usable connections a disabled/error one must read as "not there".
190
+ // act on usable connections. A disabled/error one must read as "not there".
179
191
  return connections.filter(isUsableConnection);
180
192
  }
181
193
 
@@ -261,6 +273,15 @@ export async function createLinearInstall(
261
273
  );
262
274
  }
263
275
 
276
+ export async function createJiraInstall(body: CreateJiraInstallBodyDto): Promise<InstallRedirect> {
277
+ return toInstallRedirect(
278
+ await checkedApiRequest(createJiraInstallResponseSchema, '/integrations/jira/install', {
279
+ method: 'POST',
280
+ body,
281
+ }),
282
+ );
283
+ }
284
+
264
285
  export async function createSlackInstall(
265
286
  body: CreateSlackInstallBodyDto,
266
287
  ): Promise<InstallRedirect> {
@@ -302,6 +323,44 @@ export async function completeSlackCallback({
302
323
  return toIntegrationConnection(response);
303
324
  }
304
325
 
326
+ export type JiraCallbackResult = IntegrationConnection | {sites: JiraSite[]};
327
+
328
+ export async function completeJiraCallback({
329
+ query,
330
+ token,
331
+ }: {
332
+ query: JiraCallbackQueryDto;
333
+ token: string;
334
+ }): Promise<JiraCallbackResult> {
335
+ const response = await checkedApiRequest(
336
+ jiraCallbackResponseSchema,
337
+ `/integrations/jira/callback/api?${serializeJiraCallbackQuery(query)}`,
338
+ {headers: {authorization: `Bearer ${token}`}},
339
+ );
340
+ return 'sites' in response
341
+ ? {sites: response.sites.map(toJiraSite)}
342
+ : toIntegrationConnection(response);
343
+ }
344
+
345
+ export async function completeJiraSiteSelection({
346
+ body,
347
+ token,
348
+ }: {
349
+ body: CompleteJiraSiteSelectionBodyDto;
350
+ token: string;
351
+ }): Promise<IntegrationConnection> {
352
+ const response = await checkedApiRequest(
353
+ integrationConnectionDtoSchema,
354
+ '/integrations/jira/callback/site',
355
+ {
356
+ method: 'POST',
357
+ body,
358
+ headers: {authorization: `Bearer ${token}`},
359
+ },
360
+ );
361
+ return toIntegrationConnection(response);
362
+ }
363
+
305
364
  // Called from the callback route with an explicit bearer (same as the GitHub
306
365
  // callback): the route refreshes auth itself before forwarding the grant code.
307
366
  export async function connectSentry({body, token}: {body: SentryConnectBodyDto; token: string}) {
@@ -452,6 +511,10 @@ export function useCreateSlackInstallMutation() {
452
511
  return useMutation({mutationFn: createSlackInstall});
453
512
  }
454
513
 
514
+ export function useCreateJiraInstallMutation() {
515
+ return useMutation({mutationFn: createJiraInstall});
516
+ }
517
+
455
518
  export function useCompleteLinearCallbackMutation() {
456
519
  return useMutation({mutationFn: completeLinearCallback});
457
520
  }
@@ -460,6 +523,14 @@ export function useCompleteSlackCallbackMutation() {
460
523
  return useMutation({mutationFn: completeSlackCallback});
461
524
  }
462
525
 
526
+ export function useCompleteJiraCallbackMutation() {
527
+ return useMutation({mutationFn: completeJiraCallback});
528
+ }
529
+
530
+ export function useCompleteJiraSiteSelectionMutation() {
531
+ return useMutation({mutationFn: completeJiraSiteSelection});
532
+ }
533
+
463
534
  export function useUpdateIntegrationConnectionMutation() {
464
535
  const queryClient = useQueryClient();
465
536
  return useMutation({
package/src/index.ts CHANGED
@@ -6,8 +6,12 @@ export * from './components/redirect-install-page.js';
6
6
  export * from './components/repository-picker.js';
7
7
  export * from './connection-status-badge.js';
8
8
  export * from './hooks/api/integrations.js';
9
+ export * from './jira-callback.js';
10
+ export * from './jira-form-errors.js';
9
11
  export * from './pages/gitea-install-page.js';
10
12
  export * from './pages/github-install-page.js';
13
+ export * from './pages/jira-callback-page.js';
14
+ export * from './pages/jira-install-page.js';
11
15
  export * from './pages/linear-callback-page.js';
12
16
  export * from './pages/linear-install-page.js';
13
17
  export * from './pages/sentry-callback-page.js';
@@ -0,0 +1,19 @@
1
+ import {createSingleFlight} from '@shipfox/client-ui';
2
+ import type {IntegrationConnection} from '#core/models.js';
3
+ import type {JiraCallbackResult} from '#hooks/api/integrations.js';
4
+
5
+ export const JIRA_CALLBACK_CACHE_SIZE = 32;
6
+
7
+ export const jiraCallbackRequests = createSingleFlight<string, JiraCallbackResult>({
8
+ maxTerminalResults: JIRA_CALLBACK_CACHE_SIZE,
9
+ });
10
+ export const jiraSiteSelectionRequests = createSingleFlight<string, IntegrationConnection>();
11
+ export const jiraCompletedConnections = new Map<string, IntegrationConnection>();
12
+ export const jiraToastedCallbacks = new Set<string>();
13
+
14
+ export function resetJiraCallbackState(): void {
15
+ jiraCallbackRequests.clear();
16
+ jiraSiteSelectionRequests.clear();
17
+ jiraCompletedConnections.clear();
18
+ jiraToastedCallbacks.clear();
19
+ }
@@ -0,0 +1,76 @@
1
+ import {
2
+ clearJiraInstallWorkspace,
3
+ JIRA_INSTALL_WORKSPACE_KEY,
4
+ parseJiraCallbackQuery,
5
+ readJiraInstallWorkspace,
6
+ saveJiraInstallWorkspace,
7
+ serializeJiraCallbackQuery,
8
+ } from './jira-callback.js';
9
+
10
+ describe('Jira callback helpers', () => {
11
+ it('parses and serializes success and provider-error callback queries', () => {
12
+ const success = parseJiraCallbackQuery({code: 'grant code', state: 'signed state'});
13
+ const providerError = parseJiraCallbackQuery({
14
+ error: 'access_denied',
15
+ error_description: 'User denied access',
16
+ state: 'signed state',
17
+ });
18
+
19
+ expect(success).toEqual({code: 'grant code', state: 'signed state'});
20
+ expect(providerError).toEqual({
21
+ error: 'access_denied',
22
+ error_description: 'User denied access',
23
+ state: 'signed state',
24
+ });
25
+ expect(success && serializeJiraCallbackQuery(success)).toBe(
26
+ 'code=grant+code&state=signed+state',
27
+ );
28
+ expect(providerError && serializeJiraCallbackQuery(providerError)).toBe(
29
+ 'error=access_denied&error_description=User+denied+access&state=signed+state',
30
+ );
31
+ });
32
+
33
+ it('rejects malformed callback queries and prefers a grant code', () => {
34
+ expect(parseJiraCallbackQuery({code: 'grant'})).toBeUndefined();
35
+ expect(parseJiraCallbackQuery({state: 'signed'})).toBeUndefined();
36
+ expect(parseJiraCallbackQuery({error: 'access_denied'})).toBeUndefined();
37
+ expect(parseJiraCallbackQuery({state: ['a', 'b']})).toBeUndefined();
38
+ expect(
39
+ parseJiraCallbackQuery({
40
+ code: 'grant',
41
+ error: 'access_denied',
42
+ state: 'signed',
43
+ }),
44
+ ).toEqual({code: 'grant', state: 'signed'});
45
+ });
46
+
47
+ it('round-trips workspace navigation storage and swallows storage errors', () => {
48
+ const storage = new Map<string, string>();
49
+ const workspaceStorage = {
50
+ getItem: (key: string) => storage.get(key) ?? null,
51
+ setItem: (key: string, value: string) => storage.set(key, value),
52
+ removeItem: (key: string) => storage.delete(key),
53
+ };
54
+
55
+ saveJiraInstallWorkspace(workspaceStorage, 'workspace-1');
56
+ expect(storage.get(JIRA_INSTALL_WORKSPACE_KEY)).toBe('workspace-1');
57
+ expect(readJiraInstallWorkspace(workspaceStorage)).toBe('workspace-1');
58
+ clearJiraInstallWorkspace(workspaceStorage);
59
+ expect(readJiraInstallWorkspace(workspaceStorage)).toBeUndefined();
60
+
61
+ const unavailableStorage = {
62
+ getItem: () => {
63
+ throw new Error('unavailable');
64
+ },
65
+ setItem: () => {
66
+ throw new Error('unavailable');
67
+ },
68
+ removeItem: () => {
69
+ throw new Error('unavailable');
70
+ },
71
+ };
72
+ expect(() => saveJiraInstallWorkspace(unavailableStorage, 'workspace-1')).not.toThrow();
73
+ expect(() => readJiraInstallWorkspace(unavailableStorage)).not.toThrow();
74
+ expect(() => clearJiraInstallWorkspace(unavailableStorage)).not.toThrow();
75
+ });
76
+ });
@@ -0,0 +1,67 @@
1
+ import type {JiraCallbackQueryDto} from '@shipfox/api-integration-jira-dto';
2
+ import {
3
+ type BrowserStorage,
4
+ type BrowserStorageKey,
5
+ createTypedBrowserStorage,
6
+ } from '@shipfox/client-ui';
7
+
8
+ export const JIRA_INSTALL_WORKSPACE_KEY = 'shipfox.jira-install.workspace-id';
9
+
10
+ type WorkspaceStorage = BrowserStorage | undefined;
11
+
12
+ const jiraInstallWorkspaceStorageKey = {
13
+ key: JIRA_INSTALL_WORKSPACE_KEY,
14
+ lifetime: 'session',
15
+ // This is a one-shot navigation hint, not private workspace state. It must
16
+ // remain readable on an OAuth callback before workspace hydration completes;
17
+ // the signed callback state and returned connection are authoritative.
18
+ principalScope: 'global',
19
+ serialize: (workspaceId: string) => workspaceId,
20
+ parse: (value: string) => value || undefined,
21
+ } satisfies BrowserStorageKey<string>;
22
+
23
+ export function saveJiraInstallWorkspace(storage: WorkspaceStorage, workspaceId: string): void {
24
+ installWorkspaceStorage(storage).write(workspaceId);
25
+ }
26
+
27
+ export function readJiraInstallWorkspace(storage: WorkspaceStorage): string | undefined {
28
+ return installWorkspaceStorage(storage).read();
29
+ }
30
+
31
+ export function clearJiraInstallWorkspace(storage: WorkspaceStorage): void {
32
+ installWorkspaceStorage(storage).remove();
33
+ }
34
+
35
+ function installWorkspaceStorage(storage: WorkspaceStorage) {
36
+ return createTypedBrowserStorage(() => storage, jiraInstallWorkspaceStorageKey);
37
+ }
38
+
39
+ export function parseJiraCallbackQuery(
40
+ search: Record<string, unknown>,
41
+ ): JiraCallbackQueryDto | undefined {
42
+ const state = stringParam(search.state);
43
+ if (!state) return undefined;
44
+
45
+ const code = stringParam(search.code);
46
+ if (code) return {code, state};
47
+
48
+ const error = stringParam(search.error);
49
+ if (!error) return undefined;
50
+ const errorDescription = stringParam(search.error_description);
51
+ return errorDescription ? {error, error_description: errorDescription, state} : {error, state};
52
+ }
53
+
54
+ export function serializeJiraCallbackQuery(query: JiraCallbackQueryDto): string {
55
+ const params = new URLSearchParams();
56
+ if ('code' in query) params.set('code', query.code);
57
+ else {
58
+ params.set('error', query.error);
59
+ if (query.error_description) params.set('error_description', query.error_description);
60
+ }
61
+ params.set('state', query.state);
62
+ return params.toString();
63
+ }
64
+
65
+ function stringParam(value: unknown): string | undefined {
66
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
67
+ }