General improvements to Vercel Integration

This commit is contained in:
carlosmonastyrski
2025-04-09 11:32:48 -03:00
parent 6dc085b970
commit d01cb282f9
21 changed files with 171 additions and 104 deletions

View File

@@ -1694,6 +1694,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."
}
}
};
@@ -1814,7 +1817,8 @@ export const SecretSyncs = {
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."
branch: "The branch to sync preview secrets to.",
teamId: "The ID of the Vercel team to sync secrets to."
}
}
};

View File

@@ -45,11 +45,11 @@ export const registerVercelConnectionRouter = async (server: FastifyZodProvider)
name: z.string(),
envs: z
.object({
key: z.string(),
value: z.string(),
id: z.string(),
slug: z.string(),
type: z.string(),
target: z.array(z.string()).optional(),
gitBranch: z.string().optional(),
description: z.string().optional(),
createdAt: z.number().optional(),
updatedAt: z.number().optional()
})

View File

@@ -136,7 +136,6 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) =>
case DatabricksConnectionMethod.ServicePrincipal:
return "Service Principal";
case HumanitecConnectionMethod.ApiToken:
return "API Token";
case VercelConnectionMethod.ApiToken:
return "API Token";
case PostgresConnectionMethod.UsernameAndPassword:

View File

@@ -133,29 +133,41 @@ async function fetchOrgProjects(orgId: string, apiToken: string): Promise<Vercel
);
}
async function fetchProjectEnvironments(projectId: string, apiToken: string): Promise<VercelEnvironment[]> {
return fetchAllPages<VercelEnvironment>(
`${IntegrationUrls.VERCEL_API_URL}/v9/projects/${projectId}/custom-environments`,
apiToken,
{},
"environments"
);
async function fetchProjectEnvironments(
projectId: string,
teamId: string,
apiToken: string
): Promise<VercelEnvironment[]> {
try {
return await fetchAllPages<VercelEnvironment>(
`${IntegrationUrls.VERCEL_API_URL}/v9/projects/${projectId}/custom-environments?teamId=${teamId}`,
apiToken,
{},
"environments"
);
} catch (error) {
return [];
}
}
async function fetchPreviewBranches(projectId: string, apiToken: string): Promise<string[]> {
const { data } = await request.get<TVercelBranches[]>(
`${IntegrationUrls.VERCEL_API_URL}/v1/integrations/git-branches`,
{
params: {
projectId
},
headers: {
Authorization: `Bearer ${apiToken}`,
"Accept-Encoding": "application/json"
try {
const { data } = await request.get<TVercelBranches[]>(
`${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);
);
return data.filter((b) => b.ref !== "main").map((b) => b.ref);
} catch (error) {
return [];
}
}
type VercelTeam = {
@@ -203,7 +215,7 @@ export const listProjects = async (appConnection: TVercelConnectionInput): Promi
const enhancedProjectsPromises = projects.map(async (project) => {
try {
const [environments, previewBranches] = await Promise.all([
fetchProjectEnvironments(project.id, apiToken),
fetchProjectEnvironments(project.name, org.id, apiToken),
fetchPreviewBranches(project.id, apiToken)
]);
@@ -251,9 +263,9 @@ export const getProjectEnvironmentVariables = (project: VercelApp): Record<strin
if (!project.envs) return envVars;
project.envs.forEach((env) => {
if (env.value && env.type !== "gitBranch") {
const { key, value } = env;
envVars[key] = value;
if (env.slug && env.type !== "gitBranch") {
const { id, slug } = env;
envVars[id] = slug;
}
});

View File

@@ -28,8 +28,8 @@ export type VercelTeam = {
};
export type VercelEnvironment = {
key: string;
value: string;
id: string;
slug: string;
type: string;
target?: string[];
gitBranch?: string;

View File

@@ -1,12 +1,15 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { request } from "@app/lib/config/request";
import { logger } from "@app/lib/logger";
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors";
import { TSecretMap } from "@app/services/secret-sync/secret-sync-types";
import { VercelEnvironmentType } from "./vercel-sync-enums";
import { TVercelSyncWithCredentials, VercelApiSecret } from "./vercel-sync-types";
import { DefaultVercelEnvType, TVercelSyncWithCredentials, VercelApiSecret } from "./vercel-sync-types";
function isVercelDefaultEnvType(value: string): value is DefaultVercelEnvType {
return Object.values(VercelEnvironmentType).map(String).includes(value);
}
const getVercelSecrets = async (secretSync: TVercelSyncWithCredentials) => {
const {
@@ -22,7 +25,7 @@ const getVercelSecrets = async (secretSync: TVercelSyncWithCredentials) => {
};
const { data } = await request.get<{ envs: VercelApiSecret[] }>(
`${IntegrationUrls.VERCEL_API_URL}/v9/projects/${destinationConfig.app}/env`,
`${IntegrationUrls.VERCEL_API_URL}/v9/projects/${destinationConfig.app}/env?teamId=${destinationConfig.teamId}`,
{
params,
headers: {
@@ -33,7 +36,12 @@ const getVercelSecrets = async (secretSync: TVercelSyncWithCredentials) => {
);
const filteredSecrets = data.envs.filter((secret) => {
// For environment-specific filtering
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 (
@@ -54,7 +62,7 @@ const getVercelSecrets = async (secretSync: TVercelSyncWithCredentials) => {
filteredSecrets.map(async (secret) => {
if (secret.type === "encrypted") {
const { data: decryptedSecret } = await request.get(
`${IntegrationUrls.VERCEL_API_URL}/v9/projects/${destinationConfig.app}/env/${secret.id}`,
`${IntegrationUrls.VERCEL_API_URL}/v9/projects/${destinationConfig.app}/env/${secret.id}?teamId=${destinationConfig.teamId}`,
{
params,
headers: {
@@ -82,7 +90,7 @@ const deleteSecret = async (secretSync: TVercelSyncWithCredentials, vercelSecret
try {
await request.delete(
`${IntegrationUrls.VERCEL_API_URL}/v9/projects/${destinationConfig.app}/env/${vercelSecret.id}`,
`${IntegrationUrls.VERCEL_API_URL}/v9/projects/${destinationConfig.app}/env/${vercelSecret.id}?teamId=${destinationConfig.teamId}`,
{
headers: {
Authorization: `Bearer ${apiToken}`,
@@ -108,12 +116,13 @@ const createSecret = async (secretSync: TVercelSyncWithCredentials, secretMap: T
} = secretSync;
await request.post(
`${IntegrationUrls.VERCEL_API_URL}/v10/projects/${destinationConfig.app}/env`,
`${IntegrationUrls.VERCEL_API_URL}/v10/projects/${destinationConfig.app}/env?teamId=${destinationConfig.teamId}`,
{
key,
value: secretMap[key].value,
type: "encrypted",
target: [destinationConfig.env],
target: isVercelDefaultEnvType(destinationConfig.env) ? [destinationConfig.env] : [],
customEnvironmentIds: !isVercelDefaultEnvType(destinationConfig.env) ? [destinationConfig.env] : [],
...(destinationConfig.env === VercelEnvironmentType.Preview && destinationConfig.branch
? { gitBranch: destinationConfig.branch }
: {})
@@ -146,31 +155,37 @@ const updateSecret = async (
}
} = secretSync;
// Only update if not sensitive type
if (vercelSecret.type !== "sensitive") {
await request.patch(
`${IntegrationUrls.VERCEL_API_URL}/v9/projects/${destinationConfig.app}/env/${vercelSecret.id}`,
{
key: vercelSecret.key,
value: secretMap[vercelSecret.key].value,
type: vercelSecret.type,
target: vercelSecret.target.includes(destinationConfig.env)
? [...vercelSecret.target]
: [...vercelSecret.target, destinationConfig.env],
...(destinationConfig.env === VercelEnvironmentType.Preview && destinationConfig.branch
? { gitBranch: destinationConfig.branch }
: {})
},
{
headers: {
Authorization: `Bearer ${apiToken}`,
"Accept-Encoding": "application/json"
}
}
);
} else {
logger.info(`Vercel secret ${vercelSecret.key} is of type 'sensitive' and cannot be updated through the API`);
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}`,
{
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) {
throw new SecretSyncError({
error,

View File

@@ -15,10 +15,9 @@ import { VercelEnvironmentType } from "./vercel-sync-enums";
const VercelSyncDestinationConfigSchema = z.object({
app: z.string().min(1, "App ID is required").describe(SecretSyncs.DESTINATION_CONFIG.VERCEL.app),
appName: z.string().min(1, "App Name is required").describe(SecretSyncs.DESTINATION_CONFIG.VERCEL.appName),
env: z
.enum([VercelEnvironmentType.Development, VercelEnvironmentType.Preview, VercelEnvironmentType.Production])
.describe(SecretSyncs.DESTINATION_CONFIG.VERCEL.env),
branch: z.string().optional().describe(SecretSyncs.DESTINATION_CONFIG.VERCEL.branch)
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 };

View File

@@ -2,6 +2,7 @@ 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<typeof VercelSyncListItemSchema>;
@@ -28,9 +29,12 @@ export interface VercelApiSecret {
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];

View File

@@ -1,4 +1,9 @@
---
title: "Create"
openapi: "POST /api/v1/app-connections/vercel"
---
---
<Note>
Check out the configuration docs for [Vercel Connections](/integrations/app-connections/vercel) to learn how to obtain
the required credentials.
</Note>

View File

@@ -1,4 +1,9 @@
---
title: "Update"
openapi: "PATCH /api/v1/app-connections/vercel/{connectionId}"
---
---
<Note>
Check out the configuration docs for [Vercel Connections](/integrations/app-connections/vercel) to learn how to obtain
the required credentials.
</Note>

View File

@@ -0,0 +1,4 @@
---
title: "Import Secrets"
openapi: "POST /api/v1/secret-syncs/vercel/{syncId}/import-secrets"
---

View File

@@ -3,7 +3,7 @@ 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 between both platforms.
Infisical supports connecting to Vercel using an API Token to securely sync your secrets to Vercel.
## Setup Vercel Connection in Infisical
@@ -36,18 +36,22 @@ Infisical supports connecting to Vercel using an API Token to securely sync your
<Tabs>
<Tab title="Infisical UI">
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)
</Tab>

View File

@@ -40,9 +40,8 @@ description: "Learn how to configure a Vercel Sync for Infisical."
- **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.
<Note>
Vercel does not support importing secrets.
</Note>
- **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.

View File

@@ -1123,7 +1123,8 @@
"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/remove-secrets",
"api-reference/endpoints/secret-syncs/vercel/import-secrets"
]
}
]

View File

@@ -1,3 +1,4 @@
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";
@@ -39,13 +40,26 @@ export const VercelSyncFields = () => {
?.find((project) => project.apps.some((app) => app.id === currentApp))
?.apps.find((app) => app.id === currentApp);
const allApps = projects?.flatMap((project) => project.apps) || [];
const allApps =
projects?.flatMap((project) =>
project.apps.map((app) => ({ ...app, project: project.name, projectId: project.id }))
) || [];
const environmentOptions = vercelEnvironments.map((env) => ({
key: env.slug,
type: env.slug,
name: env.name
}));
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) => ({
@@ -77,7 +91,7 @@ export const VercelSyncFields = () => {
helperText={
<Tooltip
className="max-w-md"
content="Ensure that the project exists and the service account used on this connection has write permissions for the specified project."
content="Ensure the project exists and the API token scope for this connection includes the desired project."
>
<div>
<span>Don&#39;t see the project you&#39;re looking for?</span>{" "}
@@ -95,6 +109,10 @@ export const VercelSyncFields = () => {
const appId = (option as SingleValue<TVercelConnectionApp>)?.id ?? null;
onChange(appId);
setValue("destinationConfig.branch", "");
setValue(
"destinationConfig.teamId",
(option as SingleValue<TVercelConnectionApp>)?.projectId || ""
);
setValue(
"destinationConfig.appName",
(option as SingleValue<TVercelConnectionApp>)?.name || ""
@@ -104,6 +122,7 @@ export const VercelSyncFields = () => {
placeholder="Select a project..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id.toString()}
groupBy="project"
/>
</FormControl>
)}
@@ -124,9 +143,9 @@ export const VercelSyncFields = () => {
value={
value
? {
key: value,
type: value,
name: vercelEnvironments.find((env) => env.slug === value)?.name || value
key: environmentOptions.find((env) => env.key === value)?.key,
type: environmentOptions.find((env) => env.key === value)?.type,
name: environmentOptions.find((env) => env.key === value)?.name
}
: null
}
@@ -138,8 +157,8 @@ export const VercelSyncFields = () => {
}}
options={environmentOptions}
placeholder="Select an environment..."
getOptionLabel={(option) => option.name || option.key}
getOptionValue={(option) => option.key}
getOptionLabel={(option) => option.name || option.key || ""}
getOptionValue={(option) => option.key || ""}
/>
</FormControl>
)}

View File

@@ -10,17 +10,9 @@ export const VercelSyncDestinationSchema = BaseSecretSyncSchema().merge(
destinationConfig: z.object({
app: z.string().trim().min(1, "Project required"),
appName: z.string().trim().min(1, "Project required"),
env: z.enum(
[
VercelEnvironmentType.Development,
VercelEnvironmentType.Preview,
VercelEnvironmentType.Production
],
{
required_error: "Environment is required"
}
),
branch: z.string().trim().optional()
env: z.nativeEnum(VercelEnvironmentType).or(z.string()),
branch: z.string().trim().optional(),
teamId: z.string().trim()
})
})
);

View File

@@ -52,9 +52,8 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"])
case DatabricksConnectionMethod.ServicePrincipal:
return { name: "Service Principal", icon: faUser };
case HumanitecConnectionMethod.ApiToken:
return { name: "API Token", icon: faKey };
case VercelConnectionMethod.ApiToken:
return { name: "Service API Token", icon: faKey };
return { name: "API Token", icon: faKey };
case PostgresConnectionMethod.UsernameAndPassword:
case MsSqlConnectionMethod.UsernameAndPassword:
return { name: "Username & Password", icon: faLock };

View File

@@ -5,8 +5,8 @@ export type TVercelApp = {
};
export type TVercelConnectionEnvironment = {
key: string;
value: string;
id: string;
slug: string;
type: string;
target?: string[];
gitBranch?: string;
@@ -19,6 +19,7 @@ export type TVercelConnectionApp = {
name: string;
envs?: TVercelConnectionEnvironment[];
previewBranches?: string[];
projectId: string;
};
export type TVercelConnectionOrganization = {

View File

@@ -14,9 +14,10 @@ export type TVercelSync = TRootSecretSync & {
destination: SecretSync.Vercel;
destinationConfig: {
app: string;
env: VercelEnvironment;
env: VercelEnvironment | string;
branch?: string;
appName?: string;
teamId: string;
};
connection: {
app: AppConnection.Vercel;

View File

@@ -102,7 +102,7 @@ export const VercelConnectionForm = ({ appConnection, onSubmit }: Props) => {
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Service API Token"
label="API Token"
>
<SecretInput
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"

View File

@@ -14,7 +14,9 @@ export const VercelSyncDestinationSection = ({ secretSync }: Props) => {
if (destinationConfig.env === VercelEnvironmentType.Preview && destinationConfig.branch) {
Components = (
<>
<GenericFieldLabel label="Vercel App">{destinationConfig.app}</GenericFieldLabel>
<GenericFieldLabel label="Vercel App">
{destinationConfig.appName || destinationConfig.app}
</GenericFieldLabel>
<GenericFieldLabel label="Environment">{destinationConfig.env}</GenericFieldLabel>
<GenericFieldLabel label="Preview Branch">{destinationConfig.branch}</GenericFieldLabel>
</>
@@ -22,7 +24,9 @@ export const VercelSyncDestinationSection = ({ secretSync }: Props) => {
} else {
Components = (
<>
<GenericFieldLabel label="Vercel App">{destinationConfig.app}</GenericFieldLabel>
<GenericFieldLabel label="Vercel App">
{destinationConfig.appName || destinationConfig.app}
</GenericFieldLabel>
<GenericFieldLabel label="Environment">{destinationConfig.env}</GenericFieldLabel>
</>
);