@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,227 @@
|
|
|
1
|
+
import {ApiError} from '@shipfox/client-api';
|
|
2
|
+
import {
|
|
3
|
+
classifyLinearCallbackError,
|
|
4
|
+
clearLinearInstallWorkspace,
|
|
5
|
+
LINEAR_INSTALL_WORKSPACE_KEY,
|
|
6
|
+
parseLinearCallbackQuery,
|
|
7
|
+
readLinearInstallWorkspace,
|
|
8
|
+
saveLinearInstallWorkspace,
|
|
9
|
+
serializeLinearCallbackQuery,
|
|
10
|
+
} from './linear-callback.js';
|
|
11
|
+
|
|
12
|
+
describe('Linear callback helpers', () => {
|
|
13
|
+
it('parses and serializes success and provider-error callback queries', () => {
|
|
14
|
+
const success = parseLinearCallbackQuery({code: 'grant code', state: 'signed state'});
|
|
15
|
+
const providerError = parseLinearCallbackQuery({
|
|
16
|
+
error: 'access_denied',
|
|
17
|
+
error_description: 'User denied access',
|
|
18
|
+
state: 'signed state',
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
expect(success).toEqual({code: 'grant code', state: 'signed state'});
|
|
22
|
+
expect(providerError).toEqual({
|
|
23
|
+
error: 'access_denied',
|
|
24
|
+
error_description: 'User denied access',
|
|
25
|
+
state: 'signed state',
|
|
26
|
+
});
|
|
27
|
+
expect(success && serializeLinearCallbackQuery(success)).toBe(
|
|
28
|
+
'code=grant+code&state=signed+state',
|
|
29
|
+
);
|
|
30
|
+
expect(providerError && serializeLinearCallbackQuery(providerError)).toBe(
|
|
31
|
+
'error=access_denied&error_description=User+denied+access&state=signed+state',
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('rejects malformed callback queries', () => {
|
|
36
|
+
expect(parseLinearCallbackQuery({code: 'grant'})).toBeUndefined();
|
|
37
|
+
expect(parseLinearCallbackQuery({state: 'signed'})).toBeUndefined();
|
|
38
|
+
expect(parseLinearCallbackQuery({error: 'access_denied'})).toBeUndefined();
|
|
39
|
+
expect(parseLinearCallbackQuery({state: ['a', 'b']})).toBeUndefined();
|
|
40
|
+
expect(parseLinearCallbackQuery({code: '', state: ''})).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('prefers the grant code when a callback carries both code and error', () => {
|
|
44
|
+
const parsed = parseLinearCallbackQuery({
|
|
45
|
+
code: 'grant',
|
|
46
|
+
error: 'access_denied',
|
|
47
|
+
state: 'signed',
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(parsed).toEqual({code: 'grant', state: 'signed'});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('round-trips workspace navigation storage and swallows storage errors', () => {
|
|
54
|
+
const storage = new Map<string, string>();
|
|
55
|
+
const workspaceStorage = {
|
|
56
|
+
getItem: (key: string) => storage.get(key) ?? null,
|
|
57
|
+
setItem: (key: string, value: string) => storage.set(key, value),
|
|
58
|
+
removeItem: (key: string) => storage.delete(key),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
saveLinearInstallWorkspace(workspaceStorage, 'workspace-1');
|
|
62
|
+
expect(storage.get(LINEAR_INSTALL_WORKSPACE_KEY)).toBe('workspace-1');
|
|
63
|
+
expect(readLinearInstallWorkspace(workspaceStorage)).toBe('workspace-1');
|
|
64
|
+
clearLinearInstallWorkspace(workspaceStorage);
|
|
65
|
+
expect(readLinearInstallWorkspace(workspaceStorage)).toBeUndefined();
|
|
66
|
+
|
|
67
|
+
const unavailableStorage = {
|
|
68
|
+
getItem: () => {
|
|
69
|
+
throw new Error('unavailable');
|
|
70
|
+
},
|
|
71
|
+
setItem: () => {
|
|
72
|
+
throw new Error('unavailable');
|
|
73
|
+
},
|
|
74
|
+
removeItem: () => {
|
|
75
|
+
throw new Error('unavailable');
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
expect(() => saveLinearInstallWorkspace(unavailableStorage, 'workspace-1')).not.toThrow();
|
|
79
|
+
expect(() => readLinearInstallWorkspace(unavailableStorage)).not.toThrow();
|
|
80
|
+
expect(() => clearLinearInstallWorkspace(unavailableStorage)).not.toThrow();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it.each([
|
|
84
|
+
[
|
|
85
|
+
'invalid-linear-install-state',
|
|
86
|
+
400,
|
|
87
|
+
{
|
|
88
|
+
title: 'Linear install link expired',
|
|
89
|
+
message: 'Linear install link expired. Start again from workspace settings.',
|
|
90
|
+
startOver: true,
|
|
91
|
+
signIn: false,
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
[
|
|
95
|
+
'linear-install-state-actor-mismatch',
|
|
96
|
+
403,
|
|
97
|
+
{
|
|
98
|
+
title: 'Different Shipfox account',
|
|
99
|
+
message: 'Different Shipfox account. Sign in with the account that started this install.',
|
|
100
|
+
startOver: true,
|
|
101
|
+
signIn: true,
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
[
|
|
105
|
+
'linear-installation-already-linked',
|
|
106
|
+
409,
|
|
107
|
+
{
|
|
108
|
+
title: 'Linear already linked',
|
|
109
|
+
message: 'This Linear organization is already linked to another workspace.',
|
|
110
|
+
startOver: false,
|
|
111
|
+
signIn: false,
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
[
|
|
115
|
+
'linear-connection-already-linked',
|
|
116
|
+
409,
|
|
117
|
+
{
|
|
118
|
+
title: 'Linear already linked',
|
|
119
|
+
message: 'This Linear organization is already linked to another workspace.',
|
|
120
|
+
startOver: false,
|
|
121
|
+
signIn: false,
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
[
|
|
125
|
+
'linear-authorization-scope-mismatch',
|
|
126
|
+
422,
|
|
127
|
+
{
|
|
128
|
+
title: 'Linear permissions needed',
|
|
129
|
+
message:
|
|
130
|
+
'Linear did not authorize the permissions Shipfox needs. Review the Linear consent and start again.',
|
|
131
|
+
startOver: true,
|
|
132
|
+
signIn: false,
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
[
|
|
136
|
+
'linear-oauth-callback-error',
|
|
137
|
+
422,
|
|
138
|
+
{
|
|
139
|
+
title: 'Linear permissions needed',
|
|
140
|
+
message:
|
|
141
|
+
'Linear did not authorize the permissions Shipfox needs. Review the Linear consent and start again.',
|
|
142
|
+
startOver: true,
|
|
143
|
+
signIn: false,
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
[
|
|
147
|
+
'unauthorized',
|
|
148
|
+
401,
|
|
149
|
+
{
|
|
150
|
+
title: 'Different Shipfox account',
|
|
151
|
+
message: 'Different Shipfox account. Sign in with the account that started this install.',
|
|
152
|
+
startOver: true,
|
|
153
|
+
signIn: true,
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
[
|
|
157
|
+
'provider-unavailable',
|
|
158
|
+
503,
|
|
159
|
+
{
|
|
160
|
+
message: 'Linear is temporarily unavailable. Start a new install when it is available.',
|
|
161
|
+
startOver: true,
|
|
162
|
+
signIn: false,
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
[
|
|
166
|
+
'timeout',
|
|
167
|
+
503,
|
|
168
|
+
{
|
|
169
|
+
message: 'Linear is temporarily unavailable. Start a new install when it is available.',
|
|
170
|
+
startOver: true,
|
|
171
|
+
signIn: false,
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
[
|
|
175
|
+
'rate-limited',
|
|
176
|
+
429,
|
|
177
|
+
{
|
|
178
|
+
message: 'Linear is temporarily unavailable. Start a new install when it is available.',
|
|
179
|
+
startOver: true,
|
|
180
|
+
signIn: false,
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
[
|
|
184
|
+
'network-error',
|
|
185
|
+
0,
|
|
186
|
+
{
|
|
187
|
+
message: 'Could not reach Shipfox. Check your connection and start again.',
|
|
188
|
+
startOver: true,
|
|
189
|
+
signIn: false,
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
[
|
|
193
|
+
'slug-conflict',
|
|
194
|
+
409,
|
|
195
|
+
{
|
|
196
|
+
message: 'Could not complete the Linear install. Start again from workspace settings.',
|
|
197
|
+
startOver: true,
|
|
198
|
+
signIn: false,
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
[
|
|
202
|
+
'unknown-error',
|
|
203
|
+
400,
|
|
204
|
+
{
|
|
205
|
+
message: 'Could not complete the Linear install. Start again from workspace settings.',
|
|
206
|
+
startOver: true,
|
|
207
|
+
signIn: false,
|
|
208
|
+
},
|
|
209
|
+
],
|
|
210
|
+
])('classifies %s without retrying the callback code', (code, status, expected) => {
|
|
211
|
+
const failure = classifyLinearCallbackError(
|
|
212
|
+
new ApiError({code, message: 'request failed', status}),
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
expect(failure).toEqual(expected);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('uses the generic recovery for unexpected errors', () => {
|
|
219
|
+
const failure = classifyLinearCallbackError(new Error('network down'));
|
|
220
|
+
|
|
221
|
+
expect(failure).toEqual({
|
|
222
|
+
message: 'Could not complete the Linear install. Start again from workspace settings.',
|
|
223
|
+
startOver: true,
|
|
224
|
+
signIn: false,
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type {LinearCallbackQueryDto} from '@shipfox/api-integration-linear-dto';
|
|
2
|
+
import {ApiError} from '@shipfox/client-api';
|
|
3
|
+
|
|
4
|
+
export const LINEAR_INSTALL_WORKSPACE_KEY = 'shipfox.linear-install.workspace-id';
|
|
5
|
+
|
|
6
|
+
type WorkspaceStorage = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
|
|
7
|
+
|
|
8
|
+
export function saveLinearInstallWorkspace(storage: WorkspaceStorage, workspaceId: string): void {
|
|
9
|
+
try {
|
|
10
|
+
storage.setItem(LINEAR_INSTALL_WORKSPACE_KEY, workspaceId);
|
|
11
|
+
} catch {
|
|
12
|
+
// Storage only improves recovery navigation; callback input always comes from the URL.
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function readLinearInstallWorkspace(storage: WorkspaceStorage): string | undefined {
|
|
17
|
+
try {
|
|
18
|
+
return storage.getItem(LINEAR_INSTALL_WORKSPACE_KEY) ?? undefined;
|
|
19
|
+
} catch {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function clearLinearInstallWorkspace(storage: WorkspaceStorage): void {
|
|
25
|
+
try {
|
|
26
|
+
storage.removeItem(LINEAR_INSTALL_WORKSPACE_KEY);
|
|
27
|
+
} catch {
|
|
28
|
+
// A stale navigation hint cannot alter the callback request or its response.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function parseLinearCallbackQuery(
|
|
33
|
+
search: Record<string, unknown>,
|
|
34
|
+
): LinearCallbackQueryDto | undefined {
|
|
35
|
+
const state = stringParam(search.state);
|
|
36
|
+
if (!state) return undefined;
|
|
37
|
+
|
|
38
|
+
const code = stringParam(search.code);
|
|
39
|
+
if (code) return {code, state};
|
|
40
|
+
|
|
41
|
+
const error = stringParam(search.error);
|
|
42
|
+
if (!error) return undefined;
|
|
43
|
+
const errorDescription = stringParam(search.error_description);
|
|
44
|
+
return errorDescription ? {error, error_description: errorDescription, state} : {error, state};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function serializeLinearCallbackQuery(query: LinearCallbackQueryDto): string {
|
|
48
|
+
const params = new URLSearchParams();
|
|
49
|
+
if ('code' in query) params.set('code', query.code);
|
|
50
|
+
else {
|
|
51
|
+
params.set('error', query.error);
|
|
52
|
+
if (query.error_description) params.set('error_description', query.error_description);
|
|
53
|
+
}
|
|
54
|
+
params.set('state', query.state);
|
|
55
|
+
return params.toString();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type LinearCallbackFailure = {
|
|
59
|
+
title?: string;
|
|
60
|
+
message: string;
|
|
61
|
+
startOver: boolean;
|
|
62
|
+
signIn: boolean;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export function classifyLinearCallbackError(error: unknown): LinearCallbackFailure {
|
|
66
|
+
if (error instanceof ApiError) {
|
|
67
|
+
if (error.code === 'invalid-linear-install-state') {
|
|
68
|
+
return {
|
|
69
|
+
title: 'Linear install link expired',
|
|
70
|
+
message: 'Linear install link expired. Start again from workspace settings.',
|
|
71
|
+
startOver: true,
|
|
72
|
+
signIn: false,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (error.code === 'linear-install-state-actor-mismatch' || error.code === 'unauthorized') {
|
|
76
|
+
return {
|
|
77
|
+
title: 'Different Shipfox account',
|
|
78
|
+
message: 'Different Shipfox account. Sign in with the account that started this install.',
|
|
79
|
+
startOver: true,
|
|
80
|
+
signIn: true,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (
|
|
84
|
+
error.code === 'linear-installation-already-linked' ||
|
|
85
|
+
error.code === 'linear-connection-already-linked'
|
|
86
|
+
) {
|
|
87
|
+
return {
|
|
88
|
+
title: 'Linear already linked',
|
|
89
|
+
message: 'This Linear organization is already linked to another workspace.',
|
|
90
|
+
startOver: false,
|
|
91
|
+
signIn: false,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
if (
|
|
95
|
+
error.code === 'linear-authorization-scope-mismatch' ||
|
|
96
|
+
error.code === 'linear-oauth-callback-error'
|
|
97
|
+
) {
|
|
98
|
+
return {
|
|
99
|
+
title: 'Linear permissions needed',
|
|
100
|
+
message:
|
|
101
|
+
'Linear did not authorize the permissions Shipfox needs. Review the Linear consent and start again.',
|
|
102
|
+
startOver: true,
|
|
103
|
+
signIn: false,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (error.status === 0 || error.code === 'network-error') {
|
|
107
|
+
return {
|
|
108
|
+
message: 'Could not reach Shipfox. Check your connection and start again.',
|
|
109
|
+
startOver: true,
|
|
110
|
+
signIn: false,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (
|
|
114
|
+
error.status >= 500 ||
|
|
115
|
+
error.status === 429 ||
|
|
116
|
+
error.code === 'provider-unavailable' ||
|
|
117
|
+
error.code === 'timeout' ||
|
|
118
|
+
error.code === 'rate-limited'
|
|
119
|
+
) {
|
|
120
|
+
return {
|
|
121
|
+
message: 'Linear is temporarily unavailable. Start a new install when it is available.',
|
|
122
|
+
startOver: true,
|
|
123
|
+
signIn: false,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
message: 'Could not complete the Linear install. Start again from workspace settings.',
|
|
129
|
+
startOver: true,
|
|
130
|
+
signIn: false,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function stringParam(value: unknown): string | undefined {
|
|
135
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
136
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import {ApiError} from '@shipfox/client-api';
|
|
2
|
+
import {giteaConnectErrorToFormError} from './gitea-form-errors.js';
|
|
3
|
+
|
|
4
|
+
function apiError(code: string, status = 400, message = `${code} server message`) {
|
|
5
|
+
return new ApiError({code, message, status});
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
describe('giteaConnectErrorToFormError', () => {
|
|
9
|
+
test('routes gitea-organization-not-found to the org field', () => {
|
|
10
|
+
const result = giteaConnectErrorToFormError(apiError('gitea-organization-not-found', 404));
|
|
11
|
+
|
|
12
|
+
expect(result).toEqual({
|
|
13
|
+
kind: 'field',
|
|
14
|
+
field: 'org',
|
|
15
|
+
message: "We couldn't find that organization on Gitea. Check the name and try again.",
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('routes gitea-org-already-linked to the org field', () => {
|
|
20
|
+
const result = giteaConnectErrorToFormError(apiError('gitea-org-already-linked', 409));
|
|
21
|
+
|
|
22
|
+
expect(result).toEqual({
|
|
23
|
+
kind: 'field',
|
|
24
|
+
field: 'org',
|
|
25
|
+
message: 'That organization is already installed.',
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('routes provider errors to a form-level alert with the server message', () => {
|
|
30
|
+
const result = giteaConnectErrorToFormError(apiError('rate-limited', 429));
|
|
31
|
+
|
|
32
|
+
expect(result).toEqual({kind: 'form', message: 'rate-limited server message'});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('hides the raw URL in a network-error behind a friendly form-level message', () => {
|
|
36
|
+
const result = giteaConnectErrorToFormError(apiError('network-error', 0));
|
|
37
|
+
|
|
38
|
+
expect(result).toEqual({
|
|
39
|
+
kind: 'form',
|
|
40
|
+
message: "We couldn't reach the server. Check your connection and try again.",
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('routes a non-ApiError to a generic form-level alert', () => {
|
|
45
|
+
const result = giteaConnectErrorToFormError(new Error('boom'));
|
|
46
|
+
|
|
47
|
+
expect(result).toEqual({kind: 'form', message: 'boom'});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('falls back for a non-Error value', () => {
|
|
51
|
+
const result = giteaConnectErrorToFormError('nope');
|
|
52
|
+
|
|
53
|
+
expect(result).toEqual({kind: 'form', message: 'Something went wrong. Try again.'});
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import {ApiError} from '@shipfox/client-api';
|
|
2
|
+
|
|
3
|
+
export type GiteaConnectField = 'org';
|
|
4
|
+
|
|
5
|
+
export type GiteaConnectFormErrorMapping =
|
|
6
|
+
| {kind: 'field'; field: GiteaConnectField; message: string}
|
|
7
|
+
| {kind: 'form'; message: string};
|
|
8
|
+
|
|
9
|
+
export function giteaConnectErrorToFormError(error: unknown): GiteaConnectFormErrorMapping {
|
|
10
|
+
const code = error instanceof ApiError ? error.code : undefined;
|
|
11
|
+
|
|
12
|
+
if (code === 'gitea-organization-not-found') {
|
|
13
|
+
return {
|
|
14
|
+
kind: 'field',
|
|
15
|
+
field: 'org',
|
|
16
|
+
message: "We couldn't find that organization on Gitea. Check the name and try again.",
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
if (code === 'gitea-org-already-linked') {
|
|
20
|
+
return {kind: 'field', field: 'org', message: 'That organization is already installed.'};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return {kind: 'form', message: giteaConnectErrorMessage(error)};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function giteaConnectErrorMessage(error: unknown): string {
|
|
27
|
+
if (error instanceof ApiError) {
|
|
28
|
+
// `network-error` carries the raw request URL in its message; never surface it.
|
|
29
|
+
if (error.code === 'network-error') {
|
|
30
|
+
return "We couldn't reach the server. Check your connection and try again.";
|
|
31
|
+
}
|
|
32
|
+
return error.message;
|
|
33
|
+
}
|
|
34
|
+
if (error instanceof Error) return error.message;
|
|
35
|
+
return 'Something went wrong. Try again.';
|
|
36
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import {useActiveWorkspace} from '@shipfox/client-auth';
|
|
2
|
+
import {Button, ButtonLink} from '@shipfox/react-ui/button';
|
|
3
|
+
import {Callout} from '@shipfox/react-ui/callout';
|
|
4
|
+
import {FormField, FormFieldInput, fieldError} from '@shipfox/react-ui/form-field';
|
|
5
|
+
import {toast} from '@shipfox/react-ui/toast';
|
|
6
|
+
import {Header, Text} from '@shipfox/react-ui/typography';
|
|
7
|
+
import {useForm} from '@tanstack/react-form';
|
|
8
|
+
import {useQueryClient} from '@tanstack/react-query';
|
|
9
|
+
import {Link, useNavigate} from '@tanstack/react-router';
|
|
10
|
+
import {useState} from 'react';
|
|
11
|
+
import {integrationsQueryKeys, useCreateGiteaConnectionMutation} from '#hooks/api/integrations.js';
|
|
12
|
+
import {giteaConnectErrorToFormError} from './gitea-form-errors.js';
|
|
13
|
+
|
|
14
|
+
export function GiteaInstallPage() {
|
|
15
|
+
const workspace = useActiveWorkspace();
|
|
16
|
+
const connect = useCreateGiteaConnectionMutation();
|
|
17
|
+
const queryClient = useQueryClient();
|
|
18
|
+
const navigate = useNavigate();
|
|
19
|
+
const [formError, setFormError] = useState<string | undefined>();
|
|
20
|
+
|
|
21
|
+
const form = useForm({
|
|
22
|
+
defaultValues: {org: ''},
|
|
23
|
+
onSubmit: async ({value}) => {
|
|
24
|
+
setFormError(undefined);
|
|
25
|
+
const workspaceId = workspace.id;
|
|
26
|
+
try {
|
|
27
|
+
await connect.mutateAsync({workspace_id: workspaceId, org: value.org.trim()});
|
|
28
|
+
await queryClient.invalidateQueries({
|
|
29
|
+
queryKey: integrationsQueryKeys.sourceConnections(workspaceId),
|
|
30
|
+
// The workspace home is the next active observer, not this page, so the
|
|
31
|
+
// default 'active' refetch would no-op and leave the cache stale until
|
|
32
|
+
// after the home redirects back here.
|
|
33
|
+
refetchType: 'all',
|
|
34
|
+
});
|
|
35
|
+
toast.success('Gitea organization installed.');
|
|
36
|
+
await navigate({to: '/workspaces/$wid', params: {wid: workspaceId}});
|
|
37
|
+
} catch (error) {
|
|
38
|
+
const mapped = giteaConnectErrorToFormError(error);
|
|
39
|
+
if (mapped.kind === 'field') {
|
|
40
|
+
form.setFieldMeta(mapped.field, (prev) => ({
|
|
41
|
+
...prev,
|
|
42
|
+
errorMap: {...prev.errorMap, onServer: mapped.message},
|
|
43
|
+
}));
|
|
44
|
+
} else {
|
|
45
|
+
setFormError(mapped.message);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className="mx-auto flex w-full max-w-[480px] flex-col gap-20">
|
|
53
|
+
<header className="flex flex-col gap-8">
|
|
54
|
+
<Header variant="h1">Install Gitea</Header>
|
|
55
|
+
<Text size="md" className="text-foreground-neutral-muted">
|
|
56
|
+
Enter the Gitea organization to install in this workspace.
|
|
57
|
+
</Text>
|
|
58
|
+
</header>
|
|
59
|
+
|
|
60
|
+
<form
|
|
61
|
+
className="flex flex-col gap-18"
|
|
62
|
+
noValidate
|
|
63
|
+
onSubmit={(event) => {
|
|
64
|
+
event.preventDefault();
|
|
65
|
+
event.stopPropagation();
|
|
66
|
+
void form.handleSubmit();
|
|
67
|
+
}}
|
|
68
|
+
>
|
|
69
|
+
{formError ? (
|
|
70
|
+
<Callout role="alert" type="error">
|
|
71
|
+
{formError}
|
|
72
|
+
</Callout>
|
|
73
|
+
) : null}
|
|
74
|
+
<form.Field
|
|
75
|
+
name="org"
|
|
76
|
+
validators={{
|
|
77
|
+
onBlur: ({value}) =>
|
|
78
|
+
value.trim().length > 0 ? undefined : 'Enter your Gitea organization.',
|
|
79
|
+
onSubmit: ({value}) =>
|
|
80
|
+
value.trim().length > 0 ? undefined : 'Enter your Gitea organization.',
|
|
81
|
+
}}
|
|
82
|
+
>
|
|
83
|
+
{(field) => (
|
|
84
|
+
<FormField label="Organization" id="gitea-org" error={fieldError(field)}>
|
|
85
|
+
<FormFieldInput
|
|
86
|
+
placeholder="my-org"
|
|
87
|
+
autoComplete="off"
|
|
88
|
+
value={field.state.value}
|
|
89
|
+
onChange={(event) => field.handleChange(event.target.value)}
|
|
90
|
+
onBlur={field.handleBlur}
|
|
91
|
+
/>
|
|
92
|
+
</FormField>
|
|
93
|
+
)}
|
|
94
|
+
</form.Field>
|
|
95
|
+
<div className="flex items-center gap-12">
|
|
96
|
+
<Button type="submit" isLoading={connect.isPending}>
|
|
97
|
+
Install
|
|
98
|
+
</Button>
|
|
99
|
+
<ButtonLink asChild variant="muted">
|
|
100
|
+
<Link to="/workspaces/$wid/integrations" params={{wid: workspace.id}}>
|
|
101
|
+
Cancel
|
|
102
|
+
</Link>
|
|
103
|
+
</ButtonLink>
|
|
104
|
+
</div>
|
|
105
|
+
</form>
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import {RedirectInstallPage} from '#components/redirect-install-page.js';
|
|
2
|
+
import {createGithubInstall} from '#hooks/api/integrations.js';
|
|
3
|
+
|
|
4
|
+
export function GithubInstallPage() {
|
|
5
|
+
return (
|
|
6
|
+
<RedirectInstallPage
|
|
7
|
+
installRequest={createGithubInstall}
|
|
8
|
+
errorFallbackMessage="Could not start GitHub install."
|
|
9
|
+
/>
|
|
10
|
+
);
|
|
11
|
+
}
|