@shipfox/client-integrations 0.2.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.
- package/.storybook/main.ts +1 -0
- package/.storybook/preview.css +4 -0
- package/.storybook/preview.tsx +56 -0
- package/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +207 -0
- package/LICENSE +21 -0
- package/dist/components/connection-picker.d.ts +7 -0
- package/dist/components/connection-picker.d.ts.map +1 -0
- package/dist/components/connection-picker.js +98 -0
- package/dist/components/connection-picker.js.map +1 -0
- package/dist/components/installed-integrations-section.d.ts +14 -0
- package/dist/components/installed-integrations-section.d.ts.map +1 -0
- package/dist/components/installed-integrations-section.js +203 -0
- package/dist/components/installed-integrations-section.js.map +1 -0
- package/dist/components/integration-delete-confirm-modal.d.ts +10 -0
- package/dist/components/integration-delete-confirm-modal.d.ts.map +1 -0
- package/dist/components/integration-delete-confirm-modal.js +61 -0
- package/dist/components/integration-delete-confirm-modal.js.map +1 -0
- package/dist/components/integration-gallery-for-workspace.d.ts +9 -0
- package/dist/components/integration-gallery-for-workspace.d.ts.map +1 -0
- package/dist/components/integration-gallery-for-workspace.js +169 -0
- package/dist/components/integration-gallery-for-workspace.js.map +1 -0
- package/dist/components/integration-gallery.d.ts +8 -0
- package/dist/components/integration-gallery.d.ts.map +1 -0
- package/dist/components/integration-gallery.js +26 -0
- package/dist/components/integration-gallery.js.map +1 -0
- package/dist/components/integration-gallery.stories.d.ts +26 -0
- package/dist/components/integration-gallery.stories.d.ts.map +1 -0
- package/dist/components/integration-gallery.stories.js +335 -0
- package/dist/components/integration-gallery.stories.js.map +1 -0
- package/dist/components/integration-usage-events.d.ts +4 -0
- package/dist/components/integration-usage-events.d.ts.map +1 -0
- package/dist/components/integration-usage-events.js +127 -0
- package/dist/components/integration-usage-events.js.map +1 -0
- package/dist/components/integration-usage-modal.d.ts +16 -0
- package/dist/components/integration-usage-modal.d.ts.map +1 -0
- package/dist/components/integration-usage-modal.js +184 -0
- package/dist/components/integration-usage-modal.js.map +1 -0
- package/dist/components/provider-grid.d.ts +14 -0
- package/dist/components/provider-grid.d.ts.map +1 -0
- package/dist/components/provider-grid.js +152 -0
- package/dist/components/provider-grid.js.map +1 -0
- package/dist/components/redirect-install-page.d.ts +19 -0
- package/dist/components/redirect-install-page.d.ts.map +1 -0
- package/dist/components/redirect-install-page.js +68 -0
- package/dist/components/redirect-install-page.js.map +1 -0
- package/dist/components/repository-picker.d.ts +15 -0
- package/dist/components/repository-picker.d.ts.map +1 -0
- package/dist/components/repository-picker.js +78 -0
- package/dist/components/repository-picker.js.map +1 -0
- package/dist/components/webhook/copyable-value.d.ts +10 -0
- package/dist/components/webhook/copyable-value.d.ts.map +1 -0
- package/dist/components/webhook/copyable-value.js +65 -0
- package/dist/components/webhook/copyable-value.js.map +1 -0
- package/dist/components/webhook/webhook-create-modal.d.ts +10 -0
- package/dist/components/webhook/webhook-create-modal.d.ts.map +1 -0
- package/dist/components/webhook/webhook-create-modal.js +233 -0
- package/dist/components/webhook/webhook-create-modal.js.map +1 -0
- package/dist/components/webhook/webhook-form-errors.d.ts +11 -0
- package/dist/components/webhook/webhook-form-errors.d.ts.map +1 -0
- package/dist/components/webhook/webhook-form-errors.js +27 -0
- package/dist/components/webhook/webhook-form-errors.js.map +1 -0
- package/dist/components/webhook/webhook-modals.stories.d.ts +25 -0
- package/dist/components/webhook/webhook-modals.stories.d.ts.map +1 -0
- package/dist/components/webhook/webhook-modals.stories.js +222 -0
- package/dist/components/webhook/webhook-modals.stories.js.map +1 -0
- package/dist/components/webhook/webhook-public-endpoint-alert.d.ts +2 -0
- package/dist/components/webhook/webhook-public-endpoint-alert.d.ts.map +1 -0
- package/dist/components/webhook/webhook-public-endpoint-alert.js +20 -0
- package/dist/components/webhook/webhook-public-endpoint-alert.js.map +1 -0
- package/dist/components/webhook/webhook-usage-details.d.ts +7 -0
- package/dist/components/webhook/webhook-usage-details.d.ts.map +1 -0
- package/dist/components/webhook/webhook-usage-details.js +49 -0
- package/dist/components/webhook/webhook-usage-details.js.map +1 -0
- package/dist/connection-status-badge.d.ts +12 -0
- package/dist/connection-status-badge.d.ts.map +1 -0
- package/dist/connection-status-badge.js +34 -0
- package/dist/connection-status-badge.js.map +1 -0
- package/dist/feature.d.ts +37 -0
- package/dist/feature.d.ts.map +1 -0
- package/dist/feature.js +48 -0
- package/dist/feature.js.map +1 -0
- package/dist/hooks/api/integrations.d.ts +265 -0
- package/dist/hooks/api/integrations.d.ts.map +1 -0
- package/dist/hooks/api/integrations.js +229 -0
- package/dist/hooks/api/integrations.js.map +1 -0
- package/dist/hooks/api/webhook-connections.d.ts +91 -0
- package/dist/hooks/api/webhook-connections.d.ts.map +1 -0
- package/dist/hooks/api/webhook-connections.js +97 -0
- package/dist/hooks/api/webhook-connections.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/integration-icon.d.ts +17 -0
- package/dist/integration-icon.d.ts.map +1 -0
- package/dist/integration-icon.js +22 -0
- package/dist/integration-icon.js.map +1 -0
- package/dist/linear-callback.d.ts +17 -0
- package/dist/linear-callback.d.ts.map +1 -0
- package/dist/linear-callback.js +113 -0
- package/dist/linear-callback.js.map +1 -0
- package/dist/pages/gitea-form-errors.d.ts +11 -0
- package/dist/pages/gitea-form-errors.d.ts.map +1 -0
- package/dist/pages/gitea-form-errors.js +35 -0
- package/dist/pages/gitea-form-errors.js.map +1 -0
- package/dist/pages/gitea-install-page.d.ts +2 -0
- package/dist/pages/gitea-install-page.d.ts.map +1 -0
- package/dist/pages/gitea-install-page.js +139 -0
- package/dist/pages/gitea-install-page.js.map +1 -0
- package/dist/pages/github-install-page.d.ts +2 -0
- package/dist/pages/github-install-page.d.ts.map +1 -0
- package/dist/pages/github-install-page.js +11 -0
- package/dist/pages/github-install-page.js.map +1 -0
- package/dist/pages/linear-callback-page.d.ts +2 -0
- package/dist/pages/linear-callback-page.d.ts.map +1 -0
- package/dist/pages/linear-callback-page.js +192 -0
- package/dist/pages/linear-callback-page.js.map +1 -0
- package/dist/pages/linear-install-page.d.ts +2 -0
- package/dist/pages/linear-install-page.d.ts.map +1 -0
- package/dist/pages/linear-install-page.js +24 -0
- package/dist/pages/linear-install-page.js.map +1 -0
- package/dist/pages/sentry-callback-page.d.ts +2 -0
- package/dist/pages/sentry-callback-page.d.ts.map +1 -0
- package/dist/pages/sentry-callback-page.js +256 -0
- package/dist/pages/sentry-callback-page.js.map +1 -0
- package/dist/pages/sentry-install-page.d.ts +2 -0
- package/dist/pages/sentry-install-page.d.ts.map +1 -0
- package/dist/pages/sentry-install-page.js +17 -0
- package/dist/pages/sentry-install-page.js.map +1 -0
- package/dist/pages/source-control-onboarding-page.d.ts +2 -0
- package/dist/pages/source-control-onboarding-page.d.ts.map +1 -0
- package/dist/pages/source-control-onboarding-page.js +37 -0
- package/dist/pages/source-control-onboarding-page.js.map +1 -0
- package/dist/provider-catalog.d.ts +15 -0
- package/dist/provider-catalog.d.ts.map +1 -0
- package/dist/provider-catalog.js +28 -0
- package/dist/provider-catalog.js.map +1 -0
- package/dist/routes/gitea.d.ts +6 -0
- package/dist/routes/gitea.d.ts.map +1 -0
- package/dist/routes/gitea.js +7 -0
- package/dist/routes/gitea.js.map +1 -0
- package/dist/routes/github-callback.d.ts +6 -0
- package/dist/routes/github-callback.d.ts.map +1 -0
- package/dist/routes/github-callback.js +129 -0
- package/dist/routes/github-callback.js.map +1 -0
- package/dist/routes/github.d.ts +6 -0
- package/dist/routes/github.d.ts.map +1 -0
- package/dist/routes/github.js +7 -0
- package/dist/routes/github.js.map +1 -0
- package/dist/routes/integrations.d.ts +6 -0
- package/dist/routes/integrations.d.ts.map +1 -0
- package/dist/routes/integrations.js +7 -0
- package/dist/routes/integrations.js.map +1 -0
- package/dist/routes/linear-callback.d.ts +6 -0
- package/dist/routes/linear-callback.d.ts.map +1 -0
- package/dist/routes/linear-callback.js +7 -0
- package/dist/routes/linear-callback.js.map +1 -0
- package/dist/routes/linear.d.ts +6 -0
- package/dist/routes/linear.d.ts.map +1 -0
- package/dist/routes/linear.js +7 -0
- package/dist/routes/linear.js.map +1 -0
- package/dist/routes/sentry-callback.d.ts +6 -0
- package/dist/routes/sentry-callback.d.ts.map +1 -0
- package/dist/routes/sentry-callback.js +7 -0
- package/dist/routes/sentry-callback.js.map +1 -0
- package/dist/routes/sentry.d.ts +6 -0
- package/dist/routes/sentry.d.ts.map +1 -0
- package/dist/routes/sentry.js +7 -0
- package/dist/routes/sentry.js.map +1 -0
- package/dist/sentry-callback.d.ts +35 -0
- package/dist/sentry-callback.d.ts.map +1 -0
- package/dist/sentry-callback.js +132 -0
- package/dist/sentry-callback.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/package.json +120 -0
- package/src/components/connection-picker.tsx +89 -0
- package/src/components/installed-integrations-section.tsx +198 -0
- package/src/components/integration-delete-confirm-modal.tsx +62 -0
- package/src/components/integration-gallery-for-workspace.tsx +193 -0
- package/src/components/integration-gallery.stories.tsx +302 -0
- package/src/components/integration-gallery.test.tsx +692 -0
- package/src/components/integration-gallery.tsx +49 -0
- package/src/components/integration-usage-events.test.ts +80 -0
- package/src/components/integration-usage-events.ts +116 -0
- package/src/components/integration-usage-modal.tsx +178 -0
- package/src/components/provider-grid.tsx +163 -0
- package/src/components/redirect-install-page.test.tsx +98 -0
- package/src/components/redirect-install-page.tsx +68 -0
- package/src/components/repository-picker.tsx +102 -0
- package/src/components/webhook/copyable-value.test.tsx +41 -0
- package/src/components/webhook/copyable-value.tsx +72 -0
- package/src/components/webhook/webhook-create-modal.tsx +247 -0
- package/src/components/webhook/webhook-form-errors.test.ts +45 -0
- package/src/components/webhook/webhook-form-errors.ts +32 -0
- package/src/components/webhook/webhook-modals.stories.tsx +225 -0
- package/src/components/webhook/webhook-public-endpoint-alert.tsx +15 -0
- package/src/components/webhook/webhook-usage-details.tsx +44 -0
- package/src/connection-status-badge.tsx +38 -0
- package/src/feature.ts +47 -0
- package/src/hooks/api/integrations.test.ts +105 -0
- package/src/hooks/api/integrations.ts +287 -0
- package/src/hooks/api/webhook-connections.ts +115 -0
- package/src/index.ts +16 -0
- package/src/integration-icon.react.test.tsx +40 -0
- package/src/integration-icon.test.ts +19 -0
- package/src/integration-icon.tsx +26 -0
- package/src/linear-callback.test.ts +227 -0
- package/src/linear-callback.ts +136 -0
- package/src/pages/gitea-form-errors.test.ts +55 -0
- package/src/pages/gitea-form-errors.ts +36 -0
- package/src/pages/gitea-install-page.tsx +108 -0
- package/src/pages/github-install-page.tsx +11 -0
- package/src/pages/linear-callback-page.test.tsx +157 -0
- package/src/pages/linear-callback-page.tsx +175 -0
- package/src/pages/linear-install-page.tsx +26 -0
- package/src/pages/sentry-callback-page.test.tsx +205 -0
- package/src/pages/sentry-callback-page.tsx +238 -0
- package/src/pages/sentry-install-page.tsx +17 -0
- package/src/pages/source-control-onboarding-page.test.tsx +46 -0
- package/src/pages/source-control-onboarding-page.tsx +26 -0
- package/src/provider-catalog.test.ts +42 -0
- package/src/provider-catalog.ts +49 -0
- package/src/routes/gitea.tsx +4 -0
- package/src/routes/github-callback.tsx +114 -0
- package/src/routes/github.tsx +4 -0
- package/src/routes/integrations.tsx +4 -0
- package/src/routes/linear-callback.tsx +4 -0
- package/src/routes/linear.tsx +4 -0
- package/src/routes/sentry-callback.tsx +4 -0
- package/src/routes/sentry.tsx +4 -0
- package/src/sentry-callback.test.ts +239 -0
- package/src/sentry-callback.ts +142 -0
- package/test/render.tsx +111 -0
- package/test/setup.ts +3 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +3 -0
- package/tsconfig.test.json +8 -0
- package/vercel.json +8 -0
- package/vitest.config.ts +75 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import '@testing-library/jest-dom/vitest';
|
|
3
|
+
import {ApiError} from '@shipfox/client-api';
|
|
4
|
+
import {screen, waitFor} from '@testing-library/react';
|
|
5
|
+
import {StrictMode} from 'react';
|
|
6
|
+
import {LINEAR_INSTALL_WORKSPACE_KEY} from '#linear-callback.js';
|
|
7
|
+
import {INTEGRATIONS_TEST_WID, renderIntegrationsPage} from '#test/render.js';
|
|
8
|
+
import {LinearCallbackPage} from './linear-callback-page.js';
|
|
9
|
+
|
|
10
|
+
const {completeCallbackMock} = vi.hoisted(() => ({completeCallbackMock: vi.fn()}));
|
|
11
|
+
|
|
12
|
+
vi.mock('@shipfox/client-auth', async (importOriginal) => {
|
|
13
|
+
const actual = await importOriginal<typeof import('@shipfox/client-auth')>();
|
|
14
|
+
return {
|
|
15
|
+
...actual,
|
|
16
|
+
useRefreshAuth: () => () => Promise.resolve({token: 'test-token'}),
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
vi.mock('#hooks/api/integrations.js', async (importOriginal) => {
|
|
21
|
+
const actual = await importOriginal<typeof import('#hooks/api/integrations.js')>();
|
|
22
|
+
return {
|
|
23
|
+
...actual,
|
|
24
|
+
useCompleteLinearCallbackMutation: () => ({mutateAsync: completeCallbackMock}),
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function renderCallback(search: string, options?: {strict?: boolean}) {
|
|
29
|
+
return renderIntegrationsPage({
|
|
30
|
+
path: `/integrations/linear/callback${search}`,
|
|
31
|
+
routePath: '/integrations/linear/callback',
|
|
32
|
+
element: options?.strict ? (
|
|
33
|
+
<StrictMode>
|
|
34
|
+
<LinearCallbackPage />
|
|
35
|
+
</StrictMode>
|
|
36
|
+
) : (
|
|
37
|
+
<LinearCallbackPage />
|
|
38
|
+
),
|
|
39
|
+
extraRoutes: [
|
|
40
|
+
'/workspaces/$wid/settings/integrations',
|
|
41
|
+
'/workspaces/$wid/integrations/linear',
|
|
42
|
+
'/auth/login',
|
|
43
|
+
],
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
window.sessionStorage.clear();
|
|
49
|
+
completeCallbackMock.mockReset();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('LinearCallbackPage', () => {
|
|
53
|
+
test('renders the recovery page without submitting malformed callbacks', async () => {
|
|
54
|
+
window.sessionStorage.setItem(LINEAR_INSTALL_WORKSPACE_KEY, INTEGRATIONS_TEST_WID);
|
|
55
|
+
|
|
56
|
+
renderCallback('?state=signed-state');
|
|
57
|
+
|
|
58
|
+
const heading = await screen.findByRole('heading', {name: 'Invalid Linear callback'});
|
|
59
|
+
|
|
60
|
+
expect(heading).toBeVisible();
|
|
61
|
+
expect(completeCallbackMock).not.toHaveBeenCalled();
|
|
62
|
+
expect(screen.getByRole('link', {name: 'Start over'})).toBeVisible();
|
|
63
|
+
expect(screen.getByRole('link', {name: 'Back to integrations'})).toBeVisible();
|
|
64
|
+
expect(document.activeElement).toBe(heading);
|
|
65
|
+
expect(heading).toHaveClass('outline-none');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('submits a callback once in Strict Mode, clears the handoff, and navigates to the response workspace', async () => {
|
|
69
|
+
const responseWorkspaceId = '22222222-2222-4222-8222-222222222222';
|
|
70
|
+
window.sessionStorage.setItem(LINEAR_INSTALL_WORKSPACE_KEY, INTEGRATIONS_TEST_WID);
|
|
71
|
+
completeCallbackMock.mockResolvedValue({
|
|
72
|
+
id: 'connection-1',
|
|
73
|
+
workspace_id: responseWorkspaceId,
|
|
74
|
+
provider: 'linear',
|
|
75
|
+
external_account_id: 'linear-org',
|
|
76
|
+
slug: 'linear_org',
|
|
77
|
+
display_name: 'Linear org',
|
|
78
|
+
lifecycle_status: 'active',
|
|
79
|
+
capabilities: [],
|
|
80
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
81
|
+
updated_at: '2026-01-01T00:00:00.000Z',
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
renderCallback('?code=grant-code-success&state=signed-state-success', {strict: true});
|
|
85
|
+
|
|
86
|
+
await waitFor(() =>
|
|
87
|
+
expect(completeCallbackMock).toHaveBeenCalledWith({
|
|
88
|
+
query: {code: 'grant-code-success', state: 'signed-state-success'},
|
|
89
|
+
token: 'test-token',
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
expect(completeCallbackMock).toHaveBeenCalledTimes(1);
|
|
93
|
+
await waitFor(() =>
|
|
94
|
+
expect(
|
|
95
|
+
screen.getByTestId('route:/workspaces/$wid/settings/integrations'),
|
|
96
|
+
).toBeInTheDocument(),
|
|
97
|
+
);
|
|
98
|
+
expect(window.sessionStorage.getItem(LINEAR_INSTALL_WORKSPACE_KEY)).toBeNull();
|
|
99
|
+
expect(screen.getByText('Linear installed.')).toBeInTheDocument();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('renders terminal conflicts without offering an ineffective restart', async () => {
|
|
103
|
+
completeCallbackMock.mockRejectedValue(
|
|
104
|
+
new ApiError({
|
|
105
|
+
code: 'linear-installation-already-linked',
|
|
106
|
+
message: 'already linked',
|
|
107
|
+
status: 409,
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
renderCallback('?code=grant-code-conflict&state=signed-state-conflict');
|
|
112
|
+
|
|
113
|
+
expect(await screen.findByRole('heading', {name: 'Linear already linked'})).toBeVisible();
|
|
114
|
+
expect(screen.queryByRole('link', {name: 'Start over'})).not.toBeInTheDocument();
|
|
115
|
+
expect(screen.getByRole('link', {name: 'Back to Shipfox'})).toBeVisible();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('offers account switching and a fresh install after an actor mismatch', async () => {
|
|
119
|
+
window.sessionStorage.setItem(LINEAR_INSTALL_WORKSPACE_KEY, INTEGRATIONS_TEST_WID);
|
|
120
|
+
completeCallbackMock.mockRejectedValue(
|
|
121
|
+
new ApiError({
|
|
122
|
+
code: 'linear-install-state-actor-mismatch',
|
|
123
|
+
message: 'different account',
|
|
124
|
+
status: 403,
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
renderCallback('?code=grant-code-account&state=signed-state-account');
|
|
129
|
+
|
|
130
|
+
expect(await screen.findByRole('heading', {name: 'Different Shipfox account'})).toBeVisible();
|
|
131
|
+
expect(screen.getByRole('link', {name: 'Switch account'})).toHaveAttribute(
|
|
132
|
+
'href',
|
|
133
|
+
`/auth/logout?redirect=${encodeURIComponent(
|
|
134
|
+
`/workspaces/${INTEGRATIONS_TEST_WID}/integrations/linear`,
|
|
135
|
+
)}`,
|
|
136
|
+
);
|
|
137
|
+
expect(screen.getByRole('link', {name: 'Start over'})).toBeVisible();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('suppresses late success effects after the callback page unmounts', async () => {
|
|
141
|
+
let resolveCallback!: (value: Record<string, unknown>) => void;
|
|
142
|
+
const pendingCallback = new Promise<Record<string, unknown>>((resolve) => {
|
|
143
|
+
resolveCallback = resolve;
|
|
144
|
+
});
|
|
145
|
+
window.sessionStorage.setItem(LINEAR_INSTALL_WORKSPACE_KEY, INTEGRATIONS_TEST_WID);
|
|
146
|
+
completeCallbackMock.mockReturnValue(pendingCallback);
|
|
147
|
+
|
|
148
|
+
const callback = renderCallback('?code=grant-code-unmount&state=signed-state-unmount');
|
|
149
|
+
await waitFor(() => expect(completeCallbackMock).toHaveBeenCalledTimes(1));
|
|
150
|
+
callback.unmount();
|
|
151
|
+
resolveCallback({workspace_id: INTEGRATIONS_TEST_WID});
|
|
152
|
+
await pendingCallback;
|
|
153
|
+
await Promise.resolve();
|
|
154
|
+
|
|
155
|
+
expect(window.sessionStorage.getItem(LINEAR_INSTALL_WORKSPACE_KEY)).toBe(INTEGRATIONS_TEST_WID);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type {LinearCallbackResponseDto} from '@shipfox/api-integration-linear-dto';
|
|
2
|
+
import {useRefreshAuth} from '@shipfox/client-auth';
|
|
3
|
+
import {ButtonLink} from '@shipfox/react-ui/button';
|
|
4
|
+
import {Callout} from '@shipfox/react-ui/callout';
|
|
5
|
+
import {FullPageLoader} from '@shipfox/react-ui/loader';
|
|
6
|
+
import {toast} from '@shipfox/react-ui/toast';
|
|
7
|
+
import {Text} from '@shipfox/react-ui/typography';
|
|
8
|
+
import {useQueryClient} from '@tanstack/react-query';
|
|
9
|
+
import {Link, useNavigate, useSearch} from '@tanstack/react-router';
|
|
10
|
+
import {useEffect, useMemo, useRef, useState} from 'react';
|
|
11
|
+
import {integrationsQueryKeys, useCompleteLinearCallbackMutation} from '#hooks/api/integrations.js';
|
|
12
|
+
import {
|
|
13
|
+
classifyLinearCallbackError,
|
|
14
|
+
clearLinearInstallWorkspace,
|
|
15
|
+
type LinearCallbackFailure,
|
|
16
|
+
parseLinearCallbackQuery,
|
|
17
|
+
readLinearInstallWorkspace,
|
|
18
|
+
serializeLinearCallbackQuery,
|
|
19
|
+
} from '#linear-callback.js';
|
|
20
|
+
|
|
21
|
+
// Keyed by the callback's code+state. This page has no in-place Retry (failures
|
|
22
|
+
// route to "Start over", which begins a fresh install with a new state+code), so
|
|
23
|
+
// entries are intentionally never evicted: retaining them dedupes StrictMode and
|
|
24
|
+
// remount re-runs so the single-use grant code is exchanged at most once. The
|
|
25
|
+
// sibling Sentry page evicts on settle only because its Retry replays the code.
|
|
26
|
+
const callbackRequests = new Map<string, Promise<LinearCallbackResponseDto>>();
|
|
27
|
+
// Keeps the success toast firing once per distinct callback even though the
|
|
28
|
+
// effect re-runs against the cached request as the mutation identity churns.
|
|
29
|
+
const toastedCallbacks = new Set<string>();
|
|
30
|
+
|
|
31
|
+
export function LinearCallbackPage() {
|
|
32
|
+
const search = useSearch({strict: false});
|
|
33
|
+
const navigate = useNavigate();
|
|
34
|
+
const refreshAuth = useRefreshAuth();
|
|
35
|
+
const queryClient = useQueryClient();
|
|
36
|
+
const {mutateAsync: completeLinearCallback} = useCompleteLinearCallbackMutation();
|
|
37
|
+
const params = useMemo(() => parseLinearCallbackQuery(search), [search]);
|
|
38
|
+
const workspaceId = useMemo(() => {
|
|
39
|
+
try {
|
|
40
|
+
return readLinearInstallWorkspace(window.sessionStorage);
|
|
41
|
+
} catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
}, []);
|
|
45
|
+
const [failure, setFailure] = useState<LinearCallbackFailure | undefined>();
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!params) return;
|
|
48
|
+
|
|
49
|
+
let disposed = false;
|
|
50
|
+
const key = serializeLinearCallbackQuery(params);
|
|
51
|
+
let request = callbackRequests.get(key);
|
|
52
|
+
if (!request) {
|
|
53
|
+
request = refreshAuth().then(
|
|
54
|
+
async (session) => await completeLinearCallback({query: params, token: session.token}),
|
|
55
|
+
);
|
|
56
|
+
callbackRequests.set(key, request);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
request.then(
|
|
60
|
+
async (connection) => {
|
|
61
|
+
if (disposed) return;
|
|
62
|
+
try {
|
|
63
|
+
clearLinearInstallWorkspace(window.sessionStorage);
|
|
64
|
+
} catch {
|
|
65
|
+
// The successful API response remains the source of truth for navigation.
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
await queryClient.invalidateQueries({
|
|
69
|
+
queryKey: integrationsQueryKeys.connectionsByWorkspace(connection.workspace_id),
|
|
70
|
+
});
|
|
71
|
+
} catch {
|
|
72
|
+
// Cache refresh is best effort: the successful callback is already committed server-side.
|
|
73
|
+
}
|
|
74
|
+
if (disposed) return;
|
|
75
|
+
if (!toastedCallbacks.has(key)) {
|
|
76
|
+
toastedCallbacks.add(key);
|
|
77
|
+
toast.success('Linear installed.');
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
await navigate({
|
|
81
|
+
to: '/workspaces/$wid/settings/integrations',
|
|
82
|
+
params: {wid: connection.workspace_id},
|
|
83
|
+
replace: true,
|
|
84
|
+
});
|
|
85
|
+
} catch {
|
|
86
|
+
// Keep the completed callback page visible if client navigation is interrupted.
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
(error: unknown) => {
|
|
90
|
+
if (disposed) return;
|
|
91
|
+
setFailure(classifyLinearCallbackError(error));
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
return () => {
|
|
96
|
+
disposed = true;
|
|
97
|
+
};
|
|
98
|
+
}, [completeLinearCallback, navigate, params, queryClient, refreshAuth]);
|
|
99
|
+
|
|
100
|
+
if (!params) {
|
|
101
|
+
return (
|
|
102
|
+
<LinearCallbackFailurePage
|
|
103
|
+
failure={{
|
|
104
|
+
title: 'Invalid Linear callback',
|
|
105
|
+
message: 'Invalid Linear callback. Start the install again from workspace settings.',
|
|
106
|
+
startOver: true,
|
|
107
|
+
signIn: false,
|
|
108
|
+
}}
|
|
109
|
+
workspaceId={workspaceId}
|
|
110
|
+
/>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!failure) return <FullPageLoader aria-label="Connecting Linear" />;
|
|
115
|
+
|
|
116
|
+
return <LinearCallbackFailurePage failure={failure} workspaceId={workspaceId} />;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function LinearCallbackFailurePage({
|
|
120
|
+
failure,
|
|
121
|
+
workspaceId,
|
|
122
|
+
}: {
|
|
123
|
+
failure: LinearCallbackFailure;
|
|
124
|
+
workspaceId: string | undefined;
|
|
125
|
+
}) {
|
|
126
|
+
const headingRef = useRef<HTMLHeadingElement>(null);
|
|
127
|
+
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
headingRef.current?.focus();
|
|
130
|
+
}, []);
|
|
131
|
+
|
|
132
|
+
const recoveryVariant = failure.startOver || failure.signIn ? 'muted' : 'base';
|
|
133
|
+
const switchAccountHref = workspaceId
|
|
134
|
+
? `/auth/logout?redirect=${encodeURIComponent(`/workspaces/${workspaceId}/integrations/linear`)}`
|
|
135
|
+
: '/auth/logout';
|
|
136
|
+
const settingsLink = workspaceId ? (
|
|
137
|
+
<ButtonLink asChild variant={recoveryVariant} className="min-h-44 w-full sm:w-fit">
|
|
138
|
+
<Link to="/workspaces/$wid/settings/integrations" params={{wid: workspaceId}}>
|
|
139
|
+
Back to integrations
|
|
140
|
+
</Link>
|
|
141
|
+
</ButtonLink>
|
|
142
|
+
) : (
|
|
143
|
+
<ButtonLink asChild variant={recoveryVariant} className="min-h-44 w-full sm:w-fit">
|
|
144
|
+
<Link to="/">Back to Shipfox</Link>
|
|
145
|
+
</ButtonLink>
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<main className="flex min-h-screen bg-background-subtle-base px-16 py-32">
|
|
150
|
+
<div className="mx-auto flex w-full max-w-[480px] flex-col justify-center gap-20">
|
|
151
|
+
<h2 ref={headingRef} tabIndex={-1} className="text-24 font-semibold outline-none">
|
|
152
|
+
{failure.title ?? 'Linear install could not be completed'}
|
|
153
|
+
</h2>
|
|
154
|
+
<Callout role="alert" type="error">
|
|
155
|
+
<Text size="sm">{failure.message}</Text>
|
|
156
|
+
</Callout>
|
|
157
|
+
<div className="flex flex-col gap-8 sm:flex-row sm:items-center">
|
|
158
|
+
{failure.signIn ? (
|
|
159
|
+
<ButtonLink asChild className="min-h-44 w-full sm:w-fit">
|
|
160
|
+
<Link to={switchAccountHref}>Switch account</Link>
|
|
161
|
+
</ButtonLink>
|
|
162
|
+
) : null}
|
|
163
|
+
{failure.startOver && workspaceId ? (
|
|
164
|
+
<ButtonLink asChild className="min-h-44 w-full sm:w-fit">
|
|
165
|
+
<Link to="/workspaces/$wid/integrations/linear" params={{wid: workspaceId}}>
|
|
166
|
+
Start over
|
|
167
|
+
</Link>
|
|
168
|
+
</ButtonLink>
|
|
169
|
+
) : null}
|
|
170
|
+
{settingsLink}
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
</main>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {useCallback} from 'react';
|
|
2
|
+
import {RedirectInstallPage} from '#components/redirect-install-page.js';
|
|
3
|
+
import {useCreateLinearInstallMutation} from '#hooks/api/integrations.js';
|
|
4
|
+
import {saveLinearInstallWorkspace} from '#linear-callback.js';
|
|
5
|
+
|
|
6
|
+
export function LinearInstallPage() {
|
|
7
|
+
const createInstall = useCreateLinearInstallMutation();
|
|
8
|
+
const installRequest = useCallback(
|
|
9
|
+
async (body: {workspace_id: string}) => await createInstall.mutateAsync(body),
|
|
10
|
+
[createInstall],
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<RedirectInstallPage
|
|
15
|
+
installRequest={installRequest}
|
|
16
|
+
errorFallbackMessage="Could not start Linear install."
|
|
17
|
+
beforeRedirect={(workspaceId) => {
|
|
18
|
+
try {
|
|
19
|
+
saveLinearInstallWorkspace(window.sessionStorage, workspaceId);
|
|
20
|
+
} catch {
|
|
21
|
+
// Storage can be disabled before the helper gets a usable Storage object.
|
|
22
|
+
}
|
|
23
|
+
}}
|
|
24
|
+
/>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import '@testing-library/jest-dom/vitest';
|
|
3
|
+
import {ApiError} from '@shipfox/client-api';
|
|
4
|
+
import {fireEvent, screen, waitFor} from '@testing-library/react';
|
|
5
|
+
import {renderIntegrationsPage, testWorkspace} from '#test/render.js';
|
|
6
|
+
import {SentryCallbackPage} from './sentry-callback-page.js';
|
|
7
|
+
|
|
8
|
+
const {connectSentryMock} = vi.hoisted(() => ({connectSentryMock: vi.fn()}));
|
|
9
|
+
|
|
10
|
+
const MISSING_PARAMS_RE = /missing required parameters/;
|
|
11
|
+
|
|
12
|
+
vi.mock('@shipfox/client-auth', async (importOriginal) => {
|
|
13
|
+
const actual = await importOriginal<typeof import('@shipfox/client-auth')>();
|
|
14
|
+
return {
|
|
15
|
+
...actual,
|
|
16
|
+
useRefreshAuth: () => () => Promise.resolve({token: 'test-token'}),
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
vi.mock('#hooks/api/integrations.js', async (importOriginal) => {
|
|
21
|
+
const actual = await importOriginal<typeof import('#hooks/api/integrations.js')>();
|
|
22
|
+
return {
|
|
23
|
+
...actual,
|
|
24
|
+
connectSentry: (...args: unknown[]) => connectSentryMock(...args) as never,
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function renderCallback(options: {
|
|
29
|
+
installationId: string;
|
|
30
|
+
orgSlug?: string;
|
|
31
|
+
workspaces?: Parameters<typeof renderIntegrationsPage>[0]['workspaces'];
|
|
32
|
+
search?: string;
|
|
33
|
+
loadingAuth?: boolean;
|
|
34
|
+
}) {
|
|
35
|
+
const search =
|
|
36
|
+
options.search ??
|
|
37
|
+
`?code=the-code&installationId=${options.installationId}${
|
|
38
|
+
options.orgSlug ? `&orgSlug=${options.orgSlug}` : ''
|
|
39
|
+
}`;
|
|
40
|
+
return renderIntegrationsPage({
|
|
41
|
+
path: `/integrations/sentry/callback${search}`,
|
|
42
|
+
routePath: '/integrations/sentry/callback',
|
|
43
|
+
element: <SentryCallbackPage />,
|
|
44
|
+
extraRoutes: ['/workspaces/$wid/settings/integrations', '/workspaces/$wid/integrations/sentry'],
|
|
45
|
+
...(options.workspaces ? {workspaces: options.workspaces} : {}),
|
|
46
|
+
...(options.loadingAuth ? {loadingAuth: true} : {}),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
window.sessionStorage.clear();
|
|
52
|
+
connectSentryMock.mockReset();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('SentryCallbackPage', () => {
|
|
56
|
+
test('always asks for confirmation and never auto-installs', async () => {
|
|
57
|
+
renderCallback({installationId: 'install-confirm', orgSlug: 'acme'});
|
|
58
|
+
|
|
59
|
+
expect(await screen.findByText('Install the Sentry org "acme" in a workspace.')).toBeVisible();
|
|
60
|
+
expect(screen.getByRole('button', {name: 'Install'})).toBeVisible();
|
|
61
|
+
expect(connectSentryMock).not.toHaveBeenCalled();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('waits for auth instead of bouncing away and discarding the grant code', async () => {
|
|
65
|
+
// On a cold return from Sentry, auth is still loading and `workspaces` is
|
|
66
|
+
// empty; the page must hold a loader, not treat that as "no workspace".
|
|
67
|
+
renderCallback({installationId: 'install-loading', loadingAuth: true});
|
|
68
|
+
|
|
69
|
+
expect(await screen.findByRole('status', {name: 'Loading'})).toBeInTheDocument();
|
|
70
|
+
expect(screen.queryByRole('button', {name: 'Install'})).not.toBeInTheDocument();
|
|
71
|
+
expect(connectSentryMock).not.toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('lists every workspace when several exist', async () => {
|
|
75
|
+
renderCallback({
|
|
76
|
+
installationId: 'install-multi',
|
|
77
|
+
workspaces: [
|
|
78
|
+
testWorkspace(),
|
|
79
|
+
testWorkspace({
|
|
80
|
+
id: '33333333-3333-4333-8333-333333333333',
|
|
81
|
+
name: 'Beta',
|
|
82
|
+
membershipId: 'm-2',
|
|
83
|
+
}),
|
|
84
|
+
],
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(await screen.findByText('Acme')).toBeVisible();
|
|
88
|
+
expect(screen.getByText('Beta')).toBeVisible();
|
|
89
|
+
expect(screen.getAllByRole('button', {name: 'Install'})).toHaveLength(2);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('renders a terminal state when params are missing', async () => {
|
|
93
|
+
renderCallback({installationId: 'unused', search: '?installationId=only-id'});
|
|
94
|
+
|
|
95
|
+
// Alert mounts via framer-motion (opacity 0 in jsdom), so assert presence.
|
|
96
|
+
expect(await screen.findByText(MISSING_PARAMS_RE)).toBeInTheDocument();
|
|
97
|
+
expect(screen.queryByRole('button', {name: 'Install'})).not.toBeInTheDocument();
|
|
98
|
+
expect(connectSentryMock).not.toHaveBeenCalled();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('installs on click and lands on the settings integrations page', async () => {
|
|
102
|
+
connectSentryMock.mockResolvedValue({});
|
|
103
|
+
renderCallback({installationId: 'install-success'});
|
|
104
|
+
|
|
105
|
+
fireEvent.click(await screen.findByRole('button', {name: 'Install'}));
|
|
106
|
+
|
|
107
|
+
await screen.findByTestId('route:/workspaces/$wid/settings/integrations');
|
|
108
|
+
expect(connectSentryMock).toHaveBeenCalledWith({
|
|
109
|
+
body: {
|
|
110
|
+
workspace_id: testWorkspace().id,
|
|
111
|
+
code: 'the-code',
|
|
112
|
+
installation_id: 'install-success',
|
|
113
|
+
},
|
|
114
|
+
token: 'test-token',
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('retry after a transient failure issues a fresh request', async () => {
|
|
119
|
+
connectSentryMock
|
|
120
|
+
.mockRejectedValueOnce(
|
|
121
|
+
new ApiError({message: 'down', code: 'provider-unavailable', status: 503}),
|
|
122
|
+
)
|
|
123
|
+
.mockResolvedValueOnce({});
|
|
124
|
+
renderCallback({installationId: 'install-retry'});
|
|
125
|
+
|
|
126
|
+
fireEvent.click(await screen.findByRole('button', {name: 'Install'}));
|
|
127
|
+
fireEvent.click(await screen.findByRole('button', {name: 'Retry'}));
|
|
128
|
+
|
|
129
|
+
await screen.findByTestId('route:/workspaces/$wid/settings/integrations');
|
|
130
|
+
expect(connectSentryMock).toHaveBeenCalledTimes(2);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('a non-rate-limited failure re-enables Retry after a rate-limit lock', async () => {
|
|
134
|
+
connectSentryMock
|
|
135
|
+
.mockRejectedValueOnce(
|
|
136
|
+
new ApiError({
|
|
137
|
+
message: 'slow down',
|
|
138
|
+
code: 'rate-limited',
|
|
139
|
+
status: 429,
|
|
140
|
+
// Real wire shape: client-api stores the whole {code, details} body as ApiError.details.
|
|
141
|
+
details: {code: 'rate-limited', details: {retry_after_seconds: 60}},
|
|
142
|
+
}),
|
|
143
|
+
)
|
|
144
|
+
.mockRejectedValueOnce(new ApiError({message: 'down', code: 'timeout', status: 503}));
|
|
145
|
+
renderCallback({installationId: 'install-lock-recover'});
|
|
146
|
+
|
|
147
|
+
// First failure is rate-limited, so the lock disables Retry.
|
|
148
|
+
fireEvent.click(await screen.findByRole('button', {name: 'Install'}));
|
|
149
|
+
await waitFor(() => expect(screen.getByRole('button', {name: 'Retry'})).toBeDisabled());
|
|
150
|
+
|
|
151
|
+
// A second attempt (via the still-enabled workspace Install) fails with no
|
|
152
|
+
// backoff hint — the lock must clear so the user is not stranded.
|
|
153
|
+
fireEvent.click(screen.getByRole('button', {name: 'Install'}));
|
|
154
|
+
await waitFor(() => expect(screen.getByRole('button', {name: 'Retry'})).toBeEnabled());
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('terminal 422 offers start-over, not retry', async () => {
|
|
158
|
+
connectSentryMock.mockRejectedValue(
|
|
159
|
+
new ApiError({message: 'Sentry rejected the code.', code: 'access-denied', status: 422}),
|
|
160
|
+
);
|
|
161
|
+
renderCallback({installationId: 'install-terminal'});
|
|
162
|
+
|
|
163
|
+
fireEvent.click(await screen.findByRole('button', {name: 'Install'}));
|
|
164
|
+
|
|
165
|
+
expect(await screen.findByText('Sentry rejected the code.')).toBeInTheDocument();
|
|
166
|
+
expect(screen.getByRole('link', {name: 'Start over'})).toBeInTheDocument();
|
|
167
|
+
expect(screen.queryByRole('button', {name: 'Retry'})).not.toBeInTheDocument();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('409 already-linked is terminal without retry or start-over', async () => {
|
|
171
|
+
connectSentryMock.mockRejectedValue(
|
|
172
|
+
new ApiError({
|
|
173
|
+
message: 'already linked',
|
|
174
|
+
code: 'sentry-installation-already-linked',
|
|
175
|
+
status: 409,
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
renderCallback({installationId: 'install-409'});
|
|
179
|
+
|
|
180
|
+
fireEvent.click(await screen.findByRole('button', {name: 'Install'}));
|
|
181
|
+
|
|
182
|
+
expect(
|
|
183
|
+
await screen.findByText('This Sentry org is already installed in another workspace.'),
|
|
184
|
+
).toBeInTheDocument();
|
|
185
|
+
expect(screen.queryByRole('button', {name: 'Retry'})).not.toBeInTheDocument();
|
|
186
|
+
expect(screen.queryByRole('link', {name: 'Start over'})).not.toBeInTheDocument();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('retry after failure works across a remount (no cached rejection)', async () => {
|
|
190
|
+
connectSentryMock.mockRejectedValue(
|
|
191
|
+
new ApiError({message: 'down', code: 'timeout', status: 503}),
|
|
192
|
+
);
|
|
193
|
+
const first = renderCallback({installationId: 'install-remount'});
|
|
194
|
+
fireEvent.click(await screen.findByRole('button', {name: 'Install'}));
|
|
195
|
+
await screen.findByRole('button', {name: 'Retry'});
|
|
196
|
+
first.unmount();
|
|
197
|
+
|
|
198
|
+
connectSentryMock.mockResolvedValue({});
|
|
199
|
+
renderCallback({installationId: 'install-remount'});
|
|
200
|
+
fireEvent.click(await screen.findByRole('button', {name: 'Install'}));
|
|
201
|
+
|
|
202
|
+
await screen.findByTestId('route:/workspaces/$wid/settings/integrations');
|
|
203
|
+
await waitFor(() => expect(connectSentryMock).toHaveBeenCalledTimes(2));
|
|
204
|
+
});
|
|
205
|
+
});
|