mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
Feat: Azure DevOps Integration
This commit is contained in:
@@ -98,6 +98,23 @@ export const dynamicSecretServiceFactory = ({
|
||||
if (!isConnected) throw new BadRequestError({ message: "Provider connection failed" });
|
||||
|
||||
const encryptedInput = infisicalSymmetricEncypt(JSON.stringify(inputs));
|
||||
|
||||
console.log("YES THIS IS WHERE WE ARE");
|
||||
|
||||
console.log({
|
||||
type: provider.type,
|
||||
version: 1,
|
||||
inputIV: encryptedInput.iv,
|
||||
inputTag: encryptedInput.tag,
|
||||
inputCiphertext: encryptedInput.ciphertext,
|
||||
algorithm: encryptedInput.algorithm,
|
||||
keyEncoding: encryptedInput.encoding,
|
||||
maxTTL,
|
||||
defaultTTL,
|
||||
folderId: folder.id,
|
||||
name
|
||||
});
|
||||
|
||||
const dynamicSecretCfg = await dynamicSecretDAL.create({
|
||||
type: provider.type,
|
||||
version: 1,
|
||||
@@ -111,6 +128,7 @@ export const dynamicSecretServiceFactory = ({
|
||||
folderId: folder.id,
|
||||
name
|
||||
});
|
||||
console.log("IT WORKED");
|
||||
return dynamicSecretCfg;
|
||||
};
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
|
||||
environmentsUsed: 0,
|
||||
identityLimit: null,
|
||||
identitiesUsed: 0,
|
||||
dynamicSecret: false,
|
||||
dynamicSecret: true,
|
||||
secretVersioning: true,
|
||||
pitRecovery: false,
|
||||
ipAllowlisting: false,
|
||||
|
||||
@@ -1030,6 +1030,26 @@ const getAppsCloud66 = async ({ accessToken }: { accessToken: string }) => {
|
||||
return apps;
|
||||
};
|
||||
|
||||
const getAppsAzureDevOps = async ({ accessToken, orgId }: { accessToken: string; orgId: string }) => {
|
||||
console.log({ accessToken, orgId });
|
||||
const res = (
|
||||
await request.get<{ count: number; value: Record<string, string>[] }>(
|
||||
`${IntegrationUrls.AZURE_DEVOPS_API_URL}/${orgId}/_apis/projects?api-version=7.2-preview.2`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Basic ${accessToken}`
|
||||
}
|
||||
}
|
||||
)
|
||||
).data;
|
||||
const apps = res.value.map((a) => ({
|
||||
name: a.name,
|
||||
appId: a.id
|
||||
}));
|
||||
|
||||
return apps;
|
||||
};
|
||||
|
||||
export const getApps = async ({
|
||||
integration,
|
||||
accessToken,
|
||||
@@ -1184,6 +1204,12 @@ export const getApps = async ({
|
||||
accessToken
|
||||
});
|
||||
|
||||
case Integrations.AZURE_DEVOPS:
|
||||
return getAppsAzureDevOps({
|
||||
accessToken,
|
||||
orgId: teamId as string // small hack to pass orgId as teamId
|
||||
});
|
||||
|
||||
default:
|
||||
throw new BadRequestError({ message: "integration not found" });
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TIntegrations } from "@app/db/schemas";
|
||||
import { TProjectPermission } from "@app/lib/types";
|
||||
|
||||
export type TGetIntegrationAuthDTO = {
|
||||
@@ -163,3 +164,13 @@ export type TTeamCityBuildConfig = {
|
||||
href: string;
|
||||
webUrl: string;
|
||||
};
|
||||
|
||||
export type TIntegrationsWithEnvironment = TIntegrations & {
|
||||
environment?:
|
||||
| {
|
||||
id?: string | null | undefined;
|
||||
name?: string | null | undefined;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
};
|
||||
|
||||
@@ -31,7 +31,8 @@ export enum Integrations {
|
||||
CLOUD_66 = "cloud-66",
|
||||
NORTHFLANK = "northflank",
|
||||
HASURA_CLOUD = "hasura-cloud",
|
||||
RUNDECK = "rundeck"
|
||||
RUNDECK = "rundeck",
|
||||
AZURE_DEVOPS = "azure-devops"
|
||||
}
|
||||
|
||||
export enum IntegrationType {
|
||||
@@ -88,6 +89,7 @@ export enum IntegrationUrls {
|
||||
CLOUD_66_API_URL = "https://app.cloud66.com/api",
|
||||
NORTHFLANK_API_URL = "https://api.northflank.com",
|
||||
HASURA_CLOUD_API_URL = "https://data.pro.hasura.io/v1/graphql",
|
||||
AZURE_DEVOPS_API_URL = "https://dev.azure.com",
|
||||
|
||||
GCP_SECRET_MANAGER_SERVICE_NAME = "secretmanager.googleapis.com",
|
||||
GCP_SECRET_MANAGER_URL = `https://${GCP_SECRET_MANAGER_SERVICE_NAME}`,
|
||||
@@ -378,6 +380,15 @@ export const getIntegrationOptions = async () => {
|
||||
type: "pat",
|
||||
clientId: "",
|
||||
docsLink: ""
|
||||
},
|
||||
{
|
||||
name: "Azure Devops",
|
||||
slug: "azure-devops",
|
||||
image: "Microsoft Azure.png",
|
||||
isAvailable: true,
|
||||
type: "pat",
|
||||
clientId: "",
|
||||
docsLink: ""
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ import sodium from "libsodium-wrappers";
|
||||
import isEqual from "lodash.isequal";
|
||||
import { z } from "zod";
|
||||
|
||||
import { SecretType, TIntegrationAuths, TIntegrations } from "@app/db/schemas";
|
||||
import { SecretType, TIntegrationAuths, TIntegrations, TSecrets } from "@app/db/schemas";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { request } from "@app/lib/config/request";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
@@ -35,6 +35,7 @@ import { TCreateManySecretsRawFn, TUpdateManySecretsRawFn } from "@app/services/
|
||||
|
||||
import { TIntegrationDALFactory } from "../integration/integration-dal";
|
||||
import { IntegrationMetadataSchema } from "../integration/integration-schema";
|
||||
import { TIntegrationsWithEnvironment } from "./integration-auth-types";
|
||||
import {
|
||||
IntegrationInitialSyncBehavior,
|
||||
IntegrationMappingBehavior,
|
||||
@@ -275,8 +276,8 @@ const syncSecretsAzureKeyVault = async ({
|
||||
};
|
||||
secrets: Record<string, { value: string; comment?: string }>;
|
||||
accessToken: string;
|
||||
createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise<Array<{ id: string }>>;
|
||||
updateManySecretsRawFn: (params: TUpdateManySecretsRawFn) => Promise<Array<{ id: string }>>;
|
||||
createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise<Array<TSecrets & { _id: string }>>;
|
||||
updateManySecretsRawFn: (params: TUpdateManySecretsRawFn) => Promise<Array<TSecrets & { _id: string }>>;
|
||||
}) => {
|
||||
interface GetAzureKeyVaultSecret {
|
||||
id: string; // secret URI
|
||||
@@ -966,8 +967,8 @@ const syncSecretsHeroku = async ({
|
||||
secrets,
|
||||
accessToken
|
||||
}: {
|
||||
createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise<Array<{ id: string }>>;
|
||||
updateManySecretsRawFn: (params: TUpdateManySecretsRawFn) => Promise<Array<{ id: string }>>;
|
||||
createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise<Array<TSecrets & { _id: string }>>;
|
||||
updateManySecretsRawFn: (params: TUpdateManySecretsRawFn) => Promise<Array<TSecrets & { _id: string }>>;
|
||||
integration: TIntegrations & {
|
||||
projectId: string;
|
||||
environment: {
|
||||
@@ -2075,6 +2076,116 @@ const syncSecretsTravisCI = async ({
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sync/push [secrets] to GitLab repo with name [integration.app]
|
||||
*/
|
||||
const syncSecretsAzureDevops = async ({
|
||||
integrationAuth,
|
||||
integration,
|
||||
secrets,
|
||||
accessToken
|
||||
}: {
|
||||
integrationAuth: TIntegrationAuths;
|
||||
integration: TIntegrationsWithEnvironment;
|
||||
secrets: Record<string, { value: string; comment?: string }>;
|
||||
accessToken: string;
|
||||
}) => {
|
||||
if (!integration.appId || !integration.app) {
|
||||
throw new Error("Azure DevOps: orgId and projectId are required");
|
||||
}
|
||||
if (!integration.environment || !integration.environment.name) {
|
||||
throw new Error("Azure DevOps: environment is required");
|
||||
}
|
||||
const headers = {
|
||||
Authorization: `Basic ${accessToken}`
|
||||
};
|
||||
const azureDevopsApiUrl = integrationAuth.url ? `${integrationAuth.url}` : IntegrationUrls.AZURE_DEVOPS_API_URL;
|
||||
|
||||
const getEnvGroupId = async (orgId: string, project: string, env: string) => {
|
||||
let groupId;
|
||||
const url: string | null =
|
||||
`${azureDevopsApiUrl}/${orgId}/${project}/_apis/distributedtask/variablegroups?api-version=7.2-preview.2`;
|
||||
|
||||
const response = await request.get(url, { headers });
|
||||
for (const group of response.data.value) {
|
||||
const groupName = group.name;
|
||||
if (groupName === env) {
|
||||
groupId = group.id;
|
||||
return { groupId, groupName };
|
||||
}
|
||||
}
|
||||
return { groupId: "", groupName: "" };
|
||||
};
|
||||
|
||||
const { groupId, groupName } = await getEnvGroupId(integration.app, integration.appId, integration.environment.name);
|
||||
|
||||
const variables: Record<string, { value: string }> = {};
|
||||
for await (const key of Object.keys(secrets)) {
|
||||
variables[key] = { value: secrets[key].value };
|
||||
}
|
||||
|
||||
if (!groupId) {
|
||||
// create new variable group if not present
|
||||
const url = `${azureDevopsApiUrl}/${integration.app}/_apis/distributedtask/variablegroups?api-version=7.2-preview.2`;
|
||||
const config = {
|
||||
method: "POST",
|
||||
url,
|
||||
data: {
|
||||
name: integration.environment.name,
|
||||
description: integration.environment.name,
|
||||
type: "Vsts",
|
||||
owner: "Library",
|
||||
variables,
|
||||
variableGroupProjectReferences: [
|
||||
{
|
||||
name: integration.environment.name,
|
||||
projectReference: {
|
||||
name: integration.appId
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
headers: {
|
||||
headers
|
||||
}
|
||||
};
|
||||
|
||||
const res = await request.post(url, config.data, config.headers);
|
||||
if (res.status !== 200) {
|
||||
throw new Error(`Azure DevOps: Failed to create variable group: ${res.statusText}`);
|
||||
}
|
||||
} else {
|
||||
// sync variables for pre-existing variable group
|
||||
const url = `${azureDevopsApiUrl}/${integration.app}/_apis/distributedtask/variablegroups/${groupId}?api-version=7.2-preview.2`;
|
||||
const config = {
|
||||
method: "PUT",
|
||||
url,
|
||||
data: {
|
||||
name: groupName,
|
||||
description: groupName,
|
||||
type: "Vsts",
|
||||
owner: "Library",
|
||||
variables,
|
||||
variableGroupProjectReferences: [
|
||||
{
|
||||
name: groupName,
|
||||
projectReference: {
|
||||
name: integration.appId
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
headers: {
|
||||
headers
|
||||
}
|
||||
};
|
||||
const res = await request.put(url, config.data, config.headers);
|
||||
if (res.status !== 200) {
|
||||
throw new Error(`Azure DevOps: Failed to update variable group: ${res.statusText}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sync/push [secrets] to GitLab repo with name [integration.app]
|
||||
*/
|
||||
@@ -2527,8 +2638,8 @@ const syncSecretsTerraformCloud = async ({
|
||||
accessToken,
|
||||
integrationDAL
|
||||
}: {
|
||||
createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise<Array<{ id: string }>>;
|
||||
updateManySecretsRawFn: (params: TUpdateManySecretsRawFn) => Promise<Array<{ id: string }>>;
|
||||
createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise<Array<TSecrets & { _id: string }>>;
|
||||
updateManySecretsRawFn: (params: TUpdateManySecretsRawFn) => Promise<Array<TSecrets & { _id: string }>>;
|
||||
integration: TIntegrations & {
|
||||
projectId: string;
|
||||
environment: {
|
||||
@@ -3675,8 +3786,8 @@ export const syncIntegrationSecrets = async ({
|
||||
appendices,
|
||||
projectId
|
||||
}: {
|
||||
createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise<Array<{ id: string }>>;
|
||||
updateManySecretsRawFn: (params: TUpdateManySecretsRawFn) => Promise<Array<{ id: string }>>;
|
||||
createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise<Array<TSecrets & { _id: string }>>;
|
||||
updateManySecretsRawFn: (params: TUpdateManySecretsRawFn) => Promise<Array<TSecrets & { _id: string }>>;
|
||||
integrationDAL: Pick<TIntegrationDALFactory, "updateById">;
|
||||
integration: TIntegrations & {
|
||||
projectId: string;
|
||||
@@ -3714,6 +3825,15 @@ export const syncIntegrationSecrets = async ({
|
||||
updateManySecretsRawFn
|
||||
});
|
||||
break;
|
||||
|
||||
case Integrations.AZURE_DEVOPS:
|
||||
await syncSecretsAzureDevops({
|
||||
integrationAuth,
|
||||
integration,
|
||||
secrets,
|
||||
accessToken
|
||||
});
|
||||
break;
|
||||
case Integrations.AWS_PARAMETER_STORE:
|
||||
response = await syncSecretsAWSParameterStore({
|
||||
integration,
|
||||
|
||||
@@ -33,7 +33,8 @@ const integrationSlugNameMapping: Mapping = {
|
||||
windmill: "Windmill",
|
||||
"gcp-secret-manager": "GCP Secret Manager",
|
||||
"hasura-cloud": "Hasura Cloud",
|
||||
rundeck: "Rundeck"
|
||||
rundeck: "Rundeck",
|
||||
"azure-devops": "Azure DevOps"
|
||||
};
|
||||
|
||||
const envMapping: Mapping = {
|
||||
|
||||
76
frontend/src/pages/integrations/azure-devops/authorize.tsx
Normal file
76
frontend/src/pages/integrations/azure-devops/authorize.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { useSaveIntegrationAccessToken } from "@app/hooks/api";
|
||||
|
||||
import { Button, Card, CardTitle, FormControl, Input } from "../../../components/v2";
|
||||
|
||||
export default function AzureDevopsCreateIntegrationPage() {
|
||||
const router = useRouter();
|
||||
const { mutateAsync } = useSaveIntegrationAccessToken();
|
||||
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [orgId, setOrgId] = useState("");
|
||||
const [apiKeyErrorText, setApiKeyErrorText] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleButtonClick = async () => {
|
||||
try {
|
||||
setApiKeyErrorText("");
|
||||
if (apiKey.length === 0) {
|
||||
setApiKeyErrorText("API Key cannot be blank");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
localStorage.setItem("azure-devops-org-id", orgId);
|
||||
|
||||
const integrationAuth = await mutateAsync({
|
||||
workspaceId: localStorage.getItem("projectData.id"),
|
||||
integration: "azure-devops",
|
||||
accessToken: btoa(`:${apiKey}`) // This is a base64 encoding of the API key without any username
|
||||
});
|
||||
|
||||
setIsLoading(false);
|
||||
|
||||
router.push(`/integrations/azure-devops/create?integrationAuthId=${integrationAuth.id}`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Card className="max-w-md rounded-md p-8">
|
||||
<CardTitle className="text-center">AzureDevops Integration</CardTitle>
|
||||
<FormControl
|
||||
label="AzureDevops API Token"
|
||||
errorText={apiKeyErrorText}
|
||||
isError={apiKeyErrorText !== "" ?? false}
|
||||
>
|
||||
<Input placeholder="" value={apiKey} onChange={(e) => setApiKey(e.target.value)} />
|
||||
</FormControl>
|
||||
<FormControl
|
||||
label="AzureDevops Organization Slug"
|
||||
tooltipText="This is not the organization ID, but the slug of the organization. An example would be 'my-acme-org'"
|
||||
errorText={apiKeyErrorText}
|
||||
isError={apiKeyErrorText !== "" ?? false}
|
||||
>
|
||||
<Input placeholder="" value={orgId} onChange={(e) => setOrgId(e.target.value)} />
|
||||
</FormControl>
|
||||
|
||||
<Button
|
||||
onClick={handleButtonClick}
|
||||
color="mineshaft"
|
||||
className="mt-4"
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Connect to AzureDevops
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
AzureDevopsCreateIntegrationPage.requireAuth = true;
|
||||
150
frontend/src/pages/integrations/azure-devops/create.tsx
Normal file
150
frontend/src/pages/integrations/azure-devops/create.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import queryString from "query-string";
|
||||
|
||||
import { useCreateIntegration } from "@app/hooks/api";
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardTitle,
|
||||
FormControl,
|
||||
Input,
|
||||
Select,
|
||||
SelectItem
|
||||
} from "../../../components/v2";
|
||||
import {
|
||||
useGetIntegrationAuthApps,
|
||||
useGetIntegrationAuthById
|
||||
} from "../../../hooks/api/integrationAuth";
|
||||
import { useGetWorkspaceById } from "../../../hooks/api/workspace";
|
||||
|
||||
export default function AzureDevopsCreateIntegrationPage() {
|
||||
const router = useRouter();
|
||||
const { mutateAsync } = useCreateIntegration();
|
||||
|
||||
const { integrationAuthId } = queryString.parse(router.asPath.split("?")[1]);
|
||||
|
||||
const { data: workspace } = useGetWorkspaceById(localStorage.getItem("projectData.id") ?? "");
|
||||
const { data: integrationAuth } = useGetIntegrationAuthById((integrationAuthId as string) ?? "");
|
||||
const { data: integrationAuthApps } = useGetIntegrationAuthApps({
|
||||
integrationAuthId: (integrationAuthId as string) ?? "",
|
||||
teamId: localStorage.getItem("azure-devops-org-id") ?? ""
|
||||
});
|
||||
|
||||
const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState("");
|
||||
const [secretPath, setSecretPath] = useState("/");
|
||||
const [targetApp, setTargetApp] = useState("");
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (workspace) {
|
||||
setSelectedSourceEnvironment(workspace.environments[0].slug);
|
||||
}
|
||||
}, [workspace]);
|
||||
|
||||
useEffect(() => {
|
||||
if (integrationAuthApps) {
|
||||
if (integrationAuthApps.length > 0) {
|
||||
setTargetApp(integrationAuthApps[0].name);
|
||||
} else {
|
||||
setTargetApp("none");
|
||||
}
|
||||
}
|
||||
}, [integrationAuthApps]);
|
||||
|
||||
const handleButtonClick = async () => {
|
||||
try {
|
||||
if (!integrationAuth?.id) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
await mutateAsync({
|
||||
integrationAuthId: integrationAuth?.id,
|
||||
isActive: true,
|
||||
app: localStorage.getItem("azure-devops-org-id") || "",
|
||||
appId: targetApp,
|
||||
sourceEnvironment: selectedSourceEnvironment,
|
||||
secretPath
|
||||
});
|
||||
|
||||
setIsLoading(false);
|
||||
|
||||
router.push(`/integrations/${localStorage.getItem("projectData.id")}`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
return integrationAuth &&
|
||||
workspace &&
|
||||
selectedSourceEnvironment &&
|
||||
integrationAuthApps &&
|
||||
targetApp ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Card className="max-w-md rounded-md p-8">
|
||||
<CardTitle className="text-center">AzureDevops Integration</CardTitle>
|
||||
<FormControl label="Project Environment" className="mt-4">
|
||||
<Select
|
||||
value={selectedSourceEnvironment}
|
||||
onValueChange={(val) => setSelectedSourceEnvironment(val)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
>
|
||||
{workspace?.environments.map((sourceEnvironment) => (
|
||||
<SelectItem
|
||||
value={sourceEnvironment.slug}
|
||||
key={`source-environment-${sourceEnvironment.slug}`}
|
||||
>
|
||||
{sourceEnvironment.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl label="Secrets Path">
|
||||
<Input
|
||||
value={secretPath}
|
||||
onChange={(evt) => setSecretPath(evt.target.value)}
|
||||
placeholder="Provide a path, default is /"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl label="AzureDevops Project" className="mt-4">
|
||||
<Select
|
||||
value={targetApp}
|
||||
onValueChange={(val) => setTargetApp(val)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
isDisabled={integrationAuthApps.length === 0}
|
||||
>
|
||||
{integrationAuthApps.length > 0 ? (
|
||||
integrationAuthApps.map((integrationAuthApp) => (
|
||||
<SelectItem
|
||||
value={integrationAuthApp.name}
|
||||
key={`target-environment-${integrationAuthApp.name}`}
|
||||
>
|
||||
{integrationAuthApp.name}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem value="none" key="target-app-none">
|
||||
No projects found
|
||||
</SelectItem>
|
||||
)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Button
|
||||
onClick={handleButtonClick}
|
||||
color="mineshaft"
|
||||
className="mt-4"
|
||||
isLoading={isLoading}
|
||||
isDisabled={integrationAuthApps.length === 0}
|
||||
>
|
||||
Create Integration
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
);
|
||||
}
|
||||
|
||||
AzureDevopsCreateIntegrationPage.requireAuth = true;
|
||||
@@ -131,6 +131,9 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) =>
|
||||
case "rundeck":
|
||||
link = `${window.location.origin}/integrations/rundeck/authorize`;
|
||||
break;
|
||||
case "azure-devops":
|
||||
link = `${window.location.origin}/integrations/azure-devops/authorize`;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user