@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.
Files changed (243) hide show
  1. package/.storybook/main.ts +1 -0
  2. package/.storybook/preview.css +4 -0
  3. package/.storybook/preview.tsx +56 -0
  4. package/.turbo/turbo-build.log +2 -0
  5. package/.turbo/turbo-type$colon$emit.log +1 -0
  6. package/.turbo/turbo-type.log +1 -0
  7. package/CHANGELOG.md +207 -0
  8. package/LICENSE +21 -0
  9. package/dist/components/connection-picker.d.ts +7 -0
  10. package/dist/components/connection-picker.d.ts.map +1 -0
  11. package/dist/components/connection-picker.js +98 -0
  12. package/dist/components/connection-picker.js.map +1 -0
  13. package/dist/components/installed-integrations-section.d.ts +14 -0
  14. package/dist/components/installed-integrations-section.d.ts.map +1 -0
  15. package/dist/components/installed-integrations-section.js +203 -0
  16. package/dist/components/installed-integrations-section.js.map +1 -0
  17. package/dist/components/integration-delete-confirm-modal.d.ts +10 -0
  18. package/dist/components/integration-delete-confirm-modal.d.ts.map +1 -0
  19. package/dist/components/integration-delete-confirm-modal.js +61 -0
  20. package/dist/components/integration-delete-confirm-modal.js.map +1 -0
  21. package/dist/components/integration-gallery-for-workspace.d.ts +9 -0
  22. package/dist/components/integration-gallery-for-workspace.d.ts.map +1 -0
  23. package/dist/components/integration-gallery-for-workspace.js +169 -0
  24. package/dist/components/integration-gallery-for-workspace.js.map +1 -0
  25. package/dist/components/integration-gallery.d.ts +8 -0
  26. package/dist/components/integration-gallery.d.ts.map +1 -0
  27. package/dist/components/integration-gallery.js +26 -0
  28. package/dist/components/integration-gallery.js.map +1 -0
  29. package/dist/components/integration-gallery.stories.d.ts +26 -0
  30. package/dist/components/integration-gallery.stories.d.ts.map +1 -0
  31. package/dist/components/integration-gallery.stories.js +335 -0
  32. package/dist/components/integration-gallery.stories.js.map +1 -0
  33. package/dist/components/integration-usage-events.d.ts +4 -0
  34. package/dist/components/integration-usage-events.d.ts.map +1 -0
  35. package/dist/components/integration-usage-events.js +127 -0
  36. package/dist/components/integration-usage-events.js.map +1 -0
  37. package/dist/components/integration-usage-modal.d.ts +16 -0
  38. package/dist/components/integration-usage-modal.d.ts.map +1 -0
  39. package/dist/components/integration-usage-modal.js +184 -0
  40. package/dist/components/integration-usage-modal.js.map +1 -0
  41. package/dist/components/provider-grid.d.ts +14 -0
  42. package/dist/components/provider-grid.d.ts.map +1 -0
  43. package/dist/components/provider-grid.js +152 -0
  44. package/dist/components/provider-grid.js.map +1 -0
  45. package/dist/components/redirect-install-page.d.ts +19 -0
  46. package/dist/components/redirect-install-page.d.ts.map +1 -0
  47. package/dist/components/redirect-install-page.js +68 -0
  48. package/dist/components/redirect-install-page.js.map +1 -0
  49. package/dist/components/repository-picker.d.ts +15 -0
  50. package/dist/components/repository-picker.d.ts.map +1 -0
  51. package/dist/components/repository-picker.js +78 -0
  52. package/dist/components/repository-picker.js.map +1 -0
  53. package/dist/components/webhook/copyable-value.d.ts +10 -0
  54. package/dist/components/webhook/copyable-value.d.ts.map +1 -0
  55. package/dist/components/webhook/copyable-value.js +65 -0
  56. package/dist/components/webhook/copyable-value.js.map +1 -0
  57. package/dist/components/webhook/webhook-create-modal.d.ts +10 -0
  58. package/dist/components/webhook/webhook-create-modal.d.ts.map +1 -0
  59. package/dist/components/webhook/webhook-create-modal.js +233 -0
  60. package/dist/components/webhook/webhook-create-modal.js.map +1 -0
  61. package/dist/components/webhook/webhook-form-errors.d.ts +11 -0
  62. package/dist/components/webhook/webhook-form-errors.d.ts.map +1 -0
  63. package/dist/components/webhook/webhook-form-errors.js +27 -0
  64. package/dist/components/webhook/webhook-form-errors.js.map +1 -0
  65. package/dist/components/webhook/webhook-modals.stories.d.ts +25 -0
  66. package/dist/components/webhook/webhook-modals.stories.d.ts.map +1 -0
  67. package/dist/components/webhook/webhook-modals.stories.js +222 -0
  68. package/dist/components/webhook/webhook-modals.stories.js.map +1 -0
  69. package/dist/components/webhook/webhook-public-endpoint-alert.d.ts +2 -0
  70. package/dist/components/webhook/webhook-public-endpoint-alert.d.ts.map +1 -0
  71. package/dist/components/webhook/webhook-public-endpoint-alert.js +20 -0
  72. package/dist/components/webhook/webhook-public-endpoint-alert.js.map +1 -0
  73. package/dist/components/webhook/webhook-usage-details.d.ts +7 -0
  74. package/dist/components/webhook/webhook-usage-details.d.ts.map +1 -0
  75. package/dist/components/webhook/webhook-usage-details.js +49 -0
  76. package/dist/components/webhook/webhook-usage-details.js.map +1 -0
  77. package/dist/connection-status-badge.d.ts +12 -0
  78. package/dist/connection-status-badge.d.ts.map +1 -0
  79. package/dist/connection-status-badge.js +34 -0
  80. package/dist/connection-status-badge.js.map +1 -0
  81. package/dist/feature.d.ts +37 -0
  82. package/dist/feature.d.ts.map +1 -0
  83. package/dist/feature.js +48 -0
  84. package/dist/feature.js.map +1 -0
  85. package/dist/hooks/api/integrations.d.ts +265 -0
  86. package/dist/hooks/api/integrations.d.ts.map +1 -0
  87. package/dist/hooks/api/integrations.js +229 -0
  88. package/dist/hooks/api/integrations.js.map +1 -0
  89. package/dist/hooks/api/webhook-connections.d.ts +91 -0
  90. package/dist/hooks/api/webhook-connections.d.ts.map +1 -0
  91. package/dist/hooks/api/webhook-connections.js +97 -0
  92. package/dist/hooks/api/webhook-connections.js.map +1 -0
  93. package/dist/index.d.ts +17 -0
  94. package/dist/index.d.ts.map +1 -0
  95. package/dist/index.js +18 -0
  96. package/dist/index.js.map +1 -0
  97. package/dist/integration-icon.d.ts +17 -0
  98. package/dist/integration-icon.d.ts.map +1 -0
  99. package/dist/integration-icon.js +22 -0
  100. package/dist/integration-icon.js.map +1 -0
  101. package/dist/linear-callback.d.ts +17 -0
  102. package/dist/linear-callback.d.ts.map +1 -0
  103. package/dist/linear-callback.js +113 -0
  104. package/dist/linear-callback.js.map +1 -0
  105. package/dist/pages/gitea-form-errors.d.ts +11 -0
  106. package/dist/pages/gitea-form-errors.d.ts.map +1 -0
  107. package/dist/pages/gitea-form-errors.js +35 -0
  108. package/dist/pages/gitea-form-errors.js.map +1 -0
  109. package/dist/pages/gitea-install-page.d.ts +2 -0
  110. package/dist/pages/gitea-install-page.d.ts.map +1 -0
  111. package/dist/pages/gitea-install-page.js +139 -0
  112. package/dist/pages/gitea-install-page.js.map +1 -0
  113. package/dist/pages/github-install-page.d.ts +2 -0
  114. package/dist/pages/github-install-page.d.ts.map +1 -0
  115. package/dist/pages/github-install-page.js +11 -0
  116. package/dist/pages/github-install-page.js.map +1 -0
  117. package/dist/pages/linear-callback-page.d.ts +2 -0
  118. package/dist/pages/linear-callback-page.d.ts.map +1 -0
  119. package/dist/pages/linear-callback-page.js +192 -0
  120. package/dist/pages/linear-callback-page.js.map +1 -0
  121. package/dist/pages/linear-install-page.d.ts +2 -0
  122. package/dist/pages/linear-install-page.d.ts.map +1 -0
  123. package/dist/pages/linear-install-page.js +24 -0
  124. package/dist/pages/linear-install-page.js.map +1 -0
  125. package/dist/pages/sentry-callback-page.d.ts +2 -0
  126. package/dist/pages/sentry-callback-page.d.ts.map +1 -0
  127. package/dist/pages/sentry-callback-page.js +256 -0
  128. package/dist/pages/sentry-callback-page.js.map +1 -0
  129. package/dist/pages/sentry-install-page.d.ts +2 -0
  130. package/dist/pages/sentry-install-page.d.ts.map +1 -0
  131. package/dist/pages/sentry-install-page.js +17 -0
  132. package/dist/pages/sentry-install-page.js.map +1 -0
  133. package/dist/pages/source-control-onboarding-page.d.ts +2 -0
  134. package/dist/pages/source-control-onboarding-page.d.ts.map +1 -0
  135. package/dist/pages/source-control-onboarding-page.js +37 -0
  136. package/dist/pages/source-control-onboarding-page.js.map +1 -0
  137. package/dist/provider-catalog.d.ts +15 -0
  138. package/dist/provider-catalog.d.ts.map +1 -0
  139. package/dist/provider-catalog.js +28 -0
  140. package/dist/provider-catalog.js.map +1 -0
  141. package/dist/routes/gitea.d.ts +6 -0
  142. package/dist/routes/gitea.d.ts.map +1 -0
  143. package/dist/routes/gitea.js +7 -0
  144. package/dist/routes/gitea.js.map +1 -0
  145. package/dist/routes/github-callback.d.ts +6 -0
  146. package/dist/routes/github-callback.d.ts.map +1 -0
  147. package/dist/routes/github-callback.js +129 -0
  148. package/dist/routes/github-callback.js.map +1 -0
  149. package/dist/routes/github.d.ts +6 -0
  150. package/dist/routes/github.d.ts.map +1 -0
  151. package/dist/routes/github.js +7 -0
  152. package/dist/routes/github.js.map +1 -0
  153. package/dist/routes/integrations.d.ts +6 -0
  154. package/dist/routes/integrations.d.ts.map +1 -0
  155. package/dist/routes/integrations.js +7 -0
  156. package/dist/routes/integrations.js.map +1 -0
  157. package/dist/routes/linear-callback.d.ts +6 -0
  158. package/dist/routes/linear-callback.d.ts.map +1 -0
  159. package/dist/routes/linear-callback.js +7 -0
  160. package/dist/routes/linear-callback.js.map +1 -0
  161. package/dist/routes/linear.d.ts +6 -0
  162. package/dist/routes/linear.d.ts.map +1 -0
  163. package/dist/routes/linear.js +7 -0
  164. package/dist/routes/linear.js.map +1 -0
  165. package/dist/routes/sentry-callback.d.ts +6 -0
  166. package/dist/routes/sentry-callback.d.ts.map +1 -0
  167. package/dist/routes/sentry-callback.js +7 -0
  168. package/dist/routes/sentry-callback.js.map +1 -0
  169. package/dist/routes/sentry.d.ts +6 -0
  170. package/dist/routes/sentry.d.ts.map +1 -0
  171. package/dist/routes/sentry.js +7 -0
  172. package/dist/routes/sentry.js.map +1 -0
  173. package/dist/sentry-callback.d.ts +35 -0
  174. package/dist/sentry-callback.d.ts.map +1 -0
  175. package/dist/sentry-callback.js +132 -0
  176. package/dist/sentry-callback.js.map +1 -0
  177. package/dist/tsconfig.test.tsbuildinfo +1 -0
  178. package/package.json +120 -0
  179. package/src/components/connection-picker.tsx +89 -0
  180. package/src/components/installed-integrations-section.tsx +198 -0
  181. package/src/components/integration-delete-confirm-modal.tsx +62 -0
  182. package/src/components/integration-gallery-for-workspace.tsx +193 -0
  183. package/src/components/integration-gallery.stories.tsx +302 -0
  184. package/src/components/integration-gallery.test.tsx +692 -0
  185. package/src/components/integration-gallery.tsx +49 -0
  186. package/src/components/integration-usage-events.test.ts +80 -0
  187. package/src/components/integration-usage-events.ts +116 -0
  188. package/src/components/integration-usage-modal.tsx +178 -0
  189. package/src/components/provider-grid.tsx +163 -0
  190. package/src/components/redirect-install-page.test.tsx +98 -0
  191. package/src/components/redirect-install-page.tsx +68 -0
  192. package/src/components/repository-picker.tsx +102 -0
  193. package/src/components/webhook/copyable-value.test.tsx +41 -0
  194. package/src/components/webhook/copyable-value.tsx +72 -0
  195. package/src/components/webhook/webhook-create-modal.tsx +247 -0
  196. package/src/components/webhook/webhook-form-errors.test.ts +45 -0
  197. package/src/components/webhook/webhook-form-errors.ts +32 -0
  198. package/src/components/webhook/webhook-modals.stories.tsx +225 -0
  199. package/src/components/webhook/webhook-public-endpoint-alert.tsx +15 -0
  200. package/src/components/webhook/webhook-usage-details.tsx +44 -0
  201. package/src/connection-status-badge.tsx +38 -0
  202. package/src/feature.ts +47 -0
  203. package/src/hooks/api/integrations.test.ts +105 -0
  204. package/src/hooks/api/integrations.ts +287 -0
  205. package/src/hooks/api/webhook-connections.ts +115 -0
  206. package/src/index.ts +16 -0
  207. package/src/integration-icon.react.test.tsx +40 -0
  208. package/src/integration-icon.test.ts +19 -0
  209. package/src/integration-icon.tsx +26 -0
  210. package/src/linear-callback.test.ts +227 -0
  211. package/src/linear-callback.ts +136 -0
  212. package/src/pages/gitea-form-errors.test.ts +55 -0
  213. package/src/pages/gitea-form-errors.ts +36 -0
  214. package/src/pages/gitea-install-page.tsx +108 -0
  215. package/src/pages/github-install-page.tsx +11 -0
  216. package/src/pages/linear-callback-page.test.tsx +157 -0
  217. package/src/pages/linear-callback-page.tsx +175 -0
  218. package/src/pages/linear-install-page.tsx +26 -0
  219. package/src/pages/sentry-callback-page.test.tsx +205 -0
  220. package/src/pages/sentry-callback-page.tsx +238 -0
  221. package/src/pages/sentry-install-page.tsx +17 -0
  222. package/src/pages/source-control-onboarding-page.test.tsx +46 -0
  223. package/src/pages/source-control-onboarding-page.tsx +26 -0
  224. package/src/provider-catalog.test.ts +42 -0
  225. package/src/provider-catalog.ts +49 -0
  226. package/src/routes/gitea.tsx +4 -0
  227. package/src/routes/github-callback.tsx +114 -0
  228. package/src/routes/github.tsx +4 -0
  229. package/src/routes/integrations.tsx +4 -0
  230. package/src/routes/linear-callback.tsx +4 -0
  231. package/src/routes/linear.tsx +4 -0
  232. package/src/routes/sentry-callback.tsx +4 -0
  233. package/src/routes/sentry.tsx +4 -0
  234. package/src/sentry-callback.test.ts +239 -0
  235. package/src/sentry-callback.ts +142 -0
  236. package/test/render.tsx +111 -0
  237. package/test/setup.ts +3 -0
  238. package/tsconfig.build.json +9 -0
  239. package/tsconfig.build.tsbuildinfo +1 -0
  240. package/tsconfig.json +3 -0
  241. package/tsconfig.test.json +8 -0
  242. package/vercel.json +8 -0
  243. package/vitest.config.ts +75 -0
