Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
gitpod-io
GitHub Repository: gitpod-io/gitpod
Path: blob/main/components/dashboard/src/teams/git-integrations/GitIntegrationModal.tsx
2501 views
1
/**
2
* Copyright (c) 2023 Gitpod GmbH. All rights reserved.
3
* Licensed under the GNU Affero General Public License (AGPL).
4
* See License.AGPL.txt in the project root for license information.
5
*/
6
7
import { FunctionComponent, useCallback, useContext, useEffect, useMemo, useState } from "react";
8
import { Button } from "@podkit/buttons/Button";
9
import { InputField } from "../../components/forms/InputField";
10
import { SelectInputField } from "../../components/forms/SelectInputField";
11
import { TextInputField } from "../../components/forms/TextInputField";
12
import { InputWithCopy } from "../../components/InputWithCopy";
13
import Modal, { ModalBody, ModalFooter, ModalFooterAlert, ModalHeader } from "../../components/Modal";
14
import { Subheading } from "../../components/typography/headings";
15
import { useInvalidateOrgAuthProvidersQuery } from "../../data/auth-providers/org-auth-providers-query";
16
import { useCurrentOrg } from "../../data/organizations/orgs-query";
17
import { useOnBlurError } from "../../hooks/use-onblur-error";
18
import { openAuthorizeWindow, toAuthProviderLabel } from "../../provider-utils";
19
import { gitpodHostUrl } from "../../service/service";
20
import { UserContext } from "../../user-context";
21
import { useToast } from "../../components/toasts/Toasts";
22
import { AuthProvider, AuthProviderType } from "@gitpod/public-api/lib/gitpod/v1/authprovider_pb";
23
import { useCreateOrgAuthProviderMutation } from "../../data/auth-providers/create-org-auth-provider-mutation";
24
import { useUpdateOrgAuthProviderMutation } from "../../data/auth-providers/update-org-auth-provider-mutation";
25
import { authProviderClient, userClient } from "../../service/public-api";
26
import { LoadingButton } from "@podkit/buttons/LoadingButton";
27
import {
28
isSupportAzureDevOpsIntegration,
29
useAuthProviderOptionsQuery,
30
} from "../../data/auth-providers/auth-provider-options-query";
31
32
type Props = {
33
provider?: AuthProvider;
34
onClose: () => void;
35
};
36
export const GitIntegrationModal: FunctionComponent<Props> = (props) => {
37
const { setUser } = useContext(UserContext);
38
const { toast } = useToast();
39
const team = useCurrentOrg().data;
40
const [type, setType] = useState<AuthProviderType>(props.provider?.type ?? AuthProviderType.GITLAB);
41
const [host, setHost] = useState<string>(props.provider?.host ?? "");
42
const [clientId, setClientId] = useState<string>(props.provider?.oauth2Config?.clientId ?? "");
43
const [clientSecret, setClientSecret] = useState<string>(props.provider?.oauth2Config?.clientSecret ?? "");
44
const [authorizationUrl, setAuthorizationUrl] = useState(props.provider?.oauth2Config?.authorizationUrl ?? "");
45
const [tokenUrl, setTokenUrl] = useState(props.provider?.oauth2Config?.tokenUrl ?? "");
46
const availableProviderOptions = useAuthProviderOptionsQuery(true);
47
const supportAzureDevOps = isSupportAzureDevOpsIntegration();
48
49
const [savedProvider, setSavedProvider] = useState(props.provider);
50
const isNew = !savedProvider;
51
52
// This is a readonly value to copy and plug into external oauth config
53
const redirectURL = callbackUrl();
54
55
// "bitbucket.org" is set as host value whenever "Bitbucket" is selected
56
useEffect(() => {
57
if (isNew) {
58
setHost(type === AuthProviderType.BITBUCKET ? "bitbucket.org" : "");
59
}
60
}, [isNew, type]);
61
62
const [savingProvider, setSavingProvider] = useState(false);
63
const [errorMessage, setErrorMessage] = useState<string | undefined>();
64
65
const createProvider = useCreateOrgAuthProviderMutation();
66
const updateProvider = useUpdateOrgAuthProviderMutation();
67
const invalidateOrgAuthProviders = useInvalidateOrgAuthProvidersQuery(team?.id ?? "");
68
69
const {
70
message: hostError,
71
onBlur: hostOnBlurErrorTracking,
72
isValid: hostValid,
73
} = useOnBlurError(`Provider Host Name is missing.`, host.trim().length > 0);
74
75
const {
76
message: clientIdError,
77
onBlur: clientIdOnBlur,
78
isValid: clientIdValid,
79
} = useOnBlurError(
80
`${type === AuthProviderType.GITLAB ? "Application ID" : "Client ID"} is missing.`,
81
clientId.trim().length > 0,
82
);
83
84
const {
85
message: clientSecretError,
86
onBlur: clientSecretOnBlur,
87
isValid: clientSecretValid,
88
} = useOnBlurError(
89
`${type === AuthProviderType.GITLAB ? "Secret" : "Client Secret"} is missing.`,
90
clientSecret.trim().length > 0,
91
);
92
93
const {
94
message: authorizationUrlError,
95
onBlur: authorizationUrlOnBlur,
96
isValid: authorizationUrlValid,
97
} = useOnBlurError(
98
`Authorization URL is missing.`,
99
type !== AuthProviderType.AZURE_DEVOPS || authorizationUrl.trim().length > 0,
100
);
101
102
const {
103
message: tokenUrlError,
104
onBlur: tokenUrlOnBlur,
105
isValid: tokenUrlValid,
106
} = useOnBlurError(`Token URL is missing.`, type !== AuthProviderType.AZURE_DEVOPS || tokenUrl.trim().length > 0);
107
108
// Call our error onBlur handler, and remove prefixed "https://"
109
const hostOnBlur = useCallback(() => {
110
hostOnBlurErrorTracking();
111
112
setHost(cleanHost(host));
113
}, [host, hostOnBlurErrorTracking]);
114
115
const reloadSavedProvider = useCallback(async () => {
116
if (!savedProvider || !team) {
117
return;
118
}
119
120
const { authProvider } = await authProviderClient.getAuthProvider({ authProviderId: savedProvider.id });
121
if (authProvider) {
122
setSavedProvider(authProvider);
123
}
124
}, [savedProvider, team]);
125
126
const activate = useCallback(async () => {
127
if (!team) {
128
console.error("no current team selected");
129
return;
130
}
131
132
// Set a saving state and clear any error message
133
setSavingProvider(true);
134
setErrorMessage(undefined);
135
136
const trimmedId = clientId.trim();
137
const trimmedSecret = clientSecret.trim();
138
const trimmedAuthorizationUrl = authorizationUrl.trim();
139
const trimmedTokenUrl = tokenUrl.trim();
140
141
try {
142
let newProvider: AuthProvider;
143
if (isNew) {
144
newProvider = await createProvider.mutateAsync({
145
provider: {
146
host: cleanHost(host),
147
type,
148
orgId: team.id,
149
clientId: trimmedId,
150
clientSecret: trimmedSecret,
151
authorizationUrl: trimmedAuthorizationUrl,
152
tokenUrl: trimmedTokenUrl,
153
},
154
});
155
} else {
156
newProvider = await updateProvider.mutateAsync({
157
provider: {
158
id: savedProvider.id,
159
clientId: trimmedId,
160
clientSecret: clientSecret === "redacted" ? "" : trimmedSecret,
161
authorizationUrl: trimmedAuthorizationUrl,
162
tokenUrl: trimmedTokenUrl,
163
},
164
});
165
}
166
167
// switch mode to stay and edit this integration.
168
setSavedProvider(newProvider);
169
170
// the server is checking periodically for updates of dynamic providers, thus we need to
171
// wait at least 2 seconds for the changes to be propagated before we try to use this provider.
172
await new Promise((resolve) => setTimeout(resolve, 2000));
173
174
// just open the authorization window and do *not* await
175
openAuthorizeWindow({
176
login: false,
177
host: newProvider.host,
178
onSuccess: (payload) => {
179
invalidateOrgAuthProviders();
180
181
// Refresh the current user - they may have a new identity record now
182
// setup a promise and don't wait so we can close the modal right away
183
userClient.getAuthenticatedUser({}).then(({ user }) => {
184
if (user) {
185
setUser(user);
186
}
187
});
188
toast(`${toAuthProviderLabel(newProvider.type)} integration has been activated.`);
189
190
props.onClose();
191
},
192
onError: (payload) => {
193
reloadSavedProvider();
194
195
let errorMessage: string;
196
if (typeof payload === "string") {
197
errorMessage = payload;
198
} else {
199
errorMessage = payload.description ? payload.description : `Error: ${payload.error}`;
200
}
201
setErrorMessage(errorMessage);
202
},
203
});
204
} catch (error) {
205
console.log(error);
206
setErrorMessage("message" in error ? error.message : "Failed to update Git provider");
207
}
208
209
setSavingProvider(false);
210
}, [
211
clientId,
212
clientSecret,
213
authorizationUrl,
214
tokenUrl,
215
host,
216
invalidateOrgAuthProviders,
217
isNew,
218
props,
219
savedProvider?.id,
220
setUser,
221
team,
222
toast,
223
type,
224
createProvider,
225
updateProvider,
226
reloadSavedProvider,
227
]);
228
229
const isValid = useMemo(
230
() => clientIdValid && clientSecretValid && hostValid && authorizationUrlValid && tokenUrlValid,
231
[clientIdValid, clientSecretValid, hostValid, authorizationUrlValid, tokenUrlValid],
232
);
233
234
const getNumber = (paramValue: string | null) => {
235
if (!paramValue) {
236
return 0;
237
}
238
239
try {
240
const number = Number.parseInt(paramValue, 10);
241
if (Number.isNaN(number)) {
242
return 0;
243
}
244
245
return number;
246
} catch (e) {
247
return 0;
248
}
249
};
250
251
return (
252
<Modal visible onClose={props.onClose} onSubmit={activate} autoFocus={isNew}>
253
<ModalHeader>{isNew ? "New Git Provider" : "Git Provider"}</ModalHeader>
254
<ModalBody>
255
{isNew && (
256
<Subheading>
257
Configure a Git Integration with a self-managed instance of GitLab, GitHub{" "}
258
{supportAzureDevOps ? ", Bitbucket Server or Azure DevOps" : "or Bitbucket"}.
259
</Subheading>
260
)}
261
262
<div>
263
<SelectInputField
264
disabled={!isNew}
265
label="Provider Type"
266
value={type.toString()}
267
topMargin={false}
268
onChange={(val) => setType(getNumber(val))}
269
>
270
{availableProviderOptions.map((option) => (
271
<option key={option.type} value={option.type}>
272
{option.label}
273
</option>
274
))}
275
</SelectInputField>
276
<TextInputField
277
label="Provider Host Name"
278
value={host}
279
disabled={!isNew || type === AuthProviderType.BITBUCKET}
280
placeholder={getPlaceholderForIntegrationType(type)}
281
error={hostError}
282
onChange={setHost}
283
onBlur={hostOnBlur}
284
/>
285
286
<InputField label="Redirect URI" hint={<RedirectUrlDescription type={type} />}>
287
<InputWithCopy value={redirectURL} tip="Copy the redirect URI to clipboard" />
288
</InputField>
289
290
{type === AuthProviderType.AZURE_DEVOPS && (
291
<>
292
<TextInputField
293
label="Authorization URL"
294
value={authorizationUrl}
295
error={authorizationUrlError}
296
onBlur={authorizationUrlOnBlur}
297
onChange={setAuthorizationUrl}
298
/>
299
<TextInputField
300
label="Token URL"
301
value={tokenUrl}
302
error={tokenUrlError}
303
onBlur={tokenUrlOnBlur}
304
onChange={setTokenUrl}
305
/>
306
</>
307
)}
308
309
<TextInputField
310
label={type === AuthProviderType.GITLAB ? "Application ID" : "Client ID"}
311
value={clientId}
312
error={clientIdError}
313
onBlur={clientIdOnBlur}
314
onChange={setClientId}
315
/>
316
317
<TextInputField
318
label={type === AuthProviderType.GITLAB ? "Secret" : "Client Secret"}
319
type="password"
320
value={clientSecret}
321
error={clientSecretError}
322
onChange={setClientSecret}
323
onBlur={clientSecretOnBlur}
324
/>
325
</div>
326
</ModalBody>
327
<ModalFooter
328
alert={
329
<>
330
{errorMessage ? (
331
<ModalFooterAlert type="danger">{errorMessage}</ModalFooterAlert>
332
) : (
333
!isNew &&
334
!savedProvider?.verified && (
335
<ModalFooterAlert type="warning" closable={false}>
336
You need to activate this configuration.
337
</ModalFooterAlert>
338
)
339
)}
340
</>
341
}
342
>
343
<Button variant="secondary" onClick={props.onClose}>
344
Cancel
345
</Button>
346
<LoadingButton type="submit" disabled={!isValid} loading={savingProvider}>
347
Activate
348
</LoadingButton>
349
</ModalFooter>
350
</Modal>
351
);
352
};
353
354
const callbackUrl = () => {
355
const pathname = `/auth/callback`;
356
return gitpodHostUrl.with({ pathname }).toString();
357
};
358
359
const getPlaceholderForIntegrationType = (type: AuthProviderType) => {
360
switch (type) {
361
case AuthProviderType.GITHUB:
362
return "github.example.com";
363
case AuthProviderType.GITLAB:
364
return "gitlab.example.com";
365
case AuthProviderType.BITBUCKET:
366
return "bitbucket.org";
367
case AuthProviderType.BITBUCKET_SERVER:
368
return "bitbucket.example.com";
369
case AuthProviderType.AZURE_DEVOPS:
370
return "dev.azure.com";
371
default:
372
return "";
373
}
374
};
375
376
type RedirectUrlDescriptionProps = {
377
type: AuthProviderType;
378
};
379
const RedirectUrlDescription: FunctionComponent<RedirectUrlDescriptionProps> = ({ type }) => {
380
let docsUrl = ``;
381
switch (type) {
382
case AuthProviderType.GITHUB:
383
docsUrl = `https://www.gitpod.io/docs/configure/authentication/github-enterprise`;
384
break;
385
case AuthProviderType.GITLAB:
386
docsUrl = `https://www.gitpod.io/docs/configure/authentication/gitlab#registering-a-self-hosted-gitlab-installation`;
387
break;
388
case AuthProviderType.BITBUCKET:
389
docsUrl = `https://www.gitpod.io/docs/configure/authentication`;
390
break;
391
case AuthProviderType.BITBUCKET_SERVER:
392
docsUrl = "https://www.gitpod.io/docs/configure/authentication/bitbucket-server";
393
break;
394
case AuthProviderType.AZURE_DEVOPS:
395
docsUrl = "https://www.gitpod.io/docs/configure/authentication/azure-devops";
396
break;
397
default:
398
return null;
399
}
400
401
return (
402
<span>
403
Use this redirect URI to register a {toAuthProviderLabel(type)} instance as an authorized Git provider in
404
Gitpod.{" "}
405
<a href={docsUrl} target="_blank" rel="noreferrer noopener" className="gp-link">
406
Learn more
407
</a>
408
</span>
409
);
410
};
411
412
function cleanHost(host: string) {
413
let cleanedHost = host;
414
415
// Removing https protocol
416
if (host.startsWith("https://")) {
417
cleanedHost = host.replace("https://", "");
418
}
419
420
// Trim any trailing slashes
421
cleanedHost = cleanedHost.replace(/\/$/, "");
422
423
return cleanedHost;
424
}
425
426