@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,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,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
|
+
}
|