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 @@
+
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)}
+ />
+
+ >
+ )}