diff --git a/components/dashboard/src/components/RepositoryFinder.tsx b/components/dashboard/src/components/RepositoryFinder.tsx index 108a763e487793..98c4e0d1bb4c0a 100644 --- a/components/dashboard/src/components/RepositoryFinder.tsx +++ b/components/dashboard/src/components/RepositoryFinder.tsx @@ -318,6 +318,20 @@ export default function RepositoryFinder({ }); } + if (searchString.length >= 3 && authProviders.data?.some((p) => p.type === AuthProviderType.AZURE_DEVOPS)) { + // ENT-780 + result.push({ + id: "azure-devops", + element: ( +
+ + Azure DevOps doesn't support repository searching. +
+ ), + isSelectable: false, + }); + } + if (searchString.length < 3) { // add an element that tells the user to type more result.push({ diff --git a/components/dashboard/src/data/auth-providers/auth-provider-options-query.ts b/components/dashboard/src/data/auth-providers/auth-provider-options-query.ts new file mode 100644 index 00000000000000..958d74581376af --- /dev/null +++ b/components/dashboard/src/data/auth-providers/auth-provider-options-query.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2024 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License.AGPL.txt in the project root for license information. + */ + +import { AuthProviderType } from "@gitpod/public-api/lib/gitpod/v1/authprovider_pb"; +import { isGitpodIo } from "../../utils"; +import { useMemo } from "react"; + +const optionsForPAYG = [ + { type: AuthProviderType.GITHUB, label: "GitHub" }, + { type: AuthProviderType.GITLAB, label: "GitLab" }, + { type: AuthProviderType.BITBUCKET_SERVER, label: "Bitbucket Server" }, + { type: AuthProviderType.BITBUCKET, label: "Bitbucket Cloud" }, +]; + +const optionsForEnterprise = [...optionsForPAYG, { type: AuthProviderType.AZURE_DEVOPS, label: "Azure DevOps" }]; + +export const isSupportAzureDevOpsIntegration = () => { + return isGitpodIo(); +}; + +export const useAuthProviderOptionsQuery = (isOrgLevel: boolean) => { + return useMemo(() => { + const isPAYG = isGitpodIo(); + // Azure DevOps is not supported for PAYG users and is only available for org-level integrations + // because auth flow is identified by auth provider's host, which will always be `dev.azure.com` + // + // Don't remove this until we can setup an generial application for Azure DevOps (investigate needed) + if (isPAYG || !isOrgLevel) { + return optionsForPAYG; + } + return optionsForEnterprise; + }, [isOrgLevel]); +}; diff --git a/components/dashboard/src/data/auth-providers/create-org-auth-provider-mutation.ts b/components/dashboard/src/data/auth-providers/create-org-auth-provider-mutation.ts index 5e26c643a0e610..eadffc8622d638 100644 --- a/components/dashboard/src/data/auth-providers/create-org-auth-provider-mutation.ts +++ b/components/dashboard/src/data/auth-providers/create-org-auth-provider-mutation.ts @@ -14,6 +14,8 @@ type CreateAuthProviderArgs = { clientId: string; clientSecret: string; orgId: string; + authorizationUrl?: string; + tokenUrl?: string; }; }; export const useCreateOrgAuthProviderMutation = () => { @@ -28,6 +30,8 @@ export const useCreateOrgAuthProviderMutation = () => { oauth2Config: { clientId: provider.clientId, clientSecret: provider.clientSecret, + authorizationUrl: provider.authorizationUrl, + tokenUrl: provider.tokenUrl, }, type: provider.type, }), diff --git a/components/dashboard/src/data/auth-providers/create-user-auth-provider-mutation.ts b/components/dashboard/src/data/auth-providers/create-user-auth-provider-mutation.ts index 998e08da506e9b..0f12d6c5f66877 100644 --- a/components/dashboard/src/data/auth-providers/create-user-auth-provider-mutation.ts +++ b/components/dashboard/src/data/auth-providers/create-user-auth-provider-mutation.ts @@ -14,6 +14,8 @@ type CreateAuthProviderArgs = { clientId: string; clientSecret: string; userId: string; + authorizationUrl?: string; + tokenUrl?: string; }; }; export const useCreateUserAuthProviderMutation = () => { @@ -28,6 +30,8 @@ export const useCreateUserAuthProviderMutation = () => { oauth2Config: { clientId: provider.clientId, clientSecret: provider.clientSecret, + authorizationUrl: provider.authorizationUrl, + tokenUrl: provider.tokenUrl, }, type: provider.type, }), diff --git a/components/dashboard/src/data/auth-providers/update-org-auth-provider-mutation.ts b/components/dashboard/src/data/auth-providers/update-org-auth-provider-mutation.ts index 5c24d0d0afd65a..66570b84be28c2 100644 --- a/components/dashboard/src/data/auth-providers/update-org-auth-provider-mutation.ts +++ b/components/dashboard/src/data/auth-providers/update-org-auth-provider-mutation.ts @@ -14,6 +14,8 @@ type UpdateAuthProviderArgs = { id: string; clientId: string; clientSecret: string; + authorizationUrl?: string; + tokenUrl?: string; }; }; export const useUpdateOrgAuthProviderMutation = () => { @@ -26,6 +28,8 @@ export const useUpdateOrgAuthProviderMutation = () => { authProviderId: provider.id, clientId: provider.clientId, clientSecret: provider.clientSecret, + authorizationUrl: provider.authorizationUrl, + tokenUrl: provider.tokenUrl, }), ); return response.authProvider!; diff --git a/components/dashboard/src/data/auth-providers/update-user-auth-provider-mutation.ts b/components/dashboard/src/data/auth-providers/update-user-auth-provider-mutation.ts index 046fa61ad376eb..cf99c98001467f 100644 --- a/components/dashboard/src/data/auth-providers/update-user-auth-provider-mutation.ts +++ b/components/dashboard/src/data/auth-providers/update-user-auth-provider-mutation.ts @@ -14,6 +14,8 @@ type UpdateAuthProviderArgs = { id: string; clientId: string; clientSecret: string; + authorizationUrl?: string; + tokenUrl?: string; }; }; export const useUpdateUserAuthProviderMutation = () => { @@ -26,6 +28,8 @@ export const useUpdateUserAuthProviderMutation = () => { authProviderId: provider.id, clientId: provider.clientId, clientSecret: provider.clientSecret, + authorizationUrl: provider.authorizationUrl, + tokenUrl: provider.tokenUrl, }), ); return response.authProvider!; diff --git a/components/dashboard/src/images/azuredevops.svg b/components/dashboard/src/images/azuredevops.svg new file mode 100644 index 00000000000000..3a1be63f162e06 --- /dev/null +++ b/components/dashboard/src/images/azuredevops.svg @@ -0,0 +1 @@ +Icon-devops-261 diff --git a/components/dashboard/src/provider-utils.tsx b/components/dashboard/src/provider-utils.tsx index f1c56442747aea..c12e9e1c73c21c 100644 --- a/components/dashboard/src/provider-utils.tsx +++ b/components/dashboard/src/provider-utils.tsx @@ -8,6 +8,7 @@ import { AuthProviderType } from "@gitpod/public-api/lib/gitpod/v1/authprovider_ import bitbucket from "./images/bitbucket.svg"; import github from "./images/github.svg"; import gitlab from "./images/gitlab.svg"; +import azuredevops from "./images/azuredevops.svg"; import { gitpodHostUrl } from "./service/service"; function iconForAuthProvider(type: string | AuthProviderType) { @@ -24,6 +25,9 @@ function iconForAuthProvider(type: string | AuthProviderType) { case "BitbucketServer": case AuthProviderType.BITBUCKET_SERVER: return ; + case "AzureDevOps": + case AuthProviderType.AZURE_DEVOPS: + return ; default: return <>; } @@ -39,6 +43,8 @@ export function toAuthProviderLabel(type: AuthProviderType) { return "Bitbucket Cloud"; case AuthProviderType.BITBUCKET_SERVER: return "Bitbucket Server"; + case AuthProviderType.AZURE_DEVOPS: + return "Azure DevOps"; default: return "-"; } @@ -52,6 +58,8 @@ function simplifyProviderName(host: string) { return "GitLab"; case "bitbucket.org": return "Bitbucket"; + case "dev.azure.com": + return "Azure DevOps"; default: return host; } diff --git a/components/dashboard/src/teams/git-integrations/GitIntegrationModal.tsx b/components/dashboard/src/teams/git-integrations/GitIntegrationModal.tsx index c168355699031b..8223092119770a 100644 --- a/components/dashboard/src/teams/git-integrations/GitIntegrationModal.tsx +++ b/components/dashboard/src/teams/git-integrations/GitIntegrationModal.tsx @@ -24,6 +24,10 @@ import { useCreateOrgAuthProviderMutation } from "../../data/auth-providers/crea import { useUpdateOrgAuthProviderMutation } from "../../data/auth-providers/update-org-auth-provider-mutation"; import { authProviderClient, userClient } from "../../service/public-api"; import { LoadingButton } from "@podkit/buttons/LoadingButton"; +import { + isSupportAzureDevOpsIntegration, + useAuthProviderOptionsQuery, +} from "../../data/auth-providers/auth-provider-options-query"; type Props = { provider?: AuthProvider; @@ -37,6 +41,10 @@ export const GitIntegrationModal: FunctionComponent = (props) => { const [host, setHost] = useState(props.provider?.host ?? ""); const [clientId, setClientId] = useState(props.provider?.oauth2Config?.clientId ?? ""); const [clientSecret, setClientSecret] = useState(props.provider?.oauth2Config?.clientSecret ?? ""); + const [authorizationUrl, setAuthorizationUrl] = useState(props.provider?.oauth2Config?.authorizationUrl ?? ""); + const [tokenUrl, setTokenUrl] = useState(props.provider?.oauth2Config?.tokenUrl ?? ""); + const availableProviderOptions = useAuthProviderOptionsQuery(true); + const supportAzureDevOps = isSupportAzureDevOpsIntegration(); const [savedProvider, setSavedProvider] = useState(props.provider); const isNew = !savedProvider; @@ -82,6 +90,21 @@ export const GitIntegrationModal: FunctionComponent = (props) => { clientSecret.trim().length > 0, ); + const { + message: authorizationUrlError, + onBlur: authorizationUrlOnBlur, + isValid: authorizationUrlValid, + } = useOnBlurError( + `Authorization URL is missing.`, + type !== AuthProviderType.AZURE_DEVOPS || authorizationUrl.trim().length > 0, + ); + + const { + message: tokenUrlError, + onBlur: tokenUrlOnBlur, + isValid: tokenUrlValid, + } = useOnBlurError(`Token URL is missing.`, type !== AuthProviderType.AZURE_DEVOPS || tokenUrl.trim().length > 0); + // Call our error onBlur handler, and remove prefixed "https://" const hostOnBlur = useCallback(() => { hostOnBlurErrorTracking(); @@ -112,6 +135,8 @@ export const GitIntegrationModal: FunctionComponent = (props) => { const trimmedId = clientId.trim(); const trimmedSecret = clientSecret.trim(); + const trimmedAuthorizationUrl = authorizationUrl.trim(); + const trimmedTokenUrl = tokenUrl.trim(); try { let newProvider: AuthProvider; @@ -123,6 +148,8 @@ export const GitIntegrationModal: FunctionComponent = (props) => { orgId: team.id, clientId: trimmedId, clientSecret: trimmedSecret, + authorizationUrl: trimmedAuthorizationUrl, + tokenUrl: trimmedTokenUrl, }, }); } else { @@ -131,6 +158,8 @@ export const GitIntegrationModal: FunctionComponent = (props) => { id: savedProvider.id, clientId: trimmedId, clientSecret: clientSecret === "redacted" ? "" : trimmedSecret, + authorizationUrl: trimmedAuthorizationUrl, + tokenUrl: trimmedTokenUrl, }, }); } @@ -181,6 +210,8 @@ export const GitIntegrationModal: FunctionComponent = (props) => { }, [ clientId, clientSecret, + authorizationUrl, + tokenUrl, host, invalidateOrgAuthProviders, isNew, @@ -196,8 +227,8 @@ export const GitIntegrationModal: FunctionComponent = (props) => { ]); const isValid = useMemo( - () => clientIdValid && clientSecretValid && hostValid, - [clientIdValid, clientSecretValid, hostValid], + () => clientIdValid && clientSecretValid && hostValid && authorizationUrlValid && tokenUrlValid, + [clientIdValid, clientSecretValid, hostValid, authorizationUrlValid, tokenUrlValid], ); const getNumber = (paramValue: string | null) => { @@ -223,7 +254,8 @@ export const GitIntegrationModal: FunctionComponent = (props) => { {isNew && ( - Configure a Git Integration with a self-managed instance of GitLab, GitHub, or Bitbucket Server. + Configure a Git Integration with a self-managed instance of GitLab, GitHub{" "} + {supportAzureDevOps ? ", Bitbucket Server or Azure DevOps" : "or Bitbucket"}. )} @@ -235,10 +267,11 @@ export const GitIntegrationModal: FunctionComponent = (props) => { topMargin={false} onChange={(val) => setType(getNumber(val))} > - - - - + {availableProviderOptions.map((option) => ( + + ))} = (props) => { + {type === AuthProviderType.AZURE_DEVOPS && ( + <> + + + + )} + { return "bitbucket.org"; case AuthProviderType.BITBUCKET_SERVER: return "bitbucket.example.com"; + case AuthProviderType.AZURE_DEVOPS: + return "dev.azure.com"; default: return ""; } @@ -337,6 +391,9 @@ const RedirectUrlDescription: FunctionComponent = ( case AuthProviderType.BITBUCKET_SERVER: docsUrl = "https://www.gitpod.io/docs/configure/authentication/bitbucket-server"; break; + case AuthProviderType.AZURE_DEVOPS: + docsUrl = "https://www.gitpod.io/docs/configure/authentication/azure-devops"; + break; default: return null; } diff --git a/components/dashboard/src/user-settings/AuthEntryItem.tsx b/components/dashboard/src/user-settings/AuthEntryItem.tsx index e076726ceb5aac..7c457ede4aee2e 100644 --- a/components/dashboard/src/user-settings/AuthEntryItem.tsx +++ b/components/dashboard/src/user-settings/AuthEntryItem.tsx @@ -9,6 +9,7 @@ import { ContextMenuEntry } from "../components/ContextMenu"; import { Item, ItemFieldIcon, ItemField, ItemFieldContextMenu } from "../components/ItemsList"; import { AuthProviderDescription } from "@gitpod/public-api/lib/gitpod/v1/authprovider_pb"; import { toAuthProviderLabel } from "../provider-utils"; +import { getScopeNameForScope } from "@gitpod/public-api-common/lib/auth-providers"; interface AuthEntryItemParams { ap: AuthProviderDescription; @@ -53,7 +54,7 @@ export const AuthEntryItem = (props: AuthEntryItemParams) => { - {props.getPermissions(props.ap.id)?.join(", ") || "–"} + {props.getPermissions(props.ap.id)?.map(getScopeNameForScope)?.join(", ") || "–"} Permissions diff --git a/components/dashboard/src/user-settings/Integrations.tsx b/components/dashboard/src/user-settings/Integrations.tsx index 415606cbab470c..23486e4d211212 100644 --- a/components/dashboard/src/user-settings/Integrations.tsx +++ b/components/dashboard/src/user-settings/Integrations.tsx @@ -4,7 +4,12 @@ * See License.AGPL.txt in the project root for license information. */ -import { getRequiredScopes, getScopesForAuthProviderType } from "@gitpod/public-api-common/lib/auth-providers"; +import { + AzureDevOpsOAuthScopes, + getRequiredScopes, + getScopeNameForScope, + getScopesForAuthProviderType, +} from "@gitpod/public-api-common/lib/auth-providers"; import { SelectAccountPayload } from "@gitpod/gitpod-protocol/lib/auth"; import { useQuery } from "@tanstack/react-query"; import { useCallback, useContext, useEffect, useMemo, useState } from "react"; @@ -41,6 +46,7 @@ import { useDeleteUserAuthProviderMutation } from "../data/auth-providers/delete import { Button } from "@podkit/buttons/Button"; import { isOrganizationOwned } from "@gitpod/public-api-common/lib/user-utils"; import { InputWithCopy } from "../components/InputWithCopy"; +import { useAuthProviderOptionsQuery } from "../data/auth-providers/auth-provider-options-query"; export default function Integrations() { return ( @@ -89,6 +95,11 @@ const getDescriptionForScope = (scope: string) => { return "Allow creating, merging and declining pull requests (note: Bitbucket doesn't support revoking scopes)"; case "webhook": return "Allow installing webhooks (used when enabling prebuilds for a repository, note: Bitbucket doesn't support revoking scopes)"; + // Azure DevOps + case AzureDevOpsOAuthScopes.WRITE_REPO: + return "Code read and write permissions"; + case AzureDevOpsOAuthScopes.READ_USER: + return "Read user profile"; default: return ""; } @@ -333,7 +344,7 @@ function GitProviders() { (""); const [clientId, setClientId] = useState(""); const [clientSecret, setClientSecret] = useState(""); + const [authorizationUrl, setAuthorizationUrl] = useState(""); + const [tokenUrl, setTokenUrl] = useState(""); + const [busy, setBusy] = useState(false); const [errorMessage, setErrorMessage] = useState(); const [validationError, setValidationError] = useState(); @@ -563,6 +577,8 @@ export function GitIntegrationModal( const createProvider = useCreateUserAuthProviderMutation(); const updateProvider = useUpdateUserAuthProviderMutation(); + const availableProviderOptions = useAuthProviderOptionsQuery(false); + useEffect(() => { setMode(props.mode); if (props.mode === "edit") { @@ -571,6 +587,8 @@ export function GitIntegrationModal( setHost(props.provider.host); setClientId(props.provider.oauth2Config?.clientId || ""); setClientSecret(props.provider.oauth2Config?.clientSecret || ""); + setAuthorizationUrl(props.provider.oauth2Config?.authorizationUrl || ""); + setTokenUrl(props.provider.oauth2Config?.tokenUrl || ""); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -579,7 +597,7 @@ export function GitIntegrationModal( setErrorMessage(undefined); validate(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [clientId, clientSecret, type]); + }, [clientId, clientSecret, authorizationUrl, tokenUrl, type]); const onClose = () => props.onClose && props.onClose(); const onUpdate = () => props.onUpdate && props.onUpdate(); @@ -595,6 +613,8 @@ export function GitIntegrationModal( provider: { clientId, clientSecret, + authorizationUrl, + tokenUrl, type, host, userId: props.userId, @@ -606,6 +626,8 @@ export function GitIntegrationModal( id: providerEntry?.id || "", clientId, clientSecret: clientSecret === "redacted" ? "" : clientSecret, + authorizationUrl, + tokenUrl, }, }); } @@ -682,6 +704,12 @@ export function GitIntegrationModal( const updateClientSecret = (value: string) => { setClientSecret(value.trim()); }; + const updateAuthorizationUrl = (value: string) => { + setAuthorizationUrl(value.trim()); + }; + const updateTokenUrl = (value: string) => { + setTokenUrl(value.trim()); + }; const validate = () => { const errors: string[] = []; @@ -691,6 +719,14 @@ export function GitIntegrationModal( if (clientSecret.trim().length === 0) { errors.push(`${type === AuthProviderType.GITLAB ? "Secret" : "Client Secret"} is missing.`); } + if (type === AuthProviderType.AZURE_DEVOPS) { + if (authorizationUrl.trim().length === 0) { + errors.push("Authorization URL is missing."); + } + if (tokenUrl.trim().length === 0) { + errors.push("Token URL is missing."); + } + } if (errors.length === 0) { setValidationError(undefined); return true; @@ -701,6 +737,22 @@ export function GitIntegrationModal( }; const getRedirectUrlDescription = (type: AuthProviderType, host: string) => { + if (type === AuthProviderType.AZURE_DEVOPS) { + return ( + + Use this redirect URI to update the OAuth application and set it up.  + + Learn more + + . + + ); + } let settingsUrl = ``; switch (type) { case AuthProviderType.GITHUB: @@ -759,6 +811,8 @@ export function GitIntegrationModal( return "bitbucket.org"; case AuthProviderType.BITBUCKET_SERVER: return "bitbucket.example.com"; + case AuthProviderType.AZURE_DEVOPS: + return "dev.azure.com"; default: return ""; } @@ -809,9 +863,11 @@ export function GitIntegrationModal( className="w-full" onChange={(e) => setType(getNumber(e.target.value))} > - - - + {availableProviderOptions.map((options) => ( + + ))} )} @@ -849,6 +905,30 @@ export function GitIntegrationModal( {getRedirectUrlDescription(type, host)} + {type === AuthProviderType.AZURE_DEVOPS && ( + <> +
+ + updateAuthorizationUrl(e.target.value)} + /> +
+
+ + updateTokenUrl(e.target.value)} + /> +
+ + )}