Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
gitpod-io
GitHub Repository: gitpod-io/gitpod
Path: blob/main/components/dashboard/src/user-settings/Integrations.tsx
2500 views
1
/**
2
* Copyright (c) 2021 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 {
8
AzureDevOpsOAuthScopes,
9
getRequiredScopes,
10
getScopeNameForScope,
11
getScopesForAuthProviderType,
12
} from "@gitpod/public-api-common/lib/auth-providers";
13
import { SelectAccountPayload } from "@gitpod/gitpod-protocol/lib/auth";
14
import { useQuery } from "@tanstack/react-query";
15
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
16
import Alert from "../components/Alert";
17
import { CheckboxInputField, CheckboxListField } from "../components/forms/CheckboxInputField";
18
import ConfirmationModal from "../components/ConfirmationModal";
19
import { ContextMenuEntry } from "../components/ContextMenu";
20
import InfoBox from "../components/InfoBox";
21
import { ItemsList } from "../components/ItemsList";
22
import { SpinnerLoader } from "../components/Loader";
23
import Modal, { ModalBody, ModalHeader, ModalFooter } from "../components/Modal";
24
import { Heading2, Subheading } from "../components/typography/headings";
25
import exclamation from "../images/exclamation.svg";
26
import { openAuthorizeWindow, toAuthProviderLabel } from "../provider-utils";
27
import { gitpodHostUrl } from "../service/service";
28
import { UserContext } from "../user-context";
29
import { AuthEntryItem } from "./AuthEntryItem";
30
import { IntegrationEntryItem } from "./IntegrationItemEntry";
31
import { PageWithSettingsSubMenu } from "./PageWithSettingsSubMenu";
32
import { SelectAccountModal } from "./SelectAccountModal";
33
import { useAuthProviderDescriptions } from "../data/auth-providers/auth-provider-descriptions-query";
34
import { useFeatureFlag } from "../data/featureflag-query";
35
import { EmptyMessage } from "../components/EmptyMessage";
36
import { Delayed } from "@podkit/loading/Delayed";
37
import {
38
AuthProvider,
39
AuthProviderDescription,
40
AuthProviderType,
41
} from "@gitpod/public-api/lib/gitpod/v1/authprovider_pb";
42
import { authProviderClient, scmClient, userClient } from "../service/public-api";
43
import { useCreateUserAuthProviderMutation } from "../data/auth-providers/create-user-auth-provider-mutation";
44
import { useUpdateUserAuthProviderMutation } from "../data/auth-providers/update-user-auth-provider-mutation";
45
import { useDeleteUserAuthProviderMutation } from "../data/auth-providers/delete-user-auth-provider-mutation";
46
import { Button } from "@podkit/buttons/Button";
47
import { isOrganizationOwned } from "@gitpod/public-api-common/lib/user-utils";
48
import { InputWithCopy } from "../components/InputWithCopy";
49
import { useAuthProviderOptionsQuery } from "../data/auth-providers/auth-provider-options-query";
50
51
export default function Integrations() {
52
return (
53
<div>
54
<PageWithSettingsSubMenu>
55
<GitProviders />
56
<div className="h-12"></div>
57
<GitIntegrations />
58
</PageWithSettingsSubMenu>
59
</div>
60
);
61
}
62
63
const getDescriptionForScope = (scope: string) => {
64
switch (scope) {
65
// GitHub
66
case "user:email":
67
return "Read-only access to your email addresses";
68
case "read:user":
69
return "Read-only access to your profile information";
70
case "public_repo":
71
return "Write access to code in public repositories and organizations";
72
case "repo":
73
return "Read/write access to code in private repositories and organizations";
74
case "read:org":
75
return "Read-only access to organizations (used to suggest organizations when forking a repository)";
76
case "workflow":
77
return "Allow updating GitHub Actions workflow files";
78
// GitLab
79
case "read_user":
80
return "Read-only access to your email addresses";
81
case "api":
82
return "Allow making API calls (used to set up a webhook when enabling prebuilds for a repository)";
83
case "read_repository":
84
return "Read/write access to your repositories";
85
// Bitbucket
86
case "account":
87
return "Read-only access to your account information";
88
case "repository":
89
return "Read-only access to your repositories (note: Bitbucket doesn't support revoking scopes)";
90
case "repository:write":
91
return "Read/write access to your repositories (note: Bitbucket doesn't support revoking scopes)";
92
case "pullrequest":
93
return "Read access to pull requests and ability to collaborate via comments, tasks, and approvals (note: Bitbucket doesn't support revoking scopes)";
94
case "pullrequest:write":
95
return "Allow creating, merging and declining pull requests (note: Bitbucket doesn't support revoking scopes)";
96
case "webhook":
97
return "Allow installing webhooks (used when enabling prebuilds for a repository, note: Bitbucket doesn't support revoking scopes)";
98
// Azure DevOps
99
case AzureDevOpsOAuthScopes.WRITE_REPO:
100
return "Code read and write permissions";
101
case AzureDevOpsOAuthScopes.READ_USER:
102
return "Read user profile";
103
default:
104
return "";
105
}
106
};
107
108
function GitProviders() {
109
const { user, setUser } = useContext(UserContext);
110
111
const authProviders = useAuthProviderDescriptions();
112
const [allScopes, setAllScopes] = useState<Map<string, string[]>>(new Map());
113
const [disconnectModal, setDisconnectModal] = useState<{ provider: AuthProviderDescription } | undefined>(
114
undefined,
115
);
116
const [editModal, setEditModal] = useState<
117
{ provider: AuthProviderDescription; prevScopes: Set<string>; nextScopes: Set<string> } | undefined
118
>(undefined);
119
const [selectAccountModal, setSelectAccountModal] = useState<SelectAccountPayload | undefined>(undefined);
120
const [errorMessage, setErrorMessage] = useState<string | undefined>();
121
122
const updateCurrentScopes = useCallback(async () => {
123
if (user) {
124
const scopesByProvider = new Map<string, string[]>();
125
const connectedProviders = user.identities.map((i) =>
126
authProviders.data?.find((ap) => ap.id === i.authProviderId),
127
);
128
for (let provider of connectedProviders) {
129
if (!provider) {
130
continue;
131
}
132
const token = (await scmClient.searchSCMTokens({ host: provider.host })).tokens[0];
133
scopesByProvider.set(provider.id, token?.scopes?.slice() || []);
134
}
135
setAllScopes(scopesByProvider);
136
}
137
}, [authProviders.data, user]);
138
139
useEffect(() => {
140
updateCurrentScopes();
141
}, [updateCurrentScopes]);
142
143
const isConnected = (authProviderId: string) => {
144
return !!user?.identities?.find((i) => i.authProviderId === authProviderId);
145
};
146
147
const getSettingsUrl = (ap: AuthProviderDescription) => {
148
const url = new URL(`https://${ap.host}`);
149
switch (ap.type) {
150
case AuthProviderType.GITHUB:
151
url.pathname = "settings/applications";
152
break;
153
case AuthProviderType.GITLAB:
154
url.pathname = "-/profile/applications";
155
break;
156
default:
157
return undefined;
158
}
159
return url;
160
};
161
162
const gitProviderMenu = (provider: AuthProviderDescription) => {
163
const result: ContextMenuEntry[] = [];
164
const connected = isConnected(provider.id);
165
if (connected) {
166
const settingsUrl = getSettingsUrl(provider);
167
result.push({
168
title: "Edit Permissions",
169
onClick: () => startEditPermissions(provider),
170
separator: !settingsUrl,
171
});
172
if (settingsUrl) {
173
result.push({
174
title: `Manage on ${provider.host}`,
175
onClick: () => {
176
window.open(settingsUrl, "_blank", "noopener,noreferrer");
177
},
178
separator: true,
179
});
180
}
181
const canDisconnect =
182
(user && isOrganizationOwned(user)) ||
183
authProviders.data?.some((p) => p.id !== provider.id && isConnected(p.id));
184
if (canDisconnect) {
185
result.push({
186
title: "Disconnect",
187
customFontStyle: "text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300",
188
onClick: () => setDisconnectModal({ provider }),
189
});
190
}
191
} else {
192
result.push({
193
title: "Connect",
194
customFontStyle: "text-green-600",
195
onClick: () => connect(provider),
196
});
197
}
198
return result;
199
};
200
201
const getUsername = (authProviderId: string) => {
202
return user?.identities?.find((i) => i.authProviderId === authProviderId)?.authName;
203
};
204
205
const getPermissions = (authProviderId: string) => {
206
return allScopes.get(authProviderId);
207
};
208
209
const connect = async (ap: AuthProviderDescription) => {
210
await doAuthorize(ap.host);
211
};
212
213
const disconnect = async (ap: AuthProviderDescription) => {
214
setDisconnectModal(undefined);
215
const returnTo = gitpodHostUrl.with({ pathname: "complete-auth", search: "message=success" }).toString();
216
const deauthorizeUrl = gitpodHostUrl
217
.withApi({
218
pathname: "/deauthorize",
219
search: `returnTo=${returnTo}&host=${ap.host}`,
220
})
221
.toString();
222
223
fetch(deauthorizeUrl)
224
.then((res) => {
225
if (!res.ok) {
226
throw Error("Fetch failed");
227
}
228
return res;
229
})
230
.then((response) => updateUser())
231
.catch((error) =>
232
setErrorMessage(
233
"You cannot disconnect this integration because it is required for authentication and logging in with this account.",
234
),
235
);
236
};
237
238
const startEditPermissions = async (provider: AuthProviderDescription) => {
239
// todo: add spinner
240
241
const token = (await scmClient.searchSCMTokens({ host: provider.host })).tokens[0];
242
if (token) {
243
setEditModal({ provider, prevScopes: new Set(token.scopes), nextScopes: new Set(token.scopes) });
244
}
245
};
246
247
const updateUser = async () => {
248
const { user } = await userClient.getAuthenticatedUser({});
249
if (user) {
250
setUser(user);
251
}
252
};
253
254
const doAuthorize = async (host: string, scopes?: string[]) => {
255
try {
256
await openAuthorizeWindow({
257
host,
258
scopes,
259
overrideScopes: true,
260
onSuccess: () => updateUser(),
261
onError: (error) => {
262
if (typeof error === "string") {
263
try {
264
const payload = JSON.parse(error);
265
if (SelectAccountPayload.is(payload)) {
266
setSelectAccountModal(payload);
267
}
268
} catch (error) {
269
console.log(error);
270
}
271
}
272
},
273
});
274
} catch (error) {
275
console.log(error);
276
}
277
};
278
279
const updatePermissions = async () => {
280
if (!editModal) {
281
return;
282
}
283
try {
284
await doAuthorize(editModal.provider.host, Array.from(editModal.nextScopes));
285
} catch (error) {
286
console.log(error);
287
}
288
setEditModal(undefined);
289
};
290
const onChangeScopeHandler = (checked: boolean, scope: string) => {
291
if (!editModal) {
292
return;
293
}
294
295
const nextScopes = new Set(editModal.nextScopes);
296
if (checked) {
297
nextScopes.add(scope);
298
} else {
299
nextScopes.delete(scope);
300
}
301
setEditModal({ ...editModal, nextScopes });
302
};
303
304
return (
305
<div>
306
{selectAccountModal && (
307
<SelectAccountModal {...selectAccountModal} close={() => setSelectAccountModal(undefined)} />
308
)}
309
310
{disconnectModal && (
311
<ConfirmationModal
312
title="Disconnect Provider"
313
areYouSureText="Are you sure you want to disconnect the following provider?"
314
children={{
315
name: toAuthProviderLabel(disconnectModal.provider.type),
316
description: disconnectModal.provider.host,
317
}}
318
buttonText="Disconnect Provider"
319
onClose={() => setDisconnectModal(undefined)}
320
onConfirm={() => disconnect(disconnectModal.provider)}
321
/>
322
)}
323
324
{errorMessage && (
325
<div className="flex rounded-md bg-red-600 p-3 mb-4">
326
<img
327
className="w-4 h-4 mx-2 my-auto filter-brightness-10"
328
src={exclamation}
329
alt="exclamation mark icon"
330
/>
331
<span className="text-white">{errorMessage}</span>
332
</div>
333
)}
334
335
{editModal && (
336
<Modal visible={true} onClose={() => setEditModal(undefined)}>
337
<ModalHeader>Edit Permissions</ModalHeader>
338
<ModalBody>
339
<CheckboxListField label="Configure provider permissions.">
340
{(getScopesForAuthProviderType(editModal.provider.type) || []).map((scope) => {
341
const isRequired = getRequiredScopes(editModal.provider.type)?.default.includes(scope);
342
343
return (
344
<CheckboxInputField
345
key={scope}
346
value={scope}
347
label={getScopeNameForScope(scope) + (isRequired ? " (required)" : "")}
348
hint={getDescriptionForScope(scope)}
349
checked={editModal.nextScopes.has(scope)}
350
disabled={isRequired}
351
topMargin={false}
352
onChange={(checked) => onChangeScopeHandler(checked, scope)}
353
/>
354
);
355
})}
356
</CheckboxListField>
357
</ModalBody>
358
<ModalFooter>
359
<Button
360
onClick={() => updatePermissions()}
361
disabled={equals(editModal.nextScopes, editModal.prevScopes)}
362
>
363
Update Permissions
364
</Button>
365
</ModalFooter>
366
</Modal>
367
)}
368
369
<Heading2>Git Providers</Heading2>
370
<Subheading>
371
Manage your permissions to the available Git provider integrations.{" "}
372
<a
373
className="gp-link"
374
href="https://www.gitpod.io/docs/configure/authentication"
375
target="_blank"
376
rel="noreferrer"
377
>
378
Learn more
379
</a>
380
</Subheading>
381
<ItemsList className="pt-6">
382
{authProviders.data &&
383
(authProviders.data.length === 0 ? (
384
<EmptyMessage subtitle="No Git providers have been configured yet." />
385
) : (
386
authProviders.data.map((ap) => (
387
<AuthEntryItem
388
key={ap.id}
389
isConnected={isConnected}
390
gitProviderMenu={gitProviderMenu}
391
getUsername={getUsername}
392
getPermissions={getPermissions}
393
ap={ap}
394
/>
395
))
396
))}
397
</ItemsList>
398
</div>
399
);
400
}
401
402
function GitIntegrations() {
403
const { user } = useContext(UserContext);
404
const userGitAuthProviders = useFeatureFlag("userGitAuthProviders");
405
406
const deleteUserAuthProvider = useDeleteUserAuthProviderMutation();
407
408
const [modal, setModal] = useState<
409
| { mode: "new" }
410
| { mode: "edit"; provider: AuthProvider }
411
| { mode: "delete"; provider: AuthProvider }
412
| undefined
413
>(undefined);
414
415
const {
416
data: providers,
417
isLoading,
418
refetch,
419
} = useQuery(
420
["own-auth-providers", { userId: user?.id ?? "" }],
421
async () => {
422
const { authProviders } = await authProviderClient.listAuthProviders({
423
id: { case: "userId", value: user?.id || "" },
424
});
425
return authProviders;
426
},
427
{ enabled: !!user },
428
);
429
430
const deleteProvider = async (provider: AuthProvider) => {
431
try {
432
await deleteUserAuthProvider.mutateAsync({
433
providerId: provider.id,
434
});
435
} catch (error) {
436
console.log(error);
437
}
438
setModal(undefined);
439
refetch();
440
};
441
442
const gitProviderMenu = (provider: AuthProvider) => {
443
const result: ContextMenuEntry[] = [];
444
result.push({
445
title: provider.verified ? "Edit Configuration" : "Activate Integration",
446
onClick: () => setModal({ mode: "edit", provider }),
447
separator: true,
448
});
449
result.push({
450
title: "Remove",
451
customFontStyle: "text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300",
452
onClick: () => setModal({ mode: "delete", provider }),
453
});
454
return result;
455
};
456
457
if (isLoading) {
458
return (
459
<Delayed>
460
<SpinnerLoader />
461
</Delayed>
462
);
463
}
464
465
// If user has no personal providers and ff is not enabled, don't show anything
466
// Otherwise we show their existing providers w/o ability to create new ones if ff is disabled
467
if ((providers || []).length === 0 && !userGitAuthProviders) {
468
return null;
469
}
470
471
return (
472
<div>
473
{modal?.mode === "new" && (
474
<GitIntegrationModal
475
mode={modal.mode}
476
userId={user?.id || "no-user"}
477
onClose={() => setModal(undefined)}
478
onUpdate={refetch}
479
/>
480
)}
481
{modal?.mode === "edit" && (
482
<GitIntegrationModal
483
mode={modal.mode}
484
userId={user?.id || "no-user"}
485
provider={modal.provider}
486
onClose={() => setModal(undefined)}
487
onUpdate={refetch}
488
/>
489
)}
490
{modal?.mode === "delete" && (
491
<ConfirmationModal
492
title="Remove Integration"
493
areYouSureText="Are you sure you want to remove the following Git integration?"
494
children={{
495
name: toAuthProviderLabel(modal.provider.type),
496
description: modal.provider.host,
497
}}
498
buttonText="Remove Integration"
499
onClose={() => setModal(undefined)}
500
onConfirm={async () => await deleteProvider(modal.provider)}
501
/>
502
)}
503
504
<div className="flex items-start sm:justify-between mb-2">
505
<div>
506
<Heading2>Git Integrations</Heading2>
507
<Subheading>
508
Manage Git integrations for self-managed instances of GitLab, GitHub, or Bitbucket.
509
</Subheading>
510
</div>
511
{/* Hide create button if ff is disabled */}
512
{userGitAuthProviders && (providers || []).length !== 0 ? (
513
<div className="flex mt-0">
514
<Button onClick={() => setModal({ mode: "new" })} className="ml-2">
515
New Integration
516
</Button>
517
</div>
518
) : null}
519
</div>
520
521
{providers && providers.length === 0 && (
522
<div className="w-full flex h-80 mt-2 rounded-xl bg-pk-surface-secondary">
523
<div className="m-auto text-center">
524
<Heading2 className="text-pk-content-invert-secondary self-center mb-4">
525
No Git Integrations
526
</Heading2>
527
<Subheading className="text-gray-500 mb-6">
528
In addition to the default Git Providers you can authorize
529
<br /> with a self-hosted instance of a provider.
530
</Subheading>
531
<Button onClick={() => setModal({ mode: "new" })}>New Integration</Button>
532
</div>
533
</div>
534
)}
535
<ItemsList className="pt-6">
536
{providers && providers.map((ap) => <IntegrationEntryItem ap={ap} gitProviderMenu={gitProviderMenu} />)}
537
</ItemsList>
538
</div>
539
);
540
}
541
542
export function GitIntegrationModal(
543
props: (
544
| {
545
mode: "new";
546
}
547
| {
548
mode: "edit";
549
provider: AuthProvider;
550
}
551
) & {
552
login?: boolean;
553
headerText?: string;
554
userId: string;
555
onClose?: () => void;
556
closeable?: boolean;
557
onUpdate?: () => void;
558
onAuthorize?: (payload?: string) => void;
559
},
560
) {
561
const callbackUrl = useMemo(() => gitpodHostUrl.with({ pathname: `/auth/callback` }).toString(), []);
562
563
const [mode, setMode] = useState<"new" | "edit">("new");
564
const [providerEntry, setProviderEntry] = useState<AuthProvider | undefined>(undefined);
565
566
const [type, setType] = useState<AuthProviderType>(AuthProviderType.GITLAB);
567
const [host, setHost] = useState<string>("");
568
const [clientId, setClientId] = useState<string>("");
569
const [clientSecret, setClientSecret] = useState<string>("");
570
const [authorizationUrl, setAuthorizationUrl] = useState("");
571
const [tokenUrl, setTokenUrl] = useState("");
572
573
const [busy, setBusy] = useState<boolean>(false);
574
const [errorMessage, setErrorMessage] = useState<string | undefined>();
575
const [validationError, setValidationError] = useState<string | undefined>();
576
577
const createProvider = useCreateUserAuthProviderMutation();
578
const updateProvider = useUpdateUserAuthProviderMutation();
579
580
const availableProviderOptions = useAuthProviderOptionsQuery(false);
581
582
useEffect(() => {
583
setMode(props.mode);
584
if (props.mode === "edit") {
585
setProviderEntry(props.provider);
586
setType(props.provider.type);
587
setHost(props.provider.host);
588
setClientId(props.provider.oauth2Config?.clientId || "");
589
setClientSecret(props.provider.oauth2Config?.clientSecret || "");
590
setAuthorizationUrl(props.provider.oauth2Config?.authorizationUrl || "");
591
setTokenUrl(props.provider.oauth2Config?.tokenUrl || "");
592
}
593
// eslint-disable-next-line react-hooks/exhaustive-deps
594
}, []);
595
596
useEffect(() => {
597
setErrorMessage(undefined);
598
validate();
599
// eslint-disable-next-line react-hooks/exhaustive-deps
600
}, [clientId, clientSecret, authorizationUrl, tokenUrl, type]);
601
602
const onClose = () => props.onClose && props.onClose();
603
const onUpdate = () => props.onUpdate && props.onUpdate();
604
605
const activate = async () => {
606
setBusy(true);
607
setErrorMessage(undefined);
608
try {
609
let newProvider: AuthProvider;
610
611
if (mode === "new") {
612
newProvider = await createProvider.mutateAsync({
613
provider: {
614
clientId,
615
clientSecret,
616
authorizationUrl,
617
tokenUrl,
618
type,
619
host,
620
userId: props.userId,
621
},
622
});
623
} else {
624
newProvider = await updateProvider.mutateAsync({
625
provider: {
626
id: providerEntry?.id || "",
627
clientId,
628
clientSecret: clientSecret === "redacted" ? "" : clientSecret,
629
authorizationUrl,
630
tokenUrl,
631
},
632
});
633
}
634
635
// the server is checking periodically for updates of dynamic providers, thus we need to
636
// wait at least 2 seconds for the changes to be propagated before we try to use this provider.
637
await new Promise((resolve) => setTimeout(resolve, 2000));
638
639
onUpdate();
640
641
const updateProviderEntry = async () => {
642
const { authProvider } = await authProviderClient.getAuthProvider({
643
authProviderId: newProvider.id,
644
});
645
if (authProvider) {
646
setProviderEntry(authProvider);
647
}
648
};
649
650
// just open the authorization window and do *not* await
651
openAuthorizeWindow({
652
login: props.login,
653
host: newProvider.host,
654
onSuccess: (payload) => {
655
updateProviderEntry();
656
onUpdate();
657
props.onAuthorize && props.onAuthorize(payload);
658
onClose();
659
},
660
onError: (payload) => {
661
updateProviderEntry();
662
let errorMessage: string;
663
if (typeof payload === "string") {
664
errorMessage = payload;
665
} else {
666
errorMessage = payload.description ? payload.description : `Error: ${payload.error}`;
667
}
668
setErrorMessage(errorMessage);
669
},
670
});
671
672
if (props.closeable) {
673
// close the modal, as the creation phase is done anyways.
674
onClose();
675
} else {
676
// switch mode to stay and edit this integration.
677
// this modal is expected to be closed programmatically.
678
setMode("edit");
679
setProviderEntry(newProvider);
680
}
681
} catch (error) {
682
console.log(error);
683
setErrorMessage("message" in error ? error.message : "Failed to update Git provider");
684
}
685
setBusy(false);
686
};
687
688
const updateHostValue = (host: string) => {
689
if (mode === "new") {
690
let newHostValue = host;
691
692
if (host.startsWith("https://")) {
693
newHostValue = host.replace("https://", "");
694
}
695
696
setHost(newHostValue);
697
setErrorMessage(undefined);
698
}
699
};
700
701
const updateClientId = (value: string) => {
702
setClientId(value.trim());
703
};
704
const updateClientSecret = (value: string) => {
705
setClientSecret(value.trim());
706
};
707
const updateAuthorizationUrl = (value: string) => {
708
setAuthorizationUrl(value.trim());
709
};
710
const updateTokenUrl = (value: string) => {
711
setTokenUrl(value.trim());
712
};
713
714
const validate = () => {
715
const errors: string[] = [];
716
if (clientId.trim().length === 0) {
717
errors.push(`${type === AuthProviderType.GITLAB ? "Application ID" : "Client ID"} is missing.`);
718
}
719
if (clientSecret.trim().length === 0) {
720
errors.push(`${type === AuthProviderType.GITLAB ? "Secret" : "Client Secret"} is missing.`);
721
}
722
if (type === AuthProviderType.AZURE_DEVOPS) {
723
if (authorizationUrl.trim().length === 0) {
724
errors.push("Authorization URL is missing.");
725
}
726
if (tokenUrl.trim().length === 0) {
727
errors.push("Token URL is missing.");
728
}
729
}
730
if (errors.length === 0) {
731
setValidationError(undefined);
732
return true;
733
} else {
734
setValidationError(errors.join("\n"));
735
return false;
736
}
737
};
738
739
const getRedirectUrlDescription = (type: AuthProviderType, host: string) => {
740
if (type === AuthProviderType.AZURE_DEVOPS) {
741
return (
742
<span>
743
Use this redirect URI to update the OAuth application and set it up.&nbsp;
744
<a
745
href="https://www.gitpod.io/docs/azure-devops-integration/#oauth-application"
746
target="_blank"
747
rel="noreferrer noopener"
748
className="gp-link"
749
>
750
Learn more
751
</a>
752
.
753
</span>
754
);
755
}
756
let settingsUrl = ``;
757
switch (type) {
758
case AuthProviderType.GITHUB:
759
// if host is empty or untouched by user, use the default value
760
if (host === "") {
761
settingsUrl = "github.com/settings/developers";
762
} else {
763
settingsUrl = `${host}/settings/developers`;
764
}
765
break;
766
case AuthProviderType.GITLAB:
767
// if host is empty or untouched by user, use the default value
768
if (host === "") {
769
settingsUrl = "gitlab.com/-/profile/applications";
770
} else {
771
settingsUrl = `${host}/-/profile/applications`;
772
}
773
break;
774
default:
775
return undefined;
776
}
777
let docsUrl = ``;
778
switch (type) {
779
case AuthProviderType.GITHUB:
780
docsUrl = `https://www.gitpod.io/docs/github-integration/#oauth-application`;
781
break;
782
case AuthProviderType.GITLAB:
783
docsUrl = `https://www.gitpod.io/docs/gitlab-integration/#oauth-application`;
784
break;
785
default:
786
return undefined;
787
}
788
789
return (
790
<span>
791
Use this redirect URI to update the OAuth application. Go to{" "}
792
<a href={`https://${settingsUrl}`} target="_blank" rel="noreferrer noopener" className="gp-link">
793
developer settings
794
</a>{" "}
795
and setup the OAuth application.&nbsp;
796
<a href={docsUrl} target="_blank" rel="noreferrer noopener" className="gp-link">
797
Learn more
798
</a>
799
.
800
</span>
801
);
802
};
803
804
const getPlaceholderForIntegrationType = (type: AuthProviderType) => {
805
switch (type) {
806
case AuthProviderType.GITHUB:
807
return "github.example.com";
808
case AuthProviderType.GITLAB:
809
return "gitlab.example.com";
810
case AuthProviderType.BITBUCKET:
811
return "bitbucket.org";
812
case AuthProviderType.BITBUCKET_SERVER:
813
return "bitbucket.example.com";
814
case AuthProviderType.AZURE_DEVOPS:
815
return "dev.azure.com";
816
default:
817
return "";
818
}
819
};
820
821
const getNumber = (paramValue: string | null) => {
822
if (!paramValue) {
823
return 0;
824
}
825
826
try {
827
const number = Number.parseInt(paramValue, 10);
828
if (Number.isNaN(number)) {
829
return 0;
830
}
831
832
return number;
833
} catch (e) {
834
return 0;
835
}
836
};
837
838
return (
839
// TODO: Use title and buttons props
840
<Modal visible={!!props} onClose={onClose} closeable={props.closeable}>
841
<Heading2 className="pb-2">{mode === "new" ? "New Git Integration" : "Git Integration"}</Heading2>
842
<div className="space-y-4 border-t border-b border-gray-200 dark:border-gray-800 mt-2 -mx-6 px-6 py-4">
843
{mode === "edit" && !providerEntry?.verified && (
844
<Alert type="warning">You need to activate this integration.</Alert>
845
)}
846
<div className="flex flex-col">
847
<span className="text-gray-500">
848
{props.headerText ||
849
"Configure an integration with a self-managed instance of GitLab, GitHub, or Bitbucket."}
850
</span>
851
</div>
852
853
<div className="overscroll-contain max-h-96 space-y-4 overflow-y-auto pr-2">
854
{mode === "new" && (
855
<div className="flex flex-col space-y-2">
856
<label htmlFor="type" className="font-medium">
857
Provider Type
858
</label>
859
<select
860
name="type"
861
value={type}
862
disabled={mode !== "new"}
863
className="w-full"
864
onChange={(e) => setType(getNumber(e.target.value))}
865
>
866
{availableProviderOptions.map((options) => (
867
<option key={options.type} value={options.type}>
868
{options.label}
869
</option>
870
))}
871
</select>
872
</div>
873
)}
874
{mode === "new" && type === AuthProviderType.BITBUCKET_SERVER && (
875
<InfoBox className="my-4 mx-auto">
876
OAuth 2.0 support in Bitbucket Server was added in version 7.20.{" "}
877
<a
878
target="_blank"
879
href="https://confluence.atlassian.com/bitbucketserver/bitbucket-data-center-and-server-7-20-release-notes-1101934428.html"
880
rel="noopener noreferrer"
881
className="gp-link"
882
>
883
Learn more
884
</a>
885
</InfoBox>
886
)}
887
<div className="flex flex-col space-y-2">
888
<label htmlFor="hostName" className="font-medium">
889
Provider Host Name
890
</label>
891
<input
892
id="hostName"
893
disabled={mode === "edit"}
894
type="text"
895
placeholder={getPlaceholderForIntegrationType(type)}
896
value={host}
897
className="w-full"
898
onChange={(e) => updateHostValue(e.target.value)}
899
/>
900
</div>
901
<div className="flex flex-col space-y-2">
902
<label htmlFor="redirectURI" className="font-medium">
903
Redirect URI
904
</label>
905
<InputWithCopy value={callbackUrl} tip="Copy the redirect URI to clipboard" />
906
<span className="text-gray-500 text-sm">{getRedirectUrlDescription(type, host)}</span>
907
</div>
908
{type === AuthProviderType.AZURE_DEVOPS && (
909
<>
910
<div className="flex flex-col space-y-2">
911
<label htmlFor="authorizationUrl" className="font-medium">{`Authorization URL`}</label>
912
<input
913
name="Authorization URL"
914
type="text"
915
value={authorizationUrl}
916
className="w-full"
917
onChange={(e) => updateAuthorizationUrl(e.target.value)}
918
/>
919
</div>
920
<div className="flex flex-col space-y-2">
921
<label htmlFor="tokenUrl" className="font-medium">{`Token URL`}</label>
922
<input
923
name="Token URL"
924
type="text"
925
value={tokenUrl}
926
className="w-full"
927
onChange={(e) => updateTokenUrl(e.target.value)}
928
/>
929
</div>
930
</>
931
)}
932
<div className="flex flex-col space-y-2">
933
<label htmlFor="clientId" className="font-medium">{`${
934
type === AuthProviderType.GITLAB ? "Application ID" : "Client ID"
935
}`}</label>
936
<input
937
name="clientId"
938
type="text"
939
value={clientId}
940
className="w-full"
941
onChange={(e) => updateClientId(e.target.value)}
942
/>
943
</div>
944
<div className="flex flex-col space-y-2">
945
<label htmlFor="clientSecret" className="font-medium">{`${
946
type === AuthProviderType.GITLAB ? "Secret" : "Client Secret"
947
}`}</label>
948
<input
949
name="clientSecret"
950
type="password"
951
value={clientSecret}
952
className="w-full"
953
onChange={(e) => updateClientSecret(e.target.value)}
954
/>
955
</div>
956
</div>
957
958
{(errorMessage || validationError) && (
959
<div className="flex rounded-md bg-red-600 p-3">
960
<img
961
className="w-4 h-4 mx-2 my-auto filter-brightness-10"
962
src={exclamation}
963
alt="exclamation mark icon"
964
/>
965
<span className="text-white">{errorMessage || validationError}</span>
966
</div>
967
)}
968
</div>
969
<div className="flex justify-end mt-6">
970
<Button onClick={() => validate() && activate()} disabled={!!validationError || busy}>
971
Activate Integration
972
</Button>
973
</div>
974
</Modal>
975
);
976
}
977
978
function equals(a: Set<string>, b: Set<string>): boolean {
979
return a.size === b.size && Array.from(a).every((e) => b.has(e));
980
}
981
982