Path: blob/main/components/dashboard/src/teams/git-integrations/GitIntegrationModal.tsx
2501 views
/**1* Copyright (c) 2023 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 { FunctionComponent, useCallback, useContext, useEffect, useMemo, useState } from "react";7import { Button } from "@podkit/buttons/Button";8import { InputField } from "../../components/forms/InputField";9import { SelectInputField } from "../../components/forms/SelectInputField";10import { TextInputField } from "../../components/forms/TextInputField";11import { InputWithCopy } from "../../components/InputWithCopy";12import Modal, { ModalBody, ModalFooter, ModalFooterAlert, ModalHeader } from "../../components/Modal";13import { Subheading } from "../../components/typography/headings";14import { useInvalidateOrgAuthProvidersQuery } from "../../data/auth-providers/org-auth-providers-query";15import { useCurrentOrg } from "../../data/organizations/orgs-query";16import { useOnBlurError } from "../../hooks/use-onblur-error";17import { openAuthorizeWindow, toAuthProviderLabel } from "../../provider-utils";18import { gitpodHostUrl } from "../../service/service";19import { UserContext } from "../../user-context";20import { useToast } from "../../components/toasts/Toasts";21import { AuthProvider, AuthProviderType } from "@gitpod/public-api/lib/gitpod/v1/authprovider_pb";22import { useCreateOrgAuthProviderMutation } from "../../data/auth-providers/create-org-auth-provider-mutation";23import { useUpdateOrgAuthProviderMutation } from "../../data/auth-providers/update-org-auth-provider-mutation";24import { authProviderClient, userClient } from "../../service/public-api";25import { LoadingButton } from "@podkit/buttons/LoadingButton";26import {27isSupportAzureDevOpsIntegration,28useAuthProviderOptionsQuery,29} from "../../data/auth-providers/auth-provider-options-query";3031type Props = {32provider?: AuthProvider;33onClose: () => void;34};35export const GitIntegrationModal: FunctionComponent<Props> = (props) => {36const { setUser } = useContext(UserContext);37const { toast } = useToast();38const team = useCurrentOrg().data;39const [type, setType] = useState<AuthProviderType>(props.provider?.type ?? AuthProviderType.GITLAB);40const [host, setHost] = useState<string>(props.provider?.host ?? "");41const [clientId, setClientId] = useState<string>(props.provider?.oauth2Config?.clientId ?? "");42const [clientSecret, setClientSecret] = useState<string>(props.provider?.oauth2Config?.clientSecret ?? "");43const [authorizationUrl, setAuthorizationUrl] = useState(props.provider?.oauth2Config?.authorizationUrl ?? "");44const [tokenUrl, setTokenUrl] = useState(props.provider?.oauth2Config?.tokenUrl ?? "");45const availableProviderOptions = useAuthProviderOptionsQuery(true);46const supportAzureDevOps = isSupportAzureDevOpsIntegration();4748const [savedProvider, setSavedProvider] = useState(props.provider);49const isNew = !savedProvider;5051// This is a readonly value to copy and plug into external oauth config52const redirectURL = callbackUrl();5354// "bitbucket.org" is set as host value whenever "Bitbucket" is selected55useEffect(() => {56if (isNew) {57setHost(type === AuthProviderType.BITBUCKET ? "bitbucket.org" : "");58}59}, [isNew, type]);6061const [savingProvider, setSavingProvider] = useState(false);62const [errorMessage, setErrorMessage] = useState<string | undefined>();6364const createProvider = useCreateOrgAuthProviderMutation();65const updateProvider = useUpdateOrgAuthProviderMutation();66const invalidateOrgAuthProviders = useInvalidateOrgAuthProvidersQuery(team?.id ?? "");6768const {69message: hostError,70onBlur: hostOnBlurErrorTracking,71isValid: hostValid,72} = useOnBlurError(`Provider Host Name is missing.`, host.trim().length > 0);7374const {75message: clientIdError,76onBlur: clientIdOnBlur,77isValid: clientIdValid,78} = useOnBlurError(79`${type === AuthProviderType.GITLAB ? "Application ID" : "Client ID"} is missing.`,80clientId.trim().length > 0,81);8283const {84message: clientSecretError,85onBlur: clientSecretOnBlur,86isValid: clientSecretValid,87} = useOnBlurError(88`${type === AuthProviderType.GITLAB ? "Secret" : "Client Secret"} is missing.`,89clientSecret.trim().length > 0,90);9192const {93message: authorizationUrlError,94onBlur: authorizationUrlOnBlur,95isValid: authorizationUrlValid,96} = useOnBlurError(97`Authorization URL is missing.`,98type !== AuthProviderType.AZURE_DEVOPS || authorizationUrl.trim().length > 0,99);100101const {102message: tokenUrlError,103onBlur: tokenUrlOnBlur,104isValid: tokenUrlValid,105} = useOnBlurError(`Token URL is missing.`, type !== AuthProviderType.AZURE_DEVOPS || tokenUrl.trim().length > 0);106107// Call our error onBlur handler, and remove prefixed "https://"108const hostOnBlur = useCallback(() => {109hostOnBlurErrorTracking();110111setHost(cleanHost(host));112}, [host, hostOnBlurErrorTracking]);113114const reloadSavedProvider = useCallback(async () => {115if (!savedProvider || !team) {116return;117}118119const { authProvider } = await authProviderClient.getAuthProvider({ authProviderId: savedProvider.id });120if (authProvider) {121setSavedProvider(authProvider);122}123}, [savedProvider, team]);124125const activate = useCallback(async () => {126if (!team) {127console.error("no current team selected");128return;129}130131// Set a saving state and clear any error message132setSavingProvider(true);133setErrorMessage(undefined);134135const trimmedId = clientId.trim();136const trimmedSecret = clientSecret.trim();137const trimmedAuthorizationUrl = authorizationUrl.trim();138const trimmedTokenUrl = tokenUrl.trim();139140try {141let newProvider: AuthProvider;142if (isNew) {143newProvider = await createProvider.mutateAsync({144provider: {145host: cleanHost(host),146type,147orgId: team.id,148clientId: trimmedId,149clientSecret: trimmedSecret,150authorizationUrl: trimmedAuthorizationUrl,151tokenUrl: trimmedTokenUrl,152},153});154} else {155newProvider = await updateProvider.mutateAsync({156provider: {157id: savedProvider.id,158clientId: trimmedId,159clientSecret: clientSecret === "redacted" ? "" : trimmedSecret,160authorizationUrl: trimmedAuthorizationUrl,161tokenUrl: trimmedTokenUrl,162},163});164}165166// switch mode to stay and edit this integration.167setSavedProvider(newProvider);168169// the server is checking periodically for updates of dynamic providers, thus we need to170// wait at least 2 seconds for the changes to be propagated before we try to use this provider.171await new Promise((resolve) => setTimeout(resolve, 2000));172173// just open the authorization window and do *not* await174openAuthorizeWindow({175login: false,176host: newProvider.host,177onSuccess: (payload) => {178invalidateOrgAuthProviders();179180// Refresh the current user - they may have a new identity record now181// setup a promise and don't wait so we can close the modal right away182userClient.getAuthenticatedUser({}).then(({ user }) => {183if (user) {184setUser(user);185}186});187toast(`${toAuthProviderLabel(newProvider.type)} integration has been activated.`);188189props.onClose();190},191onError: (payload) => {192reloadSavedProvider();193194let errorMessage: string;195if (typeof payload === "string") {196errorMessage = payload;197} else {198errorMessage = payload.description ? payload.description : `Error: ${payload.error}`;199}200setErrorMessage(errorMessage);201},202});203} catch (error) {204console.log(error);205setErrorMessage("message" in error ? error.message : "Failed to update Git provider");206}207208setSavingProvider(false);209}, [210clientId,211clientSecret,212authorizationUrl,213tokenUrl,214host,215invalidateOrgAuthProviders,216isNew,217props,218savedProvider?.id,219setUser,220team,221toast,222type,223createProvider,224updateProvider,225reloadSavedProvider,226]);227228const isValid = useMemo(229() => clientIdValid && clientSecretValid && hostValid && authorizationUrlValid && tokenUrlValid,230[clientIdValid, clientSecretValid, hostValid, authorizationUrlValid, tokenUrlValid],231);232233const getNumber = (paramValue: string | null) => {234if (!paramValue) {235return 0;236}237238try {239const number = Number.parseInt(paramValue, 10);240if (Number.isNaN(number)) {241return 0;242}243244return number;245} catch (e) {246return 0;247}248};249250return (251<Modal visible onClose={props.onClose} onSubmit={activate} autoFocus={isNew}>252<ModalHeader>{isNew ? "New Git Provider" : "Git Provider"}</ModalHeader>253<ModalBody>254{isNew && (255<Subheading>256Configure a Git Integration with a self-managed instance of GitLab, GitHub{" "}257{supportAzureDevOps ? ", Bitbucket Server or Azure DevOps" : "or Bitbucket"}.258</Subheading>259)}260261<div>262<SelectInputField263disabled={!isNew}264label="Provider Type"265value={type.toString()}266topMargin={false}267onChange={(val) => setType(getNumber(val))}268>269{availableProviderOptions.map((option) => (270<option key={option.type} value={option.type}>271{option.label}272</option>273))}274</SelectInputField>275<TextInputField276label="Provider Host Name"277value={host}278disabled={!isNew || type === AuthProviderType.BITBUCKET}279placeholder={getPlaceholderForIntegrationType(type)}280error={hostError}281onChange={setHost}282onBlur={hostOnBlur}283/>284285<InputField label="Redirect URI" hint={<RedirectUrlDescription type={type} />}>286<InputWithCopy value={redirectURL} tip="Copy the redirect URI to clipboard" />287</InputField>288289{type === AuthProviderType.AZURE_DEVOPS && (290<>291<TextInputField292label="Authorization URL"293value={authorizationUrl}294error={authorizationUrlError}295onBlur={authorizationUrlOnBlur}296onChange={setAuthorizationUrl}297/>298<TextInputField299label="Token URL"300value={tokenUrl}301error={tokenUrlError}302onBlur={tokenUrlOnBlur}303onChange={setTokenUrl}304/>305</>306)}307308<TextInputField309label={type === AuthProviderType.GITLAB ? "Application ID" : "Client ID"}310value={clientId}311error={clientIdError}312onBlur={clientIdOnBlur}313onChange={setClientId}314/>315316<TextInputField317label={type === AuthProviderType.GITLAB ? "Secret" : "Client Secret"}318type="password"319value={clientSecret}320error={clientSecretError}321onChange={setClientSecret}322onBlur={clientSecretOnBlur}323/>324</div>325</ModalBody>326<ModalFooter327alert={328<>329{errorMessage ? (330<ModalFooterAlert type="danger">{errorMessage}</ModalFooterAlert>331) : (332!isNew &&333!savedProvider?.verified && (334<ModalFooterAlert type="warning" closable={false}>335You need to activate this configuration.336</ModalFooterAlert>337)338)}339</>340}341>342<Button variant="secondary" onClick={props.onClose}>343Cancel344</Button>345<LoadingButton type="submit" disabled={!isValid} loading={savingProvider}>346Activate347</LoadingButton>348</ModalFooter>349</Modal>350);351};352353const callbackUrl = () => {354const pathname = `/auth/callback`;355return gitpodHostUrl.with({ pathname }).toString();356};357358const getPlaceholderForIntegrationType = (type: AuthProviderType) => {359switch (type) {360case AuthProviderType.GITHUB:361return "github.example.com";362case AuthProviderType.GITLAB:363return "gitlab.example.com";364case AuthProviderType.BITBUCKET:365return "bitbucket.org";366case AuthProviderType.BITBUCKET_SERVER:367return "bitbucket.example.com";368case AuthProviderType.AZURE_DEVOPS:369return "dev.azure.com";370default:371return "";372}373};374375type RedirectUrlDescriptionProps = {376type: AuthProviderType;377};378const RedirectUrlDescription: FunctionComponent<RedirectUrlDescriptionProps> = ({ type }) => {379let docsUrl = ``;380switch (type) {381case AuthProviderType.GITHUB:382docsUrl = `https://www.gitpod.io/docs/configure/authentication/github-enterprise`;383break;384case AuthProviderType.GITLAB:385docsUrl = `https://www.gitpod.io/docs/configure/authentication/gitlab#registering-a-self-hosted-gitlab-installation`;386break;387case AuthProviderType.BITBUCKET:388docsUrl = `https://www.gitpod.io/docs/configure/authentication`;389break;390case AuthProviderType.BITBUCKET_SERVER:391docsUrl = "https://www.gitpod.io/docs/configure/authentication/bitbucket-server";392break;393case AuthProviderType.AZURE_DEVOPS:394docsUrl = "https://www.gitpod.io/docs/configure/authentication/azure-devops";395break;396default:397return null;398}399400return (401<span>402Use this redirect URI to register a {toAuthProviderLabel(type)} instance as an authorized Git provider in403Gitpod.{" "}404<a href={docsUrl} target="_blank" rel="noreferrer noopener" className="gp-link">405Learn more406</a>407</span>408);409};410411function cleanHost(host: string) {412let cleanedHost = host;413414// Removing https protocol415if (host.startsWith("https://")) {416cleanedHost = host.replace("https://", "");417}418419// Trim any trailing slashes420cleanedHost = cleanedHost.replace(/\/$/, "");421422return cleanedHost;423}424425426