@@ -0,0 +1,238 @@
1
+ import type {SentryConnectResponseDto} from '@shipfox/api-integration-sentry-dto';
2
+ import {useAuthState, useRefreshAuth} from '@shipfox/client-auth';
3
+ import {Button, ButtonLink} from '@shipfox/react-ui/button';
4
+ import {Callout} from '@shipfox/react-ui/callout';
5
+ import {Card} from '@shipfox/react-ui/card';
6
+ import {FullPageLoader} from '@shipfox/react-ui/loader';
7
+ import {toast} from '@shipfox/react-ui/toast';
8
+ import {Header, Text} from '@shipfox/react-ui/typography';
9
+ import {useQueryClient} from '@tanstack/react-query';
10
+ import {Link, useNavigate, useSearch} from '@tanstack/react-router';
11
+ import {useEffect, useMemo, useRef, useState} from 'react';
12
+ import {connectSentry, integrationsQueryKeys} from '#hooks/api/integrations.js';
13
+ import {
14
+ classifySentryConnectError,
15
+ clearSentryInstallWorkspace,
16
+ parseSentryCallbackParams,
17
+ preselectSentryWorkspace,
18
+ readSentryInstallWorkspace,
19
+ type SentryConnectFailure,
20
+ } from '#sentry-callback.js';
21
+
22
+ // De-dupes concurrent attempts for the same installation+workspace+code
23
+ // (double click, remount while in flight). Entries are evicted once the
24
+ // request settles, so Retry, and any later attempt carrying a fresh grant
25
+ // code, always issues a genuinely new request instead of replaying a cached
26
+ // outcome.
27
+ const connectRequests = new Map<string, Promise<SentryConnectResponseDto>>();
28
+
29
+ export function SentryCallbackPage() {
30
+ const search = useSearch({strict: false});
31
+ const navigate = useNavigate();
32
+ const refreshAuth = useRefreshAuth();
33
+ const {workspaces, isLoading} = useAuthState();
34
+ const queryClient = useQueryClient();
35
+
36
+ const params = useMemo(() => parseSentryCallbackParams(search), [search]);
37
+ const storedWorkspaceId = useMemo(() => readSentryInstallWorkspace(window.sessionStorage), []);
38
+ const preselection = preselectSentryWorkspace(storedWorkspaceId, workspaces);
39
+ const preselectedId = preselection.kind === 'pick' ? preselection.preselectedId : undefined;
40
+
41
+ const [connectingId, setConnectingId] = useState<string | undefined>();
42
+ const [failure, setFailure] = useState<
43
+ {workspaceId: string; failure: SentryConnectFailure} | undefined
44
+ >();
45
+ const [retryLocked, setRetryLocked] = useState(false);
46
+ const disposedRef = useRef(false);
47
+
48
+ useEffect(() => {
49
+ disposedRef.current = false;
50
+ return () => {
51
+ disposedRef.current = true;
52
+ };
53
+ }, []);
54
+
55
+ // Wait for auth before treating an empty workspace list as "no workspace": on
56
+ // a cold return from Sentry, `workspaces` is [] until the session refresh
57
+ // resolves, and bouncing to `/` then would discard the one-shot grant code.
58
+ const noWorkspace = !isLoading && preselection.kind === 'none';
59
+ useEffect(() => {
60
+ if (!noWorkspace) return;
61
+ toast.error('You need a workspace before installing Sentry.');
62
+ navigate({to: '/', replace: true});
63
+ }, [noWorkspace, navigate]);
64
+
65
+ const retryAfterSeconds =
66
+ failure?.failure.kind === 'retryable' ? failure.failure.retryAfterSeconds : undefined;
67
+ useEffect(() => {
68
+ // No backoff window (any non-rate-limited failure) re-enables Retry, so a
69
+ // prior rate-limit lock can never strand the button in a disabled state.
70
+ if (!retryAfterSeconds) {
71
+ setRetryLocked(false);
72
+ return;
73
+ }
74
+ setRetryLocked(true);
75
+ const timer = setTimeout(() => setRetryLocked(false), retryAfterSeconds * 1000);
76
+ return () => clearTimeout(timer);
77
+ }, [retryAfterSeconds]);
78
+
79
+ if (!params) {
80
+ return (
81
+ <CallbackColumn>
82
+ <Callout role="alert" type="error">
83
+ <Text size="sm">
84
+ This Sentry link is missing required parameters. Start the install again from your
85
+ workspace settings.
86
+ </Text>
87
+ </Callout>
88
+ <ButtonLink asChild variant="muted" className="w-fit">
89
+ <Link to="/">Back to Shipfox</Link>
90
+ </ButtonLink>
91
+ </CallbackColumn>
92
+ );
93
+ }
94
+
95
+ // Hold the loader until auth resolves; the workspace list and the
96
+ // noWorkspace redirect both depend on a settled `workspaces`.
97
+ if (isLoading) return <FullPageLoader />;
98
+
99
+ if (noWorkspace) return null;
100
+
101
+ const connect = (workspaceId: string) => {
102
+ setConnectingId(workspaceId);
103
+ setFailure(undefined);
104
+ const key = `${params.installationId}|${workspaceId}|${params.code}`;
105
+ let request = connectRequests.get(key);
106
+ if (!request) {
107
+ request = refreshAuth().then((session) =>
108
+ connectSentry({
109
+ body: {
110
+ workspace_id: workspaceId,
111
+ code: params.code,
112
+ installation_id: params.installationId,
113
+ },
114
+ token: session.token,
115
+ }),
116
+ );
117
+ // Evict on settle (success or failure) so the key never replays a stale
118
+ // outcome. Two-arg `then` keeps this cleanup branch from surfacing the
119
+ // rejection as unhandled — the main chain below owns the user-facing
120
+ // failure handling.
121
+ const evict = () => connectRequests.delete(key);
122
+ request.then(evict, evict);
123
+ connectRequests.set(key, request);
124
+ }
125
+
126
+ request
127
+ .then(async () => {
128
+ clearSentryInstallWorkspace(window.sessionStorage);
129
+ await queryClient.invalidateQueries({
130
+ queryKey: integrationsQueryKeys.connectionsByWorkspace(workspaceId),
131
+ });
132
+ if (disposedRef.current) return;
133
+ toast.success('Sentry installed.');
134
+ await navigate({
135
+ to: '/workspaces/$wid/settings/integrations',
136
+ params: {wid: workspaceId},
137
+ replace: true,
138
+ });
139
+ })
140
+ .catch((error: unknown) => {
141
+ if (disposedRef.current) return;
142
+ const classified = classifySentryConnectError(error);
143
+ if (classified.kind === 'terminal') {
144
+ // Only a fresh install (new grant code) can recover; the stored
145
+ // handoff has served its purpose either way.
146
+ clearSentryInstallWorkspace(window.sessionStorage);
147
+ }
148
+ setConnectingId(undefined);
149
+ setFailure({workspaceId, failure: classified});
150
+ });
151
+ };
152
+
153
+ const orderedWorkspaces = preselectedId
154
+ ? [
155
+ ...workspaces.filter((workspace) => workspace.id === preselectedId),
156
+ ...workspaces.filter((workspace) => workspace.id !== preselectedId),
157
+ ]
158
+ : workspaces;
159
+
160
+ const failureWorkspaceId = failure?.workspaceId ?? preselectedId ?? workspaces[0]?.id;
161
+
162
+ return (
163
+ <CallbackColumn>
164
+ <header className="flex flex-col gap-8">
165
+ <Header variant="h2">Install Sentry</Header>
166
+ <Text size="sm" className="text-foreground-neutral-muted">
167
+ {params.orgSlug
168
+ ? `Install the Sentry org "${params.orgSlug}" in a workspace.`
169
+ : 'Install this Sentry integration in a workspace.'}
170
+ </Text>
171
+ </header>
172
+
173
+ {failure ? (
174
+ <Callout role="alert" type="error">
175
+ <div className="flex flex-col gap-8">
176
+ <Text size="sm">{failure.failure.message}</Text>
177
+ {failure.failure.kind === 'retryable' ? (
178
+ <Button
179
+ size="sm"
180
+ variant="secondary"
181
+ className="w-fit"
182
+ disabled={retryLocked || connectingId !== undefined}
183
+ onClick={() => connect(failure.workspaceId)}
184
+ >
185
+ Retry
186
+ </Button>
187
+ ) : null}
188
+ {failure.failure.kind === 'terminal' && failure.failure.startOver ? (
189
+ <Button asChild size="sm" variant="secondary" className="w-fit">
190
+ <Link to="/workspaces/$wid/integrations/sentry" params={{wid: failure.workspaceId}}>
191
+ Start over
192
+ </Link>
193
+ </Button>
194
+ ) : null}
195
+ </div>
196
+ </Callout>
197
+ ) : null}
198
+
199
+ <section className="flex flex-col gap-8" aria-label="Choose a workspace">
200
+ {orderedWorkspaces.map((workspace) => (
201
+ <Card key={workspace.id} className="p-16">
202
+ <div className="flex items-center justify-between gap-12">
203
+ <Text size="md" bold className="truncate">
204
+ {workspace.name}
205
+ </Text>
206
+ <Button
207
+ variant="secondary"
208
+ disabled={connectingId !== undefined}
209
+ isLoading={connectingId === workspace.id}
210
+ onClick={() => connect(workspace.id)}
211
+ >
212
+ Install
213
+ </Button>
214
+ </div>
215
+ </Card>
216
+ ))}
217
+ </section>
218
+
219
+ {failureWorkspaceId ? (
220
+ <ButtonLink asChild variant="muted" className="w-fit">
221
+ <Link to="/workspaces/$wid/settings/integrations" params={{wid: failureWorkspaceId}}>
222
+ Back to settings
223
+ </Link>
224
+ </ButtonLink>
225
+ ) : null}
226
+ </CallbackColumn>
227
+ );
228
+ }
229
+
230
+ function CallbackColumn({children}: {children: React.ReactNode}) {
231
+ return (
232
+ <main className="flex min-h-screen bg-background-subtle-base px-16 py-32">
233
+ <div className="mx-auto flex w-full max-w-[480px] flex-col justify-center gap-20">
234
+ {children}
235
+ </div>
236
+ </main>
237
+ );
238
+ }
@@ -0,0 +1,17 @@
1
+ import {RedirectInstallPage} from '#components/redirect-install-page.js';
2
+ import {createSentryInstall} from '#hooks/api/integrations.js';
3
+ import {saveSentryInstallWorkspace} from '#sentry-callback.js';
4
+
5
+ export function SentryInstallPage() {
6
+ return (
7
+ <RedirectInstallPage
8
+ installRequest={createSentryInstall}
9
+ errorFallbackMessage="Could not start Sentry install."
10
+ // Sentry's redirect has no state param; the stored id lets the callback
11
+ // pre-select this workspace. Saving never throws (see helper).
12
+ beforeRedirect={(workspaceId) => {
13
+ saveSentryInstallWorkspace(window.sessionStorage, workspaceId);
14
+ }}
15
+ />
16
+ );
17
+ }
@@ -0,0 +1,46 @@
1
+ // @vitest-environment jsdom
2
+ import '@testing-library/jest-dom/vitest';
3
+ import {configureApiClient} from '@shipfox/client-api';
4
+ import {screen} from '@testing-library/react';
5
+ import {INTEGRATIONS_TEST_WID, jsonResponse, renderIntegrationsPage} from '#test/render.js';
6
+ import {SourceControlOnboardingPage} from './source-control-onboarding-page.js';
7
+
8
+ describe('SourceControlOnboardingPage', () => {
9
+ test('renders only source-control provider cards', async () => {
10
+ const fetchImpl = vi.fn((input: RequestInfo | URL) => {
11
+ const url = input instanceof Request ? input.url : String(input);
12
+ if (url.includes('/integration-providers')) {
13
+ return Promise.resolve(
14
+ jsonResponse({
15
+ providers: [
16
+ {provider: 'github', display_name: 'GitHub', capabilities: ['source_control']},
17
+ {provider: 'sentry', display_name: 'Sentry', capabilities: []},
18
+ ],
19
+ }),
20
+ );
21
+ }
22
+ return Promise.resolve(jsonResponse({}, {status: 404}));
23
+ });
24
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
25
+
26
+ renderIntegrationsPage({
27
+ path: `/workspaces/${INTEGRATIONS_TEST_WID}/integrations`,
28
+ routePath: '/workspaces/$wid/integrations',
29
+ element: <SourceControlOnboardingPage />,
30
+ extraRoutes: ['/workspaces/$wid/integrations/github'],
31
+ });
32
+
33
+ expect(await screen.findByRole('heading', {name: 'Install source control'})).toBeVisible();
34
+ expect(await screen.findByRole('link', {name: 'Install GitHub'})).toBeVisible();
35
+ expect(screen.queryByRole('region', {name: 'Installed integrations'})).not.toBeInTheDocument();
36
+ expect(screen.queryByRole('region', {name: 'Available integrations'})).not.toBeInTheDocument();
37
+ expect(
38
+ screen.queryByText('Provider accounts installed in this workspace.'),
39
+ ).not.toBeInTheDocument();
40
+
41
+ const urls = fetchImpl.mock.calls.map(([input]) =>
42
+ input instanceof Request ? input.url : String(input),
43
+ );
44
+ expect(urls.some((url) => url.includes('/integration-connections?'))).toBe(false);
45
+ });
46
+ });
@@ -0,0 +1,26 @@
1
+ import {useActiveWorkspace} from '@shipfox/client-auth';
2
+ import {Header, Text} from '@shipfox/react-ui/typography';
3
+ import {ProviderGrid} from '#components/provider-grid.js';
4
+ import {useIntegrationProvidersQuery} from '#hooks/api/integrations.js';
5
+
6
+ export function SourceControlOnboardingPage() {
7
+ const workspace = useActiveWorkspace();
8
+ const providersQuery = useIntegrationProvidersQuery({capability: 'source_control'});
9
+
10
+ return (
11
+ <div className="mx-auto flex w-full max-w-[640px] flex-col gap-20">
12
+ <header className="flex flex-col gap-8">
13
+ <Header variant="h1">Install source control</Header>
14
+ <Text size="md" className="text-foreground-neutral-muted">
15
+ Shipfox needs a source control integration to import your repositories.
16
+ </Text>
17
+ </header>
18
+
19
+ <ProviderGrid
20
+ providersQuery={providersQuery}
21
+ workspaceId={workspace.id}
22
+ emptyMessage="Enable at least one source-control provider in the application settings."
23
+ />
24
+ </div>
25
+ );
26
+ }
@@ -0,0 +1,42 @@
1
+ import {PROVIDER_CATALOG} from './provider-catalog.js';
2
+
3
+ describe('PROVIDER_CATALOG', () => {
4
+ test('contains route entries for github, sentry, and gitea', () => {
5
+ expect(PROVIDER_CATALOG.github).toMatchObject({
6
+ kind: 'redirect-install',
7
+ setupPath: '/workspaces/$wid/integrations/github',
8
+ });
9
+ expect(PROVIDER_CATALOG.sentry).toMatchObject({
10
+ kind: 'redirect-install',
11
+ setupPath: '/workspaces/$wid/integrations/sentry',
12
+ });
13
+ expect(PROVIDER_CATALOG.linear).toMatchObject({
14
+ kind: 'redirect-install',
15
+ iconName: 'linear',
16
+ setupPath: '/workspaces/$wid/integrations/linear',
17
+ });
18
+ expect(PROVIDER_CATALOG.gitea).toMatchObject({
19
+ kind: 'direct-connect',
20
+ setupPath: '/workspaces/$wid/integrations/gitea',
21
+ });
22
+ });
23
+
24
+ test('marks providers with the install behavior their pages implement', () => {
25
+ expect(PROVIDER_CATALOG.github?.kind).toBe('redirect-install');
26
+ expect(PROVIDER_CATALOG.sentry?.kind).toBe('redirect-install');
27
+ expect(PROVIDER_CATALOG.linear?.kind).toBe('redirect-install');
28
+ expect(PROVIDER_CATALOG.gitea?.kind).toBe('direct-connect');
29
+ expect(PROVIDER_CATALOG.webhook?.kind).toBe('modal-connect');
30
+ });
31
+
32
+ test('declares webhook as a modal provider with the remixicon webhook icon', () => {
33
+ expect(PROVIDER_CATALOG.webhook).toEqual({
34
+ kind: 'modal-connect',
35
+ iconName: 'webhookLine',
36
+ });
37
+ });
38
+
39
+ test('returns undefined for unknown providers (gallery filter behavior)', () => {
40
+ expect(PROVIDER_CATALOG.gitlab).toBeUndefined();
41
+ });
42
+ });
@@ -0,0 +1,49 @@
1
+ import type {IconName} from '@shipfox/react-ui/icon';
2
+
3
+ // Literal union (not string) so `<Link to={catalog.setupPath}>` stays typed
4
+ // against TanStack Router's route tree.
5
+ export type ProviderSetupPath =
6
+ | '/workspaces/$wid/integrations/github'
7
+ | '/workspaces/$wid/integrations/gitea'
8
+ | '/workspaces/$wid/integrations/sentry'
9
+ | '/workspaces/$wid/integrations/linear';
10
+
11
+ interface RouteProviderCatalogEntry {
12
+ kind: 'redirect-install' | 'direct-connect';
13
+ iconName: IconName;
14
+ setupPath: ProviderSetupPath;
15
+ }
16
+
17
+ interface ModalProviderCatalogEntry {
18
+ kind: 'modal-connect';
19
+ iconName: IconName;
20
+ }
21
+
22
+ export type ProviderCatalogEntry = RouteProviderCatalogEntry | ModalProviderCatalogEntry;
23
+
24
+ export const PROVIDER_CATALOG: Record<string, ProviderCatalogEntry> = {
25
+ github: {
26
+ kind: 'redirect-install',
27
+ iconName: 'github',
28
+ setupPath: '/workspaces/$wid/integrations/github',
29
+ },
30
+ sentry: {
31
+ kind: 'redirect-install',
32
+ iconName: 'sentry',
33
+ setupPath: '/workspaces/$wid/integrations/sentry',
34
+ },
35
+ linear: {
36
+ kind: 'redirect-install',
37
+ iconName: 'linear',
38
+ setupPath: '/workspaces/$wid/integrations/linear',
39
+ },
40
+ gitea: {
41
+ kind: 'direct-connect',
42
+ iconName: 'gitea',
43
+ setupPath: '/workspaces/$wid/integrations/gitea',
44
+ },
45
+ webhook: {
46
+ kind: 'modal-connect',
47
+ iconName: 'webhookLine',
48
+ },
49
+ };
@@ -0,0 +1,4 @@
1
+ import {defineRoute} from '@shipfox/client-shell/runtime';
2
+ import {GiteaInstallPage} from '#pages/gitea-install-page.js';
3
+
4
+ export default defineRoute({component: GiteaInstallPage});
@@ -0,0 +1,114 @@
1
+ import {ApiError, apiRequest} from '@shipfox/client-api';
2
+ import {useRefreshAuth} from '@shipfox/client-auth';
3
+ import {defineRoute} from '@shipfox/client-shell/runtime';
4
+ import {FullPageLoader} from '@shipfox/react-ui/loader';
5
+ import {toast} from '@shipfox/react-ui/toast';
6
+ import {useNavigate, useSearch} from '@tanstack/react-router';
7
+ import {useEffect} from 'react';
8
+
9
+ interface GithubCallbackParams {
10
+ code: string;
11
+ installationId: number;
12
+ state: string;
13
+ setupAction?: string;
14
+ }
15
+
16
+ const callbackRequests = new Map<string, Promise<void>>();
17
+ const toastedCallbacks = new Set<string>();
18
+
19
+ export default defineRoute({component: GithubCallbackRoute});
20
+
21
+ function GithubCallbackRoute() {
22
+ const search = useSearch({strict: false});
23
+ const navigate = useNavigate();
24
+ const refreshAuth = useRefreshAuth();
25
+
26
+ useEffect(() => {
27
+ const missing = missingCallbackParams(search);
28
+ if (missing.length > 0) {
29
+ toast.error(
30
+ `GitHub callback is missing: ${missing.join(', ')}. ${missing.includes('code') ? 'Enable "Request user authorization (OAuth) during installation" on the Shipfox GitHub App.' : ''}`,
31
+ );
32
+ navigate({to: '/', replace: true});
33
+ return;
34
+ }
35
+ const params = githubCallbackParams(search);
36
+ if (!params) {
37
+ toast.error('GitHub callback is missing required parameters.');
38
+ navigate({to: '/', replace: true});
39
+ return;
40
+ }
41
+ let disposed = false;
42
+ const key = encodeCallbackQuery(params);
43
+ const request =
44
+ callbackRequests.get(key) ??
45
+ refreshAuth().then(async (session) => {
46
+ await apiRequest(`/integrations/github/callback/api?${key}`, {
47
+ headers: {authorization: `Bearer ${session.token}`},
48
+ });
49
+ });
50
+ callbackRequests.set(key, request);
51
+ request
52
+ .then(() => {
53
+ if (disposed) return;
54
+ if (!toastedCallbacks.has(key)) {
55
+ toastedCallbacks.add(key);
56
+ toast.success('GitHub installed.');
57
+ }
58
+ navigate({to: '/', replace: true});
59
+ })
60
+ .catch((error: unknown) => {
61
+ if (disposed) return;
62
+ if (!toastedCallbacks.has(key)) {
63
+ toastedCallbacks.add(key);
64
+ toast.error(githubCallbackErrorMessage(error));
65
+ }
66
+ navigate({to: '/', replace: true});
67
+ });
68
+ return () => {
69
+ disposed = true;
70
+ };
71
+ }, [navigate, refreshAuth, search]);
72
+
73
+ return <FullPageLoader />;
74
+ }
75
+
76
+ function missingCallbackParams(search: Record<string, unknown>): string[] {
77
+ const missing: string[] = [];
78
+ if (!stringParam(search.code)) missing.push('code');
79
+ if (!numberParam(search.installation_id)) missing.push('installation_id');
80
+ if (!stringParam(search.state)) missing.push('state');
81
+ return missing;
82
+ }
83
+ function githubCallbackParams(search: Record<string, unknown>): GithubCallbackParams | undefined {
84
+ const code = stringParam(search.code);
85
+ const installationId = numberParam(search.installation_id);
86
+ const state = stringParam(search.state);
87
+ if (!code || installationId === undefined || !state) return undefined;
88
+ const setupAction = stringParam(search.setup_action);
89
+ return setupAction ? {code, installationId, state, setupAction} : {code, installationId, state};
90
+ }
91
+ function encodeCallbackQuery(params: GithubCallbackParams): string {
92
+ const search = new URLSearchParams();
93
+ search.set('code', params.code);
94
+ search.set('installation_id', params.installationId.toString());
95
+ search.set('state', params.state);
96
+ if (params.setupAction) search.set('setup_action', params.setupAction);
97
+ return search.toString();
98
+ }
99
+ function stringParam(value: unknown): string | undefined {
100
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
101
+ }
102
+ function numberParam(value: unknown): number | undefined {
103
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
104
+ if (typeof value === 'string' && value.length > 0) {
105
+ const parsed = Number(value);
106
+ return Number.isFinite(parsed) ? parsed : undefined;
107
+ }
108
+ return undefined;
109
+ }
110
+ function githubCallbackErrorMessage(error: unknown): string {
111
+ if (error instanceof ApiError) return error.message;
112
+ if (error instanceof Error) return error.message;
113
+ return 'Could not install GitHub.';
114
+ }
@@ -0,0 +1,4 @@
1
+ import {defineRoute} from '@shipfox/client-shell/runtime';
2
+ import {GithubInstallPage} from '#pages/github-install-page.js';
3
+
4
+ export default defineRoute({component: GithubInstallPage});
@@ -0,0 +1,4 @@
1
+ import {defineRoute} from '@shipfox/client-shell/runtime';
2
+ import {SourceControlOnboardingPage} from '#pages/source-control-onboarding-page.js';
3
+
4
+ export default defineRoute({component: SourceControlOnboardingPage});
@@ -0,0 +1,4 @@
1
+ import {defineRoute} from '@shipfox/client-shell/runtime';
2
+ import {LinearCallbackPage} from '#pages/linear-callback-page.js';
3
+
4
+ export default defineRoute({component: LinearCallbackPage});
@@ -0,0 +1,4 @@
1
+ import {defineRoute} from '@shipfox/client-shell/runtime';
2
+ import {LinearInstallPage} from '#pages/linear-install-page.js';
3
+
4
+ export default defineRoute({component: LinearInstallPage});
@@ -0,0 +1,4 @@
1
+ import {defineRoute} from '@shipfox/client-shell/runtime';
2
+ import {SentryCallbackPage} from '#pages/sentry-callback-page.js';
3
+
4
+ export default defineRoute({component: SentryCallbackPage});
@@ -0,0 +1,4 @@
1
+ import {defineRoute} from '@shipfox/client-shell/runtime';
2
+ import {SentryInstallPage} from '#pages/sentry-install-page.js';
3
+
4
+ export default defineRoute({component: SentryInstallPage});