@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
@@ -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
 
@@ -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
+ }
@@ -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
+ }
@@ -40,8 +40,8 @@ export function GiteaInstallPage() {
40
40
  });
41
41
 
42
42
  return (
43
- <div className="mx-auto flex w-full max-w-[480px] flex-col gap-20">
44
- <header className="flex flex-col gap-8">
43
+ <div className="mx-auto flex w-full max-w-[480px] flex-col gap-section">
44
+ <header className="flex flex-col gap-inline">
45
45
  <Header variant="h1">Install Gitea</Header>
46
46
  <Text size="md" className="text-foreground-neutral-muted">
47
47
  Enter the Gitea organization to install in this workspace.
@@ -49,7 +49,7 @@ export function GiteaInstallPage() {
49
49
  </header>
50
50
 
51
51
  <form
52
- className="flex flex-col gap-18"
52
+ className="flex flex-col gap-group"
53
53
  noValidate
54
54
  onSubmit={(event) => {
55
55
  event.preventDefault();
@@ -83,7 +83,7 @@ export function GiteaInstallPage() {
83
83
  </FormField>
84
84
  )}
85
85
  </form.Field>
86
- <div className="flex items-center gap-12">
86
+ <div className="flex items-center gap-cluster">
87
87
  <Button type="submit" isLoading={connect.isPending}>
88
88
  Install
89
89
  </Button>