feat(integrations/vercel): custom environments support

This commit is contained in:
Daniel Hougaard
2025-01-27 23:08:47 +01:00
parent a93bfa69c9
commit 10c10642a1
10 changed files with 255 additions and 24 deletions

View File

@@ -1151,6 +1151,50 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider)
}
});
server.route({
method: "GET",
url: "/:integrationAuthId/vercel/custom-environments",
config: {
rateLimit: readLimit
},
onRequest: verifyAuth([AuthMode.JWT]),
schema: {
querystring: z.object({
teamId: z.string().trim()
}),
params: z.object({
integrationAuthId: z.string().trim()
}),
response: {
200: z.object({
environments: z
.object({
appId: z.string(),
customEnvironments: z
.object({
id: z.string(),
slug: z.string()
})
.array()
})
.array()
})
}
},
handler: async (req) => {
const environments = await server.services.integrationAuth.getVercelCustomEnvironments({
actorId: req.permission.id,
actor: req.permission.type,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
id: req.params.integrationAuthId,
teamId: req.query.teamId
});
return { environments };
}
});
server.route({
method: "GET",
url: "/:integrationAuthId/octopus-deploy/spaces",

View File

@@ -132,16 +132,26 @@ const getAppsHeroku = async ({ accessToken }: { accessToken: string }) => {
/**
* Return list of names of apps for Vercel integration
* This is re-used for getting custom environments for Vercel
*/
const getAppsVercel = async ({ accessToken, teamId }: { teamId?: string | null; accessToken: string }) => {
const apps: Array<{ name: string; appId: string }> = [];
export const getAppsVercel = async ({ accessToken, teamId }: { teamId?: string | null; accessToken: string }) => {
const apps: Array<{ name: string; appId: string; customEnvironments: Array<{ slug: string; id: string }> }> = [];
const limit = "20";
let hasMorePages = true;
let next: number | null = null;
interface Response {
projects: { name: string; id: string }[];
projects: {
name: string;
id: string;
customEnvironments?: {
id: string;
type: string;
description: string;
slug: string;
}[];
}[];
pagination: {
count: number;
next: number | null;
@@ -170,10 +180,19 @@ const getAppsVercel = async ({ accessToken, teamId }: { teamId?: string | null;
}
});
for (const project of data.projects) {
console.log(project.customEnvironments);
}
data.projects.forEach((a) => {
apps.push({
name: a.name,
appId: a.id
appId: a.id,
customEnvironments:
a.customEnvironments?.map((env) => ({
slug: env.slug,
id: env.id
})) ?? []
});
});

View File

@@ -25,11 +25,12 @@ import { TIntegrationDALFactory } from "../integration/integration-dal";
import { TKmsServiceFactory } from "../kms/kms-service";
import { KmsDataKey } from "../kms/kms-types";
import { TProjectBotServiceFactory } from "../project-bot/project-bot-service";
import { getApps } from "./integration-app-list";
import { getApps, getAppsVercel } from "./integration-app-list";
import { TCircleCIContext } from "./integration-app-types";
import { TIntegrationAuthDALFactory } from "./integration-auth-dal";
import { IntegrationAuthMetadataSchema, TIntegrationAuthMetadata } from "./integration-auth-schema";
import {
GetVercelCustomEnvironmentsDTO,
OctopusDeployScope,
TBitbucketEnvironment,
TBitbucketWorkspace,
@@ -1825,6 +1826,41 @@ export const integrationAuthServiceFactory = ({
return integrationAuthDAL.create(newIntegrationAuth);
};
const getVercelCustomEnvironments = async ({
actorId,
actor,
actorOrgId,
actorAuthMethod,
teamId,
id
}: GetVercelCustomEnvironmentsDTO) => {
const integrationAuth = await integrationAuthDAL.findById(id);
if (!integrationAuth) throw new NotFoundError({ message: `Integration auth with ID '${id}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: integrationAuth.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.SecretManager
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations);
const { botKey, shouldUseSecretV2Bridge } = await projectBotService.getBotKey(integrationAuth.projectId);
const { accessToken } = await getIntegrationAccessToken(integrationAuth, shouldUseSecretV2Bridge, botKey);
const vercelApps = await getAppsVercel({
accessToken,
teamId
});
return vercelApps.map((app) => ({
customEnvironments: app.customEnvironments,
appId: app.appId
}));
};
const getOctopusDeploySpaces = async ({
actorId,
actor,
@@ -1944,6 +1980,7 @@ export const integrationAuthServiceFactory = ({
getIntegrationAccessToken,
duplicateIntegrationAuth,
getOctopusDeploySpaces,
getOctopusDeployScopeValues
getOctopusDeployScopeValues,
getVercelCustomEnvironments
};
};

View File

@@ -284,3 +284,8 @@ export type TOctopusDeployVariableSet = {
Self: string;
};
};
export type GetVercelCustomEnvironmentsDTO = {
teamId: string;
id: string;
} & Omit<TProjectPermission, "projectId">;

View File

@@ -1450,9 +1450,13 @@ const syncSecretsVercel = async ({
secrets: Record<string, { value: string; comment?: string } | null>;
accessToken: string;
}) => {
const isCustomEnvironment = !["development", "preview", "production"].includes(
integration.targetEnvironment as string
);
interface VercelSecret {
id?: string;
type: string;
customEnvironmentIds?: string[];
key: string;
value: string;
target: string[];
@@ -1486,6 +1490,16 @@ const syncSecretsVercel = async ({
}
)
).data.envs.filter((secret) => {
if (isCustomEnvironment) {
if (!secret.customEnvironmentIds?.includes(integration.targetEnvironment as string)) {
// case: secret does not have the same custom environment
return false;
}
// no need to check for preview environment, as custom environments are not available in preview
return true;
}
if (!secret.target.includes(integration.targetEnvironment as string)) {
// case: secret does not have the same target environment
return false;
@@ -1583,7 +1597,13 @@ const syncSecretsVercel = async ({
key,
value: infisicalSecrets[key]?.value,
type: "encrypted",
target: [integration.targetEnvironment as string],
...(isCustomEnvironment
? {
customEnvironmentIds: [integration.targetEnvironment as string]
}
: {
target: [integration.targetEnvironment as string]
}),
...(integration.path
? {
gitBranch: integration.path
@@ -1607,9 +1627,19 @@ const syncSecretsVercel = async ({
key,
value: infisicalSecrets[key]?.value,
type: res[key].type,
target: res[key].target.includes(integration.targetEnvironment as string)
? [...res[key].target]
: [...res[key].target, integration.targetEnvironment as string],
...(!isCustomEnvironment
? {
target: res[key].target.includes(integration.targetEnvironment as string)
? [...res[key].target]
: [...res[key].target, integration.targetEnvironment as string]
}
: {
customEnvironmentIds: res[key].customEnvironmentIds?.includes(integration.targetEnvironment as string)
? [...res[key].customEnvironmentIds]
: [...(res[key]?.customEnvironmentIds || []), integration.targetEnvironment as string]
}),
...(integration.path
? {
gitBranch: integration.path

View File

@@ -5,6 +5,7 @@ import { request } from "@app/lib/config/request";
import { BadRequestError, ForbiddenRequestError, InternalServerError, NotFoundError } from "@app/lib/errors";
import { Integrations, IntegrationUrls } from "./integration-list";
import { AxiosError } from "axios";
type ExchangeCodeAzureResponse = {
token_type: string;
@@ -197,15 +198,22 @@ const exchangeCodeVercel = async ({ code }: { code: string }) => {
}
const res = (
await request.post<ExchangeCodeVercelResponse>(
IntegrationUrls.VERCEL_TOKEN_URL,
new URLSearchParams({
code,
client_id: appCfg.CLIENT_ID_VERCEL,
client_secret: appCfg.CLIENT_SECRET_VERCEL,
redirect_uri: `${appCfg.SITE_URL}/integrations/vercel/oauth2/callback`
await request
.post<ExchangeCodeVercelResponse>(
IntegrationUrls.VERCEL_TOKEN_URL,
new URLSearchParams({
code,
client_id: appCfg.CLIENT_ID_VERCEL,
client_secret: appCfg.CLIENT_SECRET_VERCEL,
redirect_uri: `${appCfg.SITE_URL}/integrations/vercel/oauth2/callback`
})
)
.catch((e) => {
if (e instanceof AxiosError) {
console.log(e.response?.data);
}
throw e;
})
)
).data;
return {

View File

@@ -16,5 +16,6 @@ export {
useGetIntegrationAuthTeamCityBuildConfigs,
useGetIntegrationAuthTeams,
useGetIntegrationAuthVercelBranches,
useGetIntegrationAuthVercelCustomEnvironments,
useSaveIntegrationAccessToken
} from "./queries";

View File

@@ -21,7 +21,8 @@ import {
Team,
TeamCityBuildConfig,
TGetIntegrationAuthOctopusDeployScopeValuesDTO,
TOctopusDeployVariableSetScopeValues
TOctopusDeployVariableSetScopeValues,
VercelEnvironment
} from "./types";
const integrationAuthKeys = {
@@ -132,7 +133,9 @@ const integrationAuthKeys = {
}: TGetIntegrationAuthOctopusDeployScopeValuesDTO) =>
[{ integrationAuthId }, "getIntegrationAuthOctopusDeployScopeValues", params] as const,
getIntegrationAuthCircleCIOrganizations: (integrationAuthId: string) =>
[{ integrationAuthId }, "getIntegrationAuthCircleCIOrganizations"] as const
[{ integrationAuthId }, "getIntegrationAuthCircleCIOrganizations"] as const,
getIntegrationAuthVercelCustomEnv: (integrationAuthId: string, teamId: string) =>
[{ integrationAuthId, teamId }, "integrationAuthVercelCustomEnv"] as const
};
const fetchIntegrationAuthById = async (integrationAuthId: string) => {
@@ -362,6 +365,29 @@ const fetchIntegrationAuthQoveryScopes = async ({
return undefined;
};
const fetchIntegrationAuthVercelCustomEnvironments = async ({
integrationAuthId,
teamId
}: {
integrationAuthId: string;
teamId: string;
}) => {
const {
data: { environments }
} = await apiRequest.get<{
environments: {
appId: string;
customEnvironments: VercelEnvironment[];
}[];
}>(`/api/v1/integration-auth/${integrationAuthId}/vercel/custom-environments`, {
params: {
teamId
}
});
return environments;
};
const fetchIntegrationAuthHerokuPipelines = async ({
integrationAuthId
}: {
@@ -730,6 +756,24 @@ export const useGetIntegrationAuthQoveryScopes = ({
});
};
export const useGetIntegrationAuthVercelCustomEnvironments = ({
integrationAuthId,
teamId
}: {
integrationAuthId: string;
teamId: string;
}) => {
return useQuery({
queryKey: integrationAuthKeys.getIntegrationAuthVercelCustomEnv(integrationAuthId, teamId),
queryFn: () =>
fetchIntegrationAuthVercelCustomEnvironments({
integrationAuthId,
teamId
}),
enabled: Boolean(teamId && integrationAuthId)
});
};
export const useGetIntegrationAuthHerokuPipelines = ({
integrationAuthId
}: {

View File

@@ -43,6 +43,11 @@ export type Environment = {
environmentId: string;
};
export type VercelEnvironment = {
id: string;
slug: string;
};
export type ChecklyGroup = {
name: string;
groupId: number;

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Helmet } from "react-helmet";
import {
faArrowUpRightFromSquare,
@@ -25,7 +25,8 @@ import { useCreateIntegration } from "@app/hooks/api";
import {
useGetIntegrationAuthApps,
useGetIntegrationAuthById,
useGetIntegrationAuthVercelBranches
useGetIntegrationAuthVercelBranches,
useGetIntegrationAuthVercelCustomEnvironments
} from "@app/hooks/api/integrationAuth";
import { IntegrationSyncBehavior } from "@app/hooks/api/integrations/types";
@@ -75,6 +76,11 @@ export const VercelConfigurePage = () => {
teamId: integrationAuth?.teamId as string
});
const { data: customEnvironments } = useGetIntegrationAuthVercelCustomEnvironments({
teamId: integrationAuth?.teamId as string,
integrationAuthId: integrationAuthId as string
});
const { data: branches } = useGetIntegrationAuthVercelBranches({
integrationAuthId: integrationAuthId as string,
appId: targetAppId
@@ -135,6 +141,31 @@ export const VercelConfigurePage = () => {
}
};
const selectedVercelEnvironments = useMemo(() => {
// Structure looks like:
// {appId: string, environments: [{id: string, name: string}]}[]
let selectedEnvironments = vercelEnvironments;
const environments = customEnvironments?.find(
(e) => e.appId === targetAppId
)?.customEnvironments;
if (environments && environments.length > 0) {
selectedEnvironments = [
...selectedEnvironments,
...environments.map((env) => ({
name: env.slug,
slug: env.id
}))
];
}
return selectedEnvironments;
}, [targetAppId, customEnvironments]);
console.log("selectedVercelEnvironments", selectedVercelEnvironments);
return integrationAuth &&
selectedSourceEnvironment &&
integrationAuthApps &&
@@ -210,7 +241,14 @@ export const VercelConfigurePage = () => {
>
<Select
value={targetAppId}
onValueChange={(val) => setTargetAppId(val)}
onValueChange={(val) => {
setTargetAppId(val);
// Reset the target environment if it's not a default environment
if (vercelEnvironments.every((env) => env.slug !== targetEnvironment)) {
setTargetEnvironment(vercelEnvironments[0].slug);
}
}}
className="w-full border border-mineshaft-500"
isDisabled={integrationAuthApps.length === 0}
>
@@ -236,7 +274,7 @@ export const VercelConfigurePage = () => {
onValueChange={(val) => setTargetEnvironment(val)}
className="w-full border border-mineshaft-500"
>
{vercelEnvironments.map((vercelEnvironment) => (
{selectedVercelEnvironments.map((vercelEnvironment) => (
<SelectItem
value={vercelEnvironment.slug}
key={`target-environment-${vercelEnvironment.slug}`}