+ {vercelManageAccessUrl && !origin && (
+
+ Manage access
+
+ )}
+
+ {fetcher.state !== "idle" ? "Connecting..." : "Connect Project"}
+
+
}
cancelButton={
Pull Environment Variables
Select which environment variables to pull from Vercel now. This is a one-time pull.
+ Later on environment variables can be pulled before each build.
@@ -1057,7 +1079,7 @@ export function VercelOnboardingModal({
{(() => {
- const baseSettingsPath = v3ProjectSettingsPath(
+ const baseSettingsPath = v3ProjectSettingsIntegrationsPath(
{ slug: organizationSlug },
{ slug: projectSlug },
{ slug: environmentSlug }
@@ -1081,6 +1103,7 @@ export function VercelOnboardingModal({
)}
variant="secondary/medium"
LeadingIcon={OctoKitty}
+ onClick={() => trackOnboarding("vercel onboarding github app install clicked")}
>
Install GitHub app
@@ -1110,6 +1133,7 @@ export function VercelOnboardingModal({
{
+ trackOnboarding("vercel onboarding github completed");
setState("completed");
const validUrl = safeRedirectUrl(nextUrl);
if (validUrl) {
@@ -1123,6 +1147,7 @@ export function VercelOnboardingModal({
{
+ trackOnboarding("vercel onboarding github skipped");
setState("completed");
if (fromMarketplaceContext && nextUrl) {
const validUrl = safeRedirectUrl(nextUrl);
@@ -1141,6 +1166,7 @@ export function VercelOnboardingModal({
{
+ trackOnboarding("vercel onboarding github skipped");
setState("completed");
}}
>
diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx
index 9069620c92b..e0e414b189e 100644
--- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx
+++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx
@@ -3,15 +3,17 @@ import {
ChartBarIcon,
Cog8ToothIcon,
CreditCardIcon,
- PuzzlePieceIcon,
UserGroupIcon,
} from "@heroicons/react/20/solid";
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
+import { SlackIcon } from "@trigger.dev/companyicons";
+import { VercelLogo } from "~/components/integrations/VercelLogo";
import { useFeatures } from "~/hooks/useFeatures";
import { type MatchedOrganization } from "~/hooks/useOrganizations";
import { cn } from "~/utils/cn";
import {
organizationSettingsPath,
+ organizationSlackIntegrationPath,
organizationTeamPath,
organizationVercelIntegrationPath,
rootPath,
@@ -115,13 +117,25 @@ export function OrganizationSettingsSideMenu({
to={organizationSettingsPath(organization)}
data-action="settings"
/>
+
+
diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx
index db86425b67b..d7ac89573be 100644
--- a/apps/webapp/app/components/navigation/SideMenu.tsx
+++ b/apps/webapp/app/components/navigation/SideMenu.tsx
@@ -11,6 +11,7 @@ import {
Cog8ToothIcon,
CogIcon,
ExclamationTriangleIcon,
+ PuzzlePieceIcon,
FolderIcon,
FolderOpenIcon,
GlobeAmericasIcon,
@@ -74,7 +75,8 @@ import {
v3LogsPath,
v3ProjectAlertsPath,
v3ProjectPath,
- v3ProjectSettingsPath,
+ v3ProjectSettingsGeneralPath,
+ v3ProjectSettingsIntegrationsPath,
v3QueuesPath,
v3RunsPath,
v3SchedulesPath,
@@ -589,13 +591,34 @@ export function SideMenu({
data-action="limits"
isCollapsed={isCollapsed}
/>
+
+
+
+
diff --git a/apps/webapp/app/components/navigation/sideMenuTypes.ts b/apps/webapp/app/components/navigation/sideMenuTypes.ts
index 64afdf58e65..8dc722a37d1 100644
--- a/apps/webapp/app/components/navigation/sideMenuTypes.ts
+++ b/apps/webapp/app/components/navigation/sideMenuTypes.ts
@@ -1,7 +1,7 @@
import { z } from "zod";
// Valid section IDs that can have their collapsed state toggled
-export const SideMenuSectionIdSchema = z.enum(["manage", "metrics"]);
+export const SideMenuSectionIdSchema = z.enum(["manage", "metrics", "project-settings"]);
// Inferred type from the schema
export type SideMenuSectionId = z.infer
;
diff --git a/apps/webapp/app/models/vercelIntegration.server.ts b/apps/webapp/app/models/vercelIntegration.server.ts
index c31b8bde27a..7bf46286808 100644
--- a/apps/webapp/app/models/vercelIntegration.server.ts
+++ b/apps/webapp/app/models/vercelIntegration.server.ts
@@ -975,6 +975,13 @@ export class VercelIntegrationRepository {
return { created: 0, updated: 0, errors: [] as string[] };
}
+ await this.removeAllVercelEnvVarsByKey({
+ client,
+ vercelProjectId: params.vercelProjectId,
+ teamId: params.teamId,
+ key: "TRIGGER_SECRET_KEY",
+ });
+
const result = await this.batchUpsertVercelEnvVars({
client,
vercelProjectId: params.vercelProjectId,
@@ -1526,6 +1533,35 @@ export class VercelIntegrationRepository {
return { created, updated, errors };
}
+ private static async removeAllVercelEnvVarsByKey(params: {
+ client: Vercel;
+ vercelProjectId: string;
+ teamId: string | null;
+ key: string;
+ }): Promise {
+ const { client, vercelProjectId, teamId, key } = params;
+
+ const existingEnvs = await client.projects.filterProjectEnvs({
+ idOrName: vercelProjectId,
+ ...(teamId && { teamId }),
+ });
+
+ const envs = extractVercelEnvs(existingEnvs);
+ const idsToRemove = envs
+ .filter((env) => env.key === key && env.id)
+ .map((env) => env.id!);
+
+ if (idsToRemove.length === 0) {
+ return;
+ }
+
+ await client.projects.batchRemoveProjectEnv({
+ idOrName: vercelProjectId,
+ ...(teamId && { teamId }),
+ requestBody: { ids: idsToRemove },
+ });
+ }
+
private static async upsertVercelEnvVar(params: {
client: Vercel;
vercelProjectId: string;
diff --git a/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts b/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts
index 1f5996f9967..9abc0ed0ab9 100644
--- a/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts
+++ b/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts
@@ -10,7 +10,10 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { type User } from "~/models/user.server";
import { processGitMetadata } from "./BranchesPresenter.server";
import { BranchTrackingConfigSchema, getTrackedBranchForEnvironment } from "~/v3/github";
-import { VercelProjectIntegrationDataSchema } from "~/v3/vercel/vercelProjectIntegrationSchema";
+import {
+ VercelProjectIntegrationDataSchema,
+ buildVercelDeploymentUrl,
+} from "~/v3/vercel/vercelProjectIntegrationSchema";
const pageSize = 20;
@@ -232,8 +235,11 @@ LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`;
let vercelDeploymentUrl: string | null = null;
if (hasVercelIntegration && deployment.integrationDeploymentId && vercelTeamSlug && vercelProjectName) {
- const vercelId = deployment.integrationDeploymentId.replace(/^dpl_/, "");
- vercelDeploymentUrl = `https://vercel.com/${vercelTeamSlug}/${vercelProjectName}/${vercelId}`;
+ vercelDeploymentUrl = buildVercelDeploymentUrl(
+ vercelTeamSlug,
+ vercelProjectName,
+ deployment.integrationDeploymentId
+ );
}
return {
diff --git a/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts b/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts
index bc494c118aa..c5414290e4e 100644
--- a/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts
+++ b/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts
@@ -12,6 +12,7 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { type User } from "~/models/user.server";
import { getUsername } from "~/utils/username";
import { processGitMetadata } from "./BranchesPresenter.server";
+import { VercelProjectIntegrationDataSchema } from "~/v3/vercel/vercelProjectIntegrationSchema";
import { S2 } from "@s2-dev/streamstore";
import { env } from "~/env.server";
import { createRedisClient } from "~/redis.server";
@@ -161,6 +162,51 @@ export class DeploymentPresenter {
});
const gitMetadata = processGitMetadata(deployment.git);
+
+ // Look up Vercel integration data to construct a deployment URL
+ let vercelDeploymentUrl: string | undefined;
+ const vercelProjectIntegration =
+ await this.#prismaClient.organizationProjectIntegration.findFirst({
+ where: {
+ projectId: project.id,
+ deletedAt: null,
+ organizationIntegration: {
+ service: "VERCEL",
+ deletedAt: null,
+ },
+ },
+ select: {
+ integrationData: true,
+ },
+ });
+
+ if (vercelProjectIntegration) {
+ const parsed = VercelProjectIntegrationDataSchema.safeParse(
+ vercelProjectIntegration.integrationData
+ );
+
+ if (parsed.success && parsed.data.vercelTeamSlug) {
+ const integrationDeployment =
+ await this.#prismaClient.integrationDeployment.findFirst({
+ where: {
+ deploymentId: deployment.id,
+ integrationName: "vercel",
+ },
+ select: {
+ integrationDeploymentId: true,
+ },
+ orderBy: {
+ createdAt: "desc",
+ },
+ });
+
+ if (integrationDeployment) {
+ const vercelId = integrationDeployment.integrationDeploymentId.replace(/^dpl_/, "");
+ vercelDeploymentUrl = `https://vercel.com/${parsed.data.vercelTeamSlug}/${parsed.data.vercelProjectName}/${vercelId}`;
+ }
+ }
+ }
+
const externalBuildData = deployment.externalBuildData
? ExternalBuildData.safeParse(deployment.externalBuildData)
: undefined;
@@ -227,6 +273,7 @@ export class DeploymentPresenter {
type: deployment.type,
git: gitMetadata,
triggeredVia: deployment.triggeredVia,
+ vercelDeploymentUrl,
},
};
}
diff --git a/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts b/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts
index 26688d41fdd..4a57e3ec0ef 100644
--- a/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts
+++ b/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts
@@ -40,6 +40,8 @@ export type VercelSettingsResult = {
customEnvironments: VercelCustomEnvironment[];
/** Whether autoAssignCustomDomains is enabled on the Vercel project. null if unknown. */
autoAssignCustomDomains?: boolean | null;
+ /** URL to manage Vercel integration access (project sharing) on vercel.com */
+ vercelManageAccessUrl?: string;
};
export type VercelAvailableProject = {
@@ -242,11 +244,12 @@ export class VercelSettingsPresenter extends BasePresenter {
checkPreviewEnvironment(),
getVercelProjectIntegration(),
]).andThen(([hasOrgIntegration, isGitHubConnected, hasStagingEnvironment, hasPreviewEnvironment, connectedProject]) => {
- const fetchCustomEnvsAndProjectSettings = async (): Promise<{
+ const fetchVercelData = async (): Promise<{
customEnvironments: VercelCustomEnvironment[];
autoAssignCustomDomains: boolean | null;
+ vercelManageAccessUrl?: string;
}> => {
- if (!connectedProject || !orgIntegration) {
+ if (!orgIntegration) {
return { customEnvironments: [], autoAssignCustomDomains: null };
}
const clientResult = await VercelIntegrationRepository.getVercelClient(orgIntegration);
@@ -255,6 +258,26 @@ export class VercelSettingsPresenter extends BasePresenter {
}
const client = clientResult.value;
const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration);
+
+ // Build manage access URL
+ let vercelManageAccessUrl: string | undefined;
+ const appSlug = env.VERCEL_INTEGRATION_APP_SLUG;
+ const integrationData = orgIntegration.integrationData as Record | null;
+ const installationId =
+ typeof integrationData?.installationId === "string"
+ ? integrationData.installationId
+ : undefined;
+ if (appSlug && installationId && teamId) {
+ const teamSlugResult = await VercelIntegrationRepository.getTeamSlug(client, teamId);
+ if (teamSlugResult.isOk()) {
+ vercelManageAccessUrl = `https://vercel.com/${teamSlugResult.value}/~/integrations/${appSlug}/${installationId}`;
+ }
+ }
+
+ if (!connectedProject) {
+ return { customEnvironments: [], autoAssignCustomDomains: null, vercelManageAccessUrl };
+ }
+
const [customEnvsResult, autoAssignResult] = await Promise.all([
VercelIntegrationRepository.getVercelCustomEnvironments(
client,
@@ -270,13 +293,14 @@ export class VercelSettingsPresenter extends BasePresenter {
return {
customEnvironments: customEnvsResult.isOk() ? customEnvsResult.value : [],
autoAssignCustomDomains: autoAssignResult.isOk() ? autoAssignResult.value : null,
+ vercelManageAccessUrl,
};
};
return fromPromise(
- fetchCustomEnvsAndProjectSettings(),
+ fetchVercelData(),
(error) => ({ type: "other" as const, cause: error })
- ).map(({ customEnvironments, autoAssignCustomDomains }) => ({
+ ).map(({ customEnvironments, autoAssignCustomDomains, vercelManageAccessUrl }) => ({
enabled: true,
hasOrgIntegration,
authInvalid: false,
@@ -286,6 +310,7 @@ export class VercelSettingsPresenter extends BasePresenter {
hasPreviewEnvironment,
customEnvironments,
autoAssignCustomDomains,
+ vercelManageAccessUrl,
} as VercelSettingsResult));
}).mapErr((error) => {
// Log the error and return a safe fallback
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx
index 9d32d89fd56..4a7d1df5ce8 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx
@@ -15,6 +15,7 @@ import {
} from "lucide-react";
import { ExitIcon } from "~/assets/icons/ExitIcon";
import { GitMetadata } from "~/components/GitMetadata";
+import { VercelLink } from "~/components/integrations/VercelLink";
import { RuntimeIcon } from "~/components/RuntimeIcon";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
@@ -516,6 +517,16 @@ export default function Page() {
})()}
+ {deployment.vercelDeploymentUrl && (
+
+ Linked
+
+
+
+
+
+
+ )}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx
index 61e789e138b..a42b39c4573 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx
@@ -19,7 +19,7 @@ import { useEffect } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { PromoteIcon } from "~/assets/icons/PromoteIcon";
-import { VercelLogo } from "~/components/integrations/VercelLogo";
+import { VercelLink } from "~/components/integrations/VercelLink";
import { DeploymentsNone, DeploymentsNoneDev } from "~/components/BlankStatePanels";
import { OctoKitty } from "~/components/GitHubLoginButton";
import { GitMetadata } from "~/components/GitMetadata";
@@ -56,7 +56,6 @@ import {
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
-import { SimpleTooltip } from "~/components/primitives/Tooltip";
import {
DeploymentStatus,
deploymentStatusDescription,
@@ -76,7 +75,7 @@ import {
EnvironmentParamSchema,
docsPath,
v3DeploymentPath,
- v3ProjectSettingsPath,
+ v3ProjectSettingsIntegrationsPath,
} from "~/utils/pathBuilder";
import { createSearchParams } from "~/utils/searchParams";
import { compareDeploymentVersions } from "~/v3/utils/deploymentVersions";
@@ -314,20 +313,14 @@ export default function Page() {
{hasVercelIntegration && (
{deployment.vercelDeploymentUrl ? (
- e.stopPropagation()}
- >
-
-
- }
- content="View on Vercel"
- />
+ e.stopPropagation()}
+ >
+
+
) : (
"–"
)}
@@ -377,7 +370,7 @@ export default function Page() {