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