diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index b0be8c519..2f365b6d4 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1705,6 +1705,9 @@ export const AppConnections = { sslEnabled: "Whether or not to use SSL when connecting to the database.", sslRejectUnauthorized: "Whether or not to reject unauthorized SSL certificates.", sslCertificate: "The SSL certificate to use for connection." + }, + VERCEL: { + apiToken: "The API token used to authenticate with Vercel." } } }; @@ -1820,6 +1823,13 @@ 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 preview secrets to.", + teamId: "The ID of the Vercel team to sync secrets to." } } }; 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 2cb5d6db9..adfce8c86 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 @@ -27,6 +27,7 @@ import { PostgresConnectionListItemSchema, SanitizedPostgresConnectionSchema } from "@app/services/app-connection/postgres"; +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 @@ -38,6 +39,7 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedAzureAppConfigurationConnectionSchema.options, ...SanitizedDatabricksConnectionSchema.options, ...SanitizedHumanitecConnectionSchema.options, + ...SanitizedVercelConnectionSchema.options, ...SanitizedPostgresConnectionSchema.options, ...SanitizedMsSqlConnectionSchema.options ]); @@ -50,6 +52,7 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ AzureAppConfigurationConnectionListItemSchema, DatabricksConnectionListItemSchema, HumanitecConnectionListItemSchema, + VercelConnectionListItemSchema, PostgresConnectionListItemSchema, MsSqlConnectionListItemSchema ]); 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 906ffaee9..fe9fbe5e3 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -9,6 +9,7 @@ import { registerGitHubConnectionRouter } from "./github-connection-router"; import { registerHumanitecConnectionRouter } from "./humanitec-connection-router"; import { registerMsSqlConnectionRouter } from "./mssql-connection-router"; import { registerPostgresConnectionRouter } from "./postgres-connection-router"; +import { registerVercelConnectionRouter } from "./vercel-connection-router"; export * from "./app-connection-router"; @@ -21,6 +22,7 @@ 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({ + id: z.string(), + slug: z.string(), + type: z.string(), + target: z.array(z.string()).optional(), + description: 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 f5f921c4e..b5bd9eaa2 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -6,6 +6,7 @@ export enum AppConnection { AzureKeyVault = "azure-key-vault", AzureAppConfiguration = "azure-app-configuration", Humanitec = "humanitec", + Vercel = "vercel", Postgres = "postgres", MsSql = "mssql" } diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index b2d45e71f..cc7146151 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -41,6 +41,8 @@ import { } from "./humanitec"; import { getMsSqlConnectionListItem, MsSqlConnectionMethod } from "./mssql"; import { getPostgresConnectionListItem, PostgresConnectionMethod } from "./postgres"; +import { VercelConnectionMethod } from "./vercel"; +import { getVercelConnectionListItem, validateVercelConnectionCredentials } from "./vercel/vercel-connection-fns"; export const listAppConnectionOptions = () => { return [ @@ -51,6 +53,7 @@ export const listAppConnectionOptions = () => { getAzureAppConfigurationConnectionListItem(), getDatabricksConnectionListItem(), getHumanitecConnectionListItem(), + getVercelConnectionListItem(), getPostgresConnectionListItem(), getMsSqlConnectionListItem() ].sort((a, b) => a.name.localeCompare(b.name)); @@ -108,7 +111,8 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record case DatabricksConnectionMethod.ServicePrincipal: return "Service Principal"; case HumanitecConnectionMethod.ApiToken: + case VercelConnectionMethod.ApiToken: return "API Token"; case PostgresConnectionMethod.UsernameAndPassword: case MsSqlConnectionMethod.UsernameAndPassword: @@ -175,5 +180,6 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.AzureAppConfiguration]: platformManagedCredentialsNotSupported, [AppConnection.Humanitec]: platformManagedCredentialsNotSupported, [AppConnection.Postgres]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform, - [AppConnection.MsSql]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform + [AppConnection.MsSql]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform, + [AppConnection.Vercel]: platformManagedCredentialsNotSupported }; diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index eb28070d5..a39b3d1eb 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -8,6 +8,7 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.AzureAppConfiguration]: "Azure App Configuration", [AppConnection.Databricks]: "Databricks", [AppConnection.Humanitec]: "Humanitec", + [AppConnection.Vercel]: "Vercel", [AppConnection.Postgres]: "PostgreSQL", [AppConnection.MsSql]: "Microsoft SQL Server" }; diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts index 978f3bfd7..1803d7127 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -41,6 +41,8 @@ import { ValidateHumanitecConnectionCredentialsSchema } from "./humanitec"; import { humanitecConnectionService } from "./humanitec/humanitec-connection-service"; import { ValidateMsSqlConnectionCredentialsSchema } from "./mssql"; import { ValidatePostgresConnectionCredentialsSchema } from "./postgres"; +import { ValidateVercelConnectionCredentialsSchema } from "./vercel"; +import { vercelConnectionService } from "./vercel/vercel-connection-service"; export type TAppConnectionServiceFactoryDep = { appConnectionDAL: TAppConnectionDALFactory; @@ -58,6 +60,7 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record { + return { + name: "Vercel" as const, + app: AppConnection.Vercel as const, + methods: Object.values(VercelConnectionMethod) as [VercelConnectionMethod.ApiToken] + }; +}; + +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, + teamId: string, + apiToken: string +): Promise { + try { + return await fetchAllPages( + `${IntegrationUrls.VERCEL_API_URL}/v9/projects/${projectId}/custom-environments?teamId=${teamId}`, + apiToken, + {}, + "environments" + ); + } catch (error) { + return []; + } +} + +async function fetchPreviewBranches(projectId: string, apiToken: string): Promise { + try { + 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.filter((b) => b.ref !== "main").map((b) => b.ref); + } catch (error) { + return []; + } +} + +type VercelTeam = { + id: string; + name: string; + slug: string; +}; + +type VercelUserResponse = { + user: { + id: string; + name: string; + username: string; + }; +}; + +export const listProjects = async (appConnection: TVercelConnection): 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.name, org.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.slug && env.type !== "gitBranch") { + const { id, slug } = env; + envVars[id] = slug; + } + }); + + 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..60baa4f5c --- /dev/null +++ b/backend/src/services/app-connection/vercel/vercel-connection-schemas.ts @@ -0,0 +1,58 @@ +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").describe(AppConnections.CREDENTIALS.VERCEL.apiToken) +}); + +const BaseVercelConnectionSchema = BaseAppConnectionSchema.extend({ + app: z.literal(AppConnection.Vercel) +}); + +export const VercelConnectionSchema = BaseVercelConnectionSchema.extend({ + method: z.literal(VercelConnectionMethod.ApiToken), + credentials: VercelConnectionAccessTokenCredentialsSchema +}); + +export const SanitizedVercelConnectionSchema = z.discriminatedUnion("method", [ + BaseVercelConnectionSchema.extend({ + method: z.literal(VercelConnectionMethod.ApiToken), + credentials: VercelConnectionAccessTokenCredentialsSchema.pick({}) + }) +]); + +export const ValidateVercelConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z.literal(VercelConnectionMethod.ApiToken).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..4ab69d1df --- /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 TValidateVercelConnectionCredentialsSchema = typeof ValidateVercelConnectionCredentialsSchema; + +export type TVercelConnectionConfig = DiscriminativePick & { + orgId: string; +}; + +export type VercelTeam = { + id: string; + name: string; + slug: string; +}; + +export type VercelEnvironment = { + id: string; + slug: 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 bd28e1ee7..d36f3ff05 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..60b3eb00a --- /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: true +}; 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..713971283 --- /dev/null +++ b/backend/src/services/secret-sync/vercel/vercel-sync-fns.ts @@ -0,0 +1,313 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { request } from "@app/lib/config/request"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; +import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; +import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; + +import { VercelEnvironmentType } from "./vercel-sync-enums"; +import { DefaultVercelEnvType, TVercelSyncWithCredentials, VercelApiSecret } from "./vercel-sync-types"; + +function isVercelDefaultEnvType(value: string): value is DefaultVercelEnvType { + return Object.values(VercelEnvironmentType).map(String).includes(value); +} + +const MAX_RETRIES = 5; + +const sleep = async () => + new Promise((resolve) => { + setTimeout(resolve, 60000); + }); + +const getVercelSecretsWithRetries = async ( + secretSync: TVercelSyncWithCredentials, + attempt = 0 +): Promise => { + const { + destinationConfig, + connection: { + credentials: { apiToken } + } + } = secretSync; + + const params: { [key: string]: string } = { + decrypt: "true", + ...(destinationConfig.branch ? { gitBranch: destinationConfig.branch } : {}) + }; + try { + const { data } = await request.get<{ envs: VercelApiSecret[] }>( + `${IntegrationUrls.VERCEL_API_URL}/v9/projects/${destinationConfig.app}/env?teamId=${destinationConfig.teamId}`, + { + params, + headers: { + Authorization: `Bearer ${apiToken}`, + "Accept-Encoding": "application/json" + } + } + ); + return data.envs; + } catch (error) { + if ((error as { response: { status: number } }).response.status === 429 && attempt < MAX_RETRIES) { + await sleep(); + return await getVercelSecretsWithRetries(secretSync, attempt + 1); + } + throw error; + } +}; + +const getDecryptedVercelSecret = async ( + secretSync: TVercelSyncWithCredentials, + secret: VercelApiSecret, + attempt = 0 +): Promise => { + const { + destinationConfig, + connection: { + credentials: { apiToken } + } + } = secretSync; + + const params: { [key: string]: string } = { + decrypt: "true", + ...(destinationConfig.branch ? { gitBranch: destinationConfig.branch } : {}) + }; + + try { + const { data: decryptedSecret } = await request.get( + `${IntegrationUrls.VERCEL_API_URL}/v9/projects/${destinationConfig.app}/env/${secret.id}?teamId=${destinationConfig.teamId}`, + { + params, + headers: { + Authorization: `Bearer ${apiToken}`, + "Accept-Encoding": "application/json" + } + } + ); + + return decryptedSecret as VercelApiSecret; + } catch (error) { + if ((error as { response: { status: number } }).response.status === 429 && attempt < MAX_RETRIES) { + await sleep(); + return await getDecryptedVercelSecret(secretSync, secret, attempt + 1); + } + throw error; + } +}; + +const getVercelSecrets = async (secretSync: TVercelSyncWithCredentials): Promise => { + const { destinationConfig } = secretSync; + + const secrets = await getVercelSecretsWithRetries(secretSync); + + const filteredSecrets = secrets.filter((secret) => { + if (!isVercelDefaultEnvType(destinationConfig.env)) { + if (secret.customEnvironmentIds?.includes(destinationConfig.env)) { + return true; + } + return false; + } + 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 decryptedSecret = await getDecryptedVercelSecret(secretSync, secret); + return decryptedSecret; + } + return secret; + }) + ); + + return secretsWithValues; +}; + +const deleteSecret = async ( + secretSync: TVercelSyncWithCredentials, + vercelSecret: VercelApiSecret, + attempt = 0 +): Promise => { + const { + destinationConfig, + connection: { + credentials: { apiToken } + } + } = secretSync; + + try { + await request.delete( + `${IntegrationUrls.VERCEL_API_URL}/v9/projects/${destinationConfig.app}/env/${vercelSecret.id}?teamId=${destinationConfig.teamId}`, + { + headers: { + Authorization: `Bearer ${apiToken}`, + "Accept-Encoding": "application/json" + } + } + ); + } catch (error) { + if ((error as { response: { status: number } }).response.status === 429 && attempt < MAX_RETRIES) { + await sleep(); + return await deleteSecret(secretSync, vercelSecret, attempt + 1); + } + throw new SecretSyncError({ + error, + secretKey: vercelSecret.key + }); + } +}; + +const createSecret = async ( + secretSync: TVercelSyncWithCredentials, + secretMap: TSecretMap, + key: string, + attempt = 0 +): Promise => { + try { + const { + destinationConfig, + connection: { + credentials: { apiToken } + } + } = secretSync; + + await request.post( + `${IntegrationUrls.VERCEL_API_URL}/v10/projects/${destinationConfig.app}/env?teamId=${destinationConfig.teamId}`, + { + key, + value: secretMap[key].value, + type: "encrypted", + target: isVercelDefaultEnvType(destinationConfig.env) ? [destinationConfig.env] : [], + customEnvironmentIds: !isVercelDefaultEnvType(destinationConfig.env) ? [destinationConfig.env] : [], + ...(destinationConfig.env === VercelEnvironmentType.Preview && destinationConfig.branch + ? { gitBranch: destinationConfig.branch } + : {}) + }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + "Accept-Encoding": "application/json" + } + } + ); + } catch (error) { + if ((error as { response: { status: number } }).response.status === 429 && attempt < MAX_RETRIES) { + await sleep(); + return await createSecret(secretSync, secretMap, key, attempt + 1); + } + throw new SecretSyncError({ + error, + secretKey: key + }); + } +}; + +const updateSecret = async ( + secretSync: TVercelSyncWithCredentials, + secretMap: TSecretMap, + vercelSecret: VercelApiSecret, + attempt = 0 +): Promise => { + try { + const { + destinationConfig, + connection: { + credentials: { apiToken } + } + } = secretSync; + + let target = [...vercelSecret.target]; + if (isVercelDefaultEnvType(destinationConfig.env) && !vercelSecret.target.includes(destinationConfig.env)) { + target = [...target, destinationConfig.env]; + } + let customEnvironmentIds = [...(vercelSecret.customEnvironmentIds || [])]; + if ( + !isVercelDefaultEnvType(destinationConfig.env) && + !vercelSecret.customEnvironmentIds?.includes(destinationConfig.env) + ) { + customEnvironmentIds = [...customEnvironmentIds, destinationConfig.env]; + } + + await request.patch( + `${IntegrationUrls.VERCEL_API_URL}/v9/projects/${destinationConfig.app}/env/${vercelSecret.id}?teamId=${destinationConfig.teamId}`, + { + ...(vercelSecret.type !== "sensitive" && { key: vercelSecret.key }), + value: secretMap[vercelSecret.key].value, + type: vercelSecret.type, + target, + customEnvironmentIds, + ...(destinationConfig.env === VercelEnvironmentType.Preview && destinationConfig.branch + ? { gitBranch: destinationConfig.branch } + : {}) + }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + "Accept-Encoding": "application/json" + } + } + ); + } catch (error) { + if ((error as { response: { status: number } }).response.status === 429 && attempt < MAX_RETRIES) { + await sleep(); + return await updateSecret(secretSync, secretMap, vercelSecret, attempt + 1); + } + 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 => { + const vercelSecrets = await getVercelSecrets(secretSync); + return Object.fromEntries(vercelSecrets.map((s) => [s.key, { value: s.value ?? "" }])); + }, + + 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..84d7a6da4 --- /dev/null +++ b/backend/src/services/secret-sync/vercel/vercel-sync-schemas.ts @@ -0,0 +1,49 @@ +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.nativeEnum(VercelEnvironmentType).or(z.string()).describe(SecretSyncs.DESTINATION_CONFIG.VERCEL.env), + branch: z.string().optional().describe(SecretSyncs.DESTINATION_CONFIG.VERCEL.branch), + teamId: z.string().describe(SecretSyncs.DESTINATION_CONFIG.VERCEL.teamId) +}); + +const VercelSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; + +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(true) +}); 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..d6d2b6433 --- /dev/null +++ b/backend/src/services/secret-sync/vercel/vercel-sync-types.ts @@ -0,0 +1,40 @@ +import z from "zod"; + +import { TVercelConnection } from "@app/services/app-connection/vercel"; + +import { VercelEnvironmentType } from "./vercel-sync-enums"; +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[]; + customEnvironmentIds?: string[]; + gitBranch?: string; + createdAt?: number; + updatedAt?: number; + configurationId?: string; + system?: boolean; +} + +export type DefaultVercelEnvType = (typeof VercelEnvironmentType)[keyof typeof VercelEnvironmentType]; diff --git a/docs/api-reference/endpoints/app-connections/vercel/available.mdx b/docs/api-reference/endpoints/app-connections/vercel/available.mdx new file mode 100644 index 000000000..16859bded --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/vercel/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/vercel/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/vercel/create.mdx b/docs/api-reference/endpoints/app-connections/vercel/create.mdx new file mode 100644 index 000000000..63998ac32 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/vercel/create.mdx @@ -0,0 +1,9 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/vercel" +--- + + + Check out the configuration docs for [Vercel Connections](/integrations/app-connections/vercel) to learn how to obtain + the required credentials. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/vercel/delete.mdx b/docs/api-reference/endpoints/app-connections/vercel/delete.mdx new file mode 100644 index 000000000..4e5b12eff --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/vercel/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/vercel/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/vercel/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/vercel/get-by-id.mdx new file mode 100644 index 000000000..fdeb715a8 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/vercel/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/vercel/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/vercel/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/vercel/get-by-name.mdx new file mode 100644 index 000000000..258ed67c7 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/vercel/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/vercel/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/vercel/list.mdx b/docs/api-reference/endpoints/app-connections/vercel/list.mdx new file mode 100644 index 000000000..5412d35bb --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/vercel/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/vercel" +--- diff --git a/docs/api-reference/endpoints/app-connections/vercel/update.mdx b/docs/api-reference/endpoints/app-connections/vercel/update.mdx new file mode 100644 index 000000000..d0e2f4ae2 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/vercel/update.mdx @@ -0,0 +1,9 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/vercel/{connectionId}" +--- + + + Check out the configuration docs for [Vercel Connections](/integrations/app-connections/vercel) to learn how to obtain + the required credentials. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/secret-syncs/vercel/create.mdx b/docs/api-reference/endpoints/secret-syncs/vercel/create.mdx new file mode 100644 index 000000000..e14d6dddd --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/vercel/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/vercel" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/vercel/delete.mdx b/docs/api-reference/endpoints/secret-syncs/vercel/delete.mdx new file mode 100644 index 000000000..746e7ffe5 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/vercel/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/vercel/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/vercel/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/vercel/get-by-id.mdx new file mode 100644 index 000000000..9a4efd1e6 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/vercel/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/vercel/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/vercel/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/vercel/get-by-name.mdx new file mode 100644 index 000000000..3f71a6b3b --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/vercel/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/vercel/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/vercel/import-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/vercel/import-secrets.mdx new file mode 100644 index 000000000..807eb2850 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/vercel/import-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Import Secrets" +openapi: "POST /api/v1/secret-syncs/vercel/{syncId}/import-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/vercel/list.mdx b/docs/api-reference/endpoints/secret-syncs/vercel/list.mdx new file mode 100644 index 000000000..905470d0d --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/vercel/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/vercel" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/vercel/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/vercel/remove-secrets.mdx new file mode 100644 index 000000000..49c76ef99 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/vercel/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/vercel/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/vercel/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/vercel/sync-secrets.mdx new file mode 100644 index 000000000..2b3bc8324 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/vercel/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/vercel/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/vercel/update.mdx b/docs/api-reference/endpoints/secret-syncs/vercel/update.mdx new file mode 100644 index 000000000..75be8dd89 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/vercel/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/vercel/{syncId}" +--- diff --git a/docs/images/app-connections/vercel/vercel-app-connection-created.png b/docs/images/app-connections/vercel/vercel-app-connection-created.png new file mode 100644 index 000000000..8fb371440 Binary files /dev/null and b/docs/images/app-connections/vercel/vercel-app-connection-created.png differ diff --git a/docs/images/app-connections/vercel/vercel-app-connection-modal.png b/docs/images/app-connections/vercel/vercel-app-connection-modal.png new file mode 100644 index 000000000..6b789713d Binary files /dev/null and b/docs/images/app-connections/vercel/vercel-app-connection-modal.png differ diff --git a/docs/images/app-connections/vercel/vercel-app-connection-option.png b/docs/images/app-connections/vercel/vercel-app-connection-option.png new file mode 100644 index 000000000..b4308a1a2 Binary files /dev/null and b/docs/images/app-connections/vercel/vercel-app-connection-option.png differ diff --git a/docs/images/app-connections/vercel/vercel-copy-token.png b/docs/images/app-connections/vercel/vercel-copy-token.png new file mode 100644 index 000000000..d6491c02e Binary files /dev/null and b/docs/images/app-connections/vercel/vercel-copy-token.png differ diff --git a/docs/images/app-connections/vercel/vercel-create-token.png b/docs/images/app-connections/vercel/vercel-create-token.png new file mode 100644 index 000000000..c507f0d14 Binary files /dev/null and b/docs/images/app-connections/vercel/vercel-create-token.png differ diff --git a/docs/images/app-connections/vercel/vercel-main-page.png b/docs/images/app-connections/vercel/vercel-main-page.png new file mode 100644 index 000000000..e1a28248e Binary files /dev/null and b/docs/images/app-connections/vercel/vercel-main-page.png differ diff --git a/docs/images/app-connections/vercel/vercel-settings-page.png b/docs/images/app-connections/vercel/vercel-settings-page.png new file mode 100644 index 000000000..86e67d6e1 Binary files /dev/null and b/docs/images/app-connections/vercel/vercel-settings-page.png differ diff --git a/docs/images/app-connections/vercel/vercel-token-created.png b/docs/images/app-connections/vercel/vercel-token-created.png new file mode 100644 index 000000000..5e57a4bd8 Binary files /dev/null and b/docs/images/app-connections/vercel/vercel-token-created.png differ diff --git a/docs/images/secret-syncs/vercel/select-vercel-option.png b/docs/images/secret-syncs/vercel/select-vercel-option.png new file mode 100644 index 000000000..b63d33cc7 Binary files /dev/null and b/docs/images/secret-syncs/vercel/select-vercel-option.png differ diff --git a/docs/images/secret-syncs/vercel/vercel-created.png b/docs/images/secret-syncs/vercel/vercel-created.png new file mode 100644 index 000000000..fe955b00f Binary files /dev/null and b/docs/images/secret-syncs/vercel/vercel-created.png differ diff --git a/docs/images/secret-syncs/vercel/vercel-destination.png b/docs/images/secret-syncs/vercel/vercel-destination.png new file mode 100644 index 000000000..4d0f73d35 Binary files /dev/null and b/docs/images/secret-syncs/vercel/vercel-destination.png differ diff --git a/docs/images/secret-syncs/vercel/vercel-details.png b/docs/images/secret-syncs/vercel/vercel-details.png new file mode 100644 index 000000000..4421c426c Binary files /dev/null and b/docs/images/secret-syncs/vercel/vercel-details.png differ diff --git a/docs/images/secret-syncs/vercel/vercel-options.png b/docs/images/secret-syncs/vercel/vercel-options.png new file mode 100644 index 000000000..1d38e7ae6 Binary files /dev/null and b/docs/images/secret-syncs/vercel/vercel-options.png differ diff --git a/docs/images/secret-syncs/vercel/vercel-review.png b/docs/images/secret-syncs/vercel/vercel-review.png new file mode 100644 index 000000000..7a921f4ec Binary files /dev/null and b/docs/images/secret-syncs/vercel/vercel-review.png differ diff --git a/docs/images/secret-syncs/vercel/vercel-source.png b/docs/images/secret-syncs/vercel/vercel-source.png new file mode 100644 index 000000000..efb753aad Binary files /dev/null and b/docs/images/secret-syncs/vercel/vercel-source.png differ diff --git a/docs/integrations/app-connections/vercel.mdx b/docs/integrations/app-connections/vercel.mdx new file mode 100644 index 000000000..8ef4a5647 --- /dev/null +++ b/docs/integrations/app-connections/vercel.mdx @@ -0,0 +1,97 @@ +--- +title: "Vercel Connection" +description: "Learn how to configure a Vercel Connection for Infisical." +--- + +Infisical supports connecting to Vercel using an API Token to securely sync your secrets to Vercel. + +## Setup Vercel Connection in Infisical + + + + Navigate to the Vercel **Account Settings** page by clicking on your profile icon in the top-right corner. + ![Vercel API Tokens Tab](/images/app-connections/vercel/vercel-main-page.png) + + + Select the **API Tokens** tab from the left sidebar navigation menu. + ![Vercel API Tokens Tab](/images/app-connections/vercel/vercel-settings-page.png) + + + Click the **Create** button and provide a name for your token (e.g., "Infisical Integration"). + Choose appropriate scope permissions based on your requirements. + + If you configure an expiry date for your API token, you will need to manually rotate to a new token prior to expiration to avoid integration downtime. Consider setting a calendar reminder for this task. + + ![Vercel Create API Token](/images/app-connections/vercel/vercel-create-token.png) + + + After creation, a modal with the API token will be displayed. Copy this token immediately and store it securely, as you won't be able to view it again after closing this dialog. + ![Vercel Copy API Token](/images/app-connections/vercel/vercel-copy-token.png) + + + You should now see your newly created token in the list of API tokens on the Vercel dashboard. + ![Vercel Connection Created](/images/app-connections/vercel/vercel-token-created.png) + + + + + 1. Navigate to App Connections + + In your Infisical dashboard, go to **Organization Settings** and select the **App Connections** tab. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + 2. Add Connection + + Click the **+ Add Connection** button and select the **Vercel Connection** option from the available integrations. + ![Select Vercel Connection](/images/app-connections/vercel/vercel-app-connection-option.png) + 3. Fill the Vercel Connection Modal + + Complete the Vercel Connection form by entering: + - A descriptive name for the connection + - The API Token you generated in steps 3-4 + - An optional description for future reference + ![Vercel Connection Modal](/images/app-connections/vercel/vercel-app-connection-modal.png) + 4. Connection Created + + After clicking Create, your **Vercel Connection** is established and ready to use with your Infisical projects. + ![Vercel Connection Created](/images/app-connections/vercel/vercel-app-connection-created.png) + + + To create a Vercel Connection, make an API request to the [Create Vercel + Connection](/api-reference/endpoints/app-connections/vercel/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/vercel \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-vercel-connection", + "method": "api-token", + "credentials": { + "apiToken": "...", + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-vercel-connection", + "version": 123, + "orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2025-04-01T05:31:56Z", + "updatedAt": "2025-04-01T05:31:56Z", + "app": "vercel", + "method": "api-token", + "credentials": {} + } + } + ``` + + + + \ No newline at end of file diff --git a/docs/integrations/secret-syncs/vercel.mdx b/docs/integrations/secret-syncs/vercel.mdx new file mode 100644 index 000000000..593874dee --- /dev/null +++ b/docs/integrations/secret-syncs/vercel.mdx @@ -0,0 +1,148 @@ +--- +title: "Vercel Sync" +description: "Learn how to configure a Vercel Sync for Infisical." +--- + +**Prerequisites:** + + - Set up and add secrets to [Infisical Cloud](https://app.infisical.com) + - Create a [Vercel Connection](/integrations/app-connections/vercel) + + + + 1. Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. + ![Secret Syncs Tab](/images/secret-syncs/general/secret-sync-tab.png) + + 2. Select the **Vercel** option. + ![Select Vercel](/images/secret-syncs/vercel/select-vercel-option.png) + + 3. Configure the **Source** from where secrets should be retrieved, then click **Next**. + ![Configure Source](/images/secret-syncs/vercel/vercel-source.png) + + - **Environment**: The project environment to retrieve secrets from. + - **Secret Path**: The folder path to retrieve secrets from. + + + If you need to sync secrets from multiple folder locations, check out [secret imports](/documentation/platform/secret-reference#secret-imports). + + + 4. Configure the **Destination** to where secrets should be deployed, then click **Next**. + ![Configure Destination](/images/secret-syncs/vercel/vercel-destination.png) + + - **Vercel Connection**: The Vercel Connection to authenticate with. + - **Vercel App**: The application to deploy secrets to. + - **Vercel App Environment**: The environment to deploy secrets to. + - **Vercel Preview Branch (Optional)**: Specify a branch for preview deployments if needed. + + After configuring these parameters, click the **Next** button to continue to the Sync Options step. + + 5. Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + ![Configure Options](/images/secret-syncs/vercel/vercel-options.png) + + - **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync. + - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. + - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Vercel when keys conflict. + - **Import Secrets (Prioritize Vercel)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Vercel over Infisical when keys conflict. + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. + - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. + + 6. Configure the **Details** of your Vercel Sync, then click **Next**. + ![Configure Details](/images/secret-syncs/vercel/vercel-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + 7. Review your Vercel Sync configuration, then click **Create Sync**. + ![Confirm Configuration](/images/secret-syncs/vercel/vercel-review.png) + + 8. If enabled, your Vercel Sync will begin syncing your secrets to the destination endpoint. + ![Sync Secrets](/images/secret-syncs/vercel/vercel-created.png) + + + + To create an **Vercel Sync**, make an API request to the [Create Vercel Sync](/api-reference/endpoints/secret-syncs/vercel/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/vercel \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-vercel-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "an example sync", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/my-secrets", + "isEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "destinationConfig": { + "app": "prj_bz7zgHvQETPvJWc5tmIr0tGRH9kE", + "env": "preview", + "branch": "test", + "appName": "nextjs-boilerplate", + "teamId": "team_0d444b5088888dd257" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-vercel-sync", + "description": "an example sync", + "isEnabled": true, + "version": 1, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "syncStatus": "succeeded", + "lastSyncJobId": "123", + "lastSyncMessage": null, + "lastSyncedAt": "2023-11-07T05:31:56Z", + "importStatus": null, + "lastImportJobId": null, + "lastImportMessage": null, + "lastImportedAt": null, + "removeStatus": null, + "lastRemoveJobId": null, + "lastRemoveMessage": null, + "lastRemovedAt": null, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "vercel", + "name": "my-vercel-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/my-secrets" + }, + "destination": "vercel", + "destinationConfig": { + "app": "prj_bz7zgHvQETPvJWc5tmIr0tGRH9kE", + "env": "preview", + "branch": "test", + "appName": "nextjs-boilerplate", + "teamId": "team_0d444b5088888dd257" + } + } + } + ``` + + diff --git a/docs/mint.json b/docs/mint.json index a0c53d21d..7ec5f2a35 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -421,6 +421,7 @@ "integrations/app-connections/gcp", "integrations/app-connections/github", "integrations/app-connections/humanitec", + "integrations/app-connections/vercel", "integrations/app-connections/mssql", "integrations/app-connections/postgres" ] @@ -441,7 +442,8 @@ "integrations/secret-syncs/databricks", "integrations/secret-syncs/gcp-secret-manager", "integrations/secret-syncs/github", - "integrations/secret-syncs/humanitec" + "integrations/secret-syncs/humanitec", + "integrations/secret-syncs/vercel" ] } ] @@ -960,6 +962,18 @@ "api-reference/endpoints/app-connections/humanitec/delete" ] }, + { + "group": "Vercel", + "pages": [ + "api-reference/endpoints/app-connections/vercel/list", + "api-reference/endpoints/app-connections/vercel/available", + "api-reference/endpoints/app-connections/vercel/get-by-id", + "api-reference/endpoints/app-connections/vercel/get-by-name", + "api-reference/endpoints/app-connections/vercel/create", + "api-reference/endpoints/app-connections/vercel/update", + "api-reference/endpoints/app-connections/vercel/delete" + ] + }, { "group": "Microsoft SQL Server", "pages": [ @@ -1099,6 +1113,20 @@ "api-reference/endpoints/secret-syncs/humanitec/sync-secrets", "api-reference/endpoints/secret-syncs/humanitec/remove-secrets" ] + }, + { + "group": "Vercel", + "pages": [ + "api-reference/endpoints/secret-syncs/vercel/list", + "api-reference/endpoints/secret-syncs/vercel/get-by-id", + "api-reference/endpoints/secret-syncs/vercel/get-by-name", + "api-reference/endpoints/secret-syncs/vercel/create", + "api-reference/endpoints/secret-syncs/vercel/update", + "api-reference/endpoints/secret-syncs/vercel/delete", + "api-reference/endpoints/secret-syncs/vercel/sync-secrets", + "api-reference/endpoints/secret-syncs/vercel/remove-secrets", + "api-reference/endpoints/secret-syncs/vercel/import-secrets" + ] } ] }, 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..5c328079d --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/VercelSyncFields.tsx @@ -0,0 +1,195 @@ +import { useMemo } from "react"; +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.map((app) => ({ ...app, project: project.name, projectId: project.id })) + ) || []; + + const environmentOptions = useMemo(() => { + return vercelEnvironments + .map((env) => ({ + key: env.slug, + type: env.slug, + name: env.name + })) + .concat( + selectedProject?.envs?.map((env) => ({ + key: env.id, + type: env.type, + name: env.slug + })) || [] + ); + }, [currentApp]); + + 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.teamId", + (option as SingleValue)?.projectId || "" + ); + setValue( + "destinationConfig.appName", + (option as SingleValue)?.name || "" + ); + }} + options={allApps} + placeholder="Select a project..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id.toString()} + groupBy="project" + /> +
+ )} + /> + + ( + + env.key === value)?.key, + type: environmentOptions.find((env) => env.key === value)?.type, + name: environmentOptions.find((env) => env.key === value)?.name + } + : 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 || ""} + isClearable + /> + + )} + /> + )} + + ); +}; 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 5816635f6..cdc651400 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..43e83cf96 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/VercelSyncReviewFields.tsx @@ -0,0 +1,23 @@ +import { useFormContext } from "react-hook-form"; + +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { GenericFieldLabel } from "@app/components/v2"; +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..9d3678803 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/vercel-sync-destination-schema.ts @@ -0,0 +1,18 @@ +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.nativeEnum(VercelEnvironmentType).or(z.string()), + branch: z.string().trim().optional(), + teamId: z.string().trim() + }) + }) +); diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index d75b426b7..286f2cb3f 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -12,7 +12,8 @@ import { HumanitecConnectionMethod, MsSqlConnectionMethod, PostgresConnectionMethod, - TAppConnection + TAppConnection, + VercelConnectionMethod } from "@app/hooks/api/appConnections/types"; export const APP_CONNECTION_MAP: Record = { @@ -29,6 +30,7 @@ export const APP_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 }; 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 6891e7e2c..4a9a8be7a 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -6,6 +6,7 @@ export enum AppConnection { AzureAppConfiguration = "azure-app-configuration", Databricks = "databricks", Humanitec = "humanitec", + Vercel = "vercel", Postgres = "postgres", MsSql = "mssql" } diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index d3d376e6e..f389bf230 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -39,6 +39,10 @@ export type THumanitecConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Humanitec; }; +export type TVercelConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.Vercel; +}; + export type TPostgresConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Postgres; }; @@ -55,6 +59,7 @@ export type TAppConnectionOption = | TAzureKeyVaultConnectionOption | TDatabricksConnectionOption | THumanitecConnectionOption + | TVercelConnectionOption | TPostgresConnectionOption | TMsSqlConnectionOption; @@ -66,6 +71,7 @@ export type TAppConnectionOptionMap = { [AppConnection.AzureAppConfiguration]: TAzureAppConfigurationConnectionOption; [AppConnection.Databricks]: TDatabricksConnectionOption; [AppConnection.Humanitec]: THumanitecConnectionOption; + [AppConnection.Vercel]: TVercelConnectionOption; [AppConnection.Postgres]: TPostgresConnectionOption; [AppConnection.MsSql]: TMsSqlConnectionOption; }; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index a241e21a1..15c2e9691 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -9,6 +9,7 @@ import { TGitHubConnection } from "./github-connection"; import { THumanitecConnection } from "./humanitec-connection"; import { TMsSqlConnection } from "./mssql-connection"; import { TPostgresConnection } from "./postgres-connection"; +import { TVercelConnection } from "./vercel-connection"; export * from "./aws-connection"; export * from "./azure-app-configuration-connection"; @@ -19,6 +20,7 @@ export * from "./github-connection"; export * from "./humanitec-connection"; export * from "./mssql-connection"; export * from "./postgres-connection"; +export * from "./vercel-connection"; export type TAppConnection = | TAwsConnection @@ -28,6 +30,7 @@ export type TAppConnection = | TAzureAppConfigurationConnection | TDatabricksConnection | THumanitecConnection + | TVercelConnection | TPostgresConnection | TMsSqlConnection; @@ -64,6 +67,7 @@ export type TAppConnectionMap = { [AppConnection.AzureAppConfiguration]: TAzureAppConfigurationConnection; [AppConnection.Databricks]: TDatabricksConnection; [AppConnection.Humanitec]: THumanitecConnection; + [AppConnection.Vercel]: TVercelConnection; [AppConnection.Postgres]: TPostgresConnection; [AppConnection.MsSql]: TMsSqlConnection; }; 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..d733639ab --- /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 { + ApiToken = "api-token" +} + +export type TVercelConnection = TRootAppConnection & { app: AppConnection.Vercel } & { + method: VercelConnectionMethod.ApiToken; + 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..1e25ce9ee --- /dev/null +++ b/frontend/src/hooks/api/appConnections/vercel/types.ts @@ -0,0 +1,30 @@ +export type TVercelApp = { + id: string; + name: string; + envs: { id: string; name: string }[]; +}; + +export type TVercelConnectionEnvironment = { + id: string; + slug: string; + type: string; + target?: string[]; + gitBranch?: string; + createdAt?: number; + updatedAt?: number; +}; + +export type TVercelConnectionApp = { + id: string; + name: string; + envs?: TVercelConnectionEnvironment[]; + previewBranches?: string[]; + projectId: 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..ffae61e23 --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/vercel-sync.ts @@ -0,0 +1,27 @@ +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 | string; + branch?: string; + appName?: string; + teamId: 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 6a57bef5a..b4b615938 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -18,6 +18,7 @@ import { GitHubConnectionForm } from "./GitHubConnectionForm"; import { HumanitecConnectionForm } from "./HumanitecConnectionForm"; import { MsSqlConnectionForm } from "./MsSqlConnectionForm"; import { PostgresConnectionForm } from "./PostgresConnectionForm"; +import { VercelConnectionForm } from "./VercelConnectionForm"; type FormProps = { onComplete: (appConnection: TAppConnection) => void; @@ -70,6 +71,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => { return ; case AppConnection.Humanitec: return ; + case AppConnection.Vercel: + return ; case AppConnection.Postgres: return ; case AppConnection.MsSql: @@ -124,6 +127,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { return ; case AppConnection.Humanitec: return ; + case AppConnection.Vercel: + return ; case AppConnection.Postgres: return ; case AppConnection.MsSql: 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..d655a5c6a --- /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.ApiToken), + 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.ApiToken + } + }); + + 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 49892a0a2..202d78cea 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..bc4a8531d --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/VercelSyncDestinationSection.tsx @@ -0,0 +1,36 @@ +import { ReactNode } from "react"; + +import { GenericFieldLabel } 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.appName || destinationConfig.app} + + {destinationConfig.env} + {destinationConfig.branch} + + ); + } else { + Components = ( + <> + + {destinationConfig.appName || 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 9349e7c63..d1f330f4f 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: