@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +32 -0
- package/dist/application/complete-integration-callback.d.ts +11 -0
- package/dist/application/complete-integration-callback.d.ts.map +1 -1
- package/dist/application/complete-integration-callback.js +38 -8
- package/dist/application/complete-integration-callback.js.map +1 -1
- package/dist/components/callback-status-shell.d.ts +1 -1
- package/dist/components/callback-status-shell.d.ts.map +1 -1
- package/dist/components/callback-status-shell.js +3 -3
- package/dist/components/callback-status-shell.js.map +1 -1
- package/dist/components/connection-picker.js +3 -3
- package/dist/components/connection-picker.js.map +1 -1
- package/dist/components/installed-integrations-section.js +9 -9
- package/dist/components/installed-integrations-section.js.map +1 -1
- package/dist/components/integration-delete-confirm-modal.js +1 -1
- package/dist/components/integration-delete-confirm-modal.js.map +1 -1
- package/dist/components/integration-gallery-for-workspace.js +3 -3
- package/dist/components/integration-gallery-for-workspace.js.map +1 -1
- package/dist/components/integration-usage-modal.js +6 -6
- package/dist/components/integration-usage-modal.js.map +1 -1
- package/dist/components/provider-grid.d.ts +1 -1
- package/dist/components/provider-grid.d.ts.map +1 -1
- package/dist/components/provider-grid.js +10 -10
- package/dist/components/provider-grid.js.map +1 -1
- package/dist/components/redirect-install-page.js +1 -1
- package/dist/components/redirect-install-page.js.map +1 -1
- package/dist/components/repository-picker.js +4 -4
- package/dist/components/repository-picker.js.map +1 -1
- package/dist/components/webhook/copyable-value.js +3 -3
- package/dist/components/webhook/copyable-value.js.map +1 -1
- package/dist/components/webhook/webhook-create-modal.js +1 -1
- package/dist/components/webhook/webhook-create-modal.js.map +1 -1
- package/dist/components/webhook/webhook-usage-details.js +2 -2
- package/dist/components/webhook/webhook-usage-details.js.map +1 -1
- package/dist/core/models.d.ts +6 -0
- package/dist/core/models.d.ts.map +1 -1
- package/dist/core/models.js +12 -0
- package/dist/core/models.js.map +1 -1
- package/dist/feature.d.ts +8 -0
- package/dist/feature.d.ts.map +1 -1
- package/dist/feature.js +10 -0
- package/dist/feature.js.map +1 -1
- package/dist/hooks/api/integration-mapper.d.ts +3 -1
- package/dist/hooks/api/integration-mapper.d.ts.map +1 -1
- package/dist/hooks/api/integration-mapper.js +8 -0
- package/dist/hooks/api/integration-mapper.js.map +1 -1
- package/dist/hooks/api/integrations.d.ts +25 -1
- package/dist/hooks/api/integrations.d.ts.map +1 -1
- package/dist/hooks/api/integrations.js +44 -1
- package/dist/hooks/api/integrations.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/jira-callback-state.d.ts +9 -0
- package/dist/jira-callback-state.d.ts.map +1 -0
- package/dist/jira-callback-state.js +16 -0
- package/dist/jira-callback-state.js.map +1 -0
- package/dist/jira-callback.d.ts +11 -0
- package/dist/jira-callback.d.ts.map +1 -0
- package/dist/jira-callback.js +59 -0
- package/dist/jira-callback.js.map +1 -0
- package/dist/jira-form-errors.d.ts +8 -0
- package/dist/jira-form-errors.d.ts.map +1 -0
- package/dist/jira-form-errors.js +55 -0
- package/dist/jira-form-errors.js.map +1 -0
- package/dist/pages/gitea-install-page.js +4 -4
- package/dist/pages/gitea-install-page.js.map +1 -1
- package/dist/pages/jira-callback-page.d.ts +2 -0
- package/dist/pages/jira-callback-page.d.ts.map +1 -0
- package/dist/pages/jira-callback-page.js +341 -0
- package/dist/pages/jira-callback-page.js.map +1 -0
- package/dist/pages/jira-install-page.d.ts +2 -0
- package/dist/pages/jira-install-page.d.ts.map +1 -0
- package/dist/pages/jira-install-page.js +26 -0
- package/dist/pages/jira-install-page.js.map +1 -0
- package/dist/pages/sentry-callback-page.js +7 -7
- package/dist/pages/sentry-callback-page.js.map +1 -1
- package/dist/pages/source-control-onboarding-page.js +2 -2
- package/dist/pages/source-control-onboarding-page.js.map +1 -1
- package/dist/provider-catalog.d.ts +1 -1
- package/dist/provider-catalog.d.ts.map +1 -1
- package/dist/provider-catalog.js +5 -0
- package/dist/provider-catalog.js.map +1 -1
- package/dist/routes/jira-callback.d.ts +8 -0
- package/dist/routes/jira-callback.d.ts.map +1 -0
- package/dist/routes/jira-callback.js +9 -0
- package/dist/routes/jira-callback.js.map +1 -0
- package/dist/routes/jira.d.ts +6 -0
- package/dist/routes/jira.d.ts.map +1 -0
- package/dist/routes/jira.js +7 -0
- package/dist/routes/jira.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +9 -8
- package/src/application/complete-integration-callback.test.ts +44 -1
- package/src/application/complete-integration-callback.ts +61 -8
- package/src/components/callback-status-shell.tsx +7 -4
- package/src/components/connection-picker.tsx +3 -3
- package/src/components/installed-integrations-section.tsx +9 -9
- package/src/components/integration-delete-confirm-modal.tsx +1 -1
- package/src/components/integration-gallery-for-workspace.tsx +3 -3
- package/src/components/integration-usage-events.test.ts +13 -0
- package/src/components/integration-usage-modal.tsx +6 -6
- package/src/components/provider-grid.tsx +10 -10
- package/src/components/redirect-install-page.tsx +1 -1
- package/src/components/repository-picker.test.tsx +2 -2
- package/src/components/repository-picker.tsx +6 -4
- package/src/components/webhook/copyable-value.tsx +3 -3
- package/src/components/webhook/webhook-create-modal.tsx +1 -1
- package/src/components/webhook/webhook-usage-details.tsx +2 -2
- package/src/core/models.ts +18 -0
- package/src/feature.ts +10 -0
- package/src/hooks/api/integration-mapper.ts +6 -0
- package/src/hooks/api/integrations.test.ts +92 -0
- package/src/hooks/api/integrations.ts +71 -0
- package/src/index.ts +4 -0
- package/src/jira-callback-state.ts +19 -0
- package/src/jira-callback.test.ts +76 -0
- package/src/jira-callback.ts +67 -0
- package/src/jira-form-errors.test.ts +57 -0
- package/src/jira-form-errors.ts +94 -0
- package/src/pages/gitea-install-page.tsx +4 -4
- package/src/pages/jira-callback-page.test.tsx +250 -0
- package/src/pages/jira-callback-page.tsx +366 -0
- package/src/pages/jira-install-page.tsx +28 -0
- package/src/pages/sentry-callback-page.tsx +7 -7
- package/src/pages/source-control-onboarding-page.tsx +2 -2
- package/src/provider-catalog.test.ts +6 -0
- package/src/provider-catalog.ts +7 -1
- package/src/routes/jira-callback.tsx +8 -0
- package/src/routes/jira.tsx +4 -0
- 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-
|
|
44
|
-
<header className="flex flex-col gap-
|
|
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-
|
|
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-
|
|
86
|
+
<div className="flex items-center gap-cluster">
|
|
87
87
|
<Button type="submit" isLoading={connect.isPending}>
|
|
88
88
|
Install
|
|
89
89
|
</Button>
|