diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index ec855168e..2e85783ec 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1782,6 +1782,12 @@ export const SecretSyncs = { org: "The ID of the Humanitec org to sync secrets to.", env: "The ID of the Humanitec environment to sync secrets to.", scope: "The Humanitec scope that secrets should be synced to." + }, + VERCEL: { + app: "The ID of the Vercel app to sync secrets to.", + appName: "The name of the Vercel app to sync secrets to.", + env: "The ID of the Vercel environment to sync secrets to.", + branch: "The branch to sync secrets to. Required for Preview environments." } } }; diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts index cf8181ec4..0052bb1e5 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -22,6 +22,7 @@ import { HumanitecConnectionListItemSchema, SanitizedHumanitecConnectionSchema } from "@app/services/app-connection/humanitec"; +import { SanitizedVercelConnectionSchema, VercelConnectionListItemSchema } from "@app/services/app-connection/vercel"; import { AuthMode } from "@app/services/auth/auth-type"; // can't use discriminated due to multiple schemas for certain apps @@ -32,7 +33,8 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedAzureKeyVaultConnectionSchema.options, ...SanitizedAzureAppConfigurationConnectionSchema.options, ...SanitizedDatabricksConnectionSchema.options, - ...SanitizedHumanitecConnectionSchema.options + ...SanitizedHumanitecConnectionSchema.options, + ...SanitizedVercelConnectionSchema.options ]); const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ @@ -42,7 +44,8 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ AzureKeyVaultConnectionListItemSchema, AzureAppConfigurationConnectionListItemSchema, DatabricksConnectionListItemSchema, - HumanitecConnectionListItemSchema + HumanitecConnectionListItemSchema, + VercelConnectionListItemSchema ]); export const registerAppConnectionRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts index c2b688a43..e0d7325ca 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -7,6 +7,7 @@ import { registerDatabricksConnectionRouter } from "./databricks-connection-rout import { registerGcpConnectionRouter } from "./gcp-connection-router"; import { registerGitHubConnectionRouter } from "./github-connection-router"; import { registerHumanitecConnectionRouter } from "./humanitec-connection-router"; +import { registerVercelConnectionRouter } from "./vercel-connection-router"; export * from "./app-connection-router"; @@ -18,5 +19,6 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { + registerAppConnectionEndpoints({ + app: AppConnection.Vercel, + server, + sanitizedResponseSchema: SanitizedVercelConnectionSchema, + createSchema: CreateVercelConnectionSchema, + updateSchema: UpdateVercelConnectionSchema + }); + + // The below endpoints are not exposed and for Infisical App use + server.route({ + method: "GET", + url: `/:connectionId/projects`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string(), + slug: z.string(), + apps: z + .object({ + id: z.string(), + name: z.string(), + envs: z + .object({ + key: z.string(), + value: z.string(), + type: z.string(), + target: z.array(z.string()).optional(), + gitBranch: z.string().optional(), + createdAt: z.number().optional(), + updatedAt: z.number().optional() + }) + .array() + .optional(), + previewBranches: z.array(z.string()).optional() + }) + .array() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + + const projects: VercelOrgWithApps[] = await server.services.appConnection.vercel.listProjects( + connectionId, + req.permission + ); + + return projects; + } + }); +}; diff --git a/backend/src/server/routes/v1/secret-sync-routers/index.ts b/backend/src/server/routes/v1/secret-sync-routers/index.ts index c342f3b73..f0a35b3c0 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -8,6 +8,7 @@ import { registerDatabricksSyncRouter } from "./databricks-sync-router"; import { registerGcpSyncRouter } from "./gcp-sync-router"; import { registerGitHubSyncRouter } from "./github-sync-router"; import { registerHumanitecSyncRouter } from "./humanitec-sync-router"; +import { registerVercelSyncRouter } from "./vercel-sync-router"; export * from "./secret-sync-router"; @@ -19,5 +20,6 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record { diff --git a/backend/src/server/routes/v1/secret-sync-routers/vercel-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/vercel-sync-router.ts new file mode 100644 index 000000000..e6e2f40c6 --- /dev/null +++ b/backend/src/server/routes/v1/secret-sync-routers/vercel-sync-router.ts @@ -0,0 +1,13 @@ +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { CreateVercelSyncSchema, UpdateVercelSyncSchema, VercelSyncSchema } from "@app/services/secret-sync/vercel"; + +import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints"; + +export const registerVercelSyncRouter = async (server: FastifyZodProvider) => + registerSyncSecretsEndpoints({ + destination: SecretSync.Vercel, + server, + responseSchema: VercelSyncSchema, + createSchema: CreateVercelSyncSchema, + updateSchema: UpdateVercelSyncSchema + }); diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 9da622541..cd8f2be69 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -5,7 +5,8 @@ export enum AppConnection { GCP = "gcp", AzureKeyVault = "azure-key-vault", AzureAppConfiguration = "azure-app-configuration", - Humanitec = "humanitec" + Humanitec = "humanitec", + Vercel = "vercel" } export enum AWSRegion { diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index fe5130ff1..4effa836f 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -40,6 +40,8 @@ import { HumanitecConnectionMethod, validateHumanitecConnectionCredentials } from "./humanitec"; +import { VercelConnectionMethod } from "./vercel"; +import { getVercelConnectionListItem, validateVercelConnectionCredentials } from "./vercel/vercel-connection-fns"; export const listAppConnectionOptions = () => { return [ @@ -49,7 +51,8 @@ export const listAppConnectionOptions = () => { getAzureKeyVaultConnectionListItem(), getAzureAppConfigurationConnectionListItem(), getDatabricksConnectionListItem(), - getHumanitecConnectionListItem() + getHumanitecConnectionListItem(), + getVercelConnectionListItem() ].sort((a, b) => a.name.localeCompare(b.name)); }; @@ -114,6 +117,8 @@ export const validateAppConnectionCredentials = async ( return validateAzureAppConfigurationConnectionCredentials(appConnection); case AppConnection.Humanitec: return validateHumanitecConnectionCredentials(appConnection); + case AppConnection.Vercel: + return validateVercelConnectionCredentials(appConnection); default: // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Unhandled App Connection ${app}`); @@ -138,6 +143,8 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => return "Service Principal"; case HumanitecConnectionMethod.API_TOKEN: return "API Token"; + case VercelConnectionMethod.API_TOKEN: + return "API Token"; default: // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Unhandled App Connection Method: ${method}`); diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index 8a6c65426..cc0f31b4b 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -7,5 +7,6 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.AzureKeyVault]: "Azure Key Vault", [AppConnection.AzureAppConfiguration]: "Azure App Configuration", [AppConnection.Databricks]: "Databricks", - [AppConnection.Humanitec]: "Humanitec" + [AppConnection.Humanitec]: "Humanitec", + [AppConnection.Vercel]: "Vercel" }; diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts index e2e55bba0..0d940d0f9 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -37,6 +37,8 @@ import { ValidateGitHubConnectionCredentialsSchema } from "./github"; import { githubConnectionService } from "./github/github-connection-service"; import { ValidateHumanitecConnectionCredentialsSchema } from "./humanitec"; import { humanitecConnectionService } from "./humanitec/humanitec-connection-service"; +import { ValidateVercelConnectionCredentialsSchema } from "./vercel"; +import { vercelConnectionService } from "./vercel/vercel-connection-service"; export type TAppConnectionServiceFactoryDep = { appConnectionDAL: TAppConnectionDALFactory; @@ -53,7 +55,8 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record { + return { + name: "Vercel" as const, + app: AppConnection.Vercel as const, + methods: Object.values(VercelConnectionMethod) as [VercelConnectionMethod.API_TOKEN] + }; +}; + +export const validateVercelConnectionCredentials = async (config: TVercelConnectionConfig) => { + const { credentials: inputCredentials } = config; + + let response: AxiosResponse | null = null; + + try { + response = await request.get(`${IntegrationUrls.VERCEL_API_URL}/v9/projects`, { + headers: { + Authorization: `Bearer ${inputCredentials.apiToken}` + } + }); + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to validate credentials: ${error.message || "Unknown error"}` + }); + } + throw new BadRequestError({ + message: "Unable to validate connection - verify credentials" + }); + } + + if (!response?.data) { + throw new InternalServerError({ + message: "Failed to get organizations: Response was empty" + }); + } + + return inputCredentials; +}; + +interface ApiResponse { + pagination?: { + count: number; + next: number; + }; + data: T[]; + [key: string]: unknown; +} + +async function fetchAllPages( + apiUrl: string, + apiToken: string, + initialParams: Record = {}, + dataPath?: string +): Promise { + const allItems: T[] = []; + let hasMoreItems = true; + let params: Record = { ...initialParams, limit: 100 }; + + while (hasMoreItems) { + try { + const response = await request.get>(apiUrl, { + params, + headers: { + Authorization: `Bearer ${apiToken}`, + "Accept-Encoding": "application/json" + } + }); + + if (!response?.data) { + throw new InternalServerError({ + message: `Failed to fetch data from ${apiUrl}: Response was empty or malformed` + }); + } + + let itemsData: T[]; + + if (dataPath && dataPath in response.data) { + itemsData = response.data[dataPath] as T[]; + } else { + itemsData = response.data.data; + } + + if (!Array.isArray(itemsData)) { + throw new InternalServerError({ + message: `Failed to fetch data from ${apiUrl}: Expected array but got ${typeof itemsData}` + }); + } + + allItems.push(...itemsData); + + if (response.data.pagination?.next) { + params = { ...params, since: response.data.pagination.next }; + } else { + hasMoreItems = false; + } + } catch (error) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to fetch data from ${apiUrl}: ${error.message || "Unknown error"}` + }); + } + throw error; + } + } + + return allItems; +} + +async function fetchOrgProjects(orgId: string, apiToken: string): Promise { + return fetchAllPages( + `${IntegrationUrls.VERCEL_API_URL}/v9/projects`, + apiToken, + { teamId: orgId }, + "projects" + ); +} + +async function fetchProjectEnvironments(projectId: string, apiToken: string): Promise { + return fetchAllPages( + `${IntegrationUrls.VERCEL_API_URL}/v9/projects/${projectId}/custom-environments`, + apiToken, + {}, + "environments" + ); +} + +async function fetchPreviewBranches(projectId: string, apiToken: string): Promise { + const { data } = await request.get( + `${IntegrationUrls.VERCEL_API_URL}/v1/integrations/git-branches`, + { + params: { + projectId + }, + headers: { + Authorization: `Bearer ${apiToken}`, + "Accept-Encoding": "application/json" + } + } + ); + return data.map((b) => b.ref); +} + +type VercelTeam = { + id: string; + name: string; + slug: string; +}; + +type VercelUserResponse = { + user: { + id: string; + name: string; + username: string; + }; +}; + +export const listProjects = async (appConnection: TVercelConnectionInput): Promise => { + const { credentials } = appConnection; + const { apiToken } = credentials; + + const orgs = await fetchAllPages(`${IntegrationUrls.VERCEL_API_URL}/v2/teams`, apiToken, {}, "teams"); + + const personalAccountResponse = await request.get(`${IntegrationUrls.VERCEL_API_URL}/v2/user`, { + headers: { + Authorization: `Bearer ${apiToken}`, + "Accept-Encoding": "application/json" + } + }); + + if (personalAccountResponse?.data?.user) { + const { user } = personalAccountResponse.data; + orgs.push({ + id: user.id, + name: user.name || "Personal Account", + slug: user.username || "personal" + }); + } + + const orgsWithApps: VercelOrgWithApps[] = []; + + const orgPromises = orgs.map(async (org) => { + try { + const projects = await fetchOrgProjects(org.id, apiToken); + + const enhancedProjectsPromises = projects.map(async (project) => { + try { + const [environments, previewBranches] = await Promise.all([ + fetchProjectEnvironments(project.id, apiToken), + fetchPreviewBranches(project.id, apiToken) + ]); + + return { + name: project.name, + id: project.id, + envs: environments, + previewBranches + }; + } catch (error) { + return { + name: project.name, + id: project.id, + envs: [], + previewBranches: [] + }; + } + }); + + const enhancedProjects = await Promise.all(enhancedProjectsPromises); + + return { + ...org, + apps: enhancedProjects + }; + } catch (error) { + return null; + } + }); + + const results = await Promise.all(orgPromises); + + results.forEach((result) => { + if (result !== null) { + orgsWithApps.push(result); + } + }); + + return orgsWithApps; +}; + +export const getProjectEnvironmentVariables = (project: VercelApp): Record => { + const envVars: Record = {}; + + if (!project.envs) return envVars; + + project.envs.forEach((env) => { + if (env.value && env.type !== "gitBranch") { + const { key, value } = env; + envVars[key] = value; + } + }); + + return envVars; +}; diff --git a/backend/src/services/app-connection/vercel/vercel-connection-schemas.ts b/backend/src/services/app-connection/vercel/vercel-connection-schemas.ts new file mode 100644 index 000000000..1e0b1b410 --- /dev/null +++ b/backend/src/services/app-connection/vercel/vercel-connection-schemas.ts @@ -0,0 +1,56 @@ +import z from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { VercelConnectionMethod } from "./vercel-connection-enums"; + +export const VercelConnectionAccessTokenCredentialsSchema = z.object({ + apiToken: z.string().trim().min(1, "API Token required") +}); + +const BaseVercelConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.Vercel) }); + +export const VercelConnectionSchema = BaseVercelConnectionSchema.extend({ + method: z.literal(VercelConnectionMethod.API_TOKEN), + credentials: VercelConnectionAccessTokenCredentialsSchema +}); + +export const SanitizedVercelConnectionSchema = z.discriminatedUnion("method", [ + BaseVercelConnectionSchema.extend({ + method: z.literal(VercelConnectionMethod.API_TOKEN), + credentials: VercelConnectionAccessTokenCredentialsSchema.pick({}) + }) +]); + +export const ValidateVercelConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z.literal(VercelConnectionMethod.API_TOKEN).describe(AppConnections?.CREATE(AppConnection.Vercel).method), + credentials: VercelConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.Vercel).credentials + ) + }) +]); + +export const CreateVercelConnectionSchema = ValidateVercelConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.Vercel) +); + +export const UpdateVercelConnectionSchema = z + .object({ + credentials: VercelConnectionAccessTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.Vercel).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Vercel)); + +export const VercelConnectionListItemSchema = z.object({ + name: z.literal("Vercel"), + app: z.literal(AppConnection.Vercel), + methods: z.nativeEnum(VercelConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/vercel/vercel-connection-service.ts b/backend/src/services/app-connection/vercel/vercel-connection-service.ts new file mode 100644 index 000000000..68e5215e9 --- /dev/null +++ b/backend/src/services/app-connection/vercel/vercel-connection-service.ts @@ -0,0 +1,29 @@ +import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { listProjects as getVercelProjects } from "./vercel-connection-fns"; +import { TVercelConnection } from "./vercel-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const vercelConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listProjects = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Vercel, connectionId, actor); + try { + const projects = await getVercelProjects(appConnection); + return projects; + } catch (error) { + logger.error(error, "Failed to establish connection with Vercel"); + return []; + } + }; + + return { + listProjects + }; +}; diff --git a/backend/src/services/app-connection/vercel/vercel-connection-types.ts b/backend/src/services/app-connection/vercel/vercel-connection-types.ts new file mode 100644 index 000000000..1711ab275 --- /dev/null +++ b/backend/src/services/app-connection/vercel/vercel-connection-types.ts @@ -0,0 +1,73 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateVercelConnectionSchema, + ValidateVercelConnectionCredentialsSchema, + VercelConnectionSchema +} from "./vercel-connection-schemas"; + +export type TVercelConnection = z.infer; + +export type TVercelConnectionInput = z.infer & { + app: AppConnection.Vercel; +}; + +export type TValidateVercelConnectionCredentials = typeof ValidateVercelConnectionCredentialsSchema; + +export type TVercelConnectionConfig = DiscriminativePick & { + orgId: string; +}; + +export type VercelTeam = { + id: string; + name: string; + slug: string; +}; + +export type VercelEnvironment = { + key: string; + value: string; + type: string; + target?: string[]; + gitBranch?: string; + createdAt?: number; + updatedAt?: number; +}; + +export type VercelAppMeta = { + githubCommitRef?: string; + githubCommitSha?: string; + githubCommitMessage?: string; + githubCommitAuthorName?: string; +}; + +export type VercelDeployment = { + id: string; + name: string; + url: string; + created: number; + meta?: VercelAppMeta; + target?: "production" | "preview" | "development"; +}; + +export type VercelApp = { + name: string; + id: string; + envs?: VercelEnvironment[]; + previewBranches?: string[]; +}; + +export type VercelOrgWithApps = VercelTeam & { + apps: VercelApp[]; +}; + +export type VercelUserResponse = { + user: { + id: string; + name: string; + username: string; + }; +}; diff --git a/backend/src/services/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index 006d033f5..0d1ff6dec 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -6,7 +6,8 @@ export enum SecretSync { AzureKeyVault = "azure-key-vault", AzureAppConfiguration = "azure-app-configuration", Databricks = "databricks", - Humanitec = "humanitec" + Humanitec = "humanitec", + Vercel = "vercel" } export enum SecretSyncInitialSyncBehavior { diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index 6c8a6d4df..6a224ce31 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -26,6 +26,7 @@ import { GCP_SYNC_LIST_OPTION } from "./gcp"; import { GcpSyncFns } from "./gcp/gcp-sync-fns"; import { HUMANITEC_SYNC_LIST_OPTION } from "./humanitec"; import { HumanitecSyncFns } from "./humanitec/humanitec-sync-fns"; +import { VERCEL_SYNC_LIST_OPTION, VercelSyncFns } from "./vercel"; const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.AWSParameterStore]: AWS_PARAMETER_STORE_SYNC_LIST_OPTION, @@ -35,7 +36,8 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.AzureKeyVault]: AZURE_KEY_VAULT_SYNC_LIST_OPTION, [SecretSync.AzureAppConfiguration]: AZURE_APP_CONFIGURATION_SYNC_LIST_OPTION, [SecretSync.Databricks]: DATABRICKS_SYNC_LIST_OPTION, - [SecretSync.Humanitec]: HUMANITEC_SYNC_LIST_OPTION + [SecretSync.Humanitec]: HUMANITEC_SYNC_LIST_OPTION, + [SecretSync.Vercel]: VERCEL_SYNC_LIST_OPTION }; export const listSecretSyncOptions = () => { @@ -121,6 +123,8 @@ export const SecretSyncFns = { }).syncSecrets(secretSync, secretMap); case SecretSync.Humanitec: return HumanitecSyncFns.syncSecrets(secretSync, secretMap); + case SecretSync.Vercel: + return VercelSyncFns.syncSecrets(secretSync, secretMap); default: throw new Error( `Unhandled sync destination for sync secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -165,6 +169,9 @@ export const SecretSyncFns = { case SecretSync.Humanitec: secretMap = await HumanitecSyncFns.getSecrets(secretSync); break; + case SecretSync.Vercel: + secretMap = await VercelSyncFns.getSecrets(secretSync); + break; default: throw new Error( `Unhandled sync destination for get secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -207,6 +214,8 @@ export const SecretSyncFns = { }).removeSecrets(secretSync, secretMap); case SecretSync.Humanitec: return HumanitecSyncFns.removeSecrets(secretSync, secretMap); + case SecretSync.Vercel: + return VercelSyncFns.removeSecrets(secretSync, secretMap); default: throw new Error( `Unhandled sync destination for remove secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index cd4125e1b..da39c330e 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -9,7 +9,8 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.AzureKeyVault]: "Azure Key Vault", [SecretSync.AzureAppConfiguration]: "Azure App Configuration", [SecretSync.Databricks]: "Databricks", - [SecretSync.Humanitec]: "Humanitec" + [SecretSync.Humanitec]: "Humanitec", + [SecretSync.Vercel]: "Vercel" }; export const SECRET_SYNC_CONNECTION_MAP: Record = { @@ -20,5 +21,6 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.AzureKeyVault]: AppConnection.AzureKeyVault, [SecretSync.AzureAppConfiguration]: AppConnection.AzureAppConfiguration, [SecretSync.Databricks]: AppConnection.Databricks, - [SecretSync.Humanitec]: AppConnection.Humanitec + [SecretSync.Humanitec]: AppConnection.Humanitec, + [SecretSync.Vercel]: AppConnection.Vercel }; diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index 2044c7c17..39440dccd 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -49,6 +49,7 @@ import { THumanitecSyncListItem, THumanitecSyncWithCredentials } from "./humanitec"; +import { TVercelSync, TVercelSyncInput, TVercelSyncListItem, TVercelSyncWithCredentials } from "./vercel"; export type TSecretSync = | TAwsParameterStoreSync @@ -58,7 +59,8 @@ export type TSecretSync = | TAzureKeyVaultSync | TAzureAppConfigurationSync | TDatabricksSync - | THumanitecSync; + | THumanitecSync + | TVercelSync; export type TSecretSyncWithCredentials = | TAwsParameterStoreSyncWithCredentials @@ -68,7 +70,8 @@ export type TSecretSyncWithCredentials = | TAzureKeyVaultSyncWithCredentials | TAzureAppConfigurationSyncWithCredentials | TDatabricksSyncWithCredentials - | THumanitecSyncWithCredentials; + | THumanitecSyncWithCredentials + | TVercelSyncWithCredentials; export type TSecretSyncInput = | TAwsParameterStoreSyncInput @@ -78,7 +81,8 @@ export type TSecretSyncInput = | TAzureKeyVaultSyncInput | TAzureAppConfigurationSyncInput | TDatabricksSyncInput - | THumanitecSyncInput; + | THumanitecSyncInput + | TVercelSyncInput; export type TSecretSyncListItem = | TAwsParameterStoreSyncListItem @@ -88,7 +92,8 @@ export type TSecretSyncListItem = | TAzureKeyVaultSyncListItem | TAzureAppConfigurationSyncListItem | TDatabricksSyncListItem - | THumanitecSyncListItem; + | THumanitecSyncListItem + | TVercelSyncListItem; export type TSyncOptionsConfig = { canImportSecrets: boolean; diff --git a/backend/src/services/secret-sync/vercel/index.ts b/backend/src/services/secret-sync/vercel/index.ts new file mode 100644 index 000000000..b8379b5d9 --- /dev/null +++ b/backend/src/services/secret-sync/vercel/index.ts @@ -0,0 +1,5 @@ +export * from "./vercel-sync-constants"; +export * from "./vercel-sync-enums"; +export * from "./vercel-sync-fns"; +export * from "./vercel-sync-schemas"; +export * from "./vercel-sync-types"; diff --git a/backend/src/services/secret-sync/vercel/vercel-sync-constants.ts b/backend/src/services/secret-sync/vercel/vercel-sync-constants.ts new file mode 100644 index 000000000..e5faf78a6 --- /dev/null +++ b/backend/src/services/secret-sync/vercel/vercel-sync-constants.ts @@ -0,0 +1,10 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types"; + +export const VERCEL_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "Vercel", + destination: SecretSync.Vercel, + connection: AppConnection.Vercel, + canImportSecrets: false +}; diff --git a/backend/src/services/secret-sync/vercel/vercel-sync-enums.ts b/backend/src/services/secret-sync/vercel/vercel-sync-enums.ts new file mode 100644 index 000000000..36c46985b --- /dev/null +++ b/backend/src/services/secret-sync/vercel/vercel-sync-enums.ts @@ -0,0 +1,12 @@ +export enum VercelSyncScope { + Application = "application", + Environment = "environment" +} + +export const VercelEnvironmentType = { + Development: "development", + Preview: "preview", + Production: "production" +} as const; + +export type VercelEnvironment = (typeof VercelEnvironmentType)[keyof typeof VercelEnvironmentType]; diff --git a/backend/src/services/secret-sync/vercel/vercel-sync-fns.ts b/backend/src/services/secret-sync/vercel/vercel-sync-fns.ts new file mode 100644 index 000000000..a98354e91 --- /dev/null +++ b/backend/src/services/secret-sync/vercel/vercel-sync-fns.ts @@ -0,0 +1,222 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { request } from "@app/lib/config/request"; +import { logger } from "@app/lib/logger"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; +import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; +import { SECRET_SYNC_NAME_MAP } from "@app/services/secret-sync/secret-sync-maps"; +import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; + +import { VercelEnvironmentType } from "./vercel-sync-enums"; +import { TVercelSyncWithCredentials, VercelApiSecret } from "./vercel-sync-types"; + +const getVercelSecrets = async (secretSync: TVercelSyncWithCredentials) => { + const { + destinationConfig, + connection: { + credentials: { apiToken } + } + } = secretSync; + + const params: { [key: string]: string } = { + decrypt: "true", + ...(destinationConfig.branch ? { gitBranch: destinationConfig.branch } : {}) + }; + + const { data } = await request.get<{ envs: VercelApiSecret[] }>( + `${IntegrationUrls.VERCEL_API_URL}/v9/projects/${destinationConfig.app}/env`, + { + params, + headers: { + Authorization: `Bearer ${apiToken}`, + "Accept-Encoding": "application/json" + } + } + ); + + const filteredSecrets = data.envs.filter((secret) => { + // For environment-specific filtering + if (secret.target.includes(destinationConfig.env)) { + // If it's preview environment with a branch specified + if ( + destinationConfig.env === VercelEnvironmentType.Preview && + destinationConfig.branch && + secret.gitBranch && + secret.gitBranch !== destinationConfig.branch + ) { + return false; + } + return true; + } + return false; + }); + + // For secrets of type "encrypted", we need to get their decrypted value + const secretsWithValues = await Promise.all( + filteredSecrets.map(async (secret) => { + if (secret.type === "encrypted") { + const { data: decryptedSecret } = await request.get( + `${IntegrationUrls.VERCEL_API_URL}/v9/projects/${destinationConfig.app}/env/${secret.id}`, + { + params, + headers: { + Authorization: `Bearer ${apiToken}`, + "Accept-Encoding": "application/json" + } + } + ); + return decryptedSecret as VercelApiSecret; + } + return secret; + }) + ); + + return secretsWithValues; +}; + +const deleteSecret = async (secretSync: TVercelSyncWithCredentials, vercelSecret: VercelApiSecret) => { + const { + destinationConfig, + connection: { + credentials: { apiToken } + } + } = secretSync; + + try { + await request.delete( + `${IntegrationUrls.VERCEL_API_URL}/v9/projects/${destinationConfig.app}/env/${vercelSecret.id}`, + { + headers: { + Authorization: `Bearer ${apiToken}`, + "Accept-Encoding": "application/json" + } + } + ); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: vercelSecret.key + }); + } +}; + +const createSecret = async (secretSync: TVercelSyncWithCredentials, secretMap: TSecretMap, key: string) => { + try { + const { + destinationConfig, + connection: { + credentials: { apiToken } + } + } = secretSync; + + await request.post( + `${IntegrationUrls.VERCEL_API_URL}/v10/projects/${destinationConfig.app}/env`, + { + key, + value: secretMap[key].value, + type: "encrypted", + target: [destinationConfig.env], + ...(destinationConfig.env === VercelEnvironmentType.Preview && destinationConfig.branch + ? { gitBranch: destinationConfig.branch } + : {}) + }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + "Accept-Encoding": "application/json" + } + } + ); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } +}; + +const updateSecret = async ( + secretSync: TVercelSyncWithCredentials, + secretMap: TSecretMap, + vercelSecret: VercelApiSecret +) => { + try { + const { + destinationConfig, + connection: { + credentials: { apiToken } + } + } = secretSync; + + // Only update if not sensitive type + if (vercelSecret.type !== "sensitive") { + await request.patch( + `${IntegrationUrls.VERCEL_API_URL}/v9/projects/${destinationConfig.app}/env/${vercelSecret.id}`, + { + key: vercelSecret.key, + value: secretMap[vercelSecret.key].value, + type: vercelSecret.type, + target: vercelSecret.target.includes(destinationConfig.env) + ? [...vercelSecret.target] + : [...vercelSecret.target, destinationConfig.env], + ...(destinationConfig.env === VercelEnvironmentType.Preview && destinationConfig.branch + ? { gitBranch: destinationConfig.branch } + : {}) + }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + "Accept-Encoding": "application/json" + } + } + ); + } else { + logger.info(`Vercel secret ${vercelSecret.key} is of type 'sensitive' and cannot be updated through the API`); + } + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: vercelSecret.key + }); + } +}; + +export const VercelSyncFns = { + syncSecrets: async (secretSync: TVercelSyncWithCredentials, secretMap: TSecretMap) => { + const vercelSecrets = await getVercelSecrets(secretSync); + const vercelSecretsMap = new Map(vercelSecrets.map((s) => [s.key, s])); + + // Create or update secrets + for await (const key of Object.keys(secretMap)) { + const existingSecret = vercelSecretsMap.get(key); + + if (!existingSecret) { + await createSecret(secretSync, secretMap, key); + } else if (existingSecret.value !== secretMap[key].value) { + await updateSecret(secretSync, secretMap, existingSecret); + } + } + + // Delete secrets if disableSecretDeletion is not set + if (secretSync.syncOptions.disableSecretDeletion) return; + + for await (const vercelSecret of vercelSecrets) { + if (!secretMap[vercelSecret.key]) { + await deleteSecret(secretSync, vercelSecret); + } + } + }, + + getSecrets: async (secretSync: TVercelSyncWithCredentials): Promise => { + throw new Error(`${SECRET_SYNC_NAME_MAP[secretSync.destination]} does not support importing secrets.`); + }, + + removeSecrets: async (secretSync: TVercelSyncWithCredentials, secretMap: TSecretMap) => { + const vercelSecrets = await getVercelSecrets(secretSync); + + for await (const vercelSecret of vercelSecrets) { + if (vercelSecret.key in secretMap) { + await deleteSecret(secretSync, vercelSecret); + } + } + } +}; diff --git a/backend/src/services/secret-sync/vercel/vercel-sync-schemas.ts b/backend/src/services/secret-sync/vercel/vercel-sync-schemas.ts new file mode 100644 index 000000000..94888be97 --- /dev/null +++ b/backend/src/services/secret-sync/vercel/vercel-sync-schemas.ts @@ -0,0 +1,63 @@ +import { z } from "zod"; + +import { SecretSyncs } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + BaseSecretSyncSchema, + GenericCreateSecretSyncFieldsSchema, + GenericUpdateSecretSyncFieldsSchema +} from "@app/services/secret-sync/secret-sync-schemas"; +import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; + +import { VercelEnvironmentType } from "./vercel-sync-enums"; + +const VercelSyncDestinationConfigSchema = z + .object({ + app: z.string().min(1, "App ID is required").describe(SecretSyncs.DESTINATION_CONFIG.VERCEL.app), + appName: z.string().min(1, "App Name is required").describe(SecretSyncs.DESTINATION_CONFIG.VERCEL.appName), + env: z + .enum([VercelEnvironmentType.Development, VercelEnvironmentType.Preview, VercelEnvironmentType.Production]) + .describe(SecretSyncs.DESTINATION_CONFIG.VERCEL.env), + branch: z.string().optional().describe(SecretSyncs.DESTINATION_CONFIG.VERCEL.branch) + }) + .refine( + (data) => { + if (data.env === VercelEnvironmentType.Preview) { + return !!data.branch && data.branch.trim().length > 0; + } + return true; + }, + { + message: "Branch is required for Preview environments", + path: ["branch"] + } + ); + +const VercelSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false }; + +export const VercelSyncSchema = BaseSecretSyncSchema(SecretSync.Vercel, VercelSyncOptionsConfig).extend({ + destination: z.literal(SecretSync.Vercel), + destinationConfig: VercelSyncDestinationConfigSchema +}); + +export const CreateVercelSyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.Vercel, + VercelSyncOptionsConfig +).extend({ + destinationConfig: VercelSyncDestinationConfigSchema +}); + +export const UpdateVercelSyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.Vercel, + VercelSyncOptionsConfig +).extend({ + destinationConfig: VercelSyncDestinationConfigSchema.optional() +}); + +export const VercelSyncListItemSchema = z.object({ + name: z.literal("Vercel"), + connection: z.literal(AppConnection.Vercel), + destination: z.literal(SecretSync.Vercel), + canImportSecrets: z.literal(false) +}); diff --git a/backend/src/services/secret-sync/vercel/vercel-sync-types.ts b/backend/src/services/secret-sync/vercel/vercel-sync-types.ts new file mode 100644 index 000000000..50abf4a82 --- /dev/null +++ b/backend/src/services/secret-sync/vercel/vercel-sync-types.ts @@ -0,0 +1,36 @@ +import z from "zod"; + +import { TVercelConnection } from "@app/services/app-connection/vercel"; + +import { CreateVercelSyncSchema, VercelSyncListItemSchema, VercelSyncSchema } from "./vercel-sync-schemas"; + +export type TVercelSyncListItem = z.infer; + +export type TVercelSync = z.infer; + +export type TVercelSyncInput = z.infer; + +export type TVercelSyncWithCredentials = TVercelSync & { + connection: TVercelConnection; +}; + +export type VercelSecret = { + description: string; + is_secret: boolean; + key: string; + source: "app" | "env"; + value: string; +}; + +export interface VercelApiSecret { + id: string; + key: string; + value: string; + type: string; + target: string[]; + gitBranch?: string; + createdAt?: number; + updatedAt?: number; + configurationId?: string; + system?: boolean; +} diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx index 973f8bf17..b2f9f1c2f 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx @@ -11,6 +11,7 @@ import { DatabricksSyncFields } from "./DatabricksSyncFields"; import { GcpSyncFields } from "./GcpSyncFields"; import { GitHubSyncFields } from "./GitHubSyncFields"; import { HumanitecSyncFields } from "./HumanitecSyncFields"; +import { VercelSyncFields } from "./VercelSyncFields"; export const SecretSyncDestinationFields = () => { const { watch } = useFormContext(); @@ -34,6 +35,8 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.Humanitec: return ; + case SecretSync.Vercel: + return ; default: throw new Error(`Unhandled Destination Config Field: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/VercelSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/VercelSyncFields.tsx new file mode 100644 index 000000000..1fa36e0d0 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/VercelSyncFields.tsx @@ -0,0 +1,175 @@ +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { SingleValue } from "react-select"; +import { faCircleInfo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { FilterableSelect, FormControl, Tooltip } from "@app/components/v2"; +import { + TVercelConnectionApp, + useVercelConnectionListOrganizations +} from "@app/hooks/api/appConnections/vercel"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; + +const vercelEnvironments = [ + { name: "Development", slug: "development" }, + { name: "Preview", slug: "preview" }, + { name: "Production", slug: "production" } +]; + +export const VercelSyncFields = () => { + const { control, watch, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.Vercel } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + const currentApp = watch("destinationConfig.app"); + const currentEnv = watch("destinationConfig.env"); + + const { data: projects, isLoading: isProjectsLoading } = useVercelConnectionListOrganizations( + connectionId, + { + enabled: Boolean(connectionId) + } + ); + + const selectedProject = projects + ?.find((project) => project.apps.some((app) => app.id === currentApp)) + ?.apps.find((app) => app.id === currentApp); + + const allApps = projects?.flatMap((project) => project.apps) || []; + + const environmentOptions = vercelEnvironments.map((env) => ({ + key: env.slug, + type: env.slug, + name: env.name + })); + + const previewBranchOptions = + selectedProject?.previewBranches?.map((branch) => ({ + id: branch, + name: branch + })) || []; + + const isPreviewEnvironment = currentEnv === "preview"; + + return ( + <> + { + setValue("destinationConfig.app", ""); + setValue("destinationConfig.appName", ""); + setValue("destinationConfig.env", "production"); + setValue("destinationConfig.branch", ""); + }} + /> + + ( + +
+ Don't see the project you're looking for?{" "} + +
+ + } + > + app.id === value) ?? null} + onChange={(option) => { + const appId = (option as SingleValue)?.id ?? null; + onChange(appId); + setValue("destinationConfig.branch", ""); + setValue( + "destinationConfig.appName", + (option as SingleValue)?.name || "" + ); + }} + options={allApps} + placeholder="Select a project..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id.toString()} + /> +
+ )} + /> + + ( + + env.slug === value)?.name || value + } + : null + } + onChange={(option) => { + const envKey = (option as any)?.key ?? null; + onChange(envKey); + + setValue("destinationConfig.branch", ""); + }} + options={environmentOptions} + placeholder="Select an environment..." + getOptionLabel={(option) => option.name || option.key} + getOptionValue={(option) => option.key} + /> + + )} + /> + + {isPreviewEnvironment && ( + ( + + branch.id === value) ?? null} + onChange={(option) => onChange((option as SingleValue<{ id: string }>)?.id || "")} + options={previewBranchOptions} + placeholder="Select a branch..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option?.id || ""} + /> + + )} + /> + )} + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index b14bce809..eb1b72b51 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -39,6 +39,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.AzureAppConfiguration: case SecretSync.Databricks: case SecretSync.Humanitec: + case SecretSync.Vercel: AdditionalSyncOptionsFieldsComponent = null; break; default: diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index 040944298..3d8221242 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -21,6 +21,7 @@ import { DatabricksSyncReviewFields } from "./DatabricksSyncReviewFields"; import { GcpSyncReviewFields } from "./GcpSyncReviewFields"; import { GitHubSyncReviewFields } from "./GitHubSyncReviewFields"; import { HumanitecSyncReviewFields } from "./HumanitecSyncReviewFields"; +import { VercelSyncReviewFields } from "./VercelSyncReviewFields"; export const SecretSyncReviewFields = () => { const { watch } = useFormContext(); @@ -72,6 +73,9 @@ export const SecretSyncReviewFields = () => { case SecretSync.Humanitec: DestinationFieldsComponent = ; break; + case SecretSync.Vercel: + DestinationFieldsComponent = ; + break; default: throw new Error(`Unhandled Destination Review Fields: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/VercelSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/VercelSyncReviewFields.tsx new file mode 100644 index 000000000..24db07906 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/VercelSyncReviewFields.tsx @@ -0,0 +1,23 @@ +import { useFormContext } from "react-hook-form"; + +import { SecretSyncLabel } from "@app/components/secret-syncs"; +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { VercelEnvironmentType } from "@app/hooks/api/secretSyncs/types/vercel-sync"; + +export const VercelSyncReviewFields = () => { + const { watch } = useFormContext(); + const envId = watch("destinationConfig.env"); + const branchId = watch("destinationConfig.branch"); + const appName = watch("destinationConfig.appName"); + + return ( + <> + {appName} + {envId} + {envId === VercelEnvironmentType.Preview && branchId && ( + {branchId} + )} + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts index be2322304..fa723b5e0 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts @@ -9,6 +9,7 @@ import { AzureAppConfigurationSyncDestinationSchema } from "./azure-app-configur import { AzureKeyVaultSyncDestinationSchema } from "./azure-key-vault-sync-destination-schema"; import { GcpSyncDestinationSchema } from "./gcp-sync-destination-schema"; import { HumanitecSyncDestinationSchema } from "./humanitec-sync-destination-schema"; +import { VercelSyncDestinationSchema } from "./vercel-sync-destination-schema"; const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ AwsParameterStoreSyncDestinationSchema, @@ -18,7 +19,8 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ AzureKeyVaultSyncDestinationSchema, AzureAppConfigurationSyncDestinationSchema, DatabricksSyncDestinationSchema, - HumanitecSyncDestinationSchema + HumanitecSyncDestinationSchema, + VercelSyncDestinationSchema ]); export const SecretSyncFormSchema = SecretSyncUnionSchema; diff --git a/frontend/src/components/secret-syncs/forms/schemas/vercel-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/vercel-sync-destination-schema.ts new file mode 100644 index 000000000..234e7fa66 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/vercel-sync-destination-schema.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; + +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { VercelEnvironmentType } from "@app/hooks/api/secretSyncs/types/vercel-sync"; + +export const VercelSyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.Vercel), + destinationConfig: z.object({ + app: z.string().trim().min(1, "Project required"), + appName: z.string().trim().min(1, "Project required"), + env: z.enum( + [ + VercelEnvironmentType.Development, + VercelEnvironmentType.Preview, + VercelEnvironmentType.Production + ], + { + required_error: "Environment is required" + } + ), + branch: z.string().trim().optional() + }) + }) +); diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index cf643218b..c54610033 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -12,6 +12,7 @@ import { } from "@app/hooks/api/appConnections/types"; import { DatabricksConnectionMethod } from "@app/hooks/api/appConnections/types/databricks-connection"; import { HumanitecConnectionMethod } from "@app/hooks/api/appConnections/types/humanitec-connection"; +import { VercelConnectionMethod } from "@app/hooks/api/appConnections/types/vercel-connection"; export const APP_CONNECTION_MAP: Record = { [AppConnection.AWS]: { name: "AWS", image: "Amazon Web Services.png" }, @@ -26,7 +27,8 @@ export const APP_CONNECTION_MAP: Record { @@ -47,6 +49,8 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) return { name: "Service Principal", icon: faUser }; case HumanitecConnectionMethod.API_TOKEN: return { name: "API Token", icon: faKey }; + case VercelConnectionMethod.API_TOKEN: + return { name: "Service API Token", icon: faKey }; default: throw new Error(`Unhandled App Connection Method: ${method}`); } diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index 1cc076cce..91dc06465 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -23,6 +23,10 @@ export const SECRET_SYNC_MAP: Record = { [SecretSync.AzureKeyVault]: AppConnection.AzureKeyVault, [SecretSync.AzureAppConfiguration]: AppConnection.AzureAppConfiguration, [SecretSync.Databricks]: AppConnection.Databricks, - [SecretSync.Humanitec]: AppConnection.Humanitec + [SecretSync.Humanitec]: AppConnection.Humanitec, + [SecretSync.Vercel]: AppConnection.Vercel }; export const SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP: Record< diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index c07808af9..b4e9d4812 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -5,5 +5,6 @@ export enum AppConnection { AzureKeyVault = "azure-key-vault", AzureAppConfiguration = "azure-app-configuration", Databricks = "databricks", - Humanitec = "humanitec" + Humanitec = "humanitec", + Vercel = "vercel" } diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index a323765f7..0c76298a2 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -38,6 +38,10 @@ export type THumanitecConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Humanitec; }; +export type TVercelConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.Vercel; +}; + export type TAppConnectionOption = | TAwsConnectionOption | TGitHubConnectionOption @@ -45,7 +49,8 @@ export type TAppConnectionOption = | TAzureAppConfigurationConnectionOption | TAzureKeyVaultConnectionOption | TDatabricksConnectionOption - | THumanitecConnectionOption; + | THumanitecConnectionOption + | TVercelConnectionOption; export type TAppConnectionOptionMap = { [AppConnection.AWS]: TAwsConnectionOption; @@ -55,4 +60,5 @@ export type TAppConnectionOptionMap = { [AppConnection.AzureAppConfiguration]: TAzureAppConfigurationConnectionOption; [AppConnection.Databricks]: TDatabricksConnectionOption; [AppConnection.Humanitec]: THumanitecConnectionOption; + [AppConnection.Vercel]: TVercelConnectionOption; }; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index db56839fc..1aa6bb9ec 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -8,6 +8,7 @@ import { THumanitecConnection } from "@app/hooks/api/appConnections/types/humani import { TAzureAppConfigurationConnection } from "./azure-app-configuration-connection"; import { TAzureKeyVaultConnection } from "./azure-key-vault-connection"; import { TGcpConnection } from "./gcp-connection"; +import { TVercelConnection } from "./vercel-connection"; export * from "./aws-connection"; export * from "./azure-app-configuration-connection"; @@ -23,7 +24,8 @@ export type TAppConnection = | TAzureKeyVaultConnection | TAzureAppConfigurationConnection | TDatabricksConnection - | THumanitecConnection; + | THumanitecConnection + | TVercelConnection; export type TAvailableAppConnection = Pick; @@ -58,4 +60,5 @@ export type TAppConnectionMap = { [AppConnection.AzureAppConfiguration]: TAzureAppConfigurationConnection; [AppConnection.Databricks]: TDatabricksConnection; [AppConnection.Humanitec]: THumanitecConnection; + [AppConnection.Vercel]: TVercelConnection; }; diff --git a/frontend/src/hooks/api/appConnections/types/vercel-connection.ts b/frontend/src/hooks/api/appConnections/types/vercel-connection.ts new file mode 100644 index 000000000..886f0e767 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/vercel-connection.ts @@ -0,0 +1,13 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum VercelConnectionMethod { + API_TOKEN = "api-token" +} + +export type TVercelConnection = TRootAppConnection & { app: AppConnection.Vercel } & { + method: VercelConnectionMethod.API_TOKEN; + credentials: { + apiToken: string; + }; +}; diff --git a/frontend/src/hooks/api/appConnections/vercel/index.ts b/frontend/src/hooks/api/appConnections/vercel/index.ts new file mode 100644 index 000000000..2c1906d36 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/vercel/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/vercel/queries.tsx b/frontend/src/hooks/api/appConnections/vercel/queries.tsx new file mode 100644 index 000000000..fa66adcdb --- /dev/null +++ b/frontend/src/hooks/api/appConnections/vercel/queries.tsx @@ -0,0 +1,37 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { appConnectionKeys } from "../queries"; +import { TVercelConnectionOrganization } from "./types"; + +const vercelConnectionKeys = { + all: [...appConnectionKeys.all, "vercel"] as const, + listOrganizations: (connectionId: string) => + [...vercelConnectionKeys.all, "organizations", connectionId] as const +}; + +export const useVercelConnectionListOrganizations = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TVercelConnectionOrganization[], + unknown, + TVercelConnectionOrganization[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: vercelConnectionKeys.listOrganizations(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/vercel/${connectionId}/projects` + ); + + return data; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/vercel/types.ts b/frontend/src/hooks/api/appConnections/vercel/types.ts new file mode 100644 index 000000000..82b48dc7f --- /dev/null +++ b/frontend/src/hooks/api/appConnections/vercel/types.ts @@ -0,0 +1,29 @@ +export type TVercelApp = { + id: string; + name: string; + envs: { id: string; name: string }[]; +}; + +export type TVercelConnectionEnvironment = { + key: string; + value: string; + type: string; + target?: string[]; + gitBranch?: string; + createdAt?: number; + updatedAt?: number; +}; + +export type TVercelConnectionApp = { + id: string; + name: string; + envs?: TVercelConnectionEnvironment[]; + previewBranches?: string[]; +}; + +export type TVercelConnectionOrganization = { + id: string; + name: string; + slug: string; + apps: TVercelConnectionApp[]; +}; diff --git a/frontend/src/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index 08accba16..6f1fcac88 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -6,7 +6,8 @@ export enum SecretSync { AzureKeyVault = "azure-key-vault", AzureAppConfiguration = "azure-app-configuration", Databricks = "databricks", - Humanitec = "humanitec" + Humanitec = "humanitec", + Vercel = "vercel" } export enum SecretSyncStatus { diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts index a90a8a3ef..01edd8193 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -9,6 +9,7 @@ import { TAzureAppConfigurationSync } from "./azure-app-configuration-sync"; import { TAzureKeyVaultSync } from "./azure-key-vault-sync"; import { TGcpSync } from "./gcp-sync"; import { THumanitecSync } from "./humanitec-sync"; +import { TVercelSync } from "./vercel-sync"; export type TSecretSyncOption = { name: string; @@ -24,7 +25,8 @@ export type TSecretSync = | TAzureKeyVaultSync | TAzureAppConfigurationSync | TDatabricksSync - | THumanitecSync; + | THumanitecSync + | TVercelSync; export type TListSecretSyncs = { secretSyncs: TSecretSync[] }; diff --git a/frontend/src/hooks/api/secretSyncs/types/vercel-sync.ts b/frontend/src/hooks/api/secretSyncs/types/vercel-sync.ts new file mode 100644 index 000000000..5a1869a3d --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/vercel-sync.ts @@ -0,0 +1,26 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; + +export const VercelEnvironmentType = { + Development: "development", + Preview: "preview", + Production: "production" +} as const; + +export type VercelEnvironment = (typeof VercelEnvironmentType)[keyof typeof VercelEnvironmentType]; + +export type TVercelSync = TRootSecretSync & { + destination: SecretSync.Vercel; + destinationConfig: { + app: string; + env: VercelEnvironment; + branch?: string; + appName?: string; + }; + connection: { + app: AppConnection.Vercel; + name: string; + id: string; + }; +}; diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx index fc3257841..3b6ea208d 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -16,6 +16,7 @@ import { DatabricksConnectionForm } from "./DatabricksConnectionForm"; import { GcpConnectionForm } from "./GcpConnectionForm"; import { GitHubConnectionForm } from "./GitHubConnectionForm"; import { HumanitecConnectionForm } from "./HumanitecConnectionForm"; +import { VercelConnectionForm } from "./VercelConnectionForm"; type FormProps = { onComplete: (appConnection: TAppConnection) => void; @@ -65,6 +66,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => { return ; case AppConnection.Humanitec: return ; + case AppConnection.Vercel: + return ; default: throw new Error(`Unhandled App ${app}`); } @@ -112,6 +115,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { return ; case AppConnection.Humanitec: return ; + case AppConnection.Vercel: + return ; default: throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`); } diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/VercelConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/VercelConnectionForm.tsx new file mode 100644 index 000000000..e47d50fd0 --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/VercelConnectionForm.tsx @@ -0,0 +1,135 @@ +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { + Button, + FormControl, + ModalClose, + SecretInput, + Select, + SelectItem +} from "@app/components/v2"; +import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { + TVercelConnection, + VercelConnectionMethod +} from "@app/hooks/api/appConnections/types/vercel-connection"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TVercelConnection; + onSubmit: (formData: FormData) => void; +}; + +const rootSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.Vercel) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.literal(VercelConnectionMethod.API_TOKEN), + credentials: z.object({ + apiToken: z.string().trim().min(1, "Service API Token required") + }) + }) +]); + +type FormData = z.infer; + +export const VercelConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.Vercel, + method: VercelConnectionMethod.API_TOKEN + } + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty } + } = form; + + return ( + +
+ {!isUpdate && } + ( + + + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx index c4dc564b0..7ccbbc545 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx @@ -8,6 +8,7 @@ import { DatabricksSyncDestinationCol } from "./DatabricksSyncDestinationCol"; import { GcpSyncDestinationCol } from "./GcpSyncDestinationCol"; import { GitHubSyncDestinationCol } from "./GitHubSyncDestinationCol"; import { HumanitecSyncDestinationCol } from "./HumanitecSyncDestinationCol"; +import { VercelSyncDestinationCol } from "./VercelSyncDestinationCol"; type Props = { secretSync: TSecretSync; @@ -31,6 +32,8 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => { return ; case SecretSync.Humanitec: return ; + case SecretSync.Vercel: + return ; default: throw new Error( `Unhandled Secret Sync Destination Col: ${(secretSync as TSecretSync).destination}` diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/VercelSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/VercelSyncDestinationCol.tsx new file mode 100644 index 000000000..a0c267a89 --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/VercelSyncDestinationCol.tsx @@ -0,0 +1,14 @@ +import { TVercelSync } from "@app/hooks/api/secretSyncs/types/vercel-sync"; + +import { getSecretSyncDestinationColValues } from "../helpers"; +import { SecretSyncTableCell } from "../SecretSyncTableCell"; + +type Props = { + secretSync: TVercelSync; +}; + +export const VercelSyncDestinationCol = ({ secretSync }: Props) => { + const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync); + + return ; +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts index 6eb3a04e3..941196f60 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts @@ -73,6 +73,10 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => { } secondaryText = `Organization - ${destinationConfig.org}`; break; + case SecretSync.Vercel: + primaryText = destinationConfig.appName || destinationConfig.app; + secondaryText = destinationConfig.env; + break; default: throw new Error(`Unhandled Destination Col Values ${destination}`); } diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx index c1dee10c2..a8f2e41a0 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx @@ -18,6 +18,7 @@ import { AzureAppConfigurationSyncDestinationSection } from "./AzureAppConfigura import { AzureKeyVaultSyncDestinationSection } from "./AzureKeyVaultSyncDestinationSection"; import { GcpSyncDestinationSection } from "./GcpSyncDestinationSection"; import { HumanitecSyncDestinationSection } from "./HumanitecSyncDestinationSection"; +import { VercelSyncDestinationSection } from "./VercelSyncDestinationSection"; type Props = { secretSync: TSecretSync; @@ -57,6 +58,9 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }: case SecretSync.Humanitec: DestinationComponents = ; break; + case SecretSync.Vercel: + DestinationComponents = ; + break; default: throw new Error(`Unhandled Destination Section components: ${destination}`); } diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/VercelSyncDestinationSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/VercelSyncDestinationSection.tsx new file mode 100644 index 000000000..3b204a828 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/VercelSyncDestinationSection.tsx @@ -0,0 +1,32 @@ +import { ReactNode } from "react"; + +import { SecretSyncLabel } from "@app/components/secret-syncs"; +import { TVercelSync, VercelEnvironmentType } from "@app/hooks/api/secretSyncs/types/vercel-sync"; + +type Props = { + secretSync: TVercelSync; +}; + +export const VercelSyncDestinationSection = ({ secretSync }: Props) => { + const { destinationConfig } = secretSync; + + let Components: ReactNode; + if (destinationConfig.env === VercelEnvironmentType.Preview && destinationConfig.branch) { + Components = ( + <> + {destinationConfig.app} + {destinationConfig.env} + {destinationConfig.branch} + + ); + } else { + Components = ( + <> + {destinationConfig.app} + {destinationConfig.env} + + ); + } + + return Components; +}; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx index 351cb70e1..0c552e220 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx @@ -48,6 +48,7 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) = case SecretSync.AzureAppConfiguration: case SecretSync.Databricks: case SecretSync.Humanitec: + case SecretSync.Vercel: AdditionalSyncOptionsComponent = null; break; default: