From 6457d8176c5f577b5d08eb0e26c9ca525868de42 Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Thu, 8 Jan 2026 12:46:30 +0100 Subject: [PATCH 01/33] feat(db): Database changes for Vercel integration --- apps/webapp/package.json | 1 + .../migration.sql | 29 +++++ .../migration.sql | 21 ++++ .../migration.sql | 9 ++ .../migration.sql | 3 + .../migration.sql | 3 + .../database/prisma/schema.prisma | 109 +++++++++++++----- packages/core/src/v3/schemas/api.ts | 2 + pnpm-lock.yaml | 15 +++ 9 files changed, 162 insertions(+), 30 deletions(-) create mode 100644 internal-packages/database/prisma/migrations/20260129162621_add_organization_project_integration/migration.sql create mode 100644 internal-packages/database/prisma/migrations/20260129162810_add_integration_deployment/migration.sql create mode 100644 internal-packages/database/prisma/migrations/20260129162946_alter_tables_for_integrations_data/migration.sql create mode 100644 internal-packages/database/prisma/migrations/20260129165555_add_organization_integration_idx/migration.sql create mode 100644 internal-packages/database/prisma/migrations/20260129165809_add_worker_deployment_idx/migration.sql diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 51a468b50c..8d3376ecee 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -129,6 +129,7 @@ "@unkey/cache": "^1.5.0", "@unkey/error": "^0.2.0", "@upstash/ratelimit": "^1.1.3", + "@vercel/sdk": "^1.18.5", "@whatwg-node/fetch": "^0.9.14", "ai": "^4.3.19", "assert-never": "^1.2.1", diff --git a/internal-packages/database/prisma/migrations/20260129162621_add_organization_project_integration/migration.sql b/internal-packages/database/prisma/migrations/20260129162621_add_organization_project_integration/migration.sql new file mode 100644 index 0000000000..2c18bd2e1d --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260129162621_add_organization_project_integration/migration.sql @@ -0,0 +1,29 @@ +-- CreateTable +CREATE TABLE "public"."OrganizationProjectIntegration" ( + "id" TEXT NOT NULL, + "organizationIntegrationId" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "externalEntityId" TEXT NOT NULL, + "integrationData" JSONB NOT NULL, + "installedBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "OrganizationProjectIntegration_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "OrganizationProjectIntegration_projectId_idx" ON "public"."OrganizationProjectIntegration"("projectId"); + +-- CreateIndex +CREATE INDEX "OrganizationProjectIntegration_projectId_organizationIntegr_idx" ON "public"."OrganizationProjectIntegration"("projectId", "organizationIntegrationId"); + +-- CreateIndex +CREATE INDEX "OrganizationProjectIntegration_externalEntityId_idx" ON "public"."OrganizationProjectIntegration"("externalEntityId"); + +-- AddForeignKey +ALTER TABLE "public"."OrganizationProjectIntegration" ADD CONSTRAINT "OrganizationProjectIntegration_organizationIntegrationId_fkey" FOREIGN KEY ("organizationIntegrationId") REFERENCES "public"."OrganizationIntegration"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."OrganizationProjectIntegration" ADD CONSTRAINT "OrganizationProjectIntegration_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/internal-packages/database/prisma/migrations/20260129162810_add_integration_deployment/migration.sql b/internal-packages/database/prisma/migrations/20260129162810_add_integration_deployment/migration.sql new file mode 100644 index 0000000000..5594398d06 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260129162810_add_integration_deployment/migration.sql @@ -0,0 +1,21 @@ +-- CreateTable +CREATE TABLE "public"."IntegrationDeployment" ( + "id" TEXT NOT NULL, + "integrationName" TEXT NOT NULL, + "integrationDeploymentId" TEXT NOT NULL, + "commitSHA" TEXT NOT NULL, + "deploymentId" TEXT, + "status" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "IntegrationDeployment_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "IntegrationDeployment_deploymentId_idx" ON "public"."IntegrationDeployment"("deploymentId"); + +-- CreateIndex +CREATE INDEX "IntegrationDeployment_commitSHA_idx" ON "public"."IntegrationDeployment"("commitSHA"); + +-- AddForeignKey +ALTER TABLE "public"."IntegrationDeployment" ADD CONSTRAINT "IntegrationDeployment_deploymentId_fkey" FOREIGN KEY ("deploymentId") REFERENCES "public"."WorkerDeployment"("id") ON DELETE SET NULL ON UPDATE CASCADE; \ No newline at end of file diff --git a/internal-packages/database/prisma/migrations/20260129162946_alter_tables_for_integrations_data/migration.sql b/internal-packages/database/prisma/migrations/20260129162946_alter_tables_for_integrations_data/migration.sql new file mode 100644 index 0000000000..345d337f18 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260129162946_alter_tables_for_integrations_data/migration.sql @@ -0,0 +1,9 @@ +-- AlterEnum +ALTER TYPE "public"."IntegrationService" ADD VALUE 'VERCEL'; + +-- AlterTable +ALTER TABLE "public"."OrganizationIntegration" ADD COLUMN "deletedAt" TIMESTAMP(3), +ADD COLUMN "externalOrganizationId" TEXT; + +-- AlterTable +ALTER TABLE "public"."WorkerDeployment" ADD COLUMN "commitSHA" TEXT; diff --git a/internal-packages/database/prisma/migrations/20260129165555_add_organization_integration_idx/migration.sql b/internal-packages/database/prisma/migrations/20260129165555_add_organization_integration_idx/migration.sql new file mode 100644 index 0000000000..ac24fc4bdb --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260129165555_add_organization_integration_idx/migration.sql @@ -0,0 +1,3 @@ +-- CreateIndex +CREATE INDEX CONCURRENTLY IF NOT EXISTS "OrganizationIntegration_externalOrganizationId_idx" ON "public"."OrganizationIntegration"("externalOrganizationId"); + diff --git a/internal-packages/database/prisma/migrations/20260129165809_add_worker_deployment_idx/migration.sql b/internal-packages/database/prisma/migrations/20260129165809_add_worker_deployment_idx/migration.sql new file mode 100644 index 0000000000..fcf74c0d97 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260129165809_add_worker_deployment_idx/migration.sql @@ -0,0 +1,3 @@ + +-- CreateIndex +CREATE INDEX CONCURRENTLY IF NOT EXISTS "WorkerDeployment_commitSHA_idx" ON "public"."WorkerDeployment"("commitSHA"); \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index c76b411412..4f768b4710 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -384,28 +384,29 @@ model Project { /// The master queues they are allowed to use (impacts what they can set as default and trigger runs with) allowedWorkerQueues String[] @default([]) @map("allowedMasterQueues") - environments RuntimeEnvironment[] - backgroundWorkers BackgroundWorker[] - backgroundWorkerTasks BackgroundWorkerTask[] - taskRuns TaskRun[] - runTags TaskRunTag[] - taskQueues TaskQueue[] - environmentVariables EnvironmentVariable[] - checkpoints Checkpoint[] - WorkerDeployment WorkerDeployment[] - CheckpointRestoreEvent CheckpointRestoreEvent[] - taskSchedules TaskSchedule[] - alertChannels ProjectAlertChannel[] - alerts ProjectAlert[] - alertStorages ProjectAlertStorage[] - bulkActionGroups BulkActionGroup[] - BackgroundWorkerFile BackgroundWorkerFile[] - waitpoints Waitpoint[] - taskRunWaitpoints TaskRunWaitpoint[] - taskRunCheckpoints TaskRunCheckpoint[] - waitpointTags WaitpointTag[] - connectedGithubRepository ConnectedGithubRepository? - customerQueries CustomerQuery[] + environments RuntimeEnvironment[] + backgroundWorkers BackgroundWorker[] + backgroundWorkerTasks BackgroundWorkerTask[] + taskRuns TaskRun[] + runTags TaskRunTag[] + taskQueues TaskQueue[] + environmentVariables EnvironmentVariable[] + checkpoints Checkpoint[] + WorkerDeployment WorkerDeployment[] + CheckpointRestoreEvent CheckpointRestoreEvent[] + taskSchedules TaskSchedule[] + alertChannels ProjectAlertChannel[] + alerts ProjectAlert[] + alertStorages ProjectAlertStorage[] + bulkActionGroups BulkActionGroup[] + BackgroundWorkerFile BackgroundWorkerFile[] + waitpoints Waitpoint[] + taskRunWaitpoints TaskRunWaitpoint[] + taskRunCheckpoints TaskRunCheckpoint[] + waitpointTags WaitpointTag[] + connectedGithubRepository ConnectedGithubRepository? + organizationProjectIntegration OrganizationProjectIntegration[] + customerQueries CustomerQuery[] buildSettings Json? taskScheduleInstances TaskScheduleInstance[] @@ -1825,9 +1826,10 @@ model WorkerDeployment { worker BackgroundWorker? @relation(fields: [workerId], references: [id], onDelete: Cascade, onUpdate: Cascade) workerId String? @unique - triggeredBy User? @relation(fields: [triggeredById], references: [id], onDelete: SetNull, onUpdate: Cascade) - triggeredById String? - triggeredVia String? + triggeredBy User? @relation(fields: [triggeredById], references: [id], onDelete: SetNull, onUpdate: Cascade) + triggeredById String? + triggeredVia String? + commitSHA String? startedAt DateTime? installedAt DateTime? @@ -1846,12 +1848,14 @@ model WorkerDeployment { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - promotions WorkerDeploymentPromotion[] - alerts ProjectAlert[] - workerInstance WorkerInstance[] + promotions WorkerDeploymentPromotion[] + alerts ProjectAlert[] + workerInstance WorkerInstance[] + integrationDeployments IntegrationDeployment[] @@unique([projectId, shortCode]) @@unique([environmentId, version]) + @@index([commitSHA]) } enum WorkerDeploymentStatus { @@ -2088,7 +2092,8 @@ model OrganizationIntegration { friendlyId String @unique - service IntegrationService + service IntegrationService + externalOrganizationId String? /// Identifier for external, integration's organization (e.g. Vercel's team) integrationData Json @@ -2100,12 +2105,39 @@ model OrganizationIntegration { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + deletedAt DateTime? + + alertChannels ProjectAlertChannel[] + organizationProjectIntegration OrganizationProjectIntegration[] + + @@index([externalOrganizationId]) +} - alertChannels ProjectAlertChannel[] +model OrganizationProjectIntegration { + id String @id @default(cuid()) + + organizationIntegration OrganizationIntegration @relation(fields: [organizationIntegrationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + organizationIntegrationId String + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + externalEntityId String /// Identifier for webhooks, for example Vercel's projectId + integrationData Json /// Save useful data like config or external entity name + installedBy String? /// UserId who installed the integration + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + @@index([projectId]) + @@index([projectId, organizationIntegrationId]) + @@index([externalEntityId]) } enum IntegrationService { SLACK + VERCEL } /// Bulk actions, like canceling and replaying runs @@ -2486,3 +2518,20 @@ model CustomerQuery { /// For Stripe metering job - find unprocessed queries @@index([createdAt]) } + +model IntegrationDeployment { + id String @id @default(cuid()) + + integrationName String /// For example Vercel + integrationDeploymentId String /// External ID + commitSHA String + deploymentId String? + status String? /// External deployment status + + workerDeployment WorkerDeployment? @relation(fields: [deploymentId], references: [id], onDelete: SetNull, onUpdate: Cascade) + + createdAt DateTime @default(now()) + + @@index([commitSHA]) + @@index([deploymentId]) +} diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index 0291d2a05c..71147dc308 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -694,6 +694,8 @@ export const GetDeploymentResponseBody = z.object({ version: z.string(), imageReference: z.string().nullish(), imagePlatform: z.string(), + commitSHA: z.string().nullish(), + integrationDeploymentId: z.string().nullish(), externalBuildData: ExternalBuildData.optional().nullable(), errorData: DeploymentErrorData.nullish(), worker: z diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99024a016b..edd250e184 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -521,6 +521,9 @@ importers: '@upstash/ratelimit': specifier: ^1.1.3 version: 1.1.3(patch_hash=e5922e50fbefb7b2b24950c4b1c5c9ddc4cd25464439c9548d2298c432debe74) + '@vercel/sdk': + specifier: ^1.18.5 + version: 1.18.5 '@whatwg-node/fetch': specifier: ^0.9.14 version: 0.9.14 @@ -11115,6 +11118,10 @@ packages: engines: {node: '>=18.14'} deprecated: '@vercel/postgres is deprecated. You can either choose an alternate storage solution from the Vercel Marketplace if you want to set up a new database. Or you can follow this guide to migrate your existing Vercel Postgres db: https://neon.com/docs/guides/vercel-postgres-transition-guide' + '@vercel/sdk@1.18.5': + resolution: {integrity: sha512-tzxGuUxYZQpKsf5WrImp4gnCZu3xhHA4j6KLSAQdXgpi/Lfk3JV5YGEDU6ZZBIwXDMdny5DozLd20tz/bKZsfg==} + hasBin: true + '@vitest/coverage-v8@3.1.4': resolution: {integrity: sha512-G4p6OtioySL+hPV7Y6JHlhpsODbJzt1ndwHAFkyk6vVjpK03PFsKnauZIzcd0PrK4zAbc5lc+jeZ+eNGiMA+iw==} peerDependencies: @@ -31330,6 +31337,14 @@ snapshots: transitivePeerDependencies: - utf-8-validate + '@vercel/sdk@1.18.5': + dependencies: + '@modelcontextprotocol/sdk': 1.24.2(supports-color@10.0.0)(zod@3.25.76) + zod: 3.25.76 + transitivePeerDependencies: + - '@cfworker/json-schema' + - supports-color + '@vitest/coverage-v8@3.1.4(vitest@3.1.4(@types/debug@4.1.12)(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1))': dependencies: '@ampproject/remapping': 2.3.0 From bf394dba9dc732698b4702423c5686a33fc42d84 Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Fri, 9 Jan 2026 14:17:20 +0100 Subject: [PATCH 02/33] feat(vercel): add Vercel integration settings UI and actions Add a full Remix route and UI for managing Vercel integration settings for a project environment. Implement client and server pieces including forms, validation schemas, presenters, and service calls so users can view and update connection details, toggle sync/build/deploy options, select staging environment, and complete or skip onboarding. Key changes: - Add new route file handling Vercel settings and onboarding flows. - Introduce form schemas (zod) for update, disconnect, complete and skip onboarding actions and parsing with conform/to-zod. - Implement UI components and layout using existing primitives (Dialog, Button, Fieldset, Select, Switch, etc.) and helper functions (formatVercelTargets, cn). - Wire server-side logic to look up projects, environments, and org integrations and to call VercelIntegrationService and presenters. - Add types for ConnectedVercelProject, integration data and mappings. - Add imports for utilities: path builders, message redirects, logging, session requirement, and typed fetcher helpers. Reasoning: - Provide a unified interface to configure and manage Vercel integrations per environment, improving developer experience when syncing envvars, triggering builds/deploys, and completing onboarding. --- .../app/components/GitHubLoginButton.tsx | 2 - apps/webapp/app/env.server.ts | 4 + .../app/models/orgIntegration.server.ts | 16 + .../app/models/vercelIntegration.server.ts | 1498 +++++++++++++++ .../EnvironmentVariablesPresenter.server.ts | 83 + .../v3/VercelSettingsPresenter.server.ts | 286 +++ .../route.tsx | 158 +- .../route.tsx | 155 +- .../app/routes/_app.vercel.install/route.tsx | 73 + ....projects.$projectParam.vercel.projects.ts | 122 ++ apps/webapp/app/routes/callback.vercel.ts | 267 +++ ...ents.$environmentId.regenerate-api-key.tsx | 44 + ...cts.$projectParam.env.$envParam.vercel.tsx | 1615 +++++++++++++++++ .../app/services/vercelIntegration.server.ts | 484 +++++ apps/webapp/app/utils/pathBuilder.ts | 18 + apps/webapp/app/v3/vercel/index.ts | 8 + .../app/v3/vercel/vercelOAuthState.server.ts | 58 + .../vercel/vercelProjectIntegrationSchema.ts | 243 +++ packages/core/src/v3/schemas/api.ts | 1 + 19 files changed, 5125 insertions(+), 10 deletions(-) create mode 100644 apps/webapp/app/models/vercelIntegration.server.ts create mode 100644 apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts create mode 100644 apps/webapp/app/routes/_app.vercel.install/route.tsx create mode 100644 apps/webapp/app/routes/api.v1.orgs.$organizationSlug.projects.$projectParam.vercel.projects.ts create mode 100644 apps/webapp/app/routes/callback.vercel.ts create mode 100644 apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx create mode 100644 apps/webapp/app/services/vercelIntegration.server.ts create mode 100644 apps/webapp/app/v3/vercel/index.ts create mode 100644 apps/webapp/app/v3/vercel/vercelOAuthState.server.ts create mode 100644 apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts diff --git a/apps/webapp/app/components/GitHubLoginButton.tsx b/apps/webapp/app/components/GitHubLoginButton.tsx index 87238db087..76a494927c 100644 --- a/apps/webapp/app/components/GitHubLoginButton.tsx +++ b/apps/webapp/app/components/GitHubLoginButton.tsx @@ -32,8 +32,6 @@ export function OctoKitty({ className }: { className?: string }) { baseProfile="tiny" id="Layer_1" xmlns="http://www.w3.org/2000/svg" - x="0px" - y="0px" viewBox="0 0 2350 2314.8" xmlSpace="preserve" fill="currentColor" diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index dcbcac079a..b7c7f9e432 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -425,6 +425,10 @@ const EnvironmentSchema = z ORG_SLACK_INTEGRATION_CLIENT_ID: z.string().optional(), ORG_SLACK_INTEGRATION_CLIENT_SECRET: z.string().optional(), + /** Vercel integration OAuth credentials */ + VERCEL_INTEGRATION_CLIENT_ID: z.string().optional(), + VERCEL_INTEGRATION_CLIENT_SECRET: z.string().optional(), + /** These enable the alerts feature in v3 */ ALERT_EMAIL_TRANSPORT: z.enum(["resend", "smtp", "aws-ses"]).optional(), ALERT_FROM_EMAIL: z.string().optional(), diff --git a/apps/webapp/app/models/orgIntegration.server.ts b/apps/webapp/app/models/orgIntegration.server.ts index 343da2701d..d14cef6c60 100644 --- a/apps/webapp/app/models/orgIntegration.server.ts +++ b/apps/webapp/app/models/orgIntegration.server.ts @@ -89,6 +89,22 @@ export class OrgIntegrationRepository { static isSlackSupported = !!env.ORG_SLACK_INTEGRATION_CLIENT_ID && !!env.ORG_SLACK_INTEGRATION_CLIENT_SECRET; + static isVercelSupported = + !!env.VERCEL_INTEGRATION_CLIENT_ID && !!env.VERCEL_INTEGRATION_CLIENT_SECRET; + + /** + * Generate the URL to install the Vercel integration. + * Users are redirected to Vercel's marketplace to complete the installation. + * + * @param state - Base64-encoded state containing org/project info for the callback + */ + static vercelInstallUrl(state: string): string { + // The user goes to Vercel's marketplace to install the integration + // After installation, Vercel redirects to our callback with the authorization code + const redirectUri = encodeURIComponent(`${env.APP_ORIGIN}/callback/vercel`); + return `https://vercel.com/integrations/trigger/new?state=${state}&redirect_uri=${redirectUri}`; + } + static slackAuthorizationUrl( state: string, scopes: string[] = [ diff --git a/apps/webapp/app/models/vercelIntegration.server.ts b/apps/webapp/app/models/vercelIntegration.server.ts new file mode 100644 index 0000000000..248227d866 --- /dev/null +++ b/apps/webapp/app/models/vercelIntegration.server.ts @@ -0,0 +1,1498 @@ +import { Vercel } from "@vercel/sdk"; +import { + IntegrationService, + Organization, + OrganizationIntegration, + SecretReference, +} from "@trigger.dev/database"; +import { z } from "zod"; +import { $transaction, prisma } from "~/db.server"; +import { logger } from "~/services/logger.server"; +import { getSecretStore } from "~/services/secrets/secretStore.server"; +import { generateFriendlyId } from "~/v3/friendlyIdentifiers"; +import { + createDefaultVercelIntegrationData, + SyncEnvVarsMapping, + shouldSyncEnvVar, + TriggerEnvironmentType, +} from "~/v3/vercel/vercelProjectIntegrationSchema"; +import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; + +/** + * Schema for the Vercel OAuth token stored in SecretReference + */ +export const VercelSecretSchema = z.object({ + accessToken: z.string(), + tokenType: z.string().optional(), + teamId: z.string().nullable().optional(), + userId: z.string().optional(), + installationId: z.string().optional(), + raw: z.record(z.any()).optional(), +}); + +export type VercelSecret = z.infer; + +/** + * Represents a Vercel environment variable with metadata + */ +export type VercelEnvironmentVariable = { + id: string; + key: string; + /** + * Type of the environment variable. + * "secret" or "sensitive" types cannot have their values retrieved. + */ + type: "system" | "encrypted" | "plain" | "sensitive" | "secret"; + /** + * Whether this env var is a secret (value cannot be synced) + */ + isSecret: boolean; + /** + * Target environments for this variable + */ + target: string[]; + /** + * Whether this is a shared (team-level) environment variable + */ + isShared?: boolean; +}; + +/** + * Represents a custom Vercel environment + */ +export type VercelCustomEnvironment = { + id: string; + slug: string; + description?: string; + branchMatcher?: { + pattern: string; + type: string; + }; +}; + +/** + * Repository for interacting with Vercel API using @vercel/sdk + */ +export class VercelIntegrationRepository { + /** + * Get an authenticated Vercel SDK client for an integration + */ + static async getVercelClient( + integration: OrganizationIntegration & { tokenReference: SecretReference } + ): Promise { + const secretStore = getSecretStore(integration.tokenReference.provider); + + const secret = await secretStore.getSecret( + VercelSecretSchema, + integration.tokenReference.key + ); + + if (!secret) { + throw new Error("Failed to get Vercel access token"); + } + + return new Vercel({ + bearerToken: secret.accessToken, + }); + } + + /** + * Get the team ID from an integration's stored secret + */ + static async getTeamIdFromIntegration( + integration: OrganizationIntegration & { tokenReference: SecretReference } + ): Promise { + const secretStore = getSecretStore(integration.tokenReference.provider); + + const secret = await secretStore.getSecret( + VercelSecretSchema, + integration.tokenReference.key + ); + + if (!secret) { + return null; + } + + return secret.teamId ?? null; + } + + /** + * Fetch custom environments for a Vercel project. + * Excludes standard environments (production, preview, development). + */ + static async getVercelCustomEnvironments( + client: Vercel, + projectId: string, + teamId?: string | null + ): Promise { + try { + const response = await client.environment.getV9ProjectsIdOrNameCustomEnvironments({ + idOrName: projectId, + ...(teamId && { teamId }), + }); + + // The response contains environments array + const environments = response.environments || []; + + return environments.map((env: any) => ({ + id: env.id, + slug: env.slug, + description: env.description, + branchMatcher: env.branchMatcher, + })); + } catch (error) { + logger.error("Failed to fetch Vercel custom environments", { + projectId, + teamId, + error, + }); + return []; + } + } + + /** + * Fetch all environment variables for a Vercel project. + * Returns metadata about each variable including whether it's a secret. + */ + static async getVercelEnvironmentVariables( + client: Vercel, + projectId: string, + teamId?: string | null + ): Promise { + try { + const response = await client.projects.filterProjectEnvs({ + idOrName: projectId, + ...(teamId && { teamId }), + }); + + // The response is a union type - check if it has envs array + const envs = "envs" in response && Array.isArray(response.envs) ? response.envs : []; + + return envs.map((env: any) => { + const type = env.type as VercelEnvironmentVariable["type"]; + // Secret and sensitive types cannot have their values retrieved + const isSecret = type === "secret" || type === "sensitive"; + + return { + id: env.id, + key: env.key, + type, + isSecret, + target: Array.isArray(env.target) ? env.target : [env.target].filter(Boolean), + }; + }); + } catch (error) { + logger.error("Failed to fetch Vercel environment variables", { + projectId, + teamId, + error, + }); + return []; + } + } + + /** + * Represents an environment variable with its decrypted value + */ + static async getVercelEnvironmentVariableValues( + client: Vercel, + projectId: string, + teamId?: string | null, + target?: string // Optional: filter by Vercel environment (production, preview, etc.) + ): Promise< + Array<{ + key: string; + value: string; + target: string[]; + type: string; + isSecret: boolean; + }> + > { + try { + const response = await client.projects.filterProjectEnvs({ + idOrName: projectId, + ...(teamId && { teamId }), + decrypt: "true", + }); + + // The response is a union type - check if it has envs array + const envs = + "envs" in response && Array.isArray(response.envs) ? response.envs : []; + + // Filter and map env vars + const result = envs + .filter((env: any) => { + // Skip env vars without values (secrets/sensitive types won't have values even with decrypt=true) + if (!env.value) { + return false; + } + // Filter by target if provided + if (target) { + const envTargets = Array.isArray(env.target) + ? env.target + : [env.target].filter(Boolean); + return envTargets.includes(target); + } + return true; + }) + .map((env: any) => { + const type = env.type as string; + const isSecret = type === "secret" || type === "sensitive"; + + return { + key: env.key as string, + value: env.value as string, + target: Array.isArray(env.target) + ? env.target + : [env.target].filter(Boolean), + type, + isSecret, + }; + }); + + return result; + } catch (error) { + logger.error("Failed to fetch Vercel environment variable values", { + projectId, + teamId, + target, + error, + }); + return []; + } + } + + /** + * Fetch shared environment variables metadata from Vercel team. + * Returns metadata about each variable (not values). + * Shared env vars are team-level variables that can be linked to multiple projects. + */ + static async getVercelSharedEnvironmentVariables( + client: Vercel, + teamId: string, + projectId?: string // Optional: filter by project + ): Promise< + Array<{ + id: string; + key: string; + type: string; + isSecret: boolean; + target: string[]; + }> + > { + try { + const response = await client.environment.listSharedEnvVariable({ + teamId, + ...(projectId && { projectId }), + }); + + const envVars = response.data || []; + + return envVars.map((env) => { + const type = (env.type as string) || "plain"; + const isSecret = type === "secret" || type === "sensitive"; + + return { + id: env.id as string, + key: env.key as string, + type, + isSecret, + target: Array.isArray(env.target) + ? (env.target as string[]) + : [env.target].filter(Boolean) as string[], + }; + }); + } catch (error) { + logger.error("Failed to fetch Vercel shared environment variables", { + teamId, + projectId, + error, + }); + return []; + } + } + + /** + * Fetch shared environment variables from Vercel team with their values. + * Returns decrypted values where available. + * Shared env vars are team-level variables that can be linked to multiple projects. + */ + static async getVercelSharedEnvironmentVariableValues( + client: Vercel, + teamId: string, + projectId?: string // Optional: filter by project + ): Promise< + Array<{ + key: string; + value: string; + target: string[]; + type: string; + isSecret: boolean; + applyToAllCustomEnvironments?: boolean; + }> + > { + try { + // First, get the list of shared env vars + logger.debug("Fetching shared env vars list from Vercel", { + teamId, + projectId, + }); + + const listResponse = await client.environment.listSharedEnvVariable({ + teamId, + ...(projectId && { projectId }), + }); + + const envVars = listResponse.data || []; + + logger.info("Listed shared env vars from Vercel", { + teamId, + projectId, + count: envVars.length, + envVarsInfo: envVars.map((e) => ({ + key: e.key, + type: e.type, + hasValueInList: !!e.value, + decrypted: (e as any).decrypted, + target: e.target, + })), + }); + + if (envVars.length === 0) { + return []; + } + + // Process each shared env var + // The list response may already include values for plain types + // For encrypted types, we need to call getSharedEnvVar for decrypted values + const results = await Promise.all( + envVars.map(async (env) => { + const type = (env.type as string) || "plain"; + // Note: Vercel shared env var types are: plain, encrypted, sensitive, system + // sensitive types should not have values returned (like secrets) + const isSecret = type === "sensitive"; + + // Skip sensitive types early - they won't have values + if (isSecret) { + logger.debug("Skipping sensitive shared env var", { + teamId, + envKey: env.key, + type, + }); + return null; + } + + // Check if value is already available in list response + const listValue = (env as any).value as string | undefined; + const applyToAllCustomEnvs = (env as any).applyToAllCustomEnvironments as boolean | undefined; + + if (listValue) { + logger.debug("Using value from list response for shared env var", { + teamId, + envKey: env.key, + type, + valueLength: listValue.length, + applyToAllCustomEnvironments: applyToAllCustomEnvs, + }); + + return { + key: env.key as string, + value: listValue, + target: Array.isArray(env.target) + ? (env.target as string[]) + : [env.target].filter(Boolean) as string[], + type, + isSecret, + applyToAllCustomEnvironments: applyToAllCustomEnvs, + }; + } + + // Value not in list response, try fetching with getSharedEnvVar + try { + logger.debug("Fetching decrypted value for shared env var", { + teamId, + envId: env.id, + envKey: env.key, + envType: env.type, + envTarget: env.target, + }); + + // Get the decrypted value for this shared env var + const getResponse = await client.environment.getSharedEnvVar({ + id: env.id as string, + teamId, + }); + + logger.debug("Got response for shared env var from getSharedEnvVar", { + teamId, + envId: env.id, + envKey: env.key, + hasValue: !!getResponse.value, + valueLength: getResponse.value?.length, + isDecrypted: (getResponse as any).decrypted, + responseKeys: Object.keys(getResponse), + }); + + // Skip if no value + if (!getResponse.value) { + logger.debug("Skipping shared env var - no value returned from getSharedEnvVar", { + teamId, + envId: env.id, + envKey: env.key, + type, + }); + return null; + } + + const result = { + key: env.key as string, + value: getResponse.value as string, + target: Array.isArray(env.target) + ? (env.target as string[]) + : [env.target].filter(Boolean) as string[], + type, + isSecret, + applyToAllCustomEnvironments: (env as any).applyToAllCustomEnvironments as boolean | undefined, + }; + + logger.debug("Successfully fetched shared env var value from getSharedEnvVar", { + teamId, + envKey: result.key, + target: result.target, + valueLength: result.value.length, + }); + + return result; + } catch (error) { + // Try to extract value from error.rawValue if it's a ResponseValidationError + // The API response is valid but SDK schema validation fails (e.g., deletedAt: null vs expected number) + let errorValue: string | undefined; + if (error && typeof error === "object" && "rawValue" in error) { + const rawValue = (error as any).rawValue; + if (rawValue && typeof rawValue === "object" && "value" in rawValue) { + errorValue = rawValue.value as string | undefined; + } + } + + // Use error.rawValue if available, otherwise fall back to listValue + const fallbackValue = errorValue || listValue; + + if (fallbackValue) { + logger.warn("getSharedEnvVar failed validation, using value from error.rawValue or list response", { + teamId, + envId: env.id, + envKey: env.key, + error: error instanceof Error ? error.message : String(error), + hasErrorRawValue: !!errorValue, + hasListValue: !!listValue, + valueLength: fallbackValue.length, + }); + return { + key: env.key as string, + value: fallbackValue, + target: Array.isArray(env.target) + ? (env.target as string[]) + : [env.target].filter(Boolean) as string[], + type, + isSecret, + applyToAllCustomEnvironments: applyToAllCustomEnvs, + }; + } + + // No fallback value available, skip this env var + logger.warn("Failed to get decrypted value for shared env var, no fallback available", { + teamId, + projectId, + envId: env.id, + envKey: env.key, + error: error instanceof Error ? error.message : String(error), + errorStack: error instanceof Error ? error.stack : undefined, + hasRawValue: error && typeof error === "object" && "rawValue" in error, + }); + return null; + } + }) + ); + + // Filter out null results (failed fetches or sensitive types) + const validResults = results.filter((r): r is NonNullable => r !== null); + + logger.info("Completed fetching shared env var values", { + teamId, + projectId, + totalListed: envVars.length, + successfullyFetched: validResults.length, + failedOrSkipped: envVars.length - validResults.length, + fetchedKeys: validResults.map((r) => r.key), + }); + + return validResults; + } catch (error) { + logger.error("Failed to fetch Vercel shared environment variable values", { + teamId, + projectId, + error: error instanceof Error ? error.message : String(error), + errorStack: error instanceof Error ? error.stack : undefined, + }); + return []; + } + } + + /** + * Fetch Vercel projects for a team or user + */ + static async getVercelProjects( + client: Vercel, + teamId?: string | null + ): Promise> { + try { + const response = await client.projects.getProjects({ + ...(teamId && { teamId }), + }); + + const projects = response.projects || []; + + return projects.map((project: any) => ({ + id: project.id, + name: project.name, + })); + } catch (error) { + logger.error("Failed to fetch Vercel projects", { + teamId, + error, + }); + return []; + } + } + + /** + * Create a Vercel organization integration from OAuth callback data. + * This stores the access token and creates the OrganizationIntegration record. + */ + static async createVercelOrgIntegration(params: { + accessToken: string; + tokenType?: string; + teamId: string | null; + userId?: string; + installationId?: string; + organization: Organization; + raw?: Record; + }): Promise { + const result = await $transaction(prisma, async (tx) => { + const secretStore = getSecretStore("DATABASE", { + prismaClient: tx, + }); + + const integrationFriendlyId = generateFriendlyId("org_integration"); + + const secretValue: VercelSecret = { + accessToken: params.accessToken, + tokenType: params.tokenType, + teamId: params.teamId, + userId: params.userId, + installationId: params.installationId, + raw: params.raw, + }; + + logger.debug("Storing Vercel secret", { + teamId: params.teamId, + installationId: params.installationId, + }); + + await secretStore.setSecret(integrationFriendlyId, secretValue); + + const reference = await tx.secretReference.create({ + data: { + provider: "DATABASE", + key: integrationFriendlyId, + }, + }); + + return await tx.organizationIntegration.create({ + data: { + friendlyId: integrationFriendlyId, + organizationId: params.organization.id, + service: "VERCEL", + externalOrganizationId: params.teamId, + tokenReferenceId: reference.id, + integrationData: { + teamId: params.teamId, + userId: params.userId, + installationId: params.installationId, + } as any, + }, + }); + }); + + if (!result) { + throw new Error("Failed to create Vercel organization integration"); + } + + return result; + } + + /** + * Create a Vercel project integration linking a Vercel project to a Trigger.dev project + */ + static async createVercelProjectIntegration(params: { + organizationIntegrationId: string; + projectId: string; + vercelProjectId: string; + vercelProjectName: string; + vercelTeamId: string | null; + installedByUserId?: string; + }) { + const integrationData = createDefaultVercelIntegrationData( + params.vercelProjectId, + params.vercelProjectName, + params.vercelTeamId + ); + + return prisma.organizationProjectIntegration.create({ + data: { + organizationIntegrationId: params.organizationIntegrationId, + projectId: params.projectId, + externalEntityId: params.vercelProjectId, + integrationData: integrationData as any, + installedBy: params.installedByUserId, + }, + }); + } + + /** + * Find an existing Vercel organization integration by team ID + */ + static async findVercelOrgIntegrationByTeamId( + organizationId: string, + teamId: string | null + ): Promise<(OrganizationIntegration & { tokenReference: SecretReference }) | null> { + return prisma.organizationIntegration.findFirst({ + where: { + organizationId, + service: "VERCEL", + externalOrganizationId: teamId, + deletedAt: null, + }, + include: { + tokenReference: true, + }, + }); + } + + /** + * Find Vercel organization integration for a project + */ + static async findVercelOrgIntegrationForProject( + projectId: string + ): Promise<(OrganizationIntegration & { tokenReference: SecretReference }) | null> { + const projectIntegration = await prisma.organizationProjectIntegration.findFirst({ + where: { + projectId, + deletedAt: null, + organizationIntegration: { + service: "VERCEL", + deletedAt: null, + }, + }, + include: { + organizationIntegration: { + include: { + tokenReference: true, + }, + }, + }, + }); + + return projectIntegration?.organizationIntegration ?? null; + } + + /** + * Find Vercel organization integration by organization ID + */ + static async findVercelOrgIntegrationByOrganization( + organizationId: string + ): Promise<(OrganizationIntegration & { tokenReference: SecretReference }) | null> { + return prisma.organizationIntegration.findFirst({ + where: { + organizationId, + service: "VERCEL", + deletedAt: null, + }, + include: { + tokenReference: true, + }, + }); + } + + /** + * Sync Trigger.dev API keys to Vercel as sensitive environment variables. + * Uses batch operations to minimize API calls. + * + * Mapping: + * - Production API key → Vercel "production" environment + * - Staging API key → Vercel custom environment (from vercelStagingEnvironment config) + * - Preview API key → Vercel "preview" environment + * - Development API key → Vercel "development" environment + * + * @param projectId - The Trigger.dev project ID + * @param vercelProjectId - The Vercel project ID + * @param teamId - The Vercel team ID (optional) + * @param vercelStagingEnvironment - The custom Vercel environment slug for staging (optional) + */ + static async syncApiKeysToVercel(params: { + projectId: string; + vercelProjectId: string; + teamId: string | null; + vercelStagingEnvironment?: string | null; + orgIntegration: OrganizationIntegration & { tokenReference: SecretReference }; + }): Promise<{ success: boolean; errors: string[] }> { + const errors: string[] = []; + + try { + const client = await this.getVercelClient(params.orgIntegration); + + // Get all environments for the project + const environments = await prisma.runtimeEnvironment.findMany({ + where: { + projectId: params.projectId, + type: { + in: ["PRODUCTION", "STAGING", "PREVIEW", "DEVELOPMENT"], + }, + }, + select: { + id: true, + type: true, + apiKey: true, + }, + }); + + // Build the list of env vars to sync + const envVarsToSync: Array<{ + key: string; + value: string; + target: string[]; + type: "sensitive" | "encrypted" | "plain"; + environmentType: string; + }> = []; + + for (const env of environments) { + let vercelTarget: string[]; + + switch (env.type) { + case "PRODUCTION": + vercelTarget = ["production"]; + break; + case "STAGING": + // If no custom staging environment is mapped, skip staging sync + if (!params.vercelStagingEnvironment) { + logger.debug("Skipping staging API key sync - no custom environment mapped", { + projectId: params.projectId, + vercelProjectId: params.vercelProjectId, + }); + continue; + } + vercelTarget = [params.vercelStagingEnvironment]; + break; + case "PREVIEW": + vercelTarget = ["preview"]; + break; + case "DEVELOPMENT": + vercelTarget = ["development"]; + break; + default: + continue; + } + + envVarsToSync.push({ + key: "TRIGGER_SECRET_KEY", + value: env.apiKey, + target: vercelTarget, + type: "sensitive", + environmentType: env.type, + }); + } + + if (envVarsToSync.length === 0) { + logger.debug("No API keys to sync to Vercel", { + projectId: params.projectId, + vercelProjectId: params.vercelProjectId, + }); + return { success: true, errors: [] }; + } + + // Use batch upsert to sync all env vars + const result = await this.batchUpsertVercelEnvVars({ + client, + vercelProjectId: params.vercelProjectId, + teamId: params.teamId, + envVars: envVarsToSync, + }); + + if (result.errors.length > 0) { + errors.push(...result.errors); + } + + logger.info("Synced API keys to Vercel", { + projectId: params.projectId, + vercelProjectId: params.vercelProjectId, + syncedCount: result.created + result.updated, + created: result.created, + updated: result.updated, + errors: result.errors, + }); + + return { + success: errors.length === 0, + errors, + }; + } catch (error) { + const errorMessage = `Failed to sync API keys to Vercel: ${error instanceof Error ? error.message : "Unknown error"}`; + errors.push(errorMessage); + logger.error(errorMessage, { + projectId: params.projectId, + vercelProjectId: params.vercelProjectId, + error, + }); + return { + success: false, + errors, + }; + } + } + + /** + * Sync a single API key to Vercel for a specific environment. + * Used when API keys are regenerated. + */ + static async syncSingleApiKeyToVercel(params: { + projectId: string; + environmentType: "PRODUCTION" | "STAGING" | "PREVIEW" | "DEVELOPMENT"; + apiKey: string; + }): Promise<{ success: boolean; error?: string }> { + try { + // Get the project integration + const projectIntegration = await prisma.organizationProjectIntegration.findFirst({ + where: { + projectId: params.projectId, + deletedAt: null, + organizationIntegration: { + service: "VERCEL", + deletedAt: null, + }, + }, + include: { + organizationIntegration: { + include: { + tokenReference: true, + }, + }, + }, + }); + + if (!projectIntegration) { + // No Vercel integration - nothing to sync + return { success: true }; + } + + const orgIntegration = projectIntegration.organizationIntegration; + const client = await this.getVercelClient(orgIntegration); + const teamId = await this.getTeamIdFromIntegration(orgIntegration); + + // Parse the integration data to get the staging environment mapping + const integrationData = projectIntegration.integrationData as any; + const vercelStagingEnvironment = integrationData?.config?.vercelStagingEnvironment; + + let vercelTarget: string[]; + + switch (params.environmentType) { + case "PRODUCTION": + vercelTarget = ["production"]; + break; + case "STAGING": + if (!vercelStagingEnvironment) { + logger.debug("Skipping staging API key sync - no custom environment mapped", { + projectId: params.projectId, + }); + return { success: true }; + } + vercelTarget = [vercelStagingEnvironment]; + break; + case "PREVIEW": + vercelTarget = ["preview"]; + break; + case "DEVELOPMENT": + vercelTarget = ["development"]; + break; + default: + return { success: true }; + } + + await this.upsertVercelEnvVar({ + client, + vercelProjectId: projectIntegration.externalEntityId, + teamId, + key: "TRIGGER_SECRET_KEY", + value: params.apiKey, + target: vercelTarget, + type: "plain", + }); + + logger.info("Synced regenerated API key to Vercel", { + projectId: params.projectId, + vercelProjectId: projectIntegration.externalEntityId, + environmentType: params.environmentType, + target: vercelTarget, + }); + + return { success: true }; + } catch (error) { + const errorMessage = `Failed to sync API key to Vercel: ${error instanceof Error ? error.message : "Unknown error"}`; + logger.error(errorMessage, { + projectId: params.projectId, + environmentType: params.environmentType, + error, + }); + return { success: false, error: errorMessage }; + } + } + + /** + * Pull environment variables from Vercel and store them in the Trigger.dev database. + * + * Environment mapping: + * - Vercel "production" → Trigger.dev PRODUCTION + * - Vercel "preview" → Trigger.dev PREVIEW + * - Vercel custom environment (vercelStagingEnvironment) → Trigger.dev STAGING + * + * @param projectId - The Trigger.dev project ID + * @param vercelProjectId - The Vercel project ID + * @param teamId - The Vercel team ID (optional) + * @param vercelStagingEnvironment - The custom Vercel environment slug for staging (optional) + * @param syncEnvVarsMapping - Mapping of which env vars to sync (vars with false are skipped) + * @param orgIntegration - Organization integration with token reference + */ + static async pullEnvVarsFromVercel(params: { + projectId: string; + vercelProjectId: string; + teamId: string | null; + vercelStagingEnvironment?: string | null; + syncEnvVarsMapping: SyncEnvVarsMapping; + orgIntegration: OrganizationIntegration & { tokenReference: SecretReference }; + }): Promise<{ success: boolean; errors: string[]; syncedCount: number }> { + const errors: string[] = []; + let syncedCount = 0; + + try { + const client = await this.getVercelClient(params.orgIntegration); + + // Get all runtime environments for the project + const runtimeEnvironments = await prisma.runtimeEnvironment.findMany({ + where: { + projectId: params.projectId, + type: { + in: ["PRODUCTION", "STAGING", "PREVIEW", "DEVELOPMENT"], + }, + }, + select: { + id: true, + type: true, + }, + }); + + // Build environment mapping: Trigger.dev env type → Vercel target + const envMapping: Array<{ + triggerEnvType: "PRODUCTION" | "STAGING" | "PREVIEW" | "DEVELOPMENT"; + vercelTarget: string; + runtimeEnvironmentId: string; + }> = []; + + for (const env of runtimeEnvironments) { + switch (env.type) { + case "PRODUCTION": + envMapping.push({ + triggerEnvType: "PRODUCTION", + vercelTarget: "production", + runtimeEnvironmentId: env.id, + }); + break; + case "PREVIEW": + envMapping.push({ + triggerEnvType: "PREVIEW", + vercelTarget: "preview", + runtimeEnvironmentId: env.id, + }); + break; + case "STAGING": + // Only map staging if a custom environment is configured + if (params.vercelStagingEnvironment) { + envMapping.push({ + triggerEnvType: "STAGING", + vercelTarget: params.vercelStagingEnvironment, + runtimeEnvironmentId: env.id, + }); + } + break; + case "DEVELOPMENT": + envMapping.push({ + triggerEnvType: "DEVELOPMENT", + vercelTarget: "development", + runtimeEnvironmentId: env.id, + }); + break; + } + } + + if (envMapping.length === 0) { + logger.warn("No environments to sync for Vercel integration", { + projectId: params.projectId, + vercelProjectId: params.vercelProjectId, + }); + return { success: true, errors: [], syncedCount: 0 }; + } + + const envVarRepository = new EnvironmentVariablesRepository(); + + // Fetch shared env vars once (they apply across all targets) + let sharedEnvVars: Array<{ + key: string; + value: string; + target: string[]; + type: string; + isSecret: boolean; + applyToAllCustomEnvironments?: boolean; + }> = []; + + if (params.teamId) { + logger.info("Fetching shared env vars for pull operation", { + projectId: params.projectId, + vercelProjectId: params.vercelProjectId, + teamId: params.teamId, + }); + + sharedEnvVars = await this.getVercelSharedEnvironmentVariableValues( + client, + params.teamId, + params.vercelProjectId + ); + + logger.info("Fetched shared env vars from Vercel for pull", { + projectId: params.projectId, + vercelProjectId: params.vercelProjectId, + count: sharedEnvVars.length, + keys: sharedEnvVars.map((v) => v.key), + targets: sharedEnvVars.map((v) => ({ key: v.key, target: v.target })), + }); + } else { + logger.debug("Skipping shared env vars fetch - no teamId", { + projectId: params.projectId, + vercelProjectId: params.vercelProjectId, + }); + } + + // Process each environment mapping + for (const mapping of envMapping) { + try { + // Fetch project-level env vars from Vercel for this target + const projectEnvVars = await this.getVercelEnvironmentVariableValues( + client, + params.vercelProjectId, + params.teamId, + mapping.vercelTarget + ); + + logger.debug("Fetched project env vars for target", { + projectId: params.projectId, + vercelTarget: mapping.vercelTarget, + triggerEnvType: mapping.triggerEnvType, + projectEnvVarsCount: projectEnvVars.length, + projectEnvVarKeys: projectEnvVars.map((v) => v.key), + }); + + // Filter shared env vars that target this environment + const standardTargets = ["production", "preview", "development"]; + const isCustomEnvironment = !standardTargets.includes(mapping.vercelTarget); + + const filteredSharedEnvVars = sharedEnvVars.filter((envVar) => { + // Check if this shared env var targets the current Vercel environment + const matchesTarget = envVar.target.includes(mapping.vercelTarget); + + // Also include if applyToAllCustomEnvironments is true and this is a custom environment + const matchesCustomEnv = isCustomEnvironment && envVar.applyToAllCustomEnvironments === true; + + const matches = matchesTarget || matchesCustomEnv; + + if (!matches) { + logger.debug("Shared env var excluded - target mismatch", { + envKey: envVar.key, + envVarTarget: envVar.target, + expectedTarget: mapping.vercelTarget, + isCustomEnvironment, + applyToAllCustomEnvironments: envVar.applyToAllCustomEnvironments, + }); + } + return matches; + }); + + logger.info("Filtered shared env vars for target", { + projectId: params.projectId, + vercelTarget: mapping.vercelTarget, + triggerEnvType: mapping.triggerEnvType, + totalSharedEnvVars: sharedEnvVars.length, + matchingSharedEnvVars: filteredSharedEnvVars.length, + matchingKeys: filteredSharedEnvVars.map((v) => v.key), + }); + + // Merge project and shared env vars (project vars take precedence) + const projectEnvVarKeys = new Set(projectEnvVars.map((v) => v.key)); + const sharedEnvVarsToAdd = filteredSharedEnvVars.filter((v) => !projectEnvVarKeys.has(v.key)); + const mergedEnvVars = [ + ...projectEnvVars, + ...sharedEnvVarsToAdd, + ]; + + logger.info("Merged project and shared env vars", { + projectId: params.projectId, + vercelTarget: mapping.vercelTarget, + projectEnvVarsCount: projectEnvVars.length, + sharedEnvVarsAddedCount: sharedEnvVarsToAdd.length, + sharedEnvVarsSkippedDueToOverlap: filteredSharedEnvVars.length - sharedEnvVarsToAdd.length, + totalMergedCount: mergedEnvVars.length, + mergedKeys: mergedEnvVars.map((v) => v.key), + }); + + if (mergedEnvVars.length === 0) { + logger.debug("No env vars found for Vercel target", { + projectId: params.projectId, + vercelProjectId: params.vercelProjectId, + vercelTarget: mapping.vercelTarget, + }); + continue; + } + + // Filter env vars based on syncEnvVarsMapping and exclude TRIGGER_SECRET_KEY + const varsToSync = mergedEnvVars.filter((envVar) => { + // Skip secrets (they don't have values anyway) + if (envVar.isSecret) { + logger.debug("Env var excluded - is secret", { envKey: envVar.key }); + return false; + } + // Filter out TRIGGER_SECRET_KEY - these are managed by Trigger.dev + if (envVar.key === "TRIGGER_SECRET_KEY") { + logger.debug("Env var excluded - is TRIGGER_SECRET_KEY", { envKey: envVar.key }); + return false; + } + // Check if this var should be synced based on mapping for this environment + const shouldSync = shouldSyncEnvVar( + params.syncEnvVarsMapping, + envVar.key, + mapping.triggerEnvType as TriggerEnvironmentType + ); + if (!shouldSync) { + logger.debug("Env var excluded - disabled in sync mapping", { + envKey: envVar.key, + environmentType: mapping.triggerEnvType, + }); + } + return shouldSync; + }); + + logger.info("Filtered env vars to sync", { + projectId: params.projectId, + vercelTarget: mapping.vercelTarget, + triggerEnvType: mapping.triggerEnvType, + totalMergedCount: mergedEnvVars.length, + varsToSyncCount: varsToSync.length, + varsToSyncKeys: varsToSync.map((v) => v.key), + }); + + if (varsToSync.length === 0) { + logger.debug("No env vars to sync after filtering", { + projectId: params.projectId, + vercelProjectId: params.vercelProjectId, + vercelTarget: mapping.vercelTarget, + totalVars: mergedEnvVars.length, + }); + continue; + } + + // Create env vars in Trigger.dev + logger.info("Saving env vars to Trigger.dev", { + projectId: params.projectId, + runtimeEnvironmentId: mapping.runtimeEnvironmentId, + vercelTarget: mapping.vercelTarget, + triggerEnvType: mapping.triggerEnvType, + variableCount: varsToSync.length, + variableKeys: varsToSync.map((v) => v.key), + }); + + const result = await envVarRepository.create(params.projectId, { + override: true, // Override existing vars + environmentIds: [mapping.runtimeEnvironmentId], + isSecret: false, // Vercel env vars we can read are not secrets in our system + variables: varsToSync.map((v) => ({ + key: v.key, + value: v.value, + })), + }); + + if (result.success) { + syncedCount += varsToSync.length; + logger.info("Successfully synced env vars from Vercel to Trigger.dev", { + projectId: params.projectId, + vercelProjectId: params.vercelProjectId, + vercelTarget: mapping.vercelTarget, + triggerEnvType: mapping.triggerEnvType, + count: varsToSync.length, + keys: varsToSync.map((v) => v.key), + }); + } else { + const errorMsg = `Failed to sync env vars for ${mapping.triggerEnvType}: ${result.error}`; + errors.push(errorMsg); + logger.error(errorMsg, { + projectId: params.projectId, + vercelProjectId: params.vercelProjectId, + vercelTarget: mapping.vercelTarget, + error: result.error, + variableErrors: result.variableErrors, + attemptedKeys: varsToSync.map((v) => v.key), + }); + } + } catch (envError) { + const errorMsg = `Failed to process env vars for ${mapping.triggerEnvType}: ${envError instanceof Error ? envError.message : "Unknown error"}`; + errors.push(errorMsg); + logger.error(errorMsg, { + projectId: params.projectId, + vercelProjectId: params.vercelProjectId, + vercelTarget: mapping.vercelTarget, + error: envError, + }); + } + } + + return { + success: errors.length === 0, + errors, + syncedCount, + }; + } catch (error) { + const errorMsg = `Failed to pull env vars from Vercel: ${error instanceof Error ? error.message : "Unknown error"}`; + errors.push(errorMsg); + logger.error(errorMsg, { + projectId: params.projectId, + vercelProjectId: params.vercelProjectId, + error, + }); + return { + success: false, + errors, + syncedCount, + }; + } + } + + /** + * Batch create or update environment variables in Vercel. + * Fetches existing env vars once, then creates new ones in a single batch request + * and updates existing ones individually (Vercel doesn't support batch updates). + */ + static async batchUpsertVercelEnvVars(params: { + client: Vercel; + vercelProjectId: string; + teamId: string | null; + envVars: Array<{ + key: string; + value: string; + target: string[]; + type: "sensitive" | "encrypted" | "plain"; + environmentType?: string; // For logging purposes + }>; + }): Promise<{ created: number; updated: number; errors: string[] }> { + const { client, vercelProjectId, teamId, envVars } = params; + const errors: string[] = []; + let created = 0; + let updated = 0; + + if (envVars.length === 0) { + return { created: 0, updated: 0, errors: [] }; + } + + // Fetch all existing env vars once + const existingEnvs = await client.projects.filterProjectEnvs({ + idOrName: vercelProjectId, + ...(teamId && { teamId }), + }); + + const existingEnvsList = + "envs" in existingEnvs && Array.isArray(existingEnvs.envs) ? existingEnvs.envs : []; + + // Separate env vars into ones that need to be created vs updated + const toCreate: Array<{ + key: string; + value: string; + target: string[]; + type: "sensitive" | "encrypted" | "plain"; + }> = []; + + const toUpdate: Array<{ + id: string; + key: string; + value: string; + target: string[]; + type: "sensitive" | "encrypted" | "plain"; + environmentType?: string; + }> = []; + + for (const envVar of envVars) { + // Find existing env var with matching key AND target + const existingEnv = existingEnvsList.find((env: any) => { + if (env.key !== envVar.key) { + return false; + } + const envTargets = Array.isArray(env.target) ? env.target : [env.target].filter(Boolean); + return ( + envVar.target.length === envTargets.length && + envVar.target.every((t) => envTargets.includes(t)) + ); + }); + + if (existingEnv && existingEnv.id) { + toUpdate.push({ + id: existingEnv.id, + key: envVar.key, + value: envVar.value, + target: envVar.target, + type: envVar.type, + environmentType: envVar.environmentType, + }); + } else { + toCreate.push({ + key: envVar.key, + value: envVar.value, + target: envVar.target, + type: envVar.type, + }); + } + } + + // Batch create new env vars (Vercel supports array in request body) + if (toCreate.length > 0) { + try { + await client.projects.createProjectEnv({ + idOrName: vercelProjectId, + ...(teamId && { teamId }), + requestBody: toCreate.map((env) => ({ + key: env.key, + value: env.value, + target: env.target as any, + type: env.type, + })) as any, + }); + created = toCreate.length; + } catch (error) { + const errorMsg = `Failed to batch create env vars: ${error instanceof Error ? error.message : "Unknown error"}`; + errors.push(errorMsg); + logger.error(errorMsg, { + vercelProjectId, + teamId, + count: toCreate.length, + error, + }); + } + } + + // Update existing env vars (Vercel doesn't support batch updates) + for (const envVar of toUpdate) { + try { + await client.projects.editProjectEnv({ + idOrName: vercelProjectId, + id: envVar.id, + ...(teamId && { teamId }), + requestBody: { + value: envVar.value, + target: envVar.target as any, + type: envVar.type, + }, + }); + updated++; + } catch (error) { + const errorMsg = `Failed to update ${envVar.environmentType || envVar.key} env var: ${error instanceof Error ? error.message : "Unknown error"}`; + errors.push(errorMsg); + logger.error(errorMsg, { + vercelProjectId, + teamId, + envVarId: envVar.id, + key: envVar.key, + error, + }); + } + } + + return { created, updated, errors }; + } + + /** + * Create or update an environment variable in Vercel. + * First tries to find an existing variable with the same key and target, + * then either creates or updates it. + */ + private static async upsertVercelEnvVar(params: { + client: Vercel; + vercelProjectId: string; + teamId: string | null; + key: string; + value: string; + target: string[]; + type: "sensitive" | "encrypted" | "plain"; + }): Promise { + const { client, vercelProjectId, teamId, key, value, target, type } = params; + + // First, check if the env var already exists for this target + const existingEnvs = await client.projects.filterProjectEnvs({ + idOrName: vercelProjectId, + ...(teamId && { teamId }), + }); + + const envs = "envs" in existingEnvs && Array.isArray(existingEnvs.envs) + ? existingEnvs.envs + : []; + + // Find existing env var with matching key AND target + // Vercel can have multiple env vars with the same key but different targets + const existingEnv = envs.find((env: any) => { + if (env.key !== key) { + return false; + } + // Check if the targets match (env var targets this specific environment) + const envTargets = Array.isArray(env.target) ? env.target : [env.target].filter(Boolean); + // Match if the targets are exactly the same + return target.length === envTargets.length && target.every((t) => envTargets.includes(t)); + }); + + if (existingEnv && existingEnv.id) { + // Update existing env var + await client.projects.editProjectEnv({ + idOrName: vercelProjectId, + id: existingEnv.id, + ...(teamId && { teamId }), + requestBody: { + value, + target: target as any, + type, + }, + }); + } else { + // Create new env var + await client.projects.createProjectEnv({ + idOrName: vercelProjectId, + ...(teamId && { teamId }), + requestBody: { + key, + value, + target: target as any, + type, + }, + }); + } + } +} + diff --git a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts index 730591f4eb..7d3479e2da 100644 --- a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts @@ -4,6 +4,13 @@ import { Project } from "~/models/project.server"; import { User } from "~/models/user.server"; import { filterOrphanedEnvironments, sortEnvironments } from "~/utils/environmentSort"; import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; +import { + VercelProjectIntegrationDataSchema, + SyncEnvVarsMapping, + isLegacySyncEnvVarsMapping, + migrateLegacySyncEnvVarsMapping, +} from "~/v3/vercel/vercelProjectIntegrationSchema"; +import { logger } from "~/services/logger.server"; type Result = Awaited>; export type EnvironmentVariableWithSetValues = Result["environmentVariables"][number]; @@ -94,6 +101,74 @@ export class EnvironmentVariablesPresenter { const repository = new EnvironmentVariablesRepository(this.#prismaClient); const variables = await repository.getProject(project.id); + // Get Vercel integration data if it exists + const vercelIntegration = await this.#prismaClient.organizationProjectIntegration.findFirst({ + where: { + projectId: project.id, + deletedAt: null, + organizationIntegration: { + service: "VERCEL", + deletedAt: null, + }, + }, + }); + + let vercelSyncEnvVarsMapping: SyncEnvVarsMapping = {}; + let vercelPullEnvVarsEnabled = false; + + if (vercelIntegration) { + let parsedData = VercelProjectIntegrationDataSchema.safeParse( + vercelIntegration.integrationData + ); + + // Handle migration from legacy format if needed + if (!parsedData.success) { + const rawData = vercelIntegration.integrationData as Record; + + if (rawData && isLegacySyncEnvVarsMapping(rawData.syncEnvVarsMapping)) { + logger.info("Migrating legacy Vercel sync mapping format in presenter", { + projectId: project.id, + integrationId: vercelIntegration.id, + }); + + // Migrate the legacy format + const migratedMapping = migrateLegacySyncEnvVarsMapping( + rawData.syncEnvVarsMapping as Record + ); + + // Update the data with migrated mapping + const migratedData = { + ...rawData, + syncEnvVarsMapping: migratedMapping, + }; + + // Try parsing again with migrated data + parsedData = VercelProjectIntegrationDataSchema.safeParse(migratedData); + + if (parsedData.success) { + // Save the migrated data back to the database (fire and forget) + this.#prismaClient.organizationProjectIntegration.update({ + where: { id: vercelIntegration.id }, + data: { + integrationData: migratedData as any, + }, + }).catch((error) => { + logger.error("Failed to save migrated Vercel sync mapping", { + projectId: project.id, + integrationId: vercelIntegration.id, + error, + }); + }); + } + } + } + + if (parsedData.success) { + vercelSyncEnvVarsMapping = parsedData.data.syncEnvVarsMapping; + vercelPullEnvVarsEnabled = parsedData.data.config.pullEnvVarsFromVercel; + } + } + return { environmentVariables: environmentVariables .flatMap((environmentVariable) => { @@ -127,6 +202,14 @@ export class EnvironmentVariablesPresenter { branchName: environment.branchName, })), hasStaging: environments.some((environment) => environment.type === "STAGING"), + // Vercel integration data + vercelIntegration: vercelIntegration + ? { + enabled: true, + pullEnvVarsEnabled: vercelPullEnvVarsEnabled, + syncEnvVarsMapping: vercelSyncEnvVarsMapping, + } + : null, }; } } diff --git a/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts b/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts new file mode 100644 index 0000000000..64111734fe --- /dev/null +++ b/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts @@ -0,0 +1,286 @@ +import { type PrismaClient } from "@trigger.dev/database"; +import { fromPromise, ok, ResultAsync } from "neverthrow"; +import { env } from "~/env.server"; +import { OrgIntegrationRepository } from "~/models/orgIntegration.server"; +import { + VercelIntegrationRepository, + VercelCustomEnvironment, + VercelEnvironmentVariable, +} from "~/models/vercelIntegration.server"; +import { + VercelProjectIntegrationDataSchema, + VercelProjectIntegrationData, +} from "~/v3/vercel/vercelProjectIntegrationSchema"; +import { BasePresenter } from "./basePresenter.server"; + +type VercelSettingsOptions = { + projectId: string; + organizationId: string; +}; + +export type VercelSettingsResult = { + enabled: boolean; + hasOrgIntegration: boolean; + connectedProject?: { + id: string; + vercelProjectId: string; + vercelProjectName: string; + vercelTeamId: string | null; + integrationData: VercelProjectIntegrationData; + createdAt: Date; + }; + isGitHubConnected: boolean; + hasStagingEnvironment: boolean; +}; + +export type VercelAvailableProject = { + id: string; + name: string; +}; + +export type VercelOnboardingData = { + customEnvironments: VercelCustomEnvironment[]; + environmentVariables: VercelEnvironmentVariable[]; + availableProjects: VercelAvailableProject[]; + hasProjectSelected: boolean; +}; + +export class VercelSettingsPresenter extends BasePresenter { + /** + * Get Vercel integration settings for the settings page + */ + public call({ projectId, organizationId }: VercelSettingsOptions) { + const vercelIntegrationEnabled = OrgIntegrationRepository.isVercelSupported; + + if (!vercelIntegrationEnabled) { + return ok({ + enabled: false, + hasOrgIntegration: false, + connectedProject: undefined, + isGitHubConnected: false, + hasStagingEnvironment: false, + } as VercelSettingsResult); + } + + // Check if org-level Vercel integration exists + const checkOrgIntegration = () => + fromPromise( + (this._replica as PrismaClient).organizationIntegration.findFirst({ + where: { + organizationId, + service: "VERCEL", + deletedAt: null, + }, + select: { + id: true, + }, + }), + (error) => ({ + type: "other" as const, + cause: error, + }) + ).map((orgIntegration) => orgIntegration !== null); + + // Check if GitHub is connected + const checkGitHubConnection = () => + fromPromise( + (this._replica as PrismaClient).connectedGithubRepository.findFirst({ + where: { + projectId, + repository: { + installation: { + deletedAt: null, + suspendedAt: null, + }, + }, + }, + select: { + id: true, + }, + }), + (error) => ({ + type: "other" as const, + cause: error, + }) + ).map((repo) => repo !== null); + + // Check if staging environment exists + const checkStagingEnvironment = () => + fromPromise( + (this._replica as PrismaClient).runtimeEnvironment.findFirst({ + select: { + id: true, + }, + where: { + projectId, + type: "STAGING", + }, + }), + (error) => ({ + type: "other" as const, + cause: error, + }) + ).map((env) => env !== null); + + // Get Vercel project integration + const getVercelProjectIntegration = () => + fromPromise( + (this._replica as PrismaClient).organizationProjectIntegration.findFirst({ + where: { + projectId, + deletedAt: null, + organizationIntegration: { + service: "VERCEL", + deletedAt: null, + }, + }, + include: { + organizationIntegration: true, + }, + }), + (error) => ({ + type: "other" as const, + cause: error, + }) + ).map((integration) => { + if (!integration) { + return undefined; + } + + const parsedData = VercelProjectIntegrationDataSchema.safeParse( + integration.integrationData + ); + + if (!parsedData.success) { + return undefined; + } + + return { + id: integration.id, + vercelProjectId: integration.externalEntityId, + vercelProjectName: parsedData.data.vercelProjectName, + vercelTeamId: parsedData.data.vercelTeamId, + integrationData: parsedData.data, + createdAt: integration.createdAt, + }; + }); + + return ResultAsync.combine([ + checkOrgIntegration(), + checkGitHubConnection(), + checkStagingEnvironment(), + getVercelProjectIntegration(), + ]).map(([hasOrgIntegration, isGitHubConnected, hasStagingEnvironment, connectedProject]) => ({ + enabled: true, + hasOrgIntegration, + connectedProject, + isGitHubConnected, + hasStagingEnvironment, + })); + } + + /** + * Get data needed for the onboarding modal (custom environments and env vars) + */ + public async getOnboardingData(projectId: string, organizationId: string): Promise { + // First, check if there's an org integration for this organization + const orgIntegration = await (this._replica as PrismaClient).organizationIntegration.findFirst({ + where: { + organizationId, + service: "VERCEL", + deletedAt: null, + }, + include: { + tokenReference: true, + }, + }); + + if (!orgIntegration) { + return null; + } + + // Get the Vercel client + const client = await VercelIntegrationRepository.getVercelClient(orgIntegration); + + // Get the team ID from the secret + const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration); + + // Get the project integration to find the Vercel project ID (if selected) + const projectIntegration = await (this._replica as PrismaClient).organizationProjectIntegration.findFirst({ + where: { + projectId, + deletedAt: null, + organizationIntegration: { + service: "VERCEL", + deletedAt: null, + }, + }, + }); + + // Always fetch available projects for selection + const availableProjects = await VercelIntegrationRepository.getVercelProjects(client, teamId); + + // If no project integration exists, return early with just available projects + if (!projectIntegration) { + return { + customEnvironments: [], + environmentVariables: [], + availableProjects, + hasProjectSelected: false, + }; + } + + // Fetch custom environments, project env vars, and shared env vars in parallel + const [customEnvironments, projectEnvVars, sharedEnvVars] = await Promise.all([ + VercelIntegrationRepository.getVercelCustomEnvironments( + client, + projectIntegration.externalEntityId, + teamId + ), + VercelIntegrationRepository.getVercelEnvironmentVariables( + client, + projectIntegration.externalEntityId, + teamId + ), + // Only fetch shared env vars if teamId is available + teamId + ? VercelIntegrationRepository.getVercelSharedEnvironmentVariables( + client, + teamId, + projectIntegration.externalEntityId + ) + : Promise.resolve([]), + ]); + + // Merge project and shared env vars (project vars take precedence) + // Also filter out TRIGGER_SECRET_KEY as it's managed by Trigger.dev + const projectEnvVarKeys = new Set(projectEnvVars.map((v) => v.key)); + const mergedEnvVars: VercelEnvironmentVariable[] = [ + ...projectEnvVars.filter((v) => v.key !== "TRIGGER_SECRET_KEY"), + ...sharedEnvVars + .filter((v) => !projectEnvVarKeys.has(v.key) && v.key !== "TRIGGER_SECRET_KEY") + .map((v) => ({ + id: v.id, + key: v.key, + type: v.type as VercelEnvironmentVariable["type"], + isSecret: v.isSecret, + target: v.target, + isShared: true, + })), + ]; + + // Sort environment variables alphabetically + const sortedEnvVars = [...mergedEnvVars].sort((a, b) => + a.key.localeCompare(b.key) + ); + + return { + customEnvironments, + environmentVariables: sortedEnvVars, + availableProjects, + hasProjectSelected: true, + }; + } + +} + diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx index 80976d41fc..f1913285cb 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx @@ -9,7 +9,7 @@ import { PlusIcon, TrashIcon, } from "@heroicons/react/20/solid"; -import { Form, type MetaFunction, Outlet, useActionData, useFetcher, useNavigation } from "@remix-run/react"; +import { Form, type MetaFunction, Outlet, useActionData, useFetcher, useNavigation, useRevalidator } from "@remix-run/react"; import { type ActionFunctionArgs, type LoaderFunctionArgs, @@ -70,6 +70,9 @@ import { EditEnvironmentVariableValue, EnvironmentVariable, } from "~/v3/environmentVariables/repository"; +import { VercelIntegrationService } from "~/services/vercelIntegration.server"; +import { Callout } from "~/components/primitives/Callout"; +import { shouldSyncEnvVar, type TriggerEnvironmentType } from "~/v3/vercel/vercelProjectIntegrationSchema"; export const meta: MetaFunction = () => { return [ @@ -85,7 +88,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { try { const presenter = new EnvironmentVariablesPresenter(); - const { environmentVariables, environments, hasStaging } = await presenter.call({ + const { environmentVariables, environments, hasStaging, vercelIntegration } = await presenter.call({ userId, projectSlug: projectParam, }); @@ -94,6 +97,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { environmentVariables, environments, hasStaging, + vercelIntegration, }); } catch (error) { console.error(error); @@ -111,6 +115,12 @@ const schema = z.discriminatedUnion("action", [ key: z.string(), ...DeleteEnvironmentVariableValue.shape, }), + z.object({ + action: z.literal("update-vercel-sync"), + key: z.string(), + environmentType: z.enum(["PRODUCTION", "STAGING", "PREVIEW", "DEVELOPMENT"]), + syncEnabled: z.string().transform((val) => val === "true"), + }), ]); export const action = async ({ request, params }: ActionFunctionArgs) => { @@ -179,12 +189,31 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { `Deleted ${submission.value.key} environment variable` ); } + case "update-vercel-sync": { + const vercelService = new VercelIntegrationService(); + const integration = await vercelService.getVercelProjectIntegration(project.id); + + if (!integration) { + submission.error.key = ["Vercel integration not found"]; + return json(submission); + } + + // Update the sync mapping for the specific env var and environment + await vercelService.updateSyncEnvVarForEnvironment( + project.id, + submission.value.key, + submission.value.environmentType, + submission.value.syncEnabled + ); + + return json({ success: true }); + } } }; export default function Page() { const [revealAll, setRevealAll] = useState(false); - const { environmentVariables, environments } = useTypedLoaderData(); + const { environmentVariables, environments, vercelIntegration } = useTypedLoaderData(); const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); @@ -276,12 +305,41 @@ export default function Page() { )} + {/* Vercel sync info callout */} + {vercelIntegration?.enabled && vercelIntegration?.pullEnvVarsEnabled && ( +
+ + Environment variables with Vercel sync enabled will be pulled from Vercel during builds. + Uncheck the "Vercel Sync" box to exclude specific variables from syncing. + +
+ )} + - Key - Value - Environment + + Key + + + Value + + + Environment + + {vercelIntegration?.enabled && ( + + + Vercel Sync + + + } + content="When enabled, this variable will be synced from Vercel during builds. Requires 'Sync environment variables from Vercel' to be enabled in settings." + /> + + )} Actions @@ -341,6 +399,20 @@ export default function Page() { + {vercelIntegration?.enabled && ( + + + + )} ); } + +/** + * Toggle component for toggling Vercel sync for an environment variable. + * + * When Vercel sync is enabled for a variable, it will be pulled from Vercel during builds. + * By default, all variables are synced unless explicitly disabled. + * + * Note: If the env var name is missing from syncEnvVarsMapping, it's synced by default. + * Only when syncEnvVarsMapping[envVarName] = false, the env var is skipped during builds. + */ +function VercelSyncCheckbox({ + envVarKey, + environmentType, + syncEnabled, + pullEnvVarsEnabled, +}: { + envVarKey: string; + environmentType: "PRODUCTION" | "STAGING" | "PREVIEW" | "DEVELOPMENT"; + syncEnabled: boolean; + pullEnvVarsEnabled: boolean; +}) { + const fetcher = useFetcher(); + const revalidator = useRevalidator(); + + const isLoading = fetcher.state !== "idle"; + + // Revalidate loader data after successful submission (without full page reload) + useEffect(() => { + if (fetcher.state === "idle" && fetcher.data) { + const data = fetcher.data as { success?: boolean }; + if (data.success) { + revalidator.revalidate(); + } + } + }, [fetcher.state, fetcher.data, revalidator]); + + const handleChange = (checked: boolean) => { + fetcher.submit( + { + action: "update-vercel-sync", + key: envVarKey, + environmentType, + syncEnabled: checked.toString(), + }, + { method: "post" } + ); + }; + + // If pull env vars is disabled globally, show disabled state + if (!pullEnvVarsEnabled) { + return ( + {}} + /> + } + content="Enable 'Sync environment variables from Vercel' in settings to enable individual variable sync." + /> + ); + } + + return ( + + ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx index 66ea64cb36..4b44879df1 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx @@ -39,11 +39,19 @@ import { ProjectSettingsService } from "~/services/projectSettings.server"; import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; import { organizationPath, v3ProjectPath, EnvironmentParamSchema, v3BillingPath } from "~/utils/pathBuilder"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useCallback, useRef } from "react"; +import { useSearchParams } from "@remix-run/react"; import { useEnvironment } from "~/hooks/useEnvironment"; import { ProjectSettingsPresenter } from "~/services/projectSettingsPresenter.server"; import { type BuildSettings } from "~/v3/buildSettings"; import { GitHubSettingsPanel } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github"; +import { + VercelSettingsPanel, + VercelOnboardingModal, + vercelResourcePath, +} from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel"; +import { OrgIntegrationRepository } from "~/models/orgIntegration.server"; +import { useTypedFetcher } from "remix-typedjson"; export const meta: MetaFunction = () => { return [ @@ -92,6 +100,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { return typedjson({ githubAppEnabled: gitHubApp.enabled, buildSettings, + vercelIntegrationEnabled: OrgIntegrationRepository.isVercelSupported, }); }; @@ -290,12 +299,120 @@ export const action: ActionFunction = async ({ request, params }) => { }; export default function Page() { - const { githubAppEnabled, buildSettings } = useTypedLoaderData(); + const { githubAppEnabled, buildSettings, vercelIntegrationEnabled } = + useTypedLoaderData(); const project = useProject(); const organization = useOrganization(); const environment = useEnvironment(); const lastSubmission = useActionData(); const navigation = useNavigation(); + const [searchParams, setSearchParams] = useSearchParams(); + + // Vercel onboarding modal state + const hasQueryParam = searchParams.get("vercelOnboarding") === "true"; + const [isModalOpen, setIsModalOpen] = useState(false); + const vercelFetcher = useTypedFetcher(); + + // Helper to open modal and ensure query param is present + const openVercelOnboarding = useCallback(() => { + setIsModalOpen(true); + // Ensure query param is present to maintain state during form submissions + if (!hasQueryParam) { + setSearchParams((prev) => { + prev.set("vercelOnboarding", "true"); + return prev; + }); + } + }, [hasQueryParam, setSearchParams]); + + const closeVercelOnboarding = useCallback(() => { + // Remove query param if present + if (hasQueryParam) { + setSearchParams((prev) => { + prev.delete("vercelOnboarding"); + return prev; + }); + } + // Close modal + setIsModalOpen(false); + }, [hasQueryParam, setSearchParams]); + + // When query param is present, handle modal opening + // Note: We don't close the modal based on data state during onboarding - only when explicitly closed + useEffect(() => { + if (hasQueryParam && vercelIntegrationEnabled) { + // Ensure query param is present and modal is open + if (vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") { + // Data is loaded, ensure modal is open (query param takes precedence) + if (!isModalOpen) { + openVercelOnboarding(); + } + } else if (vercelFetcher.state === "idle" && !vercelFetcher.data?.onboardingData) { + // Load onboarding data + vercelFetcher.load( + `${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true` + ); + } + } else if (!hasQueryParam && isModalOpen) { + // Query param removed but modal is open, close modal + setIsModalOpen(false); + } + }, [hasQueryParam, vercelIntegrationEnabled, organization.slug, project.slug, environment.slug, vercelFetcher.data, vercelFetcher.state, isModalOpen, openVercelOnboarding]); + + // Ensure modal stays open when query param is present (even after data reloads) + // This is a safeguard to prevent the modal from closing during form submissions + useEffect(() => { + if (hasQueryParam && !isModalOpen) { + // Query param is present but modal is closed, open it + // This ensures the modal stays open during the onboarding flow + openVercelOnboarding(); + } + }, [hasQueryParam, isModalOpen, openVercelOnboarding]); + + // When data finishes loading (from query param), ensure modal is open + useEffect(() => { + if (hasQueryParam && vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") { + // Data loaded and query param is present, ensure modal is open + if (!isModalOpen) { + openVercelOnboarding(); + } + } + }, [hasQueryParam, vercelFetcher.data, vercelFetcher.state, isModalOpen, openVercelOnboarding]); + + + // Track if we're waiting for data from button click (not query param) + const waitingForButtonClickRef = useRef(false); + + // Handle opening modal from button click (without query param) + const handleOpenVercelModal = useCallback(() => { + // Add query param to maintain state during form submissions + if (!hasQueryParam) { + setSearchParams((prev) => { + prev.set("vercelOnboarding", "true"); + return prev; + }); + } + + if (vercelFetcher.data && vercelFetcher.data.onboardingData) { + // Data already loaded, open modal immediately + openVercelOnboarding(); + } else { + // Need to load data first, mark that we're waiting for button click + waitingForButtonClickRef.current = true; + vercelFetcher.load( + `${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true` + ); + } + }, [organization.slug, project.slug, environment.slug, vercelFetcher, setSearchParams, hasQueryParam, openVercelOnboarding]); + + // When data loads from button click, open modal + useEffect(() => { + if (waitingForButtonClickRef.current && vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") { + // Data loaded from button click, open modal and ensure query param is present + waitingForButtonClickRef.current = false; + openVercelOnboarding(); + } + }, [vercelFetcher.data, vercelFetcher.state, openVercelOnboarding]); const [hasRenameFormChanges, setHasRenameFormChanges] = useState(false); @@ -425,6 +542,21 @@ export default function Page() { + {vercelIntegrationEnabled && ( +
+ Vercel integration +
+ +
+
+ )} +
Build settings
@@ -477,6 +609,25 @@ export default function Page() {
+ + {/* Vercel Onboarding Modal */} + {vercelIntegrationEnabled && ( + { + vercelFetcher.load( + `${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true` + ); + }} + /> + )} ); } diff --git a/apps/webapp/app/routes/_app.vercel.install/route.tsx b/apps/webapp/app/routes/_app.vercel.install/route.tsx new file mode 100644 index 0000000000..6a1ca4d7a6 --- /dev/null +++ b/apps/webapp/app/routes/_app.vercel.install/route.tsx @@ -0,0 +1,73 @@ +import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { redirect } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { $replica } from "~/db.server"; +import { requireUser } from "~/services/session.server"; +import { logger } from "~/services/logger.server"; +import { OrgIntegrationRepository } from "~/models/orgIntegration.server"; +import { generateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server"; +import { findProjectBySlug } from "~/models/project.server"; + +const QuerySchema = z.object({ + org_slug: z.string(), + project_slug: z.string(), +}); + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const searchParams = new URL(request.url).searchParams; + const parsed = QuerySchema.safeParse(Object.fromEntries(searchParams)); + + if (!parsed.success) { + logger.warn("Vercel App installation redirect with invalid params", { + searchParams, + error: parsed.error, + }); + throw redirect("/"); + } + + const { org_slug, project_slug } = parsed.data; + const user = await requireUser(request); + + // Find the organization + const org = await $replica.organization.findFirst({ + where: { slug: org_slug, members: { some: { userId: user.id } }, deletedAt: null }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + }, + }); + + if (!org) { + throw redirect("/"); + } + + // Find the project + const project = await findProjectBySlug(org_slug, project_slug, user.id); + if (!project) { + logger.warn("Vercel App installation attempt for non-existent project", { + org_slug, + project_slug, + userId: user.id, + }); + throw redirect("/"); + } + + // Use "prod" as the default environment slug for the redirect + // The callback will redirect to the settings page for this environment + const environmentSlug = "prod"; + + // Generate JWT state token + const stateToken = await generateVercelOAuthState({ + organizationId: org.id, + projectId: project.id, + environmentSlug, + organizationSlug: org_slug, + projectSlug: project_slug, + }); + + // Generate Vercel install URL + const vercelInstallUrl = OrgIntegrationRepository.vercelInstallUrl(stateToken); + + return redirect(vercelInstallUrl); +}; + diff --git a/apps/webapp/app/routes/api.v1.orgs.$organizationSlug.projects.$projectParam.vercel.projects.ts b/apps/webapp/app/routes/api.v1.orgs.$organizationSlug.projects.$projectParam.vercel.projects.ts new file mode 100644 index 0000000000..3039f2cdba --- /dev/null +++ b/apps/webapp/app/routes/api.v1.orgs.$organizationSlug.projects.$projectParam.vercel.projects.ts @@ -0,0 +1,122 @@ +import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { apiCors } from "~/utils/apiCors"; +import { logger } from "~/services/logger.server"; +import { VercelIntegrationService } from "~/services/vercelIntegration.server"; +import { + VercelProjectIntegrationDataSchema, +} from "~/v3/vercel/vercelProjectIntegrationSchema"; + +const ParamsSchema = z.object({ + organizationSlug: z.string(), + projectParam: z.string(), +}); + +/** + * API endpoint to retrieve connected Vercel projects for a Trigger.dev project. + * + * GET /api/v1/orgs/:organizationSlug/projects/:projectParam/vercel/projects + * + * Returns: + * - vercelProject: The connected Vercel project details (if any) + * - config: The Vercel integration configuration + * - syncEnvVarsMapping: The environment variable sync mapping + */ +export async function loader({ request, params }: LoaderFunctionArgs) { + // Handle CORS + if (request.method === "OPTIONS") { + return apiCors(request, json({})); + } + + const parsedParams = ParamsSchema.safeParse(params); + if (!parsedParams.success) { + return apiCors( + request, + json({ error: "Invalid parameters" }, { status: 400 }) + ); + } + + const { organizationSlug, projectParam } = parsedParams.data; + + try { + // Find the project + const project = await prisma.project.findFirst({ + where: { + slug: projectParam, + organization: { + slug: organizationSlug, + }, + deletedAt: null, + }, + select: { + id: true, + name: true, + slug: true, + organizationId: true, + }, + }); + + if (!project) { + return apiCors( + request, + json({ error: "Project not found" }, { status: 404 }) + ); + } + + // Get Vercel integration for the project + const vercelService = new VercelIntegrationService(); + const integration = await vercelService.getVercelProjectIntegration(project.id); + + if (!integration) { + return apiCors( + request, + json({ + connected: false, + vercelProject: null, + config: null, + syncEnvVarsMapping: null, + }) + ); + } + + const { parsedIntegrationData } = integration; + + return apiCors( + request, + json({ + connected: true, + vercelProject: { + id: parsedIntegrationData.vercelProjectId, + name: parsedIntegrationData.vercelProjectName, + teamId: parsedIntegrationData.vercelTeamId, + }, + config: { + pullEnvVarsFromVercel: parsedIntegrationData.config.pullEnvVarsFromVercel, + spawnDeploymentOnVercelEvent: parsedIntegrationData.config.spawnDeploymentOnVercelEvent, + spawnBuildOnVercelEvent: parsedIntegrationData.config.spawnBuildOnVercelEvent, + vercelStagingEnvironment: parsedIntegrationData.config.vercelStagingEnvironment, + }, + syncEnvVarsMapping: parsedIntegrationData.syncEnvVarsMapping, + triggerProject: { + id: project.id, + name: project.name, + slug: project.slug, + }, + }) + ); + } catch (error) { + logger.error("Failed to fetch Vercel projects", { + error, + organizationSlug, + projectParam, + }); + + return apiCors( + request, + json({ error: "Internal server error" }, { status: 500 }) + ); + } +} + diff --git a/apps/webapp/app/routes/callback.vercel.ts b/apps/webapp/app/routes/callback.vercel.ts new file mode 100644 index 0000000000..a71c8710ca --- /dev/null +++ b/apps/webapp/app/routes/callback.vercel.ts @@ -0,0 +1,267 @@ +import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { redirect } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { env } from "~/env.server"; +import { redirectWithErrorMessage } from "~/models/message.server"; +import { VercelIntegrationRepository } from "~/models/vercelIntegration.server"; +import { logger } from "~/services/logger.server"; +import { requireUserId } from "~/services/session.server"; +import { requestUrl } from "~/utils/requestUrl.server"; +import { v3ProjectSettingsPath } from "~/utils/pathBuilder"; +import { validateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server"; + +/** + * Schema for Vercel OAuth callback query parameters. + * + * When a user completes the Vercel integration flow, they are redirected to this callback + * with various parameters including the authorization code and configuration details. + */ +const VercelCallbackSchema = z + .object({ + // OAuth authorization code + code: z.string().optional(), + // State parameter for CSRF protection (contains org/project info) + state: z.string().optional(), + // Error from Vercel + error: z.string().optional(), + error_description: z.string().optional(), + // Vercel configuration ID + configurationId: z.string().optional(), + // Team ID if installed on a team (null for personal account) + teamId: z.string().nullable().optional(), + // The next URL Vercel wants us to redirect to (optional) + next: z.string().optional(), + }) + .passthrough(); + + +/** + * Vercel OAuth callback handler. + * + * This route handles the callback from Vercel after a user installs/configures + * the Trigger.dev integration from the Vercel marketplace. + * + * Flow: + * 1. User clicks "Connect" on our settings page + * 2. User is redirected to https://vercel.com/integrations/trigger/new + * 3. User authorizes the integration on Vercel + * 4. Vercel redirects back here with an authorization code + * 5. We exchange the code for an access token + * 6. We create OrganizationIntegration and OrganizationProjectIntegration records + * 7. Redirect to settings page with ?vercelOnboarding=true to show onboarding modal + */ +export async function loader({ request }: LoaderFunctionArgs) { + if (request.method.toUpperCase() !== "GET") { + return new Response("Method Not Allowed", { status: 405 }); + } + + const userId = await requireUserId(request); + + const url = requestUrl(request); + const parsedParams = VercelCallbackSchema.safeParse(Object.fromEntries(url.searchParams)); + + if (!parsedParams.success) { + logger.error("Invalid Vercel callback params", { error: parsedParams.error }); + throw new Response("Invalid callback parameters", { status: 400 }); + } + + const { code, state, error, error_description, configurationId, teamId } = parsedParams.data; + + // Handle errors from Vercel + if (error) { + logger.error("Vercel OAuth error", { error, error_description }); + // Redirect to a generic error page or back to settings + return redirect(`/?error=${encodeURIComponent(error_description || error)}`); + } + + // Validate required parameters + if (!code) { + logger.error("Missing authorization code from Vercel callback"); + throw new Response("Missing authorization code", { status: 400 }); + } + + if (!state) { + logger.error("Missing state parameter from Vercel callback"); + throw new Response("Missing state parameter", { status: 400 }); + } + + // Validate and decode JWT state + const validationResult = await validateVercelOAuthState(state); + + if (!validationResult.ok) { + logger.error("Invalid Vercel OAuth state JWT", { + error: validationResult.error, + }); + throw new Response("Invalid state parameter", { status: 400 }); + } + + const stateData = validationResult.state; + + // Verify user has access to the organization and project + const project = await prisma.project.findFirst({ + where: { + id: stateData.projectId, + organizationId: stateData.organizationId, + deletedAt: null, + organization: { + members: { + some: { + userId, + }, + }, + }, + }, + include: { + organization: true, + }, + }); + + if (!project) { + logger.error("Project not found or user does not have access", { + projectId: stateData.projectId, + userId, + }); + throw new Response("Project not found", { status: 404 }); + } + + // Exchange authorization code for access token + const tokenResponse = await exchangeCodeForToken(code); + if (!tokenResponse) { + return redirectWithErrorMessage( + v3ProjectSettingsPath( + { slug: stateData.organizationSlug }, + { slug: stateData.projectSlug }, + { slug: stateData.environmentSlug } + ), + request, + "Failed to connect to Vercel. Please try again." + ); + } + + try { + // Check if we already have a Vercel org integration for this team + let orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationByTeamId( + project.organizationId, + tokenResponse.teamId ?? null + ); + + // Create org integration if it doesn't exist + if (!orgIntegration) { + const newIntegration = await VercelIntegrationRepository.createVercelOrgIntegration({ + accessToken: tokenResponse.accessToken, + tokenType: tokenResponse.tokenType, + teamId: tokenResponse.teamId ?? null, + userId: tokenResponse.userId, + installationId: configurationId, + organization: project.organization, + raw: tokenResponse.raw, + }); + + // Re-fetch to get the full integration with tokenReference + orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationByTeamId( + project.organizationId, + tokenResponse.teamId ?? null + ); + } + + if (!orgIntegration) { + throw new Error("Failed to create or find Vercel organization integration"); + } + + // Fetch Vercel projects to get the project name + const vercelClient = await VercelIntegrationRepository.getVercelClient(orgIntegration); + + // The OrganizationIntegration is now created. + // The user will select a Vercel project during onboarding, which will create the + // OrganizationProjectIntegration record and sync API keys to Vercel. + + logger.info("Vercel organization integration created successfully", { + organizationId: project.organizationId, + projectId: project.id, + teamId: tokenResponse.teamId, + }); + + // Redirect to settings page with onboarding query parameter + const settingsPath = v3ProjectSettingsPath( + { slug: stateData.organizationSlug }, + { slug: stateData.projectSlug }, + { slug: stateData.environmentSlug } + ); + + return redirect(`${settingsPath}?vercelOnboarding=true`); + } catch (error) { + logger.error("Failed to create Vercel integration", { error }); + return redirectWithErrorMessage( + v3ProjectSettingsPath( + { slug: stateData.organizationSlug }, + { slug: stateData.projectSlug }, + { slug: stateData.environmentSlug } + ), + request, + "Failed to create Vercel integration. Please try again." + ); + } +} + +/** + * Exchange authorization code for access token using Vercel OAuth + */ +async function exchangeCodeForToken(code: string): Promise<{ + accessToken: string; + tokenType: string; + teamId?: string; + userId?: string; + raw: Record; +} | null> { + const clientId = env.VERCEL_INTEGRATION_CLIENT_ID; + const clientSecret = env.VERCEL_INTEGRATION_CLIENT_SECRET; + const redirectUri = `${env.APP_ORIGIN}/callback/vercel`; + + if (!clientId || !clientSecret) { + logger.error("Vercel integration not configured - missing client ID or secret"); + return null; + } + + try { + const response = await fetch("https://api.vercel.com/v2/oauth/access_token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + code, + redirect_uri: redirectUri, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + logger.error("Failed to exchange Vercel OAuth code", { + status: response.status, + error: errorText, + }); + return null; + } + + const data = (await response.json()) as { + access_token: string; + token_type: string; + team_id?: string; + user_id?: string; + }; + + return { + accessToken: data.access_token, + tokenType: data.token_type, + teamId: data.team_id, + userId: data.user_id, + raw: data as Record, + }; + } catch (error) { + logger.error("Error exchanging Vercel OAuth code", { error }); + return null; + } +} diff --git a/apps/webapp/app/routes/resources.environments.$environmentId.regenerate-api-key.tsx b/apps/webapp/app/routes/resources.environments.$environmentId.regenerate-api-key.tsx index 7ad6b1c6c5..08ba3404c3 100644 --- a/apps/webapp/app/routes/resources.environments.$environmentId.regenerate-api-key.tsx +++ b/apps/webapp/app/routes/resources.environments.$environmentId.regenerate-api-key.tsx @@ -2,8 +2,10 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime"; import { z } from "zod"; import { environmentFullTitle } from "~/components/environments/EnvironmentLabel"; import { regenerateApiKey } from "~/models/api-key.server"; +import { VercelIntegrationRepository } from "~/models/vercelIntegration.server"; import { jsonWithErrorMessage, jsonWithSuccessMessage } from "~/models/message.server"; import { requireUserId } from "~/services/session.server"; +import { logger } from "~/services/logger.server"; const ParamsSchema = z.object({ environmentId: z.string(), @@ -22,6 +24,14 @@ export async function action({ request, params }: ActionFunctionArgs) { try { const updatedEnvironment = await regenerateApiKey({ userId, environmentId }); + // Sync the regenerated API key to Vercel if integration exists + // This is done asynchronously and won't block the response + syncApiKeyToVercelInBackground( + updatedEnvironment.projectId, + updatedEnvironment.type as "PRODUCTION" | "STAGING" | "PREVIEW" | "DEVELOPMENT", + updatedEnvironment.apiKey + ); + return jsonWithSuccessMessage( { ok: true }, request, @@ -37,3 +47,37 @@ export async function action({ request, params }: ActionFunctionArgs) { ); } } + +/** + * Sync the API key to Vercel in the background. + * This runs asynchronously and doesn't block the main response. + * Errors are logged but won't fail the API key regeneration. + */ +async function syncApiKeyToVercelInBackground( + projectId: string, + environmentType: "PRODUCTION" | "STAGING" | "PREVIEW" | "DEVELOPMENT", + apiKey: string +): Promise { + try { + const result = await VercelIntegrationRepository.syncSingleApiKeyToVercel({ + projectId, + environmentType, + apiKey, + }); + + if (!result.success) { + logger.warn("Failed to sync regenerated API key to Vercel", { + projectId, + environmentType, + error: result.error, + }); + } + } catch (error) { + // Log but don't throw - we don't want to fail the main operation + logger.error("Error syncing regenerated API key to Vercel", { + projectId, + environmentType, + error, + }); + } +} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx new file mode 100644 index 0000000000..bd584594e3 --- /dev/null +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx @@ -0,0 +1,1615 @@ +import { conform, useForm } from "@conform-to/react"; +import { parse } from "@conform-to/zod"; +import { + CheckCircleIcon, + ExclamationTriangleIcon, + ChevronDownIcon, + ChevronUpIcon, +} from "@heroicons/react/20/solid"; +import { + Form, + useActionData, + useFetcher, + useNavigation, + useSearchParams, + useLocation, +} from "@remix-run/react"; +import { + type ActionFunctionArgs, + type LoaderFunctionArgs, + json, +} from "@remix-run/server-runtime"; +import { typedjson, useTypedFetcher } from "remix-typedjson"; +import { z } from "zod"; +import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; +import { DialogClose } from "@radix-ui/react-dialog"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { Callout } from "~/components/primitives/Callout"; +import { Fieldset } from "~/components/primitives/Fieldset"; +import { FormButtons } from "~/components/primitives/FormButtons"; +import { FormError } from "~/components/primitives/FormError"; +import { Header3 } from "~/components/primitives/Headers"; +import { Hint } from "~/components/primitives/Hint"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { Label } from "~/components/primitives/Label"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { Select, SelectItem } from "~/components/primitives/Select"; +import { SpinnerWhite } from "~/components/primitives/Spinner"; +import { Switch } from "~/components/primitives/Switch"; +import { TextLink } from "~/components/primitives/TextLink"; +import { DateTime } from "~/components/primitives/DateTime"; +import { + redirectBackWithErrorMessage, + redirectBackWithSuccessMessage, + redirectWithSuccessMessage, + redirectWithErrorMessage, +} from "~/models/message.server"; +import { findProjectBySlug } from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { OrgIntegrationRepository } from "~/models/orgIntegration.server"; +import { logger } from "~/services/logger.server"; +import { requireUserId } from "~/services/session.server"; +import { EnvironmentParamSchema, v3ProjectSettingsPath, vercelAppInstallPath } from "~/utils/pathBuilder"; +import { cn } from "~/utils/cn"; +import { + VercelSettingsPresenter, + type VercelSettingsResult, + type VercelOnboardingData, +} from "~/presenters/v3/VercelSettingsPresenter.server"; +import { VercelIntegrationService } from "~/services/vercelIntegration.server"; +import { VercelIntegrationRepository } from "~/models/vercelIntegration.server"; +import { + type VercelProjectIntegrationData, + type SyncEnvVarsMapping, + shouldSyncEnvVarForAnyEnvironment, +} from "~/v3/vercel/vercelProjectIntegrationSchema"; +import { useEffect, useState, useCallback, useRef } from "react"; + +// ============================================================================ +// Types +// ============================================================================ + +export type ConnectedVercelProject = { + id: string; + vercelProjectId: string; + vercelProjectName: string; + vercelTeamId: string | null; + integrationData: VercelProjectIntegrationData; + createdAt: Date; +}; + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Format Vercel target environments for display + * e.g., ["production", "preview"] → "Production, Preview" + */ +function formatVercelTargets(targets: string[]): string { + const targetLabels: Record = { + production: "Production", + preview: "Preview", + development: "Development", + }; + + return targets + .map((t) => targetLabels[t] || t) + .join(", "); +} + +/** + * Look up the name (slug) of a Vercel custom environment by its ID + */ +async function lookupVercelEnvironmentName( + projectId: string, + environmentId: string | null +): Promise { + if (!environmentId) { + return null; + } + + try { + // Get the project integration + const vercelService = new VercelIntegrationService(); + const projectIntegration = await vercelService.getVercelProjectIntegration(projectId); + if (!projectIntegration) { + return null; + } + + // Get the org integration + const orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationForProject(projectId); + if (!orgIntegration) { + return null; + } + + // Get the Vercel client + const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration); + const client = await VercelIntegrationRepository.getVercelClient(orgIntegration); + + // Fetch custom environments + const customEnvironments = await VercelIntegrationRepository.getVercelCustomEnvironments( + client, + projectIntegration.parsedIntegrationData.vercelProjectId, + teamId + ); + + // Look up the name from the ID + const environment = customEnvironments.find((env) => env.id === environmentId); + return environment?.slug || null; + } catch (error) { + logger.error("Failed to look up Vercel environment name", { + projectId, + environmentId, + error, + }); + return null; + } +} + +// ============================================================================ +// Schemas +// ============================================================================ + +const UpdateVercelConfigFormSchema = z.object({ + action: z.literal("update-config"), + pullEnvVarsFromVercel: z + .string() + .optional() + .transform((val) => val === "on"), + spawnDeploymentOnVercelEvent: z + .string() + .optional() + .transform((val) => val === "on"), + spawnBuildOnVercelEvent: z + .string() + .optional() + .transform((val) => val === "on"), + vercelStagingEnvironment: z.string().nullable().optional(), + vercelStagingName: z.string().nullable().optional(), +}); + +const DisconnectVercelFormSchema = z.object({ + action: z.literal("disconnect"), +}); + +const CompleteOnboardingFormSchema = z.object({ + action: z.literal("complete-onboarding"), + vercelStagingEnvironment: z.string().nullable().optional(), + vercelStagingName: z.string().nullable().optional(), + pullEnvVarsFromVercel: z + .string() + .optional() + .transform((val) => val === "on"), + syncEnvVarsMapping: z.string().optional(), // JSON-encoded mapping +}); + +const SkipOnboardingFormSchema = z.object({ + action: z.literal("skip-onboarding"), +}); + +const SelectVercelProjectFormSchema = z.object({ + action: z.literal("select-vercel-project"), + vercelProjectId: z.string().min(1, "Please select a Vercel project"), + vercelProjectName: z.string().min(1), +}); + +const UpdateEnvMappingFormSchema = z.object({ + action: z.literal("update-env-mapping"), + vercelStagingEnvironment: z.string().nullable().optional(), + vercelStagingName: z.string().nullable().optional(), +}); + +const VercelActionSchema = z.discriminatedUnion("action", [ + UpdateVercelConfigFormSchema, + DisconnectVercelFormSchema, + CompleteOnboardingFormSchema, + SkipOnboardingFormSchema, + SelectVercelProjectFormSchema, + UpdateEnvMappingFormSchema, +]); + +// ============================================================================ +// Loader +// ============================================================================ + +export async function loader({ request, params }: LoaderFunctionArgs) { + const userId = await requireUserId(request); + const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + throw new Response("Not Found", { status: 404 }); + } + + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + throw new Response("Not Found", { status: 404 }); + } + + const presenter = new VercelSettingsPresenter(); + const resultOrFail = await presenter.call({ + projectId: project.id, + organizationId: project.organizationId, + }); + + if (resultOrFail.isErr()) { + throw new Response("Failed to load Vercel settings", { status: 500 }); + } + + // Check if we need onboarding data + const url = new URL(request.url); + const needsOnboarding = url.searchParams.get("vercelOnboarding") === "true"; + + let onboardingData: VercelOnboardingData | null = null; + if (needsOnboarding) { + // Always fetch onboarding data when in onboarding mode, even if no project selected yet + // This allows us to show the project selection step + onboardingData = await presenter.getOnboardingData(project.id, project.organizationId); + } + + return typedjson({ + ...resultOrFail.value, + onboardingData, + organizationSlug, + projectSlug: projectParam, + environmentSlug: envParam, + projectId: project.id, + organizationId: project.organizationId, + }); +} + +// ============================================================================ +// Action +// ============================================================================ + +export async function action({ request, params }: ActionFunctionArgs) { + const userId = await requireUserId(request); + const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + throw new Response("Not Found", { status: 404 }); + } + + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + throw new Response("Not Found", { status: 404 }); + } + + const formData = await request.formData(); + const submission = parse(formData, { schema: VercelActionSchema }); + + if (!submission.value || submission.intent !== "submit") { + return json(submission); + } + + const vercelService = new VercelIntegrationService(); + const { action: actionType } = submission.value; + + // Handle update-config action + if (actionType === "update-config") { + const { + pullEnvVarsFromVercel, + spawnDeploymentOnVercelEvent, + spawnBuildOnVercelEvent, + vercelStagingEnvironment, + vercelStagingName, + } = submission.value; + + // If vercelStagingName is not provided, look it up from the environment ID + let stagingName = vercelStagingName ?? null; + if (vercelStagingEnvironment && !stagingName) { + stagingName = await lookupVercelEnvironmentName(project.id, vercelStagingEnvironment); + } + + const result = await vercelService.updateVercelIntegrationConfig(project.id, { + pullEnvVarsFromVercel, + spawnDeploymentOnVercelEvent, + spawnBuildOnVercelEvent, + vercelStagingEnvironment: vercelStagingEnvironment ?? null, + vercelStagingName: stagingName, + }); + + const settingsPath = v3ProjectSettingsPath( + { slug: organizationSlug }, + { slug: projectParam }, + { slug: envParam } + ); + + if (result) { + return redirectWithSuccessMessage(settingsPath, request, "Vercel settings updated successfully"); + } + + return redirectWithErrorMessage(settingsPath, request, "Failed to update Vercel settings"); + } + + // Handle disconnect action + if (actionType === "disconnect") { + const success = await vercelService.disconnectVercelProject(project.id); + + const settingsPath = v3ProjectSettingsPath( + { slug: organizationSlug }, + { slug: projectParam }, + { slug: envParam } + ); + + if (success) { + return redirectWithSuccessMessage(settingsPath, request, "Vercel project disconnected"); + } + + return redirectWithErrorMessage(settingsPath, request, "Failed to disconnect Vercel project"); + } + + // Handle complete-onboarding action + if (actionType === "complete-onboarding") { + const { + vercelStagingEnvironment, + vercelStagingName, + pullEnvVarsFromVercel, + syncEnvVarsMapping, + } = submission.value; + + let parsedMapping: SyncEnvVarsMapping = {}; + if (syncEnvVarsMapping) { + try { + parsedMapping = JSON.parse(syncEnvVarsMapping) as SyncEnvVarsMapping; + } catch (e) { + logger.error("Failed to parse syncEnvVarsMapping", { error: e }); + } + } + + // If vercelStagingName is not provided, look it up from the environment ID + let stagingName = vercelStagingName ?? null; + if (vercelStagingEnvironment && !stagingName) { + stagingName = await lookupVercelEnvironmentName(project.id, vercelStagingEnvironment); + } + + const result = await vercelService.completeOnboarding(project.id, { + vercelStagingEnvironment: vercelStagingEnvironment ?? null, + vercelStagingName: stagingName, + pullEnvVarsFromVercel: pullEnvVarsFromVercel ?? true, + syncEnvVarsMapping: parsedMapping, + }); + + if (result) { + // Redirect to settings page without the vercelOnboarding param to close the modal + const settingsPath = v3ProjectSettingsPath( + { slug: organizationSlug }, + { slug: projectParam }, + { slug: envParam } + ); + return redirectWithSuccessMessage(settingsPath, request, "Vercel integration setup complete"); + } + + const settingsPath = v3ProjectSettingsPath( + { slug: organizationSlug }, + { slug: projectParam }, + { slug: envParam } + ); + return redirectWithErrorMessage(settingsPath, request, "Failed to complete Vercel setup"); + } + + // Handle update-env-mapping action (during onboarding) + if (actionType === "update-env-mapping") { + const { vercelStagingEnvironment, vercelStagingName } = submission.value; + + // If vercelStagingName is not provided, look it up from the environment ID + let stagingName = vercelStagingName ?? null; + if (vercelStagingEnvironment && !stagingName) { + stagingName = await lookupVercelEnvironmentName(project.id, vercelStagingEnvironment); + } + + const result = await vercelService.updateVercelIntegrationConfig(project.id, { + vercelStagingEnvironment: vercelStagingEnvironment ?? null, + vercelStagingName: stagingName, + }); + + if (result) { + return json({ success: true }); + } + + return json({ success: false, error: "Failed to update environment mapping" }, { status: 400 }); + } + + // Handle skip-onboarding action + if (actionType === "skip-onboarding") { + await vercelService.skipOnboarding(project.id); + const settingsPath = v3ProjectSettingsPath( + { slug: organizationSlug }, + { slug: projectParam }, + { slug: envParam } + ); + return redirectWithSuccessMessage(settingsPath, request, "Vercel integration setup skipped"); + } + + // Handle select-vercel-project action + if (actionType === "select-vercel-project") { + const { vercelProjectId, vercelProjectName } = submission.value; + + try { + const { integration, syncResult } = await vercelService.selectVercelProject({ + organizationId: project.organizationId, + projectId: project.id, + vercelProjectId, + vercelProjectName, + userId, + }); + + if (!syncResult.success && syncResult.errors.length > 0) { + logger.warn("Failed to send trigger secrets to Vercel", { + projectId: project.id, + vercelProjectId, + errors: syncResult.errors, + }); + // Still proceed - user can manually configure API keys + } + + // Return success to allow the onboarding flow to continue + return json({ + success: true, + integrationId: integration.id, + syncErrors: syncResult.errors, + }); + } catch (error) { + logger.error("Failed to select Vercel project", { error }); + return json({ + error: "Failed to connect Vercel project. Please try again.", + }); + } + } + + submission.value satisfies never; + return redirectBackWithErrorMessage(request, "Failed to process request"); +} + +// ============================================================================ +// Helper: Build resource URL for fetching Vercel data +// ============================================================================ + +export function vercelResourcePath( + organizationSlug: string, + projectSlug: string, + environmentSlug: string +) { + return `/resources/orgs/${organizationSlug}/projects/${projectSlug}/env/${environmentSlug}/vercel`; +} + +// ============================================================================ +// Vercel Icon Component +// ============================================================================ + +function VercelIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +// ============================================================================ +// Components +// ============================================================================ + +/** + * Prompt to connect Vercel integration + */ +function VercelConnectionPrompt({ + organizationSlug, + projectSlug, + environmentSlug, + hasOrgIntegration, + isGitHubConnected, + onOpenModal, + isLoading, +}: { + organizationSlug: string; + projectSlug: string; + environmentSlug: string; + hasOrgIntegration: boolean; + isGitHubConnected: boolean; + onOpenModal?: () => void; + isLoading?: boolean; +}) { + // Generate the install path + const installPath = vercelAppInstallPath(organizationSlug, projectSlug); + + // Handle connecting project when org integration exists + const handleConnectProject = () => { + // Just trigger the callback - the parent will handle loading and opening + if (onOpenModal) { + onOpenModal(); + } + }; + + const isLoadingProjects = isLoading ?? false; + + return ( +
+ +
+
+ {hasOrgIntegration ? ( + <> + + + Vercel app is installed + + + ) : ( + <> + } + > + Install Vercel app + + + )} +
+
+
+
+ ); +} + +/** + * Warning banner when Vercel is connected but GitHub is not + */ +function VercelGitHubWarning() { + return ( + +

+ GitHub integration is not connected. Vercel integration cannot sync environment variables or + spawn Trigger.dev builds without a properly installed GitHub integration. +

+
+ ); +} + +/** + * Connected Vercel project settings form + */ +function ConnectedVercelProjectForm({ + connectedProject, + hasStagingEnvironment, + customEnvironments, + organizationSlug, + projectSlug, + environmentSlug, +}: { + connectedProject: ConnectedVercelProject; + hasStagingEnvironment: boolean; + customEnvironments?: Array<{ id: string; slug: string }>; + organizationSlug: string; + projectSlug: string; + environmentSlug: string; +}) { + const lastSubmission = useActionData() as any; + const navigation = useNavigation(); + + const [hasConfigChanges, setHasConfigChanges] = useState(false); + const [configValues, setConfigValues] = useState({ + pullEnvVarsFromVercel: connectedProject.integrationData.config.pullEnvVarsFromVercel, + spawnDeploymentOnVercelEvent: + connectedProject.integrationData.config.spawnDeploymentOnVercelEvent, + spawnBuildOnVercelEvent: connectedProject.integrationData.config.spawnBuildOnVercelEvent, + vercelStagingEnvironment: + connectedProject.integrationData.config.vercelStagingEnvironment || "", + vercelStagingName: connectedProject.integrationData.config.vercelStagingName || null, + }); + + useEffect(() => { + const hasChanges = + configValues.pullEnvVarsFromVercel !== + connectedProject.integrationData.config.pullEnvVarsFromVercel || + configValues.spawnDeploymentOnVercelEvent !== + connectedProject.integrationData.config.spawnDeploymentOnVercelEvent || + configValues.spawnBuildOnVercelEvent !== + connectedProject.integrationData.config.spawnBuildOnVercelEvent || + configValues.vercelStagingEnvironment !== + (connectedProject.integrationData.config.vercelStagingEnvironment || "") || + configValues.vercelStagingName !== + (connectedProject.integrationData.config.vercelStagingName || null); + setHasConfigChanges(hasChanges); + }, [configValues, connectedProject.integrationData.config]); + + const [configForm, fields] = useForm({ + id: "update-vercel-config", + lastSubmission: lastSubmission, + shouldRevalidate: "onSubmit", + onValidate({ formData }) { + return parse(formData, { + schema: UpdateVercelConfigFormSchema, + }); + }, + }); + + const isConfigLoading = + navigation.formData?.get("action") === "update-config" && + (navigation.state === "submitting" || navigation.state === "loading"); + + const actionUrl = vercelResourcePath(organizationSlug, projectSlug, environmentSlug); + + return ( + <> + {/* Connected project info */} +
+
+ + + {connectedProject.vercelProjectName} + + + + +
+ + + + + + Disconnect Vercel project +
+ + Are you sure you want to disconnect{" "} + {connectedProject.vercelProjectName}? + This will stop syncing environment variables and disable Vercel-triggered builds. + + + + + + } + cancelButton={ + + + + } + /> +
+
+
+
+ + {/* Configuration form */} +
+
+ +
+ {/* Staging environment mapping info */} + {hasStagingEnvironment && + connectedProject.integrationData.config.vercelStagingEnvironment && + connectedProject.integrationData.config.vercelStagingEnvironment !== "" && + connectedProject.integrationData.config.vercelStagingName && ( +
+
+ + + Vercel environment mapped to Trigger.dev's Staging environment. + +
+ + {connectedProject.integrationData.config.vercelStagingName} + +
+ )} + + {/* Pull env vars toggle */} +
+
+ + + When enabled, environment variables will be pulled from Vercel during builds. + Configure which variables to sync on the{" "} + + environment variables page + + . + +
+ { + setConfigValues((prev) => ({ + ...prev, + pullEnvVarsFromVercel: checked, + })); + }} + /> +
+ + {/* Spawn deployment toggle */} +
+
+ + + When enabled, a Trigger.dev deployment will be created when Vercel deploys. + +
+ { + setConfigValues((prev) => ({ + ...prev, + spawnDeploymentOnVercelEvent: checked, + })); + }} + /> +
+ + {/* Spawn build toggle */} +
+
+ + + When enabled, a Trigger.dev build will be triggered when Vercel builds. + +
+ { + setConfigValues((prev) => ({ + ...prev, + spawnBuildOnVercelEvent: checked, + })); + }} + /> +
+ + {/* Staging environment mapping */} + {hasStagingEnvironment && customEnvironments && customEnvironments.length > 0 && ( +
+ + + Select which custom Vercel environment should map to Trigger.dev's Staging + environment. + + + {configValues.vercelStagingName && ( + + )} +
+ )} +
+ + {configForm.error} +
+ + + Save + + } + /> +
+ + + ); +} + +// ============================================================================ +// Main Vercel Settings Panel Component +// ============================================================================ + +function VercelSettingsPanel({ + organizationSlug, + projectSlug, + environmentSlug, + onOpenVercelModal, + isLoadingVercelData, +}: { + organizationSlug: string; + projectSlug: string; + environmentSlug: string; + onOpenVercelModal?: () => void; + isLoadingVercelData?: boolean; +}) { + const fetcher = useTypedFetcher(); + const location = useLocation(); + + useEffect(() => { + fetcher.load(vercelResourcePath(organizationSlug, projectSlug, environmentSlug)); + }, [organizationSlug, projectSlug, environmentSlug]); + + const data = fetcher.data; + + // Loading state + if (fetcher.state === "loading" && !data) { + return ( +
+ + Loading Vercel settings... +
+ ); + } + + // Vercel integration not enabled + if (!data || !data.enabled) { + return null; + } + + // Show warning if Vercel connected but GitHub not + const showGitHubWarning = data.connectedProject && !data.isGitHubConnected; + + // Connected project exists - show form + if (data.connectedProject) { + return ( + <> + {showGitHubWarning && } + + + ); + } + + // No connected project - show connection prompt + // If org integration exists, show "app installed" message; otherwise show install button + return ( +
+ + + {data.hasOrgIntegration + ? "Connect your Vercel project to sync environment variables and trigger builds automatically." + : "Install the Vercel app to connect your projects and sync environment variables."} + + {!data.isGitHubConnected && ( + + GitHub integration is not connected. Vercel integration cannot sync environment variables or + spawn Trigger.dev builds without a properly installed GitHub integration. + + )} +
+ ); +} + +// ============================================================================ +// Onboarding Modal Component +// ============================================================================ + +type OnboardingState = + | "idle" // Initial state + | "installing" // Redirecting to Vercel installation (transient) + | "loading-projects" // Loading Vercel projects list + | "project-selection" // Showing project selection UI + | "loading-env-mapping" // After project selection, checking for custom envs + | "env-mapping" // Showing custom environment mapping UI + | "loading-env-vars" // Loading environment variables + | "env-var-sync" // Showing environment variable sync UI + | "completed"; // Onboarding complete (closes modal) + +function VercelOnboardingModal({ + isOpen, + onClose, + onboardingData, + organizationSlug, + projectSlug, + environmentSlug, + hasStagingEnvironment, + hasOrgIntegration, + onDataReload, +}: { + isOpen: boolean; + onClose: () => void; + onboardingData: VercelOnboardingData | null; + organizationSlug: string; + projectSlug: string; + environmentSlug: string; + hasStagingEnvironment: boolean; + hasOrgIntegration: boolean; + onDataReload?: () => void; +}) { + const navigation = useNavigation(); + const fetcher = useTypedFetcher(); + const envMappingFetcher = useFetcher(); + const completeOnboardingFetcher = useFetcher(); + const { Form: CompleteOnboardingForm } = completeOnboardingFetcher; + + const availableProjects = onboardingData?.availableProjects || []; + const hasProjectSelected = onboardingData?.hasProjectSelected ?? false; + const customEnvironments = onboardingData?.customEnvironments || []; + const envVars = onboardingData?.environmentVariables || []; + const hasCustomEnvs = customEnvironments.length > 0 && hasStagingEnvironment; + + // Compute initial state based on current data + const computeInitialState = useCallback((): OnboardingState => { + // If no org integration, stay in idle (shouldn't happen as modal only opens with integration) + if (!hasOrgIntegration) { + return "idle"; + } + // If no project selected, check if we need to load projects + const projectSelected = onboardingData?.hasProjectSelected ?? false; + if (!projectSelected) { + if (!onboardingData?.availableProjects || onboardingData.availableProjects.length === 0) { + return "loading-projects"; + } + return "project-selection"; + } + // Project selected, check for custom environments + const customEnvs = (onboardingData?.customEnvironments?.length ?? 0) > 0 && hasStagingEnvironment; + if (customEnvs) { + return "env-mapping"; + } + // No custom envs, check if env vars are loaded + if (!onboardingData?.environmentVariables || onboardingData.environmentVariables.length === 0) { + return "loading-env-vars"; + } + return "env-var-sync"; + }, [hasOrgIntegration, onboardingData, hasStagingEnvironment]); + + // Initialize state based on current data when modal opens + const [state, setState] = useState(() => { + if (!isOpen) return "idle"; + return computeInitialState(); + }); + + // Update state when modal opens or data changes + const prevIsOpenRef = useRef(isOpen); + useEffect(() => { + if (isOpen && !prevIsOpenRef.current) { + // Modal just opened, compute initial state + setState(computeInitialState()); + } else if (isOpen && state === "idle") { + // Modal is open but in idle state, compute initial state + setState(computeInitialState()); + } + prevIsOpenRef.current = isOpen; + }, [isOpen, state, computeInitialState]); + + const [selectedVercelProject, setSelectedVercelProject] = useState<{ + id: string; + name: string; + } | null>(null); + const [vercelStagingEnvironment, setVercelStagingEnvironment] = useState(""); + const [pullEnvVarsFromVercel, setPullEnvVarsFromVercel] = useState(true); + const [syncEnvVarsMapping, setSyncEnvVarsMapping] = useState({}); + const [expandedEnvVars, setExpandedEnvVars] = useState(false); + const [projectSelectionError, setProjectSelectionError] = useState(null); + + // State machine processor: handles state transitions based on current state and available data + // Note: "idle" state is only when modal is closed, so we don't process it here + useEffect(() => { + if (!isOpen || state === "idle") { + return; // Don't process when modal is closed or in idle state + } + + switch (state) { + + case "loading-projects": + // Trigger data reload to fetch projects + if (onDataReload) { + onDataReload(); + } + // Transition will happen when data loads (handled by another effect) + break; + + case "loading-env-mapping": + // After project selection, reload data to get custom environments + if (onDataReload) { + onDataReload(); + } + // Transition handled by button click success + break; + + case "loading-env-vars": + // Reload data to get environment variables + if (onDataReload) { + onDataReload(); + } + // Transition to env-var-sync when data is ready (handled by another effect) + break; + + // Other states don't need processing + case "installing": + case "project-selection": + case "env-mapping": + case "env-var-sync": + case "completed": + break; + } + }, [isOpen, state, hasOrgIntegration, hasProjectSelected, onboardingData, hasCustomEnvs, hasStagingEnvironment, onDataReload]); + + // Watch for data loading completion + useEffect(() => { + if (state === "loading-projects" && onboardingData?.availableProjects && onboardingData.availableProjects.length > 0) { + // Projects loaded, transition to project selection + setState("project-selection"); + } + }, [state, onboardingData?.availableProjects]); + + useEffect(() => { + if (state === "loading-env-vars" && onboardingData?.environmentVariables) { + // Environment variables loaded, transition to env-var-sync + setState("env-var-sync"); + } + }, [state, onboardingData?.environmentVariables]); + + // Handle successful project selection - transition to loading-env-mapping + useEffect(() => { + if (state === "project-selection" && fetcher.data && "success" in fetcher.data && fetcher.data.success && fetcher.state === "idle") { + // Project selection succeeded, transition to loading-env-mapping + setState("loading-env-mapping"); + // Reload data to get updated project info and env vars + if (onDataReload) { + onDataReload(); + } + } else if (fetcher.data && "error" in fetcher.data && typeof fetcher.data.error === "string") { + setProjectSelectionError(fetcher.data.error); + } + }, [state, fetcher.data, fetcher.state, onDataReload]); + + // Handle loading-env-mapping completion - check for custom environments + useEffect(() => { + if (state === "loading-env-mapping" && onboardingData) { + const hasCustomEnvs = (onboardingData.customEnvironments?.length ?? 0) > 0 && hasStagingEnvironment; + if (hasCustomEnvs) { + setState("env-mapping"); + } else { + // No custom envs, load env vars + setState("loading-env-vars"); + } + } + }, [state, onboardingData, hasStagingEnvironment]); + + // Calculate env var stats + const secretEnvVars = envVars.filter((v) => v.isSecret); + const syncableEnvVars = envVars.filter((v) => !v.isSecret); + const enabledEnvVars = syncableEnvVars.filter( + (v) => shouldSyncEnvVarForAnyEnvironment(syncEnvVarsMapping, v.key) + ); + + const isSubmitting = + navigation.state === "submitting" || navigation.state === "loading"; + + const actionUrl = vercelResourcePath(organizationSlug, projectSlug, environmentSlug); + + const handleToggleEnvVar = useCallback((key: string, enabled: boolean) => { + setSyncEnvVarsMapping((prev) => { + if (enabled) { + // Remove from mapping (default is enabled for all environments) + const { [key]: _, ...rest } = prev; + return rest; + } + // Disable for all environments + return { + ...prev, + [key]: { + PRODUCTION: false, + STAGING: false, + PREVIEW: false, + DEVELOPMENT: false, + }, + }; + }); + }, []); + + const handleToggleAll = useCallback( + (enabled: boolean) => { + setPullEnvVarsFromVercel(enabled); + if (enabled) { + // Reset all mappings (default to sync all) + setSyncEnvVarsMapping({}); + } + }, + [] + ); + + // Handle project selection submission - explicit state transition + const handleProjectSelection = useCallback(async () => { + if (!selectedVercelProject) { + setProjectSelectionError("Please select a Vercel project"); + return; + } + + setProjectSelectionError(null); + + // Submit the form programmatically using fetcher + const formData = new FormData(); + formData.append("action", "select-vercel-project"); + formData.append("vercelProjectId", selectedVercelProject.id); + formData.append("vercelProjectName", selectedVercelProject.name); + + fetcher.submit(formData, { + method: "post", + action: actionUrl, + }); + // State transition to loading-env-mapping will happen in useEffect when success + }, [selectedVercelProject, fetcher, actionUrl]); + + + // Handle skip onboarding submission - close modal immediately and submit in background + const handleSkipOnboarding = useCallback(() => { + // Close modal immediately + onClose(); + + // Submit in background (non-blocking) + const formData = new FormData(); + formData.append("action", "skip-onboarding"); + fetcher.submit(formData, { + method: "post", + action: actionUrl, + }); + }, [actionUrl, fetcher, onClose]); + + // Handle environment mapping update - explicit state transition + const handleUpdateEnvMapping = useCallback(() => { + const formData = new FormData(); + formData.append("action", "update-env-mapping"); + if (vercelStagingEnvironment) { + formData.append("vercelStagingEnvironment", vercelStagingEnvironment); + // Look up the name from customEnvironments + const environment = customEnvironments.find((env) => env.id === vercelStagingEnvironment); + if (environment) { + formData.append("vercelStagingName", environment.slug); + } + } + envMappingFetcher.submit(formData, { + method: "post", + action: actionUrl, + }); + // State transition to loading-env-vars will happen in useEffect when success + }, [vercelStagingEnvironment, customEnvironments, envMappingFetcher, actionUrl]); + + // Handle Finish button - submit form via fetcher + const handleFinishOnboarding = useCallback((e: React.FormEvent) => { + e.preventDefault(); + const form = e.currentTarget; + const formData = new FormData(form); + completeOnboardingFetcher.submit(formData, { + method: "post", + action: actionUrl, + }); + }, [completeOnboardingFetcher, actionUrl]); + + // Handle successful onboarding completion + useEffect(() => { + if (completeOnboardingFetcher.data && typeof completeOnboardingFetcher.data === "object" && "success" in completeOnboardingFetcher.data && completeOnboardingFetcher.data.success && completeOnboardingFetcher.state === "idle") { + setState("completed"); + } + }, [completeOnboardingFetcher.data, completeOnboardingFetcher.state]); + + // Handle completed state - close modal + useEffect(() => { + if (state === "completed") { + onClose(); + } + }, [state, onClose]); + + // Handle installation redirect + useEffect(() => { + if (state === "installing") { + const installUrl = vercelAppInstallPath(organizationSlug, projectSlug); + window.location.href = installUrl; // Same window redirect + } + }, [state, organizationSlug, projectSlug]); + + // Handle successful env mapping update + useEffect(() => { + if (envMappingFetcher.data && typeof envMappingFetcher.data === "object" && "success" in envMappingFetcher.data && envMappingFetcher.data.success && envMappingFetcher.state === "idle") { + setState("loading-env-vars"); + } + }, [envMappingFetcher.data, envMappingFetcher.state]); + + // Don't render if modal is closed + if (!isOpen) { + return null; + } + + // Show loading state for loading states or if data is not ready yet + // Note: "idle" is only when modal is closed, so we don't show loading for it + const isLoadingState = + state === "loading-projects" || + state === "loading-env-mapping" || + state === "loading-env-vars" || + state === "installing" || + (state === "idle" && !onboardingData); + + if (isLoadingState) { + return ( + !open && onClose()}> + + +
+ + Set up Vercel Integration +
+
+
+ +
+
+
+ ); + } + + // Determine which step content to show based on state machine + const showProjectSelection = state === "project-selection"; + const showEnvMapping = state === "env-mapping"; + const showEnvVarSync = state === "env-var-sync"; + + return ( + !open && onClose()}> + + +
+ + Set up Vercel Integration +
+
+ +
+ {/* Step: Project Selection (only if no project selected) */} + {showProjectSelection && ( +
+ Select Vercel Project + + Choose which Vercel project to connect with this Trigger.dev project. + Your API keys will be automatically synced to Vercel. + + + {availableProjects.length === 0 ? ( + + No Vercel projects found. Please create a project in Vercel first. + + ) : ( + + )} + + {projectSelectionError && ( + {projectSelectionError} + )} + + + Once connected, your TRIGGER_SECRET_KEY will be + automatically synced to Vercel for each environment. + + + + {fetcher.state !== "idle" ? "Connecting..." : "Connect Project"} + + } + cancelButton={ + + } + /> +
+ )} + + {/* Step: Environment Mapping (only if custom environments exist) */} + {showEnvMapping && ( +
+ Map Vercel Environment to Staging + + Select which custom Vercel environment should map to Trigger.dev's Staging + environment. Production and Preview environments are mapped automatically. + + + + + + Next + + } + cancelButton={ + + } + /> +
+ )} + + {/* Step: Environment Variables Sync */} + {showEnvVarSync && ( + + + + {vercelStagingEnvironment && ( + e.id === vercelStagingEnvironment)?.slug || "" + } + /> + )} + + +
+ Sync Environment Variables + + {/* Stats */} +
+
+ {syncableEnvVars.length} + can be synced +
+ {secretEnvVars.length > 0 && ( +
+ {secretEnvVars.length} + secret (cannot sync) +
+ )} +
+ + {/* Main toggle */} +
+
+ + Enable syncing of environment variables from Vercel during builds. +
+ +
+ + {/* Expandable env var list */} + {pullEnvVarsFromVercel && envVars.length > 0 && ( +
+ + + {expandedEnvVars && ( +
+ {envVars.map((envVar) => ( +
+
+ {envVar.key} + {envVar.target && envVar.target.length > 0 && ( + + {formatVercelTargets(envVar.target)} + {envVar.isShared && " · Shared"} + + )} +
+ {envVar.isSecret ? ( + Secret + ) : ( + + handleToggleEnvVar(envVar.key, checked) + } + /> + )} +
+ ))} +
+ )} +
+ )} + + + Finish + + } + cancelButton={ + hasCustomEnvs ? ( + + ) : ( + + ) + } + /> +
+
+ )} +
+
+
+ ); +} + +// Export components for use in other routes +export { VercelSettingsPanel, VercelOnboardingModal }; diff --git a/apps/webapp/app/services/vercelIntegration.server.ts b/apps/webapp/app/services/vercelIntegration.server.ts new file mode 100644 index 0000000000..f363d91efc --- /dev/null +++ b/apps/webapp/app/services/vercelIntegration.server.ts @@ -0,0 +1,484 @@ +import type { + PrismaClient, + OrganizationProjectIntegration, + OrganizationIntegration, +} from "@trigger.dev/database"; +import { prisma } from "~/db.server"; +import { logger } from "~/services/logger.server"; +import { VercelIntegrationRepository } from "~/models/vercelIntegration.server"; +import { + VercelProjectIntegrationDataSchema, + VercelProjectIntegrationData, + VercelIntegrationConfig, + SyncEnvVarsMapping, + TriggerEnvironmentType, + createDefaultVercelIntegrationData, +} from "~/v3/vercel/vercelProjectIntegrationSchema"; + +/** + * Base type for Vercel project integration with parsed data. + * Used for simple operations that don't need related data. + */ +export type VercelProjectIntegrationWithParsedData = OrganizationProjectIntegration & { + parsedIntegrationData: VercelProjectIntegrationData; +}; + +/** + * Vercel project integration including the organization integration relation. + */ +export type VercelProjectIntegrationWithData = VercelProjectIntegrationWithParsedData & { + organizationIntegration: OrganizationIntegration; +}; + +/** + * Vercel project integration including both organization integration and project relations. + */ +export type VercelProjectIntegrationWithProject = VercelProjectIntegrationWithData & { + project: { + id: string; + name: string; + slug: string; + }; +}; + +export class VercelIntegrationService { + #prismaClient: PrismaClient; + + constructor(prismaClient: PrismaClient = prisma) { + this.#prismaClient = prismaClient; + } + + /** + * Get the Vercel project integration for a specific project. + */ + async getVercelProjectIntegration( + projectId: string + ): Promise { + const integration = await this.#prismaClient.organizationProjectIntegration.findFirst({ + where: { + projectId, + deletedAt: null, + organizationIntegration: { + service: "VERCEL", + deletedAt: null, + }, + }, + include: { + organizationIntegration: true, + }, + }); + + if (!integration) { + return null; + } + + const parsedData = VercelProjectIntegrationDataSchema.safeParse(integration.integrationData); + if (!parsedData.success) { + logger.error("Failed to parse Vercel integration data", { + projectId, + integrationId: integration.id, + error: parsedData.error, + }); + return null; + } + + return { + ...integration, + parsedIntegrationData: parsedData.data, + }; + } + + /** + * Get all connected Vercel projects for an organization. + */ + async getConnectedVercelProjects( + organizationId: string + ): Promise { + const integrations = await this.#prismaClient.organizationProjectIntegration.findMany({ + where: { + deletedAt: null, + organizationIntegration: { + organizationId, + service: "VERCEL", + deletedAt: null, + }, + }, + include: { + organizationIntegration: true, + project: { + select: { + id: true, + name: true, + slug: true, + }, + }, + }, + }); + + return integrations + .map((integration) => { + const parsedData = VercelProjectIntegrationDataSchema.safeParse(integration.integrationData); + if (!parsedData.success) { + logger.error("Failed to parse Vercel integration data", { + integrationId: integration.id, + error: parsedData.error, + }); + return null; + } + + return { + ...integration, + parsedIntegrationData: parsedData.data, + }; + }) + .filter((i): i is VercelProjectIntegrationWithProject => i !== null); + } + + /** + * Create a new Vercel project integration. + * This links a Vercel project to a Trigger.dev project. + */ + async createVercelProjectIntegration(params: { + organizationIntegrationId: string; + projectId: string; + vercelProjectId: string; + vercelProjectName: string; + vercelTeamId: string | null; + installedByUserId?: string; + }): Promise { + const integrationData = createDefaultVercelIntegrationData( + params.vercelProjectId, + params.vercelProjectName, + params.vercelTeamId + ); + + return this.#prismaClient.organizationProjectIntegration.create({ + data: { + organizationIntegrationId: params.organizationIntegrationId, + projectId: params.projectId, + externalEntityId: params.vercelProjectId, + integrationData: integrationData as any, + installedBy: params.installedByUserId, + }, + }); + } + + /** + * Select a Vercel project during onboarding. + * Creates the OrganizationProjectIntegration record and syncs API keys to Vercel. + */ + async selectVercelProject(params: { + organizationId: string; + projectId: string; + vercelProjectId: string; + vercelProjectName: string; + userId: string; + }): Promise<{ + integration: OrganizationProjectIntegration; + syncResult: { success: boolean; errors: string[] }; + }> { + // Get the org integration + const orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationByOrganization( + params.organizationId + ); + + if (!orgIntegration) { + throw new Error("No Vercel organization integration found"); + } + + // Get the team ID from the stored secret + const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration); + + // Check if there's already a project integration (shouldn't happen, but handle gracefully) + const existing = await this.getVercelProjectIntegration(params.projectId); + if (existing) { + // Update the existing integration + const updated = await this.#prismaClient.organizationProjectIntegration.update({ + where: { id: existing.id }, + data: { + externalEntityId: params.vercelProjectId, + integrationData: { + ...existing.parsedIntegrationData, + vercelProjectId: params.vercelProjectId, + vercelProjectName: params.vercelProjectName, + vercelTeamId: teamId, + } as any, + }, + }); + + // Sync API keys to the newly selected project + const syncResult = await VercelIntegrationRepository.syncApiKeysToVercel({ + projectId: params.projectId, + vercelProjectId: params.vercelProjectId, + teamId, + vercelStagingEnvironment: existing.parsedIntegrationData.config.vercelStagingEnvironment, + orgIntegration, + }); + + return { integration: updated, syncResult }; + } + + // Create new project integration + const integration = await this.createVercelProjectIntegration({ + organizationIntegrationId: orgIntegration.id, + projectId: params.projectId, + vercelProjectId: params.vercelProjectId, + vercelProjectName: params.vercelProjectName, + vercelTeamId: teamId, + installedByUserId: params.userId, + }); + + // Sync API keys to Vercel immediately + const syncResult = await VercelIntegrationRepository.syncApiKeysToVercel({ + projectId: params.projectId, + vercelProjectId: params.vercelProjectId, + teamId, + // No staging environment mapping yet - will be set during onboarding + vercelStagingEnvironment: null, + orgIntegration, + }); + + logger.info("Vercel project selected and API keys synced", { + projectId: params.projectId, + vercelProjectId: params.vercelProjectId, + vercelProjectName: params.vercelProjectName, + syncSuccess: syncResult.success, + syncErrors: syncResult.errors, + }); + + return { integration, syncResult }; + } + + /** + * Update the Vercel integration config for a project. + */ + async updateVercelIntegrationConfig( + projectId: string, + configUpdates: Partial + ): Promise { + const existing = await this.getVercelProjectIntegration(projectId); + if (!existing) { + return null; + } + + const updatedConfig = { + ...existing.parsedIntegrationData.config, + ...configUpdates, + }; + + const updatedData: VercelProjectIntegrationData = { + ...existing.parsedIntegrationData, + config: updatedConfig, + }; + + const updated = await this.#prismaClient.organizationProjectIntegration.update({ + where: { id: existing.id }, + data: { + integrationData: updatedData as any, + }, + }); + + return { + ...updated, + parsedIntegrationData: updatedData, + }; + } + + /** + * Update the environment variable sync mapping for a project. + */ + async updateSyncEnvVarsMapping( + projectId: string, + syncEnvVarsMapping: SyncEnvVarsMapping + ): Promise { + const existing = await this.getVercelProjectIntegration(projectId); + if (!existing) { + return null; + } + + const updatedData: VercelProjectIntegrationData = { + ...existing.parsedIntegrationData, + syncEnvVarsMapping, + }; + + const updated = await this.#prismaClient.organizationProjectIntegration.update({ + where: { id: existing.id }, + data: { + integrationData: updatedData as any, + }, + }); + + return { + ...updated, + parsedIntegrationData: updatedData, + }; + } + + /** + * Update the sync status of a specific environment variable for a given environment type. + * This is used when toggling individual env var sync settings from the UI. + */ + async updateSyncEnvVarForEnvironment( + projectId: string, + envVarKey: string, + environmentType: TriggerEnvironmentType, + syncEnabled: boolean + ): Promise { + const existing = await this.getVercelProjectIntegration(projectId); + if (!existing) { + return null; + } + + // Get the current sync mapping + const currentMapping = existing.parsedIntegrationData.syncEnvVarsMapping || {}; + + // Get the current settings for this env var (if any) + const currentEnvVarSettings = currentMapping[envVarKey] || {}; + + // Create a new mapping with the updated value + const updatedMapping: SyncEnvVarsMapping = { + ...currentMapping, + [envVarKey]: { + ...currentEnvVarSettings, + [environmentType]: syncEnabled, + }, + }; + + const updatedData: VercelProjectIntegrationData = { + ...existing.parsedIntegrationData, + syncEnvVarsMapping: updatedMapping, + }; + + const updated = await this.#prismaClient.organizationProjectIntegration.update({ + where: { id: existing.id }, + data: { + integrationData: updatedData as any, + }, + }); + + return { + ...updated, + parsedIntegrationData: updatedData, + }; + } + + /** + * Complete the onboarding process and save all user selections. + * If pullEnvVarsFromVercel is true, also pulls env vars from Vercel and stores them in the database. + */ + async completeOnboarding( + projectId: string, + params: { + vercelStagingEnvironment?: string | null; + vercelStagingName?: string | null; + pullEnvVarsFromVercel: boolean; + syncEnvVarsMapping: SyncEnvVarsMapping; + } + ): Promise { + const existing = await this.getVercelProjectIntegration(projectId); + if (!existing) { + return null; + } + + const updatedData: VercelProjectIntegrationData = { + ...existing.parsedIntegrationData, + config: { + ...existing.parsedIntegrationData.config, + pullEnvVarsFromVercel: params.pullEnvVarsFromVercel, + vercelStagingEnvironment: params.vercelStagingEnvironment ?? null, + vercelStagingName: params.vercelStagingName ?? null, + }, + syncEnvVarsMapping: params.syncEnvVarsMapping, + }; + + const updated = await this.#prismaClient.organizationProjectIntegration.update({ + where: { id: existing.id }, + data: { + integrationData: updatedData as any, + }, + }); + + // Pull env vars from Vercel if enabled + if (params.pullEnvVarsFromVercel) { + try { + // Get the org integration with token reference + const orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationForProject( + projectId + ); + + if (orgIntegration) { + const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration); + + const pullResult = await VercelIntegrationRepository.pullEnvVarsFromVercel({ + projectId, + vercelProjectId: updatedData.vercelProjectId, + teamId, + vercelStagingEnvironment: params.vercelStagingEnvironment, + syncEnvVarsMapping: params.syncEnvVarsMapping, + orgIntegration, + }); + + if (!pullResult.success) { + logger.warn("Some errors occurred while pulling env vars from Vercel", { + projectId, + vercelProjectId: updatedData.vercelProjectId, + errors: pullResult.errors, + syncedCount: pullResult.syncedCount, + }); + } else { + logger.info("Successfully pulled env vars from Vercel", { + projectId, + vercelProjectId: updatedData.vercelProjectId, + syncedCount: pullResult.syncedCount, + }); + } + } else { + logger.warn("No org integration found when trying to pull env vars from Vercel", { + projectId, + }); + } + } catch (error) { + // Log but don't fail onboarding if env var pull fails + logger.error("Failed to pull env vars from Vercel during onboarding", { + projectId, + vercelProjectId: updatedData.vercelProjectId, + error, + }); + } + } + + return { + ...updated, + parsedIntegrationData: updatedData, + }; + } + + /** + * Skip onboarding without modifying any settings. + * This is a no-op that just closes the modal - no database changes needed. + */ + async skipOnboarding(_projectId: string): Promise { + // No-op - onboarding is tracked only via URL query parameter + // This method exists for API consistency + } + + /** + * Disconnect a Vercel project from a Trigger.dev project (soft delete). + */ + async disconnectVercelProject(projectId: string): Promise { + const existing = await this.getVercelProjectIntegration(projectId); + if (!existing) { + return false; + } + + await this.#prismaClient.organizationProjectIntegration.update({ + where: { id: existing.id }, + data: { + deletedAt: new Date(), + }, + }); + + return true; + } +} + diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 639f2f7294..86e7501536 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -151,6 +151,24 @@ export function githubAppInstallPath(organizationSlug: string, redirectTo: strin )}`; } +export function vercelAppInstallPath(organizationSlug: string, projectSlug: string) { + return `/vercel/install?org_slug=${organizationSlug}&project_slug=${projectSlug}`; +} + +export function vercelCallbackPath() { + return `/callback/vercel`; +} + +export function vercelResourcePath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `/resources/orgs/${organizationParam(organization)}/projects/${projectParam( + project + )}/env/${environmentParam(environment)}/vercel`; +} + export function v3EnvironmentPath( organization: OrgForPath, project: ProjectForPath, diff --git a/apps/webapp/app/v3/vercel/index.ts b/apps/webapp/app/v3/vercel/index.ts new file mode 100644 index 0000000000..a6e1b3ae54 --- /dev/null +++ b/apps/webapp/app/v3/vercel/index.ts @@ -0,0 +1,8 @@ +/** + * Vercel integration module. + * + * This module provides types and utilities for the Vercel integration feature. + */ + +export * from "./vercelProjectIntegrationSchema"; + diff --git a/apps/webapp/app/v3/vercel/vercelOAuthState.server.ts b/apps/webapp/app/v3/vercel/vercelOAuthState.server.ts new file mode 100644 index 0000000000..54c0838b47 --- /dev/null +++ b/apps/webapp/app/v3/vercel/vercelOAuthState.server.ts @@ -0,0 +1,58 @@ +import { generateJWT, validateJWT } from "@trigger.dev/core/v3/jwt"; +import { z } from "zod"; +import { env } from "~/env.server"; + +/** + * Schema for Vercel OAuth state JWT payload. + */ +export const VercelOAuthStateSchema = z.object({ + organizationId: z.string(), + projectId: z.string(), + environmentSlug: z.string(), + organizationSlug: z.string(), + projectSlug: z.string(), +}); + +export type VercelOAuthState = z.infer; + +/** + * Generate a JWT state token for Vercel OAuth flow. + * This function is server-only as it requires the encryption key. + * + * @param params - The state parameters to encode + * @returns A signed JWT token containing the state + */ +export async function generateVercelOAuthState( + params: VercelOAuthState +): Promise { + return generateJWT({ + secretKey: env.ENCRYPTION_KEY, + payload: params, + // OAuth state tokens should be short-lived (15 minutes) + expirationTime: "15m", + }); +} + +/** + * Validate and decode a Vercel OAuth state JWT token. + * + * @param token - The JWT token to validate + * @returns The decoded state or null if invalid + */ +export async function validateVercelOAuthState( + token: string +): Promise<{ ok: true; state: VercelOAuthState } | { ok: false; error: string }> { + const result = await validateJWT(token, env.ENCRYPTION_KEY); + + if (!result.ok) { + return { ok: false, error: result.error }; + } + + const parseResult = VercelOAuthStateSchema.safeParse(result.payload); + if (!parseResult.success) { + return { ok: false, error: "Invalid state payload" }; + } + + return { ok: true, state: parseResult.data }; +} + diff --git a/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts b/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts new file mode 100644 index 0000000000..5124366027 --- /dev/null +++ b/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts @@ -0,0 +1,243 @@ +import { z } from "zod"; + +/** + * Configuration for the Vercel integration. + * + * These settings control how the integration behaves when syncing environment variables + * and responding to Vercel deployment/build events. + */ +export const VercelIntegrationConfigSchema = z.object({ + /** + * When true, environment variables are pulled from Vercel during builds/deployments. + * This is the main toggle that controls whether env var syncing is enabled. + */ + pullEnvVarsFromVercel: z.boolean().default(true), + + /** + * When true, a Trigger.dev deployment is spawned when a Vercel deployment event occurs. + * This will be handled by the webhook implementation in the other repository. + */ + spawnDeploymentOnVercelEvent: z.boolean().default(false), + + /** + * When true, a Trigger.dev build is spawned when a Vercel build event occurs. + * This will be handled by the webhook implementation in the other repository. + */ + spawnBuildOnVercelEvent: z.boolean().default(false), + + /** + * Maps a custom Vercel environment to Trigger.dev's staging environment. + * Vercel environments: + * - production → Trigger.dev production (automatic) + * - preview → Trigger.dev preview (automatic) + * - development → not mapped + * - custom environments → user can select one to map to Trigger.dev staging + * + * This field stores the custom Vercel environment ID that maps to staging. + * When null, no custom environment is mapped to staging. + */ + vercelStagingEnvironment: z.string().nullable().default(null), + + /** + * The name (slug) of the custom Vercel environment mapped to staging. + * This is stored for display purposes to avoid needing to look up the name from the ID. + * When null, no custom environment is mapped to staging. + */ + vercelStagingName: z.string().nullable().default(null), +}); + +export type VercelIntegrationConfig = z.infer; + +/** + * Environment types for sync mapping + */ +export const TriggerEnvironmentType = z.enum(["PRODUCTION", "STAGING", "PREVIEW", "DEVELOPMENT"]); +export type TriggerEnvironmentType = z.infer; + +/** + * Per-environment sync settings for a single environment variable. + */ +export const EnvVarSyncSettingsSchema = z.record(TriggerEnvironmentType, z.boolean()); +export type EnvVarSyncSettings = z.infer; + +/** + * Mapping of environment variable names to per-environment sync settings. + * + * - If an env var name is missing from this map, it is synced by default for ALL environments. + * - For each env var, you can enable/disable syncing per environment. + * - If an environment is missing from the env var's settings, it defaults to sync (true). + * - Secret environment variables from Vercel cannot be synced due to API limitations. + * + * @example + * { + * "DATABASE_URL": { + * "PRODUCTION": true, // sync for production + * "STAGING": false, // don't sync for staging + * "PREVIEW": true, // sync for preview + * "DEVELOPMENT": false // don't sync for development + * }, + * // "API_KEY" is not in the map - will be synced for all environments by default + * } + */ +export const SyncEnvVarsMappingSchema = z.record(z.string(), EnvVarSyncSettingsSchema); + +export type SyncEnvVarsMapping = z.infer; + +/** + * Legacy mapping format (simple boolean per env var) + * Used for migration from old format to new format. + */ +export const LegacySyncEnvVarsMappingSchema = z.record(z.string(), z.boolean()); +export type LegacySyncEnvVarsMapping = z.infer; + +/** + * The complete integrationData schema for OrganizationProjectIntegration + * when the integration service is VERCEL. + * + * This is stored in the `integrationData` JSON field of OrganizationProjectIntegration. + */ +export const VercelProjectIntegrationDataSchema = z.object({ + /** + * Configuration settings for the Vercel integration + */ + config: VercelIntegrationConfigSchema, + + /** + * Mapping of environment variable names to whether they should be synced. + * See SyncEnvVarsMappingSchema for detailed documentation. + */ + syncEnvVarsMapping: SyncEnvVarsMappingSchema.default({}), + + /** + * The name of the Vercel project (for display purposes) + */ + vercelProjectName: z.string(), + + /** + * The Vercel team/organization ID (null for personal accounts) + */ + vercelTeamId: z.string().nullable(), + + /** + * The Vercel project ID. + * Note: This is also stored in OrganizationProjectIntegration.externalEntityId + * but duplicated here for convenience. + */ + vercelProjectId: z.string(), +}); + +export type VercelProjectIntegrationData = z.infer; + +/** + * Helper function to create default integration data for a new Vercel project connection. + */ +export function createDefaultVercelIntegrationData( + vercelProjectId: string, + vercelProjectName: string, + vercelTeamId: string | null +): VercelProjectIntegrationData { + return { + config: { + pullEnvVarsFromVercel: true, + spawnDeploymentOnVercelEvent: false, + spawnBuildOnVercelEvent: false, + vercelStagingEnvironment: null, + vercelStagingName: null, + }, + syncEnvVarsMapping: {}, + vercelProjectId, + vercelProjectName, + vercelTeamId, + }; +} + +/** + * Type guard to check if env var should be synced for a specific environment. + * Returns true if: + * - The env var is not in the mapping (sync all by default) + * - The environment is not in the env var's settings (sync by default) + * - The value is explicitly true + * Returns false only when explicitly set to false for the environment. + */ +export function shouldSyncEnvVar( + mapping: SyncEnvVarsMapping, + envVarName: string, + environmentType: TriggerEnvironmentType +): boolean { + const envVarSettings = mapping[envVarName]; + // If env var not in mapping, sync by default for all environments + if (!envVarSettings) { + return true; + } + const value = envVarSettings[environmentType]; + // If environment not specified, default to true (sync by default) + // Only skip if explicitly set to false + return value !== false; +} + +/** + * Check if env var should be synced for any environment. + * Used for display purposes to determine if an env var is partially or fully enabled. + */ +export function shouldSyncEnvVarForAnyEnvironment( + mapping: SyncEnvVarsMapping, + envVarName: string +): boolean { + const envVarSettings = mapping[envVarName]; + // If env var not in mapping, sync by default for all environments + if (!envVarSettings) { + return true; + } + // Check if at least one environment is enabled + const environments: TriggerEnvironmentType[] = ["PRODUCTION", "STAGING", "PREVIEW", "DEVELOPMENT"]; + return environments.some((env) => envVarSettings[env] !== false); +} + +/** + * Check if this is a legacy format mapping (simple boolean per env var) + */ +export function isLegacySyncEnvVarsMapping(mapping: unknown): mapping is LegacySyncEnvVarsMapping { + if (!mapping || typeof mapping !== "object") { + return false; + } + // Check if any value is a boolean (legacy format) + // vs an object (new format) + for (const value of Object.values(mapping)) { + if (typeof value === "boolean") { + return true; + } + // If it's an object, it's the new format + if (typeof value === "object" && value !== null) { + return false; + } + } + // Empty object could be either, treat as new format + return false; +} + +/** + * Migrate legacy sync mapping format to new per-environment format. + * If the env var was disabled in legacy format, it will be disabled for ALL environments. + * If it was enabled (or not present), it will be enabled for all environments. + */ +export function migrateLegacySyncEnvVarsMapping( + legacyMapping: LegacySyncEnvVarsMapping +): SyncEnvVarsMapping { + const newMapping: SyncEnvVarsMapping = {}; + const environments: TriggerEnvironmentType[] = ["PRODUCTION", "STAGING", "PREVIEW", "DEVELOPMENT"]; + + for (const [key, enabled] of Object.entries(legacyMapping)) { + if (enabled === false) { + // If disabled in legacy format, disable for all environments + newMapping[key] = { + PRODUCTION: false, + STAGING: false, + PREVIEW: false, + DEVELOPMENT: false, + }; + } + // If enabled (true), we don't need to add it since default is enabled + } + + return newMapping; +} diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index 71147dc308..df81cdb623 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -522,6 +522,7 @@ export const DeploymentTriggeredVia = z "cli:travis_ci", "cli:buildkite", "git_integration:github", + "integration:vercel", "dashboard", ]) .or(anyString); From 5aca4114b6c5706062a9a7d26e7df7e99d9872a7 Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Fri, 9 Jan 2026 14:52:09 +0100 Subject: [PATCH 03/33] feat(integrations): add Vercel integration settings UI and paths Add routes, loader/action, and path helpers to support Vercel integration management under organization settings. - Add organizationIntegrationsPath and organizationVercelIntegrationPath helpers to pathBuilder for consistent settings URLs. - Implement a new route _app.orgs.$organizationSlug.settings.integrations.vercel.tsx that: - validates org params and optional configurationId query param, - enforces organization membership and returns 404 if not found, - loads the organization's Vercel integration, its team/installation IDs, and connected projects, - provides action handling scaffolding for uninstall intent and integrates with existing services/repositories. - Include types, UI primitives, and data formatting via remix-typedjson to render integration details and manage deletion. Motivation: expose a dedicated settings page to view and manage Vercel integrations per organization, enable deep linking to specific integration configurations, and centralize route/path construction for consistency across the app. --- .../OrganizationSettingsSideMenu.tsx | 9 + .../app/models/vercelIntegration.server.ts | 30 ++ ...ationSlug.settings.integrations.vercel.tsx | 378 ++++++++++++++++++ apps/webapp/app/routes/vercel.configure.tsx | 47 +++ apps/webapp/app/utils/pathBuilder.ts | 8 + 5 files changed, 472 insertions(+) create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx create mode 100644 apps/webapp/app/routes/vercel.configure.tsx diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx index e42928cdd6..8758e181ff 100644 --- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx +++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx @@ -3,6 +3,7 @@ import { ChartBarIcon, Cog8ToothIcon, CreditCardIcon, + PuzzlePieceIcon, UserGroupIcon, } from "@heroicons/react/20/solid"; import { ArrowLeftIcon } from "@heroicons/react/24/solid"; @@ -12,6 +13,7 @@ import { cn } from "~/utils/cn"; import { organizationSettingsPath, organizationTeamPath, + organizationVercelIntegrationPath, rootPath, v3BillingAlertsPath, v3BillingPath, @@ -113,6 +115,13 @@ export function OrganizationSettingsSideMenu({ to={organizationSettingsPath(organization)} data-action="settings" /> +
diff --git a/apps/webapp/app/models/vercelIntegration.server.ts b/apps/webapp/app/models/vercelIntegration.server.ts index 248227d866..82e491517b 100644 --- a/apps/webapp/app/models/vercelIntegration.server.ts +++ b/apps/webapp/app/models/vercelIntegration.server.ts @@ -1494,5 +1494,35 @@ export class VercelIntegrationRepository { }); } } + + /** + * Uninstall a Vercel integration by removing the configuration + */ + static async uninstallVercelIntegration( + integration: OrganizationIntegration & { tokenReference: SecretReference } + ): Promise { + const client = await this.getVercelClient(integration); + + const secret = await getSecretStore(integration.tokenReference.provider).getSecret( + VercelSecretSchema, + integration.tokenReference.key + ); + + if (!secret?.installationId) { + throw new Error("Installation ID not found in Vercel integration"); + } + + try { + await client.integrations.deleteConfiguration({ + id: secret.installationId, + }); + } catch (error) { + logger.error("Failed to uninstall Vercel integration", { + installationId: secret.installationId, + error: error instanceof Error ? error.message : "Unknown error", + }); + throw error; + } + } } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx new file mode 100644 index 0000000000..68e2d87d4b --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx @@ -0,0 +1,378 @@ +import type { + ActionFunctionArgs, + LoaderFunctionArgs, +} from "@remix-run/node"; +import { json, redirect } from "@remix-run/node"; +import { Form, useActionData, useLoaderData, useNavigation } from "@remix-run/react"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; +import { DialogClose } from "@radix-ui/react-dialog"; +import { Button } from "~/components/primitives/Buttons"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "~/components/primitives/Dialog"; +import { FormButtons } from "~/components/primitives/FormButtons"; +import { Header1 } from "~/components/primitives/Headers"; +import { PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { Table, TableBlankRow, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow } from "~/components/primitives/Table"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { VercelIntegrationRepository } from "~/models/vercelIntegration.server"; +import { $transaction, prisma } from "~/db.server"; +import { requireUserId } from "~/services/session.server"; +import { OrganizationParamsSchema } from "~/utils/pathBuilder"; +import { logger } from "~/services/logger.server"; +import { TrashIcon } from "@heroicons/react/20/solid"; +import { vercelResourcePath } from "~/utils/pathBuilder"; +import { LinkButton } from "~/components/primitives/Buttons"; + +const SearchParamsSchema = z.object({ + configurationId: z.string().optional(), +}); + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const { organizationSlug } = OrganizationParamsSchema.parse(params); + + const url = new URL(request.url); + const { configurationId } = SearchParamsSchema.parse(Object.fromEntries(url.searchParams)); + + // Check user has access to organization + const organization = await prisma.organization.findFirst({ + where: { + slug: organizationSlug, + members: { some: { userId } }, + deletedAt: null, + }, + }); + + if (!organization) { + throw new Response("Not found", { status: 404 }); + } + + // Find Vercel integration for this organization + let vercelIntegration = await prisma.organizationIntegration.findFirst({ + where: { + organizationId: organization.id, + service: "VERCEL", + deletedAt: null, + // If configurationId is provided, filter by it in integrationData + ...(configurationId && { + integrationData: { + path: ["installationId"], + equals: configurationId, + }, + }), + }, + include: { + tokenReference: true, + }, + }); + + if (!vercelIntegration) { + return typedjson({ + organization, + vercelIntegration: null, + connectedProjects: [], + teamId: null, + installationId: null, + }); + } + + // Get team ID from integrationData + const integrationData = vercelIntegration.integrationData as any; + const teamId = integrationData?.teamId ?? null; + const installationId = integrationData?.installationId ?? null; + + // Get all connected projects for this integration + const connectedProjects = await prisma.organizationProjectIntegration.findMany({ + where: { + organizationIntegrationId: vercelIntegration.id, + deletedAt: null, + }, + include: { + project: { + select: { + id: true, + slug: true, + name: true, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + }); + + return typedjson({ + organization, + vercelIntegration, + connectedProjects, + teamId, + installationId, + }); +}; + +const ActionSchema = z.object({ + intent: z.literal("uninstall"), +}); + +export const action = async ({ request, params }: ActionFunctionArgs) => { + const userId = await requireUserId(request); + const { organizationSlug } = OrganizationParamsSchema.parse(params); + + const formData = await request.formData(); + const { intent } = ActionSchema.parse(Object.fromEntries(formData)); + + if (intent !== "uninstall") { + throw new Response("Invalid intent", { status: 400 }); + } + + // Check user has access to organization + const organization = await prisma.organization.findFirst({ + where: { + slug: organizationSlug, + members: { some: { userId } }, + deletedAt: null, + }, + }); + + if (!organization) { + throw new Response("Not found", { status: 404 }); + } + + // Find Vercel integration + const vercelIntegration = await prisma.organizationIntegration.findFirst({ + where: { + organizationId: organization.id, + service: "VERCEL", + deletedAt: null, + }, + include: { + tokenReference: true, + }, + }); + + if (!vercelIntegration) { + return json({ error: "Vercel integration not found" }, { status: 404 }); + } + + try { + // First, uninstall the integration from Vercel side + await VercelIntegrationRepository.uninstallVercelIntegration(vercelIntegration); + + // Then soft-delete the integration and all connected projects in a transaction + await $transaction(prisma, async (tx) => { + // Soft-delete all connected projects + await tx.organizationProjectIntegration.updateMany({ + where: { + organizationIntegrationId: vercelIntegration.id, + deletedAt: null, + }, + data: { deletedAt: new Date() }, + }); + + // Soft-delete the integration record + await tx.organizationIntegration.update({ + where: { id: vercelIntegration.id }, + data: { deletedAt: new Date() }, + }); + }); + + logger.info("Vercel integration uninstalled successfully", { + organizationId: organization.id, + organizationSlug, + userId, + integrationId: vercelIntegration.id, + }); + + // Redirect back to organization settings + return redirect(`/orgs/${organizationSlug}/settings`); + } catch (error) { + logger.error("Failed to uninstall Vercel integration", { + organizationId: organization.id, + organizationSlug, + userId, + integrationId: vercelIntegration.id, + error: error instanceof Error ? error.message : String(error), + }); + + return json( + { error: "Failed to uninstall Vercel integration. Please try again." }, + { status: 500 } + ); + } +}; + +export default function VercelIntegrationPage() { + const { organization, vercelIntegration, connectedProjects, teamId, installationId } = + useTypedLoaderData(); + const actionData = useActionData(); + const navigation = useNavigation(); + const isUninstalling = navigation.state === "submitting" && + navigation.formData?.get("intent") === "uninstall"; + + if (!vercelIntegration) { + return ( + + +
+ No Vercel Integration Found + + This organization doesn't have a Vercel integration configured. + +
+
+
+ ); + } + + return ( + + +
+ Vercel Integration + + Manage your organization's Vercel integration and connected projects. + +
+ + {/* Integration Info Section */} +
+
+
+

Integration Details

+
+ {teamId && ( +
+ Vercel Team ID: {teamId} +
+ )} + {installationId && ( +
+ Installation ID: {installationId} +
+ )} +
+ Installed:{" "} + {new Date(vercelIntegration.createdAt).toLocaleDateString()} +
+
+
+
+ + + + + + + Remove Vercel Integration + + This will permanently remove the Vercel integration and disconnect all projects. + This action cannot be undone. + + + + + + + + } + cancelButton={ + + + + } + /> + + + + {actionData?.error && ( + + {actionData.error} + + )} +
+
+
+ + {/* Connected Projects Section */} +
+

+ Connected Projects ({connectedProjects.length}) +

+ + {connectedProjects.length === 0 ? ( +
+ + No projects are currently connected to this Vercel integration. + +
+ ) : ( +
+ + + Project Name + Vercel Project ID + Connected + Actions + + + + {connectedProjects.map((projectIntegration) => ( + + {projectIntegration.project.name} + + {projectIntegration.externalEntityId} + + + {new Date(projectIntegration.createdAt).toLocaleDateString()} + + + + Configure + + + + ))} + {connectedProjects.length === 0 && ( + + No connected projects found. + + )} + +
+ )} + + + + ); +} \ No newline at end of file diff --git a/apps/webapp/app/routes/vercel.configure.tsx b/apps/webapp/app/routes/vercel.configure.tsx new file mode 100644 index 0000000000..8f5d6c3943 --- /dev/null +++ b/apps/webapp/app/routes/vercel.configure.tsx @@ -0,0 +1,47 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { redirect } from "@remix-run/node"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { organizationVercelIntegrationPath } from "~/utils/pathBuilder"; + +const SearchParamsSchema = z.object({ + configurationId: z.string(), +}); + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const url = new URL(request.url); + const searchParams = Object.fromEntries(url.searchParams); + + const { configurationId } = SearchParamsSchema.parse(searchParams); + + // Find the organization integration by configurationId (installationId in integrationData) + const integration = await prisma.organizationIntegration.findFirst({ + where: { + service: "VERCEL", + deletedAt: null, + integrationData: { + path: ["installationId"], + equals: configurationId, + }, + }, + include: { + organization: { + select: { + slug: true, + }, + }, + }, + }); + + if (!integration) { + throw new Response("Integration not found", { status: 404 }); + } + + // Redirect to the organization's Vercel integration page + return redirect(organizationVercelIntegrationPath(integration.organization)); +}; + +// This route doesn't render anything, it just redirects +export default function VercelConfigurePage() { + return null; +} \ No newline at end of file diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 86e7501536..52225d6721 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -121,6 +121,14 @@ export function organizationSettingsPath(organization: OrgForPath) { return `${organizationPath(organization)}/settings`; } +export function organizationIntegrationsPath(organization: OrgForPath) { + return `${organizationPath(organization)}/settings/integrations`; +} + +export function organizationVercelIntegrationPath(organization: OrgForPath) { + return `${organizationIntegrationsPath(organization)}/vercel`; +} + function organizationParam(organization: OrgForPath) { return organization.slug; } From d55c23c27fb75ba734c11a1f593aa96ba5ae6311 Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Fri, 9 Jan 2026 19:17:45 +0100 Subject: [PATCH 04/33] feat(telemetry include referralSource in identify and clear cookie for Vercel integration Add optional referralSource handling across auth flows and telemetry to record where new users originate. Extend telemetry.user.identify to accept referralSource and consolidate identify properties into a single object before sending to PostHog. Invoke this when users sign in via GitHub or magic link: read referralSource cookie, attach it for users created within the last 30 seconds (treating them as new users), and clear the cookie after use. This improves attribution for signups by capturing and sending referral information for new users while ensuring the referral cookie is removed after consumption. --- .../app/routes/auth.github.callback.tsx | 25 +++++++++++++ .../app/routes/auth.google.callback.tsx | 25 +++++++++++++ apps/webapp/app/routes/callback.vercel.ts | 25 ++++++++++--- apps/webapp/app/routes/login.mfa/route.tsx | 35 ++++++++++++++++--- apps/webapp/app/routes/magic.tsx | 25 +++++++++++++ apps/webapp/app/services/postAuth.server.ts | 5 ++- .../app/services/referralSource.server.ts | 31 ++++++++++++++++ apps/webapp/app/services/telemetry.server.ts | 32 ++++++++++++----- 8 files changed, 184 insertions(+), 19 deletions(-) create mode 100644 apps/webapp/app/services/referralSource.server.ts diff --git a/apps/webapp/app/routes/auth.github.callback.tsx b/apps/webapp/app/routes/auth.github.callback.tsx index 42473c64a4..418540f94e 100644 --- a/apps/webapp/app/routes/auth.github.callback.tsx +++ b/apps/webapp/app/routes/auth.github.callback.tsx @@ -5,6 +5,8 @@ import { getSession, redirectWithErrorMessage } from "~/models/message.server"; import { authenticator } from "~/services/auth.server"; import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server"; import { commitSession } from "~/services/sessionStorage.server"; +import { getReferralSource, clearReferralSourceCookie } from "~/services/referralSource.server"; +import { telemetry } from "~/services/telemetry.server"; import { redirectCookie } from "./auth.github"; import { sanitizeRedirectPath } from "~/utils"; @@ -56,5 +58,28 @@ export let loader: LoaderFunction = async ({ request }) => { headers.append("Set-Cookie", await commitSession(session)); headers.append("Set-Cookie", await setLastAuthMethodHeader("github")); + // Read referral source cookie and set in PostHog if present (only for new users), then clear it + const referralSource = await getReferralSource(request); + if (referralSource) { + const user = await prisma.user.findUnique({ + where: { id: auth.userId }, + }); + if (user) { + // Only set referralSource for new users (created within the last 30 seconds) + const userAge = Date.now() - user.createdAt.getTime(); + const isNewUser = userAge < 30 * 1000; // 30 seconds + + if (isNewUser) { + telemetry.user.identify({ + user, + isNewUser: true, + referralSource, + }); + } + } + // Clear the cookie after using it (regardless of whether we set it) + headers.append("Set-Cookie", await clearReferralSourceCookie()); + } + return redirect(redirectTo, { headers }); }; diff --git a/apps/webapp/app/routes/auth.google.callback.tsx b/apps/webapp/app/routes/auth.google.callback.tsx index 783ddce3a3..2a1010709c 100644 --- a/apps/webapp/app/routes/auth.google.callback.tsx +++ b/apps/webapp/app/routes/auth.google.callback.tsx @@ -5,6 +5,8 @@ import { getSession, redirectWithErrorMessage } from "~/models/message.server"; import { authenticator } from "~/services/auth.server"; import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server"; import { commitSession } from "~/services/sessionStorage.server"; +import { getReferralSource, clearReferralSourceCookie } from "~/services/referralSource.server"; +import { telemetry } from "~/services/telemetry.server"; import { redirectCookie } from "./auth.google"; import { sanitizeRedirectPath } from "~/utils"; @@ -56,6 +58,29 @@ export let loader: LoaderFunction = async ({ request }) => { headers.append("Set-Cookie", await commitSession(session)); headers.append("Set-Cookie", await setLastAuthMethodHeader("google")); + // Read referral source cookie and set in PostHog if present (only for new users), then clear it + const referralSource = await getReferralSource(request); + if (referralSource) { + const user = await prisma.user.findUnique({ + where: { id: auth.userId }, + }); + if (user) { + // Only set referralSource for new users (created within the last 30 seconds) + const userAge = Date.now() - user.createdAt.getTime(); + const isNewUser = userAge < 30 * 1000; // 30 seconds + + if (isNewUser) { + telemetry.user.identify({ + user, + isNewUser: true, + referralSource, + }); + } + } + // Clear the cookie after using it (regardless of whether we set it) + headers.append("Set-Cookie", await clearReferralSourceCookie()); + } + return redirect(redirectTo, { headers }); }; diff --git a/apps/webapp/app/routes/callback.vercel.ts b/apps/webapp/app/routes/callback.vercel.ts index a71c8710ca..9351e3e212 100644 --- a/apps/webapp/app/routes/callback.vercel.ts +++ b/apps/webapp/app/routes/callback.vercel.ts @@ -6,7 +6,8 @@ import { env } from "~/env.server"; import { redirectWithErrorMessage } from "~/models/message.server"; import { VercelIntegrationRepository } from "~/models/vercelIntegration.server"; import { logger } from "~/services/logger.server"; -import { requireUserId } from "~/services/session.server"; +import { getUserId, requireUserId } from "~/services/session.server"; +import { setReferralSourceCookie } from "~/services/referralSource.server"; import { requestUrl } from "~/utils/requestUrl.server"; import { v3ProjectSettingsPath } from "~/utils/pathBuilder"; import { validateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server"; @@ -56,7 +57,23 @@ export async function loader({ request }: LoaderFunctionArgs) { return new Response("Method Not Allowed", { status: 405 }); } - const userId = await requireUserId(request); + // Check if user is authenticated + const userId = await getUserId(request); + + // If not authenticated, set referral source cookie and redirect to login + if (!userId) { + const currentUrl = new URL(request.url); + const redirectTo = `${currentUrl.pathname}${currentUrl.search}`; + const referralCookie = await setReferralSourceCookie("vercel"); + + const headers = new Headers(); + headers.append("Set-Cookie", referralCookie); + + throw redirect(`/login?redirectTo=${encodeURIComponent(redirectTo)}`, { headers }); + } + + // User is authenticated, proceed with OAuth callback + const authenticatedUserId = await requireUserId(request); const url = requestUrl(request); const parsedParams = VercelCallbackSchema.safeParse(Object.fromEntries(url.searchParams)); @@ -107,7 +124,7 @@ export async function loader({ request }: LoaderFunctionArgs) { organization: { members: { some: { - userId, + userId: authenticatedUserId, }, }, }, @@ -120,7 +137,7 @@ export async function loader({ request }: LoaderFunctionArgs) { if (!project) { logger.error("Project not found or user does not have access", { projectId: stateData.projectId, - userId, + userId: authenticatedUserId, }); throw new Response("Project not found", { status: 404 }); } diff --git a/apps/webapp/app/routes/login.mfa/route.tsx b/apps/webapp/app/routes/login.mfa/route.tsx index 0d1a347d4b..925a4fcbc3 100644 --- a/apps/webapp/app/routes/login.mfa/route.tsx +++ b/apps/webapp/app/routes/login.mfa/route.tsx @@ -26,6 +26,9 @@ import { MultiFactorAuthenticationService } from "~/services/mfa/multiFactorAuth import { redirectWithErrorMessage, redirectBackWithErrorMessage } from "~/models/message.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { checkMfaRateLimit, MfaRateLimitError } from "~/services/mfa/mfaRateLimiter.server"; +import { getReferralSource, clearReferralSourceCookie } from "~/services/referralSource.server"; +import { telemetry } from "~/services/telemetry.server"; +import { prisma } from "~/db.server"; export const meta: MetaFunction = ({ matches }) => { const parentMeta = matches @@ -160,11 +163,33 @@ async function completeLogin(request: Request, session: Session, userId: string) session.unset("pending-mfa-user-id"); session.unset("pending-mfa-redirect-to"); - return redirect(redirectTo, { - headers: { - "Set-Cookie": await sessionStorage.commitSession(authSession), - }, - }); + const headers = new Headers(); + headers.append("Set-Cookie", await sessionStorage.commitSession(authSession)); + + // Read referral source cookie and set in PostHog if present (only for new users), then clear it + const referralSource = await getReferralSource(request); + if (referralSource) { + const user = await prisma.user.findUnique({ + where: { id: userId }, + }); + if (user) { + // Only set referralSource for new users (created within the last 30 seconds) + const userAge = Date.now() - user.createdAt.getTime(); + const isNewUser = userAge < 30 * 1000; // 30 seconds + + if (isNewUser) { + telemetry.user.identify({ + user, + isNewUser: true, + referralSource, + }); + } + } + // Clear the cookie after using it (regardless of whether we set it) + headers.append("Set-Cookie", await clearReferralSourceCookie()); + } + + return redirect(redirectTo, { headers }); } export default function LoginMfaPage() { diff --git a/apps/webapp/app/routes/magic.tsx b/apps/webapp/app/routes/magic.tsx index c45b6882ca..efbc2c6721 100644 --- a/apps/webapp/app/routes/magic.tsx +++ b/apps/webapp/app/routes/magic.tsx @@ -6,6 +6,8 @@ import { authenticator } from "~/services/auth.server"; import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server"; import { getRedirectTo } from "~/services/redirectTo.server"; import { commitSession, getSession } from "~/services/sessionStorage.server"; +import { getReferralSource, clearReferralSourceCookie } from "~/services/referralSource.server"; +import { telemetry } from "~/services/telemetry.server"; export async function loader({ request }: LoaderFunctionArgs) { const redirectTo = await getRedirectTo(request); @@ -53,5 +55,28 @@ export async function loader({ request }: LoaderFunctionArgs) { headers.append("Set-Cookie", await commitSession(session)); headers.append("Set-Cookie", await setLastAuthMethodHeader("email")); + // Read referral source cookie and set in PostHog if present (only for new users), then clear it + const referralSource = await getReferralSource(request); + if (referralSource) { + const user = await prisma.user.findUnique({ + where: { id: auth.userId }, + }); + if (user) { + // Only set referralSource for new users (created within the last 30 seconds) + const userAge = Date.now() - user.createdAt.getTime(); + const isNewUser = userAge < 30 * 1000; // 30 seconds + + if (isNewUser) { + telemetry.user.identify({ + user, + isNewUser: true, + referralSource, + }); + } + } + // Clear the cookie after using it (regardless of whether we set it) + headers.append("Set-Cookie", await clearReferralSourceCookie()); + } + return redirect(redirectTo ?? "/", { headers }); } diff --git a/apps/webapp/app/services/postAuth.server.ts b/apps/webapp/app/services/postAuth.server.ts index 39e914129a..feb42ccaef 100644 --- a/apps/webapp/app/services/postAuth.server.ts +++ b/apps/webapp/app/services/postAuth.server.ts @@ -10,5 +10,8 @@ export async function postAuthentication({ loginMethod: User["authenticationMethod"]; isNewUser: boolean; }) { - telemetry.user.identify({ user, isNewUser }); + telemetry.user.identify({ + user, + isNewUser, + }); } diff --git a/apps/webapp/app/services/referralSource.server.ts b/apps/webapp/app/services/referralSource.server.ts new file mode 100644 index 0000000000..00a5aae266 --- /dev/null +++ b/apps/webapp/app/services/referralSource.server.ts @@ -0,0 +1,31 @@ +import { createCookie } from "@remix-run/node"; +import { env } from "~/env.server"; + +export type ReferralSource = "vercel"; + +// Cookie that persists for 1 hour to track referral source during login flow +export const referralSourceCookie = createCookie("referral-source", { + maxAge: 60 * 60, // 1 hour + httpOnly: true, + sameSite: "lax", + secure: env.NODE_ENV === "production", +}); + +export async function getReferralSource(request: Request): Promise { + const cookie = request.headers.get("Cookie"); + const value = await referralSourceCookie.parse(cookie); + if (value === "vercel") { + return value; + } + return null; +} + +export async function setReferralSourceCookie(source: ReferralSource): Promise { + return referralSourceCookie.serialize(source); +} + +export async function clearReferralSourceCookie(): Promise { + return referralSourceCookie.serialize("", { + maxAge: 0, + }); +} diff --git a/apps/webapp/app/services/telemetry.server.ts b/apps/webapp/app/services/telemetry.server.ts index 98ca11ed90..f8bd3d3d99 100644 --- a/apps/webapp/app/services/telemetry.server.ts +++ b/apps/webapp/app/services/telemetry.server.ts @@ -28,18 +28,32 @@ class Telemetry { } user = { - identify: ({ user, isNewUser }: { user: User; isNewUser: boolean }) => { + identify: ({ + user, + isNewUser, + referralSource, + }: { + user: User; + isNewUser: boolean; + referralSource?: string; + }) => { if (this.#posthogClient) { + const properties: Record = { + email: user.email, + name: user.name, + authenticationMethod: user.authenticationMethod, + admin: user.admin, + createdAt: user.createdAt, + isNewUser, + }; + + if (referralSource) { + properties.referralSource = referralSource; + } + this.#posthogClient.identify({ distinctId: user.id, - properties: { - email: user.email, - name: user.name, - authenticationMethod: user.authenticationMethod, - admin: user.admin, - createdAt: user.createdAt, - isNewUser, - }, + properties, }); } if (isNewUser) { From 12cbac8a79a55eb0660af5e4c4f0a02e276b3777 Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Fri, 9 Jan 2026 21:05:12 +0100 Subject: [PATCH 05/33] feat(vercel): Vercel side initialized installation Preserve full search params when redirecting unauthenticated users to login so Vercel callback params (code, configurationId, etc.) are not lost. Add handling for Vercel callbacks that arrive without a state parameter (common for Vercel-side installations). When state is absent but configurationId is present: - Query the user's organizations and projects. - If the user has no organizations, redirect to basic details onboarding with the Vercel params preserved. - If the user has organizations but no projects, redirect to the new project creation page for the first org with the Vercel params. - If the user has orgs and projects, exchange the code for a token, fetch the Vercel integration configuration, find the default project and environment, and continue the installation flow (including generating a state JWT). Add imports for new path helpers and OAuth state generator, and wire in logic to build redirect URLs with next param when present. Improve error handling and user-facing error redirects for token and configuration fetch failures. --- .../app/models/vercelIntegration.server.ts | 64 +++++ .../presenters/v3/BranchesPresenter.server.ts | 2 +- .../route.tsx | 2 + ...ationSlug.settings.integrations.vercel.tsx | 16 +- .../route.tsx | 19 ++ .../webapp/app/routes/_app.orgs.new/route.tsx | 21 ++ apps/webapp/app/routes/callback.vercel.ts | 241 +++++++++++++++++- .../app/routes/confirm-basic-details.tsx | 23 +- apps/webapp/app/routes/login._index/route.tsx | 2 +- apps/webapp/app/routes/login.magic/route.tsx | 15 +- ...cts.$projectParam.env.$envParam.vercel.tsx | 39 ++- 11 files changed, 423 insertions(+), 21 deletions(-) diff --git a/apps/webapp/app/models/vercelIntegration.server.ts b/apps/webapp/app/models/vercelIntegration.server.ts index 82e491517b..2cfdc95356 100644 --- a/apps/webapp/app/models/vercelIntegration.server.ts +++ b/apps/webapp/app/models/vercelIntegration.server.ts @@ -116,6 +116,70 @@ export class VercelIntegrationRepository { return secret.teamId ?? null; } + /** + * Get Vercel integration configuration by configurationId + * This is used when Vercel redirects to our callback without a state parameter + */ + static async getVercelIntegrationConfiguration( + accessToken: string, + configurationId: string, + teamId?: string | null + ): Promise<{ + id: string; + teamId: string | null; + projects: string[]; + } | null> { + try { + const client = new Vercel({ + bearerToken: accessToken, + }); + + // Use the Vercel SDK to get the integration configuration + // The SDK might have a method for this, or we need to make a direct API call + const response = await fetch( + `https://api.vercel.com/v1/integrations/configuration/${configurationId}${teamId ? `?teamId=${teamId}` : ""}`, + { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + } + ); + + if (!response.ok) { + const errorText = await response.text(); + logger.error("Failed to fetch Vercel integration configuration", { + status: response.status, + error: errorText, + configurationId, + teamId, + }); + return null; + } + + const data = (await response.json()) as { + id: string; + teamId?: string | null; + projects?: string[]; + [key: string]: any; + }; + + return { + id: data.id, + teamId: data.teamId ?? null, + projects: data.projects || [], + }; + } catch (error) { + logger.error("Error fetching Vercel integration configuration", { + configurationId, + teamId, + error, + }); + return null; + } + } + /** * Fetch custom environments for a Vercel project. * Excludes standard environments (production, preview, development). diff --git a/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts b/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts index 4aafc1844b..db2cd012c4 100644 --- a/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts @@ -42,7 +42,7 @@ export type GitMetaLinks = { /** The git provider, e.g., `github` */ provider?: string; - source?: "trigger_github_app" | "github_actions" | "local"; + source?: "trigger_github_app" | "github_actions" | "local" | "trigger_vercel_app"; ghUsername?: string; ghUserAvatarUrl?: string; }; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx index 4b44879df1..94ca0d150a 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx @@ -310,6 +310,7 @@ export default function Page() { // Vercel onboarding modal state const hasQueryParam = searchParams.get("vercelOnboarding") === "true"; + const nextUrl = searchParams.get("next"); const [isModalOpen, setIsModalOpen] = useState(false); const vercelFetcher = useTypedFetcher(); @@ -621,6 +622,7 @@ export default function Page() { environmentSlug={environment.slug} hasStagingEnvironment={vercelFetcher.data?.hasStagingEnvironment ?? false} hasOrgIntegration={vercelFetcher.data?.hasOrgIntegration ?? false} + nextUrl={nextUrl ?? undefined} onDataReload={() => { vercelFetcher.load( `${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true` diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx index 68e2d87d4b..a34a0c9f28 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx @@ -32,6 +32,18 @@ import { TrashIcon } from "@heroicons/react/20/solid"; import { vercelResourcePath } from "~/utils/pathBuilder"; import { LinkButton } from "~/components/primitives/Buttons"; +function formatDate(date: Date): string { + return new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + second: "2-digit", + hour12: true, + }).format(date); +} + const SearchParamsSchema = z.object({ configurationId: z.string().optional(), }); @@ -261,7 +273,7 @@ export default function VercelIntegrationPage() { )}
Installed:{" "} - {new Date(vercelIntegration.createdAt).toLocaleDateString()} + {formatDate(new Date(vercelIntegration.createdAt))}
@@ -347,7 +359,7 @@ export default function VercelIntegrationPage() { {projectIntegration.externalEntityId} - {new Date(projectIntegration.createdAt).toLocaleDateString()} + {formatDate(new Date(projectIntegration.createdAt))} { return json(submission); } + // Check for Vercel integration params in URL + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const configurationId = url.searchParams.get("configurationId"); + const next = url.searchParams.get("next"); + try { const project = await createProject({ organizationSlug: organizationSlug, @@ -111,6 +117,19 @@ export const action: ActionFunction = async ({ request, params }) => { version: submission.value.projectVersion, }); + // If this is a Vercel integration flow, redirect back to callback + if (code && configurationId) { + const params = new URLSearchParams({ + code, + configurationId, + }); + if (next) { + params.set("next", next); + } + const callbackUrl = `/callback/vercel?${params.toString()}`; + return redirect(callbackUrl); + } + return redirectWithSuccessMessage( v3ProjectPath(project.organization, project), request, diff --git a/apps/webapp/app/routes/_app.orgs.new/route.tsx b/apps/webapp/app/routes/_app.orgs.new/route.tsx index a677782eae..0a5c7fdd6a 100644 --- a/apps/webapp/app/routes/_app.orgs.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.new/route.tsx @@ -69,6 +69,27 @@ export const action: ActionFunction = async ({ request }) => { }); } + // Preserve Vercel integration params if present + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const configurationId = url.searchParams.get("configurationId"); + const integration = url.searchParams.get("integration"); + const next = url.searchParams.get("next"); + + if (code && configurationId && integration === "vercel") { + // Redirect to projects/new with params preserved + const params = new URLSearchParams({ + code, + configurationId, + integration, + }); + if (next) { + params.set("next", next); + } + const redirectUrl = `${organizationPath(organization)}/projects/new?${params.toString()}`; + return redirect(redirectUrl); + } + return redirect(organizationPath(organization)); } catch (error: any) { return json({ errors: { body: error.message } }, { status: 400 }); diff --git a/apps/webapp/app/routes/callback.vercel.ts b/apps/webapp/app/routes/callback.vercel.ts index 9351e3e212..055b8c44b7 100644 --- a/apps/webapp/app/routes/callback.vercel.ts +++ b/apps/webapp/app/routes/callback.vercel.ts @@ -9,8 +9,8 @@ import { logger } from "~/services/logger.server"; import { getUserId, requireUserId } from "~/services/session.server"; import { setReferralSourceCookie } from "~/services/referralSource.server"; import { requestUrl } from "~/utils/requestUrl.server"; -import { v3ProjectSettingsPath } from "~/utils/pathBuilder"; -import { validateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server"; +import { v3ProjectSettingsPath, confirmBasicDetailsPath, newOrganizationPath, newProjectPath } from "~/utils/pathBuilder"; +import { validateVercelOAuthState, generateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server"; /** * Schema for Vercel OAuth callback query parameters. @@ -61,8 +61,10 @@ export async function loader({ request }: LoaderFunctionArgs) { const userId = await getUserId(request); // If not authenticated, set referral source cookie and redirect to login + // Preserve all search params (code, configurationId, etc.) in the redirectTo if (!userId) { const currentUrl = new URL(request.url); + // Preserve the full URL including all search params const redirectTo = `${currentUrl.pathname}${currentUrl.search}`; const referralCookie = await setReferralSourceCookie("vercel"); @@ -98,13 +100,222 @@ export async function loader({ request }: LoaderFunctionArgs) { throw new Response("Missing authorization code", { status: 400 }); } + // Handle case when state is missing (Vercel-side installation) if (!state) { - logger.error("Missing state parameter from Vercel callback"); - throw new Response("Missing state parameter", { status: 400 }); + if (!configurationId) { + logger.error("Missing both state and configurationId from Vercel callback"); + throw new Response("Missing state or configurationId parameter", { status: 400 }); + } + + // Check if user has organizations + const userOrganizations = await prisma.organization.findMany({ + where: { + members: { + some: { + userId: authenticatedUserId, + }, + }, + deletedAt: null, + }, + include: { + projects: { + where: { + deletedAt: null, + }, + }, + }, + }); + + // If user has no organizations, redirect to onboarding + if (userOrganizations.length === 0) { + const params = new URLSearchParams({ + code, + configurationId, + integration: "vercel", + }); + if (parsedParams.data.next) { + params.set("next", parsedParams.data.next); + } + const onboardingUrl = `${confirmBasicDetailsPath()}?${params.toString()}`; + return redirect(onboardingUrl); + } + + // If user has organizations but no projects, redirect to project creation + const hasProjects = userOrganizations.some((org) => org.projects.length > 0); + if (!hasProjects) { + // Redirect to the first organization's project creation page + const firstOrg = userOrganizations[0]; + const params = new URLSearchParams({ + code, + configurationId, + integration: "vercel", + }); + if (parsedParams.data.next) { + params.set("next", parsedParams.data.next); + } + const projectUrl = `${newProjectPath({ slug: firstOrg.slug })}?${params.toString()}`; + return redirect(projectUrl); + } + + // User has orgs and projects - handle the installation after onboarding + // Exchange code for access token first + const tokenResponse = await exchangeCodeForToken(code); + if (!tokenResponse) { + return redirectWithErrorMessage( + "/", + request, + "Failed to connect to Vercel. Please try again." + ); + } + + // Fetch configuration from Vercel + const config = await VercelIntegrationRepository.getVercelIntegrationConfiguration( + tokenResponse.accessToken, + configurationId, + tokenResponse.teamId ?? null + ); + + if (!config) { + return redirectWithErrorMessage( + "/", + request, + "Failed to fetch Vercel integration configuration. Please try again." + ); + } + + // Get user's first organization and project + const userOrg = userOrganizations[0]; + const userProject = userOrg.projects[0]; + + if (!userProject) { + // This shouldn't happen since we checked above, but handle it anyway + const projectUrl = `${newProjectPath({ slug: userOrg.slug })}?code=${encodeURIComponent(code)}&configurationId=${encodeURIComponent(configurationId)}&integration=vercel`; + return redirect(projectUrl); + } + + // Get the default environment (prod) + const environment = await prisma.runtimeEnvironment.findFirst({ + where: { + projectId: userProject.id, + slug: "prod", + archivedAt: null, + }, + }); + + if (!environment) { + return redirectWithErrorMessage( + "/", + request, + "Failed to find project environment. Please try again." + ); + } + + // Generate state JWT for the integration + const stateJWT = await generateVercelOAuthState({ + organizationId: userOrg.id, + projectId: userProject.id, + environmentSlug: environment.slug, + organizationSlug: userOrg.slug, + projectSlug: userProject.slug, + }); + + // Now proceed with the normal flow using the generated state + // We'll use the stateData from the generated state + const stateData = { + organizationId: userOrg.id, + projectId: userProject.id, + environmentSlug: environment.slug, + organizationSlug: userOrg.slug, + projectSlug: userProject.slug, + }; + + const project = await prisma.project.findFirst({ + where: { + id: stateData.projectId, + organizationId: stateData.organizationId, + deletedAt: null, + organization: { + members: { + some: { + userId: authenticatedUserId, + }, + }, + }, + }, + include: { + organization: true, + }, + }); + + if (!project) { + logger.error("Project not found or user does not have access", { + projectId: stateData.projectId, + userId: authenticatedUserId, + }); + throw new Response("Project not found", { status: 404 }); + } + + // Create the integration + try { + // Check if we already have a Vercel org integration for this team + let orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationByTeamId( + project.organizationId, + tokenResponse.teamId ?? null + ); + + // Create org integration if it doesn't exist + if (!orgIntegration) { + const newIntegration = await VercelIntegrationRepository.createVercelOrgIntegration({ + accessToken: tokenResponse.accessToken, + tokenType: tokenResponse.tokenType, + teamId: tokenResponse.teamId ?? null, + userId: tokenResponse.userId, + installationId: configurationId, + organization: project.organization, + raw: tokenResponse.raw, + }); + + // Re-fetch to get the full integration with tokenReference + orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationByTeamId( + project.organizationId, + tokenResponse.teamId ?? null + ); + } + + if (!orgIntegration) { + throw new Error("Failed to create or find Vercel organization integration"); + } + + logger.info("Vercel organization integration created successfully after onboarding", { + organizationId: project.organizationId, + projectId: project.id, + teamId: tokenResponse.teamId, + }); + + // Redirect to settings page with onboarding query parameter + const settingsPath = v3ProjectSettingsPath( + { slug: stateData.organizationSlug }, + { slug: stateData.projectSlug }, + { slug: stateData.environmentSlug } + ); + + const params = new URLSearchParams({ vercelOnboarding: "true" }); + if (parsedParams.data.next) { + params.set("next", parsedParams.data.next); + } + return redirect(`${settingsPath}?${params.toString()}`); + } catch (error) { + logger.error("Failed to create Vercel integration after onboarding", { error }); + return redirectWithErrorMessage( + "/", + request, + "Failed to create Vercel integration. Please try again." + ); + } } - // Validate and decode JWT state - const validationResult = await validateVercelOAuthState(state); + // Validate and decode JWT state (existing flow) + const validationResult = await validateVercelOAuthState(state!); if (!validationResult.ok) { logger.error("Invalid Vercel OAuth state JWT", { @@ -199,14 +410,18 @@ export async function loader({ request }: LoaderFunctionArgs) { teamId: tokenResponse.teamId, }); - // Redirect to settings page with onboarding query parameter - const settingsPath = v3ProjectSettingsPath( - { slug: stateData.organizationSlug }, - { slug: stateData.projectSlug }, - { slug: stateData.environmentSlug } - ); + // Redirect to settings page with onboarding query parameter + const settingsPath = v3ProjectSettingsPath( + { slug: stateData.organizationSlug }, + { slug: stateData.projectSlug }, + { slug: stateData.environmentSlug } + ); - return redirect(`${settingsPath}?vercelOnboarding=true`); + const params = new URLSearchParams({ vercelOnboarding: "true" }); + if (parsedParams.data.next) { + params.set("next", parsedParams.data.next); + } + return redirect(`${settingsPath}?${params.toString()}`); } catch (error) { logger.error("Failed to create Vercel integration", { error }); return redirectWithErrorMessage( diff --git a/apps/webapp/app/routes/confirm-basic-details.tsx b/apps/webapp/app/routes/confirm-basic-details.tsx index 4187a2e9d0..269c2d79e8 100644 --- a/apps/webapp/app/routes/confirm-basic-details.tsx +++ b/apps/webapp/app/routes/confirm-basic-details.tsx @@ -105,7 +105,28 @@ export const action: ActionFunction = async ({ request }) => { referralSource: submission.value.referralSource, }); - return redirectWithSuccessMessage(rootPath(), request, "Your details have been updated."); + // Preserve Vercel integration params if present + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const configurationId = url.searchParams.get("configurationId"); + const integration = url.searchParams.get("integration"); + const next = url.searchParams.get("next"); + let redirectUrl = rootPath(); + + if (code && configurationId && integration === "vercel") { + // Redirect to orgs/new with params preserved + const params = new URLSearchParams({ + code, + configurationId, + integration, + }); + if (next) { + params.set("next", next); + } + redirectUrl = `/orgs/new?${params.toString()}`; + } + + return redirectWithSuccessMessage(redirectUrl, request, "Your details have been updated."); } catch (error: any) { return json({ errors: { body: error.message } }, { status: 400 }); } diff --git a/apps/webapp/app/routes/login._index/route.tsx b/apps/webapp/app/routes/login._index/route.tsx index 40cea7905c..8878ffc888 100644 --- a/apps/webapp/app/routes/login._index/route.tsx +++ b/apps/webapp/app/routes/login._index/route.tsx @@ -167,7 +167,7 @@ export default function LoginPage() {
{data.lastAuthMethod === "email" && } val === "on"), syncEnvVarsMapping: z.string().optional(), // JSON-encoded mapping + next: z.string().optional(), }); const SkipOnboardingFormSchema = z.object({ @@ -348,6 +349,7 @@ export async function action({ request, params }: ActionFunctionArgs) { vercelStagingName, pullEnvVarsFromVercel, syncEnvVarsMapping, + next, } = submission.value; let parsedMapping: SyncEnvVarsMapping = {}; @@ -373,13 +375,30 @@ export async function action({ request, params }: ActionFunctionArgs) { }); if (result) { - // Redirect to settings page without the vercelOnboarding param to close the modal + // Check if we should redirect to the 'next' URL + if (next) { + try { + // Validate that next is a valid URL + const nextUrl = new URL(next); + // Only allow https URLs for security + if (nextUrl.protocol === "https:") { + // Return JSON with redirect URL for fetcher to handle + return json({ success: true, redirectTo: next }); + } + } catch (e) { + // Invalid URL, fall through to default redirect + logger.warn("Invalid next URL provided", { next, error: e }); + } + } + + // Default redirect to settings page without the vercelOnboarding param to close the modal const settingsPath = v3ProjectSettingsPath( { slug: organizationSlug }, { slug: projectParam }, { slug: envParam } ); - return redirectWithSuccessMessage(settingsPath, request, "Vercel integration setup complete"); + // Return JSON with redirect URL for fetcher to handle + return json({ success: true, redirectTo: settingsPath }); } const settingsPath = v3ProjectSettingsPath( @@ -983,6 +1002,7 @@ function VercelOnboardingModal({ environmentSlug, hasStagingEnvironment, hasOrgIntegration, + nextUrl, onDataReload, }: { isOpen: boolean; @@ -993,6 +1013,7 @@ function VercelOnboardingModal({ environmentSlug: string; hasStagingEnvironment: boolean; hasOrgIntegration: boolean; + nextUrl?: string; onDataReload?: () => void; }) { const navigation = useNavigation(); @@ -1260,6 +1281,13 @@ function VercelOnboardingModal({ // Handle successful onboarding completion useEffect(() => { if (completeOnboardingFetcher.data && typeof completeOnboardingFetcher.data === "object" && "success" in completeOnboardingFetcher.data && completeOnboardingFetcher.data.success && completeOnboardingFetcher.state === "idle") { + // Check if we need to redirect to a specific URL + if ("redirectTo" in completeOnboardingFetcher.data && typeof completeOnboardingFetcher.data.redirectTo === "string") { + // Navigate to the redirect URL (handles both internal and external URLs) + window.location.href = completeOnboardingFetcher.data.redirectTo; + return; + } + // No redirect, just close the modal setState("completed"); } }, [completeOnboardingFetcher.data, completeOnboardingFetcher.state]); @@ -1488,6 +1516,13 @@ function VercelOnboardingModal({ name="syncEnvVarsMapping" value={JSON.stringify(syncEnvVarsMapping)} /> + {nextUrl && ( + + )}
Sync Environment Variables From 6a09e0a75073c374a9eedb83e3087f35d26b179b Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Mon, 12 Jan 2026 07:30:47 +0100 Subject: [PATCH 06/33] refactor(vercel): tidy imports, adjust dialog structure, and unify types - replace vercelResourcePath with v3ProjectSettingsPath for linking to project settings pages so routing uses the v3 path helper. - move DialogDescription out of DialogHeader and into DialogContent to correct markup/structure and restore the form buttons layout inside the dialog content. - remove unused import (confirmBasicDetailsPath) and unused OAuth state generator import to keep callback route imports minimal. - change secret type from "sensitive"/"plain" to "encrypted" when syncing Vercel trigger keys to standardize storage semantics. - fix a stray/partial line in the Vercel callback file (cleanup after parameter parsing). These changes improve routing consistency, correct dialog markup for accessibility and layout, standardize secret typing, and clean up unused imports and stray code. --- .../app/models/vercelIntegration.server.ts | 4 +- ...ationSlug.settings.integrations.vercel.tsx | 54 +++++++++---------- apps/webapp/app/routes/callback.vercel.ts | 34 ++++-------- 3 files changed, 39 insertions(+), 53 deletions(-) diff --git a/apps/webapp/app/models/vercelIntegration.server.ts b/apps/webapp/app/models/vercelIntegration.server.ts index 2cfdc95356..986e83d4b1 100644 --- a/apps/webapp/app/models/vercelIntegration.server.ts +++ b/apps/webapp/app/models/vercelIntegration.server.ts @@ -871,7 +871,7 @@ export class VercelIntegrationRepository { key: "TRIGGER_SECRET_KEY", value: env.apiKey, target: vercelTarget, - type: "sensitive", + type: "encrypted", environmentType: env.type, }); } @@ -998,7 +998,7 @@ export class VercelIntegrationRepository { key: "TRIGGER_SECRET_KEY", value: params.apiKey, target: vercelTarget, - type: "plain", + type: "encrypted", }); logger.info("Synced regenerated API key to Vercel", { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx index a34a0c9f28..bf5ef53256 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx @@ -29,7 +29,7 @@ import { requireUserId } from "~/services/session.server"; import { OrganizationParamsSchema } from "~/utils/pathBuilder"; import { logger } from "~/services/logger.server"; import { TrashIcon } from "@heroicons/react/20/solid"; -import { vercelResourcePath } from "~/utils/pathBuilder"; +import { v3ProjectSettingsPath } from "~/utils/pathBuilder"; import { LinkButton } from "~/components/primitives/Buttons"; function formatDate(date: Date): string { @@ -291,33 +291,31 @@ export default function VercelIntegrationPage() { Remove Vercel Integration - - This will permanently remove the Vercel integration and disconnect all projects. - This action cannot be undone. - - - - - - - } - cancelButton={ - - - - } - /> - + + This will permanently remove the Vercel integration and disconnect all projects. + This action cannot be undone. + + + + + + } + cancelButton={ + + + + } + /> {actionData?.error && ( @@ -364,7 +362,7 @@ export default function VercelIntegrationPage() { Date: Mon, 12 Jan 2026 08:07:58 +0100 Subject: [PATCH 07/33] refactor(vercel): normalize env responses and helpers Introduce utilities to robustly handle Vercel API response shapes: - add normalizeTarget to coerce target values into string arrays - add extractEnvs to safely pull envs arrays from union responses Replace ad-hoc existence checks and inline normalization with these helpers in getVercelEnvironmentVariables and getVercelEnvironmentVariableValues to avoid runtime errors when API returns varying shapes (string vs array vs missing). This simplifies mapping logic and centralizes normalization. Remove several outdated JSDoc comments and compress inline documentation to keep the implementation focused and more readable. --- .../app/models/vercelIntegration.server.ts | 333 ++---------------- apps/webapp/app/routes/callback.vercel.ts | 140 ++++---- 2 files changed, 98 insertions(+), 375 deletions(-) diff --git a/apps/webapp/app/models/vercelIntegration.server.ts b/apps/webapp/app/models/vercelIntegration.server.ts index 986e83d4b1..34de34eb28 100644 --- a/apps/webapp/app/models/vercelIntegration.server.ts +++ b/apps/webapp/app/models/vercelIntegration.server.ts @@ -18,9 +18,22 @@ import { } from "~/v3/vercel/vercelProjectIntegrationSchema"; import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; -/** - * Schema for the Vercel OAuth token stored in SecretReference - */ +// Utility to normalize target arrays from Vercel API responses +function normalizeTarget(target: unknown): string[] { + if (Array.isArray(target)) return target.filter(Boolean) as string[]; + if (typeof target === 'string') return [target]; + return []; +} + +// Utility to safely extract envs array from Vercel API response +function extractEnvs(response: unknown): unknown[] { + if (response && typeof response === 'object' && 'envs' in response) { + const envs = (response as { envs: unknown }).envs; + return Array.isArray(envs) ? envs : []; + } + return []; +} + export const VercelSecretSchema = z.object({ accessToken: z.string(), tokenType: z.string().optional(), @@ -32,34 +45,15 @@ export const VercelSecretSchema = z.object({ export type VercelSecret = z.infer; -/** - * Represents a Vercel environment variable with metadata - */ export type VercelEnvironmentVariable = { id: string; key: string; - /** - * Type of the environment variable. - * "secret" or "sensitive" types cannot have their values retrieved. - */ type: "system" | "encrypted" | "plain" | "sensitive" | "secret"; - /** - * Whether this env var is a secret (value cannot be synced) - */ isSecret: boolean; - /** - * Target environments for this variable - */ target: string[]; - /** - * Whether this is a shared (team-level) environment variable - */ isShared?: boolean; }; -/** - * Represents a custom Vercel environment - */ export type VercelCustomEnvironment = { id: string; slug: string; @@ -70,13 +64,7 @@ export type VercelCustomEnvironment = { }; }; -/** - * Repository for interacting with Vercel API using @vercel/sdk - */ export class VercelIntegrationRepository { - /** - * Get an authenticated Vercel SDK client for an integration - */ static async getVercelClient( integration: OrganizationIntegration & { tokenReference: SecretReference } ): Promise { @@ -96,9 +84,6 @@ export class VercelIntegrationRepository { }); } - /** - * Get the team ID from an integration's stored secret - */ static async getTeamIdFromIntegration( integration: OrganizationIntegration & { tokenReference: SecretReference } ): Promise { @@ -116,10 +101,7 @@ export class VercelIntegrationRepository { return secret.teamId ?? null; } - /** - * Get Vercel integration configuration by configurationId - * This is used when Vercel redirects to our callback without a state parameter - */ + // Used when Vercel redirects to our callback without a state parameter static async getVercelIntegrationConfiguration( accessToken: string, configurationId: string, @@ -180,10 +162,7 @@ export class VercelIntegrationRepository { } } - /** - * Fetch custom environments for a Vercel project. - * Excludes standard environments (production, preview, development). - */ + // Excludes standard environments (production, preview, development) static async getVercelCustomEnvironments( client: Vercel, projectId: string, @@ -214,10 +193,7 @@ export class VercelIntegrationRepository { } } - /** - * Fetch all environment variables for a Vercel project. - * Returns metadata about each variable including whether it's a secret. - */ + // Returns metadata about each variable including whether it's a secret static async getVercelEnvironmentVariables( client: Vercel, projectId: string, @@ -230,7 +206,7 @@ export class VercelIntegrationRepository { }); // The response is a union type - check if it has envs array - const envs = "envs" in response && Array.isArray(response.envs) ? response.envs : []; + const envs = extractEnvs(response); return envs.map((env: any) => { const type = env.type as VercelEnvironmentVariable["type"]; @@ -242,7 +218,7 @@ export class VercelIntegrationRepository { key: env.key, type, isSecret, - target: Array.isArray(env.target) ? env.target : [env.target].filter(Boolean), + target: normalizeTarget(env.target), }; }); } catch (error) { @@ -255,9 +231,6 @@ export class VercelIntegrationRepository { } } - /** - * Represents an environment variable with its decrypted value - */ static async getVercelEnvironmentVariableValues( client: Vercel, projectId: string, @@ -280,8 +253,7 @@ export class VercelIntegrationRepository { }); // The response is a union type - check if it has envs array - const envs = - "envs" in response && Array.isArray(response.envs) ? response.envs : []; + const envs = extractEnvs(response); // Filter and map env vars const result = envs @@ -292,9 +264,7 @@ export class VercelIntegrationRepository { } // Filter by target if provided if (target) { - const envTargets = Array.isArray(env.target) - ? env.target - : [env.target].filter(Boolean); + const envTargets = normalizeTarget(env.target); return envTargets.includes(target); } return true; @@ -306,9 +276,7 @@ export class VercelIntegrationRepository { return { key: env.key as string, value: env.value as string, - target: Array.isArray(env.target) - ? env.target - : [env.target].filter(Boolean), + target: normalizeTarget(env.target), type, isSecret, }; @@ -326,11 +294,7 @@ export class VercelIntegrationRepository { } } - /** - * Fetch shared environment variables metadata from Vercel team. - * Returns metadata about each variable (not values). - * Shared env vars are team-level variables that can be linked to multiple projects. - */ + // Team-level variables that can be linked to multiple projects static async getVercelSharedEnvironmentVariables( client: Vercel, teamId: string, @@ -376,11 +340,6 @@ export class VercelIntegrationRepository { } } - /** - * Fetch shared environment variables from Vercel team with their values. - * Returns decrypted values where available. - * Shared env vars are team-level variables that can be linked to multiple projects. - */ static async getVercelSharedEnvironmentVariableValues( client: Vercel, teamId: string, @@ -397,11 +356,6 @@ export class VercelIntegrationRepository { > { try { // First, get the list of shared env vars - logger.debug("Fetching shared env vars list from Vercel", { - teamId, - projectId, - }); - const listResponse = await client.environment.listSharedEnvVariable({ teamId, ...(projectId && { projectId }), @@ -409,19 +363,6 @@ export class VercelIntegrationRepository { const envVars = listResponse.data || []; - logger.info("Listed shared env vars from Vercel", { - teamId, - projectId, - count: envVars.length, - envVarsInfo: envVars.map((e) => ({ - key: e.key, - type: e.type, - hasValueInList: !!e.value, - decrypted: (e as any).decrypted, - target: e.target, - })), - }); - if (envVars.length === 0) { return []; } @@ -438,11 +379,6 @@ export class VercelIntegrationRepository { // Skip sensitive types early - they won't have values if (isSecret) { - logger.debug("Skipping sensitive shared env var", { - teamId, - envKey: env.key, - type, - }); return null; } @@ -451,13 +387,6 @@ export class VercelIntegrationRepository { const applyToAllCustomEnvs = (env as any).applyToAllCustomEnvironments as boolean | undefined; if (listValue) { - logger.debug("Using value from list response for shared env var", { - teamId, - envKey: env.key, - type, - valueLength: listValue.length, - applyToAllCustomEnvironments: applyToAllCustomEnvs, - }); return { key: env.key as string, @@ -499,33 +428,18 @@ export class VercelIntegrationRepository { // Skip if no value if (!getResponse.value) { - logger.debug("Skipping shared env var - no value returned from getSharedEnvVar", { - teamId, - envId: env.id, - envKey: env.key, - type, - }); return null; } const result = { key: env.key as string, value: getResponse.value as string, - target: Array.isArray(env.target) - ? (env.target as string[]) - : [env.target].filter(Boolean) as string[], + target: normalizeTarget(env.target), type, isSecret, applyToAllCustomEnvironments: (env as any).applyToAllCustomEnvironments as boolean | undefined, }; - logger.debug("Successfully fetched shared env var value from getSharedEnvVar", { - teamId, - envKey: result.key, - target: result.target, - valueLength: result.value.length, - }); - return result; } catch (error) { // Try to extract value from error.rawValue if it's a ResponseValidationError @@ -581,15 +495,6 @@ export class VercelIntegrationRepository { // Filter out null results (failed fetches or sensitive types) const validResults = results.filter((r): r is NonNullable => r !== null); - logger.info("Completed fetching shared env var values", { - teamId, - projectId, - totalListed: envVars.length, - successfullyFetched: validResults.length, - failedOrSkipped: envVars.length - validResults.length, - fetchedKeys: validResults.map((r) => r.key), - }); - return validResults; } catch (error) { logger.error("Failed to fetch Vercel shared environment variable values", { @@ -602,9 +507,6 @@ export class VercelIntegrationRepository { } } - /** - * Fetch Vercel projects for a team or user - */ static async getVercelProjects( client: Vercel, teamId?: string | null @@ -629,17 +531,13 @@ export class VercelIntegrationRepository { } } - /** - * Create a Vercel organization integration from OAuth callback data. - * This stores the access token and creates the OrganizationIntegration record. - */ static async createVercelOrgIntegration(params: { accessToken: string; tokenType?: string; teamId: string | null; userId?: string; installationId?: string; - organization: Organization; + organization: Pick; raw?: Record; }): Promise { const result = await $transaction(prisma, async (tx) => { @@ -695,9 +593,6 @@ export class VercelIntegrationRepository { return result; } - /** - * Create a Vercel project integration linking a Vercel project to a Trigger.dev project - */ static async createVercelProjectIntegration(params: { organizationIntegrationId: string; projectId: string; @@ -723,9 +618,6 @@ export class VercelIntegrationRepository { }); } - /** - * Find an existing Vercel organization integration by team ID - */ static async findVercelOrgIntegrationByTeamId( organizationId: string, teamId: string | null @@ -743,9 +635,6 @@ export class VercelIntegrationRepository { }); } - /** - * Find Vercel organization integration for a project - */ static async findVercelOrgIntegrationForProject( projectId: string ): Promise<(OrganizationIntegration & { tokenReference: SecretReference }) | null> { @@ -770,9 +659,6 @@ export class VercelIntegrationRepository { return projectIntegration?.organizationIntegration ?? null; } - /** - * Find Vercel organization integration by organization ID - */ static async findVercelOrgIntegrationByOrganization( organizationId: string ): Promise<(OrganizationIntegration & { tokenReference: SecretReference }) | null> { @@ -788,21 +674,8 @@ export class VercelIntegrationRepository { }); } - /** - * Sync Trigger.dev API keys to Vercel as sensitive environment variables. - * Uses batch operations to minimize API calls. - * - * Mapping: - * - Production API key → Vercel "production" environment - * - Staging API key → Vercel custom environment (from vercelStagingEnvironment config) - * - Preview API key → Vercel "preview" environment - * - Development API key → Vercel "development" environment - * - * @param projectId - The Trigger.dev project ID - * @param vercelProjectId - The Vercel project ID - * @param teamId - The Vercel team ID (optional) - * @param vercelStagingEnvironment - The custom Vercel environment slug for staging (optional) - */ + // Sync Trigger.dev API keys to Vercel as sensitive environment variables + // Production → production, Staging → custom env, Preview → preview, Development → development static async syncApiKeysToVercel(params: { projectId: string; vercelProjectId: string; @@ -924,10 +797,7 @@ export class VercelIntegrationRepository { } } - /** - * Sync a single API key to Vercel for a specific environment. - * Used when API keys are regenerated. - */ + // Used when API keys are regenerated static async syncSingleApiKeyToVercel(params: { projectId: string; environmentType: "PRODUCTION" | "STAGING" | "PREVIEW" | "DEVELOPMENT"; @@ -1020,21 +890,8 @@ export class VercelIntegrationRepository { } } - /** - * Pull environment variables from Vercel and store them in the Trigger.dev database. - * - * Environment mapping: - * - Vercel "production" → Trigger.dev PRODUCTION - * - Vercel "preview" → Trigger.dev PREVIEW - * - Vercel custom environment (vercelStagingEnvironment) → Trigger.dev STAGING - * - * @param projectId - The Trigger.dev project ID - * @param vercelProjectId - The Vercel project ID - * @param teamId - The Vercel team ID (optional) - * @param vercelStagingEnvironment - The custom Vercel environment slug for staging (optional) - * @param syncEnvVarsMapping - Mapping of which env vars to sync (vars with false are skipped) - * @param orgIntegration - Organization integration with token reference - */ + // Pull environment variables from Vercel and store them in Trigger.dev + // production → PRODUCTION, preview → PREVIEW, custom env → STAGING static async pullEnvVarsFromVercel(params: { projectId: string; vercelProjectId: string; @@ -1127,30 +984,11 @@ export class VercelIntegrationRepository { }> = []; if (params.teamId) { - logger.info("Fetching shared env vars for pull operation", { - projectId: params.projectId, - vercelProjectId: params.vercelProjectId, - teamId: params.teamId, - }); - sharedEnvVars = await this.getVercelSharedEnvironmentVariableValues( client, params.teamId, params.vercelProjectId ); - - logger.info("Fetched shared env vars from Vercel for pull", { - projectId: params.projectId, - vercelProjectId: params.vercelProjectId, - count: sharedEnvVars.length, - keys: sharedEnvVars.map((v) => v.key), - targets: sharedEnvVars.map((v) => ({ key: v.key, target: v.target })), - }); - } else { - logger.debug("Skipping shared env vars fetch - no teamId", { - projectId: params.projectId, - vercelProjectId: params.vercelProjectId, - }); } // Process each environment mapping @@ -1164,14 +1002,6 @@ export class VercelIntegrationRepository { mapping.vercelTarget ); - logger.debug("Fetched project env vars for target", { - projectId: params.projectId, - vercelTarget: mapping.vercelTarget, - triggerEnvType: mapping.triggerEnvType, - projectEnvVarsCount: projectEnvVars.length, - projectEnvVarKeys: projectEnvVars.map((v) => v.key), - }); - // Filter shared env vars that target this environment const standardTargets = ["production", "preview", "development"]; const isCustomEnvironment = !standardTargets.includes(mapping.vercelTarget); @@ -1183,27 +1013,7 @@ export class VercelIntegrationRepository { // Also include if applyToAllCustomEnvironments is true and this is a custom environment const matchesCustomEnv = isCustomEnvironment && envVar.applyToAllCustomEnvironments === true; - const matches = matchesTarget || matchesCustomEnv; - - if (!matches) { - logger.debug("Shared env var excluded - target mismatch", { - envKey: envVar.key, - envVarTarget: envVar.target, - expectedTarget: mapping.vercelTarget, - isCustomEnvironment, - applyToAllCustomEnvironments: envVar.applyToAllCustomEnvironments, - }); - } - return matches; - }); - - logger.info("Filtered shared env vars for target", { - projectId: params.projectId, - vercelTarget: mapping.vercelTarget, - triggerEnvType: mapping.triggerEnvType, - totalSharedEnvVars: sharedEnvVars.length, - matchingSharedEnvVars: filteredSharedEnvVars.length, - matchingKeys: filteredSharedEnvVars.map((v) => v.key), + return matchesTarget || matchesCustomEnv; }); // Merge project and shared env vars (project vars take precedence) @@ -1214,22 +1024,7 @@ export class VercelIntegrationRepository { ...sharedEnvVarsToAdd, ]; - logger.info("Merged project and shared env vars", { - projectId: params.projectId, - vercelTarget: mapping.vercelTarget, - projectEnvVarsCount: projectEnvVars.length, - sharedEnvVarsAddedCount: sharedEnvVarsToAdd.length, - sharedEnvVarsSkippedDueToOverlap: filteredSharedEnvVars.length - sharedEnvVarsToAdd.length, - totalMergedCount: mergedEnvVars.length, - mergedKeys: mergedEnvVars.map((v) => v.key), - }); - if (mergedEnvVars.length === 0) { - logger.debug("No env vars found for Vercel target", { - projectId: params.projectId, - vercelProjectId: params.vercelProjectId, - vercelTarget: mapping.vercelTarget, - }); continue; } @@ -1237,58 +1032,25 @@ export class VercelIntegrationRepository { const varsToSync = mergedEnvVars.filter((envVar) => { // Skip secrets (they don't have values anyway) if (envVar.isSecret) { - logger.debug("Env var excluded - is secret", { envKey: envVar.key }); return false; } // Filter out TRIGGER_SECRET_KEY - these are managed by Trigger.dev if (envVar.key === "TRIGGER_SECRET_KEY") { - logger.debug("Env var excluded - is TRIGGER_SECRET_KEY", { envKey: envVar.key }); return false; } // Check if this var should be synced based on mapping for this environment - const shouldSync = shouldSyncEnvVar( + return shouldSyncEnvVar( params.syncEnvVarsMapping, envVar.key, mapping.triggerEnvType as TriggerEnvironmentType ); - if (!shouldSync) { - logger.debug("Env var excluded - disabled in sync mapping", { - envKey: envVar.key, - environmentType: mapping.triggerEnvType, - }); - } - return shouldSync; - }); - - logger.info("Filtered env vars to sync", { - projectId: params.projectId, - vercelTarget: mapping.vercelTarget, - triggerEnvType: mapping.triggerEnvType, - totalMergedCount: mergedEnvVars.length, - varsToSyncCount: varsToSync.length, - varsToSyncKeys: varsToSync.map((v) => v.key), }); if (varsToSync.length === 0) { - logger.debug("No env vars to sync after filtering", { - projectId: params.projectId, - vercelProjectId: params.vercelProjectId, - vercelTarget: mapping.vercelTarget, - totalVars: mergedEnvVars.length, - }); continue; } // Create env vars in Trigger.dev - logger.info("Saving env vars to Trigger.dev", { - projectId: params.projectId, - runtimeEnvironmentId: mapping.runtimeEnvironmentId, - vercelTarget: mapping.vercelTarget, - triggerEnvType: mapping.triggerEnvType, - variableCount: varsToSync.length, - variableKeys: varsToSync.map((v) => v.key), - }); - const result = await envVarRepository.create(params.projectId, { override: true, // Override existing vars environmentIds: [mapping.runtimeEnvironmentId], @@ -1301,14 +1063,6 @@ export class VercelIntegrationRepository { if (result.success) { syncedCount += varsToSync.length; - logger.info("Successfully synced env vars from Vercel to Trigger.dev", { - projectId: params.projectId, - vercelProjectId: params.vercelProjectId, - vercelTarget: mapping.vercelTarget, - triggerEnvType: mapping.triggerEnvType, - count: varsToSync.length, - keys: varsToSync.map((v) => v.key), - }); } else { const errorMsg = `Failed to sync env vars for ${mapping.triggerEnvType}: ${result.error}`; errors.push(errorMsg); @@ -1354,11 +1108,7 @@ export class VercelIntegrationRepository { } } - /** - * Batch create or update environment variables in Vercel. - * Fetches existing env vars once, then creates new ones in a single batch request - * and updates existing ones individually (Vercel doesn't support batch updates). - */ + // Batch create or update environment variables in Vercel static async batchUpsertVercelEnvVars(params: { client: Vercel; vercelProjectId: string; @@ -1412,7 +1162,7 @@ export class VercelIntegrationRepository { if (env.key !== envVar.key) { return false; } - const envTargets = Array.isArray(env.target) ? env.target : [env.target].filter(Boolean); + const envTargets = normalizeTarget(env.target); return ( envVar.target.length === envTargets.length && envVar.target.every((t) => envTargets.includes(t)) @@ -1494,11 +1244,7 @@ export class VercelIntegrationRepository { return { created, updated, errors }; } - /** - * Create or update an environment variable in Vercel. - * First tries to find an existing variable with the same key and target, - * then either creates or updates it. - */ + // Create or update an environment variable in Vercel private static async upsertVercelEnvVar(params: { client: Vercel; vercelProjectId: string; @@ -1527,7 +1273,7 @@ export class VercelIntegrationRepository { return false; } // Check if the targets match (env var targets this specific environment) - const envTargets = Array.isArray(env.target) ? env.target : [env.target].filter(Boolean); + const envTargets = normalizeTarget(env.target); // Match if the targets are exactly the same return target.length === envTargets.length && target.every((t) => envTargets.includes(t)); }); @@ -1559,9 +1305,6 @@ export class VercelIntegrationRepository { } } - /** - * Uninstall a Vercel integration by removing the configuration - */ static async uninstallVercelIntegration( integration: OrganizationIntegration & { tokenReference: SecretReference } ): Promise { diff --git a/apps/webapp/app/routes/callback.vercel.ts b/apps/webapp/app/routes/callback.vercel.ts index f784a2484d..e3342b7955 100644 --- a/apps/webapp/app/routes/callback.vercel.ts +++ b/apps/webapp/app/routes/callback.vercel.ts @@ -12,12 +12,54 @@ import { requestUrl } from "~/utils/requestUrl.server"; import { v3ProjectSettingsPath, confirmBasicDetailsPath, newProjectPath } from "~/utils/pathBuilder"; import { validateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server"; -/** - * Schema for Vercel OAuth callback query parameters. - * - * When a user completes the Vercel integration flow, they are redirected to this callback - * with various parameters including the authorization code and configuration details. - */ +async function createOrFindVercelIntegration(params: { + tokenResponse: { + accessToken: string; + tokenType: string; + teamId?: string; + userId?: string; + raw: Record; + }; + project: { + organizationId: string; + organization: { id: string; }; + }; + configurationId?: string; +}) { + const { tokenResponse, project, configurationId } = params; + + // Check if we already have a Vercel org integration for this team + let orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationByTeamId( + project.organizationId, + tokenResponse.teamId ?? null + ); + + // Create org integration if it doesn't exist + if (!orgIntegration) { + await VercelIntegrationRepository.createVercelOrgIntegration({ + accessToken: tokenResponse.accessToken, + tokenType: tokenResponse.tokenType, + teamId: tokenResponse.teamId ?? null, + userId: tokenResponse.userId, + installationId: configurationId, + organization: project.organization, + raw: tokenResponse.raw, + }); + + // Re-fetch to get the full integration with tokenReference + orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationByTeamId( + project.organizationId, + tokenResponse.teamId ?? null + ); + } + + if (!orgIntegration) { + throw new Error("Failed to create or find Vercel organization integration"); + } + + return orgIntegration; +} + const VercelCallbackSchema = z .object({ // OAuth authorization code @@ -37,21 +79,8 @@ const VercelCallbackSchema = z .passthrough(); -/** - * Vercel OAuth callback handler. - * - * This route handles the callback from Vercel after a user installs/configures - * the Trigger.dev integration from the Vercel marketplace. - * - * Flow: - * 1. User clicks "Connect" on our settings page - * 2. User is redirected to https://vercel.com/integrations/trigger/new - * 3. User authorizes the integration on Vercel - * 4. Vercel redirects back here with an authorization code - * 5. We exchange the code for an access token - * 6. We create OrganizationIntegration and OrganizationProjectIntegration records - * 7. Redirect to settings page with ?vercelOnboarding=true to show onboarding modal - */ +// Vercel OAuth callback handler +// Flow: Connect button → Vercel marketplace → user authorizes → callback with code → exchange for token → create integration export async function loader({ request }: LoaderFunctionArgs) { if (request.method.toUpperCase() !== "GET") { return new Response("Method Not Allowed", { status: 405 }); @@ -248,34 +277,11 @@ export async function loader({ request }: LoaderFunctionArgs) { // Create the integration try { - // Check if we already have a Vercel org integration for this team - let orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationByTeamId( - project.organizationId, - tokenResponse.teamId ?? null - ); - - // Create org integration if it doesn't exist - if (!orgIntegration) { - const newIntegration = await VercelIntegrationRepository.createVercelOrgIntegration({ - accessToken: tokenResponse.accessToken, - tokenType: tokenResponse.tokenType, - teamId: tokenResponse.teamId ?? null, - userId: tokenResponse.userId, - installationId: configurationId, - organization: project.organization, - raw: tokenResponse.raw, - }); - - // Re-fetch to get the full integration with tokenReference - orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationByTeamId( - project.organizationId, - tokenResponse.teamId ?? null - ); - } - - if (!orgIntegration) { - throw new Error("Failed to create or find Vercel organization integration"); - } + await createOrFindVercelIntegration({ + tokenResponse, + project, + configurationId, + }); logger.info("Vercel organization integration created successfully after onboarding", { organizationId: project.organizationId, @@ -359,34 +365,11 @@ export async function loader({ request }: LoaderFunctionArgs) { } try { - // Check if we already have a Vercel org integration for this team - let orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationByTeamId( - project.organizationId, - tokenResponse.teamId ?? null - ); - - // Create org integration if it doesn't exist - if (!orgIntegration) { - const newIntegration = await VercelIntegrationRepository.createVercelOrgIntegration({ - accessToken: tokenResponse.accessToken, - tokenType: tokenResponse.tokenType, - teamId: tokenResponse.teamId ?? null, - userId: tokenResponse.userId, - installationId: configurationId, - organization: project.organization, - raw: tokenResponse.raw, - }); - - // Re-fetch to get the full integration with tokenReference - orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationByTeamId( - project.organizationId, - tokenResponse.teamId ?? null - ); - } - - if (!orgIntegration) { - throw new Error("Failed to create or find Vercel organization integration"); - } + await createOrFindVercelIntegration({ + tokenResponse, + project, + configurationId, + }); // The OrganizationIntegration is now created. // The user will select a Vercel project during onboarding, which will create the @@ -424,9 +407,6 @@ export async function loader({ request }: LoaderFunctionArgs) { } } -/** - * Exchange authorization code for access token using Vercel OAuth - */ async function exchangeCodeForToken(code: string): Promise<{ accessToken: string; tokenType: string; From 479540bd10486b615f5e5f726421ae7ffdd2e5a3 Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Mon, 12 Jan 2026 09:40:47 +0100 Subject: [PATCH 08/33] refactor(vercel): AI Deslop --- .vscode/settings.json | 3 +- .../app/models/vercelIntegration.server.ts | 9 +-- .../app/routes/auth.github.callback.tsx | 7 +- .../app/routes/auth.google.callback.tsx | 7 +- ...cts.$projectParam.env.$envParam.vercel.tsx | 1 - .../app/services/vercelIntegration.server.ts | 75 ++----------------- 6 files changed, 12 insertions(+), 90 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 12aefeb358..b6adc5477a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,5 +7,6 @@ "packages/cli-v3/e2e": true }, "vitest.disableWorkspaceWarning": true, - "typescript.experimental.useTsgo": false + "typescript.experimental.useTsgo": false, + "chat.agent.maxRequests": 0 } diff --git a/apps/webapp/app/models/vercelIntegration.server.ts b/apps/webapp/app/models/vercelIntegration.server.ts index 34de34eb28..dc65b89099 100644 --- a/apps/webapp/app/models/vercelIntegration.server.ts +++ b/apps/webapp/app/models/vercelIntegration.server.ts @@ -18,14 +18,12 @@ import { } from "~/v3/vercel/vercelProjectIntegrationSchema"; import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; -// Utility to normalize target arrays from Vercel API responses function normalizeTarget(target: unknown): string[] { if (Array.isArray(target)) return target.filter(Boolean) as string[]; if (typeof target === 'string') return [target]; return []; } -// Utility to safely extract envs array from Vercel API response function extractEnvs(response: unknown): unknown[] { if (response && typeof response === 'object' && 'envs' in response) { const envs = (response as { envs: unknown }).envs; @@ -116,8 +114,6 @@ export class VercelIntegrationRepository { bearerToken: accessToken, }); - // Use the Vercel SDK to get the integration configuration - // The SDK might have a method for this, or we need to make a direct API call const response = await fetch( `https://api.vercel.com/v1/integrations/configuration/${configurationId}${teamId ? `?teamId=${teamId}` : ""}`, { @@ -162,7 +158,6 @@ export class VercelIntegrationRepository { } } - // Excludes standard environments (production, preview, development) static async getVercelCustomEnvironments( client: Vercel, projectId: string, @@ -235,7 +230,7 @@ export class VercelIntegrationRepository { client: Vercel, projectId: string, teamId?: string | null, - target?: string // Optional: filter by Vercel environment (production, preview, etc.) + target?: string ): Promise< Array<{ key: string; @@ -612,7 +607,7 @@ export class VercelIntegrationRepository { organizationIntegrationId: params.organizationIntegrationId, projectId: params.projectId, externalEntityId: params.vercelProjectId, - integrationData: integrationData as any, + integrationData: integrationData, installedBy: params.installedByUserId, }, }); diff --git a/apps/webapp/app/routes/auth.github.callback.tsx b/apps/webapp/app/routes/auth.github.callback.tsx index 418540f94e..531961ad11 100644 --- a/apps/webapp/app/routes/auth.github.callback.tsx +++ b/apps/webapp/app/routes/auth.github.callback.tsx @@ -19,7 +19,6 @@ export let loader: LoaderFunction = async ({ request }) => { failureRedirect: "/login", // If auth fails, the failureRedirect will be thrown as a Response }); - // manually get the session const session = await getSession(request.headers.get("cookie")); const userRecord = await prisma.user.findFirst({ @@ -51,23 +50,20 @@ export let loader: LoaderFunction = async ({ request }) => { return redirect("/login/mfa", { headers }); } - // and store the user data session.set(authenticator.sessionKey, auth); const headers = new Headers(); headers.append("Set-Cookie", await commitSession(session)); headers.append("Set-Cookie", await setLastAuthMethodHeader("github")); - // Read referral source cookie and set in PostHog if present (only for new users), then clear it const referralSource = await getReferralSource(request); if (referralSource) { const user = await prisma.user.findUnique({ where: { id: auth.userId }, }); if (user) { - // Only set referralSource for new users (created within the last 30 seconds) const userAge = Date.now() - user.createdAt.getTime(); - const isNewUser = userAge < 30 * 1000; // 30 seconds + const isNewUser = userAge < 30 * 1000; if (isNewUser) { telemetry.user.identify({ @@ -77,7 +73,6 @@ export let loader: LoaderFunction = async ({ request }) => { }); } } - // Clear the cookie after using it (regardless of whether we set it) headers.append("Set-Cookie", await clearReferralSourceCookie()); } diff --git a/apps/webapp/app/routes/auth.google.callback.tsx b/apps/webapp/app/routes/auth.google.callback.tsx index 2a1010709c..53c9735107 100644 --- a/apps/webapp/app/routes/auth.google.callback.tsx +++ b/apps/webapp/app/routes/auth.google.callback.tsx @@ -19,7 +19,6 @@ export let loader: LoaderFunction = async ({ request }) => { failureRedirect: "/login", // If auth fails, the failureRedirect will be thrown as a Response }); - // manually get the session const session = await getSession(request.headers.get("cookie")); const userRecord = await prisma.user.findFirst({ @@ -51,23 +50,20 @@ export let loader: LoaderFunction = async ({ request }) => { return redirect("/login/mfa", { headers }); } - // and store the user data session.set(authenticator.sessionKey, auth); const headers = new Headers(); headers.append("Set-Cookie", await commitSession(session)); headers.append("Set-Cookie", await setLastAuthMethodHeader("google")); - // Read referral source cookie and set in PostHog if present (only for new users), then clear it const referralSource = await getReferralSource(request); if (referralSource) { const user = await prisma.user.findUnique({ where: { id: auth.userId }, }); if (user) { - // Only set referralSource for new users (created within the last 30 seconds) const userAge = Date.now() - user.createdAt.getTime(); - const isNewUser = userAge < 30 * 1000; // 30 seconds + const isNewUser = userAge < 30 * 1000; if (isNewUser) { telemetry.user.identify({ @@ -77,7 +73,6 @@ export let loader: LoaderFunction = async ({ request }) => { }); } } - // Clear the cookie after using it (regardless of whether we set it) headers.append("Set-Cookie", await clearReferralSourceCookie()); } diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx index b7be0bc7bd..f0b613d9aa 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx @@ -433,7 +433,6 @@ export async function action({ request, params }: ActionFunctionArgs) { // Handle skip-onboarding action if (actionType === "skip-onboarding") { - await vercelService.skipOnboarding(project.id); const settingsPath = v3ProjectSettingsPath( { slug: organizationSlug }, { slug: projectParam }, diff --git a/apps/webapp/app/services/vercelIntegration.server.ts b/apps/webapp/app/services/vercelIntegration.server.ts index f363d91efc..17e734f521 100644 --- a/apps/webapp/app/services/vercelIntegration.server.ts +++ b/apps/webapp/app/services/vercelIntegration.server.ts @@ -15,24 +15,14 @@ import { createDefaultVercelIntegrationData, } from "~/v3/vercel/vercelProjectIntegrationSchema"; -/** - * Base type for Vercel project integration with parsed data. - * Used for simple operations that don't need related data. - */ export type VercelProjectIntegrationWithParsedData = OrganizationProjectIntegration & { parsedIntegrationData: VercelProjectIntegrationData; }; -/** - * Vercel project integration including the organization integration relation. - */ export type VercelProjectIntegrationWithData = VercelProjectIntegrationWithParsedData & { organizationIntegration: OrganizationIntegration; }; -/** - * Vercel project integration including both organization integration and project relations. - */ export type VercelProjectIntegrationWithProject = VercelProjectIntegrationWithData & { project: { id: string; @@ -48,9 +38,6 @@ export class VercelIntegrationService { this.#prismaClient = prismaClient; } - /** - * Get the Vercel project integration for a specific project. - */ async getVercelProjectIntegration( projectId: string ): Promise { @@ -88,9 +75,6 @@ export class VercelIntegrationService { }; } - /** - * Get all connected Vercel projects for an organization. - */ async getConnectedVercelProjects( organizationId: string ): Promise { @@ -134,10 +118,6 @@ export class VercelIntegrationService { .filter((i): i is VercelProjectIntegrationWithProject => i !== null); } - /** - * Create a new Vercel project integration. - * This links a Vercel project to a Trigger.dev project. - */ async createVercelProjectIntegration(params: { organizationIntegrationId: string; projectId: string; @@ -157,16 +137,12 @@ export class VercelIntegrationService { organizationIntegrationId: params.organizationIntegrationId, projectId: params.projectId, externalEntityId: params.vercelProjectId, - integrationData: integrationData as any, + integrationData: integrationData, installedBy: params.installedByUserId, }, }); } - /** - * Select a Vercel project during onboarding. - * Creates the OrganizationProjectIntegration record and syncs API keys to Vercel. - */ async selectVercelProject(params: { organizationId: string; projectId: string; @@ -177,7 +153,6 @@ export class VercelIntegrationService { integration: OrganizationProjectIntegration; syncResult: { success: boolean; errors: string[] }; }> { - // Get the org integration const orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationByOrganization( params.organizationId ); @@ -186,13 +161,10 @@ export class VercelIntegrationService { throw new Error("No Vercel organization integration found"); } - // Get the team ID from the stored secret const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration); - // Check if there's already a project integration (shouldn't happen, but handle gracefully) const existing = await this.getVercelProjectIntegration(params.projectId); if (existing) { - // Update the existing integration const updated = await this.#prismaClient.organizationProjectIntegration.update({ where: { id: existing.id }, data: { @@ -202,11 +174,10 @@ export class VercelIntegrationService { vercelProjectId: params.vercelProjectId, vercelProjectName: params.vercelProjectName, vercelTeamId: teamId, - } as any, + }, }, }); - // Sync API keys to the newly selected project const syncResult = await VercelIntegrationRepository.syncApiKeysToVercel({ projectId: params.projectId, vercelProjectId: params.vercelProjectId, @@ -218,7 +189,6 @@ export class VercelIntegrationService { return { integration: updated, syncResult }; } - // Create new project integration const integration = await this.createVercelProjectIntegration({ organizationIntegrationId: orgIntegration.id, projectId: params.projectId, @@ -228,12 +198,10 @@ export class VercelIntegrationService { installedByUserId: params.userId, }); - // Sync API keys to Vercel immediately const syncResult = await VercelIntegrationRepository.syncApiKeysToVercel({ projectId: params.projectId, vercelProjectId: params.vercelProjectId, teamId, - // No staging environment mapping yet - will be set during onboarding vercelStagingEnvironment: null, orgIntegration, }); @@ -249,9 +217,6 @@ export class VercelIntegrationService { return { integration, syncResult }; } - /** - * Update the Vercel integration config for a project. - */ async updateVercelIntegrationConfig( projectId: string, configUpdates: Partial @@ -274,7 +239,7 @@ export class VercelIntegrationService { const updated = await this.#prismaClient.organizationProjectIntegration.update({ where: { id: existing.id }, data: { - integrationData: updatedData as any, + integrationData: updatedData, }, }); @@ -284,9 +249,6 @@ export class VercelIntegrationService { }; } - /** - * Update the environment variable sync mapping for a project. - */ async updateSyncEnvVarsMapping( projectId: string, syncEnvVarsMapping: SyncEnvVarsMapping @@ -304,7 +266,7 @@ export class VercelIntegrationService { const updated = await this.#prismaClient.organizationProjectIntegration.update({ where: { id: existing.id }, data: { - integrationData: updatedData as any, + integrationData: updatedData, }, }); @@ -314,10 +276,6 @@ export class VercelIntegrationService { }; } - /** - * Update the sync status of a specific environment variable for a given environment type. - * This is used when toggling individual env var sync settings from the UI. - */ async updateSyncEnvVarForEnvironment( projectId: string, envVarKey: string, @@ -329,13 +287,10 @@ export class VercelIntegrationService { return null; } - // Get the current sync mapping const currentMapping = existing.parsedIntegrationData.syncEnvVarsMapping || {}; - // Get the current settings for this env var (if any) const currentEnvVarSettings = currentMapping[envVarKey] || {}; - // Create a new mapping with the updated value const updatedMapping: SyncEnvVarsMapping = { ...currentMapping, [envVarKey]: { @@ -352,7 +307,7 @@ export class VercelIntegrationService { const updated = await this.#prismaClient.organizationProjectIntegration.update({ where: { id: existing.id }, data: { - integrationData: updatedData as any, + integrationData: updatedData, }, }); @@ -362,10 +317,6 @@ export class VercelIntegrationService { }; } - /** - * Complete the onboarding process and save all user selections. - * If pullEnvVarsFromVercel is true, also pulls env vars from Vercel and stores them in the database. - */ async completeOnboarding( projectId: string, params: { @@ -394,11 +345,10 @@ export class VercelIntegrationService { const updated = await this.#prismaClient.organizationProjectIntegration.update({ where: { id: existing.id }, data: { - integrationData: updatedData as any, + integrationData: updatedData, }, }); - // Pull env vars from Vercel if enabled if (params.pullEnvVarsFromVercel) { try { // Get the org integration with token reference @@ -438,7 +388,6 @@ export class VercelIntegrationService { }); } } catch (error) { - // Log but don't fail onboarding if env var pull fails logger.error("Failed to pull env vars from Vercel during onboarding", { projectId, vercelProjectId: updatedData.vercelProjectId, @@ -453,18 +402,6 @@ export class VercelIntegrationService { }; } - /** - * Skip onboarding without modifying any settings. - * This is a no-op that just closes the modal - no database changes needed. - */ - async skipOnboarding(_projectId: string): Promise { - // No-op - onboarding is tracked only via URL query parameter - // This method exists for API consistency - } - - /** - * Disconnect a Vercel project from a Trigger.dev project (soft delete). - */ async disconnectVercelProject(projectId: string): Promise { const existing = await this.getVercelProjectIntegration(projectId); if (!existing) { From 47dce1d8685a6eab87065b2a677af57a332c9eec Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Mon, 12 Jan 2026 12:20:06 +0100 Subject: [PATCH 09/33] feat(vercel): add install param parser and org requirement helper - Add getVercelInstallParams(request) to extract Vercel installation parameters (code, configurationId, next) from a request URL and only return them when the integration param is "vercel" or absent. This centralizes install URL parsing for Vercel flows. - Add requireOrganization(request, organizationSlug) helper that ensures the caller is an authenticated member of the organization, returning the organization and userId or throwing a 404 response. This enforces access control for org-scoped routes. - Refactor EnvironmentVariablesPresenter to use a Vercel integration service for fetching project integration and remove in-presenter legacy migration/parsing logic. The presenter now relies on VercelIntegrationService.getVercelProjectIntegration(projectId, true) to obtain parsed integration data, simplifying responsibilities and consolidating Vercel-related parsing/migration in the service layer. These changes improve separation of concerns, centralize Vercel integration parsing, and add a reusable org auth helper for request handlers. --- .vscode/settings.json | 2 +- .../EnvironmentVariablesPresenter.server.ts | 70 ++----------------- ...ationSlug.settings.integrations.vercel.tsx | 45 ++---------- .../app/routes/confirm-basic-details.tsx | 19 +++-- apps/webapp/app/services/org.server.ts | 20 ++++++ .../app/services/vercelIntegration.server.ts | 4 +- apps/webapp/app/v3/vercel/index.ts | 18 +++++ .../vercel/vercelProjectIntegrationSchema.ts | 56 --------------- 8 files changed, 60 insertions(+), 174 deletions(-) create mode 100644 apps/webapp/app/services/org.server.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index b6adc5477a..14f6999f2d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,5 +8,5 @@ }, "vitest.disableWorkspaceWarning": true, "typescript.experimental.useTsgo": false, - "chat.agent.maxRequests": 0 + "chat.agent.maxRequests": 10000 } diff --git a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts index 7d3479e2da..edecb81cd3 100644 --- a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts @@ -1,16 +1,12 @@ -import { flipCauseOption } from "effect/Cause"; import { PrismaClient, prisma } from "~/db.server"; import { Project } from "~/models/project.server"; import { User } from "~/models/user.server"; import { filterOrphanedEnvironments, sortEnvironments } from "~/utils/environmentSort"; import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; import { - VercelProjectIntegrationDataSchema, SyncEnvVarsMapping, - isLegacySyncEnvVarsMapping, - migrateLegacySyncEnvVarsMapping, } from "~/v3/vercel/vercelProjectIntegrationSchema"; -import { logger } from "~/services/logger.server"; +import { VercelIntegrationService } from "~/services/vercelIntegration.server"; type Result = Awaited>; export type EnvironmentVariableWithSetValues = Result["environmentVariables"][number]; @@ -102,71 +98,15 @@ export class EnvironmentVariablesPresenter { const variables = await repository.getProject(project.id); // Get Vercel integration data if it exists - const vercelIntegration = await this.#prismaClient.organizationProjectIntegration.findFirst({ - where: { - projectId: project.id, - deletedAt: null, - organizationIntegration: { - service: "VERCEL", - deletedAt: null, - }, - }, - }); + const vercelService = new VercelIntegrationService(this.#prismaClient); + const vercelIntegration = await vercelService.getVercelProjectIntegration(project.id, true); let vercelSyncEnvVarsMapping: SyncEnvVarsMapping = {}; let vercelPullEnvVarsEnabled = false; if (vercelIntegration) { - let parsedData = VercelProjectIntegrationDataSchema.safeParse( - vercelIntegration.integrationData - ); - - // Handle migration from legacy format if needed - if (!parsedData.success) { - const rawData = vercelIntegration.integrationData as Record; - - if (rawData && isLegacySyncEnvVarsMapping(rawData.syncEnvVarsMapping)) { - logger.info("Migrating legacy Vercel sync mapping format in presenter", { - projectId: project.id, - integrationId: vercelIntegration.id, - }); - - // Migrate the legacy format - const migratedMapping = migrateLegacySyncEnvVarsMapping( - rawData.syncEnvVarsMapping as Record - ); - - // Update the data with migrated mapping - const migratedData = { - ...rawData, - syncEnvVarsMapping: migratedMapping, - }; - - // Try parsing again with migrated data - parsedData = VercelProjectIntegrationDataSchema.safeParse(migratedData); - - if (parsedData.success) { - // Save the migrated data back to the database (fire and forget) - this.#prismaClient.organizationProjectIntegration.update({ - where: { id: vercelIntegration.id }, - data: { - integrationData: migratedData as any, - }, - }).catch((error) => { - logger.error("Failed to save migrated Vercel sync mapping", { - projectId: project.id, - integrationId: vercelIntegration.id, - error, - }); - }); - } - } - } - - if (parsedData.success) { - vercelSyncEnvVarsMapping = parsedData.data.syncEnvVarsMapping; - vercelPullEnvVarsEnabled = parsedData.data.config.pullEnvVarsFromVercel; - } + vercelSyncEnvVarsMapping = vercelIntegration.parsedIntegrationData.syncEnvVarsMapping; + vercelPullEnvVarsEnabled = vercelIntegration.parsedIntegrationData.config.pullEnvVarsFromVercel; } return { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx index bf5ef53256..bba21a1cea 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx @@ -12,7 +12,6 @@ import { Dialog, DialogContent, DialogDescription, - DialogFooter, DialogHeader, DialogTitle, DialogTrigger, @@ -22,10 +21,9 @@ import { Header1 } from "~/components/primitives/Headers"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Paragraph } from "~/components/primitives/Paragraph"; import { Table, TableBlankRow, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow } from "~/components/primitives/Table"; -import { useOrganization } from "~/hooks/useOrganizations"; import { VercelIntegrationRepository } from "~/models/vercelIntegration.server"; import { $transaction, prisma } from "~/db.server"; -import { requireUserId } from "~/services/session.server"; +import { requireOrganization } from "~/services/org.server"; import { OrganizationParamsSchema } from "~/utils/pathBuilder"; import { logger } from "~/services/logger.server"; import { TrashIcon } from "@heroicons/react/20/solid"; @@ -44,29 +42,15 @@ function formatDate(date: Date): string { }).format(date); } -const SearchParamsSchema = z.object({ +const SearchParamsSchema = OrganizationParamsSchema.extend({ configurationId: z.string().optional(), }); export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); - const { organizationSlug } = OrganizationParamsSchema.parse(params); + const { organizationSlug, configurationId } = SearchParamsSchema.parse(params); + const { organization } = await requireOrganization(request, organizationSlug); const url = new URL(request.url); - const { configurationId } = SearchParamsSchema.parse(Object.fromEntries(url.searchParams)); - - // Check user has access to organization - const organization = await prisma.organization.findFirst({ - where: { - slug: organizationSlug, - members: { some: { userId } }, - deletedAt: null, - }, - }); - - if (!organization) { - throw new Response("Not found", { status: 404 }); - } // Find Vercel integration for this organization let vercelIntegration = await prisma.organizationIntegration.findFirst({ @@ -136,28 +120,9 @@ const ActionSchema = z.object({ }); export const action = async ({ request, params }: ActionFunctionArgs) => { - const userId = await requireUserId(request); const { organizationSlug } = OrganizationParamsSchema.parse(params); + const { organization, userId } = await requireOrganization(request, organizationSlug); - const formData = await request.formData(); - const { intent } = ActionSchema.parse(Object.fromEntries(formData)); - - if (intent !== "uninstall") { - throw new Response("Invalid intent", { status: 400 }); - } - - // Check user has access to organization - const organization = await prisma.organization.findFirst({ - where: { - slug: organizationSlug, - members: { some: { userId } }, - deletedAt: null, - }, - }); - - if (!organization) { - throw new Response("Not found", { status: 404 }); - } // Find Vercel integration const vercelIntegration = await prisma.organizationIntegration.findFirst({ diff --git a/apps/webapp/app/routes/confirm-basic-details.tsx b/apps/webapp/app/routes/confirm-basic-details.tsx index 269c2d79e8..0596ee8b52 100644 --- a/apps/webapp/app/routes/confirm-basic-details.tsx +++ b/apps/webapp/app/routes/confirm-basic-details.tsx @@ -25,6 +25,7 @@ import { redirectWithSuccessMessage } from "~/models/message.server"; import { updateUser } from "~/models/user.server"; import { requireUserId } from "~/services/session.server"; import { rootPath } from "~/utils/pathBuilder"; +import { getVercelInstallParams } from "~/v3/vercel"; function createSchema( constraints: { @@ -106,22 +107,18 @@ export const action: ActionFunction = async ({ request }) => { }); // Preserve Vercel integration params if present - const url = new URL(request.url); - const code = url.searchParams.get("code"); - const configurationId = url.searchParams.get("configurationId"); - const integration = url.searchParams.get("integration"); - const next = url.searchParams.get("next"); + const vercelParams = getVercelInstallParams(request); let redirectUrl = rootPath(); - if (code && configurationId && integration === "vercel") { + if (vercelParams) { // Redirect to orgs/new with params preserved const params = new URLSearchParams({ - code, - configurationId, - integration, + code: vercelParams.code, + configurationId: vercelParams.configurationId, + integration: "vercel", }); - if (next) { - params.set("next", next); + if (vercelParams.next) { + params.set("next", vercelParams.next); } redirectUrl = `/orgs/new?${params.toString()}`; } diff --git a/apps/webapp/app/services/org.server.ts b/apps/webapp/app/services/org.server.ts new file mode 100644 index 0000000000..75c1467ab2 --- /dev/null +++ b/apps/webapp/app/services/org.server.ts @@ -0,0 +1,20 @@ +import { prisma } from "~/db.server"; +import { requireUserId } from "./session.server"; + +export async function requireOrganization(request: Request, organizationSlug: string) { + const userId = await requireUserId(request); + + const organization = await prisma.organization.findFirst({ + where: { + slug: organizationSlug, + members: { some: { userId } }, + deletedAt: null, + }, + }); + + if (!organization) { + throw new Response("Organization not found", { status: 404 }); + } + + return { organization, userId }; +} diff --git a/apps/webapp/app/services/vercelIntegration.server.ts b/apps/webapp/app/services/vercelIntegration.server.ts index 17e734f521..e9390751b4 100644 --- a/apps/webapp/app/services/vercelIntegration.server.ts +++ b/apps/webapp/app/services/vercelIntegration.server.ts @@ -39,7 +39,8 @@ export class VercelIntegrationService { } async getVercelProjectIntegration( - projectId: string + projectId: string, + migrateIfNeeded: boolean = false ): Promise { const integration = await this.#prismaClient.organizationProjectIntegration.findFirst({ where: { @@ -60,6 +61,7 @@ export class VercelIntegrationService { } const parsedData = VercelProjectIntegrationDataSchema.safeParse(integration.integrationData); + if (!parsedData.success) { logger.error("Failed to parse Vercel integration data", { projectId, diff --git a/apps/webapp/app/v3/vercel/index.ts b/apps/webapp/app/v3/vercel/index.ts index a6e1b3ae54..6a335b7436 100644 --- a/apps/webapp/app/v3/vercel/index.ts +++ b/apps/webapp/app/v3/vercel/index.ts @@ -6,3 +6,21 @@ export * from "./vercelProjectIntegrationSchema"; +/** + * Extract Vercel installation parameters from a request URL. + */ +export function getVercelInstallParams(request: Request) { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const configurationId = url.searchParams.get("configurationId"); + const integration = url.searchParams.get("integration"); + const next = url.searchParams.get("next"); + + if (code && configurationId && (integration === "vercel" || !integration)) { + return { code, configurationId, next }; + } + + return null; +} + + diff --git a/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts b/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts index 5124366027..601a9a56fc 100644 --- a/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts +++ b/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts @@ -83,13 +83,6 @@ export const SyncEnvVarsMappingSchema = z.record(z.string(), EnvVarSyncSettingsS export type SyncEnvVarsMapping = z.infer; -/** - * Legacy mapping format (simple boolean per env var) - * Used for migration from old format to new format. - */ -export const LegacySyncEnvVarsMappingSchema = z.record(z.string(), z.boolean()); -export type LegacySyncEnvVarsMapping = z.infer; - /** * The complete integrationData schema for OrganizationProjectIntegration * when the integration service is VERCEL. @@ -192,52 +185,3 @@ export function shouldSyncEnvVarForAnyEnvironment( const environments: TriggerEnvironmentType[] = ["PRODUCTION", "STAGING", "PREVIEW", "DEVELOPMENT"]; return environments.some((env) => envVarSettings[env] !== false); } - -/** - * Check if this is a legacy format mapping (simple boolean per env var) - */ -export function isLegacySyncEnvVarsMapping(mapping: unknown): mapping is LegacySyncEnvVarsMapping { - if (!mapping || typeof mapping !== "object") { - return false; - } - // Check if any value is a boolean (legacy format) - // vs an object (new format) - for (const value of Object.values(mapping)) { - if (typeof value === "boolean") { - return true; - } - // If it's an object, it's the new format - if (typeof value === "object" && value !== null) { - return false; - } - } - // Empty object could be either, treat as new format - return false; -} - -/** - * Migrate legacy sync mapping format to new per-environment format. - * If the env var was disabled in legacy format, it will be disabled for ALL environments. - * If it was enabled (or not present), it will be enabled for all environments. - */ -export function migrateLegacySyncEnvVarsMapping( - legacyMapping: LegacySyncEnvVarsMapping -): SyncEnvVarsMapping { - const newMapping: SyncEnvVarsMapping = {}; - const environments: TriggerEnvironmentType[] = ["PRODUCTION", "STAGING", "PREVIEW", "DEVELOPMENT"]; - - for (const [key, enabled] of Object.entries(legacyMapping)) { - if (enabled === false) { - // If disabled in legacy format, disable for all environments - newMapping[key] = { - PRODUCTION: false, - STAGING: false, - PREVIEW: false, - DEVELOPMENT: false, - }; - } - // If enabled (true), we don't need to add it since default is enabled - } - - return newMapping; -} From ceed97dee0c1cfa4db2ddaa85522e41e6824e197 Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Mon, 12 Jan 2026 20:40:59 +0100 Subject: [PATCH 10/33] feat(vercel): update tokens, refine env lookup & cleanup Update Vercel org integration handling to update existing integration tokens instead of always creating a new record. When an existing org integration is detected, log the update, call the repository update method with the new token and metadata, and re-fetch the integration to use the up-to-date record. This avoids duplicate integrations and keeps stored tokens current. Refactor Vercel repo imports and environment lookup to improve error handling and typing. Return early when getVercelCustomEnvironments fails, use the typed result (.data) and VercelCustomEnvironment for safer lookup, and remove several commented section headers and unused helpers to simplify the module. Also remove an unused presenter import and tidy up minor formatting. Why: prevent duplicate org integrations, ensure tokens are refreshed correctly, and make environment name resolution more robust and typed. --- .../app/models/vercelIntegration.server.ts | 230 ++++++++--- .../v3/VercelSettingsPresenter.server.ts | 370 +++++++++++------- apps/webapp/app/routes/callback.vercel.ts | 27 +- ...cts.$projectParam.env.$envParam.vercel.tsx | 334 ++++++++-------- 4 files changed, 608 insertions(+), 353 deletions(-) diff --git a/apps/webapp/app/models/vercelIntegration.server.ts b/apps/webapp/app/models/vercelIntegration.server.ts index dc65b89099..4ee05798e6 100644 --- a/apps/webapp/app/models/vercelIntegration.server.ts +++ b/apps/webapp/app/models/vercelIntegration.server.ts @@ -62,6 +62,30 @@ export type VercelCustomEnvironment = { }; }; +export type VercelAPIResult = { + success: true; + data: T; +} | { + success: false; + authInvalid: boolean; + error: string; +}; + +function isVercelAuthError(error: unknown): boolean { + if (error && typeof error === 'object' && 'status' in error) { + const status = (error as { status?: number }).status; + return status === 401 || status === 403; + } + if (error && typeof error === 'object' && 'response' in error) { + const response = (error as { response?: { status?: number } }).response; + return response?.status === 401 || response?.status === 403; + } + if (error && typeof error === 'string' && (error.includes('401') || error.includes('403'))) { + return true; + } + return false; +} + export class VercelIntegrationRepository { static async getVercelClient( integration: OrganizationIntegration & { tokenReference: SecretReference } @@ -82,6 +106,30 @@ export class VercelIntegrationRepository { }); } + static async validateVercelToken( + integration: OrganizationIntegration & { tokenReference: SecretReference } + ): Promise<{ isValid: boolean }> { + try { + const client = await this.getVercelClient(integration); + await client.user.getAuthUser(); + return { isValid: true }; + } catch (error) { + const authInvalid = isVercelAuthError(error); + if (authInvalid) { + logger.debug("Vercel token validation failed - auth error", { + integrationId: integration.id, + error, + }); + return { isValid: false }; + } + logger.error("Vercel token validation failed - unexpected error", { + integrationId: integration.id, + error, + }); + throw error; + } + } + static async getTeamIdFromIntegration( integration: OrganizationIntegration & { tokenReference: SecretReference } ): Promise { @@ -162,7 +210,7 @@ export class VercelIntegrationRepository { client: Vercel, projectId: string, teamId?: string | null - ): Promise { + ): Promise> { try { const response = await client.environment.getV9ProjectsIdOrNameCustomEnvironments({ idOrName: projectId, @@ -172,19 +220,28 @@ export class VercelIntegrationRepository { // The response contains environments array const environments = response.environments || []; - return environments.map((env: any) => ({ - id: env.id, - slug: env.slug, - description: env.description, - branchMatcher: env.branchMatcher, - })); + return { + success: true, + data: environments.map((env: any) => ({ + id: env.id, + slug: env.slug, + description: env.description, + branchMatcher: env.branchMatcher, + })), + }; } catch (error) { + const authInvalid = isVercelAuthError(error); logger.error("Failed to fetch Vercel custom environments", { projectId, teamId, error, + authInvalid, }); - return []; + return { + success: false, + authInvalid, + error: error instanceof Error ? error.message : "Unknown error", + }; } } @@ -193,7 +250,7 @@ export class VercelIntegrationRepository { client: Vercel, projectId: string, teamId?: string | null - ): Promise { + ): Promise> { try { const response = await client.projects.filterProjectEnvs({ idOrName: projectId, @@ -203,26 +260,35 @@ export class VercelIntegrationRepository { // The response is a union type - check if it has envs array const envs = extractEnvs(response); - return envs.map((env: any) => { - const type = env.type as VercelEnvironmentVariable["type"]; - // Secret and sensitive types cannot have their values retrieved - const isSecret = type === "secret" || type === "sensitive"; + return { + success: true, + data: envs.map((env: any) => { + const type = env.type as VercelEnvironmentVariable["type"]; + // Secret and sensitive types cannot have their values retrieved + const isSecret = type === "secret" || type === "sensitive"; - return { - id: env.id, - key: env.key, - type, - isSecret, - target: normalizeTarget(env.target), - }; - }); + return { + id: env.id, + key: env.key, + type, + isSecret, + target: normalizeTarget(env.target), + }; + }), + }; } catch (error) { + const authInvalid = isVercelAuthError(error); logger.error("Failed to fetch Vercel environment variables", { projectId, teamId, error, + authInvalid, }); - return []; + return { + success: false, + authInvalid, + error: error instanceof Error ? error.message : "Unknown error", + }; } } @@ -294,15 +360,13 @@ export class VercelIntegrationRepository { client: Vercel, teamId: string, projectId?: string // Optional: filter by project - ): Promise< - Array<{ + ): Promise - > { + }>>> { try { const response = await client.environment.listSharedEnvVariable({ teamId, @@ -311,27 +375,36 @@ export class VercelIntegrationRepository { const envVars = response.data || []; - return envVars.map((env) => { - const type = (env.type as string) || "plain"; - const isSecret = type === "secret" || type === "sensitive"; + return { + success: true, + data: envVars.map((env) => { + const type = (env.type as string) || "plain"; + const isSecret = type === "secret" || type === "sensitive"; - return { - id: env.id as string, - key: env.key as string, - type, - isSecret, - target: Array.isArray(env.target) - ? (env.target as string[]) - : [env.target].filter(Boolean) as string[], - }; - }); + return { + id: env.id as string, + key: env.key as string, + type, + isSecret, + target: Array.isArray(env.target) + ? (env.target as string[]) + : [env.target].filter(Boolean) as string[], + }; + }), + }; } catch (error) { + const authInvalid = isVercelAuthError(error); logger.error("Failed to fetch Vercel shared environment variables", { teamId, projectId, error, + authInvalid, }); - return []; + return { + success: false, + authInvalid, + error: error instanceof Error ? error.message : "Unknown error", + }; } } @@ -505,7 +578,7 @@ export class VercelIntegrationRepository { static async getVercelProjects( client: Vercel, teamId?: string | null - ): Promise> { + ): Promise>> { try { const response = await client.projects.getProjects({ ...(teamId && { teamId }), @@ -513,19 +586,84 @@ export class VercelIntegrationRepository { const projects = response.projects || []; - return projects.map((project: any) => ({ - id: project.id, - name: project.name, - })); + return { + success: true, + data: projects.map((project: any) => ({ + id: project.id, + name: project.name, + })), + }; } catch (error) { + const authInvalid = isVercelAuthError(error); logger.error("Failed to fetch Vercel projects", { teamId, error, + authInvalid, }); - return []; + return { + success: false, + authInvalid, + error: error instanceof Error ? error.message : "Unknown error", + }; } } + static async updateVercelOrgIntegrationToken(params: { + integrationId: string; + accessToken: string; + tokenType?: string; + teamId: string | null; + userId?: string; + installationId?: string; + raw?: Record; + }): Promise { + await $transaction(prisma, async (tx) => { + // Get the existing integration to find the token reference + const integration = await tx.organizationIntegration.findUnique({ + where: { id: params.integrationId }, + include: { tokenReference: true }, + }); + + if (!integration) { + throw new Error("Vercel integration not found"); + } + + const secretStore = getSecretStore(integration.tokenReference.provider, { + prismaClient: tx, + }); + + const secretValue: VercelSecret = { + accessToken: params.accessToken, + tokenType: params.tokenType, + teamId: params.teamId, + userId: params.userId, + installationId: params.installationId, + raw: params.raw, + }; + + logger.debug("Updating Vercel secret", { + integrationId: params.integrationId, + teamId: params.teamId, + installationId: params.installationId, + }); + + // Update the secret with new token + await secretStore.setSecret(integration.tokenReference.key, secretValue); + + // Update integration metadata + await tx.organizationIntegration.update({ + where: { id: params.integrationId }, + data: { + integrationData: { + teamId: params.teamId, + userId: params.userId, + installationId: params.installationId, + } as any, + }, + }); + }); + } + static async createVercelOrgIntegration(params: { accessToken: string; tokenType?: string; diff --git a/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts b/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts index 64111734fe..d958375c81 100644 --- a/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts @@ -21,6 +21,7 @@ type VercelSettingsOptions = { export type VercelSettingsResult = { enabled: boolean; hasOrgIntegration: boolean; + authInvalid?: boolean; connectedProject?: { id: string; vercelProjectId: string; @@ -43,167 +44,223 @@ export type VercelOnboardingData = { environmentVariables: VercelEnvironmentVariable[]; availableProjects: VercelAvailableProject[]; hasProjectSelected: boolean; + authInvalid?: boolean; }; export class VercelSettingsPresenter extends BasePresenter { /** * Get Vercel integration settings for the settings page */ - public call({ projectId, organizationId }: VercelSettingsOptions) { - const vercelIntegrationEnabled = OrgIntegrationRepository.isVercelSupported; + public async call({ projectId, organizationId }: VercelSettingsOptions) { + try { + const vercelIntegrationEnabled = OrgIntegrationRepository.isVercelSupported; - if (!vercelIntegrationEnabled) { - return ok({ - enabled: false, - hasOrgIntegration: false, - connectedProject: undefined, - isGitHubConnected: false, - hasStagingEnvironment: false, - } as VercelSettingsResult); - } + if (!vercelIntegrationEnabled) { + return ok({ + enabled: false, + hasOrgIntegration: false, + authInvalid: false, + connectedProject: undefined, + isGitHubConnected: false, + hasStagingEnvironment: false, + } as VercelSettingsResult); + } - // Check if org-level Vercel integration exists - const checkOrgIntegration = () => - fromPromise( - (this._replica as PrismaClient).organizationIntegration.findFirst({ - where: { - organizationId, - service: "VERCEL", - deletedAt: null, - }, - select: { - id: true, - }, - }), + const orgIntegration = await (this._replica as PrismaClient).organizationIntegration.findFirst({ + where: { + organizationId, + service: "VERCEL", + deletedAt: null, + }, + include: { + tokenReference: true, + }, + }); + + const hasOrgIntegration = orgIntegration !== null; + + if (hasOrgIntegration) { + const tokenValidation = await VercelIntegrationRepository.validateVercelToken(orgIntegration); + if (!tokenValidation.isValid) { + return ok({ + enabled: true, + hasOrgIntegration: true, + authInvalid: true, + connectedProject: undefined, + isGitHubConnected: false, + hasStagingEnvironment: false, + } as VercelSettingsResult); + } + } + + const checkOrgIntegration = () => fromPromise( + Promise.resolve(hasOrgIntegration), (error) => ({ type: "other" as const, cause: error, }) - ).map((orgIntegration) => orgIntegration !== null); - - // Check if GitHub is connected - const checkGitHubConnection = () => - fromPromise( - (this._replica as PrismaClient).connectedGithubRepository.findFirst({ - where: { - projectId, - repository: { - installation: { - deletedAt: null, - suspendedAt: null, + ); + + const checkGitHubConnection = () => + fromPromise( + (this._replica as PrismaClient).connectedGithubRepository.findFirst({ + where: { + projectId, + repository: { + installation: { + deletedAt: null, + suspendedAt: null, + }, }, }, - }, - select: { - id: true, - }, - }), - (error) => ({ - type: "other" as const, - cause: error, - }) - ).map((repo) => repo !== null); - - // Check if staging environment exists - const checkStagingEnvironment = () => - fromPromise( - (this._replica as PrismaClient).runtimeEnvironment.findFirst({ - select: { - id: true, - }, - where: { - projectId, - type: "STAGING", - }, - }), - (error) => ({ - type: "other" as const, - cause: error, - }) - ).map((env) => env !== null); - - // Get Vercel project integration - const getVercelProjectIntegration = () => - fromPromise( - (this._replica as PrismaClient).organizationProjectIntegration.findFirst({ - where: { - projectId, - deletedAt: null, - organizationIntegration: { - service: "VERCEL", + select: { + id: true, + }, + }), + (error) => ({ + type: "other" as const, + cause: error, + }) + ).map((repo) => repo !== null); + + // Check if staging environment exists + const checkStagingEnvironment = () => + fromPromise( + (this._replica as PrismaClient).runtimeEnvironment.findFirst({ + select: { + id: true, + }, + where: { + projectId, + type: "STAGING", + }, + }), + (error) => ({ + type: "other" as const, + cause: error, + }) + ).map((env) => env !== null); + + // Get Vercel project integration + const getVercelProjectIntegration = () => + fromPromise( + (this._replica as PrismaClient).organizationProjectIntegration.findFirst({ + where: { + projectId, deletedAt: null, + organizationIntegration: { + service: "VERCEL", + deletedAt: null, + }, }, - }, - include: { - organizationIntegration: true, - }, - }), - (error) => ({ - type: "other" as const, - cause: error, - }) - ).map((integration) => { - if (!integration) { - return undefined; - } + include: { + organizationIntegration: true, + }, + }), + (error) => ({ + type: "other" as const, + cause: error, + }) + ).map((integration) => { + if (!integration) { + return undefined; + } - const parsedData = VercelProjectIntegrationDataSchema.safeParse( - integration.integrationData - ); + const parsedData = VercelProjectIntegrationDataSchema.safeParse( + integration.integrationData + ); - if (!parsedData.success) { - return undefined; - } + if (!parsedData.success) { + return undefined; + } - return { - id: integration.id, - vercelProjectId: integration.externalEntityId, - vercelProjectName: parsedData.data.vercelProjectName, - vercelTeamId: parsedData.data.vercelTeamId, - integrationData: parsedData.data, - createdAt: integration.createdAt, - }; - }); + return { + id: integration.id, + vercelProjectId: integration.externalEntityId, + vercelProjectName: parsedData.data.vercelProjectName, + vercelTeamId: parsedData.data.vercelTeamId, + integrationData: parsedData.data, + createdAt: integration.createdAt, + }; + }); - return ResultAsync.combine([ - checkOrgIntegration(), - checkGitHubConnection(), - checkStagingEnvironment(), - getVercelProjectIntegration(), - ]).map(([hasOrgIntegration, isGitHubConnected, hasStagingEnvironment, connectedProject]) => ({ - enabled: true, - hasOrgIntegration, - connectedProject, - isGitHubConnected, - hasStagingEnvironment, - })); + try { + return ResultAsync.combine([ + checkOrgIntegration(), + checkGitHubConnection(), + checkStagingEnvironment(), + getVercelProjectIntegration(), + ]).map(([hasOrgIntegration, isGitHubConnected, hasStagingEnvironment, connectedProject]) => ({ + enabled: true, + hasOrgIntegration, + authInvalid: false, + connectedProject, + isGitHubConnected, + hasStagingEnvironment, + })).mapErr((error) => { + // Log the error and return a safe fallback + console.error("Error in VercelSettingsPresenter.call:", error); + return error; + }); + } catch (syncError) { + // Handle any synchronous errors that might occur + console.error("Synchronous error in VercelSettingsPresenter.call:", syncError); + return ok({ + enabled: true, + hasOrgIntegration: false, + authInvalid: true, + connectedProject: undefined, + isGitHubConnected: false, + hasStagingEnvironment: false, + } as VercelSettingsResult); + } + } catch (error) { + // If there's an unexpected error, log it and return a safe error result + console.error("Unexpected error in VercelSettingsPresenter.call:", error); + return ok({ + enabled: true, + hasOrgIntegration: false, + authInvalid: true, + connectedProject: undefined, + isGitHubConnected: false, + hasStagingEnvironment: false, + } as VercelSettingsResult); + } } /** * Get data needed for the onboarding modal (custom environments and env vars) */ public async getOnboardingData(projectId: string, organizationId: string): Promise { - // First, check if there's an org integration for this organization - const orgIntegration = await (this._replica as PrismaClient).organizationIntegration.findFirst({ - where: { - organizationId, - service: "VERCEL", - deletedAt: null, - }, - include: { - tokenReference: true, - }, - }); + try { + const orgIntegration = await (this._replica as PrismaClient).organizationIntegration.findFirst({ + where: { + organizationId, + service: "VERCEL", + deletedAt: null, + }, + include: { + tokenReference: true, + }, + }); - if (!orgIntegration) { - return null; - } + if (!orgIntegration) { + return null; + } - // Get the Vercel client - const client = await VercelIntegrationRepository.getVercelClient(orgIntegration); + const tokenValidation = await VercelIntegrationRepository.validateVercelToken(orgIntegration); + if (!tokenValidation.isValid) { + return { + customEnvironments: [], + environmentVariables: [], + availableProjects: [], + hasProjectSelected: false, + authInvalid: true, + }; + } - // Get the team ID from the secret - const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration); + const client = await VercelIntegrationRepository.getVercelClient(orgIntegration); + const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration); // Get the project integration to find the Vercel project ID (if selected) const projectIntegration = await (this._replica as PrismaClient).organizationProjectIntegration.findFirst({ @@ -218,20 +275,30 @@ export class VercelSettingsPresenter extends BasePresenter { }); // Always fetch available projects for selection - const availableProjects = await VercelIntegrationRepository.getVercelProjects(client, teamId); + const availableProjectsResult = await VercelIntegrationRepository.getVercelProjects(client, teamId); + + if (!availableProjectsResult.success) { + return { + customEnvironments: [], + environmentVariables: [], + availableProjects: [], + hasProjectSelected: false, + authInvalid: availableProjectsResult.authInvalid, + }; + } // If no project integration exists, return early with just available projects if (!projectIntegration) { return { customEnvironments: [], environmentVariables: [], - availableProjects, + availableProjects: availableProjectsResult.data, hasProjectSelected: false, }; } // Fetch custom environments, project env vars, and shared env vars in parallel - const [customEnvironments, projectEnvVars, sharedEnvVars] = await Promise.all([ + const [customEnvironmentsResult, projectEnvVarsResult, sharedEnvVarsResult] = await Promise.all([ VercelIntegrationRepository.getVercelCustomEnvironments( client, projectIntegration.externalEntityId, @@ -249,9 +316,29 @@ export class VercelSettingsPresenter extends BasePresenter { teamId, projectIntegration.externalEntityId ) - : Promise.resolve([]), + : Promise.resolve({ success: true as const, data: [] }), ]); + // Check if any of the API calls failed due to auth issues + const authInvalid = !customEnvironmentsResult.success && customEnvironmentsResult.authInvalid || + !projectEnvVarsResult.success && projectEnvVarsResult.authInvalid || + !sharedEnvVarsResult.success && sharedEnvVarsResult.authInvalid; + + if (authInvalid) { + return { + customEnvironments: [], + environmentVariables: [], + availableProjects: availableProjectsResult.data, + hasProjectSelected: true, + authInvalid: true, + }; + } + + // Extract data from successful results + const customEnvironments = customEnvironmentsResult.success ? customEnvironmentsResult.data : []; + const projectEnvVars = projectEnvVarsResult.success ? projectEnvVarsResult.data : []; + const sharedEnvVars = sharedEnvVarsResult.success ? sharedEnvVarsResult.data : []; + // Merge project and shared env vars (project vars take precedence) // Also filter out TRIGGER_SECRET_KEY as it's managed by Trigger.dev const projectEnvVarKeys = new Set(projectEnvVars.map((v) => v.key)); @@ -277,10 +364,13 @@ export class VercelSettingsPresenter extends BasePresenter { return { customEnvironments, environmentVariables: sortedEnvVars, - availableProjects, + availableProjects: availableProjectsResult.data, hasProjectSelected: true, }; - } - -} + } catch (error) { + // Log the error and return null to indicate failure + console.error("Error in getOnboardingData:", error); + return null; + } } +} \ No newline at end of file diff --git a/apps/webapp/app/routes/callback.vercel.ts b/apps/webapp/app/routes/callback.vercel.ts index e3342b7955..0a6563fce0 100644 --- a/apps/webapp/app/routes/callback.vercel.ts +++ b/apps/webapp/app/routes/callback.vercel.ts @@ -34,8 +34,31 @@ async function createOrFindVercelIntegration(params: { tokenResponse.teamId ?? null ); - // Create org integration if it doesn't exist - if (!orgIntegration) { + // If integration exists, update the token instead of creating new one + if (orgIntegration) { + logger.info("Updating existing Vercel integration token", { + integrationId: orgIntegration.id, + teamId: tokenResponse.teamId, + organizationId: project.organizationId, + }); + + await VercelIntegrationRepository.updateVercelOrgIntegrationToken({ + integrationId: orgIntegration.id, + accessToken: tokenResponse.accessToken, + tokenType: tokenResponse.tokenType, + teamId: tokenResponse.teamId ?? null, + userId: tokenResponse.userId, + installationId: configurationId, + raw: tokenResponse.raw, + }); + + // Re-fetch to get updated integration + orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationByTeamId( + project.organizationId, + tokenResponse.teamId ?? null + ); + } else { + // Create new org integration if it doesn't exist await VercelIntegrationRepository.createVercelOrgIntegration({ accessToken: tokenResponse.accessToken, tokenType: tokenResponse.tokenType, diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx index f0b613d9aa..227fa8de20 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx @@ -57,7 +57,10 @@ import { type VercelOnboardingData, } from "~/presenters/v3/VercelSettingsPresenter.server"; import { VercelIntegrationService } from "~/services/vercelIntegration.server"; -import { VercelIntegrationRepository } from "~/models/vercelIntegration.server"; +import { + VercelIntegrationRepository, + type VercelCustomEnvironment, +} from "~/models/vercelIntegration.server"; import { type VercelProjectIntegrationData, type SyncEnvVarsMapping, @@ -65,10 +68,6 @@ import { } from "~/v3/vercel/vercelProjectIntegrationSchema"; import { useEffect, useState, useCallback, useRef } from "react"; -// ============================================================================ -// Types -// ============================================================================ - export type ConnectedVercelProject = { id: string; vercelProjectId: string; @@ -78,14 +77,6 @@ export type ConnectedVercelProject = { createdAt: Date; }; -// ============================================================================ -// Helper Functions -// ============================================================================ - -/** - * Format Vercel target environments for display - * e.g., ["production", "preview"] → "Production, Preview" - */ function formatVercelTargets(targets: string[]): string { const targetLabels: Record = { production: "Production", @@ -98,9 +89,6 @@ function formatVercelTargets(targets: string[]): string { .join(", "); } -/** - * Look up the name (slug) of a Vercel custom environment by its ID - */ async function lookupVercelEnvironmentName( projectId: string, environmentId: string | null @@ -110,32 +98,31 @@ async function lookupVercelEnvironmentName( } try { - // Get the project integration const vercelService = new VercelIntegrationService(); const projectIntegration = await vercelService.getVercelProjectIntegration(projectId); if (!projectIntegration) { return null; } - // Get the org integration const orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationForProject(projectId); if (!orgIntegration) { return null; } - // Get the Vercel client const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration); const client = await VercelIntegrationRepository.getVercelClient(orgIntegration); - // Fetch custom environments - const customEnvironments = await VercelIntegrationRepository.getVercelCustomEnvironments( + const customEnvironmentsResult = await VercelIntegrationRepository.getVercelCustomEnvironments( client, projectIntegration.parsedIntegrationData.vercelProjectId, teamId ); - // Look up the name from the ID - const environment = customEnvironments.find((env) => env.id === environmentId); + if (!customEnvironmentsResult.success) { + return null; + } + + const environment = customEnvironmentsResult.data.find((env: VercelCustomEnvironment) => env.id === environmentId); return environment?.slug || null; } catch (error) { logger.error("Failed to look up Vercel environment name", { @@ -147,10 +134,6 @@ async function lookupVercelEnvironmentName( } } -// ============================================================================ -// Schemas -// ============================================================================ - const UpdateVercelConfigFormSchema = z.object({ action: z.literal("update-config"), pullEnvVarsFromVercel: z @@ -210,59 +193,66 @@ const VercelActionSchema = z.discriminatedUnion("action", [ UpdateEnvMappingFormSchema, ]); -// ============================================================================ -// Loader -// ============================================================================ - export async function loader({ request, params }: LoaderFunctionArgs) { - const userId = await requireUserId(request); - const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); + try { + const userId = await requireUserId(request); + const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); - const project = await findProjectBySlug(organizationSlug, projectParam, userId); - if (!project) { - throw new Response("Not Found", { status: 404 }); - } + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + throw new Response("Not Found", { status: 404 }); + } - const environment = await findEnvironmentBySlug(project.id, envParam, userId); - if (!environment) { - throw new Response("Not Found", { status: 404 }); - } + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + throw new Response("Not Found", { status: 404 }); + } - const presenter = new VercelSettingsPresenter(); - const resultOrFail = await presenter.call({ - projectId: project.id, - organizationId: project.organizationId, - }); + const presenter = new VercelSettingsPresenter(); + const resultOrFail = await presenter.call({ + projectId: project.id, + organizationId: project.organizationId, + }); - if (resultOrFail.isErr()) { - throw new Response("Failed to load Vercel settings", { status: 500 }); - } + if (!resultOrFail?.isOk()) { + throw new Response("Failed to load Vercel settings", { status: 500 }); + } - // Check if we need onboarding data - const url = new URL(request.url); - const needsOnboarding = url.searchParams.get("vercelOnboarding") === "true"; + const result = resultOrFail.value; + const url = new URL(request.url); + const needsOnboarding = url.searchParams.get("vercelOnboarding") === "true"; - let onboardingData: VercelOnboardingData | null = null; - if (needsOnboarding) { - // Always fetch onboarding data when in onboarding mode, even if no project selected yet - // This allows us to show the project selection step - onboardingData = await presenter.getOnboardingData(project.id, project.organizationId); - } + let onboardingData: VercelOnboardingData | null = null; + if (needsOnboarding) { + onboardingData = await presenter.getOnboardingData(project.id, project.organizationId); + } - return typedjson({ - ...resultOrFail.value, - onboardingData, - organizationSlug, - projectSlug: projectParam, - environmentSlug: envParam, - projectId: project.id, - organizationId: project.organizationId, - }); -} + const authInvalid = onboardingData?.authInvalid || result.authInvalid || false; + + return typedjson({ + ...result, + authInvalid: authInvalid || result.authInvalid, + onboardingData, + organizationSlug, + projectSlug: projectParam, + environmentSlug: envParam, + projectId: project.id, + organizationId: project.organizationId, + }); + } catch (error) { + if (error instanceof Response) { + throw error; + } -// ============================================================================ -// Action -// ============================================================================ + logger.error("Unexpected error in Vercel settings loader", { + url: request.url, + params, + error, + }); + + throw new Response("Internal Server Error", { status: 500 }); + } +} export async function action({ request, params }: ActionFunctionArgs) { const userId = await requireUserId(request); @@ -481,10 +471,6 @@ export async function action({ request, params }: ActionFunctionArgs) { return redirectBackWithErrorMessage(request, "Failed to process request"); } -// ============================================================================ -// Helper: Build resource URL for fetching Vercel data -// ============================================================================ - export function vercelResourcePath( organizationSlug: string, projectSlug: string, @@ -493,10 +479,6 @@ export function vercelResourcePath( return `/resources/orgs/${organizationSlug}/projects/${projectSlug}/env/${environmentSlug}/vercel`; } -// ============================================================================ -// Vercel Icon Component -// ============================================================================ - function VercelIcon({ className }: { className?: string }) { return ( void; isLoading?: boolean; }) { - // Generate the install path const installPath = vercelAppInstallPath(organizationSlug, projectSlug); - // Handle connecting project when org integration exists const handleConnectProject = () => { - // Just trigger the callback - the parent will handle loading and opening if (onOpenModal) { onOpenModal(); } }; const isLoadingProjects = isLoading ?? false; + const isDisabled = isLoadingProjects || !onOpenModal; return (
@@ -557,7 +530,7 @@ function VercelConnectionPrompt({
Vercel app is installed + {!onOpenModal && ( + + Please reconnect Vercel to continue + + )} ) : ( <> @@ -588,9 +566,38 @@ function VercelConnectionPrompt({ ); } -/** - * Warning banner when Vercel is connected but GitHub is not - */ +function VercelAuthInvalidBanner({ + organizationSlug, + projectSlug, +}: { + organizationSlug: string; + projectSlug: string; +}) { + const installUrl = vercelAppInstallPath(organizationSlug, projectSlug); + + return ( + +
+
+

+ Vercel connection expired +

+

+ Your Vercel access token has expired or been revoked. Please reconnect to restore functionality. +

+ + Reconnect Vercel + +
+
+
+ ); +} + function VercelGitHubWarning() { return ( @@ -602,9 +609,6 @@ function VercelGitHubWarning() { ); } -/** - * Connected Vercel project settings form - */ function ConnectedVercelProjectForm({ connectedProject, hasStagingEnvironment, @@ -888,10 +892,6 @@ function ConnectedVercelProjectForm({ ); } -// ============================================================================ -// Main Vercel Settings Panel Component -// ============================================================================ - function VercelSettingsPanel({ organizationSlug, projectSlug, @@ -907,14 +907,39 @@ function VercelSettingsPanel({ }) { const fetcher = useTypedFetcher(); const location = useLocation(); + const data = fetcher.data; + const [hasError, setHasError] = useState(false); + const [hasFetched, setHasFetched] = useState(false); useEffect(() => { - fetcher.load(vercelResourcePath(organizationSlug, projectSlug, environmentSlug)); - }, [organizationSlug, projectSlug, environmentSlug]); + if (!data?.authInvalid && !hasError && !data && !hasFetched) { + fetcher.load(vercelResourcePath(organizationSlug, projectSlug, environmentSlug)); + setHasFetched(true); + } + }, [organizationSlug, projectSlug, environmentSlug, data?.authInvalid, hasError, data, hasFetched]); - const data = fetcher.data; + useEffect(() => { + if (hasFetched && fetcher.state === "idle" && fetcher.data === undefined && !hasError) { + setHasError(true); + } + }, [fetcher.state, fetcher.data, hasError, hasFetched]); + + if (hasError) { + return ( +
+
+ +
+

Failed to load Vercel settings

+

+ There was an error loading the Vercel integration settings. Please refresh the page to try again. +

+
+
+
+ ); + } - // Loading state if (fetcher.state === "loading" && !data) { return (
@@ -924,63 +949,61 @@ function VercelSettingsPanel({ ); } - // Vercel integration not enabled if (!data || !data.enabled) { return null; } - // Show warning if Vercel connected but GitHub not const showGitHubWarning = data.connectedProject && !data.isGitHubConnected; + const showAuthInvalid = data.authInvalid || data.onboardingData?.authInvalid; - // Connected project exists - show form if (data.connectedProject) { return ( <> + {showAuthInvalid && } {showGitHubWarning && } - + />)} ); } - // No connected project - show connection prompt - // If org integration exists, show "app installed" message; otherwise show install button return (
- - - {data.hasOrgIntegration - ? "Connect your Vercel project to sync environment variables and trigger builds automatically." - : "Install the Vercel app to connect your projects and sync environment variables."} - - {!data.isGitHubConnected && ( - - GitHub integration is not connected. Vercel integration cannot sync environment variables or - spawn Trigger.dev builds without a properly installed GitHub integration. - + {showAuthInvalid && } + {!showAuthInvalid && ( + <> + + + {data.hasOrgIntegration + ? "Connect your Vercel project to sync environment variables and trigger builds automatically." + : "Install the Vercel app to connect your projects and sync environment variables."} + + {!data.isGitHubConnected && ( + + GitHub integration is not connected. Vercel integration cannot sync environment variables or + spawn Trigger.dev builds without a properly installed GitHub integration. + + )} + )}
); } -// ============================================================================ -// Onboarding Modal Component -// ============================================================================ - type OnboardingState = | "idle" // Initial state | "installing" // Redirecting to Vercel installation (transient) @@ -1027,13 +1050,10 @@ function VercelOnboardingModal({ const envVars = onboardingData?.environmentVariables || []; const hasCustomEnvs = customEnvironments.length > 0 && hasStagingEnvironment; - // Compute initial state based on current data const computeInitialState = useCallback((): OnboardingState => { - // If no org integration, stay in idle (shouldn't happen as modal only opens with integration) - if (!hasOrgIntegration) { + if (!hasOrgIntegration || onboardingData?.authInvalid) { return "idle"; } - // If no project selected, check if we need to load projects const projectSelected = onboardingData?.hasProjectSelected ?? false; if (!projectSelected) { if (!onboardingData?.availableProjects || onboardingData.availableProjects.length === 0) { @@ -1041,12 +1061,10 @@ function VercelOnboardingModal({ } return "project-selection"; } - // Project selected, check for custom environments const customEnvs = (onboardingData?.customEnvironments?.length ?? 0) > 0 && hasStagingEnvironment; if (customEnvs) { return "env-mapping"; } - // No custom envs, check if env vars are loaded if (!onboardingData?.environmentVariables || onboardingData.environmentVariables.length === 0) { return "loading-env-vars"; } @@ -1082,11 +1100,14 @@ function VercelOnboardingModal({ const [expandedEnvVars, setExpandedEnvVars] = useState(false); const [projectSelectionError, setProjectSelectionError] = useState(null); - // State machine processor: handles state transitions based on current state and available data - // Note: "idle" state is only when modal is closed, so we don't process it here useEffect(() => { if (!isOpen || state === "idle") { - return; // Don't process when modal is closed or in idle state + return; + } + + if (onboardingData?.authInvalid) { + onClose(); + return; } switch (state) { @@ -1127,18 +1148,18 @@ function VercelOnboardingModal({ // Watch for data loading completion useEffect(() => { - if (state === "loading-projects" && onboardingData?.availableProjects && onboardingData.availableProjects.length > 0) { + if (!onboardingData?.authInvalid && state === "loading-projects" && onboardingData?.availableProjects && onboardingData.availableProjects.length > 0) { // Projects loaded, transition to project selection setState("project-selection"); } - }, [state, onboardingData?.availableProjects]); + }, [state, onboardingData?.availableProjects, onboardingData?.authInvalid]); useEffect(() => { - if (state === "loading-env-vars" && onboardingData?.environmentVariables) { + if (!onboardingData?.authInvalid && state === "loading-env-vars" && onboardingData?.environmentVariables) { // Environment variables loaded, transition to env-var-sync setState("env-var-sync"); } - }, [state, onboardingData?.environmentVariables]); + }, [state, onboardingData?.environmentVariables, onboardingData?.authInvalid]); // Handle successful project selection - transition to loading-env-mapping useEffect(() => { @@ -1210,7 +1231,6 @@ function VercelOnboardingModal({ [] ); - // Handle project selection submission - explicit state transition const handleProjectSelection = useCallback(async () => { if (!selectedVercelProject) { setProjectSelectionError("Please select a Vercel project"); @@ -1219,7 +1239,6 @@ function VercelOnboardingModal({ setProjectSelectionError(null); - // Submit the form programmatically using fetcher const formData = new FormData(); formData.append("action", "select-vercel-project"); formData.append("vercelProjectId", selectedVercelProject.id); @@ -1229,16 +1248,12 @@ function VercelOnboardingModal({ method: "post", action: actionUrl, }); - // State transition to loading-env-mapping will happen in useEffect when success }, [selectedVercelProject, fetcher, actionUrl]); - // Handle skip onboarding submission - close modal immediately and submit in background const handleSkipOnboarding = useCallback(() => { - // Close modal immediately onClose(); - // Submit in background (non-blocking) const formData = new FormData(); formData.append("action", "skip-onboarding"); fetcher.submit(formData, { @@ -1247,13 +1262,11 @@ function VercelOnboardingModal({ }); }, [actionUrl, fetcher, onClose]); - // Handle environment mapping update - explicit state transition const handleUpdateEnvMapping = useCallback(() => { const formData = new FormData(); formData.append("action", "update-env-mapping"); if (vercelStagingEnvironment) { formData.append("vercelStagingEnvironment", vercelStagingEnvironment); - // Look up the name from customEnvironments const environment = customEnvironments.find((env) => env.id === vercelStagingEnvironment); if (environment) { formData.append("vercelStagingName", environment.slug); @@ -1263,10 +1276,8 @@ function VercelOnboardingModal({ method: "post", action: actionUrl, }); - // State transition to loading-env-vars will happen in useEffect when success }, [vercelStagingEnvironment, customEnvironments, envMappingFetcher, actionUrl]); - // Handle Finish button - submit form via fetcher const handleFinishOnboarding = useCallback((e: React.FormEvent) => { e.preventDefault(); const form = e.currentTarget; @@ -1313,13 +1324,10 @@ function VercelOnboardingModal({ } }, [envMappingFetcher.data, envMappingFetcher.state]); - // Don't render if modal is closed - if (!isOpen) { + if (!isOpen || onboardingData?.authInvalid) { return null; } - // Show loading state for loading states or if data is not ready yet - // Note: "idle" is only when modal is closed, so we don't show loading for it const isLoadingState = state === "loading-projects" || state === "loading-env-mapping" || @@ -1345,7 +1353,6 @@ function VercelOnboardingModal({ ); } - // Determine which step content to show based on state machine const showProjectSelection = state === "project-selection"; const showEnvMapping = state === "env-mapping"; const showEnvVarSync = state === "env-var-sync"; @@ -1361,7 +1368,6 @@ function VercelOnboardingModal({
- {/* Step: Project Selection (only if no project selected) */} {showProjectSelection && (
Select Vercel Project @@ -1430,7 +1436,6 @@ function VercelOnboardingModal({
)} - {/* Step: Environment Mapping (only if custom environments exist) */} {showEnvMapping && (
Map Vercel Environment to Staging @@ -1492,7 +1497,6 @@ function VercelOnboardingModal({
)} - {/* Step: Environment Variables Sync */} {showEnvVarSync && ( From 2f926d7c46a66f2709c21a29007461e9247602fb Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Mon, 12 Jan 2026 21:19:56 +0100 Subject: [PATCH 11/33] fix(vercel): surface auth failures and handle uninstall flows - return uninstall result from uninstallVercelIntegration so caller can react when Vercel rejects the request due to invalid credentials. - detect auth errors more robustly in isVercelAuthError by checking statusCode fields on non-Axios error shapes. - treat 401/403 from Vercel as soft-fail: log a warning and continue to clean up local DB records instead of aborting, and surface authInvalid flag to the route handler. - log a warning when uninstall succeeds but the token is invalid; log normal info for successful uninstalls. --- .../app/models/vercelIntegration.server.ts | 15 ++++++++++- ...ationSlug.settings.integrations.vercel.tsx | 25 +++++++++++++------ apps/webapp/app/routes/callback.vercel.ts | 5 ++-- 3 files changed, 33 insertions(+), 12 deletions(-) diff --git a/apps/webapp/app/models/vercelIntegration.server.ts b/apps/webapp/app/models/vercelIntegration.server.ts index 4ee05798e6..d20ce6801a 100644 --- a/apps/webapp/app/models/vercelIntegration.server.ts +++ b/apps/webapp/app/models/vercelIntegration.server.ts @@ -80,6 +80,10 @@ function isVercelAuthError(error: unknown): boolean { const response = (error as { response?: { status?: number } }).response; return response?.status === 401 || response?.status === 403; } + if (error && typeof error === 'object' && 'statusCode' in error) { + const statusCode = (error as { statusCode?: number }).statusCode; + return statusCode === 401 || statusCode === 403; + } if (error && typeof error === 'string' && (error.includes('401') || error.includes('403'))) { return true; } @@ -1440,7 +1444,7 @@ export class VercelIntegrationRepository { static async uninstallVercelIntegration( integration: OrganizationIntegration & { tokenReference: SecretReference } - ): Promise { + ): Promise<{ authInvalid: boolean }> { const client = await this.getVercelClient(integration); const secret = await getSecretStore(integration.tokenReference.provider).getSecret( @@ -1456,11 +1460,20 @@ export class VercelIntegrationRepository { await client.integrations.deleteConfiguration({ id: secret.installationId, }); + return { authInvalid: false }; } catch (error) { + const isAuthError = isVercelAuthError(error); logger.error("Failed to uninstall Vercel integration", { installationId: secret.installationId, error: error instanceof Error ? error.message : "Unknown error", + isAuthError, }); + + // If it's an auth error (401/403), we should still clean up our side + // but return the flag so caller knows the token is invalid + if (isAuthError) { + return { authInvalid: true }; + } throw error; } } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx index bba21a1cea..1251150691 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx @@ -141,8 +141,8 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { } try { - // First, uninstall the integration from Vercel side - await VercelIntegrationRepository.uninstallVercelIntegration(vercelIntegration); + // First, attempt to uninstall the integration from Vercel side + const uninstallResult = await VercelIntegrationRepository.uninstallVercelIntegration(vercelIntegration); // Then soft-delete the integration and all connected projects in a transaction await $transaction(prisma, async (tx) => { @@ -162,12 +162,21 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { }); }); - logger.info("Vercel integration uninstalled successfully", { - organizationId: organization.id, - organizationSlug, - userId, - integrationId: vercelIntegration.id, - }); + if (uninstallResult.authInvalid) { + logger.warn("Vercel integration uninstalled with auth error - token invalid", { + organizationId: organization.id, + organizationSlug, + userId, + integrationId: vercelIntegration.id, + }); + } else { + logger.info("Vercel integration uninstalled successfully", { + organizationId: organization.id, + organizationSlug, + userId, + integrationId: vercelIntegration.id, + }); + } // Redirect back to organization settings return redirect(`/orgs/${organizationSlug}/settings`); diff --git a/apps/webapp/app/routes/callback.vercel.ts b/apps/webapp/app/routes/callback.vercel.ts index 0a6563fce0..f73a8bc017 100644 --- a/apps/webapp/app/routes/callback.vercel.ts +++ b/apps/webapp/app/routes/callback.vercel.ts @@ -140,10 +140,9 @@ export async function loader({ request }: LoaderFunctionArgs) { const { code, state, error, error_description, configurationId, next: nextUrl } = parsedParams.data; // Handle errors from Vercel - if (error) { + if (error && true) { logger.error("Vercel OAuth error", { error, error_description }); - // Redirect to a generic error page or back to settings - return redirect(`/?error=${encodeURIComponent(error_description || error)}`); + throw new Response("Vercel OAuth error", { status: 500 }); } // Validate required parameters From fb591e131ee40c51775d91cee101032e91f2f345 Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Mon, 12 Jan 2026 21:39:38 +0100 Subject: [PATCH 12/33] feat(vercel): implement robust OAuth callback handling Add typed structures, schema validation, and token exchange helper for the Vercel OAuth callback flow. Introduce TokenResponse, ProjectWithOrganization, StateData, and CallbackParams types to make intent explicit and reduce implicit any usage. Add VercelCallbackSchema (zod) to validate incoming callback query parameters and ensure safer parsing of code/state/error payloads. Implement exchangeCodeForToken helper that posts to Vercel's token endpoint, handles errors, logs failures, and returns a normalized token shape (including raw response). This centralizes HTTP logic and improves error handling. Refactor createOrFindVercelIntegration to use the new TokenResponse and ProjectWithOrganization types, simplify flow comments, and avoid repetitive code by updating/creating integrations with consistent data. Overall this change improves type safety, error reporting, and readability of the Vercel OAuth callback route. --- apps/webapp/app/routes/callback.vercel.ts | 742 ++++++++++++---------- 1 file changed, 398 insertions(+), 344 deletions(-) diff --git a/apps/webapp/app/routes/callback.vercel.ts b/apps/webapp/app/routes/callback.vercel.ts index f73a8bc017..9f91285992 100644 --- a/apps/webapp/app/routes/callback.vercel.ts +++ b/apps/webapp/app/routes/callback.vercel.ts @@ -9,32 +9,131 @@ import { logger } from "~/services/logger.server"; import { getUserId, requireUserId } from "~/services/session.server"; import { setReferralSourceCookie } from "~/services/referralSource.server"; import { requestUrl } from "~/utils/requestUrl.server"; -import { v3ProjectSettingsPath, confirmBasicDetailsPath, newProjectPath } from "~/utils/pathBuilder"; +import { + v3ProjectSettingsPath, + confirmBasicDetailsPath, + newProjectPath, +} from "~/utils/pathBuilder"; import { validateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server"; +// ============================================================================ +// Types +// ============================================================================ + +type TokenResponse = { + accessToken: string; + tokenType: string; + teamId?: string; + userId?: string; + raw: Record; +}; + +type ProjectWithOrganization = { + id: string; + organizationId: string; + organization: { id: string }; +}; + +type StateData = { + organizationId: string; + projectId: string; + environmentSlug: string; + organizationSlug: string; + projectSlug: string; +}; + +type CallbackParams = { + code: string; + state?: string; + configurationId?: string; + nextUrl?: string; +}; + +// ============================================================================ +// Schema +// ============================================================================ + +const VercelCallbackSchema = z + .object({ + code: z.string().optional(), + state: z.string().optional(), + error: z.string().optional(), + error_description: z.string().optional(), + configurationId: z.string().optional(), + teamId: z.string().nullable().optional(), + next: z.string().optional(), + }) + .passthrough(); + +// ============================================================================ +// Shared Utilities +// ============================================================================ + +async function exchangeCodeForToken(code: string): Promise { + const clientId = env.VERCEL_INTEGRATION_CLIENT_ID; + const clientSecret = env.VERCEL_INTEGRATION_CLIENT_SECRET; + const redirectUri = `${env.APP_ORIGIN}/callback/vercel`; + + if (!clientId || !clientSecret) { + logger.error("Vercel integration not configured - missing client ID or secret"); + return null; + } + + try { + const response = await fetch("https://api.vercel.com/v2/oauth/access_token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + code, + redirect_uri: redirectUri, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + logger.error("Failed to exchange Vercel OAuth code", { + status: response.status, + error: errorText, + }); + return null; + } + + const data = (await response.json()) as { + access_token: string; + token_type: string; + team_id?: string; + user_id?: string; + }; + + return { + accessToken: data.access_token, + tokenType: data.token_type, + teamId: data.team_id, + userId: data.user_id, + raw: data as Record, + }; + } catch (error) { + logger.error("Error exchanging Vercel OAuth code", { error }); + return null; + } +} + async function createOrFindVercelIntegration(params: { - tokenResponse: { - accessToken: string; - tokenType: string; - teamId?: string; - userId?: string; - raw: Record; - }; - project: { - organizationId: string; - organization: { id: string; }; - }; + tokenResponse: TokenResponse; + project: ProjectWithOrganization; configurationId?: string; }) { const { tokenResponse, project, configurationId } = params; - - // Check if we already have a Vercel org integration for this team + let orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationByTeamId( project.organizationId, tokenResponse.teamId ?? null ); - // If integration exists, update the token instead of creating new one if (orgIntegration) { logger.info("Updating existing Vercel integration token", { integrationId: orgIntegration.id, @@ -52,13 +151,11 @@ async function createOrFindVercelIntegration(params: { raw: tokenResponse.raw, }); - // Re-fetch to get updated integration orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationByTeamId( project.organizationId, tokenResponse.teamId ?? null ); } else { - // Create new org integration if it doesn't exist await VercelIntegrationRepository.createVercelOrgIntegration({ accessToken: tokenResponse.accessToken, tokenType: tokenResponse.tokenType, @@ -69,7 +166,6 @@ async function createOrFindVercelIntegration(params: { raw: tokenResponse.raw, }); - // Re-fetch to get the full integration with tokenReference orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationByTeamId( project.organizationId, tokenResponse.teamId ?? null @@ -83,258 +179,242 @@ async function createOrFindVercelIntegration(params: { return orgIntegration; } -const VercelCallbackSchema = z - .object({ - // OAuth authorization code - code: z.string().optional(), - // State parameter for CSRF protection (contains org/project info) - state: z.string().optional(), - // Error from Vercel - error: z.string().optional(), - error_description: z.string().optional(), - // Vercel configuration ID - configurationId: z.string().optional(), - // Team ID if installed on a team (null for personal account) - teamId: z.string().nullable().optional(), - // The next URL Vercel wants us to redirect to (optional) - next: z.string().optional(), - }) - .passthrough(); - +async function fetchProjectWithAccess( + projectId: string, + organizationId: string, + userId: string +): Promise { + return prisma.project.findFirst({ + where: { + id: projectId, + organizationId, + deletedAt: null, + organization: { + members: { + some: { + userId, + }, + }, + }, + }, + include: { + organization: true, + }, + }); +} -// Vercel OAuth callback handler -// Flow: Connect button → Vercel marketplace → user authorizes → callback with code → exchange for token → create integration -export async function loader({ request }: LoaderFunctionArgs) { - if (request.method.toUpperCase() !== "GET") { - return new Response("Method Not Allowed", { status: 405 }); - } +function buildSettingsRedirectUrl(stateData: StateData, nextUrl?: string): string { + const settingsPath = v3ProjectSettingsPath( + { slug: stateData.organizationSlug }, + { slug: stateData.projectSlug }, + { slug: stateData.environmentSlug } + ); - // Check if user is authenticated - const userId = await getUserId(request); - - // If not authenticated, set referral source cookie and redirect to login - // Preserve all search params (code, configurationId, etc.) in the redirectTo - if (!userId) { - const currentUrl = new URL(request.url); - // Preserve the full URL including all search params - const redirectTo = `${currentUrl.pathname}${currentUrl.search}`; - const referralCookie = await setReferralSourceCookie("vercel"); - - const headers = new Headers(); - headers.append("Set-Cookie", referralCookie); - - throw redirect(`/login?redirectTo=${encodeURIComponent(redirectTo)}`, { headers }); + const params = new URLSearchParams({ vercelOnboarding: "true" }); + if (nextUrl) { + params.set("next", nextUrl); } - // User is authenticated, proceed with OAuth callback - const authenticatedUserId = await requireUserId(request); - - const url = requestUrl(request); - const parsedParams = VercelCallbackSchema.safeParse(Object.fromEntries(url.searchParams)); + return `${settingsPath}?${params.toString()}`; +} - if (!parsedParams.success) { - logger.error("Invalid Vercel callback params", { error: parsedParams.error }); - throw new Response("Invalid callback parameters", { status: 400 }); - } +async function completeIntegrationSetup(params: { + tokenResponse: TokenResponse; + project: ProjectWithOrganization; + stateData: StateData; + configurationId?: string; + nextUrl?: string; + request: Request; + logContext: string; +}): Promise { + const { tokenResponse, project, stateData, configurationId, nextUrl, request, logContext } = + params; - const { code, state, error, error_description, configurationId, next: nextUrl } = parsedParams.data; + try { + await createOrFindVercelIntegration({ + tokenResponse, + project, + configurationId, + }); - // Handle errors from Vercel - if (error && true) { - logger.error("Vercel OAuth error", { error, error_description }); - throw new Response("Vercel OAuth error", { status: 500 }); - } + logger.info(`Vercel organization integration created successfully ${logContext}`, { + organizationId: project.organizationId, + projectId: project.id, + teamId: tokenResponse.teamId, + }); - // Validate required parameters - if (!code) { - logger.error("Missing authorization code from Vercel callback"); - throw new Response("Missing authorization code", { status: 400 }); + return redirect(buildSettingsRedirectUrl(stateData, nextUrl)); + } catch (error) { + logger.error(`Failed to create Vercel integration ${logContext}`, { error }); + return redirectWithErrorMessage( + v3ProjectSettingsPath( + { slug: stateData.organizationSlug }, + { slug: stateData.projectSlug }, + { slug: stateData.environmentSlug } + ), + request, + "Failed to create Vercel integration. Please try again." + ); } +} - // Handle case when state is missing (Vercel-side installation) - if (!state) { - if (!configurationId) { - logger.error("Missing both state and configurationId from Vercel callback"); - throw new Response("Missing state or configurationId parameter", { status: 400 }); - } - - // Check if user has organizations - const userOrganizations = await prisma.organization.findMany({ - where: { - members: { - some: { - userId: authenticatedUserId, - }, +// ============================================================================ +// Marketplace-Invoked Flow (without state) +// User installs from Vercel marketplace, no state parameter present +// ============================================================================ + +async function handleMarketplaceInvokedFlow(params: { + code: string; + configurationId: string; + nextUrl?: string; + userId: string; + request: Request; +}): Promise { + const { code, configurationId, nextUrl, userId, request } = params; + + const userOrganizations = await prisma.organization.findMany({ + where: { + members: { + some: { + userId, }, - deletedAt: null, }, - include: { - projects: { - where: { - deletedAt: null, - }, + deletedAt: null, + }, + include: { + projects: { + where: { + deletedAt: null, }, }, - }); - - // If user has no organizations, redirect to onboarding - if (userOrganizations.length === 0) { - const params = new URLSearchParams({ - code, - configurationId, - integration: "vercel", - }); - if (nextUrl && !state) { - params.set("next", nextUrl); - } - const onboardingUrl = `${confirmBasicDetailsPath()}?${params.toString()}`; - return redirect(onboardingUrl); - } + }, + }); - // If user has organizations but no projects, redirect to project creation - const hasProjects = userOrganizations.some((org) => org.projects.length > 0); - if (!hasProjects) { - // Redirect to the first organization's project creation page - const firstOrg = userOrganizations[0]; - const params = new URLSearchParams({ - code, - configurationId, - integration: "vercel", - }); - if (nextUrl && !state) { - params.set("next", nextUrl); - } - const projectUrl = `${newProjectPath({ slug: firstOrg.slug })}?${params.toString()}`; - return redirect(projectUrl); + // No organizations - redirect to onboarding + if (userOrganizations.length === 0) { + const onboardingParams = new URLSearchParams({ + code, + configurationId, + integration: "vercel", + }); + if (nextUrl) { + onboardingParams.set("next", nextUrl); } + return redirect(`${confirmBasicDetailsPath()}?${onboardingParams.toString()}`); + } - // User has orgs and projects - handle the installation after onboarding - // Exchange code for access token first - const tokenResponse = await exchangeCodeForToken(code); - if (!tokenResponse) { - return redirectWithErrorMessage( - "/", - request, - "Failed to connect to Vercel. Please try again." - ); + // Has organizations but no projects - redirect to project creation + const hasProjects = userOrganizations.some((org) => org.projects.length > 0); + if (!hasProjects) { + const firstOrg = userOrganizations[0]; + const projectParams = new URLSearchParams({ + code, + configurationId, + integration: "vercel", + }); + if (nextUrl) { + projectParams.set("next", nextUrl); } + return redirect(`${newProjectPath({ slug: firstOrg.slug })}?${projectParams.toString()}`); + } - // Fetch configuration from Vercel - const config = await VercelIntegrationRepository.getVercelIntegrationConfiguration( - tokenResponse.accessToken, - configurationId, - tokenResponse.teamId ?? null + // User has orgs and projects - complete the installation + const tokenResponse = await exchangeCodeForToken(code); + if (!tokenResponse) { + return redirectWithErrorMessage( + "/", + request, + "Failed to connect to Vercel. Please try again." ); + } - if (!config) { - return redirectWithErrorMessage( - "/", - request, - "Failed to fetch Vercel integration configuration. Please try again." - ); - } + const config = await VercelIntegrationRepository.getVercelIntegrationConfiguration( + tokenResponse.accessToken, + configurationId, + tokenResponse.teamId ?? null + ); - // Get user's first organization and project - const userOrg = userOrganizations[0]; - const userProject = userOrg.projects[0]; + if (!config) { + return redirectWithErrorMessage( + "/", + request, + "Failed to fetch Vercel integration configuration. Please try again." + ); + } - if (!userProject) { - // This shouldn't happen since we checked above, but handle it anyway - const projectUrl = `${newProjectPath({ slug: userOrg.slug })}?code=${encodeURIComponent(code)}&configurationId=${encodeURIComponent(configurationId)}&integration=vercel`; - return redirect(projectUrl); - } + const userOrg = userOrganizations[0]; + const userProject = userOrg.projects[0]; - // Get the default environment (prod) - const environment = await prisma.runtimeEnvironment.findFirst({ - where: { - projectId: userProject.id, - slug: "prod", - archivedAt: null, - }, + if (!userProject) { + const projectParams = new URLSearchParams({ + code, + configurationId, + integration: "vercel", }); + return redirect(`${newProjectPath({ slug: userOrg.slug })}?${projectParams.toString()}`); + } - if (!environment) { - return redirectWithErrorMessage( - "/", - request, - "Failed to find project environment. Please try again." - ); - } - - // Now proceed with the normal flow using the generated state - // We'll use the stateData from the generated state - const stateData = { - organizationId: userOrg.id, + const environment = await prisma.runtimeEnvironment.findFirst({ + where: { projectId: userProject.id, - environmentSlug: environment.slug, - organizationSlug: userOrg.slug, - projectSlug: userProject.slug, - }; - - const project = await prisma.project.findFirst({ - where: { - id: stateData.projectId, - organizationId: stateData.organizationId, - deletedAt: null, - organization: { - members: { - some: { - userId: authenticatedUserId, - }, - }, - }, - }, - include: { - organization: true, - }, - }); + slug: "prod", + archivedAt: null, + }, + }); - if (!project) { - logger.error("Project not found or user does not have access", { - projectId: stateData.projectId, - userId: authenticatedUserId, - }); - throw new Response("Project not found", { status: 404 }); - } + if (!environment) { + return redirectWithErrorMessage( + "/", + request, + "Failed to find project environment. Please try again." + ); + } - // Create the integration - try { - await createOrFindVercelIntegration({ - tokenResponse, - project, - configurationId, - }); + const stateData: StateData = { + organizationId: userOrg.id, + projectId: userProject.id, + environmentSlug: environment.slug, + organizationSlug: userOrg.slug, + projectSlug: userProject.slug, + }; - logger.info("Vercel organization integration created successfully after onboarding", { - organizationId: project.organizationId, - projectId: project.id, - teamId: tokenResponse.teamId, - }); + const project = await fetchProjectWithAccess(stateData.projectId, stateData.organizationId, userId); - // Redirect to settings page with onboarding query parameter - const settingsPath = v3ProjectSettingsPath( - { slug: stateData.organizationSlug }, - { slug: stateData.projectSlug }, - { slug: stateData.environmentSlug } - ); - - const params = new URLSearchParams({ vercelOnboarding: "true" }); - if (nextUrl && !state) { - params.set("next", nextUrl); - } - return redirect(`${settingsPath}?${params.toString()}`); - } catch (error) { - logger.error("Failed to create Vercel integration after onboarding", { error }); - return redirectWithErrorMessage( - "/", - request, - "Failed to create Vercel integration. Please try again." - ); - } + if (!project) { + logger.error("Project not found or user does not have access", { + projectId: stateData.projectId, + userId, + }); + throw new Response("Project not found", { status: 404 }); } - // Validate and decode JWT state (existing flow) - const validationResult = await validateVercelOAuthState(state!); + return completeIntegrationSetup({ + tokenResponse, + project, + stateData, + configurationId, + nextUrl, + request, + logContext: "after marketplace installation", + }); +} + +// ============================================================================ +// Self-Invoked Flow (with state) +// User clicks connect button from our app, state parameter contains project info +// ============================================================================ + +async function handleSelfInvokedFlow(params: { + code: string; + state: string; + configurationId?: string; + nextUrl?: string; + userId: string; + request: Request; +}): Promise { + const { code, state, configurationId, nextUrl, userId, request } = params; + + const validationResult = await validateVercelOAuthState(state); if (!validationResult.ok) { logger.error("Invalid Vercel OAuth state JWT", { @@ -345,34 +425,16 @@ export async function loader({ request }: LoaderFunctionArgs) { const stateData = validationResult.state; - // Verify user has access to the organization and project - const project = await prisma.project.findFirst({ - where: { - id: stateData.projectId, - organizationId: stateData.organizationId, - deletedAt: null, - organization: { - members: { - some: { - userId: authenticatedUserId, - }, - }, - }, - }, - include: { - organization: true, - }, - }); + const project = await fetchProjectWithAccess(stateData.projectId, stateData.organizationId, userId); if (!project) { logger.error("Project not found or user does not have access", { projectId: stateData.projectId, - userId: authenticatedUserId, + userId, }); throw new Response("Project not found", { status: 404 }); } - // Exchange authorization code for access token const tokenResponse = await exchangeCodeForToken(code); if (!tokenResponse) { return redirectWithErrorMessage( @@ -386,104 +448,96 @@ export async function loader({ request }: LoaderFunctionArgs) { ); } - try { - await createOrFindVercelIntegration({ - tokenResponse, - project, - configurationId, - }); + return completeIntegrationSetup({ + tokenResponse, + project, + stateData, + configurationId, + nextUrl, + request, + logContext: "via self-invoked flow", + }); +} - // The OrganizationIntegration is now created. - // The user will select a Vercel project during onboarding, which will create the - // OrganizationProjectIntegration record and sync API keys to Vercel. +// ============================================================================ +// Main Loader +// ============================================================================ - logger.info("Vercel organization integration created successfully", { - organizationId: project.organizationId, - projectId: project.id, - teamId: tokenResponse.teamId, - }); +export async function loader({ request }: LoaderFunctionArgs) { + if (request.method.toUpperCase() !== "GET") { + return new Response("Method Not Allowed", { status: 405 }); + } - // Redirect to settings page with onboarding query parameter - const settingsPath = v3ProjectSettingsPath( - { slug: stateData.organizationSlug }, - { slug: stateData.projectSlug }, - { slug: stateData.environmentSlug } - ); + // Check authentication - redirect to login if not authenticated + const userId = await getUserId(request); - const params = new URLSearchParams({ vercelOnboarding: "true" }); - if (nextUrl && !state) { - params.set("next", nextUrl); - } - return redirect(`${settingsPath}?${params.toString()}`); - } catch (error) { - logger.error("Failed to create Vercel integration", { error }); - return redirectWithErrorMessage( - v3ProjectSettingsPath( - { slug: stateData.organizationSlug }, - { slug: stateData.projectSlug }, - { slug: stateData.environmentSlug } - ), - request, - "Failed to create Vercel integration. Please try again." - ); + if (!userId) { + const currentUrl = new URL(request.url); + const redirectTo = `${currentUrl.pathname}${currentUrl.search}`; + const referralCookie = await setReferralSourceCookie("vercel"); + + const headers = new Headers(); + headers.append("Set-Cookie", referralCookie); + + throw redirect(`/login?redirectTo=${encodeURIComponent(redirectTo)}`, { headers }); } -} -async function exchangeCodeForToken(code: string): Promise<{ - accessToken: string; - tokenType: string; - teamId?: string; - userId?: string; - raw: Record; -} | null> { - const clientId = env.VERCEL_INTEGRATION_CLIENT_ID; - const clientSecret = env.VERCEL_INTEGRATION_CLIENT_SECRET; - const redirectUri = `${env.APP_ORIGIN}/callback/vercel`; + const authenticatedUserId = await requireUserId(request); - if (!clientId || !clientSecret) { - logger.error("Vercel integration not configured - missing client ID or secret"); - return null; + // Parse and validate callback parameters + const url = requestUrl(request); + const parsedParams = VercelCallbackSchema.safeParse(Object.fromEntries(url.searchParams)); + + if (!parsedParams.success) { + logger.error("Invalid Vercel callback params", { error: parsedParams.error }); + throw new Response("Invalid callback parameters", { status: 400 }); } - try { - const response = await fetch("https://api.vercel.com/v2/oauth/access_token", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - client_id: clientId, - client_secret: clientSecret, - code, - redirect_uri: redirectUri, - }), - }); + const { + code, + state, + error, + error_description, + configurationId, + next: nextUrl, + } = parsedParams.data; - if (!response.ok) { - const errorText = await response.text(); - logger.error("Failed to exchange Vercel OAuth code", { - status: response.status, - error: errorText, - }); - return null; - } + // Handle errors from Vercel + if (error) { + logger.error("Vercel OAuth error", { error, error_description }); + throw new Response("Vercel OAuth error", { status: 500 }); + } - const data = (await response.json()) as { - access_token: string; - token_type: string; - team_id?: string; - user_id?: string; - }; + // Validate authorization code is present + if (!code) { + logger.error("Missing authorization code from Vercel callback"); + throw new Response("Missing authorization code", { status: 400 }); + } - return { - accessToken: data.access_token, - tokenType: data.token_type, - teamId: data.team_id, - userId: data.user_id, - raw: data as Record, - }; - } catch (error) { - logger.error("Error exchanging Vercel OAuth code", { error }); - return null; + // Route to appropriate handler based on presence of state parameter + if (state) { + // Self-invoked flow: user clicked connect from our app + return handleSelfInvokedFlow({ + code, + state, + configurationId, + nextUrl, + userId: authenticatedUserId, + request, + }); + } + + // Marketplace-invoked flow: user installed from Vercel marketplace + if (!configurationId) { + logger.error("Missing both state and configurationId from Vercel callback"); + throw new Response("Missing state or configurationId parameter", { status: 400 }); } + + return handleMarketplaceInvokedFlow({ + code, + configurationId, + nextUrl, + userId: authenticatedUserId, + request, + }); } From f4a6d4fff31e6b732d769b2cd8184d7e41443439 Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Tue, 13 Jan 2026 21:29:51 +0100 Subject: [PATCH 13/33] feat(onboarding): add Vercel onboarding route and OAuth handling Add a new Remix route apps/webapp/app/routes/onboarding.vercel.tsx that implements server loader and token exchange to support Vercel integration onboarding. Introduce schemas (zod) for loader query parameters and form actions, and add helper exchangeCodeForToken to call Vercel's OAuth token endpoint with robust error logging. Fetch the current user's organizations and projects from Prisma in the loader, validate incoming query params, and handle missing/misconfigured Vercel client credentials with clear log messages. Add typed TokenResponse shape, use env variables for client credentials and redirect URI, and use redirectWithErrorMessage for user-facing failures. Motivation: enable users to connect a Vercel integration during onboarding by validating parameters, exchanging OAuth codes, and presenting organization/project selection backed by the app database. --- .../route.tsx | 3 + apps/webapp/app/routes/callback.vercel.ts | 150 +++-- apps/webapp/app/routes/onboarding.vercel.tsx | 524 ++++++++++++++++++ ...cts.$projectParam.env.$envParam.vercel.tsx | 85 ++- 4 files changed, 700 insertions(+), 62 deletions(-) create mode 100644 apps/webapp/app/routes/onboarding.vercel.tsx diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx index 358450acda..b43189e1d3 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx @@ -31,6 +31,7 @@ import { organizationPath, selectPlanPath, v3ProjectPath, + v3ProjectSettingsPath, } from "~/utils/pathBuilder"; export async function loader({ params, request }: LoaderFunctionArgs) { @@ -122,6 +123,8 @@ export const action: ActionFunction = async ({ request, params }) => { const params = new URLSearchParams({ code, configurationId, + organizationId: project.organization.id, + projectId: project.id, }); if (next) { params.set("next", next); diff --git a/apps/webapp/app/routes/callback.vercel.ts b/apps/webapp/app/routes/callback.vercel.ts index 9f91285992..fd2c4bd0bf 100644 --- a/apps/webapp/app/routes/callback.vercel.ts +++ b/apps/webapp/app/routes/callback.vercel.ts @@ -62,6 +62,8 @@ const VercelCallbackSchema = z configurationId: z.string().optional(), teamId: z.string().nullable().optional(), next: z.string().optional(), + organizationId: z.string().optional(), + projectId: z.string().optional(), }) .passthrough(); @@ -296,6 +298,7 @@ async function handleMarketplaceInvokedFlow(params: { code, configurationId, integration: "vercel", + fromMarketplace: "true", }); if (nextUrl) { onboardingParams.set("next", nextUrl); @@ -303,7 +306,7 @@ async function handleMarketplaceInvokedFlow(params: { return redirect(`${confirmBasicDetailsPath()}?${onboardingParams.toString()}`); } - // Has organizations but no projects - redirect to project creation +// Check if user has organizations with projects const hasProjects = userOrganizations.some((org) => org.projects.length > 0); if (!hasProjects) { const firstOrg = userOrganizations[0]; @@ -311,6 +314,7 @@ async function handleMarketplaceInvokedFlow(params: { code, configurationId, integration: "vercel", + fromMarketplace: "true", }); if (nextUrl) { projectParams.set("next", nextUrl); @@ -318,45 +322,48 @@ async function handleMarketplaceInvokedFlow(params: { return redirect(`${newProjectPath({ slug: firstOrg.slug })}?${projectParams.toString()}`); } - // User has orgs and projects - complete the installation - const tokenResponse = await exchangeCodeForToken(code); - if (!tokenResponse) { - return redirectWithErrorMessage( - "/", - request, - "Failed to connect to Vercel. Please try again." - ); + // Multiple organizations - redirect to onboarding + if (userOrganizations.length > 1) { + const selectionParams = new URLSearchParams({ + code, + configurationId, + }); + if (nextUrl) { + selectionParams.set("next", nextUrl); + } + return redirect(`/onboarding/vercel?${selectionParams.toString()}`); } - const config = await VercelIntegrationRepository.getVercelIntegrationConfiguration( - tokenResponse.accessToken, - configurationId, - tokenResponse.teamId ?? null - ); + // Single organization - check project count + const singleOrg = userOrganizations[0]; + + if (singleOrg.projects.length > 1) { + const projectParams = new URLSearchParams({ + organizationId: singleOrg.id, + code, + configurationId, + }); + if (nextUrl) { + projectParams.set("next", nextUrl); + } + return redirect(`/onboarding/vercel?${projectParams.toString()}`); + } - if (!config) { + // Single org with single project - complete installation directly + const tokenResponse = await exchangeCodeForToken(code); + if (!tokenResponse) { return redirectWithErrorMessage( "/", request, - "Failed to fetch Vercel integration configuration. Please try again." + "Failed to connect to Vercel. Your session may have expired. Please try again from Vercel." ); } - const userOrg = userOrganizations[0]; - const userProject = userOrg.projects[0]; - - if (!userProject) { - const projectParams = new URLSearchParams({ - code, - configurationId, - integration: "vercel", - }); - return redirect(`${newProjectPath({ slug: userOrg.slug })}?${projectParams.toString()}`); - } + const singleProject = singleOrg.projects[0]; const environment = await prisma.runtimeEnvironment.findFirst({ where: { - projectId: userProject.id, + projectId: singleProject.id, slug: "prod", archivedAt: null, }, @@ -371,11 +378,11 @@ async function handleMarketplaceInvokedFlow(params: { } const stateData: StateData = { - organizationId: userOrg.id, - projectId: userProject.id, + organizationId: singleOrg.id, + projectId: singleProject.id, environmentSlug: environment.slug, - organizationSlug: userOrg.slug, - projectSlug: userProject.slug, + organizationSlug: singleOrg.slug, + projectSlug: singleProject.slug, }; const project = await fetchProjectWithAccess(stateData.projectId, stateData.organizationId, userId); @@ -420,7 +427,21 @@ async function handleSelfInvokedFlow(params: { logger.error("Invalid Vercel OAuth state JWT", { error: validationResult.error, }); - throw new Response("Invalid state parameter", { status: 400 }); + + // Check if JWT has expired + if (validationResult.error?.includes("expired") || validationResult.error?.includes("Token has expired")) { + return redirectWithErrorMessage( + "/", + request, + "Your installation session has expired. Please start the installation again." + ); + } + + return redirectWithErrorMessage( + "/", + request, + "Invalid installation session. Please try again." + ); } const stateData = validationResult.state; @@ -500,6 +521,8 @@ export async function loader({ request }: LoaderFunctionArgs) { error_description, configurationId, next: nextUrl, + organizationId, + projectId, } = parsedParams.data; // Handle errors from Vercel @@ -514,6 +537,67 @@ export async function loader({ request }: LoaderFunctionArgs) { throw new Response("Missing authorization code", { status: 400 }); } + // Handle return from project creation with org and project IDs + if (organizationId && projectId && configurationId && !state) { + const project = await fetchProjectWithAccess(projectId, organizationId, authenticatedUserId); + + if (!project) { + logger.error("Project not found or user does not have access", { + projectId, + organizationId, + userId: authenticatedUserId, + }); + return redirectWithErrorMessage( + "/", + request, + "Project not found. Please try again." + ); + } + + const tokenResponse = await exchangeCodeForToken(code); + if (!tokenResponse) { + return redirectWithErrorMessage( + "/", + request, + "Failed to connect to Vercel. Your session may have expired. Please try again from Vercel." + ); + } + + const environment = await prisma.runtimeEnvironment.findFirst({ + where: { + projectId: project.id, + slug: "prod", + archivedAt: null, + }, + }); + + if (!environment) { + return redirectWithErrorMessage( + "/", + request, + "Failed to find project environment. Please try again." + ); + } + + const stateData: StateData = { + organizationId: project.organizationId, + projectId: project.id, + environmentSlug: environment.slug, + organizationSlug: project.organization.slug, + projectSlug: project.slug, + }; + + return completeIntegrationSetup({ + tokenResponse, + project, + stateData, + configurationId, + nextUrl, + request, + logContext: "after project creation", + }); + } + // Route to appropriate handler based on presence of state parameter if (state) { // Self-invoked flow: user clicked connect from our app diff --git a/apps/webapp/app/routes/onboarding.vercel.tsx b/apps/webapp/app/routes/onboarding.vercel.tsx new file mode 100644 index 0000000000..1f10f5ddac --- /dev/null +++ b/apps/webapp/app/routes/onboarding.vercel.tsx @@ -0,0 +1,524 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { json, redirect } from "@remix-run/server-runtime"; +import { Form, useLoaderData, useNavigation } from "@remix-run/react"; +import { z } from "zod"; +import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout"; +import { BackgroundWrapper } from "~/components/BackgroundWrapper"; +import { Button } from "~/components/primitives/Buttons"; +import { Fieldset } from "~/components/primitives/Fieldset"; +import { FormButtons } from "~/components/primitives/FormButtons"; +import { FormTitle } from "~/components/primitives/FormTitle"; +import { Select, SelectItem, SelectGroup, SelectGroupLabel } from "~/components/primitives/Select"; +import { prisma } from "~/db.server"; +import { logger } from "~/services/logger.server"; +import { requireUserId } from "~/services/session.server"; +import { confirmBasicDetailsPath, v3ProjectSettingsPath, newProjectPath } from "~/utils/pathBuilder"; +import { redirectWithErrorMessage } from "~/models/message.server"; +import { VercelIntegrationRepository } from "~/models/vercelIntegration.server"; +import { env } from "~/env.server"; + + +const LoaderParamsSchema = z.object({ + organizationId: z.string().optional().nullable(), + code: z.string(), + configurationId: z.string(), + next: z.string().optional().nullable(), +}); + +const SelectOrgActionSchema = z.object({ + action: z.literal("select-org"), + organizationId: z.string(), + code: z.string(), + configurationId: z.string(), + next: z.string().optional(), +}); + +const SelectProjectActionSchema = z.object({ + action: z.literal("select-project"), + projectId: z.string(), + organizationId: z.string(), + code: z.string(), + configurationId: z.string(), + next: z.string().optional().nullable(), +}); + +const ActionSchema = z.discriminatedUnion("action", [ + SelectOrgActionSchema, + SelectProjectActionSchema, +]); + +type TokenResponse = { + accessToken: string; + tokenType: string; + teamId?: string; + userId?: string; + raw: Record; +}; + +async function exchangeCodeForToken(code: string): Promise { + const clientId = env.VERCEL_INTEGRATION_CLIENT_ID; + const clientSecret = env.VERCEL_INTEGRATION_CLIENT_SECRET; + const redirectUri = `${env.APP_ORIGIN}/callback/vercel`; + + if (!clientId || !clientSecret) { + logger.error("Vercel integration not configured"); + return null; + } + + try { + const response = await fetch("https://api.vercel.com/v2/oauth/access_token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + code, + redirect_uri: redirectUri, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + logger.error("Failed to exchange Vercel OAuth code", { + status: response.status, + error: errorText, + }); + return null; + } + + const data = (await response.json()) as { + access_token: string; + token_type: string; + team_id?: string; + user_id?: string; + }; + + return { + accessToken: data.access_token, + tokenType: data.token_type, + teamId: data.team_id, + userId: data.user_id, + raw: data as Record, + }; + } catch (error) { + logger.error("Error exchanging Vercel OAuth code", { error }); + return null; + } +} + +export async function loader({ request }: LoaderFunctionArgs) { + const userId = await requireUserId(request); + const url = new URL(request.url); + + const params = LoaderParamsSchema.safeParse({ + organizationId: url.searchParams.get("organizationId"), + code: url.searchParams.get("code"), + configurationId: url.searchParams.get("configurationId"), + next: url.searchParams.get("next"), + }); + + if (!params.success) { + logger.error("Invalid params for Vercel onboarding", { error: params.error }); + return redirectWithErrorMessage( + "/", + request, + "Invalid installation parameters. Please try again from Vercel." + ); + } + + const organizations = await prisma.organization.findMany({ + where: { + members: { + some: { userId }, + }, + deletedAt: null, + }, + select: { + id: true, + title: true, + slug: true, + projects: { + where: { + deletedAt: null, + }, + select: { + id: true, + name: true, + slug: true, + }, + orderBy: { + createdAt: "asc", + }, + }, + }, + orderBy: { + createdAt: "asc", + }, + }); + + if (organizations.length === 0) { + const onboardingParams = new URLSearchParams({ + code: params.data.code, + configurationId: params.data.configurationId, + integration: "vercel", + }); + if (params.data.next) { + onboardingParams.set("next", params.data.next); + } + return redirect(`${confirmBasicDetailsPath()}?${onboardingParams.toString()}`); + } + + // If organizationId is provided, show project selection + if (params.data.organizationId) { + const organization = organizations.find((org) => org.id === params.data.organizationId); + + if (!organization) { + logger.error("Organization not found or access denied", { + organizationId: params.data.organizationId, + userId, + }); + return redirectWithErrorMessage( + "/", + request, + "Organization not found. Please try again." + ); + } + + return json({ + step: "project" as const, + organization, + organizations, + code: params.data.code, + configurationId: params.data.configurationId, + next: params.data.next, + }); + } + + // Single org - automatically move to project selection + if (organizations.length === 1) { + const singleOrg = organizations[0]; + const projectParams = new URLSearchParams({ + organizationId: singleOrg.id, + code: params.data.code, + configurationId: params.data.configurationId, + }); + if (params.data.next) { + projectParams.set("next", params.data.next); + } + return redirect(`/onboarding/vercel?${projectParams.toString()}`); + } + + // Multiple orgs - show org selection + return json({ + step: "org" as const, + organizations, + code: params.data.code, + configurationId: params.data.configurationId, + next: params.data.next, + }); +} + +export async function action({ request }: ActionFunctionArgs) { + const userId = await requireUserId(request); + const formData = await request.formData(); + + const submission = ActionSchema.safeParse({ + action: formData.get("action"), + organizationId: formData.get("organizationId"), + projectId: formData.get("projectId"), + code: formData.get("code"), + configurationId: formData.get("configurationId"), + next: formData.get("next"), + }); + + if (!submission.success) { + return json({ error: "Invalid submission" }, { status: 400 }); + } + + const { code, configurationId, next } = submission.data; + + // Handle org selection + if (submission.data.action === "select-org") { + const { organizationId } = submission.data; + + const projectParams = new URLSearchParams({ + organizationId, + code, + configurationId, + }); + if (next) { + projectParams.set("next", next); + } + + return redirect(`/onboarding/vercel?${projectParams.toString()}`); + } + + // Handle project selection + const { projectId, organizationId } = submission.data; + + // Install integration with selected project + const project = await prisma.project.findFirst({ + where: { + id: projectId, + organizationId, + deletedAt: null, + organization: { + members: { some: { userId } }, + }, + }, + include: { + organization: true, + }, + }); + + if (!project) { + logger.error("Project not found or access denied", { projectId, userId }); + return redirectWithErrorMessage("/", request, "Project not found."); + } + + const tokenResponse = await exchangeCodeForToken(code); + if (!tokenResponse) { + return redirectWithErrorMessage( + "/", + request, + "Failed to connect to Vercel. Your session may have expired. Please try again from Vercel." + ); + } + + const environment = await prisma.runtimeEnvironment.findFirst({ + where: { + projectId: project.id, + slug: "prod", + archivedAt: null, + }, + }); + + if (!environment) { + return redirectWithErrorMessage( + "/", + request, + "Failed to find project environment. Please try again." + ); + } + + try { + let orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationByTeamId( + project.organizationId, + tokenResponse.teamId ?? null + ); + + if (orgIntegration) { + await VercelIntegrationRepository.updateVercelOrgIntegrationToken({ + integrationId: orgIntegration.id, + accessToken: tokenResponse.accessToken, + tokenType: tokenResponse.tokenType, + teamId: tokenResponse.teamId ?? null, + userId: tokenResponse.userId, + installationId: configurationId, + raw: tokenResponse.raw, + }); + + orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationByTeamId( + project.organizationId, + tokenResponse.teamId ?? null + ); + } else { + await VercelIntegrationRepository.createVercelOrgIntegration({ + accessToken: tokenResponse.accessToken, + tokenType: tokenResponse.tokenType, + teamId: tokenResponse.teamId ?? null, + userId: tokenResponse.userId, + installationId: configurationId, + organization: project.organization, + raw: tokenResponse.raw, + }); + + orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationByTeamId( + project.organizationId, + tokenResponse.teamId ?? null + ); + } + + if (!orgIntegration) { + throw new Error("Failed to create or find Vercel organization integration"); + } + + logger.info("Vercel organization integration created successfully", { + organizationId: project.organizationId, + projectId: project.id, + teamId: tokenResponse.teamId, + }); + + const settingsPath = v3ProjectSettingsPath( + { slug: project.organization.slug }, + { slug: project.slug }, + { slug: environment.slug } + ); + + const params = new URLSearchParams({ vercelOnboarding: "true", fromMarketplace: "true" }); + if (next) { + params.set("next", next); + } + + return redirect(`${settingsPath}?${params.toString()}`); + } catch (error) { + logger.error("Failed to create Vercel integration", { error }); + return redirectWithErrorMessage( + "/", + request, + "Failed to create Vercel integration. Please try again." + ); + } +} + +export default function VercelOnboardingPage() { + const data = useLoaderData(); + const navigation = useNavigation(); + const isSubmitting = navigation.state === "submitting"; + + if (data.step === "org") { + return ( + + + + +
+ + + + {data.next && } + +
+ +
+ +
+ + +
+ +
+
+
+
+
+
+ ); + } + + // Project selection step + return ( + + + + +
+ + + + + {data.next && } + +
+ + +
+ +
+ + +
+
+
+
+
+
+
+ ); +} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx index 227fa8de20..58acea885e 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx @@ -1043,6 +1043,8 @@ function VercelOnboardingModal({ const envMappingFetcher = useFetcher(); const completeOnboardingFetcher = useFetcher(); const { Form: CompleteOnboardingForm } = completeOnboardingFetcher; + const [searchParams] = useSearchParams(); + const fromMarketplaceContext = searchParams.get("fromMarketplace") === "true"; const availableProjects = onboardingData?.availableProjects || []; const hasProjectSelected = onboardingData?.hasProjectSelected ?? false; @@ -1250,17 +1252,25 @@ function VercelOnboardingModal({ }); }, [selectedVercelProject, fetcher, actionUrl]); - const handleSkipOnboarding = useCallback(() => { onClose(); + if (fromMarketplaceContext) { + return window.close(); + } + const formData = new FormData(); formData.append("action", "skip-onboarding"); fetcher.submit(formData, { method: "post", action: actionUrl, }); - }, [actionUrl, fetcher, onClose]); + }, [actionUrl, fetcher, onClose, nextUrl, fromMarketplaceContext]); + + const handleSkipEnvMapping = useCallback(() => { + // Skip the env mapping step and go directly to loading env vars + setState("loading-env-vars"); + }, []); const handleUpdateEnvMapping = useCallback(() => { const formData = new FormData(); @@ -1324,6 +1334,26 @@ function VercelOnboardingModal({ } }, [envMappingFetcher.data, envMappingFetcher.state]); + // Preselect environment in env-mapping state + useEffect(() => { + if (state === "env-mapping" && customEnvironments.length > 0 && !vercelStagingEnvironment) { + let selectedId = ""; + + if (customEnvironments.length === 1) { + // Only one environment, preselect it + selectedId = customEnvironments[0].id; + } else { + // Multiple environments, check for 'staging' (case-insensitive) + const stagingEnv = customEnvironments.find( + (env) => env.slug.toLowerCase() === "staging" + ); + selectedId = stagingEnv ? stagingEnv.id : customEnvironments[0].id; + } + + setVercelStagingEnvironment(selectedId); + } + }, [state, customEnvironments, vercelStagingEnvironment]); + if (!isOpen || onboardingData?.authInvalid) { return null; } @@ -1451,31 +1481,36 @@ function VercelOnboardingModal({ setVercelStagingEnvironment(value); } }} - items={[{ id: "", slug: "None (skip)" }, ...customEnvironments]} + items={customEnvironments} variant="tertiary/medium" placeholder="Select environment" dropdownIcon text={ - vercelStagingEnvironment - ? customEnvironments.find((e) => e.id === vercelStagingEnvironment)?.slug || - "None" - : "None (skip)" + customEnvironments.find((e) => e.id === vercelStagingEnvironment)?.slug || + "Select environment" } > - {[ - - None (skip) - , - ...customEnvironments.map((env) => ( - - {env.slug} - - )), - ]} + {customEnvironments.map((env) => ( + + {env.slug} + + ))} - + +
+ - } - cancelButton={ - - } - /> +
+
)} From 3104e7e681f56cd3e5ecd77fd0376f2c6efa3664 Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Wed, 14 Jan 2026 18:10:36 +0100 Subject: [PATCH 14/33] feat(vercel): integrate app slug, update callbacks, and clean UI imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for VERCEL_INTEGRATION_APP_SLUG and require it for Vercel integration availability checks. Update the Vercel install URL and redirect callback to use the configured app slug and the /vercel/callback path so install flows from Vercel Marketplace route correctly back to the app. Improve route and component code: - Add loader comment to vercel.configure route. - Remove unused conform import and unused OrgIntegrationRepository. - Add Tooltip primitives import and utility shortEnvironmentLabel for environment display. - Consolidate and reuse v3ProjectSettingsPath earlier in the handler to avoid duplication. - Remove unused cn and message redirect import cleanup. These changes fix Marketplace installation URL construction, ensure the integration is only advertised when fully configured, and tidy up imports and handler flow for clearer UI logic. Move code around, so it’s easier to understand. Make the data flow more transparent. For dashboard initialized flow: vercel/install -> vercel -> vercel/callback -> vercel/connect For marketplace initialized flow: Vercel -> vercel/callback -> vercel/onboarding -> vercel/connect --- .vscode/settings.json | 2 +- apps/webapp/app/env.server.ts | 1 + .../app/models/orgIntegration.server.ts | 6 +- .../app/models/vercelIntegration.server.ts | 2 + .../v3/VercelSettingsPresenter.server.ts | 25 +- .../route.tsx | 34 +- apps/webapp/app/routes/callback.vercel.ts | 627 ------------------ ...cts.$projectParam.env.$envParam.vercel.tsx | 99 ++- apps/webapp/app/routes/vercel.callback.ts | 73 ++ apps/webapp/app/routes/vercel.configure.tsx | 3 + apps/webapp/app/routes/vercel.connect.tsx | 234 +++++++ .../route.tsx => vercel.install.tsx} | 0 ...rding.vercel.tsx => vercel.onboarding.tsx} | 213 ++---- apps/webapp/app/utils/pathBuilder.ts | 2 +- 14 files changed, 490 insertions(+), 831 deletions(-) delete mode 100644 apps/webapp/app/routes/callback.vercel.ts create mode 100644 apps/webapp/app/routes/vercel.callback.ts create mode 100644 apps/webapp/app/routes/vercel.connect.tsx rename apps/webapp/app/routes/{_app.vercel.install/route.tsx => vercel.install.tsx} (100%) rename apps/webapp/app/routes/{onboarding.vercel.tsx => vercel.onboarding.tsx} (70%) diff --git a/.vscode/settings.json b/.vscode/settings.json index 14f6999f2d..382a5ae620 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,6 +7,6 @@ "packages/cli-v3/e2e": true }, "vitest.disableWorkspaceWarning": true, - "typescript.experimental.useTsgo": false, + "typescript.experimental.useTsgo": true, "chat.agent.maxRequests": 10000 } diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index b7c7f9e432..6733af0add 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -428,6 +428,7 @@ const EnvironmentSchema = z /** Vercel integration OAuth credentials */ VERCEL_INTEGRATION_CLIENT_ID: z.string().optional(), VERCEL_INTEGRATION_CLIENT_SECRET: z.string().optional(), + VERCEL_INTEGRATION_APP_SLUG: z.string().optional(), /** These enable the alerts feature in v3 */ ALERT_EMAIL_TRANSPORT: z.enum(["resend", "smtp", "aws-ses"]).optional(), diff --git a/apps/webapp/app/models/orgIntegration.server.ts b/apps/webapp/app/models/orgIntegration.server.ts index d14cef6c60..521c0c93a8 100644 --- a/apps/webapp/app/models/orgIntegration.server.ts +++ b/apps/webapp/app/models/orgIntegration.server.ts @@ -90,7 +90,7 @@ export class OrgIntegrationRepository { !!env.ORG_SLACK_INTEGRATION_CLIENT_ID && !!env.ORG_SLACK_INTEGRATION_CLIENT_SECRET; static isVercelSupported = - !!env.VERCEL_INTEGRATION_CLIENT_ID && !!env.VERCEL_INTEGRATION_CLIENT_SECRET; + !!env.VERCEL_INTEGRATION_CLIENT_ID && !!env.VERCEL_INTEGRATION_CLIENT_SECRET && !!env.VERCEL_INTEGRATION_APP_SLUG; /** * Generate the URL to install the Vercel integration. @@ -101,8 +101,8 @@ export class OrgIntegrationRepository { static vercelInstallUrl(state: string): string { // The user goes to Vercel's marketplace to install the integration // After installation, Vercel redirects to our callback with the authorization code - const redirectUri = encodeURIComponent(`${env.APP_ORIGIN}/callback/vercel`); - return `https://vercel.com/integrations/trigger/new?state=${state}&redirect_uri=${redirectUri}`; + const redirectUri = encodeURIComponent(`${env.APP_ORIGIN}/vercel/callback`); + return `https://vercel.com/integrations/${env.VERCEL_INTEGRATION_APP_SLUG}/new?state=${state}&redirect_uri=${redirectUri}`; } static slackAuthorizationUrl( diff --git a/apps/webapp/app/models/vercelIntegration.server.ts b/apps/webapp/app/models/vercelIntegration.server.ts index d20ce6801a..ec32d2363a 100644 --- a/apps/webapp/app/models/vercelIntegration.server.ts +++ b/apps/webapp/app/models/vercelIntegration.server.ts @@ -676,6 +676,7 @@ export class VercelIntegrationRepository { installationId?: string; organization: Pick; raw?: Record; + origin: 'marketplace' | 'dashboard'; }): Promise { const result = await $transaction(prisma, async (tx) => { const secretStore = getSecretStore("DATABASE", { @@ -718,6 +719,7 @@ export class VercelIntegrationRepository { teamId: params.teamId, userId: params.userId, installationId: params.installationId, + origin: params.origin, } as any, }, }); diff --git a/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts b/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts index d958375c81..0fa14bb41f 100644 --- a/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts @@ -1,4 +1,4 @@ -import { type PrismaClient } from "@trigger.dev/database"; +import { type PrismaClient, type RuntimeEnvironmentType } from "@trigger.dev/database"; import { fromPromise, ok, ResultAsync } from "neverthrow"; import { env } from "~/env.server"; import { OrgIntegrationRepository } from "~/models/orgIntegration.server"; @@ -7,6 +7,7 @@ import { VercelCustomEnvironment, VercelEnvironmentVariable, } from "~/models/vercelIntegration.server"; +import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; import { VercelProjectIntegrationDataSchema, VercelProjectIntegrationData, @@ -44,7 +45,9 @@ export type VercelOnboardingData = { environmentVariables: VercelEnvironmentVariable[]; availableProjects: VercelAvailableProject[]; hasProjectSelected: boolean; + hasProjectSelected: boolean; authInvalid?: boolean; + existingVariables: Record; }; export class VercelSettingsPresenter extends BasePresenter { @@ -255,7 +258,9 @@ export class VercelSettingsPresenter extends BasePresenter { environmentVariables: [], availableProjects: [], hasProjectSelected: false, + hasProjectSelected: false, authInvalid: true, + existingVariables: {}, }; } @@ -283,7 +288,9 @@ export class VercelSettingsPresenter extends BasePresenter { environmentVariables: [], availableProjects: [], hasProjectSelected: false, + hasProjectSelected: false, authInvalid: availableProjectsResult.authInvalid, + existingVariables: {}, }; } @@ -293,7 +300,9 @@ export class VercelSettingsPresenter extends BasePresenter { customEnvironments: [], environmentVariables: [], availableProjects: availableProjectsResult.data, + availableProjects: availableProjectsResult.data, hasProjectSelected: false, + existingVariables: {}, }; } @@ -330,7 +339,10 @@ export class VercelSettingsPresenter extends BasePresenter { environmentVariables: [], availableProjects: availableProjectsResult.data, hasProjectSelected: true, + availableProjects: availableProjectsResult.data, + hasProjectSelected: true, authInvalid: true, + existingVariables: {}, }; } @@ -361,11 +373,22 @@ export class VercelSettingsPresenter extends BasePresenter { a.key.localeCompare(b.key) ); + // Get existing environment variables in Trigger.dev + const envVarRepository = new EnvironmentVariablesRepository(this._replica as PrismaClient); + const existingVariables = await envVarRepository.getProject(projectId); + const existingVariablesRecord: Record = {}; + for (const v of existingVariables) { + existingVariablesRecord[v.key] = { + environments: v.values.map((val) => val.environment.type), + }; + } + return { customEnvironments, environmentVariables: sortedEnvVars, availableProjects: availableProjectsResult.data, hasProjectSelected: true, + existingVariables: existingVariablesRecord, }; } catch (error) { // Log the error and return null to indicate failure diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx index b43189e1d3..46768f8326 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx @@ -33,6 +33,7 @@ import { v3ProjectPath, v3ProjectSettingsPath, } from "~/utils/pathBuilder"; +import { generateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server"; export async function loader({ params, request }: LoaderFunctionArgs) { const userId = await requireUserId(request); @@ -118,19 +119,42 @@ export const action: ActionFunction = async ({ request, params }) => { version: submission.value.projectVersion, }); - // If this is a Vercel integration flow, redirect back to callback + // If this is a Vercel integration flow, generate state and redirect to connect if (code && configurationId) { + const environment = await prisma.runtimeEnvironment.findFirst({ + where: { + projectId: project.id, + slug: "prod", + archivedAt: null, + }, + }); + + if (!environment) { + return redirectWithErrorMessage( + newProjectPath({ slug: organizationSlug }), + request, + "Failed to find project environment." + ); + } + + const state = await generateVercelOAuthState({ + organizationId: project.organization.id, + projectId: project.id, + environmentSlug: environment.slug, + organizationSlug: project.organization.slug, + projectSlug: project.slug, + }); + const params = new URLSearchParams({ + state, code, configurationId, - organizationId: project.organization.id, - projectId: project.id, + origin: "marketplace", }); if (next) { params.set("next", next); } - const callbackUrl = `/callback/vercel?${params.toString()}`; - return redirect(callbackUrl); + return redirect(`/vercel/connect?${params.toString()}`); } return redirectWithSuccessMessage( diff --git a/apps/webapp/app/routes/callback.vercel.ts b/apps/webapp/app/routes/callback.vercel.ts deleted file mode 100644 index fd2c4bd0bf..0000000000 --- a/apps/webapp/app/routes/callback.vercel.ts +++ /dev/null @@ -1,627 +0,0 @@ -import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { redirect } from "@remix-run/server-runtime"; -import { z } from "zod"; -import { prisma } from "~/db.server"; -import { env } from "~/env.server"; -import { redirectWithErrorMessage } from "~/models/message.server"; -import { VercelIntegrationRepository } from "~/models/vercelIntegration.server"; -import { logger } from "~/services/logger.server"; -import { getUserId, requireUserId } from "~/services/session.server"; -import { setReferralSourceCookie } from "~/services/referralSource.server"; -import { requestUrl } from "~/utils/requestUrl.server"; -import { - v3ProjectSettingsPath, - confirmBasicDetailsPath, - newProjectPath, -} from "~/utils/pathBuilder"; -import { validateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server"; - -// ============================================================================ -// Types -// ============================================================================ - -type TokenResponse = { - accessToken: string; - tokenType: string; - teamId?: string; - userId?: string; - raw: Record; -}; - -type ProjectWithOrganization = { - id: string; - organizationId: string; - organization: { id: string }; -}; - -type StateData = { - organizationId: string; - projectId: string; - environmentSlug: string; - organizationSlug: string; - projectSlug: string; -}; - -type CallbackParams = { - code: string; - state?: string; - configurationId?: string; - nextUrl?: string; -}; - -// ============================================================================ -// Schema -// ============================================================================ - -const VercelCallbackSchema = z - .object({ - code: z.string().optional(), - state: z.string().optional(), - error: z.string().optional(), - error_description: z.string().optional(), - configurationId: z.string().optional(), - teamId: z.string().nullable().optional(), - next: z.string().optional(), - organizationId: z.string().optional(), - projectId: z.string().optional(), - }) - .passthrough(); - -// ============================================================================ -// Shared Utilities -// ============================================================================ - -async function exchangeCodeForToken(code: string): Promise { - const clientId = env.VERCEL_INTEGRATION_CLIENT_ID; - const clientSecret = env.VERCEL_INTEGRATION_CLIENT_SECRET; - const redirectUri = `${env.APP_ORIGIN}/callback/vercel`; - - if (!clientId || !clientSecret) { - logger.error("Vercel integration not configured - missing client ID or secret"); - return null; - } - - try { - const response = await fetch("https://api.vercel.com/v2/oauth/access_token", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - client_id: clientId, - client_secret: clientSecret, - code, - redirect_uri: redirectUri, - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - logger.error("Failed to exchange Vercel OAuth code", { - status: response.status, - error: errorText, - }); - return null; - } - - const data = (await response.json()) as { - access_token: string; - token_type: string; - team_id?: string; - user_id?: string; - }; - - return { - accessToken: data.access_token, - tokenType: data.token_type, - teamId: data.team_id, - userId: data.user_id, - raw: data as Record, - }; - } catch (error) { - logger.error("Error exchanging Vercel OAuth code", { error }); - return null; - } -} - -async function createOrFindVercelIntegration(params: { - tokenResponse: TokenResponse; - project: ProjectWithOrganization; - configurationId?: string; -}) { - const { tokenResponse, project, configurationId } = params; - - let orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationByTeamId( - project.organizationId, - tokenResponse.teamId ?? null - ); - - if (orgIntegration) { - logger.info("Updating existing Vercel integration token", { - integrationId: orgIntegration.id, - teamId: tokenResponse.teamId, - organizationId: project.organizationId, - }); - - await VercelIntegrationRepository.updateVercelOrgIntegrationToken({ - integrationId: orgIntegration.id, - accessToken: tokenResponse.accessToken, - tokenType: tokenResponse.tokenType, - teamId: tokenResponse.teamId ?? null, - userId: tokenResponse.userId, - installationId: configurationId, - raw: tokenResponse.raw, - }); - - orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationByTeamId( - project.organizationId, - tokenResponse.teamId ?? null - ); - } else { - await VercelIntegrationRepository.createVercelOrgIntegration({ - accessToken: tokenResponse.accessToken, - tokenType: tokenResponse.tokenType, - teamId: tokenResponse.teamId ?? null, - userId: tokenResponse.userId, - installationId: configurationId, - organization: project.organization, - raw: tokenResponse.raw, - }); - - orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationByTeamId( - project.organizationId, - tokenResponse.teamId ?? null - ); - } - - if (!orgIntegration) { - throw new Error("Failed to create or find Vercel organization integration"); - } - - return orgIntegration; -} - -async function fetchProjectWithAccess( - projectId: string, - organizationId: string, - userId: string -): Promise { - return prisma.project.findFirst({ - where: { - id: projectId, - organizationId, - deletedAt: null, - organization: { - members: { - some: { - userId, - }, - }, - }, - }, - include: { - organization: true, - }, - }); -} - -function buildSettingsRedirectUrl(stateData: StateData, nextUrl?: string): string { - const settingsPath = v3ProjectSettingsPath( - { slug: stateData.organizationSlug }, - { slug: stateData.projectSlug }, - { slug: stateData.environmentSlug } - ); - - const params = new URLSearchParams({ vercelOnboarding: "true" }); - if (nextUrl) { - params.set("next", nextUrl); - } - - return `${settingsPath}?${params.toString()}`; -} - -async function completeIntegrationSetup(params: { - tokenResponse: TokenResponse; - project: ProjectWithOrganization; - stateData: StateData; - configurationId?: string; - nextUrl?: string; - request: Request; - logContext: string; -}): Promise { - const { tokenResponse, project, stateData, configurationId, nextUrl, request, logContext } = - params; - - try { - await createOrFindVercelIntegration({ - tokenResponse, - project, - configurationId, - }); - - logger.info(`Vercel organization integration created successfully ${logContext}`, { - organizationId: project.organizationId, - projectId: project.id, - teamId: tokenResponse.teamId, - }); - - return redirect(buildSettingsRedirectUrl(stateData, nextUrl)); - } catch (error) { - logger.error(`Failed to create Vercel integration ${logContext}`, { error }); - return redirectWithErrorMessage( - v3ProjectSettingsPath( - { slug: stateData.organizationSlug }, - { slug: stateData.projectSlug }, - { slug: stateData.environmentSlug } - ), - request, - "Failed to create Vercel integration. Please try again." - ); - } -} - -// ============================================================================ -// Marketplace-Invoked Flow (without state) -// User installs from Vercel marketplace, no state parameter present -// ============================================================================ - -async function handleMarketplaceInvokedFlow(params: { - code: string; - configurationId: string; - nextUrl?: string; - userId: string; - request: Request; -}): Promise { - const { code, configurationId, nextUrl, userId, request } = params; - - const userOrganizations = await prisma.organization.findMany({ - where: { - members: { - some: { - userId, - }, - }, - deletedAt: null, - }, - include: { - projects: { - where: { - deletedAt: null, - }, - }, - }, - }); - - // No organizations - redirect to onboarding - if (userOrganizations.length === 0) { - const onboardingParams = new URLSearchParams({ - code, - configurationId, - integration: "vercel", - fromMarketplace: "true", - }); - if (nextUrl) { - onboardingParams.set("next", nextUrl); - } - return redirect(`${confirmBasicDetailsPath()}?${onboardingParams.toString()}`); - } - -// Check if user has organizations with projects - const hasProjects = userOrganizations.some((org) => org.projects.length > 0); - if (!hasProjects) { - const firstOrg = userOrganizations[0]; - const projectParams = new URLSearchParams({ - code, - configurationId, - integration: "vercel", - fromMarketplace: "true", - }); - if (nextUrl) { - projectParams.set("next", nextUrl); - } - return redirect(`${newProjectPath({ slug: firstOrg.slug })}?${projectParams.toString()}`); - } - - // Multiple organizations - redirect to onboarding - if (userOrganizations.length > 1) { - const selectionParams = new URLSearchParams({ - code, - configurationId, - }); - if (nextUrl) { - selectionParams.set("next", nextUrl); - } - return redirect(`/onboarding/vercel?${selectionParams.toString()}`); - } - - // Single organization - check project count - const singleOrg = userOrganizations[0]; - - if (singleOrg.projects.length > 1) { - const projectParams = new URLSearchParams({ - organizationId: singleOrg.id, - code, - configurationId, - }); - if (nextUrl) { - projectParams.set("next", nextUrl); - } - return redirect(`/onboarding/vercel?${projectParams.toString()}`); - } - - // Single org with single project - complete installation directly - const tokenResponse = await exchangeCodeForToken(code); - if (!tokenResponse) { - return redirectWithErrorMessage( - "/", - request, - "Failed to connect to Vercel. Your session may have expired. Please try again from Vercel." - ); - } - - const singleProject = singleOrg.projects[0]; - - const environment = await prisma.runtimeEnvironment.findFirst({ - where: { - projectId: singleProject.id, - slug: "prod", - archivedAt: null, - }, - }); - - if (!environment) { - return redirectWithErrorMessage( - "/", - request, - "Failed to find project environment. Please try again." - ); - } - - const stateData: StateData = { - organizationId: singleOrg.id, - projectId: singleProject.id, - environmentSlug: environment.slug, - organizationSlug: singleOrg.slug, - projectSlug: singleProject.slug, - }; - - const project = await fetchProjectWithAccess(stateData.projectId, stateData.organizationId, userId); - - if (!project) { - logger.error("Project not found or user does not have access", { - projectId: stateData.projectId, - userId, - }); - throw new Response("Project not found", { status: 404 }); - } - - return completeIntegrationSetup({ - tokenResponse, - project, - stateData, - configurationId, - nextUrl, - request, - logContext: "after marketplace installation", - }); -} - -// ============================================================================ -// Self-Invoked Flow (with state) -// User clicks connect button from our app, state parameter contains project info -// ============================================================================ - -async function handleSelfInvokedFlow(params: { - code: string; - state: string; - configurationId?: string; - nextUrl?: string; - userId: string; - request: Request; -}): Promise { - const { code, state, configurationId, nextUrl, userId, request } = params; - - const validationResult = await validateVercelOAuthState(state); - - if (!validationResult.ok) { - logger.error("Invalid Vercel OAuth state JWT", { - error: validationResult.error, - }); - - // Check if JWT has expired - if (validationResult.error?.includes("expired") || validationResult.error?.includes("Token has expired")) { - return redirectWithErrorMessage( - "/", - request, - "Your installation session has expired. Please start the installation again." - ); - } - - return redirectWithErrorMessage( - "/", - request, - "Invalid installation session. Please try again." - ); - } - - const stateData = validationResult.state; - - const project = await fetchProjectWithAccess(stateData.projectId, stateData.organizationId, userId); - - if (!project) { - logger.error("Project not found or user does not have access", { - projectId: stateData.projectId, - userId, - }); - throw new Response("Project not found", { status: 404 }); - } - - const tokenResponse = await exchangeCodeForToken(code); - if (!tokenResponse) { - return redirectWithErrorMessage( - v3ProjectSettingsPath( - { slug: stateData.organizationSlug }, - { slug: stateData.projectSlug }, - { slug: stateData.environmentSlug } - ), - request, - "Failed to connect to Vercel. Please try again." - ); - } - - return completeIntegrationSetup({ - tokenResponse, - project, - stateData, - configurationId, - nextUrl, - request, - logContext: "via self-invoked flow", - }); -} - -// ============================================================================ -// Main Loader -// ============================================================================ - -export async function loader({ request }: LoaderFunctionArgs) { - if (request.method.toUpperCase() !== "GET") { - return new Response("Method Not Allowed", { status: 405 }); - } - - // Check authentication - redirect to login if not authenticated - const userId = await getUserId(request); - - if (!userId) { - const currentUrl = new URL(request.url); - const redirectTo = `${currentUrl.pathname}${currentUrl.search}`; - const referralCookie = await setReferralSourceCookie("vercel"); - - const headers = new Headers(); - headers.append("Set-Cookie", referralCookie); - - throw redirect(`/login?redirectTo=${encodeURIComponent(redirectTo)}`, { headers }); - } - - const authenticatedUserId = await requireUserId(request); - - // Parse and validate callback parameters - const url = requestUrl(request); - const parsedParams = VercelCallbackSchema.safeParse(Object.fromEntries(url.searchParams)); - - if (!parsedParams.success) { - logger.error("Invalid Vercel callback params", { error: parsedParams.error }); - throw new Response("Invalid callback parameters", { status: 400 }); - } - - const { - code, - state, - error, - error_description, - configurationId, - next: nextUrl, - organizationId, - projectId, - } = parsedParams.data; - - // Handle errors from Vercel - if (error) { - logger.error("Vercel OAuth error", { error, error_description }); - throw new Response("Vercel OAuth error", { status: 500 }); - } - - // Validate authorization code is present - if (!code) { - logger.error("Missing authorization code from Vercel callback"); - throw new Response("Missing authorization code", { status: 400 }); - } - - // Handle return from project creation with org and project IDs - if (organizationId && projectId && configurationId && !state) { - const project = await fetchProjectWithAccess(projectId, organizationId, authenticatedUserId); - - if (!project) { - logger.error("Project not found or user does not have access", { - projectId, - organizationId, - userId: authenticatedUserId, - }); - return redirectWithErrorMessage( - "/", - request, - "Project not found. Please try again." - ); - } - - const tokenResponse = await exchangeCodeForToken(code); - if (!tokenResponse) { - return redirectWithErrorMessage( - "/", - request, - "Failed to connect to Vercel. Your session may have expired. Please try again from Vercel." - ); - } - - const environment = await prisma.runtimeEnvironment.findFirst({ - where: { - projectId: project.id, - slug: "prod", - archivedAt: null, - }, - }); - - if (!environment) { - return redirectWithErrorMessage( - "/", - request, - "Failed to find project environment. Please try again." - ); - } - - const stateData: StateData = { - organizationId: project.organizationId, - projectId: project.id, - environmentSlug: environment.slug, - organizationSlug: project.organization.slug, - projectSlug: project.slug, - }; - - return completeIntegrationSetup({ - tokenResponse, - project, - stateData, - configurationId, - nextUrl, - request, - logContext: "after project creation", - }); - } - - // Route to appropriate handler based on presence of state parameter - if (state) { - // Self-invoked flow: user clicked connect from our app - return handleSelfInvokedFlow({ - code, - state, - configurationId, - nextUrl, - userId: authenticatedUserId, - request, - }); - } - - // Marketplace-invoked flow: user installed from Vercel marketplace - if (!configurationId) { - logger.error("Missing both state and configurationId from Vercel callback"); - throw new Response("Missing state or configurationId parameter", { status: 400 }); - } - - return handleMarketplaceInvokedFlow({ - code, - configurationId, - nextUrl, - userId: authenticatedUserId, - request, - }); -} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx index 58acea885e..c23fc08c06 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx @@ -1,4 +1,4 @@ -import { conform, useForm } from "@conform-to/react"; +import { useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; import { CheckCircleIcon, @@ -38,22 +38,24 @@ import { SpinnerWhite } from "~/components/primitives/Spinner"; import { Switch } from "~/components/primitives/Switch"; import { TextLink } from "~/components/primitives/TextLink"; import { DateTime } from "~/components/primitives/DateTime"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, + TooltipProvider +} from "~/components/primitives/Tooltip"; import { redirectBackWithErrorMessage, - redirectBackWithSuccessMessage, redirectWithSuccessMessage, redirectWithErrorMessage, } from "~/models/message.server"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; -import { OrgIntegrationRepository } from "~/models/orgIntegration.server"; import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; import { EnvironmentParamSchema, v3ProjectSettingsPath, vercelAppInstallPath } from "~/utils/pathBuilder"; -import { cn } from "~/utils/cn"; import { VercelSettingsPresenter, - type VercelSettingsResult, type VercelOnboardingData, } from "~/presenters/v3/VercelSettingsPresenter.server"; import { VercelIntegrationService } from "~/services/vercelIntegration.server"; @@ -67,6 +69,20 @@ import { shouldSyncEnvVarForAnyEnvironment, } from "~/v3/vercel/vercelProjectIntegrationSchema"; import { useEffect, useState, useCallback, useRef } from "react"; +import { RuntimeEnvironmentType } from "@trigger.dev/database"; + +function shortEnvironmentLabel(environment: RuntimeEnvironmentType) { + switch (environment) { + case "PRODUCTION": + return "Prod"; + case "STAGING": + return "Staging"; + case "DEVELOPMENT": + return "Dev"; + case "PREVIEW": + return "Preview"; + } +} export type ConnectedVercelProject = { id: string; @@ -275,6 +291,12 @@ export async function action({ request, params }: ActionFunctionArgs) { return json(submission); } + const settingsPath = v3ProjectSettingsPath( + { slug: organizationSlug }, + { slug: projectParam }, + { slug: envParam } + ); + const vercelService = new VercelIntegrationService(); const { action: actionType } = submission.value; @@ -302,12 +324,6 @@ export async function action({ request, params }: ActionFunctionArgs) { vercelStagingName: stagingName, }); - const settingsPath = v3ProjectSettingsPath( - { slug: organizationSlug }, - { slug: projectParam }, - { slug: envParam } - ); - if (result) { return redirectWithSuccessMessage(settingsPath, request, "Vercel settings updated successfully"); } @@ -319,12 +335,6 @@ export async function action({ request, params }: ActionFunctionArgs) { if (actionType === "disconnect") { const success = await vercelService.disconnectVercelProject(project.id); - const settingsPath = v3ProjectSettingsPath( - { slug: organizationSlug }, - { slug: projectParam }, - { slug: envParam } - ); - if (success) { return redirectWithSuccessMessage(settingsPath, request, "Vercel project disconnected"); } @@ -382,20 +392,10 @@ export async function action({ request, params }: ActionFunctionArgs) { } // Default redirect to settings page without the vercelOnboarding param to close the modal - const settingsPath = v3ProjectSettingsPath( - { slug: organizationSlug }, - { slug: projectParam }, - { slug: envParam } - ); // Return JSON with redirect URL for fetcher to handle return json({ success: true, redirectTo: settingsPath }); } - const settingsPath = v3ProjectSettingsPath( - { slug: organizationSlug }, - { slug: projectParam }, - { slug: envParam } - ); return redirectWithErrorMessage(settingsPath, request, "Failed to complete Vercel setup"); } @@ -423,11 +423,6 @@ export async function action({ request, params }: ActionFunctionArgs) { // Handle skip-onboarding action if (actionType === "skip-onboarding") { - const settingsPath = v3ProjectSettingsPath( - { slug: organizationSlug }, - { slug: projectParam }, - { slug: envParam } - ); return redirectWithSuccessMessage(settingsPath, request, "Vercel integration setup skipped"); } @@ -1044,12 +1039,13 @@ function VercelOnboardingModal({ const completeOnboardingFetcher = useFetcher(); const { Form: CompleteOnboardingForm } = completeOnboardingFetcher; const [searchParams] = useSearchParams(); - const fromMarketplaceContext = searchParams.get("fromMarketplace") === "true"; + const fromMarketplaceContext = searchParams.get("origin") === "marketplace"; const availableProjects = onboardingData?.availableProjects || []; const hasProjectSelected = onboardingData?.hasProjectSelected ?? false; const customEnvironments = onboardingData?.customEnvironments || []; const envVars = onboardingData?.environmentVariables || []; + const existingVars = onboardingData?.existingVariables || {}; const hasCustomEnvs = customEnvironments.length > 0 && hasStagingEnvironment; const computeInitialState = useCallback((): OnboardingState => { @@ -1197,6 +1193,8 @@ function VercelOnboardingModal({ (v) => shouldSyncEnvVarForAnyEnvironment(syncEnvVarsMapping, v.key) ); + const overlappingEnvVarsCount = enabledEnvVars.filter((v) => existingVars[v.key]).length; + const isSubmitting = navigation.state === "submitting" || navigation.state === "loading"; @@ -1546,7 +1544,7 @@ function VercelOnboardingModal({ name="syncEnvVarsMapping" value={JSON.stringify(syncEnvVarsMapping)} /> - {nextUrl && ( + {nextUrl && !fromMarketplaceContext && (
+ {overlappingEnvVarsCount > 0 && ( +
+ + + {overlappingEnvVarsCount} env vars are going to be updated (marked with{" "} + + underline + + ) + +
+ )} {/* Expandable env var list */} {pullEnvVarsFromVercel && envVars.length > 0 && ( @@ -1611,7 +1621,26 @@ function VercelOnboardingModal({ className="flex items-center justify-between gap-2 border-b px-3 py-2 last:border-b-0" >
- {envVar.key} + {existingVars[envVar.key] ? ( + + + +
+ {envVar.key} +
+
+ + {`This variable is going to be replaced in: ${existingVars[ + envVar.key + ].environments + .map((e) => shortEnvironmentLabel(e)) + .join(", ")}`} + +
+
+ ) : ( + {envVar.key} + )} {envVar.target && envVar.target.length > 0 && ( {formatVercelTargets(envVar.target)} diff --git a/apps/webapp/app/routes/vercel.callback.ts b/apps/webapp/app/routes/vercel.callback.ts new file mode 100644 index 0000000000..3154e6dcba --- /dev/null +++ b/apps/webapp/app/routes/vercel.callback.ts @@ -0,0 +1,73 @@ +import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { redirect } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { logger } from "~/services/logger.server"; +import { getUserId, requireUserId } from "~/services/session.server"; +import { setReferralSourceCookie } from "~/services/referralSource.server"; +import { requestUrl } from "~/utils/requestUrl.server"; + +const VercelCallbackSchema = z + .object({ + code: z.string().optional(), + state: z.string().optional(), + error: z.string().optional(), + error_description: z.string().optional(), + configurationId: z.string(), + next: z.string().optional() + }) + .passthrough(); + +export async function loader({ request }: LoaderFunctionArgs) { + if (request.method.toUpperCase() !== "GET") { + throw new Response("Method Not Allowed", { status: 405 }); + } + + const userId = await getUserId(request); + if (!userId) { + const currentUrl = new URL(request.url); + const redirectTo = `${currentUrl.pathname}${currentUrl.search}`; + const referralCookie = await setReferralSourceCookie("vercel"); + + const headers = new Headers(); + headers.append("Set-Cookie", referralCookie); + + throw redirect(`/login?redirectTo=${encodeURIComponent(redirectTo)}`, { headers }); + } + + const url = requestUrl(request); + const parsed = VercelCallbackSchema.safeParse(Object.fromEntries(url.searchParams)); + + if (!parsed.success) { + logger.error("Invalid Vercel callback params", { error: parsed.error }); + throw new Response("Invalid callback parameters", { status: 400 }); + } + + const { code, state, error, error_description, configurationId, next: nextUrl } = parsed.data; + + if (error) { + logger.error("Vercel OAuth error", { error, error_description }); + throw new Response("Vercel OAuth error", { status: 500 }); + } + + if (!code) { + logger.error("Missing authorization code from Vercel callback"); + throw new Response("Missing authorization code", { status: 400 }); + } + + // Route with state: dashboard-invoked flow + if (state) { + const params = new URLSearchParams({ state, configurationId, code, origin: "dashboard" }); + if (nextUrl) params.set("next", nextUrl); + return redirect(`/vercel/connect?${params.toString()}`); + } + + // Route without state but with configurationId: marketplace-invoked flow + if (configurationId) { + const params = new URLSearchParams({ code, configurationId, origin: "marketplace" }); + if (nextUrl) params.set("next", nextUrl); + return redirect(`/vercel/onboarding?${params.toString()}`); + } + + logger.error("Missing both state and configurationId from Vercel callback"); + throw new Response("Missing state or configurationId parameter", { status: 400 }); +} diff --git a/apps/webapp/app/routes/vercel.configure.tsx b/apps/webapp/app/routes/vercel.configure.tsx index 8f5d6c3943..f79d333509 100644 --- a/apps/webapp/app/routes/vercel.configure.tsx +++ b/apps/webapp/app/routes/vercel.configure.tsx @@ -8,6 +8,9 @@ const SearchParamsSchema = z.object({ configurationId: z.string(), }); +/** + * Endpoint to handle Vercel integration configuration request coming from marketplace + */ export const loader = async ({ request }: LoaderFunctionArgs) => { const url = new URL(request.url); const searchParams = Object.fromEntries(url.searchParams); diff --git a/apps/webapp/app/routes/vercel.connect.tsx b/apps/webapp/app/routes/vercel.connect.tsx new file mode 100644 index 0000000000..171700f792 --- /dev/null +++ b/apps/webapp/app/routes/vercel.connect.tsx @@ -0,0 +1,234 @@ +import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { redirect } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { env } from "~/env.server"; +import { VercelIntegrationRepository } from "~/models/vercelIntegration.server"; +import { logger } from "~/services/logger.server"; +import { requireUserId } from "~/services/session.server"; +import { requestUrl } from "~/utils/requestUrl.server"; +import { v3ProjectSettingsPath } from "~/utils/pathBuilder"; +import { validateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server"; + +const VercelConnectSchema = z.object({ + state: z.string(), + configurationId: z.string(), + code: z.string(), + next: z.string().optional(), + origin: z.enum(["marketplace", "dashboard"]), +}); + +type TokenResponse = { + accessToken: string; + tokenType: string; + teamId?: string; + userId?: string; + raw: Record; +}; + +async function exchangeCodeForToken(code: string): Promise { + const clientId = env.VERCEL_INTEGRATION_CLIENT_ID; + const clientSecret = env.VERCEL_INTEGRATION_CLIENT_SECRET; + const redirectUri = `${env.APP_ORIGIN}/vercel/callback`; + + if (!clientId || !clientSecret) { + logger.error("Vercel integration not configured"); + return null; + } + + try { + const response = await fetch("https://api.vercel.com/v2/oauth/access_token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + code, + redirect_uri: redirectUri, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + logger.error("Failed to exchange Vercel OAuth code", { + status: response.status, + error: errorText, + }); + return null; + } + + const data = (await response.json()) as { + access_token: string; + token_type: string; + team_id?: string; + user_id?: string; + }; + + return { + accessToken: data.access_token, + tokenType: data.token_type, + teamId: data.team_id, + userId: data.user_id, + raw: data as Record, + }; + } catch (error) { + logger.error("Error exchanging Vercel OAuth code", { error }); + return null; + } +} + +async function createOrFindVercelIntegration( + organizationId: string, + projectId: string, + tokenResponse: TokenResponse, + configurationId: string, + origin: 'marketplace' | 'dashboard' +): Promise { + const project = await prisma.project.findUnique({ + where: { id: projectId }, + include: { organization: true }, + }); + + if (!project) { + throw new Error("Project not found"); + } + + let orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationByTeamId( + organizationId, + tokenResponse.teamId ?? null + ); + + if (orgIntegration) { + await VercelIntegrationRepository.updateVercelOrgIntegrationToken({ + integrationId: orgIntegration.id, + accessToken: tokenResponse.accessToken, + tokenType: tokenResponse.tokenType, + teamId: tokenResponse.teamId ?? null, + userId: tokenResponse.userId, + installationId: configurationId, + raw: tokenResponse.raw + }); + } else { + await VercelIntegrationRepository.createVercelOrgIntegration({ + accessToken: tokenResponse.accessToken, + tokenType: tokenResponse.tokenType, + teamId: tokenResponse.teamId ?? null, + userId: tokenResponse.userId, + installationId: configurationId, + organization: project.organization, + raw: tokenResponse.raw, + origin, + }); + } +} + +export async function loader({ request }: LoaderFunctionArgs) { + const userId = await requireUserId(request); + const url = requestUrl(request); + + const parsed = VercelConnectSchema.safeParse(Object.fromEntries(url.searchParams)); + if (!parsed.success) { + logger.error("Invalid Vercel connect params", { error: parsed.error }); + throw new Response("Invalid parameters", { status: 400 }); + } + + const { state, configurationId, code, next, origin } = parsed.data; + + const validationResult = await validateVercelOAuthState(state); + if (!validationResult.ok) { + logger.error("Invalid Vercel OAuth state JWT", { error: validationResult.error }); + + if ( + validationResult.error?.includes("expired") || + validationResult.error?.includes("Token has expired") + ) { + const params = new URLSearchParams({ error: "expired" }); + return redirect(`/vercel/onboarding?${params.toString()}`); + } + + throw new Response("Invalid state", { status: 400 }); + } + + const stateData = validationResult.state; + + const project = await prisma.project.findFirst({ + where: { + id: stateData.projectId, + organizationId: stateData.organizationId, + deletedAt: null, + organization: { + members: { + some: { userId }, + }, + }, + }, + include: { + organization: true, + }, + }); + + if (!project) { + logger.error("Project not found or access denied", { + projectId: stateData.projectId, + userId, + }); + throw new Response("Project not found", { status: 404 }); + } + + const tokenResponse = await exchangeCodeForToken(code); + if (!tokenResponse) { + const params = new URLSearchParams({ error: "expired" }); + return redirect(`/vercel/onboarding?${params.toString()}`); + } + + const environment = await prisma.runtimeEnvironment.findFirst({ + where: { + projectId: project.id, + slug: stateData.environmentSlug, + archivedAt: null, + }, + }); + + if (!environment) { + logger.error("Environment not found", { + projectId: project.id, + environmentSlug: stateData.environmentSlug, + }); + throw new Response("Environment not found", { status: 404 }); + } + + try { + await createOrFindVercelIntegration(stateData.organizationId, stateData.projectId, tokenResponse, configurationId, origin); + + logger.info("Vercel organization integration created successfully", { + organizationId: stateData.organizationId, + projectId: stateData.projectId, + teamId: tokenResponse.teamId, + }); + + const settingsPath = v3ProjectSettingsPath( + { slug: stateData.organizationSlug }, + { slug: stateData.projectSlug }, + { slug: environment.slug } + ); + + const params = new URLSearchParams({ vercelOnboarding: "true", origin }); + if (next) { + params.set("next", next); + } + + return redirect(`${settingsPath}?${params.toString()}`); + } catch (error) { + logger.error("Failed to complete Vercel integration", { error }); + + const settingsPath = v3ProjectSettingsPath( + { slug: stateData.organizationSlug }, + { slug: stateData.projectSlug }, + { slug: environment.slug } + ); + + throw redirect(settingsPath); + } +} diff --git a/apps/webapp/app/routes/_app.vercel.install/route.tsx b/apps/webapp/app/routes/vercel.install.tsx similarity index 100% rename from apps/webapp/app/routes/_app.vercel.install/route.tsx rename to apps/webapp/app/routes/vercel.install.tsx diff --git a/apps/webapp/app/routes/onboarding.vercel.tsx b/apps/webapp/app/routes/vercel.onboarding.tsx similarity index 70% rename from apps/webapp/app/routes/onboarding.vercel.tsx rename to apps/webapp/app/routes/vercel.onboarding.tsx index 1f10f5ddac..74353837fe 100644 --- a/apps/webapp/app/routes/onboarding.vercel.tsx +++ b/apps/webapp/app/routes/vercel.onboarding.tsx @@ -5,24 +5,24 @@ import { z } from "zod"; import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout"; import { BackgroundWrapper } from "~/components/BackgroundWrapper"; import { Button } from "~/components/primitives/Buttons"; +import { Callout } from "~/components/primitives/Callout"; import { Fieldset } from "~/components/primitives/Fieldset"; import { FormButtons } from "~/components/primitives/FormButtons"; import { FormTitle } from "~/components/primitives/FormTitle"; -import { Select, SelectItem, SelectGroup, SelectGroupLabel } from "~/components/primitives/Select"; +import { Select, SelectItem } from "~/components/primitives/Select"; import { prisma } from "~/db.server"; import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; -import { confirmBasicDetailsPath, v3ProjectSettingsPath, newProjectPath } from "~/utils/pathBuilder"; +import { confirmBasicDetailsPath, newProjectPath } from "~/utils/pathBuilder"; import { redirectWithErrorMessage } from "~/models/message.server"; -import { VercelIntegrationRepository } from "~/models/vercelIntegration.server"; -import { env } from "~/env.server"; - +import { generateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server"; const LoaderParamsSchema = z.object({ organizationId: z.string().optional().nullable(), code: z.string(), - configurationId: z.string(), + configurationId: z.string().optional().nullable(), next: z.string().optional().nullable(), + error: z.string().optional().nullable(), }); const SelectOrgActionSchema = z.object({ @@ -47,67 +47,6 @@ const ActionSchema = z.discriminatedUnion("action", [ SelectProjectActionSchema, ]); -type TokenResponse = { - accessToken: string; - tokenType: string; - teamId?: string; - userId?: string; - raw: Record; -}; - -async function exchangeCodeForToken(code: string): Promise { - const clientId = env.VERCEL_INTEGRATION_CLIENT_ID; - const clientSecret = env.VERCEL_INTEGRATION_CLIENT_SECRET; - const redirectUri = `${env.APP_ORIGIN}/callback/vercel`; - - if (!clientId || !clientSecret) { - logger.error("Vercel integration not configured"); - return null; - } - - try { - const response = await fetch("https://api.vercel.com/v2/oauth/access_token", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - client_id: clientId, - client_secret: clientSecret, - code, - redirect_uri: redirectUri, - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - logger.error("Failed to exchange Vercel OAuth code", { - status: response.status, - error: errorText, - }); - return null; - } - - const data = (await response.json()) as { - access_token: string; - token_type: string; - team_id?: string; - user_id?: string; - }; - - return { - accessToken: data.access_token, - tokenType: data.token_type, - teamId: data.team_id, - userId: data.user_id, - raw: data as Record, - }; - } catch (error) { - logger.error("Error exchanging Vercel OAuth code", { error }); - return null; - } -} - export async function loader({ request }: LoaderFunctionArgs) { const userId = await requireUserId(request); const url = new URL(request.url); @@ -117,6 +56,7 @@ export async function loader({ request }: LoaderFunctionArgs) { code: url.searchParams.get("code"), configurationId: url.searchParams.get("configurationId"), next: url.searchParams.get("next"), + error: url.searchParams.get("error"), }); if (!params.success) { @@ -128,6 +68,17 @@ export async function loader({ request }: LoaderFunctionArgs) { ); } + const { error } = params.data; + if (error === "expired") { + return json({ + step: "error" as const, + error: "Your installation session has expired. Please start the installation again.", + code: params.data.code, + configurationId: params.data.configurationId, + next: params.data.next, + }); + } + const organizations = await prisma.organization.findMany({ where: { members: { @@ -158,6 +109,7 @@ export async function loader({ request }: LoaderFunctionArgs) { }, }); + // New user: no organizations if (organizations.length === 0) { const onboardingParams = new URLSearchParams({ code: params.data.code, @@ -196,21 +148,6 @@ export async function loader({ request }: LoaderFunctionArgs) { }); } - // Single org - automatically move to project selection - if (organizations.length === 1) { - const singleOrg = organizations[0]; - const projectParams = new URLSearchParams({ - organizationId: singleOrg.id, - code: params.data.code, - configurationId: params.data.configurationId, - }); - if (params.data.next) { - projectParams.set("next", params.data.next); - } - return redirect(`/onboarding/vercel?${projectParams.toString()}`); - } - - // Multiple orgs - show org selection return json({ step: "org" as const, organizations, @@ -252,13 +189,12 @@ export async function action({ request }: ActionFunctionArgs) { projectParams.set("next", next); } - return redirect(`/onboarding/vercel?${projectParams.toString()}`); + return redirect(`/vercel/onboarding?${projectParams.toString()}`); } // Handle project selection const { projectId, organizationId } = submission.data; - // Install integration with selected project const project = await prisma.project.findFirst({ where: { id: projectId, @@ -275,16 +211,7 @@ export async function action({ request }: ActionFunctionArgs) { if (!project) { logger.error("Project not found or access denied", { projectId, userId }); - return redirectWithErrorMessage("/", request, "Project not found."); - } - - const tokenResponse = await exchangeCodeForToken(code); - if (!tokenResponse) { - return redirectWithErrorMessage( - "/", - request, - "Failed to connect to Vercel. Your session may have expired. Please try again from Vercel." - ); + return json({ error: "Project not found" }, { status: 404 }); } const environment = await prisma.runtimeEnvironment.findFirst({ @@ -296,80 +223,33 @@ export async function action({ request }: ActionFunctionArgs) { }); if (!environment) { - return redirectWithErrorMessage( - "/", - request, - "Failed to find project environment. Please try again." - ); + logger.error("Environment not found", { projectId: project.id }); + return json({ error: "Environment not found" }, { status: 404 }); } try { - let orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationByTeamId( - project.organizationId, - tokenResponse.teamId ?? null - ); - - if (orgIntegration) { - await VercelIntegrationRepository.updateVercelOrgIntegrationToken({ - integrationId: orgIntegration.id, - accessToken: tokenResponse.accessToken, - tokenType: tokenResponse.tokenType, - teamId: tokenResponse.teamId ?? null, - userId: tokenResponse.userId, - installationId: configurationId, - raw: tokenResponse.raw, - }); - - orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationByTeamId( - project.organizationId, - tokenResponse.teamId ?? null - ); - } else { - await VercelIntegrationRepository.createVercelOrgIntegration({ - accessToken: tokenResponse.accessToken, - tokenType: tokenResponse.tokenType, - teamId: tokenResponse.teamId ?? null, - userId: tokenResponse.userId, - installationId: configurationId, - organization: project.organization, - raw: tokenResponse.raw, - }); - - orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationByTeamId( - project.organizationId, - tokenResponse.teamId ?? null - ); - } - - if (!orgIntegration) { - throw new Error("Failed to create or find Vercel organization integration"); - } - - logger.info("Vercel organization integration created successfully", { + const state = await generateVercelOAuthState({ organizationId: project.organizationId, projectId: project.id, - teamId: tokenResponse.teamId, + environmentSlug: environment.slug, + organizationSlug: project.organization.slug, + projectSlug: project.slug, }); - const settingsPath = v3ProjectSettingsPath( - { slug: project.organization.slug }, - { slug: project.slug }, - { slug: environment.slug } - ); - - const params = new URLSearchParams({ vercelOnboarding: "true", fromMarketplace: "true" }); + const params = new URLSearchParams({ + state, + code, + configurationId, + origin: "marketplace", + }); if (next) { params.set("next", next); } - return redirect(`${settingsPath}?${params.toString()}`); + return redirect(`/vercel/connect?${params.toString()}`); } catch (error) { - logger.error("Failed to create Vercel integration", { error }); - return redirectWithErrorMessage( - "/", - request, - "Failed to create Vercel integration. Please try again." - ); + logger.error("Failed to generate Vercel OAuth state", { error }); + return json({ error: "Failed to generate installation state" }, { status: 500 }); } } @@ -378,6 +258,25 @@ export default function VercelOnboardingPage() { const navigation = useNavigation(); const isSubmitting = navigation.state === "submitting"; + if (data.step === "error") { + return ( + + + + + + + + + ); + } + if (data.step === "org") { return ( @@ -439,7 +338,6 @@ export default function VercelOnboardingPage() { Continue
-
@@ -449,7 +347,6 @@ export default function VercelOnboardingPage() { ); } - // Project selection step return ( diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 52225d6721..d82d945ae7 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -164,7 +164,7 @@ export function vercelAppInstallPath(organizationSlug: string, projectSlug: stri } export function vercelCallbackPath() { - return `/callback/vercel`; + return `/vercel/callback`; } export function vercelResourcePath( From 2cd89b03ff1c01de5013812a4414674d8fc0ae78 Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Fri, 16 Jan 2026 11:45:44 +0100 Subject: [PATCH 15/33] feat(vercel): pass environment id and centralize OAuth exchange Update onboarding flow to propagate the selected Vercel environment when reloading data and move the OAuth token exchange into the VercelIntegration repository. - Add vercelEnvironmentId parameter to onDataReload and include it in the vercelFetcher load URL so the UI can reload with the specific environment context. - Remove local exchangeCodeForToken implementation from the route and call VercelIntegrationRepository.exchangeCodeForToken instead to centralize OAuth token exchange logic. - Rework redirects and settings path handling to compute settingsPath earlier and reuse it consistently on success and error paths. - Import env into the vercel integration model file (prepares use of configuration from env.server). These changes improve consistency, reduce duplicated OAuth code, and ensure environment-specific reloads work correctly. --- .../app/models/vercelIntegration.server.ts | 107 +++++++- .../v3/VercelSettingsPresenter.server.ts | 230 ++++++++++-------- .../route.tsx | 6 +- ...cts.$projectParam.env.$envParam.vercel.tsx | 83 ++++--- apps/webapp/app/routes/vercel.connect.tsx | 85 +------ apps/webapp/app/routes/vercel.onboarding.tsx | 14 +- 6 files changed, 287 insertions(+), 238 deletions(-) diff --git a/apps/webapp/app/models/vercelIntegration.server.ts b/apps/webapp/app/models/vercelIntegration.server.ts index ec32d2363a..ceff4b629e 100644 --- a/apps/webapp/app/models/vercelIntegration.server.ts +++ b/apps/webapp/app/models/vercelIntegration.server.ts @@ -7,6 +7,7 @@ import { } from "@trigger.dev/database"; import { z } from "zod"; import { $transaction, prisma } from "~/db.server"; +import { env } from "~/env.server"; import { logger } from "~/services/logger.server"; import { getSecretStore } from "~/services/secrets/secretStore.server"; import { generateFriendlyId } from "~/v3/friendlyIdentifiers"; @@ -43,6 +44,14 @@ export const VercelSecretSchema = z.object({ export type VercelSecret = z.infer; +export type TokenResponse = { + accessToken: string; + tokenType: string; + teamId?: string; + userId?: string; + raw: Record; +}; + export type VercelEnvironmentVariable = { id: string; key: string; @@ -71,26 +80,101 @@ export type VercelAPIResult = { error: string; }; -function isVercelAuthError(error: unknown): boolean { +const VercelErrorSchema = z.union([ + z.object({ status: z.number() }), + z.object({ response: z.object({ status: z.number() }) }), + z.object({ statusCode: z.number() }), +]); + +function extractVercelErrorStatus(error: unknown): number | null { if (error && typeof error === 'object' && 'status' in error) { - const status = (error as { status?: number }).status; - return status === 401 || status === 403; + const parsed = VercelErrorSchema.safeParse(error); + if (parsed.success && 'status' in parsed.data) { + return parsed.data.status; + } } + if (error && typeof error === 'object' && 'response' in error) { - const response = (error as { response?: { status?: number } }).response; - return response?.status === 401 || response?.status === 403; + const parsed = VercelErrorSchema.safeParse(error); + if (parsed.success && 'response' in parsed.data) { + return parsed.data.response.status; + } } + if (error && typeof error === 'object' && 'statusCode' in error) { - const statusCode = (error as { statusCode?: number }).statusCode; - return statusCode === 401 || statusCode === 403; + const parsed = VercelErrorSchema.safeParse(error); + if (parsed.success && 'statusCode' in parsed.data) { + return parsed.data.statusCode; + } } - if (error && typeof error === 'string' && (error.includes('401') || error.includes('403'))) { - return true; + + if (typeof error === 'string') { + if (error.includes('401')) return 401; + if (error.includes('403')) return 403; } - return false; + + return null; +} + +function isVercelAuthError(error: unknown): boolean { + const status = extractVercelErrorStatus(error); + return status === 401 || status === 403; } export class VercelIntegrationRepository { + static async exchangeCodeForToken(code: string): Promise { + const clientId = env.VERCEL_INTEGRATION_CLIENT_ID; + const clientSecret = env.VERCEL_INTEGRATION_CLIENT_SECRET; + const redirectUri = `${env.APP_ORIGIN}/vercel/callback`; + + if (!clientId || !clientSecret) { + logger.error("Vercel integration not configured"); + return null; + } + + try { + const response = await fetch("https://api.vercel.com/v2/oauth/access_token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + code, + redirect_uri: redirectUri, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + logger.error("Failed to exchange Vercel OAuth code", { + status: response.status, + error: errorText, + }); + return null; + } + + const data = (await response.json()) as { + access_token: string; + token_type: string; + team_id?: string; + user_id?: string; + }; + + return { + accessToken: data.access_token, + tokenType: data.token_type, + teamId: data.team_id, + userId: data.user_id, + raw: data as Record, + }; + } catch (error) { + logger.error("Error exchanging Vercel OAuth code", { error }); + return null; + } + } + static async getVercelClient( integration: OrganizationIntegration & { tokenReference: SecretReference } ): Promise { @@ -253,7 +337,7 @@ export class VercelIntegrationRepository { static async getVercelEnvironmentVariables( client: Vercel, projectId: string, - teamId?: string | null + teamId?: string | null, ): Promise> { try { const response = await client.projects.filterProjectEnvs({ @@ -277,6 +361,7 @@ export class VercelIntegrationRepository { type, isSecret, target: normalizeTarget(env.target), + customEnvironmentIds: env.customEnvironmentIds as string[] ?? [], }; }), }; diff --git a/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts b/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts index 0fa14bb41f..f747921a10 100644 --- a/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts @@ -45,7 +45,6 @@ export type VercelOnboardingData = { environmentVariables: VercelEnvironmentVariable[]; availableProjects: VercelAvailableProject[]; hasProjectSelected: boolean; - hasProjectSelected: boolean; authInvalid?: boolean; existingVariables: Record; }; @@ -234,7 +233,11 @@ export class VercelSettingsPresenter extends BasePresenter { /** * Get data needed for the onboarding modal (custom environments and env vars) */ - public async getOnboardingData(projectId: string, organizationId: string): Promise { + public async getOnboardingData( + projectId: string, + organizationId: string, + vercelEnvironmentId?: string + ): Promise { try { const orgIntegration = await (this._replica as PrismaClient).organizationIntegration.findFirst({ where: { @@ -258,7 +261,6 @@ export class VercelSettingsPresenter extends BasePresenter { environmentVariables: [], availableProjects: [], hasProjectSelected: false, - hasProjectSelected: false, authInvalid: true, existingVariables: {}, }; @@ -268,132 +270,144 @@ export class VercelSettingsPresenter extends BasePresenter { const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration); // Get the project integration to find the Vercel project ID (if selected) - const projectIntegration = await (this._replica as PrismaClient).organizationProjectIntegration.findFirst({ - where: { - projectId, - deletedAt: null, - organizationIntegration: { - service: "VERCEL", + const projectIntegration = await (this._replica as PrismaClient).organizationProjectIntegration.findFirst({ + where: { + projectId, deletedAt: null, + organizationIntegration: { + service: "VERCEL", + deletedAt: null, + }, }, - }, - }); + }); // Always fetch available projects for selection - const availableProjectsResult = await VercelIntegrationRepository.getVercelProjects(client, teamId); - - if (!availableProjectsResult.success) { - return { - customEnvironments: [], - environmentVariables: [], - availableProjects: [], - hasProjectSelected: false, - hasProjectSelected: false, - authInvalid: availableProjectsResult.authInvalid, - existingVariables: {}, - }; - } + const availableProjectsResult = await VercelIntegrationRepository.getVercelProjects(client, teamId); + + if (!availableProjectsResult.success) { + return { + customEnvironments: [], + environmentVariables: [], + availableProjects: [], + hasProjectSelected: false, + authInvalid: availableProjectsResult.authInvalid, + existingVariables: {}, + }; + } // If no project integration exists, return early with just available projects - if (!projectIntegration) { - return { - customEnvironments: [], - environmentVariables: [], - availableProjects: availableProjectsResult.data, - availableProjects: availableProjectsResult.data, - hasProjectSelected: false, - existingVariables: {}, - }; - } + if (!projectIntegration) { + return { + customEnvironments: [], + environmentVariables: [], + availableProjects: availableProjectsResult.data, + hasProjectSelected: false, + existingVariables: {}, + }; + } // Fetch custom environments, project env vars, and shared env vars in parallel - const [customEnvironmentsResult, projectEnvVarsResult, sharedEnvVarsResult] = await Promise.all([ - VercelIntegrationRepository.getVercelCustomEnvironments( - client, - projectIntegration.externalEntityId, - teamId - ), - VercelIntegrationRepository.getVercelEnvironmentVariables( - client, - projectIntegration.externalEntityId, - teamId - ), + const [customEnvironmentsResult, projectEnvVarsResult, sharedEnvVarsResult] = await Promise.all([ + VercelIntegrationRepository.getVercelCustomEnvironments( + client, + projectIntegration.externalEntityId, + teamId + ), + VercelIntegrationRepository.getVercelEnvironmentVariables( + client, + projectIntegration.externalEntityId, + teamId + ), // Only fetch shared env vars if teamId is available - teamId - ? VercelIntegrationRepository.getVercelSharedEnvironmentVariables( - client, - teamId, - projectIntegration.externalEntityId - ) - : Promise.resolve({ success: true as const, data: [] }), - ]); - - // Check if any of the API calls failed due to auth issues - const authInvalid = !customEnvironmentsResult.success && customEnvironmentsResult.authInvalid || - !projectEnvVarsResult.success && projectEnvVarsResult.authInvalid || - !sharedEnvVarsResult.success && sharedEnvVarsResult.authInvalid; - - if (authInvalid) { - return { - customEnvironments: [], - environmentVariables: [], - availableProjects: availableProjectsResult.data, - hasProjectSelected: true, - availableProjects: availableProjectsResult.data, - hasProjectSelected: true, - authInvalid: true, - existingVariables: {}, - }; - } + teamId + ? VercelIntegrationRepository.getVercelSharedEnvironmentVariables( + client, + teamId, + projectIntegration.externalEntityId + ) + : Promise.resolve({ success: true as const, data: [] }), + ]); + // Check if any of the API calls failed due to auth issues + const authInvalid = + (!customEnvironmentsResult.success && customEnvironmentsResult.authInvalid) || + (!projectEnvVarsResult.success && projectEnvVarsResult.authInvalid) || + (!sharedEnvVarsResult.success && sharedEnvVarsResult.authInvalid); + + if (authInvalid) { + return { + customEnvironments: [], + environmentVariables: [], + availableProjects: availableProjectsResult.data, + hasProjectSelected: true, + authInvalid: true, + existingVariables: {}, + }; + } // Extract data from successful results - const customEnvironments = customEnvironmentsResult.success ? customEnvironmentsResult.data : []; - const projectEnvVars = projectEnvVarsResult.success ? projectEnvVarsResult.data : []; - const sharedEnvVars = sharedEnvVarsResult.success ? sharedEnvVarsResult.data : []; + const customEnvironments = customEnvironmentsResult.success ? customEnvironmentsResult.data : []; + const projectEnvVars = projectEnvVarsResult.success ? projectEnvVarsResult.data : []; + const sharedEnvVars = sharedEnvVarsResult.success ? sharedEnvVarsResult.data : []; // Merge project and shared env vars (project vars take precedence) // Also filter out TRIGGER_SECRET_KEY as it's managed by Trigger.dev - const projectEnvVarKeys = new Set(projectEnvVars.map((v) => v.key)); - const mergedEnvVars: VercelEnvironmentVariable[] = [ - ...projectEnvVars.filter((v) => v.key !== "TRIGGER_SECRET_KEY"), - ...sharedEnvVars - .filter((v) => !projectEnvVarKeys.has(v.key) && v.key !== "TRIGGER_SECRET_KEY") - .map((v) => ({ - id: v.id, - key: v.key, - type: v.type as VercelEnvironmentVariable["type"], - isSecret: v.isSecret, - target: v.target, - isShared: true, - })), - ]; + const projectEnvVarKeys = new Set(projectEnvVars.map((v) => v.key)); + const mergedEnvVars: VercelEnvironmentVariable[] = [ + ...projectEnvVars + .filter((v) => v.key !== "TRIGGER_SECRET_KEY") + .map((v) => { + const envVar = { ...v }; + // Check if this env var is used in the selected custom environment + if (vercelEnvironmentId && (v as any).customEnvironmentIds?.includes(vercelEnvironmentId)) { + envVar.target = [...v.target, 'staging']; + } + return envVar; + }), + ...sharedEnvVars + .filter((v) => !projectEnvVarKeys.has(v.key) && v.key !== "TRIGGER_SECRET_KEY") + .map((v) => { + const envVar = { + id: v.id, + key: v.key, + type: v.type as VercelEnvironmentVariable["type"], + isSecret: v.isSecret, + target: v.target, + isShared: true, + }; + // Check if this shared env var is used in the selected custom environment + if (vercelEnvironmentId && (v as any).customEnvironmentIds?.includes(vercelEnvironmentId)) { + envVar.target = [...v.target, 'staging']; + } + return envVar; + }), + ]; // Sort environment variables alphabetically - const sortedEnvVars = [...mergedEnvVars].sort((a, b) => - a.key.localeCompare(b.key) - ); + const sortedEnvVars = [...mergedEnvVars].sort((a, b) => + a.key.localeCompare(b.key) + ); // Get existing environment variables in Trigger.dev - const envVarRepository = new EnvironmentVariablesRepository(this._replica as PrismaClient); - const existingVariables = await envVarRepository.getProject(projectId); - const existingVariablesRecord: Record = {}; - for (const v of existingVariables) { - existingVariablesRecord[v.key] = { - environments: v.values.map((val) => val.environment.type), - }; - } + const envVarRepository = new EnvironmentVariablesRepository(this._replica as PrismaClient); + const existingVariables = await envVarRepository.getProject(projectId); + const existingVariablesRecord: Record = {}; + for (const v of existingVariables) { + existingVariablesRecord[v.key] = { + environments: v.values.map((val) => val.environment.type), + }; + } - return { - customEnvironments, - environmentVariables: sortedEnvVars, - availableProjects: availableProjectsResult.data, - hasProjectSelected: true, - existingVariables: existingVariablesRecord, - }; + return { + customEnvironments, + environmentVariables: sortedEnvVars, + availableProjects: availableProjectsResult.data, + hasProjectSelected: true, + existingVariables: existingVariablesRecord, + }; } catch (error) { - // Log the error and return null to indicate failure console.error("Error in getOnboardingData:", error); return null; - } } + } + } } \ No newline at end of file diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx index 94ca0d150a..a36827be4a 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx @@ -623,9 +623,11 @@ export default function Page() { hasStagingEnvironment={vercelFetcher.data?.hasStagingEnvironment ?? false} hasOrgIntegration={vercelFetcher.data?.hasOrgIntegration ?? false} nextUrl={nextUrl ?? undefined} - onDataReload={() => { + onDataReload={(vercelEnvironmentId) => { vercelFetcher.load( - `${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true` + `${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true${ + vercelEnvironmentId ? `&vercelEnvironmentId=${vercelEnvironmentId}` : "" + }` ); }} /> diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx index c23fc08c06..5b3395eb59 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx @@ -72,7 +72,7 @@ import { useEffect, useState, useCallback, useRef } from "react"; import { RuntimeEnvironmentType } from "@trigger.dev/database"; function shortEnvironmentLabel(environment: RuntimeEnvironmentType) { - switch (environment) { + switch (environment.toUpperCase()) { case "PRODUCTION": return "Prod"; case "STAGING": @@ -98,10 +98,11 @@ function formatVercelTargets(targets: string[]): string { production: "Production", preview: "Preview", development: "Development", + staging: "Staging", }; return targets - .map((t) => targetLabels[t] || t) + .map((t) => targetLabels[t.toLowerCase()] || t) .join(", "); } @@ -237,10 +238,15 @@ export async function loader({ request, params }: LoaderFunctionArgs) { const result = resultOrFail.value; const url = new URL(request.url); const needsOnboarding = url.searchParams.get("vercelOnboarding") === "true"; + const vercelEnvironmentId = url.searchParams.get("vercelEnvironmentId") || undefined; let onboardingData: VercelOnboardingData | null = null; if (needsOnboarding) { - onboardingData = await presenter.getOnboardingData(project.id, project.organizationId); + onboardingData = await presenter.getOnboardingData( + project.id, + project.organizationId, + vercelEnvironmentId + ); } const authInvalid = onboardingData?.authInvalid || result.authInvalid || false; @@ -726,14 +732,14 @@ function ConnectedVercelProjectForm({ connectedProject.integrationData.config.vercelStagingEnvironment && connectedProject.integrationData.config.vercelStagingEnvironment !== "" && connectedProject.integrationData.config.vercelStagingName && ( -
+
Vercel environment mapped to Trigger.dev's Staging environment.
- + {connectedProject.integrationData.config.vercelStagingName}
@@ -1031,7 +1037,7 @@ function VercelOnboardingModal({ hasStagingEnvironment: boolean; hasOrgIntegration: boolean; nextUrl?: string; - onDataReload?: () => void; + onDataReload?: (vercelStagingEnvironment?: string) => void; }) { const navigation = useNavigation(); const fetcher = useTypedFetcher(); @@ -1129,7 +1135,7 @@ function VercelOnboardingModal({ case "loading-env-vars": // Reload data to get environment variables if (onDataReload) { - onDataReload(); + onDataReload(vercelStagingEnvironment || undefined); } // Transition to env-var-sync when data is ready (handled by another effect) break; @@ -1142,12 +1148,12 @@ function VercelOnboardingModal({ case "completed": break; } - }, [isOpen, state, hasOrgIntegration, hasProjectSelected, onboardingData, hasCustomEnvs, hasStagingEnvironment, onDataReload]); + }, [isOpen, state, hasOrgIntegration, hasProjectSelected, onboardingData, hasCustomEnvs, hasStagingEnvironment, vercelStagingEnvironment, onDataReload]); // Watch for data loading completion useEffect(() => { - if (!onboardingData?.authInvalid && state === "loading-projects" && onboardingData?.availableProjects && onboardingData.availableProjects.length > 0) { - // Projects loaded, transition to project selection + if (!onboardingData?.authInvalid && state === "loading-projects" && onboardingData?.availableProjects !== undefined) { + // Projects loaded (whether empty or not), transition to project selection setState("project-selection"); } }, [state, onboardingData?.availableProjects, onboardingData?.authInvalid]); @@ -1166,6 +1172,7 @@ function VercelOnboardingModal({ setState("loading-env-mapping"); // Reload data to get updated project info and env vars if (onDataReload) { + console.log("Vercel onboarding: Reloading data after successful project selection to get updated project info and env vars"); onDataReload(); } } else if (fetcher.data && "error" in fetcher.data && typeof fetcher.data.error === "string") { @@ -1267,24 +1274,31 @@ function VercelOnboardingModal({ const handleSkipEnvMapping = useCallback(() => { // Skip the env mapping step and go directly to loading env vars + setVercelStagingEnvironment(""); setState("loading-env-vars"); }, []); const handleUpdateEnvMapping = useCallback(() => { + if (!vercelStagingEnvironment) { + setState("loading-env-vars"); + return; + } + + // Save the environment mapping first const formData = new FormData(); formData.append("action", "update-env-mapping"); - if (vercelStagingEnvironment) { - formData.append("vercelStagingEnvironment", vercelStagingEnvironment); - const environment = customEnvironments.find((env) => env.id === vercelStagingEnvironment); - if (environment) { - formData.append("vercelStagingName", environment.slug); - } + formData.append("vercelStagingEnvironment", vercelStagingEnvironment); + const environment = customEnvironments.find((env) => env.id === vercelStagingEnvironment); + if (environment) { + formData.append("vercelStagingName", environment.slug); } + envMappingFetcher.submit(formData, { method: "post", action: actionUrl, }); - }, [vercelStagingEnvironment, customEnvironments, envMappingFetcher, actionUrl]); + + }, [vercelStagingEnvironment, customEnvironments, envMappingFetcher, actionUrl, onDataReload]); const handleFinishOnboarding = useCallback((e: React.FormEvent) => { e.preventDefault(); @@ -1365,7 +1379,7 @@ function VercelOnboardingModal({ if (isLoadingState) { return ( - !open && onClose()}> + !open && !fromMarketplaceContext && onClose()}>
@@ -1386,7 +1400,7 @@ function VercelOnboardingModal({ const showEnvVarSync = state === "env-var-sync"; return ( - !open && onClose()}> + !open && !fromMarketplaceContext && onClose()}>
@@ -1544,7 +1558,7 @@ function VercelOnboardingModal({ name="syncEnvVarsMapping" value={JSON.stringify(syncEnvVarsMapping)} /> - {nextUrl && !fromMarketplaceContext && ( + {nextUrl && fromMarketplaceContext && (
- {overlappingEnvVarsCount > 0 && ( -
- - - {overlappingEnvVarsCount} env vars are going to be updated (marked with{" "} - - underline - - ) - -
- )} {/* Expandable env var list */} {pullEnvVarsFromVercel && envVars.length > 0 && ( @@ -1624,8 +1626,8 @@ function VercelOnboardingModal({ {existingVars[envVar.key] ? ( - -
+ +
{envVar.key}
@@ -1665,6 +1667,19 @@ function VercelOnboardingModal({ )}
)} + + {overlappingEnvVarsCount > 0 && pullEnvVarsFromVercel && ( +
+ + + {overlappingEnvVarsCount} env vars are going to be updated (marked with{" "} + + underline + + ) + +
+ )} ; -}; - -async function exchangeCodeForToken(code: string): Promise { - const clientId = env.VERCEL_INTEGRATION_CLIENT_ID; - const clientSecret = env.VERCEL_INTEGRATION_CLIENT_SECRET; - const redirectUri = `${env.APP_ORIGIN}/vercel/callback`; - - if (!clientId || !clientSecret) { - logger.error("Vercel integration not configured"); - return null; - } - - try { - const response = await fetch("https://api.vercel.com/v2/oauth/access_token", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - client_id: clientId, - client_secret: clientSecret, - code, - redirect_uri: redirectUri, - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - logger.error("Failed to exchange Vercel OAuth code", { - status: response.status, - error: errorText, - }); - return null; - } - - const data = (await response.json()) as { - access_token: string; - token_type: string; - team_id?: string; - user_id?: string; - }; - - return { - accessToken: data.access_token, - tokenType: data.token_type, - teamId: data.team_id, - userId: data.user_id, - raw: data as Record, - }; - } catch (error) { - logger.error("Error exchanging Vercel OAuth code", { error }); - return null; - } -} - async function createOrFindVercelIntegration( organizationId: string, projectId: string, @@ -177,7 +115,7 @@ export async function loader({ request }: LoaderFunctionArgs) { throw new Response("Project not found", { status: 404 }); } - const tokenResponse = await exchangeCodeForToken(code); + const tokenResponse = await VercelIntegrationRepository.exchangeCodeForToken(code); if (!tokenResponse) { const params = new URLSearchParams({ error: "expired" }); return redirect(`/vercel/onboarding?${params.toString()}`); @@ -199,6 +137,12 @@ export async function loader({ request }: LoaderFunctionArgs) { throw new Response("Environment not found", { status: 404 }); } + const settingsPath = v3ProjectSettingsPath( + { slug: stateData.organizationSlug }, + { slug: stateData.projectSlug }, + { slug: environment.slug } + ); + try { await createOrFindVercelIntegration(stateData.organizationId, stateData.projectId, tokenResponse, configurationId, origin); @@ -208,12 +152,6 @@ export async function loader({ request }: LoaderFunctionArgs) { teamId: tokenResponse.teamId, }); - const settingsPath = v3ProjectSettingsPath( - { slug: stateData.organizationSlug }, - { slug: stateData.projectSlug }, - { slug: environment.slug } - ); - const params = new URLSearchParams({ vercelOnboarding: "true", origin }); if (next) { params.set("next", next); @@ -222,13 +160,6 @@ export async function loader({ request }: LoaderFunctionArgs) { return redirect(`${settingsPath}?${params.toString()}`); } catch (error) { logger.error("Failed to complete Vercel integration", { error }); - - const settingsPath = v3ProjectSettingsPath( - { slug: stateData.organizationSlug }, - { slug: stateData.projectSlug }, - { slug: environment.slug } - ); - throw redirect(settingsPath); } } diff --git a/apps/webapp/app/routes/vercel.onboarding.tsx b/apps/webapp/app/routes/vercel.onboarding.tsx index 74353837fe..676605245f 100644 --- a/apps/webapp/app/routes/vercel.onboarding.tsx +++ b/apps/webapp/app/routes/vercel.onboarding.tsx @@ -1,5 +1,6 @@ import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; import { json, redirect } from "@remix-run/server-runtime"; +import { useState } from "react"; import { Form, useLoaderData, useNavigation } from "@remix-run/react"; import { z } from "zod"; import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout"; @@ -246,7 +247,7 @@ export async function action({ request }: ActionFunctionArgs) { params.set("next", next); } - return redirect(`/vercel/connect?${params.toString()}`); + return redirect(`/vercel/connect?${params.toString()}`, 303); } catch (error) { logger.error("Failed to generate Vercel OAuth state", { error }); return json({ error: "Failed to generate installation state" }, { status: 500 }); @@ -257,6 +258,7 @@ export default function VercelOnboardingPage() { const data = useLoaderData(); const navigation = useNavigation(); const isSubmitting = navigation.state === "submitting"; + const [isInstalling, setIsInstalling] = useState(false); if (data.step === "error") { return ( @@ -355,7 +357,7 @@ export default function VercelOnboardingPage() { title="Select Project" description={`Choose which project in ${data.organization.title} to install the Vercel integration into.`} /> -
+ setIsInstalling(true)}> @@ -384,14 +386,14 @@ export default function VercelOnboardingPage() {
-
-
From 1c88cfae71908833bce6597c43f2497bf1b30b8d Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Fri, 23 Jan 2026 19:22:40 +0100 Subject: [PATCH 16/33] feat(api): Add environmentSlug for initialize deployment response body --- apps/webapp/app/routes/api.v1.deployments.ts | 1 + packages/core/src/v3/schemas/api.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/webapp/app/routes/api.v1.deployments.ts b/apps/webapp/app/routes/api.v1.deployments.ts index 0190ba123d..829cbdfcc7 100644 --- a/apps/webapp/app/routes/api.v1.deployments.ts +++ b/apps/webapp/app/routes/api.v1.deployments.ts @@ -48,6 +48,7 @@ export async function action({ request, params }: ActionFunctionArgs) { deployment.externalBuildData as InitializeDeploymentResponseBody["externalBuildData"], imageTag: imageRef, imagePlatform: deployment.imagePlatform, + environmentSlug: authenticatedEnv.slug, eventStream, }; diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index df81cdb623..dffb3e0c13 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -577,6 +577,7 @@ export const InitializeDeploymentResponseBody = z.object({ version: z.string(), imageTag: z.string(), imagePlatform: z.string(), + environmentSlug: z.string(), externalBuildData: ExternalBuildData.optional().nullable(), eventStream: z .object({ From aae795737c9bed43e4afe689472b96bba2b24fa2 Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Mon, 26 Jan 2026 12:01:01 +0100 Subject: [PATCH 17/33] feat(vercel): preserve secret status when syncing env vars&improve setting names - Query existing environment variable values to detect which keys are currently marked as secrets and build a set of secret keys. - Split variables into secret and non-secret groups so non-secrets can be created without unintentionally overriding secret flags. - Create non-secret variables via envVarRepository.create and update synced counters and error handling accordingly. - Rename the Vercel integration settings to be more meaningful - Iterate on Vercel onboarding flow, add GitHub step --- .../app/models/vercelIntegration.server.ts | 132 ++- .../EnvironmentVariablesPresenter.server.ts | 7 +- .../v3/VercelSettingsPresenter.server.ts | 121 +- .../route.tsx | 66 +- ....projects.$projectParam.vercel.projects.ts | 5 +- ...ojects.$projectRef.envvars.$slug.import.ts | 2 + ...cts.$projectParam.env.$envParam.vercel.tsx | 1051 +++++++++++------ .../app/services/vercelIntegration.server.ts | 99 +- .../environmentVariablesRepository.server.ts | 34 +- .../vercel/vercelProjectIntegrationSchema.ts | 189 ++- 10 files changed, 1161 insertions(+), 545 deletions(-) diff --git a/apps/webapp/app/models/vercelIntegration.server.ts b/apps/webapp/app/models/vercelIntegration.server.ts index ceff4b629e..0080848c76 100644 --- a/apps/webapp/app/models/vercelIntegration.server.ts +++ b/apps/webapp/app/models/vercelIntegration.server.ts @@ -1187,6 +1187,15 @@ export class VercelIntegrationRepository { } } + logger.info("Vercel pullEnvVarsFromVercel: environment mapping", { + projectId: params.projectId, + vercelProjectId: params.vercelProjectId, + envMappingCount: envMapping.length, + envMapping: envMapping.map(m => ({ type: m.triggerEnvType, target: m.vercelTarget })), + runtimeEnvironmentsCount: runtimeEnvironments.length, + runtimeEnvironments: runtimeEnvironments.map(e => e.type), + }); + if (envMapping.length === 0) { logger.warn("No environments to sync for Vercel integration", { projectId: params.projectId, @@ -1252,6 +1261,16 @@ export class VercelIntegrationRepository { continue; } + logger.info("Vercel pullEnvVarsFromVercel: fetched env vars for target", { + projectId: params.projectId, + vercelTarget: mapping.vercelTarget, + triggerEnvType: mapping.triggerEnvType, + projectEnvVarsCount: projectEnvVars.length, + sharedEnvVarsCount: filteredSharedEnvVars.length, + mergedEnvVarsCount: mergedEnvVars.length, + mergedEnvVarKeys: mergedEnvVars.map(v => v.key), + }); + // Filter env vars based on syncEnvVarsMapping and exclude TRIGGER_SECRET_KEY const varsToSync = mergedEnvVars.filter((envVar) => { // Skip secrets (they don't have values anyway) @@ -1270,34 +1289,105 @@ export class VercelIntegrationRepository { ); }); + logger.info("Vercel pullEnvVarsFromVercel: filtered vars to sync", { + projectId: params.projectId, + vercelTarget: mapping.vercelTarget, + triggerEnvType: mapping.triggerEnvType, + varsToSyncCount: varsToSync.length, + varsToSyncKeys: varsToSync.map(v => v.key), + }); + if (varsToSync.length === 0) { continue; } - // Create env vars in Trigger.dev - const result = await envVarRepository.create(params.projectId, { - override: true, // Override existing vars - environmentIds: [mapping.runtimeEnvironmentId], - isSecret: false, // Vercel env vars we can read are not secrets in our system - variables: varsToSync.map((v) => ({ - key: v.key, - value: v.value, - })), + // Query existing env vars to check which ones are already secrets + // We need to preserve the secret status when overriding + const existingSecretKeys = new Set(); + const existingVarValues = await prisma.environmentVariableValue.findMany({ + where: { + environmentId: mapping.runtimeEnvironmentId, + variable: { + projectId: params.projectId, + key: { + in: varsToSync.map((v) => v.key), + }, + }, + }, + select: { + isSecret: true, + variable: { + select: { + key: true, + }, + }, + }, }); - if (result.success) { - syncedCount += varsToSync.length; - } else { - const errorMsg = `Failed to sync env vars for ${mapping.triggerEnvType}: ${result.error}`; - errors.push(errorMsg); - logger.error(errorMsg, { - projectId: params.projectId, - vercelProjectId: params.vercelProjectId, - vercelTarget: mapping.vercelTarget, - error: result.error, - variableErrors: result.variableErrors, - attemptedKeys: varsToSync.map((v) => v.key), + for (const varValue of existingVarValues) { + if (varValue.isSecret) { + existingSecretKeys.add(varValue.variable.key); + } + } + + // Split vars into secret and non-secret groups + const secretVars = varsToSync.filter((v) => existingSecretKeys.has(v.key)); + const nonSecretVars = varsToSync.filter((v) => !existingSecretKeys.has(v.key)); + + // Create non-secret vars + if (nonSecretVars.length > 0) { + const result = await envVarRepository.create(params.projectId, { + override: true, + environmentIds: [mapping.runtimeEnvironmentId], + isSecret: false, + variables: nonSecretVars.map((v) => ({ + key: v.key, + value: v.value, + })), + }); + + if (result.success) { + syncedCount += nonSecretVars.length; + } else { + const errorMsg = `Failed to sync env vars for ${mapping.triggerEnvType}: ${result.error}`; + errors.push(errorMsg); + logger.error(errorMsg, { + projectId: params.projectId, + vercelProjectId: params.vercelProjectId, + vercelTarget: mapping.vercelTarget, + error: result.error, + variableErrors: result.variableErrors, + attemptedKeys: nonSecretVars.map((v) => v.key), + }); + } + } + + // Create secret vars (preserve their secret status) + if (secretVars.length > 0) { + const result = await envVarRepository.create(params.projectId, { + override: true, + environmentIds: [mapping.runtimeEnvironmentId], + isSecret: true, // Preserve secret status + variables: secretVars.map((v) => ({ + key: v.key, + value: v.value, + })), }); + + if (result.success) { + syncedCount += secretVars.length; + } else { + const errorMsg = `Failed to sync secret env vars for ${mapping.triggerEnvType}: ${result.error}`; + errors.push(errorMsg); + logger.error(errorMsg, { + projectId: params.projectId, + vercelProjectId: params.vercelProjectId, + vercelTarget: mapping.vercelTarget, + error: result.error, + variableErrors: result.variableErrors, + attemptedKeys: secretVars.map((v) => v.key), + }); + } } } catch (envError) { const errorMsg = `Failed to process env vars for ${mapping.triggerEnvType}: ${envError instanceof Error ? envError.message : "Unknown error"}`; diff --git a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts index edecb81cd3..d3f7f4b238 100644 --- a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts @@ -5,6 +5,7 @@ import { filterOrphanedEnvironments, sortEnvironments } from "~/utils/environmen import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; import { SyncEnvVarsMapping, + EnvSlug, } from "~/v3/vercel/vercelProjectIntegrationSchema"; import { VercelIntegrationService } from "~/services/vercelIntegration.server"; @@ -102,11 +103,11 @@ export class EnvironmentVariablesPresenter { const vercelIntegration = await vercelService.getVercelProjectIntegration(project.id, true); let vercelSyncEnvVarsMapping: SyncEnvVarsMapping = {}; - let vercelPullEnvVarsEnabled = false; + let vercelPullEnvVarsBeforeBuild: EnvSlug[] | null = null; if (vercelIntegration) { vercelSyncEnvVarsMapping = vercelIntegration.parsedIntegrationData.syncEnvVarsMapping; - vercelPullEnvVarsEnabled = vercelIntegration.parsedIntegrationData.config.pullEnvVarsFromVercel; + vercelPullEnvVarsBeforeBuild = vercelIntegration.parsedIntegrationData.config.pullEnvVarsBeforeBuild ?? null; } return { @@ -146,7 +147,7 @@ export class EnvironmentVariablesPresenter { vercelIntegration: vercelIntegration ? { enabled: true, - pullEnvVarsEnabled: vercelPullEnvVarsEnabled, + pullEnvVarsBeforeBuild: vercelPullEnvVarsBeforeBuild, syncEnvVarsMapping: vercelSyncEnvVarsMapping, } : null, diff --git a/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts b/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts index f747921a10..45d24ca03e 100644 --- a/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts @@ -1,4 +1,4 @@ -import { type PrismaClient, type RuntimeEnvironmentType } from "@trigger.dev/database"; +import { type PrismaClient } from "@trigger.dev/database"; import { fromPromise, ok, ResultAsync } from "neverthrow"; import { env } from "~/env.server"; import { OrgIntegrationRepository } from "~/models/orgIntegration.server"; @@ -40,13 +40,29 @@ export type VercelAvailableProject = { name: string; }; +export type GitHubAppInstallationForVercel = { + id: string; + appInstallationId: bigint; + targetType: string; + accountHandle: string; + repositories: Array<{ + id: string; + name: string; + fullName: string; + private: boolean; + htmlUrl: string; + }>; +}; + export type VercelOnboardingData = { customEnvironments: VercelCustomEnvironment[]; environmentVariables: VercelEnvironmentVariable[]; availableProjects: VercelAvailableProject[]; hasProjectSelected: boolean; authInvalid?: boolean; - existingVariables: Record; + existingVariables: Record; // Environment slugs (non-archived only) + gitHubAppInstallations: GitHubAppInstallationForVercel[]; + isGitHubConnected: boolean; }; export class VercelSettingsPresenter extends BasePresenter { @@ -234,11 +250,71 @@ export class VercelSettingsPresenter extends BasePresenter { * Get data needed for the onboarding modal (custom environments and env vars) */ public async getOnboardingData( - projectId: string, + projectId: string, organizationId: string, vercelEnvironmentId?: string ): Promise { try { + // Fetch GitHub app installations and connected repo in parallel with Vercel data + const [gitHubInstallations, connectedGitHubRepo] = await Promise.all([ + (this._replica as PrismaClient).githubAppInstallation.findMany({ + where: { + organizationId, + deletedAt: null, + suspendedAt: null, + }, + select: { + id: true, + accountHandle: true, + targetType: true, + appInstallationId: true, + repositories: { + select: { + id: true, + name: true, + fullName: true, + htmlUrl: true, + private: true, + }, + take: 200, + }, + }, + take: 20, + orderBy: { + createdAt: "desc", + }, + }), + (this._replica as PrismaClient).connectedGithubRepository.findFirst({ + where: { + projectId, + repository: { + installation: { + deletedAt: null, + suspendedAt: null, + }, + }, + }, + select: { + id: true, + }, + }), + ]); + + const isGitHubConnected = connectedGitHubRepo !== null; + const gitHubAppInstallations: GitHubAppInstallationForVercel[] = gitHubInstallations.map((installation) => ({ + id: installation.id, + appInstallationId: installation.appInstallationId, + targetType: installation.targetType, + accountHandle: installation.accountHandle, + repositories: installation.repositories.map((repo) => ({ + id: repo.id, + name: repo.name, + fullName: repo.fullName, + private: repo.private, + htmlUrl: repo.htmlUrl, + })), + })); + const orgIntegration = await (this._replica as PrismaClient).organizationIntegration.findFirst({ where: { organizationId, @@ -263,6 +339,8 @@ export class VercelSettingsPresenter extends BasePresenter { hasProjectSelected: false, authInvalid: true, existingVariables: {}, + gitHubAppInstallations, + isGitHubConnected, }; } @@ -292,6 +370,8 @@ export class VercelSettingsPresenter extends BasePresenter { hasProjectSelected: false, authInvalid: availableProjectsResult.authInvalid, existingVariables: {}, + gitHubAppInstallations, + isGitHubConnected, }; } @@ -303,6 +383,8 @@ export class VercelSettingsPresenter extends BasePresenter { availableProjects: availableProjectsResult.data, hasProjectSelected: false, existingVariables: {}, + gitHubAppInstallations, + isGitHubConnected, }; } @@ -341,6 +423,8 @@ export class VercelSettingsPresenter extends BasePresenter { hasProjectSelected: true, authInvalid: true, existingVariables: {}, + gitHubAppInstallations, + isGitHubConnected, }; } @@ -388,13 +472,34 @@ export class VercelSettingsPresenter extends BasePresenter { ); // Get existing environment variables in Trigger.dev + // Fetch environments with their slugs and archived status to filter properly + const projectEnvs = await (this._replica as PrismaClient).runtimeEnvironment.findMany({ + where: { + projectId, + archivedAt: null, // Filter out archived environments + }, + select: { + id: true, + slug: true, + type: true, + }, + }); + const envIdToSlug = new Map(projectEnvs.map((e) => [e.id, e.slug])); + const activeEnvIds = new Set(projectEnvs.map((e) => e.id)); + const envVarRepository = new EnvironmentVariablesRepository(this._replica as PrismaClient); const existingVariables = await envVarRepository.getProject(projectId); - const existingVariablesRecord: Record = {}; + const existingVariablesRecord: Record = {}; for (const v of existingVariables) { - existingVariablesRecord[v.key] = { - environments: v.values.map((val) => val.environment.type), - }; + // Filter out archived environments and map to slugs + const activeEnvSlugs = v.values + .filter((val) => activeEnvIds.has(val.environment.id)) + .map((val) => envIdToSlug.get(val.environment.id) || val.environment.type.toLowerCase()); + if (activeEnvSlugs.length > 0) { + existingVariablesRecord[v.key] = { + environments: activeEnvSlugs, + }; + } } return { @@ -403,6 +508,8 @@ export class VercelSettingsPresenter extends BasePresenter { availableProjects: availableProjectsResult.data, hasProjectSelected: true, existingVariables: existingVariablesRecord, + gitHubAppInstallations, + isGitHubConnected, }; } catch (error) { console.error("Error in getOnboardingData:", error); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx index f1913285cb..b777b3e676 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx @@ -71,8 +71,7 @@ import { EnvironmentVariable, } from "~/v3/environmentVariables/repository"; import { VercelIntegrationService } from "~/services/vercelIntegration.server"; -import { Callout } from "~/components/primitives/Callout"; -import { shouldSyncEnvVar, type TriggerEnvironmentType } from "~/v3/vercel/vercelProjectIntegrationSchema"; +import { shouldSyncEnvVar, isPullEnvVarsEnabledForEnvironment, type TriggerEnvironmentType } from "~/v3/vercel/vercelProjectIntegrationSchema"; export const meta: MetaFunction = () => { return [ @@ -305,16 +304,6 @@ export default function Page() {
)} - {/* Vercel sync info callout */} - {vercelIntegration?.enabled && vercelIntegration?.pullEnvVarsEnabled && ( -
- - Environment variables with Vercel sync enabled will be pulled from Vercel during builds. - Uncheck the "Vercel Sync" box to exclude specific variables from syncing. - -
- )} - @@ -332,11 +321,11 @@ export default function Page() { - Vercel Sync + Pull from Vercel } - content="When enabled, this variable will be synced from Vercel during builds. Requires 'Sync environment variables from Vercel' to be enabled in settings." + content="When enabled, this variable will be pulled from Vercel during builds. Requires 'Pull env vars before build' to be enabled in settings." /> )} @@ -401,16 +390,21 @@ export default function Page() { {vercelIntegration?.enabled && ( - + {variable.environment.type !== "DEVELOPMENT" && ( + + )} )} {}} /> } - content="Enable 'Sync environment variables from Vercel' in settings to enable individual variable sync." + content="Enable 'Pull env vars before build' for this environment in Vercel settings." /> ); } diff --git a/apps/webapp/app/routes/api.v1.orgs.$organizationSlug.projects.$projectParam.vercel.projects.ts b/apps/webapp/app/routes/api.v1.orgs.$organizationSlug.projects.$projectParam.vercel.projects.ts index 3039f2cdba..e53ad28d21 100644 --- a/apps/webapp/app/routes/api.v1.orgs.$organizationSlug.projects.$projectParam.vercel.projects.ts +++ b/apps/webapp/app/routes/api.v1.orgs.$organizationSlug.projects.$projectParam.vercel.projects.ts @@ -93,9 +93,8 @@ export async function loader({ request, params }: LoaderFunctionArgs) { teamId: parsedIntegrationData.vercelTeamId, }, config: { - pullEnvVarsFromVercel: parsedIntegrationData.config.pullEnvVarsFromVercel, - spawnDeploymentOnVercelEvent: parsedIntegrationData.config.spawnDeploymentOnVercelEvent, - spawnBuildOnVercelEvent: parsedIntegrationData.config.spawnBuildOnVercelEvent, + atomicBuilds: parsedIntegrationData.config.atomicBuilds, + pullEnvVarsBeforeBuild: parsedIntegrationData.config.pullEnvVarsBeforeBuild, vercelStagingEnvironment: parsedIntegrationData.config.vercelStagingEnvironment, }, syncEnvVarsMapping: parsedIntegrationData.syncEnvVarsMapping, diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.import.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.import.ts index ad2372a654..5ef66546ae 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.import.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.import.ts @@ -41,6 +41,8 @@ export async function action({ params, request }: ActionFunctionArgs) { const result = await repository.create(environment.project.id, { override: typeof body.override === "boolean" ? body.override : false, environmentIds: [environment.id], + // Pass parent environment ID so new variables can inherit isSecret from parent + parentEnvironmentId: environment.parentEnvironmentId ?? undefined, variables: Object.entries(body.variables).map(([key, value]) => ({ key, value, diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx index 5b3395eb59..0360d3d896 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx @@ -44,6 +44,16 @@ import { TooltipTrigger, TooltipProvider } from "~/components/primitives/Tooltip"; +import { + EnvironmentIcon, + environmentFullTitle, + environmentTextClassName, +} from "~/components/environments/EnvironmentLabel"; +import { OctoKitty } from "~/components/GitHubLoginButton"; +import { + ConnectGitHubRepoModal, + type GitHubAppInstallation, +} from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github"; import { redirectBackWithErrorMessage, redirectWithSuccessMessage, @@ -53,7 +63,7 @@ import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; -import { EnvironmentParamSchema, v3ProjectSettingsPath, vercelAppInstallPath } from "~/utils/pathBuilder"; +import { EnvironmentParamSchema, v3ProjectSettingsPath, vercelAppInstallPath, githubAppInstallPath } from "~/utils/pathBuilder"; import { VercelSettingsPresenter, type VercelOnboardingData, @@ -66,23 +76,11 @@ import { import { type VercelProjectIntegrationData, type SyncEnvVarsMapping, + type EnvSlug, shouldSyncEnvVarForAnyEnvironment, + envTypeToSlug, } from "~/v3/vercel/vercelProjectIntegrationSchema"; import { useEffect, useState, useCallback, useRef } from "react"; -import { RuntimeEnvironmentType } from "@trigger.dev/database"; - -function shortEnvironmentLabel(environment: RuntimeEnvironmentType) { - switch (environment.toUpperCase()) { - case "PRODUCTION": - return "Prod"; - case "STAGING": - return "Staging"; - case "DEVELOPMENT": - return "Dev"; - case "PREVIEW": - return "Preview"; - } -} export type ConnectedVercelProject = { id: string; @@ -151,22 +149,29 @@ async function lookupVercelEnvironmentName( } } +const EnvSlugSchema = z.enum(["dev", "stg", "prod", "preview"]); + const UpdateVercelConfigFormSchema = z.object({ action: z.literal("update-config"), - pullEnvVarsFromVercel: z - .string() - .optional() - .transform((val) => val === "on"), - spawnDeploymentOnVercelEvent: z - .string() - .optional() - .transform((val) => val === "on"), - spawnBuildOnVercelEvent: z - .string() - .optional() - .transform((val) => val === "on"), + atomicBuilds: z.string().optional().transform((val) => { + if (!val) return null; + try { + const parsed = JSON.parse(val); + return Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } + }), + pullEnvVarsBeforeBuild: z.string().optional().transform((val) => { + if (!val) return null; + try { + const parsed = JSON.parse(val); + return Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } + }), vercelStagingEnvironment: z.string().nullable().optional(), - vercelStagingName: z.string().nullable().optional(), }); const DisconnectVercelFormSchema = z.object({ @@ -176,11 +181,24 @@ const DisconnectVercelFormSchema = z.object({ const CompleteOnboardingFormSchema = z.object({ action: z.literal("complete-onboarding"), vercelStagingEnvironment: z.string().nullable().optional(), - vercelStagingName: z.string().nullable().optional(), - pullEnvVarsFromVercel: z - .string() - .optional() - .transform((val) => val === "on"), + pullEnvVarsBeforeBuild: z.string().optional().transform((val) => { + if (!val) return null; + try { + const parsed = JSON.parse(val); + return Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } + }), + atomicBuilds: z.string().optional().transform((val) => { + if (!val) return null; + try { + const parsed = JSON.parse(val); + return Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } + }), syncEnvVarsMapping: z.string().optional(), // JSON-encoded mapping next: z.string().optional(), }); @@ -198,7 +216,6 @@ const SelectVercelProjectFormSchema = z.object({ const UpdateEnvMappingFormSchema = z.object({ action: z.literal("update-env-mapping"), vercelStagingEnvironment: z.string().nullable().optional(), - vercelStagingName: z.string().nullable().optional(), }); const VercelActionSchema = z.discriminatedUnion("action", [ @@ -309,25 +326,15 @@ export async function action({ request, params }: ActionFunctionArgs) { // Handle update-config action if (actionType === "update-config") { const { - pullEnvVarsFromVercel, - spawnDeploymentOnVercelEvent, - spawnBuildOnVercelEvent, + atomicBuilds, + pullEnvVarsBeforeBuild, vercelStagingEnvironment, - vercelStagingName, } = submission.value; - // If vercelStagingName is not provided, look it up from the environment ID - let stagingName = vercelStagingName ?? null; - if (vercelStagingEnvironment && !stagingName) { - stagingName = await lookupVercelEnvironmentName(project.id, vercelStagingEnvironment); - } - const result = await vercelService.updateVercelIntegrationConfig(project.id, { - pullEnvVarsFromVercel, - spawnDeploymentOnVercelEvent, - spawnBuildOnVercelEvent, + atomicBuilds: atomicBuilds as EnvSlug[] | null, + pullEnvVarsBeforeBuild: pullEnvVarsBeforeBuild as EnvSlug[] | null, vercelStagingEnvironment: vercelStagingEnvironment ?? null, - vercelStagingName: stagingName, }); if (result) { @@ -352,8 +359,8 @@ export async function action({ request, params }: ActionFunctionArgs) { if (actionType === "complete-onboarding") { const { vercelStagingEnvironment, - vercelStagingName, - pullEnvVarsFromVercel, + pullEnvVarsBeforeBuild, + atomicBuilds, syncEnvVarsMapping, next, } = submission.value; @@ -367,16 +374,19 @@ export async function action({ request, params }: ActionFunctionArgs) { } } - // If vercelStagingName is not provided, look it up from the environment ID - let stagingName = vercelStagingName ?? null; - if (vercelStagingEnvironment && !stagingName) { - stagingName = await lookupVercelEnvironmentName(project.id, vercelStagingEnvironment); - } + logger.info("Vercel complete-onboarding action: received params", { + projectId: project.id, + vercelStagingEnvironment, + pullEnvVarsBeforeBuild, + atomicBuilds, + syncEnvVarsMappingRaw: syncEnvVarsMapping, + parsedMappingKeys: Object.keys(parsedMapping), + }); const result = await vercelService.completeOnboarding(project.id, { vercelStagingEnvironment: vercelStagingEnvironment ?? null, - vercelStagingName: stagingName, - pullEnvVarsFromVercel: pullEnvVarsFromVercel ?? true, + pullEnvVarsBeforeBuild: pullEnvVarsBeforeBuild as EnvSlug[] | null, + atomicBuilds: atomicBuilds as EnvSlug[] | null, syncEnvVarsMapping: parsedMapping, }); @@ -407,17 +417,10 @@ export async function action({ request, params }: ActionFunctionArgs) { // Handle update-env-mapping action (during onboarding) if (actionType === "update-env-mapping") { - const { vercelStagingEnvironment, vercelStagingName } = submission.value; - - // If vercelStagingName is not provided, look it up from the environment ID - let stagingName = vercelStagingName ?? null; - if (vercelStagingEnvironment && !stagingName) { - stagingName = await lookupVercelEnvironmentName(project.id, vercelStagingEnvironment); - } + const { vercelStagingEnvironment } = submission.value; const result = await vercelService.updateVercelIntegrationConfig(project.id, { vercelStagingEnvironment: vercelStagingEnvironment ?? null, - vercelStagingName: stagingName, }); if (result) { @@ -603,13 +606,28 @@ function VercelGitHubWarning() { return (

- GitHub integration is not connected. Vercel integration cannot sync environment variables or + GitHub integration is not connected. Vercel integration cannot pull environment variables or spawn Trigger.dev builds without a properly installed GitHub integration.

); } +const ALL_ENV_SLUGS: EnvSlug[] = ["prod", "stg", "preview", "dev"]; + +function envSlugLabel(slug: EnvSlug): string { + switch (slug) { + case "prod": + return "Production"; + case "stg": + return "Staging"; + case "preview": + return "Preview"; + case "dev": + return "Development"; + } +} + function ConnectedVercelProjectForm({ connectedProject, hasStagingEnvironment, @@ -630,29 +648,28 @@ function ConnectedVercelProjectForm({ const [hasConfigChanges, setHasConfigChanges] = useState(false); const [configValues, setConfigValues] = useState({ - pullEnvVarsFromVercel: connectedProject.integrationData.config.pullEnvVarsFromVercel, - spawnDeploymentOnVercelEvent: - connectedProject.integrationData.config.spawnDeploymentOnVercelEvent, - spawnBuildOnVercelEvent: connectedProject.integrationData.config.spawnBuildOnVercelEvent, + atomicBuilds: connectedProject.integrationData.config.atomicBuilds ?? [], + pullEnvVarsBeforeBuild: connectedProject.integrationData.config.pullEnvVarsBeforeBuild ?? [], vercelStagingEnvironment: connectedProject.integrationData.config.vercelStagingEnvironment || "", - vercelStagingName: connectedProject.integrationData.config.vercelStagingName || null, }); + // Track original values for comparison + const originalAtomicBuilds = connectedProject.integrationData.config.atomicBuilds ?? []; + const originalPullEnvVars = connectedProject.integrationData.config.pullEnvVarsBeforeBuild ?? []; + const originalStagingEnv = connectedProject.integrationData.config.vercelStagingEnvironment || ""; + useEffect(() => { - const hasChanges = - configValues.pullEnvVarsFromVercel !== - connectedProject.integrationData.config.pullEnvVarsFromVercel || - configValues.spawnDeploymentOnVercelEvent !== - connectedProject.integrationData.config.spawnDeploymentOnVercelEvent || - configValues.spawnBuildOnVercelEvent !== - connectedProject.integrationData.config.spawnBuildOnVercelEvent || - configValues.vercelStagingEnvironment !== - (connectedProject.integrationData.config.vercelStagingEnvironment || "") || - configValues.vercelStagingName !== - (connectedProject.integrationData.config.vercelStagingName || null); - setHasConfigChanges(hasChanges); - }, [configValues, connectedProject.integrationData.config]); + const atomicBuildsChanged = + JSON.stringify([...configValues.atomicBuilds].sort()) !== + JSON.stringify([...originalAtomicBuilds].sort()); + const pullEnvVarsChanged = + JSON.stringify([...configValues.pullEnvVarsBeforeBuild].sort()) !== + JSON.stringify([...originalPullEnvVars].sort()); + const stagingEnvChanged = configValues.vercelStagingEnvironment !== originalStagingEnv; + + setHasConfigChanges(atomicBuildsChanged || pullEnvVarsChanged || stagingEnvChanged); + }, [configValues, originalAtomicBuilds, originalPullEnvVars, originalStagingEnv]); const [configForm, fields] = useForm({ id: "update-vercel-config", @@ -671,6 +688,21 @@ function ConnectedVercelProjectForm({ const actionUrl = vercelResourcePath(organizationSlug, projectSlug, environmentSlug); + // Filter out staging if no staging environment exists + const availableEnvSlugs = hasStagingEnvironment + ? ALL_ENV_SLUGS + : ALL_ENV_SLUGS.filter((s) => s !== "stg"); + + // For pull env vars and atomic deployments, exclude "dev" (not needed for development) + const availableEnvSlugsForBuildSettings = availableEnvSlugs.filter((s) => s !== "dev"); + + // Format selected environments for display + const formatSelectedEnvs = (selected: EnvSlug[], availableSlugs: EnvSlug[] = availableEnvSlugs): string => { + if (selected.length === 0) return "None selected"; + if (selected.length === availableSlugs.length) return "All environments"; + return selected.map(envSlugLabel).join(", "); + }; + return ( <> {/* Connected project info */} @@ -700,7 +732,7 @@ function ConnectedVercelProjectForm({ Are you sure you want to disconnect{" "} {connectedProject.vercelProjectName}? - This will stop syncing environment variables and disable Vercel-triggered builds. + This will stop pulling environment variables and disable atomic deployments. + + + +
- {/* Staging environment mapping info */} - {hasStagingEnvironment && - connectedProject.integrationData.config.vercelStagingEnvironment && - connectedProject.integrationData.config.vercelStagingEnvironment !== "" && - connectedProject.integrationData.config.vercelStagingName && ( -
-
- - - Vercel environment mapped to Trigger.dev's Staging environment. - -
- - {connectedProject.integrationData.config.vercelStagingName} - -
- )} - - {/* Pull env vars toggle */} -
-
- - - When enabled, environment variables will be pulled from Vercel during builds. - Configure which variables to sync on the{" "} - - environment variables page - - . - -
- { - setConfigValues((prev) => ({ - ...prev, - pullEnvVarsFromVercel: checked, - })); - }} - /> -
- - {/* Spawn deployment toggle */} -
-
- - - When enabled, a Trigger.dev deployment will be created when Vercel deploys. - -
- { - setConfigValues((prev) => ({ - ...prev, - spawnDeploymentOnVercelEvent: checked, - })); - }} - /> -
- - {/* Spawn build toggle */} -
-
- - - When enabled, a Trigger.dev build will be triggered when Vercel builds. - -
- { - setConfigValues((prev) => ({ - ...prev, - spawnBuildOnVercelEvent: checked, - })); - }} - /> -
- {/* Staging environment mapping */} {hasStagingEnvironment && customEnvironments && customEnvironments.length > 0 && (
@@ -824,15 +784,12 @@ function ConnectedVercelProjectForm({ environment. - {configValues.vercelStagingName && ( - - )}
)} + + {/* Pull env vars before build */} +
+
+
+ + + Select which environments should pull environment variables from Vercel before + each build.{" "} + + Configure which variables to pull + + . + +
+ { + setConfigValues((prev) => ({ + ...prev, + pullEnvVarsBeforeBuild: checked ? [...availableEnvSlugsForBuildSettings] : [], + })); + }} + /> +
+
+ {availableEnvSlugsForBuildSettings.map((slug) => { + const envType = slug === "prod" ? "PRODUCTION" : slug === "stg" ? "STAGING" : "PREVIEW"; + return ( +
+
+ + + {environmentFullTitle({ type: envType })} + +
+ { + setConfigValues((prev) => ({ + ...prev, + pullEnvVarsBeforeBuild: checked + ? [...prev.pullEnvVarsBeforeBuild, slug] + : prev.pullEnvVarsBeforeBuild.filter((s) => s !== slug), + })); + }} + /> +
+ ); + })} +
+
+ + {/* Atomic deployments */} +
+
+
+ + + Select which environments should wait for Vercel deployment to complete before + promoting the Trigger.dev deployment. + +
+ { + setConfigValues((prev) => ({ + ...prev, + atomicBuilds: checked ? [...availableEnvSlugsForBuildSettings] : [], + })); + }} + /> +
+
+ {availableEnvSlugsForBuildSettings.map((slug) => { + const envType = slug === "prod" ? "PRODUCTION" : slug === "stg" ? "STAGING" : "PREVIEW"; + return ( +
+
+ + + {environmentFullTitle({ type: envType })} + +
+ { + setConfigValues((prev) => ({ + ...prev, + atomicBuilds: checked + ? [...prev.atomicBuilds, slug] + : prev.atomicBuilds.filter((s) => s !== slug), + })); + }} + /> +
+ ); + })} +
+
{configForm.error} @@ -990,12 +1046,12 @@ function VercelSettingsPanel({ /> {data.hasOrgIntegration - ? "Connect your Vercel project to sync environment variables and trigger builds automatically." - : "Install the Vercel app to connect your projects and sync environment variables."} + ? "Connect your Vercel project to pull environment variables and trigger builds automatically." + : "Install the Vercel app to connect your projects and pull environment variables."} {!data.isGitHubConnected && ( - GitHub integration is not connected. Vercel integration cannot sync environment variables or + GitHub integration is not connected. Vercel integration cannot pull environment variables or spawn Trigger.dev builds without a properly installed GitHub integration. )} @@ -1013,7 +1069,9 @@ type OnboardingState = | "loading-env-mapping" // After project selection, checking for custom envs | "env-mapping" // Showing custom environment mapping UI | "loading-env-vars" // Loading environment variables - | "env-var-sync" // Showing environment variable sync UI + | "env-var-sync" // Showing environment variable sync UI (one-time sync now) + | "build-settings" // Configure pullEnvVarsBeforeBuild and atomicBuilds + | "github-connection" // Connect GitHub repository | "completed"; // Onboarding complete (closes modal) function VercelOnboardingModal({ @@ -1083,10 +1141,13 @@ function VercelOnboardingModal({ // Update state when modal opens or data changes const prevIsOpenRef = useRef(isOpen); + // Track if we've synced staging for pull env vars (reset when modal reopens) + const hasSyncedStagingRef = useRef(false); useEffect(() => { if (isOpen && !prevIsOpenRef.current) { - // Modal just opened, compute initial state + // Modal just opened, compute initial state and reset staging sync flag setState(computeInitialState()); + hasSyncedStagingRef.current = false; } else if (isOpen && state === "idle") { // Modal is open but in idle state, compute initial state setState(computeInitialState()); @@ -1099,13 +1160,49 @@ function VercelOnboardingModal({ name: string; } | null>(null); const [vercelStagingEnvironment, setVercelStagingEnvironment] = useState(""); - const [pullEnvVarsFromVercel, setPullEnvVarsFromVercel] = useState(true); + // Available env slugs based on staging environment + const availableEnvSlugsForOnboarding: EnvSlug[] = hasStagingEnvironment + ? ["prod", "stg", "preview", "dev"] + : ["prod", "preview", "dev"]; + // For build settings (pull env vars and atomic deployments), exclude "dev" (not needed for development) + const availableEnvSlugsForOnboardingBuildSettings: EnvSlug[] = availableEnvSlugsForOnboarding.filter( + (s) => s !== "dev" + ); + // Build settings state (for build-settings step) + // Default: pull env vars enabled for all environments + const [pullEnvVarsBeforeBuild, setPullEnvVarsBeforeBuild] = useState( + () => (hasStagingEnvironment ? ["prod", "stg", "preview"] : ["prod", "preview"]) + ); + const [atomicBuilds, setAtomicBuilds] = useState([]); + + // Sync pullEnvVarsBeforeBuild when hasStagingEnvironment becomes true (once) + // This ensures staging is included when it becomes available, but respects user changes after + useEffect(() => { + if (hasStagingEnvironment && !hasSyncedStagingRef.current) { + hasSyncedStagingRef.current = true; + setPullEnvVarsBeforeBuild((prev) => { + if (!prev.includes("stg")) { + return [...prev, "stg"]; + } + return prev; + }); + } + }, [hasStagingEnvironment]); + // Env var sync state (for env-var-sync step - one-time sync) const [syncEnvVarsMapping, setSyncEnvVarsMapping] = useState({}); const [expandedEnvVars, setExpandedEnvVars] = useState(false); const [projectSelectionError, setProjectSelectionError] = useState(null); + // GitHub connection state (for github-connection step) + const gitHubAppInstallations = onboardingData?.gitHubAppInstallations ?? []; + const isGitHubConnectedForOnboarding = onboardingData?.isGitHubConnected ?? false; + + // Track if we've triggered a reload for the current loading state to prevent infinite loops + const loadingStateRef = useRef(null); + useEffect(() => { if (!isOpen || state === "idle") { + loadingStateRef.current = null; return; } @@ -1114,10 +1211,16 @@ function VercelOnboardingModal({ return; } + // Skip if we've already triggered a reload for this state + if (loadingStateRef.current === state) { + return; + } + switch (state) { case "loading-projects": // Trigger data reload to fetch projects + loadingStateRef.current = state; if (onDataReload) { onDataReload(); } @@ -1126,6 +1229,7 @@ function VercelOnboardingModal({ case "loading-env-mapping": // After project selection, reload data to get custom environments + loadingStateRef.current = state; if (onDataReload) { onDataReload(); } @@ -1134,6 +1238,7 @@ function VercelOnboardingModal({ case "loading-env-vars": // Reload data to get environment variables + loadingStateRef.current = state; if (onDataReload) { onDataReload(vercelStagingEnvironment || undefined); } @@ -1146,9 +1251,12 @@ function VercelOnboardingModal({ case "env-mapping": case "env-var-sync": case "completed": + case "build-settings": + case "github-connection": + loadingStateRef.current = null; break; } - }, [isOpen, state, hasOrgIntegration, hasProjectSelected, onboardingData, hasCustomEnvs, hasStagingEnvironment, vercelStagingEnvironment, onDataReload]); + }, [isOpen, state, onboardingData?.authInvalid, vercelStagingEnvironment, onDataReload, onClose]); // Watch for data loading completion useEffect(() => { @@ -1207,32 +1315,53 @@ function VercelOnboardingModal({ const actionUrl = vercelResourcePath(organizationSlug, projectSlug, environmentSlug); + // Toggle individual env var for the one-time sync const handleToggleEnvVar = useCallback((key: string, enabled: boolean) => { setSyncEnvVarsMapping((prev) => { + const newMapping = { ...prev }; + if (enabled) { - // Remove from mapping (default is enabled for all environments) - const { [key]: _, ...rest } = prev; - return rest; + // Remove this key from all environment mappings (default is enabled) + for (const envSlug of ALL_ENV_SLUGS) { + if (newMapping[envSlug]) { + const { [key]: _, ...rest } = newMapping[envSlug]; + if (Object.keys(rest).length === 0) { + delete newMapping[envSlug]; + } else { + newMapping[envSlug] = rest; + } + } + } + } else { + // Disable for all environments + for (const envSlug of ALL_ENV_SLUGS) { + newMapping[envSlug] = { + ...(newMapping[envSlug] || {}), + [key]: false, + }; + } } - // Disable for all environments - return { - ...prev, - [key]: { - PRODUCTION: false, - STAGING: false, - PREVIEW: false, - DEVELOPMENT: false, - }, - }; + + return newMapping; }); }, []); - const handleToggleAll = useCallback( - (enabled: boolean) => { - setPullEnvVarsFromVercel(enabled); + // Toggle all env vars for the one-time sync (select/deselect all) + const handleToggleAllEnvVars = useCallback( + (enabled: boolean, syncableVars: Array<{ key: string }>) => { if (enabled) { // Reset all mappings (default to sync all) setSyncEnvVarsMapping({}); + } else { + // Disable all syncable vars for all environments + const newMapping: SyncEnvVarsMapping = {}; + for (const envSlug of ALL_ENV_SLUGS) { + newMapping[envSlug] = {}; + for (const v of syncableVars) { + newMapping[envSlug][v.key] = false; + } + } + setSyncEnvVarsMapping(newMapping); } }, [] @@ -1288,17 +1417,36 @@ function VercelOnboardingModal({ const formData = new FormData(); formData.append("action", "update-env-mapping"); formData.append("vercelStagingEnvironment", vercelStagingEnvironment); - const environment = customEnvironments.find((env) => env.id === vercelStagingEnvironment); - if (environment) { - formData.append("vercelStagingName", environment.slug); - } envMappingFetcher.submit(formData, { method: "post", action: actionUrl, }); - - }, [vercelStagingEnvironment, customEnvironments, envMappingFetcher, actionUrl, onDataReload]); + + }, [vercelStagingEnvironment, envMappingFetcher, actionUrl]); + + const handleBuildSettingsNext = useCallback(() => { + // Build the form data to complete onboarding (save settings and sync env vars) + const formData = new FormData(); + formData.append("action", "complete-onboarding"); + formData.append("vercelStagingEnvironment", vercelStagingEnvironment || ""); + formData.append("pullEnvVarsBeforeBuild", JSON.stringify(pullEnvVarsBeforeBuild)); + formData.append("atomicBuilds", JSON.stringify(atomicBuilds)); + formData.append("syncEnvVarsMapping", JSON.stringify(syncEnvVarsMapping)); + if (nextUrl && fromMarketplaceContext && isGitHubConnectedForOnboarding) { + formData.append("next", nextUrl); + } + + completeOnboardingFetcher.submit(formData, { + method: "post", + action: actionUrl, + }); + + // If GitHub is not connected, transition to GitHub step after saving + if (!isGitHubConnectedForOnboarding) { + setState("github-connection"); + } + }, [vercelStagingEnvironment, pullEnvVarsBeforeBuild, atomicBuilds, syncEnvVarsMapping, nextUrl, fromMarketplaceContext, isGitHubConnectedForOnboarding, completeOnboardingFetcher, actionUrl]); const handleFinishOnboarding = useCallback((e: React.FormEvent) => { e.preventDefault(); @@ -1313,6 +1461,10 @@ function VercelOnboardingModal({ // Handle successful onboarding completion useEffect(() => { if (completeOnboardingFetcher.data && typeof completeOnboardingFetcher.data === "object" && "success" in completeOnboardingFetcher.data && completeOnboardingFetcher.data.success && completeOnboardingFetcher.state === "idle") { + // Don't close modal if we're on the github-connection step (user still needs to connect GitHub) + if (state === "github-connection") { + return; + } // Check if we need to redirect to a specific URL if ("redirectTo" in completeOnboardingFetcher.data && typeof completeOnboardingFetcher.data.redirectTo === "string") { // Navigate to the redirect URL (handles both internal and external URLs) @@ -1322,7 +1474,7 @@ function VercelOnboardingModal({ // No redirect, just close the modal setState("completed"); } - }, [completeOnboardingFetcher.data, completeOnboardingFetcher.state]); + }, [completeOnboardingFetcher.data, completeOnboardingFetcher.state, state]); // Handle completed state - close modal useEffect(() => { @@ -1398,6 +1550,8 @@ function VercelOnboardingModal({ const showProjectSelection = state === "project-selection"; const showEnvMapping = state === "env-mapping"; const showEnvVarSync = state === "env-var-sync"; + const showBuildSettings = state === "build-settings"; + const showGitHubConnection = state === "github-connection"; return ( !open && !fromMarketplaceContext && onClose()}> @@ -1537,182 +1691,339 @@ function VercelOnboardingModal({ )} {showEnvVarSync && ( - - - - {vercelStagingEnvironment && ( - e.id === vercelStagingEnvironment)?.slug || "" - } - /> - )} - - {nextUrl && fromMarketplaceContext && ( - - )} - -
- Sync Environment Variables +
+ Pull Environment Variables + + Select which environment variables to pull from Vercel now. This is a one-time pull. + - {/* Stats */} -
+ {/* Stats */} +
+
+ {syncableEnvVars.length} + can be pulled +
+ {secretEnvVars.length > 0 && (
- {syncableEnvVars.length} - can be synced + {secretEnvVars.length} + secret (cannot pull)
- {secretEnvVars.length > 0 && ( -
- {secretEnvVars.length} - secret (cannot sync) + )} +
+ + {/* Main toggle - controls selecting/deselecting all env vars */} +
+
+ + Select all variables to pull from Vercel. +
+ handleToggleAllEnvVars(checked, syncableEnvVars)} + /> +
+ + {/* Expandable env var list */} + {envVars.length > 0 && ( +
+ + + {expandedEnvVars && ( +
+ {envVars.map((envVar) => ( +
+
+ {existingVars[envVar.key] ? ( + + + +
+ {envVar.key} +
+
+ + {`This variable is going to be replaced in: ${existingVars[ + envVar.key + ].environments.join(", ")}`} + +
+
+ ) : ( + {envVar.key} + )} + {envVar.target && envVar.target.length > 0 && ( + + {formatVercelTargets(envVar.target)} + {envVar.isShared && " · Shared"} + + )} +
+ {envVar.isSecret ? ( + Secret + ) : ( + + handleToggleEnvVar(envVar.key, checked) + } + /> + )} +
+ ))}
)}
+ )} + + {overlappingEnvVarsCount > 0 && enabledEnvVars.length > 0 && ( +
+ + + {overlappingEnvVarsCount} env vars are going to be updated (marked with{" "} + + underline + + ) + +
+ )} + + setState("build-settings")} + > + Next + + } + cancelButton={ + hasCustomEnvs ? ( + + ) : ( + + ) + } + /> +
+ )} - {/* Main toggle */} -
+ {showBuildSettings && ( +
+ Build Settings + + Configure how environment variables are pulled during builds and atomic deployments. + + + {/* Pull env vars before build */} +
+
- - Enable syncing of environment variables from Vercel during builds. + + + Select which environments should automatically pull environment variables from + Vercel before each build. +
{ + setPullEnvVarsBeforeBuild(checked ? [...availableEnvSlugsForOnboardingBuildSettings] : []); + }} />
+
+ {availableEnvSlugsForOnboardingBuildSettings.map((slug) => { + const envType = slug === "prod" ? "PRODUCTION" : slug === "stg" ? "STAGING" : "PREVIEW"; + return ( +
+
+ + + {environmentFullTitle({ type: envType })} + +
+ { + setPullEnvVarsBeforeBuild((prev) => + checked ? [...prev, slug] : prev.filter((s) => s !== slug) + ); + }} + /> +
+ ); + })} +
+
- {/* Expandable env var list */} - {pullEnvVarsFromVercel && envVars.length > 0 && ( -
- - - {expandedEnvVars && ( -
- {envVars.map((envVar) => ( -
-
- {existingVars[envVar.key] ? ( - - - -
- {envVar.key} -
-
- - {`This variable is going to be replaced in: ${existingVars[ - envVar.key - ].environments - .map((e) => shortEnvironmentLabel(e)) - .join(", ")}`} - -
-
- ) : ( - {envVar.key} - )} - {envVar.target && envVar.target.length > 0 && ( - - {formatVercelTargets(envVar.target)} - {envVar.isShared && " · Shared"} - - )} -
- {envVar.isSecret ? ( - Secret - ) : ( - - handleToggleEnvVar(envVar.key, checked) - } - /> - )} -
- ))} + {/* Atomic deployments */} +
+
+
+ + + Select which environments should wait for Vercel deployment to complete before + promoting the Trigger.dev deployment. + +
+ { + setAtomicBuilds(checked ? [...availableEnvSlugsForOnboardingBuildSettings] : []); + }} + /> +
+
+ {availableEnvSlugsForOnboardingBuildSettings.map((slug) => { + const envType = slug === "prod" ? "PRODUCTION" : slug === "stg" ? "STAGING" : "PREVIEW"; + return ( +
+
+ + + {environmentFullTitle({ type: envType })} + +
+ { + setAtomicBuilds((prev) => + checked ? [...prev, slug] : prev.filter((s) => s !== slug) + ); + }} + />
+ ); + })} +
+
+ + + {isGitHubConnectedForOnboarding ? "Finish" : "Next"} + + } + cancelButton={ + + } + /> +
+ )} + + {showGitHubConnection && ( +
+ Connect GitHub Repository + + To fully integrate with Vercel, Trigger.dev needs access to your source code. + This allows automatic deployments and build synchronization. + + + +

+ Connecting your GitHub repository enables Trigger.dev to read your source code + and automatically create deployments when you push changes to Vercel. +

+
+ + {gitHubAppInstallations.length === 0 ? ( +
+ - )} - - {overlappingEnvVarsCount > 0 && pullEnvVarsFromVercel && ( -
- - - {overlappingEnvVarsCount} env vars are going to be updated (marked with{" "} - - underline - - ) + variant="secondary/medium" + LeadingIcon={OctoKitty} + > + Install GitHub app + +
+ ) : ( +
+
+ + + GitHub app is installed
- )} +
+ )} - - Finish - - } - cancelButton={ - hasCustomEnvs ? ( - - ) : ( - - ) - } - /> -
- + { + setState("completed"); + if (fromMarketplaceContext && nextUrl) { + window.location.href = nextUrl; + } + }} + > + Skip for now + + } + /> +
)}
diff --git a/apps/webapp/app/services/vercelIntegration.server.ts b/apps/webapp/app/services/vercelIntegration.server.ts index e9390751b4..a44e15fe11 100644 --- a/apps/webapp/app/services/vercelIntegration.server.ts +++ b/apps/webapp/app/services/vercelIntegration.server.ts @@ -12,6 +12,8 @@ import { VercelIntegrationConfig, SyncEnvVarsMapping, TriggerEnvironmentType, + EnvSlug, + envTypeToSlug, createDefaultVercelIntegrationData, } from "~/v3/vercel/vercelProjectIntegrationSchema"; @@ -290,14 +292,15 @@ export class VercelIntegrationService { } const currentMapping = existing.parsedIntegrationData.syncEnvVarsMapping || {}; + const envSlug = envTypeToSlug(environmentType); - const currentEnvVarSettings = currentMapping[envVarKey] || {}; + const currentEnvSettings = currentMapping[envSlug] || {}; const updatedMapping: SyncEnvVarsMapping = { ...currentMapping, - [envVarKey]: { - ...currentEnvVarSettings, - [environmentType]: syncEnabled, + [envSlug]: { + ...currentEnvSettings, + [envVarKey]: syncEnabled, }, }; @@ -323,8 +326,8 @@ export class VercelIntegrationService { projectId: string, params: { vercelStagingEnvironment?: string | null; - vercelStagingName?: string | null; - pullEnvVarsFromVercel: boolean; + pullEnvVarsBeforeBuild?: EnvSlug[] | null; + atomicBuilds?: EnvSlug[] | null; syncEnvVarsMapping: SyncEnvVarsMapping; } ): Promise { @@ -337,11 +340,13 @@ export class VercelIntegrationService { ...existing.parsedIntegrationData, config: { ...existing.parsedIntegrationData.config, - pullEnvVarsFromVercel: params.pullEnvVarsFromVercel, + pullEnvVarsBeforeBuild: params.pullEnvVarsBeforeBuild ?? null, + atomicBuilds: params.atomicBuilds ?? null, vercelStagingEnvironment: params.vercelStagingEnvironment ?? null, - vercelStagingName: params.vercelStagingName ?? null, }, - syncEnvVarsMapping: params.syncEnvVarsMapping, + // Don't save syncEnvVarsMapping - it's only used for the one-time pull during onboarding + // Keep the existing mapping (or empty default) + syncEnvVarsMapping: existing.parsedIntegrationData.syncEnvVarsMapping, }; const updated = await this.#prismaClient.organizationProjectIntegration.update({ @@ -351,51 +356,61 @@ export class VercelIntegrationService { }, }); - if (params.pullEnvVarsFromVercel) { - try { - // Get the org integration with token reference - const orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationForProject( - projectId - ); + // Pull env vars now (one-time sync during onboarding) + // Always attempt to pull - the pullEnvVarsFromVercel function will filter based on the mapping. + // We can't easily check hasEnabledVars because vars NOT in the mapping are enabled by default, + // so a mapping like { "dev": { "VAR1": false } } still means VAR2, VAR3, etc. should be synced. + try { + // Get the org integration with token reference + const orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationForProject( + projectId + ); - if (orgIntegration) { - const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration); + if (orgIntegration) { + const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration); - const pullResult = await VercelIntegrationRepository.pullEnvVarsFromVercel({ + logger.info("Vercel onboarding: pulling env vars from Vercel", { + projectId, + vercelProjectId: updatedData.vercelProjectId, + teamId, + vercelStagingEnvironment: params.vercelStagingEnvironment, + syncEnvVarsMappingKeys: Object.keys(params.syncEnvVarsMapping), + }); + + const pullResult = await VercelIntegrationRepository.pullEnvVarsFromVercel({ + projectId, + vercelProjectId: updatedData.vercelProjectId, + teamId, + vercelStagingEnvironment: params.vercelStagingEnvironment, + syncEnvVarsMapping: params.syncEnvVarsMapping, + orgIntegration, + }); + + if (!pullResult.success) { + logger.warn("Some errors occurred while pulling env vars from Vercel", { projectId, vercelProjectId: updatedData.vercelProjectId, - teamId, - vercelStagingEnvironment: params.vercelStagingEnvironment, - syncEnvVarsMapping: params.syncEnvVarsMapping, - orgIntegration, + errors: pullResult.errors, + syncedCount: pullResult.syncedCount, }); - - if (!pullResult.success) { - logger.warn("Some errors occurred while pulling env vars from Vercel", { - projectId, - vercelProjectId: updatedData.vercelProjectId, - errors: pullResult.errors, - syncedCount: pullResult.syncedCount, - }); - } else { - logger.info("Successfully pulled env vars from Vercel", { - projectId, - vercelProjectId: updatedData.vercelProjectId, - syncedCount: pullResult.syncedCount, - }); - } } else { - logger.warn("No org integration found when trying to pull env vars from Vercel", { + logger.info("Successfully pulled env vars from Vercel", { projectId, + vercelProjectId: updatedData.vercelProjectId, + syncedCount: pullResult.syncedCount, }); } - } catch (error) { - logger.error("Failed to pull env vars from Vercel during onboarding", { + } else { + logger.warn("No org integration found when trying to pull env vars from Vercel", { projectId, - vercelProjectId: updatedData.vercelProjectId, - error, }); } + } catch (error) { + logger.error("Failed to pull env vars from Vercel during onboarding", { + projectId, + vercelProjectId: updatedData.vercelProjectId, + error, + }); } return { diff --git a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts index 0ade9436d4..575c7c2569 100644 --- a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts +++ b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts @@ -51,6 +51,8 @@ export class EnvironmentVariablesRepository implements Repository { override: boolean; environmentIds: string[]; isSecret?: boolean; + // When creating variables for a branch environment, inherit isSecret from parent if not explicitly set + parentEnvironmentId?: string; variables: { key: string; value: string; @@ -164,6 +166,26 @@ export class EnvironmentVariablesRepository implements Repository { prismaClient: tx, }); + // If parentEnvironmentId is provided and isSecret is not explicitly set, + // look up if the parent has this variable marked as secret + let inheritedIsSecret: boolean | undefined = undefined; + if (options.isSecret === undefined && options.parentEnvironmentId) { + const parentVariableValue = await tx.environmentVariableValue.findFirst({ + where: { + variableId: environmentVariable.id, + environmentId: options.parentEnvironmentId, + }, + select: { + isSecret: true, + }, + }); + if (parentVariableValue?.isSecret) { + inheritedIsSecret = true; + } + } + + const effectiveIsSecret = options.isSecret ?? inheritedIsSecret; + //set the secret values and references for (const environmentId of options.environmentIds) { const key = secretKey(projectId, environmentId, variable.key); @@ -191,11 +213,15 @@ export class EnvironmentVariablesRepository implements Repository { variableId: environmentVariable.id, environmentId: environmentId, valueReferenceId: secretReference.id, - isSecret: options.isSecret, - }, - update: { - isSecret: options.isSecret, + isSecret: effectiveIsSecret, }, + // Only update isSecret if explicitly provided, otherwise preserve existing value + update: + options.isSecret !== undefined + ? { + isSecret: options.isSecret, + } + : {}, }); await secretStore.setSecret<{ secret: string }>(key, { diff --git a/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts b/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts index 601a9a56fc..ed1391b8b8 100644 --- a/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts +++ b/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts @@ -1,5 +1,16 @@ import { z } from "zod"; +/** + * Environment slugs used in API keys and configuration. + * These map to RuntimeEnvironmentType as follows: + * - "dev" → DEVELOPMENT + * - "stg" → STAGING + * - "prod" → PRODUCTION + * - "preview" → PREVIEW + */ +export const EnvSlugSchema = z.enum(["dev", "stg", "prod", "preview"]); +export type EnvSlug = z.infer; + /** * Configuration for the Vercel integration. * @@ -8,78 +19,71 @@ import { z } from "zod"; */ export const VercelIntegrationConfigSchema = z.object({ /** - * When true, environment variables are pulled from Vercel during builds/deployments. - * This is the main toggle that controls whether env var syncing is enabled. - */ - pullEnvVarsFromVercel: z.boolean().default(true), - - /** - * When true, a Trigger.dev deployment is spawned when a Vercel deployment event occurs. - * This will be handled by the webhook implementation in the other repository. + * Array of environment slugs to enable atomic deployments for. + * When an environment slug is in this array, Trigger.dev deployment waits for + * Vercel deployment to complete before promoting. + * + * Example: ["prod"] enables atomic builds for production only + * null/undefined = atomic builds disabled for all environments */ - spawnDeploymentOnVercelEvent: z.boolean().default(false), + atomicBuilds: z.array(EnvSlugSchema).nullable().optional(), /** - * When true, a Trigger.dev build is spawned when a Vercel build event occurs. - * This will be handled by the webhook implementation in the other repository. + * Array of environment slugs to pull env vars for before build. + * When an environment slug is in this array, env vars are pulled from Vercel + * before each Trigger.dev build starts for that environment. + * + * Example: ["prod", "stg"] will pull Vercel env vars for production and staging builds + * null/undefined = env var pulling disabled for all environments */ - spawnBuildOnVercelEvent: z.boolean().default(false), + pullEnvVarsBeforeBuild: z.array(EnvSlugSchema).nullable().optional(), /** * Maps a custom Vercel environment to Trigger.dev's staging environment. * Vercel environments: * - production → Trigger.dev production (automatic) * - preview → Trigger.dev preview (automatic) - * - development → not mapped + * - development → Trigger.dev development (automatic) * - custom environments → user can select one to map to Trigger.dev staging * * This field stores the custom Vercel environment ID that maps to staging. * When null, no custom environment is mapped to staging. */ - vercelStagingEnvironment: z.string().nullable().default(null), - - /** - * The name (slug) of the custom Vercel environment mapped to staging. - * This is stored for display purposes to avoid needing to look up the name from the ID. - * When null, no custom environment is mapped to staging. - */ - vercelStagingName: z.string().nullable().default(null), + vercelStagingEnvironment: z.string().nullable().optional(), }); export type VercelIntegrationConfig = z.infer; /** - * Environment types for sync mapping + * Environment types for sync mapping (RuntimeEnvironmentType from database) */ export const TriggerEnvironmentType = z.enum(["PRODUCTION", "STAGING", "PREVIEW", "DEVELOPMENT"]); export type TriggerEnvironmentType = z.infer; /** - * Per-environment sync settings for a single environment variable. - */ -export const EnvVarSyncSettingsSchema = z.record(TriggerEnvironmentType, z.boolean()); -export type EnvVarSyncSettings = z.infer; - -/** - * Mapping of environment variable names to per-environment sync settings. + * Mapping of environment slugs to per-variable sync settings. + * + * Structure: { [envSlug]: { [varName]: boolean } } * - * - If an env var name is missing from this map, it is synced by default for ALL environments. - * - For each env var, you can enable/disable syncing per environment. - * - If an environment is missing from the env var's settings, it defaults to sync (true). + * - If an env slug is missing from this map, all variables are synced by default for that environment. + * - For each environment, you can enable/disable syncing per variable. + * - If a variable is missing from an environment's settings, it defaults to sync (true). * - Secret environment variables from Vercel cannot be synced due to API limitations. * * @example * { - * "DATABASE_URL": { - * "PRODUCTION": true, // sync for production - * "STAGING": false, // don't sync for staging - * "PREVIEW": true, // sync for preview - * "DEVELOPMENT": false // don't sync for development + * "prod": { + * "DATABASE_URL": true, // sync for production + * "DEBUG_MODE": false // don't sync for production * }, - * // "API_KEY" is not in the map - will be synced for all environments by default + * "stg": { + * "DATABASE_URL": true, + * "DEBUG_MODE": true + * } + * // "dev" is not in the map - all variables will be synced for dev by default * } */ -export const SyncEnvVarsMappingSchema = z.record(z.string(), EnvVarSyncSettingsSchema); +export const SyncEnvVarsMappingSchema = z.record(EnvSlugSchema, z.record(z.string(), z.boolean())).default({}); export type SyncEnvVarsMapping = z.infer; @@ -96,10 +100,10 @@ export const VercelProjectIntegrationDataSchema = z.object({ config: VercelIntegrationConfigSchema, /** - * Mapping of environment variable names to whether they should be synced. + * Mapping of environment slugs to per-variable sync settings. * See SyncEnvVarsMappingSchema for detailed documentation. */ - syncEnvVarsMapping: SyncEnvVarsMappingSchema.default({}), + syncEnvVarsMapping: SyncEnvVarsMappingSchema, /** * The name of the Vercel project (for display purposes) @@ -131,11 +135,9 @@ export function createDefaultVercelIntegrationData( ): VercelProjectIntegrationData { return { config: { - pullEnvVarsFromVercel: true, - spawnDeploymentOnVercelEvent: false, - spawnBuildOnVercelEvent: false, + atomicBuilds: null, + pullEnvVarsBeforeBuild: null, vercelStagingEnvironment: null, - vercelStagingName: null, }, syncEnvVarsMapping: {}, vercelProjectId, @@ -144,11 +146,43 @@ export function createDefaultVercelIntegrationData( }; } +/** + * Convert RuntimeEnvironmentType to EnvSlug + */ +export function envTypeToSlug(environmentType: TriggerEnvironmentType): EnvSlug { + switch (environmentType) { + case "DEVELOPMENT": + return "dev"; + case "STAGING": + return "stg"; + case "PRODUCTION": + return "prod"; + case "PREVIEW": + return "preview"; + } +} + +/** + * Convert EnvSlug to RuntimeEnvironmentType + */ +export function envSlugToType(slug: EnvSlug): TriggerEnvironmentType { + switch (slug) { + case "dev": + return "DEVELOPMENT"; + case "stg": + return "STAGING"; + case "prod": + return "PRODUCTION"; + case "preview": + return "PREVIEW"; + } +} + /** * Type guard to check if env var should be synced for a specific environment. * Returns true if: - * - The env var is not in the mapping (sync all by default) - * - The environment is not in the env var's settings (sync by default) + * - The environment slug is not in the mapping (sync all vars by default) + * - The env var is not in the environment's settings (sync by default) * - The value is explicitly true * Returns false only when explicitly set to false for the environment. */ @@ -157,13 +191,14 @@ export function shouldSyncEnvVar( envVarName: string, environmentType: TriggerEnvironmentType ): boolean { - const envVarSettings = mapping[envVarName]; - // If env var not in mapping, sync by default for all environments - if (!envVarSettings) { + const envSlug = envTypeToSlug(environmentType); + const envSettings = mapping[envSlug]; + // If environment not in mapping, sync all vars by default + if (!envSettings) { return true; } - const value = envVarSettings[environmentType]; - // If environment not specified, default to true (sync by default) + const value = envSettings[envVarName]; + // If env var not specified for this environment, default to true (sync by default) // Only skip if explicitly set to false return value !== false; } @@ -176,12 +211,48 @@ export function shouldSyncEnvVarForAnyEnvironment( mapping: SyncEnvVarsMapping, envVarName: string ): boolean { - const envVarSettings = mapping[envVarName]; - // If env var not in mapping, sync by default for all environments - if (!envVarSettings) { - return true; + const envSlugs: EnvSlug[] = ["dev", "stg", "prod", "preview"]; + + // Check each environment + for (const slug of envSlugs) { + const envSettings = mapping[slug]; + // If environment not in mapping, all vars are synced by default + if (!envSettings) { + return true; + } + // If var is explicitly true or not specified for this environment, it's enabled + if (envSettings[envVarName] !== false) { + return true; + } + } + + return false; +} + +/** + * Check if pull env vars is enabled for a specific environment. + */ +export function isPullEnvVarsEnabledForEnvironment( + pullEnvVarsBeforeBuild: EnvSlug[] | null | undefined, + environmentType: TriggerEnvironmentType +): boolean { + if (!pullEnvVarsBeforeBuild || pullEnvVarsBeforeBuild.length === 0) { + return false; + } + const envSlug = envTypeToSlug(environmentType); + return pullEnvVarsBeforeBuild.includes(envSlug); +} + +/** + * Check if atomic builds is enabled for a specific environment. + */ +export function isAtomicBuildsEnabledForEnvironment( + atomicBuilds: EnvSlug[] | null | undefined, + environmentType: TriggerEnvironmentType +): boolean { + if (!atomicBuilds || atomicBuilds.length === 0) { + return false; } - // Check if at least one environment is enabled - const environments: TriggerEnvironmentType[] = ["PRODUCTION", "STAGING", "PREVIEW", "DEVELOPMENT"]; - return environments.some((env) => envVarSettings[env] !== false); + const envSlug = envTypeToSlug(environmentType); + return atomicBuilds.includes(envSlug); } From ca5a494b40a531d2adc59935331ff07168c42241 Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Mon, 26 Jan 2026 17:43:30 +0100 Subject: [PATCH 18/33] feat(env): skip unchanged env var updates and preserve secrets Query and read existing environment variable secret values from the secret store and track which vars are marked secret. Compare current values to the incoming Vercel values and skip any variables whose stored secret value matches the incoming value. Log when no changes are detected and when only changed vars are updated. Also adjust the split between secret and non-secret groups to operate on the filtered changed set. Additionally, avoid redundant secret writes when creating/updating secret references in the environment variables repository by checking the secret store first and skipping unchanged values. These changes reduce unnecessary secret writes and API calls, preserve secret status when overriding, and add diagnostic logging for skipped and changed variables. --- .../app/models/vercelIntegration.server.ts | 65 +++++++++++++++++-- .../route.tsx | 1 - .../environmentVariablesRepository.server.ts | 7 ++ packages/core/src/v3/schemas/api.ts | 1 - 4 files changed, 65 insertions(+), 9 deletions(-) diff --git a/apps/webapp/app/models/vercelIntegration.server.ts b/apps/webapp/app/models/vercelIntegration.server.ts index 0080848c76..0b47afb8ad 100644 --- a/apps/webapp/app/models/vercelIntegration.server.ts +++ b/apps/webapp/app/models/vercelIntegration.server.ts @@ -1301,9 +1301,11 @@ export class VercelIntegrationRepository { continue; } - // Query existing env vars to check which ones are already secrets - // We need to preserve the secret status when overriding + // Query existing env vars to check which ones are already secrets and get their current values + // We need to preserve the secret status when overriding and skip unchanged values const existingSecretKeys = new Set(); + const existingValues = new Map(); + const existingVarValues = await prisma.environmentVariableValue.findMany({ where: { environmentId: mapping.runtimeEnvironmentId, @@ -1316,6 +1318,11 @@ export class VercelIntegrationRepository { }, select: { isSecret: true, + valueReference: { + select: { + key: true, + }, + }, variable: { select: { key: true, @@ -1324,15 +1331,59 @@ export class VercelIntegrationRepository { }, }); - for (const varValue of existingVarValues) { - if (varValue.isSecret) { - existingSecretKeys.add(varValue.variable.key); + // Get existing values from the secret store + if (existingVarValues.length > 0) { + const secretStore = getSecretStore("DATABASE", { prismaClient: prisma }); + const SecretValue = z.object({ secret: z.string() }); + + for (const varValue of existingVarValues) { + if (varValue.isSecret) { + existingSecretKeys.add(varValue.variable.key); + } + + // Fetch the current value from the secret store + if (varValue.valueReference?.key) { + try { + const existingSecret = await secretStore.getSecret(SecretValue, varValue.valueReference.key); + if (existingSecret) { + existingValues.set(varValue.variable.key, existingSecret.secret); + } + } catch { + // If we can't read the existing value, we'll update it anyway + } + } } } + // Filter out vars that haven't changed + const changedVars = varsToSync.filter((v) => { + const existingValue = existingValues.get(v.key); + // Include if: no existing value, or value is different + return existingValue === undefined || existingValue !== v.value; + }); + + if (changedVars.length === 0) { + logger.info("Vercel pullEnvVarsFromVercel: no changes detected, skipping", { + projectId: params.projectId, + vercelTarget: mapping.vercelTarget, + triggerEnvType: mapping.triggerEnvType, + skippedCount: varsToSync.length, + }); + continue; + } + + logger.info("Vercel pullEnvVarsFromVercel: updating changed vars", { + projectId: params.projectId, + vercelTarget: mapping.vercelTarget, + triggerEnvType: mapping.triggerEnvType, + changedCount: changedVars.length, + unchangedCount: varsToSync.length - changedVars.length, + changedKeys: changedVars.map((v) => v.key), + }); + // Split vars into secret and non-secret groups - const secretVars = varsToSync.filter((v) => existingSecretKeys.has(v.key)); - const nonSecretVars = varsToSync.filter((v) => !existingSecretKeys.has(v.key)); + const secretVars = changedVars.filter((v) => existingSecretKeys.has(v.key)); + const nonSecretVars = changedVars.filter((v) => !existingSecretKeys.has(v.key)); // Create non-secret vars if (nonSecretVars.length > 0) { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx index b777b3e676..ebe11d2a0f 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx @@ -408,7 +408,6 @@ export default function Page() { )} diff --git a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts index 575c7c2569..74eadbd04a 100644 --- a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts +++ b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts @@ -190,6 +190,13 @@ export class EnvironmentVariablesRepository implements Repository { for (const environmentId of options.environmentIds) { const key = secretKey(projectId, environmentId, variable.key); + // Check if value already exists and is the same - skip update if unchanged + const existingSecret = await secretStore.getSecret(SecretValue, key); + if (existingSecret && existingSecret.secret === variable.value) { + // Value is unchanged, skip this variable for this environment + continue; + } + //create the secret reference const secretReference = await tx.secretReference.upsert({ where: { diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index dffb3e0c13..df81cdb623 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -577,7 +577,6 @@ export const InitializeDeploymentResponseBody = z.object({ version: z.string(), imageTag: z.string(), imagePlatform: z.string(), - environmentSlug: z.string(), externalBuildData: ExternalBuildData.optional().nullable(), eventStream: z .object({ From 425ebe372e1c544c14b3e85f221ceec74ffd4568 Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Mon, 26 Jan 2026 20:36:44 +0100 Subject: [PATCH 19/33] feat(db): add versioning and lastUpdatedBy to env vars Add a version integer (default 1) and a lastUpdatedBy JSON field to the EnvironmentVariableValue model. Include a migration that alters the EnvironmentVariableValue table to add the version column (NOT NULL, default 1) and the lastUpdatedBy JSONB column. This enables simple optimistic versioning and stores metadata about the actor that last updated an environment variable, improving concurrency control and auditability. --- .../migration.sql | 3 +++ internal-packages/database/prisma/schema.prisma | 3 +++ 2 files changed, 6 insertions(+) create mode 100644 internal-packages/database/prisma/migrations/20260126175159_add_environment_variable_versioning/migration.sql diff --git a/internal-packages/database/prisma/migrations/20260126175159_add_environment_variable_versioning/migration.sql b/internal-packages/database/prisma/migrations/20260126175159_add_environment_variable_versioning/migration.sql new file mode 100644 index 0000000000..17f013f388 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260126175159_add_environment_variable_versioning/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "public"."EnvironmentVariableValue" ADD COLUMN "lastUpdatedBy" JSONB, +ADD COLUMN "version" INTEGER NOT NULL DEFAULT 1; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 4f768b4710..d0fd08b45a 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -1713,6 +1713,9 @@ model EnvironmentVariableValue { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + version Int @default(1) + lastUpdatedBy Json? + @@unique([variableId, environmentId]) } From 3b92fbd0061bda02cb312f8539206d056ac4b0ca Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Mon, 26 Jan 2026 21:22:29 +0100 Subject: [PATCH 20/33] feat(env-vars): add updater metadata and include user info Add a discriminated EnvironmentVariableUpdater schema to represent who last updated a variable (user or integration) and thread it through create/edit/value types. Persist lastUpdatedBy and version fields in presenter queries, collect referenced user records, and resolve a display-friendly updatedBy user object (id, name, avatar) for each environment variable value. Also ensure isSecret is read from the value record and avoid returning secrets' values. These changes allow auditing of who modified env vars (user vs integration), enable showing updater details in the UI, and make the presenter robust against missing value records. --- .../components/integrations/VercelLogo.tsx | 12 ++ .../app/models/vercelIntegration.server.ts | 8 ++ .../EnvironmentVariablesPresenter.server.ts | 66 +++++++++- .../route.tsx | 8 +- .../route.tsx | 54 ++++++-- ...cts.$projectParam.env.$envParam.vercel.tsx | 23 +--- .../environmentVariablesRepository.server.ts | 117 +++++++++++------- .../app/v3/environmentVariables/repository.ts | 18 +++ 8 files changed, 231 insertions(+), 75 deletions(-) create mode 100644 apps/webapp/app/components/integrations/VercelLogo.tsx diff --git a/apps/webapp/app/components/integrations/VercelLogo.tsx b/apps/webapp/app/components/integrations/VercelLogo.tsx new file mode 100644 index 0000000000..7ddf039abf --- /dev/null +++ b/apps/webapp/app/components/integrations/VercelLogo.tsx @@ -0,0 +1,12 @@ +export function VercelLogo({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/models/vercelIntegration.server.ts b/apps/webapp/app/models/vercelIntegration.server.ts index 0b47afb8ad..a32ace06d6 100644 --- a/apps/webapp/app/models/vercelIntegration.server.ts +++ b/apps/webapp/app/models/vercelIntegration.server.ts @@ -1395,6 +1395,10 @@ export class VercelIntegrationRepository { key: v.key, value: v.value, })), + lastUpdatedBy: { + type: "integration", + integration: "vercel", + }, }); if (result.success) { @@ -1423,6 +1427,10 @@ export class VercelIntegrationRepository { key: v.key, value: v.value, })), + lastUpdatedBy: { + type: "integration", + integration: "vercel", + }, }); if (result.success) { diff --git a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts index d3f7f4b238..4189ea54c3 100644 --- a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts @@ -3,6 +3,7 @@ import { Project } from "~/models/project.server"; import { User } from "~/models/user.server"; import { filterOrphanedEnvironments, sortEnvironments } from "~/utils/environmentSort"; import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; +import type { EnvironmentVariableUpdater } from "~/v3/environmentVariables/repository"; import { SyncEnvVarsMapping, EnvSlug, @@ -48,6 +49,9 @@ export class EnvironmentVariablesPresenter { select: { id: true, environmentId: true, + version: true, + lastUpdatedBy: true, + updatedAt: true, valueReference: { select: { key: true, @@ -71,6 +75,42 @@ export class EnvironmentVariablesPresenter { }, }); + const userIds = new Set( + environmentVariables + .flatMap((envVar) => envVar.values) + .map((value) => value.lastUpdatedBy) + .filter( + (lastUpdatedBy): lastUpdatedBy is { type: "user"; userId: string } => + lastUpdatedBy !== null && + typeof lastUpdatedBy === "object" && + "type" in lastUpdatedBy && + lastUpdatedBy.type === "user" && + "userId" in lastUpdatedBy && + typeof lastUpdatedBy.userId === "string" + ) + .map((lastUpdatedBy) => lastUpdatedBy.userId) + ); + + const users = + userIds.size > 0 + ? await this.#prismaClient.user.findMany({ + where: { + id: { + in: Array.from(userIds), + }, + }, + select: { + id: true, + name: true, + displayName: true, + avatarUrl: true, + }, + }) + : []; + + const usersRecord: Record = + Object.fromEntries(users.map((u) => [u.id, u])); + const environments = await this.#prismaClient.runtimeEnvironment.findMany({ select: { id: true, @@ -117,13 +157,29 @@ export class EnvironmentVariablesPresenter { return sortedEnvironments.flatMap((env) => { const val = variable?.values.find((v) => v.environment.id === env.id); - const isSecret = - environmentVariable.values.find((v) => v.environmentId === env.id)?.isSecret ?? false; + const valueRecord = environmentVariable.values.find((v) => v.environmentId === env.id); + const isSecret = valueRecord?.isSecret ?? false; - if (!val) { + if (!val || !valueRecord) { return []; } + const lastUpdatedBy = valueRecord.lastUpdatedBy as EnvironmentVariableUpdater | null; + + const updatedByUser = + lastUpdatedBy?.type === "user" + ? (() => { + const user = usersRecord[lastUpdatedBy.userId]; + return user + ? { + id: user.id, + name: user.displayName || user.name || "Unknown", + avatarUrl: user.avatarUrl, + } + : null; + })() + : null; + return [ { id: environmentVariable.id, @@ -131,6 +187,10 @@ export class EnvironmentVariablesPresenter { environment: { type: env.type, id: env.id, branchName: env.branchName }, value: isSecret ? "" : val.value, isSecret, + version: valueRecord.version, + lastUpdatedBy, + updatedByUser, + updatedAt: valueRecord.updatedAt, }, ]; }); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx index c52942a8ac..86bd5bbc95 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx @@ -151,7 +151,13 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { } const repository = new EnvironmentVariablesRepository(prisma); - const result = await repository.create(project.id, submission.value); + const result = await repository.create(project.id, { + ...submission.value, + lastUpdatedBy: { + type: "user", + userId, + }, + }); if (!result.success) { if (result.variableErrors) { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx index ebe11d2a0f..923ccf8276 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx @@ -19,10 +19,12 @@ import { useEffect, useMemo, useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; +import { VercelLogo } from "~/components/integrations/VercelLogo"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { ClipboardField } from "~/components/primitives/ClipboardField"; import { CopyableText } from "~/components/primitives/CopyableText"; +import { DateTime } from "~/components/primitives/DateTime"; import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; import { Fieldset } from "~/components/primitives/Fieldset"; import { FormButtons } from "~/components/primitives/FormButtons"; @@ -70,6 +72,7 @@ import { EditEnvironmentVariableValue, EnvironmentVariable, } from "~/v3/environmentVariables/repository"; +import { UserAvatar } from "~/components/UserProfilePhoto"; import { VercelIntegrationService } from "~/services/vercelIntegration.server"; import { shouldSyncEnvVar, isPullEnvVarsEnabledForEnvironment, type TriggerEnvironmentType } from "~/v3/vercel/vercelProjectIntegrationSchema"; @@ -160,7 +163,13 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { switch (submission.value.action) { case "edit": { const repository = new EnvironmentVariablesRepository(prisma); - const result = await repository.editValue(project.id, submission.value); + const result = await repository.editValue(project.id, { + ...submission.value, + lastUpdatedBy: { + type: "user", + userId, + }, + }); if (!result.success) { submission.error.key = [result.error]; @@ -307,17 +316,17 @@ export default function Page() {
- + Key - + Value - + Environment {vercelIntegration?.enabled && ( - + @@ -329,7 +338,10 @@ export default function Page() { /> )} - + Ver + Updated by + Updated at + Actions @@ -407,8 +419,36 @@ export default function Page() { )} )} + + {variable.version} + + + {variable.updatedByUser ? ( +
+ + {variable.updatedByUser.name} +
+ ) : variable.lastUpdatedBy?.type === "integration" ? ( +
+ + + {variable.lastUpdatedBy.integration} + +
+ ) : null} +
+ + {variable.updatedAt ? ( + + ) : null} + - + {environmentVariables.length === 0 ? (
You haven't set any environment variables yet. diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx index 0360d3d896..890a1ca321 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx @@ -44,6 +44,7 @@ import { TooltipTrigger, TooltipProvider } from "~/components/primitives/Tooltip"; +import { VercelLogo } from "~/components/integrations/VercelLogo"; import { EnvironmentIcon, environmentFullTitle, @@ -483,18 +484,6 @@ export function vercelResourcePath( return `/resources/orgs/${organizationSlug}/projects/${projectSlug}/env/${environmentSlug}/vercel`; } -function VercelIcon({ className }: { className?: string }) { - return ( - - - - ); -} function VercelConnectionPrompt({ organizationSlug, @@ -538,7 +527,7 @@ function VercelConnectionPrompt({ LeadingIcon={ isLoadingProjects ? () => - : () => + : () => } > {isLoadingProjects ? "Loading projects..." : "Connect Vercel project"} @@ -557,7 +546,7 @@ function VercelConnectionPrompt({ } + LeadingIcon={() => } > Install Vercel app @@ -708,7 +697,7 @@ function ConnectedVercelProjectForm({ {/* Connected project info */}
- + {connectedProject.vercelProjectName} @@ -1535,7 +1524,7 @@ function VercelOnboardingModal({
- + Set up Vercel Integration
@@ -1558,7 +1547,7 @@ function VercelOnboardingModal({
- + Set up Vercel Integration
diff --git a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts index 74eadbd04a..b925bc4f3a 100644 --- a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts +++ b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts @@ -6,9 +6,12 @@ import { env } from "~/env.server"; import { getSecretStore } from "~/services/secrets/secretStore.server"; import { generateFriendlyId } from "../friendlyIdentifiers"; import { + type CreateEnvironmentVariables, type CreateResult, type DeleteEnvironmentVariable, type DeleteEnvironmentVariableValue, + type EditEnvironmentVariable, + type EditEnvironmentVariableValue, type EnvironmentVariable, type EnvironmentVariableWithSecret, type ProjectEnvironmentVariable, @@ -45,20 +48,7 @@ const SecretValue = z.object({ secret: z.string() }); export class EnvironmentVariablesRepository implements Repository { constructor(private prismaClient: PrismaClient = prisma) {} - async create( - projectId: string, - options: { - override: boolean; - environmentIds: string[]; - isSecret?: boolean; - // When creating variables for a branch environment, inherit isSecret from parent if not explicitly set - parentEnvironmentId?: string; - variables: { - key: string; - value: string; - }[]; - } - ): Promise { + async create(projectId: string, options: CreateEnvironmentVariables): Promise { const project = await this.prismaClient.project.findFirst({ where: { id: projectId, @@ -209,28 +199,44 @@ export class EnvironmentVariablesRepository implements Repository { update: {}, }); - const variableValue = await tx.environmentVariableValue.upsert({ + const existingValueRecord = await tx.environmentVariableValue.findFirst({ where: { - variableId_environmentId: { - variableId: environmentVariable.id, - environmentId, - }, - }, - create: { variableId: environmentVariable.id, - environmentId: environmentId, - valueReferenceId: secretReference.id, - isSecret: effectiveIsSecret, + environmentId, }, - // Only update isSecret if explicitly provided, otherwise preserve existing value - update: - options.isSecret !== undefined - ? { - isSecret: options.isSecret, - } - : {}, }); + if (existingValueRecord) { + await tx.environmentVariableValue.update({ + where: { + id: existingValueRecord.id, + }, + data: { + version: { + increment: 1, + }, + lastUpdatedBy: options.lastUpdatedBy ? options.lastUpdatedBy : undefined, + valueReferenceId: secretReference.id, + ...(options.isSecret !== undefined + ? { + isSecret: options.isSecret, + } + : {}), + }, + }); + } else { + await tx.environmentVariableValue.create({ + data: { + variableId: environmentVariable.id, + environmentId: environmentId, + valueReferenceId: secretReference.id, + isSecret: effectiveIsSecret, + version: 1, + lastUpdatedBy: options.lastUpdatedBy ? options.lastUpdatedBy : Prisma.JsonNull, + }, + }); + } + await secretStore.setSecret<{ secret: string }>(key, { secret: variable.value, }); @@ -259,14 +265,7 @@ export class EnvironmentVariablesRepository implements Repository { } } - async edit( - projectId: string, - options: { - values: { value: string; environmentId: string }[]; - id: string; - keepEmptyValues?: boolean; - } - ): Promise { + async edit(projectId: string, options: EditEnvironmentVariable): Promise { const project = await this.prismaClient.project.findFirst({ where: { id: projectId, @@ -356,6 +355,20 @@ export class EnvironmentVariablesRepository implements Repository { await secretStore.setSecret<{ secret: string }>(key, { secret: value.value, }); + await tx.environmentVariableValue.update({ + where: { + variableId_environmentId: { + variableId: environmentVariable.id, + environmentId: value.environmentId, + }, + }, + data: { + version: { + increment: 1, + }, + lastUpdatedBy: options.lastUpdatedBy ? options.lastUpdatedBy : undefined, + }, + }); } continue; } @@ -373,6 +386,8 @@ export class EnvironmentVariablesRepository implements Repository { variableId: environmentVariable.id, environmentId: value.environmentId, valueReferenceId: secretReference.id, + version: 1, + lastUpdatedBy: options.lastUpdatedBy ? options.lastUpdatedBy : Prisma.JsonNull, }, }); @@ -393,14 +408,7 @@ export class EnvironmentVariablesRepository implements Repository { } } - async editValue( - projectId: string, - options: { - id: string; - environmentId: string; - value: string; - } - ): Promise { + async editValue(projectId: string, options: EditEnvironmentVariableValue): Promise { const project = await this.prismaClient.project.findFirst({ where: { id: projectId, @@ -459,6 +467,21 @@ export class EnvironmentVariablesRepository implements Repository { await secretStore.setSecret<{ secret: string }>(key, { secret: options.value, }); + + await tx.environmentVariableValue.update({ + where: { + variableId_environmentId: { + variableId: environmentVariable.id, + environmentId: options.environmentId, + }, + }, + data: { + version: { + increment: 1, + }, + lastUpdatedBy: options.lastUpdatedBy ? options.lastUpdatedBy : undefined, + }, + }); }); return { diff --git a/apps/webapp/app/v3/environmentVariables/repository.ts b/apps/webapp/app/v3/environmentVariables/repository.ts index 521e22f7a2..ea027bc2ca 100644 --- a/apps/webapp/app/v3/environmentVariables/repository.ts +++ b/apps/webapp/app/v3/environmentVariables/repository.ts @@ -6,9 +6,25 @@ export const EnvironmentVariableKey = z .nonempty("Key is required") .regex(/^\w+$/, "Keys can only use alphanumeric characters and underscores"); +export const EnvironmentVariableUpdaterSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("user"), + userId: z.string(), + }), + z.object({ + type: z.literal("integration"), + integration: z.string(), + }), +]); +export type EnvironmentVariableUpdater = z.infer; + export const CreateEnvironmentVariables = z.object({ + override: z.boolean(), environmentIds: z.array(z.string()), + isSecret: z.boolean().optional(), + parentEnvironmentId: z.string().optional(), variables: z.array(z.object({ key: EnvironmentVariableKey, value: z.string() })), + lastUpdatedBy: EnvironmentVariableUpdaterSchema.optional(), }); export type CreateEnvironmentVariables = z.infer; @@ -32,6 +48,7 @@ export const EditEnvironmentVariable = z.object({ }) ), keepEmptyValues: z.boolean().optional(), + lastUpdatedBy: EnvironmentVariableUpdaterSchema.optional(), }); export type EditEnvironmentVariable = z.infer; @@ -51,6 +68,7 @@ export const EditEnvironmentVariableValue = z.object({ id: z.string(), environmentId: z.string(), value: z.string(), + lastUpdatedBy: EnvironmentVariableUpdaterSchema.optional(), }); export type EditEnvironmentVariableValue = z.infer; From 99e1f81c7ae177e62a3e81b5d1bc74d849477103 Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Wed, 28 Jan 2026 18:55:04 +0100 Subject: [PATCH 21/33] feat(vercel): add pullNewEnvVars option and modal dismiss control Add pullNewEnvVars to Vercel integration types, schema, defaults, and parsing so integrations can opt into discovering new environment variables from Vercel during builds. - Add pullNewEnv boolean to VercelProjectIntegrationData and to the Vercel integration Zod schema (nullable/optional). - Default pullNewEnvVars to true in createDefaultVercelIntegrationData and update defaults for atomicBuilds and pullEnvVarsBeforeBuild to include non-dev environments. - Add helper isPullNewEnvVarsEnabled to coerce undefined/null to true. - Parse pullNewEnvVars from route form input (string -> boolean|null). Also improve GitHub connect modal handling: - Add preventDismiss prop to the connect GitHub modal component. - Prevent closing the modal via Escape key or clicking outside when preventDismiss is true by blocking onInteractOutside/onEscapeKeyDown and refusing to change open state when attempting to close. These changes enable automatic discovery of new Vercel env vars by default and provide a way to force modal persistence during critical flows. --- ...cts.$projectParam.env.$envParam.github.tsx | 28 +- ...cts.$projectParam.env.$envParam.vercel.tsx | 317 ++++++++++++++---- apps/webapp/app/routes/vercel.onboarding.tsx | 280 +++++++++------- .../app/services/vercelIntegration.server.ts | 2 + .../vercel/vercelProjectIntegrationSchema.ts | 27 +- 5 files changed, 454 insertions(+), 200 deletions(-) diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx index bb7406ed44..2673a9c464 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx @@ -330,12 +330,15 @@ export function ConnectGitHubRepoModal({ projectSlug, environmentSlug, redirectUrl, + preventDismiss, }: { gitHubAppInstallations: GitHubAppInstallation[]; organizationSlug: string; projectSlug: string; environmentSlug: string; redirectUrl?: string; + /** When true, prevents closing the modal via Escape key or clicking outside */ + preventDismiss?: boolean; }) { const [isModalOpen, setIsModalOpen] = useState(false); const lastSubmission = useActionData() as any; @@ -385,13 +388,34 @@ export function ConnectGitHubRepoModal({ const actionUrl = gitHubResourcePath(organizationSlug, projectSlug, environmentSlug); return ( - + { + // When preventDismiss is true, only allow opening, not closing + if (preventDismiss && !open) { + return; + } + setIsModalOpen(open); + }} + > - + { + if (preventDismiss) { + e.preventDefault(); + } + }} + onEscapeKeyDown={(e) => { + if (preventDismiss) { + e.preventDefault(); + } + }} + > Connect GitHub repository
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx index 890a1ca321..5175609152 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx @@ -172,6 +172,10 @@ const UpdateVercelConfigFormSchema = z.object({ return null; } }), + pullNewEnvVars: z.string().optional().transform((val) => { + if (val === undefined || val === "") return null; + return val === "true"; + }), vercelStagingEnvironment: z.string().nullable().optional(), }); @@ -200,8 +204,14 @@ const CompleteOnboardingFormSchema = z.object({ return null; } }), + pullNewEnvVars: z.string().optional().transform((val) => { + if (val === undefined || val === "") return null; + return val === "true"; + }), syncEnvVarsMapping: z.string().optional(), // JSON-encoded mapping next: z.string().optional(), + // When true, returns JSON instead of redirecting (used when transitioning to github-connection step) + skipRedirect: z.string().optional().transform((val) => val === "true"), }); const SkipOnboardingFormSchema = z.object({ @@ -329,12 +339,14 @@ export async function action({ request, params }: ActionFunctionArgs) { const { atomicBuilds, pullEnvVarsBeforeBuild, + pullNewEnvVars, vercelStagingEnvironment, } = submission.value; const result = await vercelService.updateVercelIntegrationConfig(project.id, { atomicBuilds: atomicBuilds as EnvSlug[] | null, pullEnvVarsBeforeBuild: pullEnvVarsBeforeBuild as EnvSlug[] | null, + pullNewEnvVars: pullNewEnvVars, vercelStagingEnvironment: vercelStagingEnvironment ?? null, }); @@ -362,8 +374,10 @@ export async function action({ request, params }: ActionFunctionArgs) { vercelStagingEnvironment, pullEnvVarsBeforeBuild, atomicBuilds, + pullNewEnvVars, syncEnvVarsMapping, next, + skipRedirect, } = submission.value; let parsedMapping: SyncEnvVarsMapping = {}; @@ -380,6 +394,7 @@ export async function action({ request, params }: ActionFunctionArgs) { vercelStagingEnvironment, pullEnvVarsBeforeBuild, atomicBuilds, + pullNewEnvVars, syncEnvVarsMappingRaw: syncEnvVarsMapping, parsedMappingKeys: Object.keys(parsedMapping), }); @@ -388,10 +403,16 @@ export async function action({ request, params }: ActionFunctionArgs) { vercelStagingEnvironment: vercelStagingEnvironment ?? null, pullEnvVarsBeforeBuild: pullEnvVarsBeforeBuild as EnvSlug[] | null, atomicBuilds: atomicBuilds as EnvSlug[] | null, + pullNewEnvVars: pullNewEnvVars, syncEnvVarsMapping: parsedMapping, }); if (result) { + // If skipRedirect is true, return success without redirect (used when transitioning to github-connection step) + if (skipRedirect) { + return json({ success: true }); + } + // Check if we should redirect to the 'next' URL if (next) { try { @@ -639,6 +660,7 @@ function ConnectedVercelProjectForm({ const [configValues, setConfigValues] = useState({ atomicBuilds: connectedProject.integrationData.config.atomicBuilds ?? [], pullEnvVarsBeforeBuild: connectedProject.integrationData.config.pullEnvVarsBeforeBuild ?? [], + pullNewEnvVars: connectedProject.integrationData.config.pullNewEnvVars !== false, vercelStagingEnvironment: connectedProject.integrationData.config.vercelStagingEnvironment || "", }); @@ -646,6 +668,7 @@ function ConnectedVercelProjectForm({ // Track original values for comparison const originalAtomicBuilds = connectedProject.integrationData.config.atomicBuilds ?? []; const originalPullEnvVars = connectedProject.integrationData.config.pullEnvVarsBeforeBuild ?? []; + const originalPullNewEnvVars = connectedProject.integrationData.config.pullNewEnvVars !== false; const originalStagingEnv = connectedProject.integrationData.config.vercelStagingEnvironment || ""; useEffect(() => { @@ -655,10 +678,11 @@ function ConnectedVercelProjectForm({ const pullEnvVarsChanged = JSON.stringify([...configValues.pullEnvVarsBeforeBuild].sort()) !== JSON.stringify([...originalPullEnvVars].sort()); + const pullNewEnvVarsChanged = configValues.pullNewEnvVars !== originalPullNewEnvVars; const stagingEnvChanged = configValues.vercelStagingEnvironment !== originalStagingEnv; - setHasConfigChanges(atomicBuildsChanged || pullEnvVarsChanged || stagingEnvChanged); - }, [configValues, originalAtomicBuilds, originalPullEnvVars, originalStagingEnv]); + setHasConfigChanges(atomicBuildsChanged || pullEnvVarsChanged || pullNewEnvVarsChanged || stagingEnvChanged); + }, [configValues, originalAtomicBuilds, originalPullEnvVars, originalPullNewEnvVars, originalStagingEnv]); const [configForm, fields] = useForm({ id: "update-vercel-config", @@ -755,6 +779,11 @@ function ConnectedVercelProjectForm({ name="pullEnvVarsBeforeBuild" value={JSON.stringify(configValues.pullEnvVarsBeforeBuild)} /> + +
+ {/* Discover new env vars */} + {(() => { + const isPullEnvVarsDisabled = configValues.pullEnvVarsBeforeBuild.length === 0; + return ( +
+
+
+ + + When enabled, automatically discovers and creates new environment variables + from Vercel that don't exist in Trigger.dev yet during builds. + +
+ + setConfigValues((prev) => ({ ...prev, pullNewEnvVars: checked })) + } + /> +
+
+ ); + })()} + {/* Atomic deployments */}
@@ -1112,15 +1168,18 @@ function VercelOnboardingModal({ } return "project-selection"; } - const customEnvs = (onboardingData?.customEnvironments?.length ?? 0) > 0 && hasStagingEnvironment; - if (customEnvs) { - return "env-mapping"; + // For marketplace origin, skip env-mapping step and go directly to env-var-sync + if (!fromMarketplaceContext) { + const customEnvs = (onboardingData?.customEnvironments?.length ?? 0) > 0 && hasStagingEnvironment; + if (customEnvs) { + return "env-mapping"; + } } if (!onboardingData?.environmentVariables || onboardingData.environmentVariables.length === 0) { return "loading-env-vars"; } return "env-var-sync"; - }, [hasOrgIntegration, onboardingData, hasStagingEnvironment]); + }, [hasOrgIntegration, onboardingData, hasStagingEnvironment, fromMarketplaceContext]); // Initialize state based on current data when modal opens const [state, setState] = useState(() => { @@ -1158,13 +1217,16 @@ function VercelOnboardingModal({ (s) => s !== "dev" ); // Build settings state (for build-settings step) - // Default: pull env vars enabled for all environments + // Default: pull env vars and atomic builds enabled for all non-dev environments const [pullEnvVarsBeforeBuild, setPullEnvVarsBeforeBuild] = useState( () => (hasStagingEnvironment ? ["prod", "stg", "preview"] : ["prod", "preview"]) ); - const [atomicBuilds, setAtomicBuilds] = useState([]); + const [atomicBuilds, setAtomicBuilds] = useState( + () => (hasStagingEnvironment ? ["prod", "stg", "preview"] : ["prod", "preview"]) + ); + const [pullNewEnvVars, setPullNewEnvVars] = useState(true); - // Sync pullEnvVarsBeforeBuild when hasStagingEnvironment becomes true (once) + // Sync pullEnvVarsBeforeBuild and atomicBuilds when hasStagingEnvironment becomes true (once) // This ensures staging is included when it becomes available, but respects user changes after useEffect(() => { if (hasStagingEnvironment && !hasSyncedStagingRef.current) { @@ -1175,6 +1237,12 @@ function VercelOnboardingModal({ } return prev; }); + setAtomicBuilds((prev) => { + if (!prev.includes("stg")) { + return [...prev, "stg"]; + } + return prev; + }); } }, [hasStagingEnvironment]); // Env var sync state (for env-var-sync step - one-time sync) @@ -1186,6 +1254,44 @@ function VercelOnboardingModal({ const gitHubAppInstallations = onboardingData?.gitHubAppInstallations ?? []; const isGitHubConnectedForOnboarding = onboardingData?.isGitHubConnected ?? false; + // Track if we've triggered a redirect for marketplace completion + const hasTriggeredMarketplaceRedirectRef = useRef(false); + + // Auto-redirect for marketplace flow when returning from GitHub with everything complete + useEffect(() => { + // Only trigger once per session to prevent redirect loops + if (hasTriggeredMarketplaceRedirectRef.current) { + return; + } + + // Check if all conditions are met for auto-redirect: + // - Modal is open + // - Coming from marketplace + // - Has nextUrl to redirect to + // - Project is already connected (onboarding settings saved) + // - GitHub is now connected + if ( + isOpen && + fromMarketplaceContext && + nextUrl && + hasProjectSelected && + isGitHubConnectedForOnboarding + ) { + hasTriggeredMarketplaceRedirectRef.current = true; + // Small delay to ensure state is settled before redirect + setTimeout(() => { + window.location.href = nextUrl; + }, 100); + } + }, [isOpen, fromMarketplaceContext, nextUrl, hasProjectSelected, isGitHubConnectedForOnboarding]); + + // Reset the redirect ref when modal closes + useEffect(() => { + if (!isOpen) { + hasTriggeredMarketplaceRedirectRef.current = false; + } + }, [isOpen]); + // Track if we've triggered a reload for the current loading state to prevent infinite loops const loadingStateRef = useRef(null); @@ -1278,13 +1384,14 @@ function VercelOnboardingModal({ }, [state, fetcher.data, fetcher.state, onDataReload]); // Handle loading-env-mapping completion - check for custom environments + // For marketplace origin, skip env-mapping step useEffect(() => { if (state === "loading-env-mapping" && onboardingData) { const hasCustomEnvs = (onboardingData.customEnvironments?.length ?? 0) > 0 && hasStagingEnvironment; - if (hasCustomEnvs) { + if (hasCustomEnvs && !fromMarketplaceContext) { setState("env-mapping"); } else { - // No custom envs, load env vars + // No custom envs or marketplace flow, load env vars setState("loading-env-vars"); } } @@ -1421,11 +1528,17 @@ function VercelOnboardingModal({ formData.append("vercelStagingEnvironment", vercelStagingEnvironment || ""); formData.append("pullEnvVarsBeforeBuild", JSON.stringify(pullEnvVarsBeforeBuild)); formData.append("atomicBuilds", JSON.stringify(atomicBuilds)); + formData.append("pullNewEnvVars", String(pullNewEnvVars)); formData.append("syncEnvVarsMapping", JSON.stringify(syncEnvVarsMapping)); if (nextUrl && fromMarketplaceContext && isGitHubConnectedForOnboarding) { formData.append("next", nextUrl); } + // If GitHub is not connected, skip redirect to stay on modal and transition to github-connection step + if (!isGitHubConnectedForOnboarding) { + formData.append("skipRedirect", "true"); + } + completeOnboardingFetcher.submit(formData, { method: "post", action: actionUrl, @@ -1435,7 +1548,7 @@ function VercelOnboardingModal({ if (!isGitHubConnectedForOnboarding) { setState("github-connection"); } - }, [vercelStagingEnvironment, pullEnvVarsBeforeBuild, atomicBuilds, syncEnvVarsMapping, nextUrl, fromMarketplaceContext, isGitHubConnectedForOnboarding, completeOnboardingFetcher, actionUrl]); + }, [vercelStagingEnvironment, pullEnvVarsBeforeBuild, atomicBuilds, pullNewEnvVars, syncEnvVarsMapping, nextUrl, fromMarketplaceContext, isGitHubConnectedForOnboarding, completeOnboardingFetcher, actionUrl]); const handleFinishOnboarding = useCallback((e: React.FormEvent) => { e.preventDefault(); @@ -1660,12 +1773,15 @@ function VercelOnboardingModal({ Cancel
- + {/* Skip button only shown for dashboard flow */} + {!fromMarketplaceContext && ( + + )} } cancelButton={ - hasCustomEnvs ? ( + hasCustomEnvs && !fromMarketplaceContext ? (
+ {/* Discover new env vars */} + {(() => { + const isPullEnvVarsDisabled = pullEnvVarsBeforeBuild.length === 0; + return ( +
+
+
+ + + When enabled, automatically discovers and creates new environment variables + from Vercel that don't exist in Trigger.dev yet during builds. + +
+ +
+
+ ); + })()} + {/* Atomic deployments */}
@@ -1959,57 +2108,93 @@ function VercelOnboardingModal({

- {gitHubAppInstallations.length === 0 ? ( -
- - Install GitHub app - -
- ) : ( -
-
- { + // Build redirect URL that preserves Vercel marketplace context + const baseSettingsPath = v3ProjectSettingsPath( + { slug: organizationSlug }, + { slug: projectSlug }, + { slug: environmentSlug } + ); + const redirectParams = new URLSearchParams(); + redirectParams.set("vercelOnboarding", "true"); + if (fromMarketplaceContext) { + redirectParams.set("origin", "marketplace"); + } + if (nextUrl) { + redirectParams.set("next", nextUrl); + } + const redirectUrlWithContext = `${baseSettingsPath}?${redirectParams.toString()}`; + + return gitHubAppInstallations.length === 0 ? ( +
+ - - GitHub app is installed - + variant="secondary/medium" + LeadingIcon={OctoKitty} + > + Install GitHub app +
-
- )} + ) : ( +
+
+ + + GitHub app is installed + +
+
+ ); + })()} { - setState("completed"); - if (fromMarketplaceContext && nextUrl) { + isGitHubConnectedForOnboarding && fromMarketplaceContext && nextUrl ? ( + + }} + > + Complete + + ) : ( + + ) + } + cancelButton={ + isGitHubConnectedForOnboarding && fromMarketplaceContext && nextUrl ? ( + + ) : undefined } />
diff --git a/apps/webapp/app/routes/vercel.onboarding.tsx b/apps/webapp/app/routes/vercel.onboarding.tsx index 676605245f..f950d3da2f 100644 --- a/apps/webapp/app/routes/vercel.onboarding.tsx +++ b/apps/webapp/app/routes/vercel.onboarding.tsx @@ -1,16 +1,20 @@ import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; import { json, redirect } from "@remix-run/server-runtime"; import { useState } from "react"; -import { Form, useLoaderData, useNavigation } from "@remix-run/react"; +import { Form, useNavigation } from "@remix-run/react"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; +import { BuildingOfficeIcon, FolderIcon } from "@heroicons/react/20/solid"; import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout"; import { BackgroundWrapper } from "~/components/BackgroundWrapper"; -import { Button } from "~/components/primitives/Buttons"; -import { Callout } from "~/components/primitives/Callout"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Fieldset } from "~/components/primitives/Fieldset"; import { FormButtons } from "~/components/primitives/FormButtons"; import { FormTitle } from "~/components/primitives/FormTitle"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { Label } from "~/components/primitives/Label"; import { Select, SelectItem } from "~/components/primitives/Select"; +import { ButtonSpinner } from "~/components/primitives/Spinner"; import { prisma } from "~/db.server"; import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; @@ -30,7 +34,7 @@ const SelectOrgActionSchema = z.object({ action: z.literal("select-org"), organizationId: z.string(), code: z.string(), - configurationId: z.string(), + configurationId: z.string().optional().nullable(), next: z.string().optional(), }); @@ -39,7 +43,7 @@ const SelectProjectActionSchema = z.object({ projectId: z.string(), organizationId: z.string(), code: z.string(), - configurationId: z.string(), + configurationId: z.string().optional().nullable(), next: z.string().optional().nullable(), }); @@ -62,7 +66,7 @@ export async function loader({ request }: LoaderFunctionArgs) { if (!params.success) { logger.error("Invalid params for Vercel onboarding", { error: params.error }); - return redirectWithErrorMessage( + throw redirectWithErrorMessage( "/", request, "Invalid installation parameters. Please try again from Vercel." @@ -71,12 +75,12 @@ export async function loader({ request }: LoaderFunctionArgs) { const { error } = params.data; if (error === "expired") { - return json({ + return typedjson({ step: "error" as const, error: "Your installation session has expired. Please start the installation again.", code: params.data.code, - configurationId: params.data.configurationId, - next: params.data.next, + configurationId: params.data.configurationId ?? null, + next: params.data.next ?? null, }); } @@ -112,15 +116,16 @@ export async function loader({ request }: LoaderFunctionArgs) { // New user: no organizations if (organizations.length === 0) { - const onboardingParams = new URLSearchParams({ - code: params.data.code, - configurationId: params.data.configurationId, - integration: "vercel", - }); + const onboardingParams = new URLSearchParams(); + onboardingParams.set("code", params.data.code); + if (params.data.configurationId) { + onboardingParams.set("configurationId", params.data.configurationId); + } + onboardingParams.set("integration", "vercel"); if (params.data.next) { onboardingParams.set("next", params.data.next); } - return redirect(`${confirmBasicDetailsPath()}?${onboardingParams.toString()}`); + throw redirect(`${confirmBasicDetailsPath()}?${onboardingParams.toString()}`); } // If organizationId is provided, show project selection @@ -132,29 +137,29 @@ export async function loader({ request }: LoaderFunctionArgs) { organizationId: params.data.organizationId, userId, }); - return redirectWithErrorMessage( + throw redirectWithErrorMessage( "/", request, "Organization not found. Please try again." ); } - return json({ + return typedjson({ step: "project" as const, organization, organizations, code: params.data.code, - configurationId: params.data.configurationId, - next: params.data.next, + configurationId: params.data.configurationId ?? null, + next: params.data.next ?? null, }); } - return json({ + return typedjson({ step: "org" as const, organizations, code: params.data.code, - configurationId: params.data.configurationId, - next: params.data.next, + configurationId: params.data.configurationId ?? null, + next: params.data.next ?? null, }); } @@ -181,11 +186,12 @@ export async function action({ request }: ActionFunctionArgs) { if (submission.data.action === "select-org") { const { organizationId } = submission.data; - const projectParams = new URLSearchParams({ - organizationId, - code, - configurationId, - }); + const projectParams = new URLSearchParams(); + projectParams.set("organizationId", organizationId); + projectParams.set("code", code); + if (configurationId) { + projectParams.set("configurationId", configurationId); + } if (next) { projectParams.set("next", next); } @@ -237,12 +243,13 @@ export async function action({ request }: ActionFunctionArgs) { projectSlug: project.slug, }); - const params = new URLSearchParams({ - state, - code, - configurationId, - origin: "marketplace", - }); + const params = new URLSearchParams(); + params.set("state", state); + params.set("code", code); + if (configurationId) { + params.set("configurationId", configurationId); + } + params.set("origin", "marketplace"); if (next) { params.set("next", next); } @@ -255,7 +262,7 @@ export async function action({ request }: ActionFunctionArgs) { } export default function VercelOnboardingPage() { - const data = useLoaderData(); + const data = useTypedLoaderData(); const navigation = useNavigation(); const isSubmitting = navigation.state === "submitting"; const [isInstalling, setIsInstalling] = useState(false); @@ -280,67 +287,71 @@ export default function VercelOnboardingPage() { } if (data.step === "org") { + const newOrgUrl = (() => { + const params = new URLSearchParams(); + params.set("code", data.code); + if (data.configurationId) { + params.set("configurationId", data.configurationId); + } + params.set("integration", "vercel"); + if (data.next) { + params.set("next", data.next); + } + return `/orgs/new?${params.toString()}`; + })(); + return ( } title="Select Organization" description="Choose which organization to install the Vercel integration into." /> - + {data.configurationId && ( + + )} {data.next && }
- + typeof v === "string" + ? data.organizations.find((o) => o.id === v)?.title || "Choose an organization" + : "Choose an organization" + } + > + {data.organizations.map((org) => ( + + {org.title} + + ))} + + + + + + New Organization + + +
} - > - {data.organizations.map((org) => ( - - {org.title} - - ))} - -
- -
- - -
-
+ /> @@ -349,71 +360,80 @@ export default function VercelOnboardingPage() { ); } + const newProjectUrl = (() => { + const params = new URLSearchParams(); + params.set("code", data.code); + if (data.configurationId) { + params.set("configurationId", data.configurationId); + } + params.set("integration", "vercel"); + params.set("organizationId", data.organization.id); + if (data.next) { + params.set("next", data.next); + } + return `${newProjectPath({ slug: data.organization.slug })}?${params.toString()}`; + })(); + + const isLoading = isSubmitting || isInstalling; + return ( } title="Select Project" - description={`Choose which project in ${data.organization.title} to install the Vercel integration into.`} + description={`Choose which project in "${data.organization.title}" to install the Vercel integration into.`} />
setIsInstalling(true)}> - + {data.configurationId && ( + + )} {data.next && }
- + typeof v === "string" + ? data.organization.projects.find((p) => p.id === v)?.name || "Choose a project" + : "Choose a project" + } + > + {data.organization.projects.map((project) => ( + + {project.name} + + ))} + + + + + + New Project + + +
} - > - {data.organization.projects.map((project) => ( - - {project.name} - - ))} - - -
- -
- - -
-
+ /> diff --git a/apps/webapp/app/services/vercelIntegration.server.ts b/apps/webapp/app/services/vercelIntegration.server.ts index a44e15fe11..dcf7f26b27 100644 --- a/apps/webapp/app/services/vercelIntegration.server.ts +++ b/apps/webapp/app/services/vercelIntegration.server.ts @@ -328,6 +328,7 @@ export class VercelIntegrationService { vercelStagingEnvironment?: string | null; pullEnvVarsBeforeBuild?: EnvSlug[] | null; atomicBuilds?: EnvSlug[] | null; + pullNewEnvVars?: boolean | null; syncEnvVarsMapping: SyncEnvVarsMapping; } ): Promise { @@ -342,6 +343,7 @@ export class VercelIntegrationService { ...existing.parsedIntegrationData.config, pullEnvVarsBeforeBuild: params.pullEnvVarsBeforeBuild ?? null, atomicBuilds: params.atomicBuilds ?? null, + pullNewEnvVars: params.pullNewEnvVars ?? null, vercelStagingEnvironment: params.vercelStagingEnvironment ?? null, }, // Don't save syncEnvVarsMapping - it's only used for the one-time pull during onboarding diff --git a/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts b/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts index ed1391b8b8..fc629b4bb3 100644 --- a/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts +++ b/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts @@ -50,6 +50,17 @@ export const VercelIntegrationConfigSchema = z.object({ * When null, no custom environment is mapped to staging. */ vercelStagingEnvironment: z.string().nullable().optional(), + + /** + * When enabled, discovers and creates new env vars from Vercel during builds. + * This allows new environment variables added in Vercel to be automatically + * pulled into Trigger.dev. + * + * Default: true (enabled) + * null/undefined = defaults to enabled + * false = only sync existing variables, don't discover new ones + */ + pullNewEnvVars: z.boolean().nullable().optional(), }); export type VercelIntegrationConfig = z.infer; @@ -127,6 +138,7 @@ export type VercelProjectIntegrationData = z.infer Date: Thu, 29 Jan 2026 22:32:04 +0100 Subject: [PATCH 22/33] feat(vercel): enhance staging environment handling and add preview support Refactor the Vercel integration to change the structure of the `vercelStagingEnvironment` to an object containing `environmentId` and `displayName`. Update related logic to utilize the new structure, ensuring that the environment ID is correctly referenced in various parts of the application.Additionally, introduce support for a preview environment by adding `hasPreviewEnvironment` and `customEnvironments` to the Vercel settings presenter. Update the UI components and routes to accommodate these changes, allowing for better management of Vercel environments in the application. --- .../app/models/vercelIntegration.server.ts | 10 +- .../v3/VercelSettingsPresenter.server.ts | 68 ++++- .../route.tsx | 1 + ...cts.$projectParam.env.$envParam.vercel.tsx | 260 +++++++++--------- .../app/services/vercelIntegration.server.ts | 2 +- .../vercel/vercelProjectIntegrationSchema.ts | 5 +- packages/core/src/v3/schemas/api.ts | 1 - 7 files changed, 208 insertions(+), 139 deletions(-) diff --git a/apps/webapp/app/models/vercelIntegration.server.ts b/apps/webapp/app/models/vercelIntegration.server.ts index a32ace06d6..56ca1c7627 100644 --- a/apps/webapp/app/models/vercelIntegration.server.ts +++ b/apps/webapp/app/models/vercelIntegration.server.ts @@ -904,7 +904,7 @@ export class VercelIntegrationRepository { projectId: string; vercelProjectId: string; teamId: string | null; - vercelStagingEnvironment?: string | null; + vercelStagingEnvironment?: { environmentId: string; displayName: string } | null; orgIntegration: OrganizationIntegration & { tokenReference: SecretReference }; }): Promise<{ success: boolean; errors: string[] }> { const errors: string[] = []; @@ -952,7 +952,7 @@ export class VercelIntegrationRepository { }); continue; } - vercelTarget = [params.vercelStagingEnvironment]; + vercelTarget = [params.vercelStagingEnvironment.environmentId]; break; case "PREVIEW": vercelTarget = ["preview"]; @@ -1073,7 +1073,7 @@ export class VercelIntegrationRepository { }); return { success: true }; } - vercelTarget = [vercelStagingEnvironment]; + vercelTarget = [vercelStagingEnvironment.environmentId]; break; case "PREVIEW": vercelTarget = ["preview"]; @@ -1120,7 +1120,7 @@ export class VercelIntegrationRepository { projectId: string; vercelProjectId: string; teamId: string | null; - vercelStagingEnvironment?: string | null; + vercelStagingEnvironment?: { environmentId: string; displayName: string } | null; syncEnvVarsMapping: SyncEnvVarsMapping; orgIntegration: OrganizationIntegration & { tokenReference: SecretReference }; }): Promise<{ success: boolean; errors: string[]; syncedCount: number }> { @@ -1172,7 +1172,7 @@ export class VercelIntegrationRepository { if (params.vercelStagingEnvironment) { envMapping.push({ triggerEnvType: "STAGING", - vercelTarget: params.vercelStagingEnvironment, + vercelTarget: params.vercelStagingEnvironment.environmentId, runtimeEnvironmentId: env.id, }); } diff --git a/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts b/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts index 45d24ca03e..7154f50d83 100644 --- a/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts @@ -33,6 +33,8 @@ export type VercelSettingsResult = { }; isGitHubConnected: boolean; hasStagingEnvironment: boolean; + hasPreviewEnvironment: boolean; + customEnvironments: VercelCustomEnvironment[]; }; export type VercelAvailableProject = { @@ -81,6 +83,8 @@ export class VercelSettingsPresenter extends BasePresenter { connectedProject: undefined, isGitHubConnected: false, hasStagingEnvironment: false, + hasPreviewEnvironment: false, + customEnvironments: [], } as VercelSettingsResult); } @@ -107,6 +111,8 @@ export class VercelSettingsPresenter extends BasePresenter { connectedProject: undefined, isGitHubConnected: false, hasStagingEnvironment: false, + hasPreviewEnvironment: false, + customEnvironments: [], } as VercelSettingsResult); } } @@ -159,6 +165,24 @@ export class VercelSettingsPresenter extends BasePresenter { }) ).map((env) => env !== null); + // Check if preview environment exists + const checkPreviewEnvironment = () => + fromPromise( + (this._replica as PrismaClient).runtimeEnvironment.findFirst({ + select: { + id: true, + }, + where: { + projectId, + type: "PREVIEW", + }, + }), + (error) => ({ + type: "other" as const, + cause: error, + }) + ).map((env) => env !== null); + // Get Vercel project integration const getVercelProjectIntegration = () => fromPromise( @@ -207,15 +231,39 @@ export class VercelSettingsPresenter extends BasePresenter { checkOrgIntegration(), checkGitHubConnection(), checkStagingEnvironment(), + checkPreviewEnvironment(), getVercelProjectIntegration(), - ]).map(([hasOrgIntegration, isGitHubConnected, hasStagingEnvironment, connectedProject]) => ({ - enabled: true, - hasOrgIntegration, - authInvalid: false, - connectedProject, - isGitHubConnected, - hasStagingEnvironment, - })).mapErr((error) => { + ]).andThen(([hasOrgIntegration, isGitHubConnected, hasStagingEnvironment, hasPreviewEnvironment, connectedProject]) => { + const fetchCustomEnvs = async (): Promise => { + if (!connectedProject || !orgIntegration) return []; + try { + const client = await VercelIntegrationRepository.getVercelClient(orgIntegration); + const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration); + const result = await VercelIntegrationRepository.getVercelCustomEnvironments( + client, + connectedProject.vercelProjectId, + teamId + ); + return result.success ? result.data : []; + } catch { + return []; + } + }; + + return fromPromise( + fetchCustomEnvs(), + (error) => ({ type: "other" as const, cause: error }) + ).map((customEnvironments) => ({ + enabled: true, + hasOrgIntegration, + authInvalid: false, + connectedProject, + isGitHubConnected, + hasStagingEnvironment, + hasPreviewEnvironment, + customEnvironments, + } as VercelSettingsResult)); + }).mapErr((error) => { // Log the error and return a safe fallback console.error("Error in VercelSettingsPresenter.call:", error); return error; @@ -230,6 +278,8 @@ export class VercelSettingsPresenter extends BasePresenter { connectedProject: undefined, isGitHubConnected: false, hasStagingEnvironment: false, + hasPreviewEnvironment: false, + customEnvironments: [], } as VercelSettingsResult); } } catch (error) { @@ -242,6 +292,8 @@ export class VercelSettingsPresenter extends BasePresenter { connectedProject: undefined, isGitHubConnected: false, hasStagingEnvironment: false, + hasPreviewEnvironment: false, + customEnvironments: [], } as VercelSettingsResult); } } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx index a36827be4a..167d80126d 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx @@ -621,6 +621,7 @@ export default function Page() { projectSlug={project.slug} environmentSlug={environment.slug} hasStagingEnvironment={vercelFetcher.data?.hasStagingEnvironment ?? false} + hasPreviewEnvironment={vercelFetcher.data?.hasPreviewEnvironment ?? false} hasOrgIntegration={vercelFetcher.data?.hasOrgIntegration ?? false} nextUrl={nextUrl ?? undefined} onDataReload={(vercelEnvironmentId) => { diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx index 5175609152..e57e75e114 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx @@ -71,7 +71,6 @@ import { } from "~/presenters/v3/VercelSettingsPresenter.server"; import { VercelIntegrationService } from "~/services/vercelIntegration.server"; import { - VercelIntegrationRepository, type VercelCustomEnvironment, } from "~/models/vercelIntegration.server"; import { @@ -105,47 +104,17 @@ function formatVercelTargets(targets: string[]): string { .join(", "); } -async function lookupVercelEnvironmentName( - projectId: string, - environmentId: string | null -): Promise { - if (!environmentId) { - return null; - } - +function parseVercelStagingEnvironment( + value: string | null | undefined +): { environmentId: string; displayName: string } | null { + if (!value) return null; try { - const vercelService = new VercelIntegrationService(); - const projectIntegration = await vercelService.getVercelProjectIntegration(projectId); - if (!projectIntegration) { - return null; + const parsed = JSON.parse(value) as { environmentId?: string; displayName?: string }; + if (parsed?.environmentId && parsed?.displayName) { + return { environmentId: parsed.environmentId, displayName: parsed.displayName }; } - - const orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationForProject(projectId); - if (!orgIntegration) { - return null; - } - - const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration); - const client = await VercelIntegrationRepository.getVercelClient(orgIntegration); - - const customEnvironmentsResult = await VercelIntegrationRepository.getVercelCustomEnvironments( - client, - projectIntegration.parsedIntegrationData.vercelProjectId, - teamId - ); - - if (!customEnvironmentsResult.success) { - return null; - } - - const environment = customEnvironmentsResult.data.find((env: VercelCustomEnvironment) => env.id === environmentId); - return environment?.slug || null; - } catch (error) { - logger.error("Failed to look up Vercel environment name", { - projectId, - environmentId, - error, - }); + return null; + } catch { return null; } } @@ -343,11 +312,13 @@ export async function action({ request, params }: ActionFunctionArgs) { vercelStagingEnvironment, } = submission.value; + const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment); + const result = await vercelService.updateVercelIntegrationConfig(project.id, { atomicBuilds: atomicBuilds as EnvSlug[] | null, pullEnvVarsBeforeBuild: pullEnvVarsBeforeBuild as EnvSlug[] | null, pullNewEnvVars: pullNewEnvVars, - vercelStagingEnvironment: vercelStagingEnvironment ?? null, + vercelStagingEnvironment: parsedStagingEnv, }); if (result) { @@ -399,8 +370,10 @@ export async function action({ request, params }: ActionFunctionArgs) { parsedMappingKeys: Object.keys(parsedMapping), }); + const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment); + const result = await vercelService.completeOnboarding(project.id, { - vercelStagingEnvironment: vercelStagingEnvironment ?? null, + vercelStagingEnvironment: parsedStagingEnv, pullEnvVarsBeforeBuild: pullEnvVarsBeforeBuild as EnvSlug[] | null, atomicBuilds: atomicBuilds as EnvSlug[] | null, pullNewEnvVars: pullNewEnvVars, @@ -441,8 +414,10 @@ export async function action({ request, params }: ActionFunctionArgs) { if (actionType === "update-env-mapping") { const { vercelStagingEnvironment } = submission.value; + const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment); + const result = await vercelService.updateVercelIntegrationConfig(project.id, { - vercelStagingEnvironment: vercelStagingEnvironment ?? null, + vercelStagingEnvironment: parsedStagingEnv, }); if (result) { @@ -641,6 +616,7 @@ function envSlugLabel(slug: EnvSlug): string { function ConnectedVercelProjectForm({ connectedProject, hasStagingEnvironment, + hasPreviewEnvironment, customEnvironments, organizationSlug, projectSlug, @@ -648,7 +624,8 @@ function ConnectedVercelProjectForm({ }: { connectedProject: ConnectedVercelProject; hasStagingEnvironment: boolean; - customEnvironments?: Array<{ id: string; slug: string }>; + hasPreviewEnvironment: boolean; + customEnvironments: Array<{ id: string; slug: string }>; organizationSlug: string; projectSlug: string; environmentSlug: string; @@ -662,14 +639,14 @@ function ConnectedVercelProjectForm({ pullEnvVarsBeforeBuild: connectedProject.integrationData.config.pullEnvVarsBeforeBuild ?? [], pullNewEnvVars: connectedProject.integrationData.config.pullNewEnvVars !== false, vercelStagingEnvironment: - connectedProject.integrationData.config.vercelStagingEnvironment || "", + connectedProject.integrationData.config.vercelStagingEnvironment ?? null, }); // Track original values for comparison const originalAtomicBuilds = connectedProject.integrationData.config.atomicBuilds ?? []; const originalPullEnvVars = connectedProject.integrationData.config.pullEnvVarsBeforeBuild ?? []; const originalPullNewEnvVars = connectedProject.integrationData.config.pullNewEnvVars !== false; - const originalStagingEnv = connectedProject.integrationData.config.vercelStagingEnvironment || ""; + const originalStagingEnv = connectedProject.integrationData.config.vercelStagingEnvironment ?? null; useEffect(() => { const atomicBuildsChanged = @@ -679,7 +656,7 @@ function ConnectedVercelProjectForm({ JSON.stringify([...configValues.pullEnvVarsBeforeBuild].sort()) !== JSON.stringify([...originalPullEnvVars].sort()); const pullNewEnvVarsChanged = configValues.pullNewEnvVars !== originalPullNewEnvVars; - const stagingEnvChanged = configValues.vercelStagingEnvironment !== originalStagingEnv; + const stagingEnvChanged = configValues.vercelStagingEnvironment?.environmentId !== originalStagingEnv?.environmentId; setHasConfigChanges(atomicBuildsChanged || pullEnvVarsChanged || pullNewEnvVarsChanged || stagingEnvChanged); }, [configValues, originalAtomicBuilds, originalPullEnvVars, originalPullNewEnvVars, originalStagingEnv]); @@ -701,10 +678,12 @@ function ConnectedVercelProjectForm({ const actionUrl = vercelResourcePath(organizationSlug, projectSlug, environmentSlug); - // Filter out staging if no staging environment exists - const availableEnvSlugs = hasStagingEnvironment - ? ALL_ENV_SLUGS - : ALL_ENV_SLUGS.filter((s) => s !== "stg"); + // Filter out environments that don't exist for this project + const availableEnvSlugs = ALL_ENV_SLUGS.filter((s) => { + if (s === "stg" && !hasStagingEnvironment) return false; + if (s === "preview" && !hasPreviewEnvironment) return false; + return true; + }); // For pull env vars and atomic deployments, exclude "dev" (not needed for development) const availableEnvSlugsForBuildSettings = availableEnvSlugs.filter((s) => s !== "dev"); @@ -787,7 +766,7 @@ function ConnectedVercelProjectForm({
@@ -802,12 +781,15 @@ function ConnectedVercelProjectForm({ environment. { if (!Array.isArray(value)) { - setVercelStagingEnvironment(value); + const env = customEnvironments.find((e) => e.id === value); + setVercelStagingEnvironment( + env ? { environmentId: env.id, displayName: env.slug } : null + ); } }} items={customEnvironments} variant="tertiary/medium" placeholder="Select environment" dropdownIcon - text={ - customEnvironments.find((e) => e.id === vercelStagingEnvironment)?.slug || - "Select environment" - } + text={vercelStagingEnvironment?.displayName || "Select environment"} > {customEnvironments.map((env) => ( @@ -1968,13 +1978,15 @@ function VercelOnboardingModal({ Vercel before each build.
- { - setPullEnvVarsBeforeBuild(checked ? [...availableEnvSlugsForOnboardingBuildSettings] : []); - }} - /> + {availableEnvSlugsForOnboardingBuildSettings.length > 1 && ( + 0 && availableEnvSlugsForOnboardingBuildSettings.every((s) => pullEnvVarsBeforeBuild.includes(s))} + onCheckedChange={(checked) => { + setPullEnvVarsBeforeBuild(checked ? [...availableEnvSlugsForOnboardingBuildSettings] : []); + }} + /> + )}
{availableEnvSlugsForOnboardingBuildSettings.map((slug) => { @@ -2004,7 +2016,7 @@ function VercelOnboardingModal({ {/* Discover new env vars */} {(() => { - const isPullEnvVarsDisabled = pullEnvVarsBeforeBuild.length === 0; + const isPullEnvVarsDisabled = !availableEnvSlugsForOnboardingBuildSettings.some((s) => pullEnvVarsBeforeBuild.includes(s)); return (
@@ -2036,13 +2048,15 @@ function VercelOnboardingModal({ promoting the Trigger.dev deployment.
- { - setAtomicBuilds(checked ? [...availableEnvSlugsForOnboardingBuildSettings] : []); - }} - /> + {availableEnvSlugsForOnboardingBuildSettings.length > 1 && ( + 0 && availableEnvSlugsForOnboardingBuildSettings.every((s) => atomicBuilds.includes(s))} + onCheckedChange={(checked) => { + setAtomicBuilds(checked ? [...availableEnvSlugsForOnboardingBuildSettings] : []); + }} + /> + )}
{availableEnvSlugsForOnboardingBuildSettings.map((slug) => { diff --git a/apps/webapp/app/services/vercelIntegration.server.ts b/apps/webapp/app/services/vercelIntegration.server.ts index dcf7f26b27..3aca446100 100644 --- a/apps/webapp/app/services/vercelIntegration.server.ts +++ b/apps/webapp/app/services/vercelIntegration.server.ts @@ -325,7 +325,7 @@ export class VercelIntegrationService { async completeOnboarding( projectId: string, params: { - vercelStagingEnvironment?: string | null; + vercelStagingEnvironment?: { environmentId: string; displayName: string } | null; pullEnvVarsBeforeBuild?: EnvSlug[] | null; atomicBuilds?: EnvSlug[] | null; pullNewEnvVars?: boolean | null; diff --git a/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts b/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts index fc629b4bb3..ed94bbbff4 100644 --- a/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts +++ b/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts @@ -49,7 +49,10 @@ export const VercelIntegrationConfigSchema = z.object({ * This field stores the custom Vercel environment ID that maps to staging. * When null, no custom environment is mapped to staging. */ - vercelStagingEnvironment: z.string().nullable().optional(), + vercelStagingEnvironment: z.object({ + environmentId: z.string(), + displayName: z.string(), + }).nullable().optional(), /** * When enabled, discovers and creates new env vars from Vercel during builds. diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index df81cdb623..b46509442e 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -696,7 +696,6 @@ export const GetDeploymentResponseBody = z.object({ imageReference: z.string().nullish(), imagePlatform: z.string(), commitSHA: z.string().nullish(), - integrationDeploymentId: z.string().nullish(), externalBuildData: ExternalBuildData.optional().nullable(), errorData: DeploymentErrorData.nullish(), worker: z From 738ce5c55b85a7b83c28de005a243cf01f1897a2 Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Mon, 2 Feb 2026 16:09:58 +0100 Subject: [PATCH 23/33] feat(vercel): refine integration defaults and surface settings Change default Vercel integration data to enable atomic builds only for production and keep pull env vars enabled for all non-dev environments. This tightens build behavior to match expected defaults and avoids enabling atomic builds for staging/preview by default. Add optional integrationDeployments field to deployment schema and include it in the deployment API response. This introduces a typed array of integration deployment records (id, integrationName, integrationDeploymentId, commitSHA, createdAt) so downstream clients can surface related integration deployment metadata. Expose Vercel project setting autoAssignCustomDomains in presenter by fetching it alongside custom environments. Combine fetching of custom environments and the auto-assign setting into a single helper that returns both values (or null when unknown), and thread the new autoAssignCustomDomains flag through the presenter types. Also rename a UI label from "Pull from Vercel" to "Sync" for clarity. --- .../app/models/vercelIntegration.server.ts | 65 ++++++ .../v3/VercelSettingsPresenter.server.ts | 40 +++- .../route.tsx | 2 +- .../api.v1.deployments.$deploymentId.ts | 11 + ...cts.$projectParam.env.$envParam.vercel.tsx | 197 +++++++++--------- .../app/services/vercelIntegration.server.ts | 150 +++++++++++++ .../services/initializeDeployment.server.ts | 1 + .../vercel/vercelProjectIntegrationSchema.ts | 4 +- packages/core/src/v3/schemas/api.ts | 11 + 9 files changed, 374 insertions(+), 107 deletions(-) diff --git a/apps/webapp/app/models/vercelIntegration.server.ts b/apps/webapp/app/models/vercelIntegration.server.ts index 56ca1c7627..909a90df1c 100644 --- a/apps/webapp/app/models/vercelIntegration.server.ts +++ b/apps/webapp/app/models/vercelIntegration.server.ts @@ -1678,6 +1678,71 @@ export class VercelIntegrationRepository { } } + /** + * Get the autoAssignCustomDomains setting for a Vercel project. + * Returns true if auto-assign is enabled, false if disabled, null on error. + */ + static async getAutoAssignCustomDomains( + client: Vercel, + vercelProjectId: string, + teamId?: string | null + ): Promise { + try { + // The Vercel SDK doesn't have a getProject method, so we use updateProject + // with an empty requestBody to read the project data without changing anything. + const project = await client.projects.updateProject({ + idOrName: vercelProjectId, + ...(teamId && { teamId }), + requestBody: {}, + }); + + return project.autoAssignCustomDomains ?? null; + } catch (error) { + logger.error("Failed to get Vercel project autoAssignCustomDomains", { + vercelProjectId, + teamId, + error, + }); + return null; + } + } + + /** + * Disable autoAssignCustomDomains on a Vercel project. + * This is required for atomic deployments — prevents Vercel from auto-promoting + * deployments before TRIGGER_VERSION is set. + */ + static async disableAutoAssignCustomDomains( + client: Vercel, + vercelProjectId: string, + teamId?: string | null + ): Promise<{ success: boolean; error?: string }> { + try { + await client.projects.updateProject({ + idOrName: vercelProjectId, + ...(teamId && { teamId }), + requestBody: { + autoAssignCustomDomains: false, + }, + }); + + logger.info("Disabled autoAssignCustomDomains on Vercel project", { + vercelProjectId, + teamId, + }); + + return { success: true }; + } catch (error) { + const errorMessage = `Failed to disable autoAssignCustomDomains: ${error instanceof Error ? error.message : "Unknown error"}`; + logger.error(errorMessage, { + vercelProjectId, + teamId, + error, + }); + return { success: false, error: errorMessage }; + } + } + static async uninstallVercelIntegration( integration: OrganizationIntegration & { tokenReference: SecretReference } ): Promise<{ authInvalid: boolean }> { diff --git a/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts b/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts index 7154f50d83..2469c0f4b8 100644 --- a/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts @@ -35,6 +35,8 @@ export type VercelSettingsResult = { hasStagingEnvironment: boolean; hasPreviewEnvironment: boolean; customEnvironments: VercelCustomEnvironment[]; + /** Whether autoAssignCustomDomains is enabled on the Vercel project. null if unknown. */ + autoAssignCustomDomains?: boolean | null; }; export type VercelAvailableProject = { @@ -234,26 +236,41 @@ export class VercelSettingsPresenter extends BasePresenter { checkPreviewEnvironment(), getVercelProjectIntegration(), ]).andThen(([hasOrgIntegration, isGitHubConnected, hasStagingEnvironment, hasPreviewEnvironment, connectedProject]) => { - const fetchCustomEnvs = async (): Promise => { - if (!connectedProject || !orgIntegration) return []; + const fetchCustomEnvsAndProjectSettings = async (): Promise<{ + customEnvironments: VercelCustomEnvironment[]; + autoAssignCustomDomains: boolean | null; + }> => { + if (!connectedProject || !orgIntegration) { + return { customEnvironments: [], autoAssignCustomDomains: null }; + } try { const client = await VercelIntegrationRepository.getVercelClient(orgIntegration); const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration); - const result = await VercelIntegrationRepository.getVercelCustomEnvironments( - client, - connectedProject.vercelProjectId, - teamId - ); - return result.success ? result.data : []; + const [customEnvsResult, autoAssign] = await Promise.all([ + VercelIntegrationRepository.getVercelCustomEnvironments( + client, + connectedProject.vercelProjectId, + teamId + ), + VercelIntegrationRepository.getAutoAssignCustomDomains( + client, + connectedProject.vercelProjectId, + teamId + ), + ]); + return { + customEnvironments: customEnvsResult.success ? customEnvsResult.data : [], + autoAssignCustomDomains: autoAssign, + }; } catch { - return []; + return { customEnvironments: [], autoAssignCustomDomains: null }; } }; return fromPromise( - fetchCustomEnvs(), + fetchCustomEnvsAndProjectSettings(), (error) => ({ type: "other" as const, cause: error }) - ).map((customEnvironments) => ({ + ).map(({ customEnvironments, autoAssignCustomDomains }) => ({ enabled: true, hasOrgIntegration, authInvalid: false, @@ -262,6 +279,7 @@ export class VercelSettingsPresenter extends BasePresenter { hasStagingEnvironment, hasPreviewEnvironment, customEnvironments, + autoAssignCustomDomains, } 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.environment-variables/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx index 923ccf8276..7f7ed0fc20 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx @@ -330,7 +330,7 @@ export default function Page() { - Pull from Vercel + Sync } diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts index ca3417b75b..d9ee637a7c 100644 --- a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts @@ -39,6 +39,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { tasks: true, }, }, + integrationDeployments: true, }, }); @@ -69,5 +70,15 @@ export async function loader({ request, params }: LoaderFunctionArgs) { })), } : undefined, + integrationDeployments: + deployment.integrationDeployments.length > 0 + ? deployment.integrationDeployments.map((id) => ({ + id: id.id, + integrationName: id.integrationName, + integrationDeploymentId: id.integrationDeploymentId, + commitSHA: id.commitSHA, + createdAt: id.createdAt, + })) + : undefined, } satisfies GetDeploymentResponseBody); } diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx index e57e75e114..df15680888 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx @@ -71,6 +71,7 @@ import { } from "~/presenters/v3/VercelSettingsPresenter.server"; import { VercelIntegrationService } from "~/services/vercelIntegration.server"; import { + VercelIntegrationRepository, type VercelCustomEnvironment, } from "~/models/vercelIntegration.server"; import { @@ -198,6 +199,10 @@ const UpdateEnvMappingFormSchema = z.object({ vercelStagingEnvironment: z.string().nullable().optional(), }); +const DisableAutoAssignFormSchema = z.object({ + action: z.literal("disable-auto-assign"), +}); + const VercelActionSchema = z.discriminatedUnion("action", [ UpdateVercelConfigFormSchema, DisconnectVercelFormSchema, @@ -205,6 +210,7 @@ const VercelActionSchema = z.discriminatedUnion("action", [ SkipOnboardingFormSchema, SelectVercelProjectFormSchema, UpdateEnvMappingFormSchema, + DisableAutoAssignFormSchema, ]); export async function loader({ request, params }: LoaderFunctionArgs) { @@ -468,6 +474,42 @@ export async function action({ request, params }: ActionFunctionArgs) { } } + // Handle disable-auto-assign action + if (actionType === "disable-auto-assign") { + try { + const orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationForProject( + project.id + ); + + if (!orgIntegration) { + return redirectWithErrorMessage(settingsPath, request, "No Vercel integration found"); + } + + const client = await VercelIntegrationRepository.getVercelClient(orgIntegration); + const projectIntegration = await vercelService.getVercelProjectIntegration(project.id); + + if (!projectIntegration) { + return redirectWithErrorMessage(settingsPath, request, "No Vercel project connected"); + } + + const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration); + const result = await VercelIntegrationRepository.disableAutoAssignCustomDomains( + client, + projectIntegration.parsedIntegrationData.vercelProjectId, + teamId + ); + + if (result.success) { + return redirectWithSuccessMessage(settingsPath, request, "Auto-assign custom domains disabled"); + } + + return redirectWithErrorMessage(settingsPath, request, "Failed to disable auto-assign custom domains"); + } catch (error) { + logger.error("Failed to disable auto-assign custom domains", { error }); + return redirectWithErrorMessage(settingsPath, request, "Failed to disable auto-assign custom domains"); + } + } + submission.value satisfies never; return redirectBackWithErrorMessage(request, "Failed to process request"); } @@ -618,6 +660,7 @@ function ConnectedVercelProjectForm({ hasStagingEnvironment, hasPreviewEnvironment, customEnvironments, + autoAssignCustomDomains, organizationSlug, projectSlug, environmentSlug, @@ -626,6 +669,7 @@ function ConnectedVercelProjectForm({ hasStagingEnvironment: boolean; hasPreviewEnvironment: boolean; customEnvironments: Array<{ id: string; slug: string }>; + autoAssignCustomDomains: boolean | null; organizationSlug: string; projectSlug: string; environmentSlug: string; @@ -900,55 +944,59 @@ function ConnectedVercelProjectForm({ {/* Atomic deployments */}
-
+
- Select which environments should wait for Vercel deployment to complete before - promoting the Trigger.dev deployment. + When enabled, production deployments wait for Vercel deployment to complete + before promoting the Trigger.dev deployment.
- {availableEnvSlugsForBuildSettings.length > 1 && ( - 0 && availableEnvSlugsForBuildSettings.every((s) => configValues.atomicBuilds.includes(s))} - onCheckedChange={(checked) => { - setConfigValues((prev) => ({ - ...prev, - atomicBuilds: checked ? [...availableEnvSlugsForBuildSettings] : [], - })); - }} - /> - )} -
-
- {availableEnvSlugsForBuildSettings.map((slug) => { - const envType = slug === "prod" ? "PRODUCTION" : slug === "stg" ? "STAGING" : "PREVIEW"; - return ( -
-
- - - {environmentFullTitle({ type: envType })} - -
- { - setConfigValues((prev) => ({ - ...prev, - atomicBuilds: checked - ? [...prev.atomicBuilds, slug] - : prev.atomicBuilds.filter((s) => s !== slug), - })); - }} - /> -
- ); - })} + { + setConfigValues((prev) => ({ + ...prev, + atomicBuilds: checked ? ["prod"] : [], + })); + }} + />
+ + {/* Warning: autoAssignCustomDomains must be disabled for atomic deployments */} + {autoAssignCustomDomains !== false && + configValues.atomicBuilds.includes("prod") && ( + +
+

+ Atomic deployments require the "Auto-assign Custom Domains" setting to be + disabled on your Vercel project. Without this, Vercel will promote + deployments before Trigger.dev is ready. +

+
+ + + +
+
+ )}
{configForm.error} @@ -1048,6 +1096,7 @@ function VercelSettingsPanel({ hasStagingEnvironment={data.hasStagingEnvironment} hasPreviewEnvironment={data.hasPreviewEnvironment} customEnvironments={data.customEnvironments} + autoAssignCustomDomains={data.autoAssignCustomDomains ?? null} organizationSlug={organizationSlug} projectSlug={projectSlug} environmentSlug={environmentSlug} @@ -1212,11 +1261,11 @@ function VercelOnboardingModal({ () => availableEnvSlugsForOnboardingBuildSettings ); const [atomicBuilds, setAtomicBuilds] = useState( - () => availableEnvSlugsForOnboardingBuildSettings + () => ["prod"] ); const [pullNewEnvVars, setPullNewEnvVars] = useState(true); - // Sync pullEnvVarsBeforeBuild and atomicBuilds when hasStagingEnvironment becomes true (once) + // Sync pullEnvVarsBeforeBuild when hasStagingEnvironment becomes true (once) // This ensures staging is included when it becomes available, but respects user changes after useEffect(() => { if (hasStagingEnvironment && !hasSyncedStagingRef.current) { @@ -1227,16 +1276,10 @@ function VercelOnboardingModal({ } return prev; }); - setAtomicBuilds((prev) => { - if (!prev.includes("stg")) { - return [...prev, "stg"]; - } - return prev; - }); } }, [hasStagingEnvironment]); - // Sync pullEnvVarsBeforeBuild and atomicBuilds when hasPreviewEnvironment becomes true (once) + // Sync pullEnvVarsBeforeBuild when hasPreviewEnvironment becomes true (once) // This ensures preview is included when it becomes available, but respects user changes after useEffect(() => { if (hasPreviewEnvironment && !hasSyncedPreviewRef.current) { @@ -1247,12 +1290,6 @@ function VercelOnboardingModal({ } return prev; }); - setAtomicBuilds((prev) => { - if (!prev.includes("preview")) { - return [...prev, "preview"]; - } - return prev; - }); } }, [hasPreviewEnvironment]); // Env var sync state (for env-var-sync step - one-time sync) @@ -2040,47 +2077,21 @@ function VercelOnboardingModal({ {/* Atomic deployments */}
-
+
- Select which environments should wait for Vercel deployment to complete before - promoting the Trigger.dev deployment. + When enabled, production deployments wait for Vercel deployment to complete + before promoting the Trigger.dev deployment.
- {availableEnvSlugsForOnboardingBuildSettings.length > 1 && ( - 0 && availableEnvSlugsForOnboardingBuildSettings.every((s) => atomicBuilds.includes(s))} - onCheckedChange={(checked) => { - setAtomicBuilds(checked ? [...availableEnvSlugsForOnboardingBuildSettings] : []); - }} - /> - )} -
-
- {availableEnvSlugsForOnboardingBuildSettings.map((slug) => { - const envType = slug === "prod" ? "PRODUCTION" : slug === "stg" ? "STAGING" : "PREVIEW"; - return ( -
-
- - - {environmentFullTitle({ type: envType })} - -
- { - setAtomicBuilds((prev) => - checked ? [...prev, slug] : prev.filter((s) => s !== slug) - ); - }} - /> -
- ); - })} + { + setAtomicBuilds(checked ? ["prod"] : []); + }} + />
diff --git a/apps/webapp/app/services/vercelIntegration.server.ts b/apps/webapp/app/services/vercelIntegration.server.ts index 3aca446100..615d38d608 100644 --- a/apps/webapp/app/services/vercelIntegration.server.ts +++ b/apps/webapp/app/services/vercelIntegration.server.ts @@ -2,10 +2,12 @@ import type { PrismaClient, OrganizationProjectIntegration, OrganizationIntegration, + SecretReference, } from "@trigger.dev/database"; import { prisma } from "~/db.server"; import { logger } from "~/services/logger.server"; import { VercelIntegrationRepository } from "~/models/vercelIntegration.server"; +import { findCurrentWorkerDeployment } from "~/v3/models/workerDeployment.server"; import { VercelProjectIntegrationDataSchema, VercelProjectIntegrationData, @@ -210,6 +212,22 @@ export class VercelIntegrationService { orgIntegration, }); + // Disable autoAssignCustomDomains on the Vercel project for atomic deployments + try { + const client = await VercelIntegrationRepository.getVercelClient(orgIntegration); + await VercelIntegrationRepository.disableAutoAssignCustomDomains( + client, + params.vercelProjectId, + teamId + ); + } catch (error) { + logger.warn("Failed to disable autoAssignCustomDomains during project selection", { + projectId: params.projectId, + vercelProjectId: params.vercelProjectId, + error, + }); + } + logger.info("Vercel project selected and API keys synced", { projectId: params.projectId, vercelProjectId: params.vercelProjectId, @@ -247,6 +265,21 @@ export class VercelIntegrationService { }, }); + // Sync TRIGGER_VERSION if atomic builds were just enabled for prod + if (updatedConfig.atomicBuilds?.includes("prod")) { + const orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationForProject( + projectId + ); + + if (orgIntegration) { + await this.#syncTriggerVersionToVercelProduction( + projectId, + updatedConfig.atomicBuilds, + orgIntegration + ); + } + } + return { ...updated, parsedIntegrationData: updatedData, @@ -402,6 +435,13 @@ export class VercelIntegrationService { syncedCount: pullResult.syncedCount, }); } + + // Sync TRIGGER_VERSION if atomic builds are enabled and a deployed build exists + await this.#syncTriggerVersionToVercelProduction( + projectId, + updatedData.config.atomicBuilds, + orgIntegration + ); } else { logger.warn("No org integration found when trying to pull env vars from Vercel", { projectId, @@ -421,6 +461,116 @@ export class VercelIntegrationService { }; } + /** + * Syncs the current deployed version as TRIGGER_VERSION to Vercel production. + * Called during onboarding completion and when atomic builds are enabled via config update. + * Non-blocking — errors are logged but don't fail the parent operation. + */ + async #syncTriggerVersionToVercelProduction( + projectId: string, + atomicBuilds: string[] | null | undefined, + orgIntegration: OrganizationIntegration & { tokenReference: SecretReference } + ): Promise { + try { + if (!atomicBuilds?.includes("prod")) { + return; + } + + // Find the PRODUCTION runtime environment for this project + const prodEnvironment = await this.#prismaClient.runtimeEnvironment.findFirst({ + where: { + projectId, + type: "PRODUCTION", + }, + select: { + id: true, + }, + }); + + if (!prodEnvironment) { + return; + } + + // Get the current promoted deployment version + const currentDeployment = await findCurrentWorkerDeployment({ + environmentId: prodEnvironment.id, + }); + + if (!currentDeployment?.version) { + return; + } + + const client = await VercelIntegrationRepository.getVercelClient(orgIntegration); + const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration); + + // Get the Vercel project ID from the project integration + const projectIntegration = await this.#prismaClient.organizationProjectIntegration.findFirst({ + where: { + projectId, + organizationIntegrationId: orgIntegration.id, + deletedAt: null, + }, + select: { + externalEntityId: true, + }, + }); + + if (!projectIntegration) { + return; + } + + const vercelProjectId = projectIntegration.externalEntityId; + + // Check if TRIGGER_VERSION already exists targeting production + const envVarsResult = await VercelIntegrationRepository.getVercelEnvironmentVariables( + client, + vercelProjectId, + teamId + ); + + if (!envVarsResult.success) { + logger.warn("Failed to fetch Vercel env vars for TRIGGER_VERSION sync", { + projectId, + vercelProjectId, + error: envVarsResult.error, + }); + return; + } + + const existingTriggerVersion = envVarsResult.data.find( + (env) => env.key === "TRIGGER_VERSION" && env.target.includes("production") + ); + + if (existingTriggerVersion) { + return; + } + + // Push TRIGGER_VERSION to Vercel production + await client.projects.createProjectEnv({ + idOrName: vercelProjectId, + ...(teamId && { teamId }), + upsert: "true", + requestBody: { + key: "TRIGGER_VERSION", + value: currentDeployment.version, + target: ["production"] as any, + type: "encrypted", + }, + }); + + logger.info("Synced TRIGGER_VERSION to Vercel production", { + projectId, + vercelProjectId, + version: currentDeployment.version, + }); + } catch (error) { + logger.error("Failed to sync TRIGGER_VERSION to Vercel production", { + projectId, + error, + }); + } + } + async disconnectVercelProject(projectId: string): Promise { const existing = await this.getVercelProjectIntegration(projectId); if (!existing) { diff --git a/apps/webapp/app/v3/services/initializeDeployment.server.ts b/apps/webapp/app/v3/services/initializeDeployment.server.ts index 52a968792c..96439d94d6 100644 --- a/apps/webapp/app/v3/services/initializeDeployment.server.ts +++ b/apps/webapp/app/v3/services/initializeDeployment.server.ts @@ -221,6 +221,7 @@ export class InitializeDeploymentService extends BaseService { imageReference: imageRef, imagePlatform: env.DEPLOY_IMAGE_PLATFORM, git: payload.gitMeta ?? undefined, + commitSHA: payload.gitMeta?.commitSha ?? undefined, runtime: payload.runtime ?? undefined, triggeredVia: payload.triggeredVia ?? undefined, startedAt: initialStatus === "BUILDING" ? new Date() : undefined, diff --git a/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts b/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts index ed94bbbff4..7a209756ae 100644 --- a/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts +++ b/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts @@ -141,7 +141,7 @@ export type VercelProjectIntegrationData = z.infer; From 6ecd2cc24772310ddbef401d58024cdc8d51a04a Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Mon, 2 Feb 2026 17:15:43 +0100 Subject: [PATCH 24/33] feat(webapp): tidy environment variables table layout Adjust column widths and consolidate the "Updated" columns into a single cell to simplify the table header and row layout. Increase Key/Value/Environment header widths slightly to better match content when Vercel integration is enabled. Replace separate columns for version, updated-by, and updated-at with one "Updated" column that renders the actor (user avatar/name or Vercel integration) and the timestamp together. Also simplify the Actions header sizing to remove a tiny fixed width and use hiddenLabel with zero width, improving responsiveness. These changes clean up the table markup, reduce cluttered columns, and improve alignment and responsiveness of the environment variables list. --- .../route.tsx | 69 +++++++++---------- ...cts.$projectParam.env.$envParam.vercel.tsx | 67 ++++++++++++++---- 2 files changed, 87 insertions(+), 49 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx index 7f7ed0fc20..2355b9748f 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx @@ -316,13 +316,13 @@ export default function Page() {
- + Key - + Value - + Environment {vercelIntegration?.enabled && ( @@ -338,10 +338,10 @@ export default function Page() { /> )} - Ver - Updated by - Updated at - + + Updated + + Actions @@ -420,35 +420,34 @@ export default function Page() { )} - {variable.version} - - - {variable.updatedByUser ? ( -
- - {variable.updatedByUser.name} -
- ) : variable.lastUpdatedBy?.type === "integration" ? ( -
- - - {variable.lastUpdatedBy.integration} +
+ {variable.updatedByUser ? ( +
+ + {variable.updatedByUser.name} +
+ ) : (variable.lastUpdatedBy?.type === "integration" && variable.lastUpdatedBy?.integration === 'vercel' ) ? ( +
+ + + {variable.lastUpdatedBy.integration} + +
+ ) : null} + {variable.updatedAt ? ( + + -
- ) : null} - - - {variable.updatedAt ? ( - - ) : null} + ) : null} +
- + {environmentVariables.length === 0 ? (
You haven't set any environment variables yet. @@ -535,7 +534,7 @@ function EditEnvironmentVariablePanel({ @@ -631,7 +630,7 @@ function DeleteEnvironmentVariableButton({ leadingIconClassName="text-rose-500 group-hover/button:text-text-bright transition-colors" className="ml-0.5 transition-colors group-hover/button:bg-error" > - {isLoading ? "Deleting" : "Delete"} + {isLoading ? "Deleting" : ""} ); diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx index df15680888..dfeeda4550 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx @@ -1295,6 +1295,7 @@ function VercelOnboardingModal({ // Env var sync state (for env-var-sync step - one-time sync) const [syncEnvVarsMapping, setSyncEnvVarsMapping] = useState({}); const [expandedEnvVars, setExpandedEnvVars] = useState(false); + const [expandedSecretEnvVars, setExpandedSecretEnvVars] = useState(false); const [projectSelectionError, setProjectSelectionError] = useState(null); // GitHub connection state (for github-connection step) @@ -1876,8 +1877,8 @@ function VercelOnboardingModal({ />
- {/* Expandable env var list */} - {envVars.length > 0 && ( + {/* Expandable syncable env var list */} + {syncableEnvVars.length > 0 && (
+ ))} + + )} + + )} + + {/* Expandable secret env var list */} + {secretEnvVars.length > 0 && ( +
+ + + {expandedSecretEnvVars && ( +
+ {secretEnvVars.map((envVar) => ( +
+
+ {envVar.key} + {envVar.target && envVar.target.length > 0 && ( + + {formatVercelTargets(envVar.target)} + {envVar.isShared && " · Shared"} + + )} +
+ Secret
))}
From 7b9acb6276f5ed1d5284ec467c3809a0034afa87 Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Mon, 2 Feb 2026 20:35:19 +0100 Subject: [PATCH 25/33] feat(api): add source metadata for env var imports Add an optional discriminated `source` field to the import request schema to capture whether variables are imported by a user or an integration and to include the corresponding identifier (userId or integration). Propagate this source into the database payloads (`lastUpdatedBy`) when creating or syncing environment variables so the system records who/what initiated the import. --- .../api.v1.projects.$projectRef.envvars.$slug.import.ts | 2 ++ packages/core/src/v3/schemas/api.ts | 6 ++++++ pnpm-lock.yaml | 3 ++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.import.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.import.ts index 5ef66546ae..53bc4429c1 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.import.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.import.ts @@ -47,6 +47,7 @@ export async function action({ params, request }: ActionFunctionArgs) { key, value, })), + lastUpdatedBy: body.source, }); // Only sync parent variables if this is a branch environment @@ -58,6 +59,7 @@ export async function action({ params, request }: ActionFunctionArgs) { key, value, })), + lastUpdatedBy: body.source, }); let childFailure = !result.success ? result : undefined; diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index 97006c5cda..de25b13562 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -1152,6 +1152,12 @@ export const ImportEnvironmentVariablesRequestBody = z.object({ variables: z.record(z.string()), parentVariables: z.record(z.string()).optional(), override: z.boolean().optional(), + source: z + .discriminatedUnion("type", [ + z.object({ type: z.literal("user"), userId: z.string() }), + z.object({ type: z.literal("integration"), integration: z.string() }), + ]) + .optional(), }); export type ImportEnvironmentVariablesRequestBody = z.infer< diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index edd250e184..b963c79a92 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31339,10 +31339,11 @@ snapshots: '@vercel/sdk@1.18.5': dependencies: - '@modelcontextprotocol/sdk': 1.24.2(supports-color@10.0.0)(zod@3.25.76) + '@modelcontextprotocol/sdk': 1.25.2(hono@4.5.11)(supports-color@10.0.0)(zod@3.25.76) zod: 3.25.76 transitivePeerDependencies: - '@cfworker/json-schema' + - hono - supports-color '@vitest/coverage-v8@3.1.4(vitest@3.1.4(@types/debug@4.1.12)(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1))': From 861687a266d417d3f5ea7375b4b83b7f29496eb5 Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Tue, 3 Feb 2026 13:57:54 +0100 Subject: [PATCH 26/33] feat(vercel): implement build settings fields for Vercel integration Add a new component, BuildSettingsFields, to manage Vercel build settings, including options for pulling environment variables before builds, discovering new environment variables, and enabling atomic deployments. Update related routes and schemas to accommodate the new discoverEnvVars functionality, replacing the previous pullNewEnvVars option. This enhances the Vercel integration by providing a more granular control over environment variable management during builds. --- .../integrations/VercelBuildSettings.tsx | 180 +++++++++++ .../route.tsx | 17 + ...cts.$projectParam.env.$envParam.vercel.tsx | 294 +++++------------- .../app/services/vercelIntegration.server.ts | 31 +- .../vercel/vercelProjectIntegrationSchema.ts | 29 +- 5 files changed, 317 insertions(+), 234 deletions(-) create mode 100644 apps/webapp/app/components/integrations/VercelBuildSettings.tsx diff --git a/apps/webapp/app/components/integrations/VercelBuildSettings.tsx b/apps/webapp/app/components/integrations/VercelBuildSettings.tsx new file mode 100644 index 0000000000..3001894f24 --- /dev/null +++ b/apps/webapp/app/components/integrations/VercelBuildSettings.tsx @@ -0,0 +1,180 @@ +import { Switch } from "~/components/primitives/Switch"; +import { Label } from "~/components/primitives/Label"; +import { Hint } from "~/components/primitives/Hint"; +import { TextLink } from "~/components/primitives/TextLink"; +import { + EnvironmentIcon, + environmentFullTitle, + environmentTextClassName, +} from "~/components/environments/EnvironmentLabel"; +import type { EnvSlug } from "~/v3/vercel/vercelProjectIntegrationSchema"; + +type BuildSettingsFieldsProps = { + availableEnvSlugs: EnvSlug[]; + pullEnvVarsBeforeBuild: EnvSlug[]; + onPullEnvVarsChange: (slugs: EnvSlug[]) => void; + discoverEnvVars: EnvSlug[]; + onDiscoverEnvVarsChange: (slugs: EnvSlug[]) => void; + atomicBuilds: EnvSlug[]; + onAtomicBuildsChange: (slugs: EnvSlug[]) => void; + envVarsConfigLink?: string; +}; + +function slugToEnvType(slug: EnvSlug) { + return slug === "prod" ? "PRODUCTION" : slug === "stg" ? "STAGING" : "PREVIEW"; +} + +export function BuildSettingsFields({ + availableEnvSlugs, + pullEnvVarsBeforeBuild, + onPullEnvVarsChange, + discoverEnvVars, + onDiscoverEnvVarsChange, + atomicBuilds, + onAtomicBuildsChange, + envVarsConfigLink, +}: BuildSettingsFieldsProps) { + return ( + <> + {/* Pull env vars before build */} +
+
+
+ + + Select which environments should pull environment variables from Vercel before each + build.{" "} + {envVarsConfigLink && ( + <> + Configure which variables to pull. + + )} + +
+ {availableEnvSlugs.length > 1 && ( + 0 && + availableEnvSlugs.every((s) => pullEnvVarsBeforeBuild.includes(s)) + } + onCheckedChange={(checked) => { + onPullEnvVarsChange(checked ? [...availableEnvSlugs] : []); + }} + /> + )} +
+
+ {availableEnvSlugs.map((slug) => { + const envType = slugToEnvType(slug); + return ( +
+
+ + + {environmentFullTitle({ type: envType })} + +
+ { + onPullEnvVarsChange( + checked + ? [...pullEnvVarsBeforeBuild, slug] + : pullEnvVarsBeforeBuild.filter((s) => s !== slug) + ); + }} + /> +
+ ); + })} +
+
+ + {/* Discover new env vars */} +
+
+
+ + + Select which environments should automatically discover and create new environment + variables from Vercel during builds. + +
+ {availableEnvSlugs.length > 1 && ( + 0 && + availableEnvSlugs.every( + (s) => discoverEnvVars.includes(s) || !pullEnvVarsBeforeBuild.includes(s) + ) && + availableEnvSlugs.some((s) => discoverEnvVars.includes(s)) + } + disabled={!availableEnvSlugs.some((s) => pullEnvVarsBeforeBuild.includes(s))} + onCheckedChange={(checked) => { + onDiscoverEnvVarsChange( + checked + ? availableEnvSlugs.filter((s) => pullEnvVarsBeforeBuild.includes(s)) + : [] + ); + }} + /> + )} +
+
+ {availableEnvSlugs.map((slug) => { + const envType = slugToEnvType(slug); + const isPullDisabled = !pullEnvVarsBeforeBuild.includes(slug); + return ( +
+
+ + + {environmentFullTitle({ type: envType })} + +
+ { + onDiscoverEnvVarsChange( + checked + ? [...discoverEnvVars, slug] + : discoverEnvVars.filter((s) => s !== slug) + ); + }} + /> +
+ ); + })} +
+
+ + {/* Atomic deployments */} +
+
+
+ + + When enabled, production deployments wait for Vercel deployment to complete before + promoting the Trigger.dev deployment. + +
+ { + onAtomicBuildsChange(checked ? ["prod"] : []); + }} + /> +
+
+ + ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx index 2355b9748f..afd74190a3 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx @@ -187,6 +187,23 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { return json(submission); } + // Clean up syncEnvVarsMapping if Vercel integration exists + const vercelService = new VercelIntegrationService(); + const integration = await vercelService.getVercelProjectIntegration(project.id); + if (integration) { + const runtimeEnv = await prisma.runtimeEnvironment.findUnique({ + where: { id: submission.value.environmentId }, + select: { type: true }, + }); + if (runtimeEnv) { + await vercelService.removeSyncEnvVarForEnvironment( + project.id, + submission.value.key, + runtimeEnv.type as TriggerEnvironmentType + ); + } + } + return redirectWithSuccessMessage( v3EnvironmentVariablesPath( { slug: organizationSlug }, diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx index dfeeda4550..f2115e86b3 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx @@ -45,6 +45,7 @@ import { TooltipProvider } from "~/components/primitives/Tooltip"; import { VercelLogo } from "~/components/integrations/VercelLogo"; +import { BuildSettingsFields } from "~/components/integrations/VercelBuildSettings"; import { EnvironmentIcon, environmentFullTitle, @@ -142,9 +143,14 @@ const UpdateVercelConfigFormSchema = z.object({ return null; } }), - pullNewEnvVars: z.string().optional().transform((val) => { - if (val === undefined || val === "") return null; - return val === "true"; + discoverEnvVars: z.string().optional().transform((val) => { + if (!val) return null; + try { + const parsed = JSON.parse(val); + return Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } }), vercelStagingEnvironment: z.string().nullable().optional(), }); @@ -174,9 +180,14 @@ const CompleteOnboardingFormSchema = z.object({ return null; } }), - pullNewEnvVars: z.string().optional().transform((val) => { - if (val === undefined || val === "") return null; - return val === "true"; + discoverEnvVars: z.string().optional().transform((val) => { + if (!val) return null; + try { + const parsed = JSON.parse(val); + return Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } }), syncEnvVarsMapping: z.string().optional(), // JSON-encoded mapping next: z.string().optional(), @@ -314,7 +325,7 @@ export async function action({ request, params }: ActionFunctionArgs) { const { atomicBuilds, pullEnvVarsBeforeBuild, - pullNewEnvVars, + discoverEnvVars, vercelStagingEnvironment, } = submission.value; @@ -323,7 +334,7 @@ export async function action({ request, params }: ActionFunctionArgs) { const result = await vercelService.updateVercelIntegrationConfig(project.id, { atomicBuilds: atomicBuilds as EnvSlug[] | null, pullEnvVarsBeforeBuild: pullEnvVarsBeforeBuild as EnvSlug[] | null, - pullNewEnvVars: pullNewEnvVars, + discoverEnvVars: discoverEnvVars as EnvSlug[] | null, vercelStagingEnvironment: parsedStagingEnv, }); @@ -351,7 +362,7 @@ export async function action({ request, params }: ActionFunctionArgs) { vercelStagingEnvironment, pullEnvVarsBeforeBuild, atomicBuilds, - pullNewEnvVars, + discoverEnvVars, syncEnvVarsMapping, next, skipRedirect, @@ -371,7 +382,7 @@ export async function action({ request, params }: ActionFunctionArgs) { vercelStagingEnvironment, pullEnvVarsBeforeBuild, atomicBuilds, - pullNewEnvVars, + discoverEnvVars, syncEnvVarsMappingRaw: syncEnvVarsMapping, parsedMappingKeys: Object.keys(parsedMapping), }); @@ -382,7 +393,7 @@ export async function action({ request, params }: ActionFunctionArgs) { vercelStagingEnvironment: parsedStagingEnv, pullEnvVarsBeforeBuild: pullEnvVarsBeforeBuild as EnvSlug[] | null, atomicBuilds: atomicBuilds as EnvSlug[] | null, - pullNewEnvVars: pullNewEnvVars, + discoverEnvVars: discoverEnvVars as EnvSlug[] | null, syncEnvVarsMapping: parsedMapping, }); @@ -681,7 +692,7 @@ function ConnectedVercelProjectForm({ const [configValues, setConfigValues] = useState({ atomicBuilds: connectedProject.integrationData.config.atomicBuilds ?? [], pullEnvVarsBeforeBuild: connectedProject.integrationData.config.pullEnvVarsBeforeBuild ?? [], - pullNewEnvVars: connectedProject.integrationData.config.pullNewEnvVars !== false, + discoverEnvVars: connectedProject.integrationData.config.discoverEnvVars ?? [], vercelStagingEnvironment: connectedProject.integrationData.config.vercelStagingEnvironment ?? null, }); @@ -689,7 +700,7 @@ function ConnectedVercelProjectForm({ // Track original values for comparison const originalAtomicBuilds = connectedProject.integrationData.config.atomicBuilds ?? []; const originalPullEnvVars = connectedProject.integrationData.config.pullEnvVarsBeforeBuild ?? []; - const originalPullNewEnvVars = connectedProject.integrationData.config.pullNewEnvVars !== false; + const originalDiscoverEnvVars = connectedProject.integrationData.config.discoverEnvVars ?? []; const originalStagingEnv = connectedProject.integrationData.config.vercelStagingEnvironment ?? null; useEffect(() => { @@ -699,11 +710,13 @@ function ConnectedVercelProjectForm({ const pullEnvVarsChanged = JSON.stringify([...configValues.pullEnvVarsBeforeBuild].sort()) !== JSON.stringify([...originalPullEnvVars].sort()); - const pullNewEnvVarsChanged = configValues.pullNewEnvVars !== originalPullNewEnvVars; + const discoverEnvVarsChanged = + JSON.stringify([...configValues.discoverEnvVars].sort()) !== + JSON.stringify([...originalDiscoverEnvVars].sort()); const stagingEnvChanged = configValues.vercelStagingEnvironment?.environmentId !== originalStagingEnv?.environmentId; - setHasConfigChanges(atomicBuildsChanged || pullEnvVarsChanged || pullNewEnvVarsChanged || stagingEnvChanged); - }, [configValues, originalAtomicBuilds, originalPullEnvVars, originalPullNewEnvVars, originalStagingEnv]); + setHasConfigChanges(atomicBuildsChanged || pullEnvVarsChanged || discoverEnvVarsChanged || stagingEnvChanged); + }, [configValues, originalAtomicBuilds, originalPullEnvVars, originalDiscoverEnvVars, originalStagingEnv]); const [configForm, fields] = useForm({ id: "update-vercel-config", @@ -804,8 +817,8 @@ function ConnectedVercelProjectForm({ /> )} - {/* Pull env vars before build */} -
-
-
- - - Select which environments should pull environment variables from Vercel before - each build.{" "} - - Configure which variables to pull - - . - -
- {availableEnvSlugsForBuildSettings.length > 1 && ( - 0 && availableEnvSlugsForBuildSettings.every((s) => configValues.pullEnvVarsBeforeBuild.includes(s))} - onCheckedChange={(checked) => { - setConfigValues((prev) => ({ - ...prev, - pullEnvVarsBeforeBuild: checked ? [...availableEnvSlugsForBuildSettings] : [], - })); - }} - /> - )} -
-
- {availableEnvSlugsForBuildSettings.map((slug) => { - const envType = slug === "prod" ? "PRODUCTION" : slug === "stg" ? "STAGING" : "PREVIEW"; - return ( -
-
- - - {environmentFullTitle({ type: envType })} - -
- { - setConfigValues((prev) => ({ - ...prev, - pullEnvVarsBeforeBuild: checked - ? [...prev.pullEnvVarsBeforeBuild, slug] - : prev.pullEnvVarsBeforeBuild.filter((s) => s !== slug), - })); - }} - /> -
- ); - })} -
- -
- - {/* Discover new env vars */} - {(() => { - const isPullEnvVarsDisabled = !availableEnvSlugsForBuildSettings.some((s) => configValues.pullEnvVarsBeforeBuild.includes(s)); - return ( -
-
-
- - - When enabled, automatically discovers and creates new environment variables - from Vercel that don't exist in Trigger.dev yet during builds. - -
- - setConfigValues((prev) => ({ ...prev, pullNewEnvVars: checked })) - } - /> -
-
- ); - })()} - - {/* Atomic deployments */} -
-
-
- - - When enabled, production deployments wait for Vercel deployment to complete - before promoting the Trigger.dev deployment. - -
- { - setConfigValues((prev) => ({ - ...prev, - atomicBuilds: checked ? ["prod"] : [], - })); - }} - /> -
-
+ + setConfigValues((prev) => ({ ...prev, pullEnvVarsBeforeBuild: slugs })) + } + discoverEnvVars={configValues.discoverEnvVars} + onDiscoverEnvVarsChange={(slugs) => + setConfigValues((prev) => ({ ...prev, discoverEnvVars: slugs })) + } + atomicBuilds={configValues.atomicBuilds} + onAtomicBuildsChange={(slugs) => + setConfigValues((prev) => ({ ...prev, atomicBuilds: slugs })) + } + envVarsConfigLink={`/orgs/${organizationSlug}/projects/${projectSlug}/environment-variables`} + /> {/* Warning: autoAssignCustomDomains must be disabled for atomic deployments */} {autoAssignCustomDomains !== false && @@ -1263,9 +1185,11 @@ function VercelOnboardingModal({ const [atomicBuilds, setAtomicBuilds] = useState( () => ["prod"] ); - const [pullNewEnvVars, setPullNewEnvVars] = useState(true); + const [discoverEnvVars, setDiscoverEnvVars] = useState( + () => availableEnvSlugsForOnboardingBuildSettings + ); - // Sync pullEnvVarsBeforeBuild when hasStagingEnvironment becomes true (once) + // Sync pullEnvVarsBeforeBuild and discoverEnvVars when hasStagingEnvironment becomes true (once) // This ensures staging is included when it becomes available, but respects user changes after useEffect(() => { if (hasStagingEnvironment && !hasSyncedStagingRef.current) { @@ -1276,10 +1200,16 @@ function VercelOnboardingModal({ } return prev; }); + setDiscoverEnvVars((prev) => { + if (!prev.includes("stg")) { + return [...prev, "stg"]; + } + return prev; + }); } }, [hasStagingEnvironment]); - // Sync pullEnvVarsBeforeBuild when hasPreviewEnvironment becomes true (once) + // Sync pullEnvVarsBeforeBuild and discoverEnvVars when hasPreviewEnvironment becomes true (once) // This ensures preview is included when it becomes available, but respects user changes after useEffect(() => { if (hasPreviewEnvironment && !hasSyncedPreviewRef.current) { @@ -1290,6 +1220,12 @@ function VercelOnboardingModal({ } return prev; }); + setDiscoverEnvVars((prev) => { + if (!prev.includes("preview")) { + return [...prev, "preview"]; + } + return prev; + }); } }, [hasPreviewEnvironment]); // Env var sync state (for env-var-sync step - one-time sync) @@ -1576,7 +1512,7 @@ function VercelOnboardingModal({ formData.append("vercelStagingEnvironment", vercelStagingEnvironment ? JSON.stringify(vercelStagingEnvironment) : ""); formData.append("pullEnvVarsBeforeBuild", JSON.stringify(pullEnvVarsBeforeBuild)); formData.append("atomicBuilds", JSON.stringify(atomicBuilds)); - formData.append("pullNewEnvVars", String(pullNewEnvVars)); + formData.append("discoverEnvVars", JSON.stringify(discoverEnvVars)); formData.append("syncEnvVarsMapping", JSON.stringify(syncEnvVarsMapping)); if (nextUrl && fromMarketplaceContext && isGitHubConnectedForOnboarding) { formData.append("next", nextUrl); @@ -1596,7 +1532,7 @@ function VercelOnboardingModal({ if (!isGitHubConnectedForOnboarding) { setState("github-connection"); } - }, [vercelStagingEnvironment, pullEnvVarsBeforeBuild, atomicBuilds, pullNewEnvVars, syncEnvVarsMapping, nextUrl, fromMarketplaceContext, isGitHubConnectedForOnboarding, completeOnboardingFetcher, actionUrl]); + }, [vercelStagingEnvironment, pullEnvVarsBeforeBuild, atomicBuilds, discoverEnvVars, syncEnvVarsMapping, nextUrl, fromMarketplaceContext, isGitHubConnectedForOnboarding, completeOnboardingFetcher, actionUrl]); const handleFinishOnboarding = useCallback((e: React.FormEvent) => { e.preventDefault(); @@ -2044,95 +1980,15 @@ function VercelOnboardingModal({ Configure how environment variables are pulled during builds and atomic deployments. - {/* Pull env vars before build */} -
-
-
- - - Select which environments should automatically pull environment variables from - Vercel before each build. - -
- {availableEnvSlugsForOnboardingBuildSettings.length > 1 && ( - 0 && availableEnvSlugsForOnboardingBuildSettings.every((s) => pullEnvVarsBeforeBuild.includes(s))} - onCheckedChange={(checked) => { - setPullEnvVarsBeforeBuild(checked ? [...availableEnvSlugsForOnboardingBuildSettings] : []); - }} - /> - )} -
-
- {availableEnvSlugsForOnboardingBuildSettings.map((slug) => { - const envType = slug === "prod" ? "PRODUCTION" : slug === "stg" ? "STAGING" : "PREVIEW"; - return ( -
-
- - - {environmentFullTitle({ type: envType })} - -
- { - setPullEnvVarsBeforeBuild((prev) => - checked ? [...prev, slug] : prev.filter((s) => s !== slug) - ); - }} - /> -
- ); - })} -
-
- - {/* Discover new env vars */} - {(() => { - const isPullEnvVarsDisabled = !availableEnvSlugsForOnboardingBuildSettings.some((s) => pullEnvVarsBeforeBuild.includes(s)); - return ( -
-
-
- - - When enabled, automatically discovers and creates new environment variables - from Vercel that don't exist in Trigger.dev yet during builds. - -
- -
-
- ); - })()} - - {/* Atomic deployments */} -
-
-
- - - When enabled, production deployments wait for Vercel deployment to complete - before promoting the Trigger.dev deployment. - -
- { - setAtomicBuilds(checked ? ["prod"] : []); - }} - /> -
-
+ { + const existing = await this.getVercelProjectIntegration(projectId); + if (!existing) return; + + const currentMapping = existing.parsedIntegrationData.syncEnvVarsMapping || {}; + const envSlug = envTypeToSlug(environmentType); + const currentEnvSettings = currentMapping[envSlug]; + if (!currentEnvSettings || !(envVarKey in currentEnvSettings)) return; + + const { [envVarKey]: _, ...rest } = currentEnvSettings; + const updatedMapping = { ...currentMapping, [envSlug]: rest }; + + await this.#prismaClient.organizationProjectIntegration.update({ + where: { id: existing.id }, + data: { + integrationData: { + ...existing.parsedIntegrationData, + syncEnvVarsMapping: updatedMapping, + }, + }, + }); + } + async completeOnboarding( projectId: string, params: { vercelStagingEnvironment?: { environmentId: string; displayName: string } | null; pullEnvVarsBeforeBuild?: EnvSlug[] | null; atomicBuilds?: EnvSlug[] | null; - pullNewEnvVars?: boolean | null; + discoverEnvVars?: EnvSlug[] | null; syncEnvVarsMapping: SyncEnvVarsMapping; } ): Promise { @@ -376,7 +403,7 @@ export class VercelIntegrationService { ...existing.parsedIntegrationData.config, pullEnvVarsBeforeBuild: params.pullEnvVarsBeforeBuild ?? null, atomicBuilds: params.atomicBuilds ?? null, - pullNewEnvVars: params.pullNewEnvVars ?? null, + discoverEnvVars: params.discoverEnvVars ?? null, vercelStagingEnvironment: params.vercelStagingEnvironment ?? null, }, // Don't save syncEnvVarsMapping - it's only used for the one-time pull during onboarding diff --git a/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts b/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts index 7a209756ae..0e787bdb68 100644 --- a/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts +++ b/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts @@ -55,15 +55,14 @@ export const VercelIntegrationConfigSchema = z.object({ }).nullable().optional(), /** - * When enabled, discovers and creates new env vars from Vercel during builds. - * This allows new environment variables added in Vercel to be automatically - * pulled into Trigger.dev. + * Array of environment slugs for which new env vars should be discovered from Vercel during builds. + * When an environment slug is in this array, new environment variables added in Vercel + * will be automatically pulled into Trigger.dev for that environment. * - * Default: true (enabled) - * null/undefined = defaults to enabled - * false = only sync existing variables, don't discover new ones + * Example: ["prod", "stg"] will discover new env vars for production and staging builds + * null/undefined = discovery disabled for all environments */ - pullNewEnvVars: z.boolean().nullable().optional(), + discoverEnvVars: z.array(EnvSlugSchema).nullable().optional(), }); export type VercelIntegrationConfig = z.infer; @@ -152,7 +151,7 @@ export function createDefaultVercelIntegrationData( config: { atomicBuilds: ["prod"], pullEnvVarsBeforeBuild: ["prod", "stg", "preview"], - pullNewEnvVars: true, + discoverEnvVars: ["prod", "stg", "preview"], vercelStagingEnvironment: null, }, syncEnvVarsMapping: {}, @@ -163,13 +162,17 @@ export function createDefaultVercelIntegrationData( } /** - * Check if pull new env vars is enabled. - * Defaults to true if not explicitly set to false. + * Check if discover env vars is enabled for a specific environment. */ -export function isPullNewEnvVarsEnabled( - pullNewEnvVars: boolean | null | undefined +export function isDiscoverEnvVarsEnabledForEnvironment( + discoverEnvVars: EnvSlug[] | null | undefined, + environmentType: TriggerEnvironmentType ): boolean { - return pullNewEnvVars !== false; + if (!discoverEnvVars || discoverEnvVars.length === 0) { + return false; + } + const envSlug = envTypeToSlug(environmentType); + return discoverEnvVars.includes(envSlug); } /** From cccb4e222d670eba600af6e39022f7faf547d76d Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Tue, 3 Feb 2026 15:22:12 +0100 Subject: [PATCH 27/33] feat(vercel): add Vercel onboarding modal component Introduce a new VercelOnboardingModal component to streamline the onboarding process for Vercel integration. This modal manages project selection, environment variable synchronization, and build settings configuration. It enhances user experience by providing a structured flow for onboarding, including handling custom environments and GitHub integration. Additionally, refactor related logic in the Vercel integration service and update schemas to support the new onboarding features. --- .../integrations/VercelOnboardingModal.tsx | 1054 ++++++++++++++ .../app/models/vercelIntegration.server.ts | 325 +---- .../v3/VercelSettingsPresenter.server.ts | 20 +- ...cts.$projectParam.env.$envParam.vercel.tsx | 1212 +---------------- .../app/services/vercelIntegration.server.ts | 17 - apps/webapp/app/v3/vercel/index.ts | 9 - .../app/v3/vercel/vercelOAuthState.server.ts | 18 - .../vercel/vercelProjectIntegrationSchema.ts | 198 +-- 8 files changed, 1187 insertions(+), 1666 deletions(-) create mode 100644 apps/webapp/app/components/integrations/VercelOnboardingModal.tsx diff --git a/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx b/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx new file mode 100644 index 0000000000..56c4d82e4b --- /dev/null +++ b/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx @@ -0,0 +1,1054 @@ +import { + CheckCircleIcon, + ExclamationTriangleIcon, + ChevronDownIcon, + ChevronUpIcon, +} from "@heroicons/react/20/solid"; +import { + useFetcher, + useNavigation, + useSearchParams, +} from "@remix-run/react"; +import { useTypedFetcher } from "remix-typedjson"; +import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { Callout } from "~/components/primitives/Callout"; +import { FormButtons } from "~/components/primitives/FormButtons"; +import { FormError } from "~/components/primitives/FormError"; +import { Header3 } from "~/components/primitives/Headers"; +import { Hint } from "~/components/primitives/Hint"; +import { Label } from "~/components/primitives/Label"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { Select, SelectItem } from "~/components/primitives/Select"; +import { SpinnerWhite } from "~/components/primitives/Spinner"; +import { Switch } from "~/components/primitives/Switch"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, + TooltipProvider, +} from "~/components/primitives/Tooltip"; +import { VercelLogo } from "~/components/integrations/VercelLogo"; +import { BuildSettingsFields } from "~/components/integrations/VercelBuildSettings"; +import { OctoKitty } from "~/components/GitHubLoginButton"; +import { + ConnectGitHubRepoModal, + type GitHubAppInstallation, +} from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github"; +import { + type SyncEnvVarsMapping, + type EnvSlug, + ALL_ENV_SLUGS, + shouldSyncEnvVarForAnyEnvironment, + getAvailableEnvSlugs, + getAvailableEnvSlugsForBuildSettings, +} from "~/v3/vercel/vercelProjectIntegrationSchema"; +import { type VercelCustomEnvironment } from "~/models/vercelIntegration.server"; +import { type VercelOnboardingData } from "~/presenters/v3/VercelSettingsPresenter.server"; +import { vercelAppInstallPath, v3ProjectSettingsPath, githubAppInstallPath } from "~/utils/pathBuilder"; +import { vercelResourcePath } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel"; +import type { loader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel"; +import { useEffect, useState, useCallback, useRef } from "react"; + +function formatVercelTargets(targets: string[]): string { + const targetLabels: Record = { + production: "Production", + preview: "Preview", + development: "Development", + staging: "Staging", + }; + + return targets + .map((t) => targetLabels[t.toLowerCase()] || t) + .join(", "); +} + +type OnboardingState = + | "idle" + | "installing" + | "loading-projects" + | "project-selection" + | "loading-env-mapping" + | "env-mapping" + | "loading-env-vars" + | "env-var-sync" + | "build-settings" + | "github-connection" + | "completed"; + +export function VercelOnboardingModal({ + isOpen, + onClose, + onboardingData, + organizationSlug, + projectSlug, + environmentSlug, + hasStagingEnvironment, + hasPreviewEnvironment, + hasOrgIntegration, + nextUrl, + onDataReload, +}: { + isOpen: boolean; + onClose: () => void; + onboardingData: VercelOnboardingData | null; + organizationSlug: string; + projectSlug: string; + environmentSlug: string; + hasStagingEnvironment: boolean; + hasPreviewEnvironment: boolean; + hasOrgIntegration: boolean; + nextUrl?: string; + onDataReload?: (vercelStagingEnvironment?: string) => void; +}) { + const navigation = useNavigation(); + const fetcher = useTypedFetcher(); + const envMappingFetcher = useFetcher(); + const completeOnboardingFetcher = useFetcher(); + const { Form: CompleteOnboardingForm } = completeOnboardingFetcher; + const [searchParams] = useSearchParams(); + const fromMarketplaceContext = searchParams.get("origin") === "marketplace"; + + const availableProjects = onboardingData?.availableProjects || []; + const hasProjectSelected = onboardingData?.hasProjectSelected ?? false; + const customEnvironments = onboardingData?.customEnvironments || []; + const envVars = onboardingData?.environmentVariables || []; + const existingVars = onboardingData?.existingVariables || {}; + const hasCustomEnvs = customEnvironments.length > 0 && hasStagingEnvironment; + + const computeInitialState = useCallback((): OnboardingState => { + if (!hasOrgIntegration || onboardingData?.authInvalid) { + return "idle"; + } + const projectSelected = onboardingData?.hasProjectSelected ?? false; + if (!projectSelected) { + if (!onboardingData?.availableProjects || onboardingData.availableProjects.length === 0) { + return "loading-projects"; + } + return "project-selection"; + } + // For marketplace origin, skip env-mapping step and go directly to env-var-sync + if (!fromMarketplaceContext) { + const customEnvs = (onboardingData?.customEnvironments?.length ?? 0) > 0 && hasStagingEnvironment; + if (customEnvs) { + return "env-mapping"; + } + } + if (!onboardingData?.environmentVariables || onboardingData.environmentVariables.length === 0) { + return "loading-env-vars"; + } + return "env-var-sync"; + }, [hasOrgIntegration, onboardingData, hasStagingEnvironment, fromMarketplaceContext]); + + const [state, setState] = useState(() => { + if (!isOpen) return "idle"; + return computeInitialState(); + }); + + const prevIsOpenRef = useRef(isOpen); + const hasSyncedStagingRef = useRef(false); + const hasSyncedPreviewRef = useRef(false); + useEffect(() => { + if (isOpen && !prevIsOpenRef.current) { + setState(computeInitialState()); + hasSyncedStagingRef.current = false; + hasSyncedPreviewRef.current = false; + } else if (isOpen && state === "idle") { + setState(computeInitialState()); + } + prevIsOpenRef.current = isOpen; + }, [isOpen, state, computeInitialState]); + + const [selectedVercelProject, setSelectedVercelProject] = useState<{ + id: string; + name: string; + } | null>(null); + const [vercelStagingEnvironment, setVercelStagingEnvironment] = useState<{ + environmentId: string; + displayName: string; + } | null>(null); + const availableEnvSlugsForOnboarding = getAvailableEnvSlugs(hasStagingEnvironment, hasPreviewEnvironment); + const availableEnvSlugsForOnboardingBuildSettings = getAvailableEnvSlugsForBuildSettings(hasStagingEnvironment, hasPreviewEnvironment); + const [pullEnvVarsBeforeBuild, setPullEnvVarsBeforeBuild] = useState( + () => availableEnvSlugsForOnboardingBuildSettings + ); + const [atomicBuilds, setAtomicBuilds] = useState( + () => ["prod"] + ); + const [discoverEnvVars, setDiscoverEnvVars] = useState( + () => availableEnvSlugsForOnboardingBuildSettings + ); + + // Sync pullEnvVarsBeforeBuild and discoverEnvVars when hasStagingEnvironment becomes true (once) + useEffect(() => { + if (hasStagingEnvironment && !hasSyncedStagingRef.current) { + hasSyncedStagingRef.current = true; + setPullEnvVarsBeforeBuild((prev) => { + if (!prev.includes("stg")) { + return [...prev, "stg"]; + } + return prev; + }); + setDiscoverEnvVars((prev) => { + if (!prev.includes("stg")) { + return [...prev, "stg"]; + } + return prev; + }); + } + }, [hasStagingEnvironment]); + + // Sync pullEnvVarsBeforeBuild and discoverEnvVars when hasPreviewEnvironment becomes true (once) + useEffect(() => { + if (hasPreviewEnvironment && !hasSyncedPreviewRef.current) { + hasSyncedPreviewRef.current = true; + setPullEnvVarsBeforeBuild((prev) => { + if (!prev.includes("preview")) { + return [...prev, "preview"]; + } + return prev; + }); + setDiscoverEnvVars((prev) => { + if (!prev.includes("preview")) { + return [...prev, "preview"]; + } + return prev; + }); + } + }, [hasPreviewEnvironment]); + const [syncEnvVarsMapping, setSyncEnvVarsMapping] = useState({}); + const [expandedEnvVars, setExpandedEnvVars] = useState(false); + const [expandedSecretEnvVars, setExpandedSecretEnvVars] = useState(false); + const [projectSelectionError, setProjectSelectionError] = useState(null); + + const gitHubAppInstallations = onboardingData?.gitHubAppInstallations ?? []; + const isGitHubConnectedForOnboarding = onboardingData?.isGitHubConnected ?? false; + + const hasTriggeredMarketplaceRedirectRef = useRef(false); + + // Auto-redirect for marketplace flow when returning from GitHub with everything complete + useEffect(() => { + if (hasTriggeredMarketplaceRedirectRef.current) { + return; + } + + if ( + isOpen && + fromMarketplaceContext && + nextUrl && + hasProjectSelected && + isGitHubConnectedForOnboarding + ) { + hasTriggeredMarketplaceRedirectRef.current = true; + setTimeout(() => { + window.location.href = nextUrl; + }, 100); + } + }, [isOpen, fromMarketplaceContext, nextUrl, hasProjectSelected, isGitHubConnectedForOnboarding]); + + useEffect(() => { + if (!isOpen) { + hasTriggeredMarketplaceRedirectRef.current = false; + } + }, [isOpen]); + + const loadingStateRef = useRef(null); + + useEffect(() => { + if (!isOpen || state === "idle") { + loadingStateRef.current = null; + return; + } + + if (onboardingData?.authInvalid) { + onClose(); + return; + } + + if (loadingStateRef.current === state) { + return; + } + + switch (state) { + + case "loading-projects": + loadingStateRef.current = state; + if (onDataReload) { + onDataReload(); + } + break; + + case "loading-env-mapping": + loadingStateRef.current = state; + if (onDataReload) { + onDataReload(); + } + break; + + case "loading-env-vars": + loadingStateRef.current = state; + if (onDataReload) { + onDataReload(vercelStagingEnvironment?.environmentId || undefined); + } + break; + + case "installing": + case "project-selection": + case "env-mapping": + case "env-var-sync": + case "completed": + case "build-settings": + case "github-connection": + loadingStateRef.current = null; + break; + } + }, [isOpen, state, onboardingData?.authInvalid, vercelStagingEnvironment, onDataReload, onClose]); + + useEffect(() => { + if (!onboardingData?.authInvalid && state === "loading-projects" && onboardingData?.availableProjects !== undefined) { + setState("project-selection"); + } + }, [state, onboardingData?.availableProjects, onboardingData?.authInvalid]); + + useEffect(() => { + if (!onboardingData?.authInvalid && state === "loading-env-vars" && onboardingData?.environmentVariables) { + setState("env-var-sync"); + } + }, [state, onboardingData?.environmentVariables, onboardingData?.authInvalid]); + + useEffect(() => { + if (state === "project-selection" && fetcher.data && "success" in fetcher.data && fetcher.data.success && fetcher.state === "idle") { + setState("loading-env-mapping"); + if (onDataReload) { + console.log("Vercel onboarding: Reloading data after successful project selection to get updated project info and env vars"); + onDataReload(); + } + } else if (fetcher.data && "error" in fetcher.data && typeof fetcher.data.error === "string") { + setProjectSelectionError(fetcher.data.error); + } + }, [state, fetcher.data, fetcher.state, onDataReload]); + + // For marketplace origin, skip env-mapping step + useEffect(() => { + if (state === "loading-env-mapping" && onboardingData) { + const hasCustomEnvs = (onboardingData.customEnvironments?.length ?? 0) > 0 && hasStagingEnvironment; + if (hasCustomEnvs && !fromMarketplaceContext) { + setState("env-mapping"); + } else { + setState("loading-env-vars"); + } + } + }, [state, onboardingData, hasStagingEnvironment]); + + const secretEnvVars = envVars.filter((v) => v.isSecret); + const syncableEnvVars = envVars.filter((v) => !v.isSecret); + const enabledEnvVars = syncableEnvVars.filter( + (v) => shouldSyncEnvVarForAnyEnvironment(syncEnvVarsMapping, v.key) + ); + + const overlappingEnvVarsCount = enabledEnvVars.filter((v) => existingVars[v.key]).length; + + const isSubmitting = + navigation.state === "submitting" || navigation.state === "loading"; + + const actionUrl = vercelResourcePath(organizationSlug, projectSlug, environmentSlug); + + const handleToggleEnvVar = useCallback((key: string, enabled: boolean) => { + setSyncEnvVarsMapping((prev) => { + const newMapping = { ...prev }; + + if (enabled) { + for (const envSlug of ALL_ENV_SLUGS) { + if (newMapping[envSlug]) { + const { [key]: _, ...rest } = newMapping[envSlug]; + if (Object.keys(rest).length === 0) { + delete newMapping[envSlug]; + } else { + newMapping[envSlug] = rest; + } + } + } + } else { + for (const envSlug of ALL_ENV_SLUGS) { + newMapping[envSlug] = { + ...(newMapping[envSlug] || {}), + [key]: false, + }; + } + } + + return newMapping; + }); + }, []); + + const handleToggleAllEnvVars = useCallback( + (enabled: boolean, syncableVars: Array<{ key: string }>) => { + if (enabled) { + setSyncEnvVarsMapping({}); + } else { + const newMapping: SyncEnvVarsMapping = {}; + for (const envSlug of ALL_ENV_SLUGS) { + newMapping[envSlug] = {}; + for (const v of syncableVars) { + newMapping[envSlug][v.key] = false; + } + } + setSyncEnvVarsMapping(newMapping); + } + }, + [] + ); + + const handleProjectSelection = useCallback(async () => { + if (!selectedVercelProject) { + setProjectSelectionError("Please select a Vercel project"); + return; + } + + setProjectSelectionError(null); + + const formData = new FormData(); + formData.append("action", "select-vercel-project"); + formData.append("vercelProjectId", selectedVercelProject.id); + formData.append("vercelProjectName", selectedVercelProject.name); + + fetcher.submit(formData, { + method: "post", + action: actionUrl, + }); + }, [selectedVercelProject, fetcher, actionUrl]); + + const handleSkipOnboarding = useCallback(() => { + onClose(); + + if (fromMarketplaceContext) { + return window.close(); + } + + const formData = new FormData(); + formData.append("action", "skip-onboarding"); + fetcher.submit(formData, { + method: "post", + action: actionUrl, + }); + }, [actionUrl, fetcher, onClose, nextUrl, fromMarketplaceContext]); + + const handleSkipEnvMapping = useCallback(() => { + setVercelStagingEnvironment(null); + setState("loading-env-vars"); + }, []); + + const handleUpdateEnvMapping = useCallback(() => { + if (!vercelStagingEnvironment) { + setState("loading-env-vars"); + return; + } + + const formData = new FormData(); + formData.append("action", "update-env-mapping"); + formData.append("vercelStagingEnvironment", JSON.stringify(vercelStagingEnvironment)); + + envMappingFetcher.submit(formData, { + method: "post", + action: actionUrl, + }); + + }, [vercelStagingEnvironment, envMappingFetcher, actionUrl]); + + const handleBuildSettingsNext = useCallback(() => { + const formData = new FormData(); + formData.append("action", "complete-onboarding"); + formData.append("vercelStagingEnvironment", vercelStagingEnvironment ? JSON.stringify(vercelStagingEnvironment) : ""); + formData.append("pullEnvVarsBeforeBuild", JSON.stringify(pullEnvVarsBeforeBuild)); + formData.append("atomicBuilds", JSON.stringify(atomicBuilds)); + formData.append("discoverEnvVars", JSON.stringify(discoverEnvVars)); + formData.append("syncEnvVarsMapping", JSON.stringify(syncEnvVarsMapping)); + if (nextUrl && fromMarketplaceContext && isGitHubConnectedForOnboarding) { + formData.append("next", nextUrl); + } + + if (!isGitHubConnectedForOnboarding) { + formData.append("skipRedirect", "true"); + } + + completeOnboardingFetcher.submit(formData, { + method: "post", + action: actionUrl, + }); + + if (!isGitHubConnectedForOnboarding) { + setState("github-connection"); + } + }, [vercelStagingEnvironment, pullEnvVarsBeforeBuild, atomicBuilds, discoverEnvVars, syncEnvVarsMapping, nextUrl, fromMarketplaceContext, isGitHubConnectedForOnboarding, completeOnboardingFetcher, actionUrl]); + + const handleFinishOnboarding = useCallback((e: React.FormEvent) => { + e.preventDefault(); + const form = e.currentTarget; + const formData = new FormData(form); + completeOnboardingFetcher.submit(formData, { + method: "post", + action: actionUrl, + }); + }, [completeOnboardingFetcher, actionUrl]); + + useEffect(() => { + if (completeOnboardingFetcher.data && typeof completeOnboardingFetcher.data === "object" && "success" in completeOnboardingFetcher.data && completeOnboardingFetcher.data.success && completeOnboardingFetcher.state === "idle") { + if (state === "github-connection") { + return; + } + if ("redirectTo" in completeOnboardingFetcher.data && typeof completeOnboardingFetcher.data.redirectTo === "string") { + window.location.href = completeOnboardingFetcher.data.redirectTo; + return; + } + setState("completed"); + } + }, [completeOnboardingFetcher.data, completeOnboardingFetcher.state, state]); + + useEffect(() => { + if (state === "completed") { + onClose(); + } + }, [state, onClose]); + + useEffect(() => { + if (state === "installing") { + const installUrl = vercelAppInstallPath(organizationSlug, projectSlug); + window.location.href = installUrl; + } + }, [state, organizationSlug, projectSlug]); + + useEffect(() => { + if (envMappingFetcher.data && typeof envMappingFetcher.data === "object" && "success" in envMappingFetcher.data && envMappingFetcher.data.success && envMappingFetcher.state === "idle") { + setState("loading-env-vars"); + } + }, [envMappingFetcher.data, envMappingFetcher.state]); + + useEffect(() => { + if (state === "env-mapping" && customEnvironments.length > 0 && !vercelStagingEnvironment) { + let selectedEnv: VercelCustomEnvironment; + + if (customEnvironments.length === 1) { + selectedEnv = customEnvironments[0]; + } else { + const stagingEnv = customEnvironments.find( + (env) => env.slug.toLowerCase() === "staging" + ); + selectedEnv = stagingEnv ?? customEnvironments[0]; + } + + setVercelStagingEnvironment({ environmentId: selectedEnv.id, displayName: selectedEnv.slug }); + } + }, [state, customEnvironments, vercelStagingEnvironment]); + + if (!isOpen || onboardingData?.authInvalid) { + return null; + } + + const isLoadingState = + state === "loading-projects" || + state === "loading-env-mapping" || + state === "loading-env-vars" || + state === "installing" || + (state === "idle" && !onboardingData); + + if (isLoadingState) { + return ( + !open && !fromMarketplaceContext && onClose()}> + + +
+ + Set up Vercel Integration +
+
+
+ +
+
+
+ ); + } + + const showProjectSelection = state === "project-selection"; + const showEnvMapping = state === "env-mapping"; + const showEnvVarSync = state === "env-var-sync"; + const showBuildSettings = state === "build-settings"; + const showGitHubConnection = state === "github-connection"; + + return ( + !open && !fromMarketplaceContext && onClose()}> + + +
+ + Set up Vercel Integration +
+
+ +
+ {showProjectSelection && ( +
+ Select Vercel Project + + Choose which Vercel project to connect with this Trigger.dev project. + Your API keys will be automatically synced to Vercel. + + + {availableProjects.length === 0 ? ( + + No Vercel projects found. Please create a project in Vercel first. + + ) : ( + + )} + + {projectSelectionError && ( + {projectSelectionError} + )} + + + Once connected, your TRIGGER_SECRET_KEY will be + automatically synced to Vercel for each environment. + + + + {fetcher.state !== "idle" ? "Connecting..." : "Connect Project"} + + } + cancelButton={ + + } + /> +
+ )} + + {showEnvMapping && ( +
+ Map Vercel Environment to Staging + + Select which custom Vercel environment should map to Trigger.dev's Staging + environment. Production and Preview environments are mapped automatically. + + + + +
+ +
+ {!fromMarketplaceContext && ( + + )} + +
+
+
+ )} + + {showEnvVarSync && ( +
+ Pull Environment Variables + + Select which environment variables to pull from Vercel now. This is a one-time pull. + + +
+
+ {syncableEnvVars.length} + can be pulled +
+ {secretEnvVars.length > 0 && ( +
+ {secretEnvVars.length} + secret (cannot pull) +
+ )} +
+ +
+
+ + Select all variables to pull from Vercel. +
+ handleToggleAllEnvVars(checked, syncableEnvVars)} + /> +
+ + {syncableEnvVars.length > 0 && ( +
+ + + {expandedEnvVars && ( +
+ {syncableEnvVars.map((envVar) => ( +
+
+ {existingVars[envVar.key] ? ( + + + +
+ {envVar.key} +
+
+ + {`This variable is going to be replaced in: ${existingVars[ + envVar.key + ].environments.join(", ")}`} + +
+
+ ) : ( + {envVar.key} + )} + {envVar.target && envVar.target.length > 0 && ( + + {formatVercelTargets(envVar.target)} + {envVar.isShared && " · Shared"} + + )} +
+ + handleToggleEnvVar(envVar.key, checked) + } + /> +
+ ))} +
+ )} +
+ )} + + {secretEnvVars.length > 0 && ( +
+ + + {expandedSecretEnvVars && ( +
+ {secretEnvVars.map((envVar) => ( +
+
+ {envVar.key} + {envVar.target && envVar.target.length > 0 && ( + + {formatVercelTargets(envVar.target)} + {envVar.isShared && " · Shared"} + + )} +
+ Secret +
+ ))} +
+ )} +
+ )} + + {overlappingEnvVarsCount > 0 && enabledEnvVars.length > 0 && ( +
+ + + {overlappingEnvVarsCount} env vars are going to be updated (marked with{" "} + + underline + + ) + +
+ )} + + { + if (fromMarketplaceContext) { + handleBuildSettingsNext(); + } else { + setState("build-settings"); + } + }} + disabled={fromMarketplaceContext && completeOnboardingFetcher.state !== "idle"} + LeadingIcon={fromMarketplaceContext && completeOnboardingFetcher.state !== "idle" ? SpinnerWhite : undefined} + > + {fromMarketplaceContext ? (isGitHubConnectedForOnboarding ? "Finish" : "Next") : "Next"} + + } + cancelButton={ + hasCustomEnvs && !fromMarketplaceContext ? ( + + ) : ( + + ) + } + /> +
+ )} + + {showBuildSettings && ( +
+ Build Settings + + Configure how environment variables are pulled during builds and atomic deployments. + + + + + + {isGitHubConnectedForOnboarding ? "Finish" : "Next"} + + } + cancelButton={ + + } + /> +
+ )} + + {showGitHubConnection && ( +
+ Connect GitHub Repository + + To fully integrate with Vercel, Trigger.dev needs access to your source code. + This allows automatic deployments and build synchronization. + + + +

+ Connecting your GitHub repository enables Trigger.dev to read your source code + and automatically create deployments when you push changes to Vercel. +

+
+ + {(() => { + const baseSettingsPath = v3ProjectSettingsPath( + { slug: organizationSlug }, + { slug: projectSlug }, + { slug: environmentSlug } + ); + const redirectParams = new URLSearchParams(); + redirectParams.set("vercelOnboarding", "true"); + if (fromMarketplaceContext) { + redirectParams.set("origin", "marketplace"); + } + if (nextUrl) { + redirectParams.set("next", nextUrl); + } + const redirectUrlWithContext = `${baseSettingsPath}?${redirectParams.toString()}`; + + return gitHubAppInstallations.length === 0 ? ( +
+ + Install GitHub app + +
+ ) : ( +
+
+ + + GitHub app is installed + +
+
+ ); + })()} + + { + setState("completed"); + window.location.href = nextUrl; + }} + > + Complete + + ) : ( + + ) + } + cancelButton={ + isGitHubConnectedForOnboarding && fromMarketplaceContext && nextUrl ? ( + + ) : undefined + } + /> +
+ )} +
+
+
+ ); +} diff --git a/apps/webapp/app/models/vercelIntegration.server.ts b/apps/webapp/app/models/vercelIntegration.server.ts index 909a90df1c..57c1756eb6 100644 --- a/apps/webapp/app/models/vercelIntegration.server.ts +++ b/apps/webapp/app/models/vercelIntegration.server.ts @@ -12,10 +12,10 @@ import { logger } from "~/services/logger.server"; import { getSecretStore } from "~/services/secrets/secretStore.server"; import { generateFriendlyId } from "~/v3/friendlyIdentifiers"; import { - createDefaultVercelIntegrationData, SyncEnvVarsMapping, shouldSyncEnvVar, TriggerEnvironmentType, + envTypeToVercelTarget, } from "~/v3/vercel/vercelProjectIntegrationSchema"; import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; @@ -33,6 +33,20 @@ function extractEnvs(response: unknown): unknown[] { return []; } +function isVercelSecretType(type: string): boolean { + return type === "secret" || type === "sensitive"; +} + +function vercelApiError(message: string, context: Record, error: unknown): VercelAPIResult { + const authInvalid = isVercelAuthError(error); + logger.error(message, { ...context, error, authInvalid }); + return { + success: false, + authInvalid, + error: error instanceof Error ? error.message : "Unknown error", + }; +} + export const VercelSecretSchema = z.object({ accessToken: z.string(), tokenType: z.string().optional(), @@ -235,7 +249,6 @@ export class VercelIntegrationRepository { return secret.teamId ?? null; } - // Used when Vercel redirects to our callback without a state parameter static async getVercelIntegrationConfiguration( accessToken: string, configurationId: string, @@ -305,7 +318,6 @@ export class VercelIntegrationRepository { ...(teamId && { teamId }), }); - // The response contains environments array const environments = response.environments || []; return { @@ -318,22 +330,10 @@ export class VercelIntegrationRepository { })), }; } catch (error) { - const authInvalid = isVercelAuthError(error); - logger.error("Failed to fetch Vercel custom environments", { - projectId, - teamId, - error, - authInvalid, - }); - return { - success: false, - authInvalid, - error: error instanceof Error ? error.message : "Unknown error", - }; + return vercelApiError("Failed to fetch Vercel custom environments", { projectId, teamId }, error); } } - // Returns metadata about each variable including whether it's a secret static async getVercelEnvironmentVariables( client: Vercel, projectId: string, @@ -345,39 +345,25 @@ export class VercelIntegrationRepository { ...(teamId && { teamId }), }); - // The response is a union type - check if it has envs array const envs = extractEnvs(response); return { success: true, data: envs.map((env: any) => { const type = env.type as VercelEnvironmentVariable["type"]; - // Secret and sensitive types cannot have their values retrieved - const isSecret = type === "secret" || type === "sensitive"; return { id: env.id, key: env.key, type, - isSecret, + isSecret: isVercelSecretType(type), target: normalizeTarget(env.target), customEnvironmentIds: env.customEnvironmentIds as string[] ?? [], }; }), }; } catch (error) { - const authInvalid = isVercelAuthError(error); - logger.error("Failed to fetch Vercel environment variables", { - projectId, - teamId, - error, - authInvalid, - }); - return { - success: false, - authInvalid, - error: error instanceof Error ? error.message : "Unknown error", - }; + return vercelApiError("Failed to fetch Vercel environment variables", { projectId, teamId }, error); } } @@ -402,33 +388,27 @@ export class VercelIntegrationRepository { decrypt: "true", }); - // The response is a union type - check if it has envs array const envs = extractEnvs(response); - // Filter and map env vars const result = envs .filter((env: any) => { - // Skip env vars without values (secrets/sensitive types won't have values even with decrypt=true) if (!env.value) { return false; } - // Filter by target if provided if (target) { - const envTargets = normalizeTarget(env.target); - return envTargets.includes(target); + return normalizeTarget(env.target).includes(target); } return true; }) .map((env: any) => { const type = env.type as string; - const isSecret = type === "secret" || type === "sensitive"; return { key: env.key as string, value: env.value as string, target: normalizeTarget(env.target), type, - isSecret, + isSecret: isVercelSecretType(type), }; }); @@ -444,7 +424,6 @@ export class VercelIntegrationRepository { } } - // Team-level variables that can be linked to multiple projects static async getVercelSharedEnvironmentVariables( client: Vercel, teamId: string, @@ -468,32 +447,18 @@ export class VercelIntegrationRepository { success: true, data: envVars.map((env) => { const type = (env.type as string) || "plain"; - const isSecret = type === "secret" || type === "sensitive"; return { id: env.id as string, key: env.key as string, type, - isSecret, - target: Array.isArray(env.target) - ? (env.target as string[]) - : [env.target].filter(Boolean) as string[], + isSecret: isVercelSecretType(type), + target: normalizeTarget(env.target), }; }), }; } catch (error) { - const authInvalid = isVercelAuthError(error); - logger.error("Failed to fetch Vercel shared environment variables", { - teamId, - projectId, - error, - authInvalid, - }); - return { - success: false, - authInvalid, - error: error instanceof Error ? error.message : "Unknown error", - }; + return vercelApiError("Failed to fetch Vercel shared environment variables", { teamId, projectId }, error); } } @@ -512,7 +477,6 @@ export class VercelIntegrationRepository { }> > { try { - // First, get the list of shared env vars const listResponse = await client.environment.listSharedEnvVariable({ teamId, ...(projectId && { projectId }), @@ -524,40 +488,29 @@ export class VercelIntegrationRepository { return []; } - // Process each shared env var - // The list response may already include values for plain types - // For encrypted types, we need to call getSharedEnvVar for decrypted values const results = await Promise.all( envVars.map(async (env) => { const type = (env.type as string) || "plain"; - // Note: Vercel shared env var types are: plain, encrypted, sensitive, system - // sensitive types should not have values returned (like secrets) - const isSecret = type === "sensitive"; + const isSecret = isVercelSecretType(type); - // Skip sensitive types early - they won't have values if (isSecret) { return null; } - // Check if value is already available in list response const listValue = (env as any).value as string | undefined; const applyToAllCustomEnvs = (env as any).applyToAllCustomEnvironments as boolean | undefined; if (listValue) { - return { key: env.key as string, value: listValue, - target: Array.isArray(env.target) - ? (env.target as string[]) - : [env.target].filter(Boolean) as string[], + target: normalizeTarget(env.target), type, isSecret, applyToAllCustomEnvironments: applyToAllCustomEnvs, }; } - // Value not in list response, try fetching with getSharedEnvVar try { logger.debug("Fetching decrypted value for shared env var", { teamId, @@ -573,17 +526,6 @@ export class VercelIntegrationRepository { teamId, }); - logger.debug("Got response for shared env var from getSharedEnvVar", { - teamId, - envId: env.id, - envKey: env.key, - hasValue: !!getResponse.value, - valueLength: getResponse.value?.length, - isDecrypted: (getResponse as any).decrypted, - responseKeys: Object.keys(getResponse), - }); - - // Skip if no value if (!getResponse.value) { return null; } @@ -599,8 +541,8 @@ export class VercelIntegrationRepository { return result; } catch (error) { - // Try to extract value from error.rawValue if it's a ResponseValidationError - // The API response is valid but SDK schema validation fails (e.g., deletedAt: null vs expected number) + // Workaround: Vercel SDK may throw ResponseValidationError even when the API response + // is valid (e.g., deletedAt: null vs expected number). Extract value from rawValue. let errorValue: string | undefined; if (error && typeof error === "object" && "rawValue" in error) { const rawValue = (error as any).rawValue; @@ -609,7 +551,6 @@ export class VercelIntegrationRepository { } } - // Use error.rawValue if available, otherwise fall back to listValue const fallbackValue = errorValue || listValue; if (fallbackValue) { @@ -625,16 +566,13 @@ export class VercelIntegrationRepository { return { key: env.key as string, value: fallbackValue, - target: Array.isArray(env.target) - ? (env.target as string[]) - : [env.target].filter(Boolean) as string[], + target: normalizeTarget(env.target), type, isSecret, applyToAllCustomEnvironments: applyToAllCustomEnvs, }; } - // No fallback value available, skip this env var logger.warn("Failed to get decrypted value for shared env var, no fallback available", { teamId, projectId, @@ -649,7 +587,6 @@ export class VercelIntegrationRepository { }) ); - // Filter out null results (failed fetches or sensitive types) const validResults = results.filter((r): r is NonNullable => r !== null); return validResults; @@ -683,17 +620,7 @@ export class VercelIntegrationRepository { })), }; } catch (error) { - const authInvalid = isVercelAuthError(error); - logger.error("Failed to fetch Vercel projects", { - teamId, - error, - authInvalid, - }); - return { - success: false, - authInvalid, - error: error instanceof Error ? error.message : "Unknown error", - }; + return vercelApiError("Failed to fetch Vercel projects", { teamId }, error); } } @@ -707,7 +634,6 @@ export class VercelIntegrationRepository { raw?: Record; }): Promise { await $transaction(prisma, async (tx) => { - // Get the existing integration to find the token reference const integration = await tx.organizationIntegration.findUnique({ where: { id: params.integrationId }, include: { tokenReference: true }, @@ -736,10 +662,8 @@ export class VercelIntegrationRepository { installationId: params.installationId, }); - // Update the secret with new token await secretStore.setSecret(integration.tokenReference.key, secretValue); - // Update integration metadata await tx.organizationIntegration.update({ where: { id: params.integrationId }, data: { @@ -817,31 +741,6 @@ export class VercelIntegrationRepository { return result; } - static async createVercelProjectIntegration(params: { - organizationIntegrationId: string; - projectId: string; - vercelProjectId: string; - vercelProjectName: string; - vercelTeamId: string | null; - installedByUserId?: string; - }) { - const integrationData = createDefaultVercelIntegrationData( - params.vercelProjectId, - params.vercelProjectName, - params.vercelTeamId - ); - - return prisma.organizationProjectIntegration.create({ - data: { - organizationIntegrationId: params.organizationIntegrationId, - projectId: params.projectId, - externalEntityId: params.vercelProjectId, - integrationData: integrationData, - installedBy: params.installedByUserId, - }, - }); - } - static async findVercelOrgIntegrationByTeamId( organizationId: string, teamId: string | null @@ -898,8 +797,6 @@ export class VercelIntegrationRepository { }); } - // Sync Trigger.dev API keys to Vercel as sensitive environment variables - // Production → production, Staging → custom env, Preview → preview, Development → development static async syncApiKeysToVercel(params: { projectId: string; vercelProjectId: string; @@ -937,31 +834,13 @@ export class VercelIntegrationRepository { }> = []; for (const env of environments) { - let vercelTarget: string[]; - - switch (env.type) { - case "PRODUCTION": - vercelTarget = ["production"]; - break; - case "STAGING": - // If no custom staging environment is mapped, skip staging sync - if (!params.vercelStagingEnvironment) { - logger.debug("Skipping staging API key sync - no custom environment mapped", { - projectId: params.projectId, - vercelProjectId: params.vercelProjectId, - }); - continue; - } - vercelTarget = [params.vercelStagingEnvironment.environmentId]; - break; - case "PREVIEW": - vercelTarget = ["preview"]; - break; - case "DEVELOPMENT": - vercelTarget = ["development"]; - break; - default: - continue; + const vercelTarget = envTypeToVercelTarget( + env.type as TriggerEnvironmentType, + params.vercelStagingEnvironment?.environmentId + ); + + if (!vercelTarget) { + continue; } envVarsToSync.push({ @@ -981,7 +860,6 @@ export class VercelIntegrationRepository { return { success: true, errors: [] }; } - // Use batch upsert to sync all env vars const result = await this.batchUpsertVercelEnvVars({ client, vercelProjectId: params.vercelProjectId, @@ -1021,14 +899,12 @@ export class VercelIntegrationRepository { } } - // Used when API keys are regenerated static async syncSingleApiKeyToVercel(params: { projectId: string; environmentType: "PRODUCTION" | "STAGING" | "PREVIEW" | "DEVELOPMENT"; apiKey: string; }): Promise<{ success: boolean; error?: string }> { try { - // Get the project integration const projectIntegration = await prisma.organizationProjectIntegration.findFirst({ where: { projectId: params.projectId, @@ -1048,7 +924,6 @@ export class VercelIntegrationRepository { }); if (!projectIntegration) { - // No Vercel integration - nothing to sync return { success: true }; } @@ -1056,33 +931,16 @@ export class VercelIntegrationRepository { const client = await this.getVercelClient(orgIntegration); const teamId = await this.getTeamIdFromIntegration(orgIntegration); - // Parse the integration data to get the staging environment mapping const integrationData = projectIntegration.integrationData as any; const vercelStagingEnvironment = integrationData?.config?.vercelStagingEnvironment; - let vercelTarget: string[]; + const vercelTarget = envTypeToVercelTarget( + params.environmentType, + vercelStagingEnvironment?.environmentId + ); - switch (params.environmentType) { - case "PRODUCTION": - vercelTarget = ["production"]; - break; - case "STAGING": - if (!vercelStagingEnvironment) { - logger.debug("Skipping staging API key sync - no custom environment mapped", { - projectId: params.projectId, - }); - return { success: true }; - } - vercelTarget = [vercelStagingEnvironment.environmentId]; - break; - case "PREVIEW": - vercelTarget = ["preview"]; - break; - case "DEVELOPMENT": - vercelTarget = ["development"]; - break; - default: - return { success: true }; + if (!vercelTarget) { + return { success: true }; } await this.upsertVercelEnvVar({ @@ -1114,8 +972,6 @@ export class VercelIntegrationRepository { } } - // Pull environment variables from Vercel and store them in Trigger.dev - // production → PRODUCTION, preview → PREVIEW, custom env → STAGING static async pullEnvVarsFromVercel(params: { projectId: string; vercelProjectId: string; @@ -1144,7 +1000,6 @@ export class VercelIntegrationRepository { }, }); - // Build environment mapping: Trigger.dev env type → Vercel target const envMapping: Array<{ triggerEnvType: "PRODUCTION" | "STAGING" | "PREVIEW" | "DEVELOPMENT"; vercelTarget: string; @@ -1152,39 +1007,20 @@ export class VercelIntegrationRepository { }> = []; for (const env of runtimeEnvironments) { - switch (env.type) { - case "PRODUCTION": - envMapping.push({ - triggerEnvType: "PRODUCTION", - vercelTarget: "production", - runtimeEnvironmentId: env.id, - }); - break; - case "PREVIEW": - envMapping.push({ - triggerEnvType: "PREVIEW", - vercelTarget: "preview", - runtimeEnvironmentId: env.id, - }); - break; - case "STAGING": - // Only map staging if a custom environment is configured - if (params.vercelStagingEnvironment) { - envMapping.push({ - triggerEnvType: "STAGING", - vercelTarget: params.vercelStagingEnvironment.environmentId, - runtimeEnvironmentId: env.id, - }); - } - break; - case "DEVELOPMENT": - envMapping.push({ - triggerEnvType: "DEVELOPMENT", - vercelTarget: "development", - runtimeEnvironmentId: env.id, - }); - break; + const vercelTarget = envTypeToVercelTarget( + env.type as TriggerEnvironmentType, + params.vercelStagingEnvironment?.environmentId + ); + + if (!vercelTarget) { + continue; } + + envMapping.push({ + triggerEnvType: env.type as "PRODUCTION" | "STAGING" | "PREVIEW" | "DEVELOPMENT", + vercelTarget: vercelTarget[0], + runtimeEnvironmentId: env.id, + }); } logger.info("Vercel pullEnvVarsFromVercel: environment mapping", { @@ -1227,7 +1063,6 @@ export class VercelIntegrationRepository { // Process each environment mapping for (const mapping of envMapping) { try { - // Fetch project-level env vars from Vercel for this target const projectEnvVars = await this.getVercelEnvironmentVariableValues( client, params.vercelProjectId, @@ -1235,21 +1070,15 @@ export class VercelIntegrationRepository { mapping.vercelTarget ); - // Filter shared env vars that target this environment const standardTargets = ["production", "preview", "development"]; const isCustomEnvironment = !standardTargets.includes(mapping.vercelTarget); const filteredSharedEnvVars = sharedEnvVars.filter((envVar) => { - // Check if this shared env var targets the current Vercel environment const matchesTarget = envVar.target.includes(mapping.vercelTarget); - - // Also include if applyToAllCustomEnvironments is true and this is a custom environment const matchesCustomEnv = isCustomEnvironment && envVar.applyToAllCustomEnvironments === true; - return matchesTarget || matchesCustomEnv; }); - // Merge project and shared env vars (project vars take precedence) const projectEnvVarKeys = new Set(projectEnvVars.map((v) => v.key)); const sharedEnvVarsToAdd = filteredSharedEnvVars.filter((v) => !projectEnvVarKeys.has(v.key)); const mergedEnvVars = [ @@ -1271,17 +1100,13 @@ export class VercelIntegrationRepository { mergedEnvVarKeys: mergedEnvVars.map(v => v.key), }); - // Filter env vars based on syncEnvVarsMapping and exclude TRIGGER_SECRET_KEY const varsToSync = mergedEnvVars.filter((envVar) => { - // Skip secrets (they don't have values anyway) if (envVar.isSecret) { return false; } - // Filter out TRIGGER_SECRET_KEY - these are managed by Trigger.dev if (envVar.key === "TRIGGER_SECRET_KEY") { return false; } - // Check if this var should be synced based on mapping for this environment return shouldSyncEnvVar( params.syncEnvVarsMapping, envVar.key, @@ -1301,8 +1126,6 @@ export class VercelIntegrationRepository { continue; } - // Query existing env vars to check which ones are already secrets and get their current values - // We need to preserve the secret status when overriding and skip unchanged values const existingSecretKeys = new Set(); const existingValues = new Map(); @@ -1331,7 +1154,6 @@ export class VercelIntegrationRepository { }, }); - // Get existing values from the secret store if (existingVarValues.length > 0) { const secretStore = getSecretStore("DATABASE", { prismaClient: prisma }); const SecretValue = z.object({ secret: z.string() }); @@ -1341,7 +1163,6 @@ export class VercelIntegrationRepository { existingSecretKeys.add(varValue.variable.key); } - // Fetch the current value from the secret store if (varValue.valueReference?.key) { try { const existingSecret = await secretStore.getSecret(SecretValue, varValue.valueReference.key); @@ -1355,10 +1176,8 @@ export class VercelIntegrationRepository { } } - // Filter out vars that haven't changed const changedVars = varsToSync.filter((v) => { const existingValue = existingValues.get(v.key); - // Include if: no existing value, or value is different return existingValue === undefined || existingValue !== v.value; }); @@ -1381,11 +1200,9 @@ export class VercelIntegrationRepository { changedKeys: changedVars.map((v) => v.key), }); - // Split vars into secret and non-secret groups const secretVars = changedVars.filter((v) => existingSecretKeys.has(v.key)); const nonSecretVars = changedVars.filter((v) => !existingSecretKeys.has(v.key)); - // Create non-secret vars if (nonSecretVars.length > 0) { const result = await envVarRepository.create(params.projectId, { override: true, @@ -1417,12 +1234,11 @@ export class VercelIntegrationRepository { } } - // Create secret vars (preserve their secret status) if (secretVars.length > 0) { const result = await envVarRepository.create(params.projectId, { override: true, environmentIds: [mapping.runtimeEnvironmentId], - isSecret: true, // Preserve secret status + isSecret: true, variables: secretVars.map((v) => ({ key: v.key, value: v.value, @@ -1481,7 +1297,6 @@ export class VercelIntegrationRepository { } } - // Batch create or update environment variables in Vercel static async batchUpsertVercelEnvVars(params: { client: Vercel; vercelProjectId: string; @@ -1503,7 +1318,6 @@ export class VercelIntegrationRepository { return { created: 0, updated: 0, errors: [] }; } - // Fetch all existing env vars once const existingEnvs = await client.projects.filterProjectEnvs({ idOrName: vercelProjectId, ...(teamId && { teamId }), @@ -1512,7 +1326,6 @@ export class VercelIntegrationRepository { const existingEnvsList = "envs" in existingEnvs && Array.isArray(existingEnvs.envs) ? existingEnvs.envs : []; - // Separate env vars into ones that need to be created vs updated const toCreate: Array<{ key: string; value: string; @@ -1530,7 +1343,6 @@ export class VercelIntegrationRepository { }> = []; for (const envVar of envVars) { - // Find existing env var with matching key AND target const existingEnv = existingEnvsList.find((env: any) => { if (env.key !== envVar.key) { return false; @@ -1561,7 +1373,6 @@ export class VercelIntegrationRepository { } } - // Batch create new env vars (Vercel supports array in request body) if (toCreate.length > 0) { try { await client.projects.createProjectEnv({ @@ -1617,7 +1428,6 @@ export class VercelIntegrationRepository { return { created, updated, errors }; } - // Create or update an environment variable in Vercel private static async upsertVercelEnvVar(params: { client: Vercel; vercelProjectId: string; @@ -1629,7 +1439,6 @@ export class VercelIntegrationRepository { }): Promise { const { client, vercelProjectId, teamId, key, value, target, type } = params; - // First, check if the env var already exists for this target const existingEnvs = await client.projects.filterProjectEnvs({ idOrName: vercelProjectId, ...(teamId && { teamId }), @@ -1639,20 +1448,16 @@ export class VercelIntegrationRepository { ? existingEnvs.envs : []; - // Find existing env var with matching key AND target // Vercel can have multiple env vars with the same key but different targets const existingEnv = envs.find((env: any) => { if (env.key !== key) { return false; } - // Check if the targets match (env var targets this specific environment) const envTargets = normalizeTarget(env.target); - // Match if the targets are exactly the same return target.length === envTargets.length && target.every((t) => envTargets.includes(t)); }); if (existingEnv && existingEnv.id) { - // Update existing env var await client.projects.editProjectEnv({ idOrName: vercelProjectId, id: existingEnv.id, @@ -1664,7 +1469,6 @@ export class VercelIntegrationRepository { }, }); } else { - // Create new env var await client.projects.createProjectEnv({ idOrName: vercelProjectId, ...(teamId && { teamId }), @@ -1678,18 +1482,13 @@ export class VercelIntegrationRepository { } } - /** - * Get the autoAssignCustomDomains setting for a Vercel project. - * Returns true if auto-assign is enabled, false if disabled, null on error. - */ static async getAutoAssignCustomDomains( client: Vercel, vercelProjectId: string, teamId?: string | null ): Promise { try { - // The Vercel SDK doesn't have a getProject method, so we use updateProject - // with an empty requestBody to read the project data without changing anything. + // Vercel SDK lacks a getProject method — updateProject with empty body reads without modifying. const project = await client.projects.updateProject({ idOrName: vercelProjectId, ...(teamId && { teamId }), @@ -1707,11 +1506,7 @@ export class VercelIntegrationRepository { } } - /** - * Disable autoAssignCustomDomains on a Vercel project. - * This is required for atomic deployments — prevents Vercel from auto-promoting - * deployments before TRIGGER_VERSION is set. - */ + /** Disable autoAssignCustomDomains — required for atomic deployments. */ static async disableAutoAssignCustomDomains( client: Vercel, vercelProjectId: string, @@ -1769,9 +1564,7 @@ export class VercelIntegrationRepository { error: error instanceof Error ? error.message : "Unknown error", isAuthError, }); - - // If it's an auth error (401/403), we should still clean up our side - // but return the flag so caller knows the token is invalid + // Auth errors (401/403): still clean up on our side, return flag for caller if (isAuthError) { return { authInvalid: true }; } diff --git a/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts b/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts index 2469c0f4b8..d81df0f440 100644 --- a/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts @@ -149,7 +149,6 @@ export class VercelSettingsPresenter extends BasePresenter { }) ).map((repo) => repo !== null); - // Check if staging environment exists const checkStagingEnvironment = () => fromPromise( (this._replica as PrismaClient).runtimeEnvironment.findFirst({ @@ -167,7 +166,6 @@ export class VercelSettingsPresenter extends BasePresenter { }) ).map((env) => env !== null); - // Check if preview environment exists const checkPreviewEnvironment = () => fromPromise( (this._replica as PrismaClient).runtimeEnvironment.findFirst({ @@ -185,7 +183,6 @@ export class VercelSettingsPresenter extends BasePresenter { }) ).map((env) => env !== null); - // Get Vercel project integration const getVercelProjectIntegration = () => fromPromise( (this._replica as PrismaClient).organizationProjectIntegration.findFirst({ @@ -325,7 +322,6 @@ export class VercelSettingsPresenter extends BasePresenter { vercelEnvironmentId?: string ): Promise { try { - // Fetch GitHub app installations and connected repo in parallel with Vercel data const [gitHubInstallations, connectedGitHubRepo] = await Promise.all([ (this._replica as PrismaClient).githubAppInstallation.findMany({ where: { @@ -417,7 +413,6 @@ export class VercelSettingsPresenter extends BasePresenter { const client = await VercelIntegrationRepository.getVercelClient(orgIntegration); const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration); - // Get the project integration to find the Vercel project ID (if selected) const projectIntegration = await (this._replica as PrismaClient).organizationProjectIntegration.findFirst({ where: { projectId, @@ -429,7 +424,6 @@ export class VercelSettingsPresenter extends BasePresenter { }, }); - // Always fetch available projects for selection const availableProjectsResult = await VercelIntegrationRepository.getVercelProjects(client, teamId); if (!availableProjectsResult.success) { @@ -445,7 +439,6 @@ export class VercelSettingsPresenter extends BasePresenter { }; } - // If no project integration exists, return early with just available projects if (!projectIntegration) { return { customEnvironments: [], @@ -458,7 +451,6 @@ export class VercelSettingsPresenter extends BasePresenter { }; } - // Fetch custom environments, project env vars, and shared env vars in parallel const [customEnvironmentsResult, projectEnvVarsResult, sharedEnvVarsResult] = await Promise.all([ VercelIntegrationRepository.getVercelCustomEnvironments( client, @@ -479,8 +471,7 @@ export class VercelSettingsPresenter extends BasePresenter { ) : Promise.resolve({ success: true as const, data: [] }), ]); - // Check if any of the API calls failed due to auth issues - const authInvalid = + const authInvalid = (!customEnvironmentsResult.success && customEnvironmentsResult.authInvalid) || (!projectEnvVarsResult.success && projectEnvVarsResult.authInvalid) || (!sharedEnvVarsResult.success && sharedEnvVarsResult.authInvalid); @@ -498,20 +489,17 @@ export class VercelSettingsPresenter extends BasePresenter { }; } - // Extract data from successful results const customEnvironments = customEnvironmentsResult.success ? customEnvironmentsResult.data : []; const projectEnvVars = projectEnvVarsResult.success ? projectEnvVarsResult.data : []; const sharedEnvVars = sharedEnvVarsResult.success ? sharedEnvVarsResult.data : []; - // Merge project and shared env vars (project vars take precedence) - // Also filter out TRIGGER_SECRET_KEY as it's managed by Trigger.dev + // Filter out TRIGGER_SECRET_KEY (managed by Trigger.dev) and merge project + shared env vars const projectEnvVarKeys = new Set(projectEnvVars.map((v) => v.key)); const mergedEnvVars: VercelEnvironmentVariable[] = [ ...projectEnvVars .filter((v) => v.key !== "TRIGGER_SECRET_KEY") .map((v) => { const envVar = { ...v }; - // Check if this env var is used in the selected custom environment if (vercelEnvironmentId && (v as any).customEnvironmentIds?.includes(vercelEnvironmentId)) { envVar.target = [...v.target, 'staging']; } @@ -528,7 +516,6 @@ export class VercelSettingsPresenter extends BasePresenter { target: v.target, isShared: true, }; - // Check if this shared env var is used in the selected custom environment if (vercelEnvironmentId && (v as any).customEnvironmentIds?.includes(vercelEnvironmentId)) { envVar.target = [...v.target, 'staging']; } @@ -536,13 +523,10 @@ export class VercelSettingsPresenter extends BasePresenter { }), ]; - // Sort environment variables alphabetically const sortedEnvVars = [...mergedEnvVars].sort((a, b) => a.key.localeCompare(b.key) ); - // Get existing environment variables in Trigger.dev - // Fetch environments with their slugs and archived status to filter properly const projectEnvs = await (this._replica as PrismaClient).runtimeEnvironment.findMany({ where: { projectId, diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx index f2115e86b3..f7498cee4c 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx @@ -3,15 +3,12 @@ import { parse } from "@conform-to/zod"; import { CheckCircleIcon, ExclamationTriangleIcon, - ChevronDownIcon, - ChevronUpIcon, } from "@heroicons/react/20/solid"; import { Form, useActionData, useFetcher, useNavigation, - useSearchParams, useLocation, } from "@remix-run/react"; import { @@ -28,34 +25,15 @@ import { Callout } from "~/components/primitives/Callout"; import { Fieldset } from "~/components/primitives/Fieldset"; import { FormButtons } from "~/components/primitives/FormButtons"; import { FormError } from "~/components/primitives/FormError"; -import { Header3 } from "~/components/primitives/Headers"; import { Hint } from "~/components/primitives/Hint"; import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; import { Paragraph } from "~/components/primitives/Paragraph"; import { Select, SelectItem } from "~/components/primitives/Select"; import { SpinnerWhite } from "~/components/primitives/Spinner"; -import { Switch } from "~/components/primitives/Switch"; -import { TextLink } from "~/components/primitives/TextLink"; import { DateTime } from "~/components/primitives/DateTime"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, - TooltipProvider -} from "~/components/primitives/Tooltip"; import { VercelLogo } from "~/components/integrations/VercelLogo"; import { BuildSettingsFields } from "~/components/integrations/VercelBuildSettings"; -import { - EnvironmentIcon, - environmentFullTitle, - environmentTextClassName, -} from "~/components/environments/EnvironmentLabel"; -import { OctoKitty } from "~/components/GitHubLoginButton"; -import { - ConnectGitHubRepoModal, - type GitHubAppInstallation, -} from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github"; import { redirectBackWithErrorMessage, redirectWithSuccessMessage, @@ -65,24 +43,23 @@ import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; -import { EnvironmentParamSchema, v3ProjectSettingsPath, vercelAppInstallPath, githubAppInstallPath } from "~/utils/pathBuilder"; +import { EnvironmentParamSchema, v3ProjectSettingsPath, vercelAppInstallPath } from "~/utils/pathBuilder"; import { VercelSettingsPresenter, type VercelOnboardingData, } from "~/presenters/v3/VercelSettingsPresenter.server"; import { VercelIntegrationService } from "~/services/vercelIntegration.server"; -import { - VercelIntegrationRepository, - type VercelCustomEnvironment, -} from "~/models/vercelIntegration.server"; +import { VercelIntegrationRepository } from "~/models/vercelIntegration.server"; import { type VercelProjectIntegrationData, type SyncEnvVarsMapping, type EnvSlug, - shouldSyncEnvVarForAnyEnvironment, + jsonArrayField, envTypeToSlug, + getAvailableEnvSlugs, + getAvailableEnvSlugsForBuildSettings, } from "~/v3/vercel/vercelProjectIntegrationSchema"; -import { useEffect, useState, useCallback, useRef } from "react"; +import { useEffect, useState } from "react"; export type ConnectedVercelProject = { id: string; @@ -93,19 +70,6 @@ export type ConnectedVercelProject = { createdAt: Date; }; -function formatVercelTargets(targets: string[]): string { - const targetLabels: Record = { - production: "Production", - preview: "Preview", - development: "Development", - staging: "Staging", - }; - - return targets - .map((t) => targetLabels[t.toLowerCase()] || t) - .join(", "); -} - function parseVercelStagingEnvironment( value: string | null | undefined ): { environmentId: string; displayName: string } | null { @@ -121,37 +85,11 @@ function parseVercelStagingEnvironment( } } -const EnvSlugSchema = z.enum(["dev", "stg", "prod", "preview"]); - const UpdateVercelConfigFormSchema = z.object({ action: z.literal("update-config"), - atomicBuilds: z.string().optional().transform((val) => { - if (!val) return null; - try { - const parsed = JSON.parse(val); - return Array.isArray(parsed) ? parsed : null; - } catch { - return null; - } - }), - pullEnvVarsBeforeBuild: z.string().optional().transform((val) => { - if (!val) return null; - try { - const parsed = JSON.parse(val); - return Array.isArray(parsed) ? parsed : null; - } catch { - return null; - } - }), - discoverEnvVars: z.string().optional().transform((val) => { - if (!val) return null; - try { - const parsed = JSON.parse(val); - return Array.isArray(parsed) ? parsed : null; - } catch { - return null; - } - }), + atomicBuilds: jsonArrayField, + pullEnvVarsBeforeBuild: jsonArrayField, + discoverEnvVars: jsonArrayField, vercelStagingEnvironment: z.string().nullable().optional(), }); @@ -162,36 +100,11 @@ const DisconnectVercelFormSchema = z.object({ const CompleteOnboardingFormSchema = z.object({ action: z.literal("complete-onboarding"), vercelStagingEnvironment: z.string().nullable().optional(), - pullEnvVarsBeforeBuild: z.string().optional().transform((val) => { - if (!val) return null; - try { - const parsed = JSON.parse(val); - return Array.isArray(parsed) ? parsed : null; - } catch { - return null; - } - }), - atomicBuilds: z.string().optional().transform((val) => { - if (!val) return null; - try { - const parsed = JSON.parse(val); - return Array.isArray(parsed) ? parsed : null; - } catch { - return null; - } - }), - discoverEnvVars: z.string().optional().transform((val) => { - if (!val) return null; - try { - const parsed = JSON.parse(val); - return Array.isArray(parsed) ? parsed : null; - } catch { - return null; - } - }), - syncEnvVarsMapping: z.string().optional(), // JSON-encoded mapping + pullEnvVarsBeforeBuild: jsonArrayField, + atomicBuilds: jsonArrayField, + discoverEnvVars: jsonArrayField, + syncEnvVarsMapping: z.string().optional(), next: z.string().optional(), - // When true, returns JSON instead of redirecting (used when transitioning to github-connection step) skipRedirect: z.string().optional().transform((val) => val === "true"), }); @@ -320,7 +233,6 @@ export async function action({ request, params }: ActionFunctionArgs) { const vercelService = new VercelIntegrationService(); const { action: actionType } = submission.value; - // Handle update-config action if (actionType === "update-config") { const { atomicBuilds, @@ -345,7 +257,6 @@ export async function action({ request, params }: ActionFunctionArgs) { return redirectWithErrorMessage(settingsPath, request, "Failed to update Vercel settings"); } - // Handle disconnect action if (actionType === "disconnect") { const success = await vercelService.disconnectVercelProject(project.id); @@ -356,7 +267,6 @@ export async function action({ request, params }: ActionFunctionArgs) { return redirectWithErrorMessage(settingsPath, request, "Failed to disconnect Vercel project"); } - // Handle complete-onboarding action if (actionType === "complete-onboarding") { const { vercelStagingEnvironment, @@ -398,36 +308,28 @@ export async function action({ request, params }: ActionFunctionArgs) { }); if (result) { - // If skipRedirect is true, return success without redirect (used when transitioning to github-connection step) if (skipRedirect) { return json({ success: true }); } - // Check if we should redirect to the 'next' URL if (next) { try { - // Validate that next is a valid URL const nextUrl = new URL(next); // Only allow https URLs for security if (nextUrl.protocol === "https:") { - // Return JSON with redirect URL for fetcher to handle return json({ success: true, redirectTo: next }); } } catch (e) { - // Invalid URL, fall through to default redirect logger.warn("Invalid next URL provided", { next, error: e }); } } - // Default redirect to settings page without the vercelOnboarding param to close the modal - // Return JSON with redirect URL for fetcher to handle return json({ success: true, redirectTo: settingsPath }); } return redirectWithErrorMessage(settingsPath, request, "Failed to complete Vercel setup"); } - // Handle update-env-mapping action (during onboarding) if (actionType === "update-env-mapping") { const { vercelStagingEnvironment } = submission.value; @@ -444,12 +346,10 @@ export async function action({ request, params }: ActionFunctionArgs) { return json({ success: false, error: "Failed to update environment mapping" }, { status: 400 }); } - // Handle skip-onboarding action if (actionType === "skip-onboarding") { return redirectWithSuccessMessage(settingsPath, request, "Vercel integration setup skipped"); } - // Handle select-vercel-project action if (actionType === "select-vercel-project") { const { vercelProjectId, vercelProjectName } = submission.value; @@ -468,10 +368,8 @@ export async function action({ request, params }: ActionFunctionArgs) { vercelProjectId, errors: syncResult.errors, }); - // Still proceed - user can manually configure API keys } - // Return success to allow the onboarding flow to continue return json({ success: true, integrationId: integration.id, @@ -485,7 +383,6 @@ export async function action({ request, params }: ActionFunctionArgs) { } } - // Handle disable-auto-assign action if (actionType === "disable-auto-assign") { try { const orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationForProject( @@ -651,8 +548,6 @@ function VercelGitHubWarning() { ); } -const ALL_ENV_SLUGS: EnvSlug[] = ["prod", "stg", "preview", "dev"]; - function envSlugLabel(slug: EnvSlug): string { switch (slug) { case "prod": @@ -697,7 +592,6 @@ function ConnectedVercelProjectForm({ connectedProject.integrationData.config.vercelStagingEnvironment ?? null, }); - // Track original values for comparison const originalAtomicBuilds = connectedProject.integrationData.config.atomicBuilds ?? []; const originalPullEnvVars = connectedProject.integrationData.config.pullEnvVarsBeforeBuild ?? []; const originalDiscoverEnvVars = connectedProject.integrationData.config.discoverEnvVars ?? []; @@ -735,17 +629,9 @@ function ConnectedVercelProjectForm({ const actionUrl = vercelResourcePath(organizationSlug, projectSlug, environmentSlug); - // Filter out environments that don't exist for this project - const availableEnvSlugs = ALL_ENV_SLUGS.filter((s) => { - if (s === "stg" && !hasStagingEnvironment) return false; - if (s === "preview" && !hasPreviewEnvironment) return false; - return true; - }); - - // For pull env vars and atomic deployments, exclude "dev" (not needed for development) - const availableEnvSlugsForBuildSettings = availableEnvSlugs.filter((s) => s !== "dev"); + const availableEnvSlugs = getAvailableEnvSlugs(hasStagingEnvironment, hasPreviewEnvironment); + const availableEnvSlugsForBuildSettings = getAvailableEnvSlugsForBuildSettings(hasStagingEnvironment, hasPreviewEnvironment); - // Format selected environments for display const formatSelectedEnvs = (selected: EnvSlug[], availableSlugs: EnvSlug[] = availableEnvSlugs): string => { if (selected.length === 0) return "None selected"; if (selected.length === availableSlugs.length) return "All environments"; @@ -754,7 +640,6 @@ function ConnectedVercelProjectForm({ return ( <> - {/* Connected project info */}
@@ -1058,1072 +943,7 @@ function VercelSettingsPanel({ ); } -type OnboardingState = - | "idle" // Initial state - | "installing" // Redirecting to Vercel installation (transient) - | "loading-projects" // Loading Vercel projects list - | "project-selection" // Showing project selection UI - | "loading-env-mapping" // After project selection, checking for custom envs - | "env-mapping" // Showing custom environment mapping UI - | "loading-env-vars" // Loading environment variables - | "env-var-sync" // Showing environment variable sync UI (one-time sync now) - | "build-settings" // Configure pullEnvVarsBeforeBuild and atomicBuilds - | "github-connection" // Connect GitHub repository - | "completed"; // Onboarding complete (closes modal) - -function VercelOnboardingModal({ - isOpen, - onClose, - onboardingData, - organizationSlug, - projectSlug, - environmentSlug, - hasStagingEnvironment, - hasPreviewEnvironment, - hasOrgIntegration, - nextUrl, - onDataReload, -}: { - isOpen: boolean; - onClose: () => void; - onboardingData: VercelOnboardingData | null; - organizationSlug: string; - projectSlug: string; - environmentSlug: string; - hasStagingEnvironment: boolean; - hasPreviewEnvironment: boolean; - hasOrgIntegration: boolean; - nextUrl?: string; - onDataReload?: (vercelStagingEnvironment?: string) => void; -}) { - const navigation = useNavigation(); - const fetcher = useTypedFetcher(); - const envMappingFetcher = useFetcher(); - const completeOnboardingFetcher = useFetcher(); - const { Form: CompleteOnboardingForm } = completeOnboardingFetcher; - const [searchParams] = useSearchParams(); - const fromMarketplaceContext = searchParams.get("origin") === "marketplace"; - - const availableProjects = onboardingData?.availableProjects || []; - const hasProjectSelected = onboardingData?.hasProjectSelected ?? false; - const customEnvironments = onboardingData?.customEnvironments || []; - const envVars = onboardingData?.environmentVariables || []; - const existingVars = onboardingData?.existingVariables || {}; - const hasCustomEnvs = customEnvironments.length > 0 && hasStagingEnvironment; - - const computeInitialState = useCallback((): OnboardingState => { - if (!hasOrgIntegration || onboardingData?.authInvalid) { - return "idle"; - } - const projectSelected = onboardingData?.hasProjectSelected ?? false; - if (!projectSelected) { - if (!onboardingData?.availableProjects || onboardingData.availableProjects.length === 0) { - return "loading-projects"; - } - return "project-selection"; - } - // For marketplace origin, skip env-mapping step and go directly to env-var-sync - if (!fromMarketplaceContext) { - const customEnvs = (onboardingData?.customEnvironments?.length ?? 0) > 0 && hasStagingEnvironment; - if (customEnvs) { - return "env-mapping"; - } - } - if (!onboardingData?.environmentVariables || onboardingData.environmentVariables.length === 0) { - return "loading-env-vars"; - } - return "env-var-sync"; - }, [hasOrgIntegration, onboardingData, hasStagingEnvironment, fromMarketplaceContext]); - - // Initialize state based on current data when modal opens - const [state, setState] = useState(() => { - if (!isOpen) return "idle"; - return computeInitialState(); - }); - - // Update state when modal opens or data changes - const prevIsOpenRef = useRef(isOpen); - // Track if we've synced staging/preview for pull env vars (reset when modal reopens) - const hasSyncedStagingRef = useRef(false); - const hasSyncedPreviewRef = useRef(false); - useEffect(() => { - if (isOpen && !prevIsOpenRef.current) { - // Modal just opened, compute initial state and reset sync flags - setState(computeInitialState()); - hasSyncedStagingRef.current = false; - hasSyncedPreviewRef.current = false; - } else if (isOpen && state === "idle") { - // Modal is open but in idle state, compute initial state - setState(computeInitialState()); - } - prevIsOpenRef.current = isOpen; - }, [isOpen, state, computeInitialState]); - - const [selectedVercelProject, setSelectedVercelProject] = useState<{ - id: string; - name: string; - } | null>(null); - const [vercelStagingEnvironment, setVercelStagingEnvironment] = useState<{ - environmentId: string; - displayName: string; - } | null>(null); - // Available env slugs based on staging and preview environment existence - const availableEnvSlugsForOnboarding: EnvSlug[] = ALL_ENV_SLUGS.filter((s) => { - if (s === "stg" && !hasStagingEnvironment) return false; - if (s === "preview" && !hasPreviewEnvironment) return false; - return true; - }); - // For build settings (pull env vars and atomic deployments), exclude "dev" (not needed for development) - const availableEnvSlugsForOnboardingBuildSettings: EnvSlug[] = availableEnvSlugsForOnboarding.filter( - (s) => s !== "dev" - ); - // Build settings state (for build-settings step) - // Default: pull env vars and atomic builds enabled for all non-dev environments - const [pullEnvVarsBeforeBuild, setPullEnvVarsBeforeBuild] = useState( - () => availableEnvSlugsForOnboardingBuildSettings - ); - const [atomicBuilds, setAtomicBuilds] = useState( - () => ["prod"] - ); - const [discoverEnvVars, setDiscoverEnvVars] = useState( - () => availableEnvSlugsForOnboardingBuildSettings - ); - - // Sync pullEnvVarsBeforeBuild and discoverEnvVars when hasStagingEnvironment becomes true (once) - // This ensures staging is included when it becomes available, but respects user changes after - useEffect(() => { - if (hasStagingEnvironment && !hasSyncedStagingRef.current) { - hasSyncedStagingRef.current = true; - setPullEnvVarsBeforeBuild((prev) => { - if (!prev.includes("stg")) { - return [...prev, "stg"]; - } - return prev; - }); - setDiscoverEnvVars((prev) => { - if (!prev.includes("stg")) { - return [...prev, "stg"]; - } - return prev; - }); - } - }, [hasStagingEnvironment]); - - // Sync pullEnvVarsBeforeBuild and discoverEnvVars when hasPreviewEnvironment becomes true (once) - // This ensures preview is included when it becomes available, but respects user changes after - useEffect(() => { - if (hasPreviewEnvironment && !hasSyncedPreviewRef.current) { - hasSyncedPreviewRef.current = true; - setPullEnvVarsBeforeBuild((prev) => { - if (!prev.includes("preview")) { - return [...prev, "preview"]; - } - return prev; - }); - setDiscoverEnvVars((prev) => { - if (!prev.includes("preview")) { - return [...prev, "preview"]; - } - return prev; - }); - } - }, [hasPreviewEnvironment]); - // Env var sync state (for env-var-sync step - one-time sync) - const [syncEnvVarsMapping, setSyncEnvVarsMapping] = useState({}); - const [expandedEnvVars, setExpandedEnvVars] = useState(false); - const [expandedSecretEnvVars, setExpandedSecretEnvVars] = useState(false); - const [projectSelectionError, setProjectSelectionError] = useState(null); - - // GitHub connection state (for github-connection step) - const gitHubAppInstallations = onboardingData?.gitHubAppInstallations ?? []; - const isGitHubConnectedForOnboarding = onboardingData?.isGitHubConnected ?? false; - - // Track if we've triggered a redirect for marketplace completion - const hasTriggeredMarketplaceRedirectRef = useRef(false); - - // Auto-redirect for marketplace flow when returning from GitHub with everything complete - useEffect(() => { - // Only trigger once per session to prevent redirect loops - if (hasTriggeredMarketplaceRedirectRef.current) { - return; - } - - // Check if all conditions are met for auto-redirect: - // - Modal is open - // - Coming from marketplace - // - Has nextUrl to redirect to - // - Project is already connected (onboarding settings saved) - // - GitHub is now connected - if ( - isOpen && - fromMarketplaceContext && - nextUrl && - hasProjectSelected && - isGitHubConnectedForOnboarding - ) { - hasTriggeredMarketplaceRedirectRef.current = true; - // Small delay to ensure state is settled before redirect - setTimeout(() => { - window.location.href = nextUrl; - }, 100); - } - }, [isOpen, fromMarketplaceContext, nextUrl, hasProjectSelected, isGitHubConnectedForOnboarding]); - - // Reset the redirect ref when modal closes - useEffect(() => { - if (!isOpen) { - hasTriggeredMarketplaceRedirectRef.current = false; - } - }, [isOpen]); - - // Track if we've triggered a reload for the current loading state to prevent infinite loops - const loadingStateRef = useRef(null); - - useEffect(() => { - if (!isOpen || state === "idle") { - loadingStateRef.current = null; - return; - } - - if (onboardingData?.authInvalid) { - onClose(); - return; - } - - // Skip if we've already triggered a reload for this state - if (loadingStateRef.current === state) { - return; - } - - switch (state) { - - case "loading-projects": - // Trigger data reload to fetch projects - loadingStateRef.current = state; - if (onDataReload) { - onDataReload(); - } - // Transition will happen when data loads (handled by another effect) - break; - - case "loading-env-mapping": - // After project selection, reload data to get custom environments - loadingStateRef.current = state; - if (onDataReload) { - onDataReload(); - } - // Transition handled by button click success - break; - - case "loading-env-vars": - // Reload data to get environment variables - loadingStateRef.current = state; - if (onDataReload) { - onDataReload(vercelStagingEnvironment?.environmentId || undefined); - } - // Transition to env-var-sync when data is ready (handled by another effect) - break; - - // Other states don't need processing - case "installing": - case "project-selection": - case "env-mapping": - case "env-var-sync": - case "completed": - case "build-settings": - case "github-connection": - loadingStateRef.current = null; - break; - } - }, [isOpen, state, onboardingData?.authInvalid, vercelStagingEnvironment, onDataReload, onClose]); - - // Watch for data loading completion - useEffect(() => { - if (!onboardingData?.authInvalid && state === "loading-projects" && onboardingData?.availableProjects !== undefined) { - // Projects loaded (whether empty or not), transition to project selection - setState("project-selection"); - } - }, [state, onboardingData?.availableProjects, onboardingData?.authInvalid]); - - useEffect(() => { - if (!onboardingData?.authInvalid && state === "loading-env-vars" && onboardingData?.environmentVariables) { - // Environment variables loaded, transition to env-var-sync - setState("env-var-sync"); - } - }, [state, onboardingData?.environmentVariables, onboardingData?.authInvalid]); - - // Handle successful project selection - transition to loading-env-mapping - useEffect(() => { - if (state === "project-selection" && fetcher.data && "success" in fetcher.data && fetcher.data.success && fetcher.state === "idle") { - // Project selection succeeded, transition to loading-env-mapping - setState("loading-env-mapping"); - // Reload data to get updated project info and env vars - if (onDataReload) { - console.log("Vercel onboarding: Reloading data after successful project selection to get updated project info and env vars"); - onDataReload(); - } - } else if (fetcher.data && "error" in fetcher.data && typeof fetcher.data.error === "string") { - setProjectSelectionError(fetcher.data.error); - } - }, [state, fetcher.data, fetcher.state, onDataReload]); - - // Handle loading-env-mapping completion - check for custom environments - // For marketplace origin, skip env-mapping step - useEffect(() => { - if (state === "loading-env-mapping" && onboardingData) { - const hasCustomEnvs = (onboardingData.customEnvironments?.length ?? 0) > 0 && hasStagingEnvironment; - if (hasCustomEnvs && !fromMarketplaceContext) { - setState("env-mapping"); - } else { - // No custom envs or marketplace flow, load env vars - setState("loading-env-vars"); - } - } - }, [state, onboardingData, hasStagingEnvironment]); - - // Calculate env var stats - const secretEnvVars = envVars.filter((v) => v.isSecret); - const syncableEnvVars = envVars.filter((v) => !v.isSecret); - const enabledEnvVars = syncableEnvVars.filter( - (v) => shouldSyncEnvVarForAnyEnvironment(syncEnvVarsMapping, v.key) - ); - - const overlappingEnvVarsCount = enabledEnvVars.filter((v) => existingVars[v.key]).length; - - const isSubmitting = - navigation.state === "submitting" || navigation.state === "loading"; - - const actionUrl = vercelResourcePath(organizationSlug, projectSlug, environmentSlug); - - // Toggle individual env var for the one-time sync - const handleToggleEnvVar = useCallback((key: string, enabled: boolean) => { - setSyncEnvVarsMapping((prev) => { - const newMapping = { ...prev }; - - if (enabled) { - // Remove this key from all environment mappings (default is enabled) - for (const envSlug of ALL_ENV_SLUGS) { - if (newMapping[envSlug]) { - const { [key]: _, ...rest } = newMapping[envSlug]; - if (Object.keys(rest).length === 0) { - delete newMapping[envSlug]; - } else { - newMapping[envSlug] = rest; - } - } - } - } else { - // Disable for all environments - for (const envSlug of ALL_ENV_SLUGS) { - newMapping[envSlug] = { - ...(newMapping[envSlug] || {}), - [key]: false, - }; - } - } - - return newMapping; - }); - }, []); - - // Toggle all env vars for the one-time sync (select/deselect all) - const handleToggleAllEnvVars = useCallback( - (enabled: boolean, syncableVars: Array<{ key: string }>) => { - if (enabled) { - // Reset all mappings (default to sync all) - setSyncEnvVarsMapping({}); - } else { - // Disable all syncable vars for all environments - const newMapping: SyncEnvVarsMapping = {}; - for (const envSlug of ALL_ENV_SLUGS) { - newMapping[envSlug] = {}; - for (const v of syncableVars) { - newMapping[envSlug][v.key] = false; - } - } - setSyncEnvVarsMapping(newMapping); - } - }, - [] - ); - - const handleProjectSelection = useCallback(async () => { - if (!selectedVercelProject) { - setProjectSelectionError("Please select a Vercel project"); - return; - } - - setProjectSelectionError(null); - - const formData = new FormData(); - formData.append("action", "select-vercel-project"); - formData.append("vercelProjectId", selectedVercelProject.id); - formData.append("vercelProjectName", selectedVercelProject.name); - - fetcher.submit(formData, { - method: "post", - action: actionUrl, - }); - }, [selectedVercelProject, fetcher, actionUrl]); - - const handleSkipOnboarding = useCallback(() => { - onClose(); - - if (fromMarketplaceContext) { - return window.close(); - } - - const formData = new FormData(); - formData.append("action", "skip-onboarding"); - fetcher.submit(formData, { - method: "post", - action: actionUrl, - }); - }, [actionUrl, fetcher, onClose, nextUrl, fromMarketplaceContext]); - - const handleSkipEnvMapping = useCallback(() => { - // Skip the env mapping step and go directly to loading env vars - setVercelStagingEnvironment(null); - setState("loading-env-vars"); - }, []); - - const handleUpdateEnvMapping = useCallback(() => { - if (!vercelStagingEnvironment) { - setState("loading-env-vars"); - return; - } - - // Save the environment mapping first - const formData = new FormData(); - formData.append("action", "update-env-mapping"); - formData.append("vercelStagingEnvironment", JSON.stringify(vercelStagingEnvironment)); - - envMappingFetcher.submit(formData, { - method: "post", - action: actionUrl, - }); - - }, [vercelStagingEnvironment, envMappingFetcher, actionUrl]); - - const handleBuildSettingsNext = useCallback(() => { - // Build the form data to complete onboarding (save settings and sync env vars) - const formData = new FormData(); - formData.append("action", "complete-onboarding"); - formData.append("vercelStagingEnvironment", vercelStagingEnvironment ? JSON.stringify(vercelStagingEnvironment) : ""); - formData.append("pullEnvVarsBeforeBuild", JSON.stringify(pullEnvVarsBeforeBuild)); - formData.append("atomicBuilds", JSON.stringify(atomicBuilds)); - formData.append("discoverEnvVars", JSON.stringify(discoverEnvVars)); - formData.append("syncEnvVarsMapping", JSON.stringify(syncEnvVarsMapping)); - if (nextUrl && fromMarketplaceContext && isGitHubConnectedForOnboarding) { - formData.append("next", nextUrl); - } - - // If GitHub is not connected, skip redirect to stay on modal and transition to github-connection step - if (!isGitHubConnectedForOnboarding) { - formData.append("skipRedirect", "true"); - } - - completeOnboardingFetcher.submit(formData, { - method: "post", - action: actionUrl, - }); - - // If GitHub is not connected, transition to GitHub step after saving - if (!isGitHubConnectedForOnboarding) { - setState("github-connection"); - } - }, [vercelStagingEnvironment, pullEnvVarsBeforeBuild, atomicBuilds, discoverEnvVars, syncEnvVarsMapping, nextUrl, fromMarketplaceContext, isGitHubConnectedForOnboarding, completeOnboardingFetcher, actionUrl]); - - const handleFinishOnboarding = useCallback((e: React.FormEvent) => { - e.preventDefault(); - const form = e.currentTarget; - const formData = new FormData(form); - completeOnboardingFetcher.submit(formData, { - method: "post", - action: actionUrl, - }); - }, [completeOnboardingFetcher, actionUrl]); - - // Handle successful onboarding completion - useEffect(() => { - if (completeOnboardingFetcher.data && typeof completeOnboardingFetcher.data === "object" && "success" in completeOnboardingFetcher.data && completeOnboardingFetcher.data.success && completeOnboardingFetcher.state === "idle") { - // Don't close modal if we're on the github-connection step (user still needs to connect GitHub) - if (state === "github-connection") { - return; - } - // Check if we need to redirect to a specific URL - if ("redirectTo" in completeOnboardingFetcher.data && typeof completeOnboardingFetcher.data.redirectTo === "string") { - // Navigate to the redirect URL (handles both internal and external URLs) - window.location.href = completeOnboardingFetcher.data.redirectTo; - return; - } - // No redirect, just close the modal - setState("completed"); - } - }, [completeOnboardingFetcher.data, completeOnboardingFetcher.state, state]); - - // Handle completed state - close modal - useEffect(() => { - if (state === "completed") { - onClose(); - } - }, [state, onClose]); - - // Handle installation redirect - useEffect(() => { - if (state === "installing") { - const installUrl = vercelAppInstallPath(organizationSlug, projectSlug); - window.location.href = installUrl; // Same window redirect - } - }, [state, organizationSlug, projectSlug]); - - // Handle successful env mapping update - useEffect(() => { - if (envMappingFetcher.data && typeof envMappingFetcher.data === "object" && "success" in envMappingFetcher.data && envMappingFetcher.data.success && envMappingFetcher.state === "idle") { - setState("loading-env-vars"); - } - }, [envMappingFetcher.data, envMappingFetcher.state]); - - // Preselect environment in env-mapping state - useEffect(() => { - if (state === "env-mapping" && customEnvironments.length > 0 && !vercelStagingEnvironment) { - let selectedEnv: VercelCustomEnvironment; - - if (customEnvironments.length === 1) { - // Only one environment, preselect it - selectedEnv = customEnvironments[0]; - } else { - // Multiple environments, check for 'staging' (case-insensitive) - const stagingEnv = customEnvironments.find( - (env) => env.slug.toLowerCase() === "staging" - ); - selectedEnv = stagingEnv ?? customEnvironments[0]; - } - - setVercelStagingEnvironment({ environmentId: selectedEnv.id, displayName: selectedEnv.slug }); - } - }, [state, customEnvironments, vercelStagingEnvironment]); - - if (!isOpen || onboardingData?.authInvalid) { - return null; - } - - const isLoadingState = - state === "loading-projects" || - state === "loading-env-mapping" || - state === "loading-env-vars" || - state === "installing" || - (state === "idle" && !onboardingData); - - if (isLoadingState) { - return ( - !open && !fromMarketplaceContext && onClose()}> - - -
- - Set up Vercel Integration -
-
-
- -
-
-
- ); - } - - const showProjectSelection = state === "project-selection"; - const showEnvMapping = state === "env-mapping"; - const showEnvVarSync = state === "env-var-sync"; - const showBuildSettings = state === "build-settings"; - const showGitHubConnection = state === "github-connection"; - - return ( - !open && !fromMarketplaceContext && onClose()}> - - -
- - Set up Vercel Integration -
-
- -
- {showProjectSelection && ( -
- Select Vercel Project - - Choose which Vercel project to connect with this Trigger.dev project. - Your API keys will be automatically synced to Vercel. - - - {availableProjects.length === 0 ? ( - - No Vercel projects found. Please create a project in Vercel first. - - ) : ( - - )} - - {projectSelectionError && ( - {projectSelectionError} - )} - - - Once connected, your TRIGGER_SECRET_KEY will be - automatically synced to Vercel for each environment. - - - - {fetcher.state !== "idle" ? "Connecting..." : "Connect Project"} - - } - cancelButton={ - - } - /> -
- )} - - {showEnvMapping && ( -
- Map Vercel Environment to Staging - - Select which custom Vercel environment should map to Trigger.dev's Staging - environment. Production and Preview environments are mapped automatically. - - - - -
- -
- {/* Skip button only shown for dashboard flow */} - {!fromMarketplaceContext && ( - - )} - -
-
-
- )} - - {showEnvVarSync && ( -
- Pull Environment Variables - - Select which environment variables to pull from Vercel now. This is a one-time pull. - - {/* Stats */} -
-
- {syncableEnvVars.length} - can be pulled -
- {secretEnvVars.length > 0 && ( -
- {secretEnvVars.length} - secret (cannot pull) -
- )} -
- - {/* Main toggle - controls selecting/deselecting all env vars */} -
-
- - Select all variables to pull from Vercel. -
- handleToggleAllEnvVars(checked, syncableEnvVars)} - /> -
- - {/* Expandable syncable env var list */} - {syncableEnvVars.length > 0 && ( -
- - - {expandedEnvVars && ( -
- {syncableEnvVars.map((envVar) => ( -
-
- {existingVars[envVar.key] ? ( - - - -
- {envVar.key} -
-
- - {`This variable is going to be replaced in: ${existingVars[ - envVar.key - ].environments.join(", ")}`} - -
-
- ) : ( - {envVar.key} - )} - {envVar.target && envVar.target.length > 0 && ( - - {formatVercelTargets(envVar.target)} - {envVar.isShared && " · Shared"} - - )} -
- - handleToggleEnvVar(envVar.key, checked) - } - /> -
- ))} -
- )} -
- )} - - {/* Expandable secret env var list */} - {secretEnvVars.length > 0 && ( -
- - - {expandedSecretEnvVars && ( -
- {secretEnvVars.map((envVar) => ( -
-
- {envVar.key} - {envVar.target && envVar.target.length > 0 && ( - - {formatVercelTargets(envVar.target)} - {envVar.isShared && " · Shared"} - - )} -
- Secret -
- ))} -
- )} -
- )} - - {overlappingEnvVarsCount > 0 && enabledEnvVars.length > 0 && ( -
- - - {overlappingEnvVarsCount} env vars are going to be updated (marked with{" "} - - underline - - ) - -
- )} - - { - if (fromMarketplaceContext) { - // Marketplace flow: skip build-settings, use defaults and go to github or complete - handleBuildSettingsNext(); - } else { - setState("build-settings"); - } - }} - disabled={fromMarketplaceContext && completeOnboardingFetcher.state !== "idle"} - LeadingIcon={fromMarketplaceContext && completeOnboardingFetcher.state !== "idle" ? SpinnerWhite : undefined} - > - {fromMarketplaceContext ? (isGitHubConnectedForOnboarding ? "Finish" : "Next") : "Next"} - - } - cancelButton={ - hasCustomEnvs && !fromMarketplaceContext ? ( - - ) : ( - - ) - } - /> -
- )} - - {showBuildSettings && ( -
- Build Settings - - Configure how environment variables are pulled during builds and atomic deployments. - - - - - - {isGitHubConnectedForOnboarding ? "Finish" : "Next"} - - } - cancelButton={ - - } - /> -
- )} - - {showGitHubConnection && ( -
- Connect GitHub Repository - - To fully integrate with Vercel, Trigger.dev needs access to your source code. - This allows automatic deployments and build synchronization. - - - -

- Connecting your GitHub repository enables Trigger.dev to read your source code - and automatically create deployments when you push changes to Vercel. -

-
- - {(() => { - // Build redirect URL that preserves Vercel marketplace context - const baseSettingsPath = v3ProjectSettingsPath( - { slug: organizationSlug }, - { slug: projectSlug }, - { slug: environmentSlug } - ); - const redirectParams = new URLSearchParams(); - redirectParams.set("vercelOnboarding", "true"); - if (fromMarketplaceContext) { - redirectParams.set("origin", "marketplace"); - } - if (nextUrl) { - redirectParams.set("next", nextUrl); - } - const redirectUrlWithContext = `${baseSettingsPath}?${redirectParams.toString()}`; - - return gitHubAppInstallations.length === 0 ? ( -
- - Install GitHub app - -
- ) : ( -
-
- - - GitHub app is installed - -
-
- ); - })()} - - { - setState("completed"); - window.location.href = nextUrl; - }} - > - Complete - - ) : ( - - ) - } - cancelButton={ - isGitHubConnectedForOnboarding && fromMarketplaceContext && nextUrl ? ( - - ) : undefined - } - /> -
- )} -
-
-
- ); -} +import { VercelOnboardingModal } from "~/components/integrations/VercelOnboardingModal"; -// Export components for use in other routes export { VercelSettingsPanel, VercelOnboardingModal }; diff --git a/apps/webapp/app/services/vercelIntegration.server.ts b/apps/webapp/app/services/vercelIntegration.server.ts index f8cac3e480..06b2a0fca8 100644 --- a/apps/webapp/app/services/vercelIntegration.server.ts +++ b/apps/webapp/app/services/vercelIntegration.server.ts @@ -212,7 +212,6 @@ export class VercelIntegrationService { orgIntegration, }); - // Disable autoAssignCustomDomains on the Vercel project for atomic deployments try { const client = await VercelIntegrationRepository.getVercelClient(orgIntegration); await VercelIntegrationRepository.disableAutoAssignCustomDomains( @@ -265,7 +264,6 @@ export class VercelIntegrationService { }, }); - // Sync TRIGGER_VERSION if atomic builds were just enabled for prod if (updatedConfig.atomicBuilds?.includes("prod")) { const orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationForProject( projectId @@ -406,8 +404,6 @@ export class VercelIntegrationService { discoverEnvVars: params.discoverEnvVars ?? null, vercelStagingEnvironment: params.vercelStagingEnvironment ?? null, }, - // Don't save syncEnvVarsMapping - it's only used for the one-time pull during onboarding - // Keep the existing mapping (or empty default) syncEnvVarsMapping: existing.parsedIntegrationData.syncEnvVarsMapping, }; @@ -418,12 +414,7 @@ export class VercelIntegrationService { }, }); - // Pull env vars now (one-time sync during onboarding) - // Always attempt to pull - the pullEnvVarsFromVercel function will filter based on the mapping. - // We can't easily check hasEnabledVars because vars NOT in the mapping are enabled by default, - // so a mapping like { "dev": { "VAR1": false } } still means VAR2, VAR3, etc. should be synced. try { - // Get the org integration with token reference const orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationForProject( projectId ); @@ -463,7 +454,6 @@ export class VercelIntegrationService { }); } - // Sync TRIGGER_VERSION if atomic builds are enabled and a deployed build exists await this.#syncTriggerVersionToVercelProduction( projectId, updatedData.config.atomicBuilds, @@ -488,11 +478,6 @@ export class VercelIntegrationService { }; } - /** - * Syncs the current deployed version as TRIGGER_VERSION to Vercel production. - * Called during onboarding completion and when atomic builds are enabled via config update. - * Non-blocking — errors are logged but don't fail the parent operation. - */ async #syncTriggerVersionToVercelProduction( projectId: string, atomicBuilds: string[] | null | undefined, @@ -503,7 +488,6 @@ export class VercelIntegrationService { return; } - // Find the PRODUCTION runtime environment for this project const prodEnvironment = await this.#prismaClient.runtimeEnvironment.findFirst({ where: { projectId, @@ -518,7 +502,6 @@ export class VercelIntegrationService { return; } - // Get the current promoted deployment version const currentDeployment = await findCurrentWorkerDeployment({ environmentId: prodEnvironment.id, }); diff --git a/apps/webapp/app/v3/vercel/index.ts b/apps/webapp/app/v3/vercel/index.ts index 6a335b7436..f34f0b64c6 100644 --- a/apps/webapp/app/v3/vercel/index.ts +++ b/apps/webapp/app/v3/vercel/index.ts @@ -1,14 +1,5 @@ -/** - * Vercel integration module. - * - * This module provides types and utilities for the Vercel integration feature. - */ - export * from "./vercelProjectIntegrationSchema"; -/** - * Extract Vercel installation parameters from a request URL. - */ export function getVercelInstallParams(request: Request) { const url = new URL(request.url); const code = url.searchParams.get("code"); diff --git a/apps/webapp/app/v3/vercel/vercelOAuthState.server.ts b/apps/webapp/app/v3/vercel/vercelOAuthState.server.ts index 54c0838b47..31f42acc87 100644 --- a/apps/webapp/app/v3/vercel/vercelOAuthState.server.ts +++ b/apps/webapp/app/v3/vercel/vercelOAuthState.server.ts @@ -2,9 +2,6 @@ import { generateJWT, validateJWT } from "@trigger.dev/core/v3/jwt"; import { z } from "zod"; import { env } from "~/env.server"; -/** - * Schema for Vercel OAuth state JWT payload. - */ export const VercelOAuthStateSchema = z.object({ organizationId: z.string(), projectId: z.string(), @@ -15,30 +12,16 @@ export const VercelOAuthStateSchema = z.object({ export type VercelOAuthState = z.infer; -/** - * Generate a JWT state token for Vercel OAuth flow. - * This function is server-only as it requires the encryption key. - * - * @param params - The state parameters to encode - * @returns A signed JWT token containing the state - */ export async function generateVercelOAuthState( params: VercelOAuthState ): Promise { return generateJWT({ secretKey: env.ENCRYPTION_KEY, payload: params, - // OAuth state tokens should be short-lived (15 minutes) expirationTime: "15m", }); } -/** - * Validate and decode a Vercel OAuth state JWT token. - * - * @param token - The JWT token to validate - * @returns The decoded state or null if invalid - */ export async function validateVercelOAuthState( token: string ): Promise<{ ok: true; state: VercelOAuthState } | { ok: false; error: string }> { @@ -55,4 +38,3 @@ export async function validateVercelOAuthState( return { ok: true, state: parseResult.data }; } - diff --git a/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts b/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts index 0e787bdb68..c8ad68bbb7 100644 --- a/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts +++ b/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts @@ -1,147 +1,59 @@ import { z } from "zod"; -/** - * Environment slugs used in API keys and configuration. - * These map to RuntimeEnvironmentType as follows: - * - "dev" → DEVELOPMENT - * - "stg" → STAGING - * - "prod" → PRODUCTION - * - "preview" → PREVIEW - */ export const EnvSlugSchema = z.enum(["dev", "stg", "prod", "preview"]); export type EnvSlug = z.infer; +export const ALL_ENV_SLUGS: EnvSlug[] = ["dev", "stg", "prod", "preview"]; + /** - * Configuration for the Vercel integration. - * - * These settings control how the integration behaves when syncing environment variables - * and responding to Vercel deployment/build events. + * Zod transform for form fields that submit JSON-encoded arrays. + * Parses the string as JSON and returns the array, or null if invalid. */ +export const jsonArrayField = z.string().optional().transform((val) => { + if (!val) return null; + try { + const parsed = JSON.parse(val); + return Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } +}); + export const VercelIntegrationConfigSchema = z.object({ - /** - * Array of environment slugs to enable atomic deployments for. - * When an environment slug is in this array, Trigger.dev deployment waits for - * Vercel deployment to complete before promoting. - * - * Example: ["prod"] enables atomic builds for production only - * null/undefined = atomic builds disabled for all environments - */ atomicBuilds: z.array(EnvSlugSchema).nullable().optional(), - - /** - * Array of environment slugs to pull env vars for before build. - * When an environment slug is in this array, env vars are pulled from Vercel - * before each Trigger.dev build starts for that environment. - * - * Example: ["prod", "stg"] will pull Vercel env vars for production and staging builds - * null/undefined = env var pulling disabled for all environments - */ pullEnvVarsBeforeBuild: z.array(EnvSlugSchema).nullable().optional(), - - /** - * Maps a custom Vercel environment to Trigger.dev's staging environment. - * Vercel environments: - * - production → Trigger.dev production (automatic) - * - preview → Trigger.dev preview (automatic) - * - development → Trigger.dev development (automatic) - * - custom environments → user can select one to map to Trigger.dev staging - * - * This field stores the custom Vercel environment ID that maps to staging. - * When null, no custom environment is mapped to staging. - */ + /** Maps a custom Vercel environment to Trigger.dev's staging environment. */ vercelStagingEnvironment: z.object({ environmentId: z.string(), displayName: z.string(), }).nullable().optional(), - - /** - * Array of environment slugs for which new env vars should be discovered from Vercel during builds. - * When an environment slug is in this array, new environment variables added in Vercel - * will be automatically pulled into Trigger.dev for that environment. - * - * Example: ["prod", "stg"] will discover new env vars for production and staging builds - * null/undefined = discovery disabled for all environments - */ discoverEnvVars: z.array(EnvSlugSchema).nullable().optional(), }); export type VercelIntegrationConfig = z.infer; -/** - * Environment types for sync mapping (RuntimeEnvironmentType from database) - */ export const TriggerEnvironmentType = z.enum(["PRODUCTION", "STAGING", "PREVIEW", "DEVELOPMENT"]); export type TriggerEnvironmentType = z.infer; /** - * Mapping of environment slugs to per-variable sync settings. - * - * Structure: { [envSlug]: { [varName]: boolean } } - * - * - If an env slug is missing from this map, all variables are synced by default for that environment. - * - For each environment, you can enable/disable syncing per variable. - * - If a variable is missing from an environment's settings, it defaults to sync (true). - * - Secret environment variables from Vercel cannot be synced due to API limitations. - * - * @example - * { - * "prod": { - * "DATABASE_URL": true, // sync for production - * "DEBUG_MODE": false // don't sync for production - * }, - * "stg": { - * "DATABASE_URL": true, - * "DEBUG_MODE": true - * } - * // "dev" is not in the map - all variables will be synced for dev by default - * } + * Per-environment, per-variable sync settings. + * Missing env slug = sync all vars. Missing var in env = sync by default. + * Only explicitly `false` entries disable sync. */ export const SyncEnvVarsMappingSchema = z.record(EnvSlugSchema, z.record(z.string(), z.boolean())).default({}); export type SyncEnvVarsMapping = z.infer; -/** - * The complete integrationData schema for OrganizationProjectIntegration - * when the integration service is VERCEL. - * - * This is stored in the `integrationData` JSON field of OrganizationProjectIntegration. - */ export const VercelProjectIntegrationDataSchema = z.object({ - /** - * Configuration settings for the Vercel integration - */ config: VercelIntegrationConfigSchema, - - /** - * Mapping of environment slugs to per-variable sync settings. - * See SyncEnvVarsMappingSchema for detailed documentation. - */ syncEnvVarsMapping: SyncEnvVarsMappingSchema, - - /** - * The name of the Vercel project (for display purposes) - */ vercelProjectName: z.string(), - - /** - * The Vercel team/organization ID (null for personal accounts) - */ vercelTeamId: z.string().nullable(), - - /** - * The Vercel project ID. - * Note: This is also stored in OrganizationProjectIntegration.externalEntityId - * but duplicated here for convenience. - */ vercelProjectId: z.string(), }); export type VercelProjectIntegrationData = z.infer; -/** - * Helper function to create default integration data for a new Vercel project connection. - * Defaults to having atomic builds enabled for production and pull env vars enabled for all non-dev environments. - */ export function createDefaultVercelIntegrationData( vercelProjectId: string, vercelProjectName: string, @@ -162,8 +74,43 @@ export function createDefaultVercelIntegrationData( } /** - * Check if discover env vars is enabled for a specific environment. + * Maps a Trigger.dev environment type to its Vercel target identifier(s). + * Returns null for STAGING when no custom environment is configured. */ +export function envTypeToVercelTarget( + envType: TriggerEnvironmentType, + stagingEnvironmentId?: string | null +): string[] | null { + switch (envType) { + case "PRODUCTION": + return ["production"]; + case "STAGING": + return stagingEnvironmentId ? [stagingEnvironmentId] : null; + case "PREVIEW": + return ["preview"]; + case "DEVELOPMENT": + return ["development"]; + } +} + +export function getAvailableEnvSlugs( + hasStagingEnvironment: boolean, + hasPreviewEnvironment: boolean +): EnvSlug[] { + return ALL_ENV_SLUGS.filter((s) => { + if (s === "stg" && !hasStagingEnvironment) return false; + if (s === "preview" && !hasPreviewEnvironment) return false; + return true; + }); +} + +export function getAvailableEnvSlugsForBuildSettings( + hasStagingEnvironment: boolean, + hasPreviewEnvironment: boolean +): EnvSlug[] { + return getAvailableEnvSlugs(hasStagingEnvironment, hasPreviewEnvironment).filter((s) => s !== "dev"); +} + export function isDiscoverEnvVarsEnabledForEnvironment( discoverEnvVars: EnvSlug[] | null | undefined, environmentType: TriggerEnvironmentType @@ -175,9 +122,6 @@ export function isDiscoverEnvVarsEnabledForEnvironment( return discoverEnvVars.includes(envSlug); } -/** - * Convert RuntimeEnvironmentType to EnvSlug - */ export function envTypeToSlug(environmentType: TriggerEnvironmentType): EnvSlug { switch (environmentType) { case "DEVELOPMENT": @@ -191,9 +135,6 @@ export function envTypeToSlug(environmentType: TriggerEnvironmentType): EnvSlug } } -/** - * Convert EnvSlug to RuntimeEnvironmentType - */ export function envSlugToType(slug: EnvSlug): TriggerEnvironmentType { switch (slug) { case "dev": @@ -207,14 +148,6 @@ export function envSlugToType(slug: EnvSlug): TriggerEnvironmentType { } } -/** - * Type guard to check if env var should be synced for a specific environment. - * Returns true if: - * - The environment slug is not in the mapping (sync all vars by default) - * - The env var is not in the environment's settings (sync by default) - * - The value is explicitly true - * Returns false only when explicitly set to false for the environment. - */ export function shouldSyncEnvVar( mapping: SyncEnvVarsMapping, envVarName: string, @@ -222,34 +155,21 @@ export function shouldSyncEnvVar( ): boolean { const envSlug = envTypeToSlug(environmentType); const envSettings = mapping[envSlug]; - // If environment not in mapping, sync all vars by default if (!envSettings) { return true; } - const value = envSettings[envVarName]; - // If env var not specified for this environment, default to true (sync by default) - // Only skip if explicitly set to false - return value !== false; + return envSettings[envVarName] !== false; } -/** - * Check if env var should be synced for any environment. - * Used for display purposes to determine if an env var is partially or fully enabled. - */ export function shouldSyncEnvVarForAnyEnvironment( mapping: SyncEnvVarsMapping, envVarName: string ): boolean { - const envSlugs: EnvSlug[] = ["dev", "stg", "prod", "preview"]; - - // Check each environment - for (const slug of envSlugs) { + for (const slug of ALL_ENV_SLUGS) { const envSettings = mapping[slug]; - // If environment not in mapping, all vars are synced by default if (!envSettings) { return true; } - // If var is explicitly true or not specified for this environment, it's enabled if (envSettings[envVarName] !== false) { return true; } @@ -258,9 +178,6 @@ export function shouldSyncEnvVarForAnyEnvironment( return false; } -/** - * Check if pull env vars is enabled for a specific environment. - */ export function isPullEnvVarsEnabledForEnvironment( pullEnvVarsBeforeBuild: EnvSlug[] | null | undefined, environmentType: TriggerEnvironmentType @@ -272,9 +189,6 @@ export function isPullEnvVarsEnabledForEnvironment( return pullEnvVarsBeforeBuild.includes(envSlug); } -/** - * Check if atomic builds is enabled for a specific environment. - */ export function isAtomicBuildsEnabledForEnvironment( atomicBuilds: EnvSlug[] | null | undefined, environmentType: TriggerEnvironmentType From 13489c6eb4ce1d44268ac1639a1ae6e1ceb58276 Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Tue, 3 Feb 2026 16:01:46 +0100 Subject: [PATCH 28/33] fix(webapp): Fix typings --- apps/webapp/app/models/orgIntegration.server.ts | 7 +++++++ apps/webapp/app/routes/api.v1.deployments.ts | 1 - apps/webapp/app/v3/services/alerts/deliverAlert.server.ts | 3 ++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/models/orgIntegration.server.ts b/apps/webapp/app/models/orgIntegration.server.ts index 521c0c93a8..80a0334281 100644 --- a/apps/webapp/app/models/orgIntegration.server.ts +++ b/apps/webapp/app/models/orgIntegration.server.ts @@ -47,6 +47,13 @@ export type AuthenticatableIntegration = OrganizationIntegration & { tokenReference: SecretReference; }; +export function isIntegrationForService( + integration: AuthenticatableIntegration, + service: TService +): integration is OrganizationIntegrationForService { + return (integration.service satisfies IntegrationService) === service; +} + export class OrgIntegrationRepository { static async getAuthenticatedClientForIntegration( integration: OrganizationIntegrationForService, diff --git a/apps/webapp/app/routes/api.v1.deployments.ts b/apps/webapp/app/routes/api.v1.deployments.ts index 829cbdfcc7..0190ba123d 100644 --- a/apps/webapp/app/routes/api.v1.deployments.ts +++ b/apps/webapp/app/routes/api.v1.deployments.ts @@ -48,7 +48,6 @@ export async function action({ request, params }: ActionFunctionArgs) { deployment.externalBuildData as InitializeDeploymentResponseBody["externalBuildData"], imageTag: imageRef, imagePlatform: deployment.imagePlatform, - environmentSlug: authenticatedEnv.slug, eventStream, }; diff --git a/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts b/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts index a27d738094..debb176da5 100644 --- a/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts +++ b/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts @@ -22,6 +22,7 @@ import { environmentTitle } from "~/components/environments/EnvironmentLabel"; import { type Prisma, type prisma, type PrismaClientOrTransaction } from "~/db.server"; import { env } from "~/env.server"; import { + isIntegrationForService, type OrganizationIntegrationForService, OrgIntegrationRepository, } from "~/models/orgIntegration.server"; @@ -644,7 +645,7 @@ export class DeliverAlertService extends BaseService { }, }); - if (!integration) { + if (!integration || !isIntegrationForService(integration, "SLACK")) { logger.error("[DeliverAlert] Slack integration not found", { alert, }); From 4537caccd79dac5c70ebda1720330665205d547d Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Tue, 3 Feb 2026 19:51:06 +0100 Subject: [PATCH 29/33] feat(vercel): PR feedback changes --- .../integrations/VercelOnboardingModal.tsx | 1 - .../app/models/vercelIntegration.server.ts | 3 +- .../presenters/v3/BranchesPresenter.server.ts | 2 +- .../route.tsx | 3 +- ...ationSlug.settings.integrations.vercel.tsx | 14 +-- ....projects.$projectParam.vercel.projects.ts | 20 +++- .../app/routes/auth.github.callback.tsx | 23 +---- .../app/routes/auth.google.callback.tsx | 23 +---- apps/webapp/app/routes/login.mfa/route.tsx | 28 +----- apps/webapp/app/routes/magic.tsx | 26 +---- ...ents.$environmentId.regenerate-api-key.tsx | 16 +--- ...cts.$projectParam.env.$envParam.vercel.tsx | 2 +- apps/webapp/app/routes/vercel.callback.ts | 7 +- apps/webapp/app/routes/vercel.onboarding.tsx | 9 +- .../app/services/referralSource.server.ts | 21 ++++ .../app/services/vercelIntegration.server.ts | 96 +++++++++---------- .../environmentVariablesRepository.server.ts | 25 +++-- 17 files changed, 136 insertions(+), 183 deletions(-) diff --git a/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx b/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx index 56c4d82e4b..60de3af12c 100644 --- a/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx +++ b/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx @@ -320,7 +320,6 @@ export function VercelOnboardingModal({ if (state === "project-selection" && fetcher.data && "success" in fetcher.data && fetcher.data.success && fetcher.state === "idle") { setState("loading-env-mapping"); if (onDataReload) { - console.log("Vercel onboarding: Reloading data after successful project selection to get updated project info and env vars"); onDataReload(); } } else if (fetcher.data && "error" in fetcher.data && typeof fetcher.data.error === "string") { diff --git a/apps/webapp/app/models/vercelIntegration.server.ts b/apps/webapp/app/models/vercelIntegration.server.ts index 57c1756eb6..c5da72327a 100644 --- a/apps/webapp/app/models/vercelIntegration.server.ts +++ b/apps/webapp/app/models/vercelIntegration.server.ts @@ -1,6 +1,5 @@ import { Vercel } from "@vercel/sdk"; import { - IntegrationService, Organization, OrganizationIntegration, SecretReference, @@ -34,7 +33,7 @@ function extractEnvs(response: unknown): unknown[] { } function isVercelSecretType(type: string): boolean { - return type === "secret" || type === "sensitive"; + return type === "secret" || type === "sensitive" || type === "encrypted"; } function vercelApiError(message: string, context: Record, error: unknown): VercelAPIResult { diff --git a/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts b/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts index db2cd012c4..4aafc1844b 100644 --- a/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts @@ -42,7 +42,7 @@ export type GitMetaLinks = { /** The git provider, e.g., `github` */ provider?: string; - source?: "trigger_github_app" | "github_actions" | "local" | "trigger_vercel_app"; + source?: "trigger_github_app" | "github_actions" | "local"; ghUsername?: string; ghUserAvatarUrl?: string; }; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx index 167d80126d..627c5e1cfe 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx @@ -50,6 +50,7 @@ import { VercelOnboardingModal, vercelResourcePath, } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel"; +import type { loader as vercelLoader } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel"; import { OrgIntegrationRepository } from "~/models/orgIntegration.server"; import { useTypedFetcher } from "remix-typedjson"; @@ -312,7 +313,7 @@ export default function Page() { const hasQueryParam = searchParams.get("vercelOnboarding") === "true"; const nextUrl = searchParams.get("next"); const [isModalOpen, setIsModalOpen] = useState(false); - const vercelFetcher = useTypedFetcher(); + const vercelFetcher = useTypedFetcher(); // Helper to open modal and ensure query param is present const openVercelOnboarding = useCallback(() => { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx index 1251150691..93132abddc 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx @@ -3,7 +3,7 @@ import type { LoaderFunctionArgs, } from "@remix-run/node"; import { json, redirect } from "@remix-run/node"; -import { Form, useActionData, useLoaderData, useNavigation } from "@remix-run/react"; +import { Form, useActionData, useNavigation } from "@remix-run/react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { DialogClose } from "@radix-ui/react-dialog"; @@ -20,7 +20,7 @@ import { FormButtons } from "~/components/primitives/FormButtons"; import { Header1 } from "~/components/primitives/Headers"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Paragraph } from "~/components/primitives/Paragraph"; -import { Table, TableBlankRow, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow } from "~/components/primitives/Table"; +import { Table, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow } from "~/components/primitives/Table"; import { VercelIntegrationRepository } from "~/models/vercelIntegration.server"; import { $transaction, prisma } from "~/db.server"; import { requireOrganization } from "~/services/org.server"; @@ -123,6 +123,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { const { organizationSlug } = OrganizationParamsSchema.parse(params); const { organization, userId } = await requireOrganization(request, organizationSlug); + const formData = await request.formData(); + const result = ActionSchema.safeParse({ intent: formData.get("intent") }); + if (!result.success) { + return json({ error: "Invalid action" }, { status: 400 }); + } // Find Vercel integration const vercelIntegration = await prisma.organizationIntegration.findFirst({ @@ -347,11 +352,6 @@ export default function VercelIntegrationPage() { ))} - {connectedProjects.length === 0 && ( - - No connected projects found. - - )}
)} diff --git a/apps/webapp/app/routes/api.v1.orgs.$organizationSlug.projects.$projectParam.vercel.projects.ts b/apps/webapp/app/routes/api.v1.orgs.$organizationSlug.projects.$projectParam.vercel.projects.ts index e53ad28d21..aa97265caf 100644 --- a/apps/webapp/app/routes/api.v1.orgs.$organizationSlug.projects.$projectParam.vercel.projects.ts +++ b/apps/webapp/app/routes/api.v1.orgs.$organizationSlug.projects.$projectParam.vercel.projects.ts @@ -4,10 +4,8 @@ import { z } from "zod"; import { prisma } from "~/db.server"; import { apiCors } from "~/utils/apiCors"; import { logger } from "~/services/logger.server"; +import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; import { VercelIntegrationService } from "~/services/vercelIntegration.server"; -import { - VercelProjectIntegrationDataSchema, -} from "~/v3/vercel/vercelProjectIntegrationSchema"; const ParamsSchema = z.object({ organizationSlug: z.string(), @@ -30,6 +28,15 @@ export async function loader({ request, params }: LoaderFunctionArgs) { return apiCors(request, json({})); } + const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); + + if (!authenticationResult) { + return apiCors( + request, + json({ error: "Invalid or Missing Access Token" }, { status: 401 }) + ); + } + const parsedParams = ParamsSchema.safeParse(params); if (!parsedParams.success) { return apiCors( @@ -41,12 +48,17 @@ export async function loader({ request, params }: LoaderFunctionArgs) { const { organizationSlug, projectParam } = parsedParams.data; try { - // Find the project + // Find the project, verifying org membership const project = await prisma.project.findFirst({ where: { slug: projectParam, organization: { slug: organizationSlug, + members: { + some: { + userId: authenticationResult.userId, + }, + }, }, deletedAt: null, }, diff --git a/apps/webapp/app/routes/auth.github.callback.tsx b/apps/webapp/app/routes/auth.github.callback.tsx index 531961ad11..2313b348f4 100644 --- a/apps/webapp/app/routes/auth.github.callback.tsx +++ b/apps/webapp/app/routes/auth.github.callback.tsx @@ -5,8 +5,7 @@ import { getSession, redirectWithErrorMessage } from "~/models/message.server"; import { authenticator } from "~/services/auth.server"; import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server"; import { commitSession } from "~/services/sessionStorage.server"; -import { getReferralSource, clearReferralSourceCookie } from "~/services/referralSource.server"; -import { telemetry } from "~/services/telemetry.server"; +import { trackAndClearReferralSource } from "~/services/referralSource.server"; import { redirectCookie } from "./auth.github"; import { sanitizeRedirectPath } from "~/utils"; @@ -56,25 +55,7 @@ export let loader: LoaderFunction = async ({ request }) => { headers.append("Set-Cookie", await commitSession(session)); headers.append("Set-Cookie", await setLastAuthMethodHeader("github")); - const referralSource = await getReferralSource(request); - if (referralSource) { - const user = await prisma.user.findUnique({ - where: { id: auth.userId }, - }); - if (user) { - const userAge = Date.now() - user.createdAt.getTime(); - const isNewUser = userAge < 30 * 1000; - - if (isNewUser) { - telemetry.user.identify({ - user, - isNewUser: true, - referralSource, - }); - } - } - headers.append("Set-Cookie", await clearReferralSourceCookie()); - } + await trackAndClearReferralSource(request, auth.userId, headers); return redirect(redirectTo, { headers }); }; diff --git a/apps/webapp/app/routes/auth.google.callback.tsx b/apps/webapp/app/routes/auth.google.callback.tsx index 53c9735107..65dabd605c 100644 --- a/apps/webapp/app/routes/auth.google.callback.tsx +++ b/apps/webapp/app/routes/auth.google.callback.tsx @@ -5,8 +5,7 @@ import { getSession, redirectWithErrorMessage } from "~/models/message.server"; import { authenticator } from "~/services/auth.server"; import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server"; import { commitSession } from "~/services/sessionStorage.server"; -import { getReferralSource, clearReferralSourceCookie } from "~/services/referralSource.server"; -import { telemetry } from "~/services/telemetry.server"; +import { trackAndClearReferralSource } from "~/services/referralSource.server"; import { redirectCookie } from "./auth.google"; import { sanitizeRedirectPath } from "~/utils"; @@ -56,25 +55,7 @@ export let loader: LoaderFunction = async ({ request }) => { headers.append("Set-Cookie", await commitSession(session)); headers.append("Set-Cookie", await setLastAuthMethodHeader("google")); - const referralSource = await getReferralSource(request); - if (referralSource) { - const user = await prisma.user.findUnique({ - where: { id: auth.userId }, - }); - if (user) { - const userAge = Date.now() - user.createdAt.getTime(); - const isNewUser = userAge < 30 * 1000; - - if (isNewUser) { - telemetry.user.identify({ - user, - isNewUser: true, - referralSource, - }); - } - } - headers.append("Set-Cookie", await clearReferralSourceCookie()); - } + await trackAndClearReferralSource(request, auth.userId, headers); return redirect(redirectTo, { headers }); }; diff --git a/apps/webapp/app/routes/login.mfa/route.tsx b/apps/webapp/app/routes/login.mfa/route.tsx index 925a4fcbc3..17d64c465e 100644 --- a/apps/webapp/app/routes/login.mfa/route.tsx +++ b/apps/webapp/app/routes/login.mfa/route.tsx @@ -26,9 +26,7 @@ import { MultiFactorAuthenticationService } from "~/services/mfa/multiFactorAuth import { redirectWithErrorMessage, redirectBackWithErrorMessage } from "~/models/message.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { checkMfaRateLimit, MfaRateLimitError } from "~/services/mfa/mfaRateLimiter.server"; -import { getReferralSource, clearReferralSourceCookie } from "~/services/referralSource.server"; -import { telemetry } from "~/services/telemetry.server"; -import { prisma } from "~/db.server"; +import { trackAndClearReferralSource } from "~/services/referralSource.server"; export const meta: MetaFunction = ({ matches }) => { const parentMeta = matches @@ -165,29 +163,9 @@ async function completeLogin(request: Request, session: Session, userId: string) const headers = new Headers(); headers.append("Set-Cookie", await sessionStorage.commitSession(authSession)); + headers.append("Set-Cookie", await commitSession(session)); - // Read referral source cookie and set in PostHog if present (only for new users), then clear it - const referralSource = await getReferralSource(request); - if (referralSource) { - const user = await prisma.user.findUnique({ - where: { id: userId }, - }); - if (user) { - // Only set referralSource for new users (created within the last 30 seconds) - const userAge = Date.now() - user.createdAt.getTime(); - const isNewUser = userAge < 30 * 1000; // 30 seconds - - if (isNewUser) { - telemetry.user.identify({ - user, - isNewUser: true, - referralSource, - }); - } - } - // Clear the cookie after using it (regardless of whether we set it) - headers.append("Set-Cookie", await clearReferralSourceCookie()); - } + await trackAndClearReferralSource(request, userId, headers); return redirect(redirectTo, { headers }); } diff --git a/apps/webapp/app/routes/magic.tsx b/apps/webapp/app/routes/magic.tsx index efbc2c6721..682f0ef46e 100644 --- a/apps/webapp/app/routes/magic.tsx +++ b/apps/webapp/app/routes/magic.tsx @@ -6,8 +6,7 @@ import { authenticator } from "~/services/auth.server"; import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server"; import { getRedirectTo } from "~/services/redirectTo.server"; import { commitSession, getSession } from "~/services/sessionStorage.server"; -import { getReferralSource, clearReferralSourceCookie } from "~/services/referralSource.server"; -import { telemetry } from "~/services/telemetry.server"; +import { trackAndClearReferralSource } from "~/services/referralSource.server"; export async function loader({ request }: LoaderFunctionArgs) { const redirectTo = await getRedirectTo(request); @@ -55,28 +54,7 @@ export async function loader({ request }: LoaderFunctionArgs) { headers.append("Set-Cookie", await commitSession(session)); headers.append("Set-Cookie", await setLastAuthMethodHeader("email")); - // Read referral source cookie and set in PostHog if present (only for new users), then clear it - const referralSource = await getReferralSource(request); - if (referralSource) { - const user = await prisma.user.findUnique({ - where: { id: auth.userId }, - }); - if (user) { - // Only set referralSource for new users (created within the last 30 seconds) - const userAge = Date.now() - user.createdAt.getTime(); - const isNewUser = userAge < 30 * 1000; // 30 seconds - - if (isNewUser) { - telemetry.user.identify({ - user, - isNewUser: true, - referralSource, - }); - } - } - // Clear the cookie after using it (regardless of whether we set it) - headers.append("Set-Cookie", await clearReferralSourceCookie()); - } + await trackAndClearReferralSource(request, auth.userId, headers); return redirect(redirectTo ?? "/", { headers }); } diff --git a/apps/webapp/app/routes/resources.environments.$environmentId.regenerate-api-key.tsx b/apps/webapp/app/routes/resources.environments.$environmentId.regenerate-api-key.tsx index 08ba3404c3..534a07d7fb 100644 --- a/apps/webapp/app/routes/resources.environments.$environmentId.regenerate-api-key.tsx +++ b/apps/webapp/app/routes/resources.environments.$environmentId.regenerate-api-key.tsx @@ -25,8 +25,7 @@ export async function action({ request, params }: ActionFunctionArgs) { const updatedEnvironment = await regenerateApiKey({ userId, environmentId }); // Sync the regenerated API key to Vercel if integration exists - // This is done asynchronously and won't block the response - syncApiKeyToVercelInBackground( + await syncApiKeyToVercelInBackground( updatedEnvironment.projectId, updatedEnvironment.type as "PRODUCTION" | "STAGING" | "PREVIEW" | "DEVELOPMENT", updatedEnvironment.apiKey @@ -49,8 +48,7 @@ export async function action({ request, params }: ActionFunctionArgs) { } /** - * Sync the API key to Vercel in the background. - * This runs asynchronously and doesn't block the main response. + * Sync the API key to Vercel. * Errors are logged but won't fail the API key regeneration. */ async function syncApiKeyToVercelInBackground( @@ -64,17 +62,9 @@ async function syncApiKeyToVercelInBackground( environmentType, apiKey, }); - - if (!result.success) { - logger.warn("Failed to sync regenerated API key to Vercel", { - projectId, - environmentType, - error: result.error, - }); - } } catch (error) { // Log but don't throw - we don't want to fail the main operation - logger.error("Error syncing regenerated API key to Vercel", { + logger.warn("Error syncing regenerated API key to Vercel", { projectId, environmentType, error, diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx index f7498cee4c..cd7378c2d5 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx @@ -769,7 +769,7 @@ function ConnectedVercelProjectForm({ onAtomicBuildsChange={(slugs) => setConfigValues((prev) => ({ ...prev, atomicBuilds: slugs })) } - envVarsConfigLink={`/orgs/${organizationSlug}/projects/${projectSlug}/environment-variables`} + envVarsConfigLink={`/orgs/${organizationSlug}/projects/${projectSlug}/env/${environmentSlug}/environment-variables`} /> {/* Warning: autoAssignCustomDomains must be disabled for atomic deployments */} diff --git a/apps/webapp/app/routes/vercel.callback.ts b/apps/webapp/app/routes/vercel.callback.ts index 3154e6dcba..e42951c5e8 100644 --- a/apps/webapp/app/routes/vercel.callback.ts +++ b/apps/webapp/app/routes/vercel.callback.ts @@ -2,7 +2,7 @@ import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; import { redirect } from "@remix-run/server-runtime"; import { z } from "zod"; import { logger } from "~/services/logger.server"; -import { getUserId, requireUserId } from "~/services/session.server"; +import { getUserId } from "~/services/session.server"; import { setReferralSourceCookie } from "~/services/referralSource.server"; import { requestUrl } from "~/utils/requestUrl.server"; @@ -12,7 +12,7 @@ const VercelCallbackSchema = z state: z.string().optional(), error: z.string().optional(), error_description: z.string().optional(), - configurationId: z.string(), + configurationId: z.string().optional(), next: z.string().optional() }) .passthrough(); @@ -56,7 +56,8 @@ export async function loader({ request }: LoaderFunctionArgs) { // Route with state: dashboard-invoked flow if (state) { - const params = new URLSearchParams({ state, configurationId, code, origin: "dashboard" }); + const params = new URLSearchParams({ state, code, origin: "dashboard" }); + if (configurationId) params.set("configurationId", configurationId); if (nextUrl) params.set("next", nextUrl); return redirect(`/vercel/connect?${params.toString()}`); } diff --git a/apps/webapp/app/routes/vercel.onboarding.tsx b/apps/webapp/app/routes/vercel.onboarding.tsx index f950d3da2f..5f4def4191 100644 --- a/apps/webapp/app/routes/vercel.onboarding.tsx +++ b/apps/webapp/app/routes/vercel.onboarding.tsx @@ -1,6 +1,6 @@ import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; import { json, redirect } from "@remix-run/server-runtime"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Form, useNavigation } from "@remix-run/react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; @@ -267,6 +267,13 @@ export default function VercelOnboardingPage() { const isSubmitting = navigation.state === "submitting"; const [isInstalling, setIsInstalling] = useState(false); + // Reset isInstalling when navigation returns to idle (e.g. on error) + useEffect(() => { + if (navigation.state === "idle" && isInstalling) { + setIsInstalling(false); + } + }, [navigation.state, isInstalling]); + if (data.step === "error") { return ( diff --git a/apps/webapp/app/services/referralSource.server.ts b/apps/webapp/app/services/referralSource.server.ts index 00a5aae266..fbc4d6c76b 100644 --- a/apps/webapp/app/services/referralSource.server.ts +++ b/apps/webapp/app/services/referralSource.server.ts @@ -1,5 +1,7 @@ import { createCookie } from "@remix-run/node"; +import { prisma } from "~/db.server"; import { env } from "~/env.server"; +import { telemetry } from "~/services/telemetry.server"; export type ReferralSource = "vercel"; @@ -29,3 +31,22 @@ export async function clearReferralSourceCookie(): Promise { maxAge: 0, }); } + +export async function trackAndClearReferralSource( + request: Request, + userId: string, + headers: Headers +): Promise { + const referralSource = await getReferralSource(request); + if (!referralSource) return; + + headers.append("Set-Cookie", await clearReferralSourceCookie()); + + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) return; + + const userAge = Date.now() - user.createdAt.getTime(); + if (userAge >= 30 * 1000) return; + + telemetry.user.identify({ user, isNewUser: true, referralSource }); +} diff --git a/apps/webapp/app/services/vercelIntegration.server.ts b/apps/webapp/app/services/vercelIntegration.server.ts index 06b2a0fca8..f089369536 100644 --- a/apps/webapp/app/services/vercelIntegration.server.ts +++ b/apps/webapp/app/services/vercelIntegration.server.ts @@ -264,18 +264,20 @@ export class VercelIntegrationService { }, }); - if (updatedConfig.atomicBuilds?.includes("prod")) { - const orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationForProject( - projectId - ); + if (!updatedConfig.atomicBuilds?.includes("prod")) { + return { ...updated, parsedIntegrationData: updatedData }; + } - if (orgIntegration) { - await this.#syncTriggerVersionToVercelProduction( - projectId, - updatedConfig.atomicBuilds, - orgIntegration - ); - } + const orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationForProject( + projectId + ); + + if (orgIntegration) { + await this.#syncTriggerVersionToVercelProduction( + projectId, + updatedConfig.atomicBuilds, + orgIntegration + ); } return { @@ -404,7 +406,7 @@ export class VercelIntegrationService { discoverEnvVars: params.discoverEnvVars ?? null, vercelStagingEnvironment: params.vercelStagingEnvironment ?? null, }, - syncEnvVarsMapping: existing.parsedIntegrationData.syncEnvVarsMapping, + syncEnvVarsMapping: params.syncEnvVarsMapping ?? existing.parsedIntegrationData.syncEnvVarsMapping, }; const updated = await this.#prismaClient.organizationProjectIntegration.update({ @@ -419,51 +421,49 @@ export class VercelIntegrationService { projectId ); - if (orgIntegration) { - const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration); + if (!orgIntegration) { + return { ...updated, parsedIntegrationData: updatedData }; + } - logger.info("Vercel onboarding: pulling env vars from Vercel", { - projectId, - vercelProjectId: updatedData.vercelProjectId, - teamId, - vercelStagingEnvironment: params.vercelStagingEnvironment, - syncEnvVarsMappingKeys: Object.keys(params.syncEnvVarsMapping), - }); + const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration); - const pullResult = await VercelIntegrationRepository.pullEnvVarsFromVercel({ - projectId, - vercelProjectId: updatedData.vercelProjectId, - teamId, - vercelStagingEnvironment: params.vercelStagingEnvironment, - syncEnvVarsMapping: params.syncEnvVarsMapping, - orgIntegration, - }); + logger.info("Vercel onboarding: pulling env vars from Vercel", { + projectId, + vercelProjectId: updatedData.vercelProjectId, + teamId, + vercelStagingEnvironment: params.vercelStagingEnvironment, + syncEnvVarsMappingKeys: Object.keys(params.syncEnvVarsMapping), + }); - if (!pullResult.success) { - logger.warn("Some errors occurred while pulling env vars from Vercel", { - projectId, - vercelProjectId: updatedData.vercelProjectId, - errors: pullResult.errors, - syncedCount: pullResult.syncedCount, - }); - } else { - logger.info("Successfully pulled env vars from Vercel", { - projectId, - vercelProjectId: updatedData.vercelProjectId, - syncedCount: pullResult.syncedCount, - }); - } + const pullResult = await VercelIntegrationRepository.pullEnvVarsFromVercel({ + projectId, + vercelProjectId: updatedData.vercelProjectId, + teamId, + vercelStagingEnvironment: params.vercelStagingEnvironment, + syncEnvVarsMapping: params.syncEnvVarsMapping, + orgIntegration, + }); - await this.#syncTriggerVersionToVercelProduction( + if (!pullResult.success) { + logger.warn("Some errors occurred while pulling env vars from Vercel", { projectId, - updatedData.config.atomicBuilds, - orgIntegration - ); + vercelProjectId: updatedData.vercelProjectId, + errors: pullResult.errors, + syncedCount: pullResult.syncedCount, + }); } else { - logger.warn("No org integration found when trying to pull env vars from Vercel", { + logger.info("Successfully pulled env vars from Vercel", { projectId, + vercelProjectId: updatedData.vercelProjectId, + syncedCount: pullResult.syncedCount, }); } + + await this.#syncTriggerVersionToVercelProduction( + projectId, + updatedData.config.atomicBuilds, + orgIntegration + ); } catch (error) { logger.error("Failed to pull env vars from Vercel during onboarding", { projectId, diff --git a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts index b925bc4f3a..40a25f212e 100644 --- a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts +++ b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts @@ -180,10 +180,22 @@ export class EnvironmentVariablesRepository implements Repository { for (const environmentId of options.environmentIds) { const key = secretKey(projectId, environmentId, variable.key); - // Check if value already exists and is the same - skip update if unchanged + const existingValueRecord = await tx.environmentVariableValue.findFirst({ + where: { + variableId: environmentVariable.id, + environmentId, + }, + }); + + // Check if value already exists and is the same, and no metadata change (e.g. isSecret toggle) const existingSecret = await secretStore.getSecret(SecretValue, key); - if (existingSecret && existingSecret.secret === variable.value) { - // Value is unchanged, skip this variable for this environment + const canSkip = + existingSecret && + existingSecret.secret === variable.value && + existingValueRecord && + (options.isSecret === undefined || + existingValueRecord.isSecret === options.isSecret); + if (canSkip) { continue; } @@ -199,13 +211,6 @@ export class EnvironmentVariablesRepository implements Repository { update: {}, }); - const existingValueRecord = await tx.environmentVariableValue.findFirst({ - where: { - variableId: environmentVariable.id, - environmentId, - }, - }); - if (existingValueRecord) { await tx.environmentVariableValue.update({ where: { From d268b0527b7947aa4b03ec84a8200c87203e15bc Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Tue, 3 Feb 2026 20:00:46 +0100 Subject: [PATCH 30/33] chore(webapp): Logging cleanup --- .../app/models/vercelIntegration.server.ts | 70 ------------------- .../v3/VercelSettingsPresenter.server.ts | 9 +-- ...cts.$projectParam.env.$envParam.vercel.tsx | 10 --- 3 files changed, 5 insertions(+), 84 deletions(-) diff --git a/apps/webapp/app/models/vercelIntegration.server.ts b/apps/webapp/app/models/vercelIntegration.server.ts index c5da72327a..eed9b9b740 100644 --- a/apps/webapp/app/models/vercelIntegration.server.ts +++ b/apps/webapp/app/models/vercelIntegration.server.ts @@ -511,14 +511,6 @@ export class VercelIntegrationRepository { } try { - logger.debug("Fetching decrypted value for shared env var", { - teamId, - envId: env.id, - envKey: env.key, - envType: env.type, - envTarget: env.target, - }); - // Get the decrypted value for this shared env var const getResponse = await client.environment.getSharedEnvVar({ id: env.id as string, @@ -655,12 +647,6 @@ export class VercelIntegrationRepository { raw: params.raw, }; - logger.debug("Updating Vercel secret", { - integrationId: params.integrationId, - teamId: params.teamId, - installationId: params.installationId, - }); - await secretStore.setSecret(integration.tokenReference.key, secretValue); await tx.organizationIntegration.update({ @@ -702,11 +688,6 @@ export class VercelIntegrationRepository { raw: params.raw, }; - logger.debug("Storing Vercel secret", { - teamId: params.teamId, - installationId: params.installationId, - }); - await secretStore.setSecret(integrationFriendlyId, secretValue); const reference = await tx.secretReference.create({ @@ -852,10 +833,6 @@ export class VercelIntegrationRepository { } if (envVarsToSync.length === 0) { - logger.debug("No API keys to sync to Vercel", { - projectId: params.projectId, - vercelProjectId: params.vercelProjectId, - }); return { success: true, errors: [] }; } @@ -1022,15 +999,6 @@ export class VercelIntegrationRepository { }); } - logger.info("Vercel pullEnvVarsFromVercel: environment mapping", { - projectId: params.projectId, - vercelProjectId: params.vercelProjectId, - envMappingCount: envMapping.length, - envMapping: envMapping.map(m => ({ type: m.triggerEnvType, target: m.vercelTarget })), - runtimeEnvironmentsCount: runtimeEnvironments.length, - runtimeEnvironments: runtimeEnvironments.map(e => e.type), - }); - if (envMapping.length === 0) { logger.warn("No environments to sync for Vercel integration", { projectId: params.projectId, @@ -1089,16 +1057,6 @@ export class VercelIntegrationRepository { continue; } - logger.info("Vercel pullEnvVarsFromVercel: fetched env vars for target", { - projectId: params.projectId, - vercelTarget: mapping.vercelTarget, - triggerEnvType: mapping.triggerEnvType, - projectEnvVarsCount: projectEnvVars.length, - sharedEnvVarsCount: filteredSharedEnvVars.length, - mergedEnvVarsCount: mergedEnvVars.length, - mergedEnvVarKeys: mergedEnvVars.map(v => v.key), - }); - const varsToSync = mergedEnvVars.filter((envVar) => { if (envVar.isSecret) { return false; @@ -1113,14 +1071,6 @@ export class VercelIntegrationRepository { ); }); - logger.info("Vercel pullEnvVarsFromVercel: filtered vars to sync", { - projectId: params.projectId, - vercelTarget: mapping.vercelTarget, - triggerEnvType: mapping.triggerEnvType, - varsToSyncCount: varsToSync.length, - varsToSyncKeys: varsToSync.map(v => v.key), - }); - if (varsToSync.length === 0) { continue; } @@ -1181,24 +1131,9 @@ export class VercelIntegrationRepository { }); if (changedVars.length === 0) { - logger.info("Vercel pullEnvVarsFromVercel: no changes detected, skipping", { - projectId: params.projectId, - vercelTarget: mapping.vercelTarget, - triggerEnvType: mapping.triggerEnvType, - skippedCount: varsToSync.length, - }); continue; } - logger.info("Vercel pullEnvVarsFromVercel: updating changed vars", { - projectId: params.projectId, - vercelTarget: mapping.vercelTarget, - triggerEnvType: mapping.triggerEnvType, - changedCount: changedVars.length, - unchangedCount: varsToSync.length - changedVars.length, - changedKeys: changedVars.map((v) => v.key), - }); - const secretVars = changedVars.filter((v) => existingSecretKeys.has(v.key)); const nonSecretVars = changedVars.filter((v) => !existingSecretKeys.has(v.key)); @@ -1520,11 +1455,6 @@ export class VercelIntegrationRepository { }, }); - logger.info("Disabled autoAssignCustomDomains on Vercel project", { - vercelProjectId, - teamId, - }); - return { success: true }; } catch (error) { const errorMessage = `Failed to disable autoAssignCustomDomains: ${error instanceof Error ? error.message : "Unknown error"}`; diff --git a/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts b/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts index d81df0f440..c9524823c9 100644 --- a/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts @@ -1,6 +1,7 @@ import { type PrismaClient } from "@trigger.dev/database"; import { fromPromise, ok, ResultAsync } from "neverthrow"; import { env } from "~/env.server"; +import { logger } from "~/services/logger.server"; import { OrgIntegrationRepository } from "~/models/orgIntegration.server"; import { VercelIntegrationRepository, @@ -280,12 +281,12 @@ export class VercelSettingsPresenter extends BasePresenter { } as VercelSettingsResult)); }).mapErr((error) => { // Log the error and return a safe fallback - console.error("Error in VercelSettingsPresenter.call:", error); + logger.error("Error in VercelSettingsPresenter.call", { error }); return error; }); } catch (syncError) { // Handle any synchronous errors that might occur - console.error("Synchronous error in VercelSettingsPresenter.call:", syncError); + logger.error("Synchronous error in VercelSettingsPresenter.call", { error: syncError }); return ok({ enabled: true, hasOrgIntegration: false, @@ -299,7 +300,7 @@ export class VercelSettingsPresenter extends BasePresenter { } } catch (error) { // If there's an unexpected error, log it and return a safe error result - console.error("Unexpected error in VercelSettingsPresenter.call:", error); + logger.error("Unexpected error in VercelSettingsPresenter.call", { error }); return ok({ enabled: true, hasOrgIntegration: false, @@ -566,7 +567,7 @@ export class VercelSettingsPresenter extends BasePresenter { isGitHubConnected, }; } catch (error) { - console.error("Error in getOnboardingData:", error); + logger.error("Error in getOnboardingData", { error }); return null; } } diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx index cd7378c2d5..9dcc8920c8 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx @@ -287,16 +287,6 @@ export async function action({ request, params }: ActionFunctionArgs) { } } - logger.info("Vercel complete-onboarding action: received params", { - projectId: project.id, - vercelStagingEnvironment, - pullEnvVarsBeforeBuild, - atomicBuilds, - discoverEnvVars, - syncEnvVarsMappingRaw: syncEnvVarsMapping, - parsedMappingKeys: Object.keys(parsedMapping), - }); - const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment); const result = await vercelService.completeOnboarding(project.id, { From 7d1ad25d4be34d69d4bf97665c22915b508204c3 Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Tue, 3 Feb 2026 20:14:06 +0100 Subject: [PATCH 31/33] chore(core): Remove unused enum --- packages/core/src/v3/schemas/api.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index de25b13562..4cb5c96503 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -522,7 +522,6 @@ export const DeploymentTriggeredVia = z "cli:travis_ci", "cli:buildkite", "git_integration:github", - "integration:vercel", "dashboard", ]) .or(anyString); From 28f5e4e269cdfde01544d81ae70166504654d0a9 Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Tue, 3 Feb 2026 20:14:22 +0100 Subject: [PATCH 32/33] feat(core): Add Vercel integration changelog --- .changeset/vercel-integration.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/vercel-integration.md diff --git a/.changeset/vercel-integration.md b/.changeset/vercel-integration.md new file mode 100644 index 0000000000..8b638e3643 --- /dev/null +++ b/.changeset/vercel-integration.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/core": patch +--- + +Add Vercel integration support to API schemas: `commitSHA` and `integrationDeployments` on deployment responses, and `source` field for environment variable imports. From 29a3434e8fcdc088c807486e6cd8ac01ae154a6e Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Tue, 3 Feb 2026 21:18:37 +0100 Subject: [PATCH 33/33] chore(vercel): Rewrite logic to use neverthrow instead of try-catch --- ...ationSlug.settings.integrations.vercel.tsx | 82 +++--- ....projects.$projectParam.vercel.projects.ts | 148 +++++----- ...cts.$projectParam.env.$envParam.vercel.tsx | 269 ++++++++++-------- apps/webapp/app/routes/vercel.connect.tsx | 34 ++- apps/webapp/app/routes/vercel.onboarding.tsx | 38 +-- 5 files changed, 312 insertions(+), 259 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx index 93132abddc..413bea3e10 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx @@ -3,6 +3,7 @@ import type { LoaderFunctionArgs, } from "@remix-run/node"; import { json, redirect } from "@remix-run/node"; +import { fromPromise } from "neverthrow"; import { Form, useActionData, useNavigation } from "@remix-run/react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; @@ -145,53 +146,41 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { return json({ error: "Vercel integration not found" }, { status: 404 }); } - try { - // First, attempt to uninstall the integration from Vercel side - const uninstallResult = await VercelIntegrationRepository.uninstallVercelIntegration(vercelIntegration); + const uninstallActionResult = await fromPromise( + (async () => { + // First, attempt to uninstall the integration from Vercel side + const uninstallResult = await VercelIntegrationRepository.uninstallVercelIntegration(vercelIntegration); - // Then soft-delete the integration and all connected projects in a transaction - await $transaction(prisma, async (tx) => { - // Soft-delete all connected projects - await tx.organizationProjectIntegration.updateMany({ - where: { - organizationIntegrationId: vercelIntegration.id, - deletedAt: null, - }, - data: { deletedAt: new Date() }, - }); + // Then soft-delete the integration and all connected projects in a transaction + await $transaction(prisma, async (tx) => { + // Soft-delete all connected projects + await tx.organizationProjectIntegration.updateMany({ + where: { + organizationIntegrationId: vercelIntegration.id, + deletedAt: null, + }, + data: { deletedAt: new Date() }, + }); - // Soft-delete the integration record - await tx.organizationIntegration.update({ - where: { id: vercelIntegration.id }, - data: { deletedAt: new Date() }, + // Soft-delete the integration record + await tx.organizationIntegration.update({ + where: { id: vercelIntegration.id }, + data: { deletedAt: new Date() }, + }); }); - }); - if (uninstallResult.authInvalid) { - logger.warn("Vercel integration uninstalled with auth error - token invalid", { - organizationId: organization.id, - organizationSlug, - userId, - integrationId: vercelIntegration.id, - }); - } else { - logger.info("Vercel integration uninstalled successfully", { - organizationId: organization.id, - organizationSlug, - userId, - integrationId: vercelIntegration.id, - }); - } + return uninstallResult; + })(), + (error) => error + ); - // Redirect back to organization settings - return redirect(`/orgs/${organizationSlug}/settings`); - } catch (error) { + if (uninstallActionResult.isErr()) { logger.error("Failed to uninstall Vercel integration", { organizationId: organization.id, organizationSlug, userId, integrationId: vercelIntegration.id, - error: error instanceof Error ? error.message : String(error), + error: uninstallActionResult.error instanceof Error ? uninstallActionResult.error.message : String(uninstallActionResult.error), }); return json( @@ -199,6 +188,25 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { { status: 500 } ); } + + if (uninstallActionResult.value.authInvalid) { + logger.warn("Vercel integration uninstalled with auth error - token invalid", { + organizationId: organization.id, + organizationSlug, + userId, + integrationId: vercelIntegration.id, + }); + } else { + logger.info("Vercel integration uninstalled successfully", { + organizationId: organization.id, + organizationSlug, + userId, + integrationId: vercelIntegration.id, + }); + } + + // Redirect back to organization settings + return redirect(`/orgs/${organizationSlug}/settings`); }; export default function VercelIntegrationPage() { diff --git a/apps/webapp/app/routes/api.v1.orgs.$organizationSlug.projects.$projectParam.vercel.projects.ts b/apps/webapp/app/routes/api.v1.orgs.$organizationSlug.projects.$projectParam.vercel.projects.ts index aa97265caf..aaf5468588 100644 --- a/apps/webapp/app/routes/api.v1.orgs.$organizationSlug.projects.$projectParam.vercel.projects.ts +++ b/apps/webapp/app/routes/api.v1.orgs.$organizationSlug.projects.$projectParam.vercel.projects.ts @@ -1,5 +1,6 @@ import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; +import { fromPromise } from "neverthrow"; import { z } from "zod"; import { prisma } from "~/db.server"; import { apiCors } from "~/utils/apiCors"; @@ -47,79 +48,46 @@ export async function loader({ request, params }: LoaderFunctionArgs) { const { organizationSlug, projectParam } = parsedParams.data; - try { - // Find the project, verifying org membership - const project = await prisma.project.findFirst({ - where: { - slug: projectParam, - organization: { - slug: organizationSlug, - members: { - some: { - userId: authenticationResult.userId, + const result = await fromPromise( + (async () => { + // Find the project, verifying org membership + const project = await prisma.project.findFirst({ + where: { + slug: projectParam, + organization: { + slug: organizationSlug, + members: { + some: { + userId: authenticationResult.userId, + }, }, }, + deletedAt: null, }, - deletedAt: null, - }, - select: { - id: true, - name: true, - slug: true, - organizationId: true, - }, - }); + select: { + id: true, + name: true, + slug: true, + organizationId: true, + }, + }); - if (!project) { - return apiCors( - request, - json({ error: "Project not found" }, { status: 404 }) - ); - } - - // Get Vercel integration for the project - const vercelService = new VercelIntegrationService(); - const integration = await vercelService.getVercelProjectIntegration(project.id); - - if (!integration) { - return apiCors( - request, - json({ - connected: false, - vercelProject: null, - config: null, - syncEnvVarsMapping: null, - }) - ); - } - - const { parsedIntegrationData } = integration; + if (!project) { + return { type: "not_found" as const }; + } - return apiCors( - request, - json({ - connected: true, - vercelProject: { - id: parsedIntegrationData.vercelProjectId, - name: parsedIntegrationData.vercelProjectName, - teamId: parsedIntegrationData.vercelTeamId, - }, - config: { - atomicBuilds: parsedIntegrationData.config.atomicBuilds, - pullEnvVarsBeforeBuild: parsedIntegrationData.config.pullEnvVarsBeforeBuild, - vercelStagingEnvironment: parsedIntegrationData.config.vercelStagingEnvironment, - }, - syncEnvVarsMapping: parsedIntegrationData.syncEnvVarsMapping, - triggerProject: { - id: project.id, - name: project.name, - slug: project.slug, - }, - }) - ); - } catch (error) { + // Get Vercel integration for the project + const vercelService = new VercelIntegrationService(); + const integration = await vercelService.getVercelProjectIntegration(project.id); + + return { type: "success" as const, project, integration }; + })(), + (error) => error + ); + + if (result.isErr()) { logger.error("Failed to fetch Vercel projects", { - error, + error: result.error, organizationSlug, projectParam, }); @@ -129,5 +97,51 @@ export async function loader({ request, params }: LoaderFunctionArgs) { json({ error: "Internal server error" }, { status: 500 }) ); } + + if (result.value.type === "not_found") { + return apiCors( + request, + json({ error: "Project not found" }, { status: 404 }) + ); + } + + const { project, integration } = result.value; + + if (!integration) { + return apiCors( + request, + json({ + connected: false, + vercelProject: null, + config: null, + syncEnvVarsMapping: null, + }) + ); + } + + const { parsedIntegrationData } = integration; + + return apiCors( + request, + json({ + connected: true, + vercelProject: { + id: parsedIntegrationData.vercelProjectId, + name: parsedIntegrationData.vercelProjectName, + teamId: parsedIntegrationData.vercelTeamId, + }, + config: { + atomicBuilds: parsedIntegrationData.config.atomicBuilds, + pullEnvVarsBeforeBuild: parsedIntegrationData.config.pullEnvVarsBeforeBuild, + vercelStagingEnvironment: parsedIntegrationData.config.vercelStagingEnvironment, + }, + syncEnvVarsMapping: parsedIntegrationData.syncEnvVarsMapping, + triggerProject: { + id: project.id, + name: project.name, + slug: project.slug, + }, + }) + ); } diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx index 9dcc8920c8..2d6f71010b 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx @@ -59,6 +59,7 @@ import { getAvailableEnvSlugs, getAvailableEnvSlugsForBuildSettings, } from "~/v3/vercel/vercelProjectIntegrationSchema"; +import { fromPromise } from "neverthrow"; import { useEffect, useState } from "react"; export type ConnectedVercelProject = { @@ -233,124 +234,136 @@ export async function action({ request, params }: ActionFunctionArgs) { const vercelService = new VercelIntegrationService(); const { action: actionType } = submission.value; - if (actionType === "update-config") { - const { - atomicBuilds, - pullEnvVarsBeforeBuild, - discoverEnvVars, - vercelStagingEnvironment, - } = submission.value; - - const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment); - - const result = await vercelService.updateVercelIntegrationConfig(project.id, { - atomicBuilds: atomicBuilds as EnvSlug[] | null, - pullEnvVarsBeforeBuild: pullEnvVarsBeforeBuild as EnvSlug[] | null, - discoverEnvVars: discoverEnvVars as EnvSlug[] | null, - vercelStagingEnvironment: parsedStagingEnv, - }); + switch (actionType) { + case "update-config": { + const { + atomicBuilds, + pullEnvVarsBeforeBuild, + discoverEnvVars, + vercelStagingEnvironment, + } = submission.value; + + const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment); + + const result = await vercelService.updateVercelIntegrationConfig(project.id, { + atomicBuilds: atomicBuilds as EnvSlug[] | null, + pullEnvVarsBeforeBuild: pullEnvVarsBeforeBuild as EnvSlug[] | null, + discoverEnvVars: discoverEnvVars as EnvSlug[] | null, + vercelStagingEnvironment: parsedStagingEnv, + }); + + if (result) { + return redirectWithSuccessMessage(settingsPath, request, "Vercel settings updated successfully"); + } - if (result) { - return redirectWithSuccessMessage(settingsPath, request, "Vercel settings updated successfully"); + return redirectWithErrorMessage(settingsPath, request, "Failed to update Vercel settings"); } - return redirectWithErrorMessage(settingsPath, request, "Failed to update Vercel settings"); - } + case "disconnect": { + const success = await vercelService.disconnectVercelProject(project.id); - if (actionType === "disconnect") { - const success = await vercelService.disconnectVercelProject(project.id); + if (success) { + return redirectWithSuccessMessage(settingsPath, request, "Vercel project disconnected"); + } - if (success) { - return redirectWithSuccessMessage(settingsPath, request, "Vercel project disconnected"); + return redirectWithErrorMessage(settingsPath, request, "Failed to disconnect Vercel project"); } - return redirectWithErrorMessage(settingsPath, request, "Failed to disconnect Vercel project"); - } - - if (actionType === "complete-onboarding") { - const { - vercelStagingEnvironment, - pullEnvVarsBeforeBuild, - atomicBuilds, - discoverEnvVars, - syncEnvVarsMapping, - next, - skipRedirect, - } = submission.value; - - let parsedMapping: SyncEnvVarsMapping = {}; - if (syncEnvVarsMapping) { - try { - parsedMapping = JSON.parse(syncEnvVarsMapping) as SyncEnvVarsMapping; - } catch (e) { - logger.error("Failed to parse syncEnvVarsMapping", { error: e }); + case "complete-onboarding": { + const { + vercelStagingEnvironment, + pullEnvVarsBeforeBuild, + atomicBuilds, + discoverEnvVars, + syncEnvVarsMapping, + next, + skipRedirect, + } = submission.value; + + let parsedMapping: SyncEnvVarsMapping = {}; + if (syncEnvVarsMapping) { + try { + parsedMapping = JSON.parse(syncEnvVarsMapping) as SyncEnvVarsMapping; + } catch (e) { + logger.error("Failed to parse syncEnvVarsMapping", { error: e }); + } } - } - const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment); + const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment); - const result = await vercelService.completeOnboarding(project.id, { - vercelStagingEnvironment: parsedStagingEnv, - pullEnvVarsBeforeBuild: pullEnvVarsBeforeBuild as EnvSlug[] | null, - atomicBuilds: atomicBuilds as EnvSlug[] | null, - discoverEnvVars: discoverEnvVars as EnvSlug[] | null, - syncEnvVarsMapping: parsedMapping, - }); + const result = await vercelService.completeOnboarding(project.id, { + vercelStagingEnvironment: parsedStagingEnv, + pullEnvVarsBeforeBuild: pullEnvVarsBeforeBuild as EnvSlug[] | null, + atomicBuilds: atomicBuilds as EnvSlug[] | null, + discoverEnvVars: discoverEnvVars as EnvSlug[] | null, + syncEnvVarsMapping: parsedMapping, + }); - if (result) { - if (skipRedirect) { - return json({ success: true }); - } + if (result) { + if (skipRedirect) { + return json({ success: true }); + } - if (next) { - try { - const nextUrl = new URL(next); - // Only allow https URLs for security - if (nextUrl.protocol === "https:") { - return json({ success: true, redirectTo: next }); + if (next) { + try { + const nextUrl = new URL(next); + // Only allow https URLs for security + if (nextUrl.protocol === "https:") { + return json({ success: true, redirectTo: next }); + } + } catch (e) { + logger.warn("Invalid next URL provided", { next, error: e }); } - } catch (e) { - logger.warn("Invalid next URL provided", { next, error: e }); } + + return json({ success: true, redirectTo: settingsPath }); } - return json({ success: true, redirectTo: settingsPath }); + return redirectWithErrorMessage(settingsPath, request, "Failed to complete Vercel setup"); } - return redirectWithErrorMessage(settingsPath, request, "Failed to complete Vercel setup"); - } + case "update-env-mapping": { + const { vercelStagingEnvironment } = submission.value; - if (actionType === "update-env-mapping") { - const { vercelStagingEnvironment } = submission.value; + const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment); - const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment); + const result = await vercelService.updateVercelIntegrationConfig(project.id, { + vercelStagingEnvironment: parsedStagingEnv, + }); - const result = await vercelService.updateVercelIntegrationConfig(project.id, { - vercelStagingEnvironment: parsedStagingEnv, - }); + if (result) { + return json({ success: true }); + } - if (result) { - return json({ success: true }); + return json({ success: false, error: "Failed to update environment mapping" }, { status: 400 }); } - return json({ success: false, error: "Failed to update environment mapping" }, { status: 400 }); - } + case "skip-onboarding": { + return redirectWithSuccessMessage(settingsPath, request, "Vercel integration setup skipped"); + } - if (actionType === "skip-onboarding") { - return redirectWithSuccessMessage(settingsPath, request, "Vercel integration setup skipped"); - } + case "select-vercel-project": { + const { vercelProjectId, vercelProjectName } = submission.value; - if (actionType === "select-vercel-project") { - const { vercelProjectId, vercelProjectName } = submission.value; + const selectResult = await fromPromise( + vercelService.selectVercelProject({ + organizationId: project.organizationId, + projectId: project.id, + vercelProjectId, + vercelProjectName, + userId, + }), + (error) => error + ); - try { - const { integration, syncResult } = await vercelService.selectVercelProject({ - organizationId: project.organizationId, - projectId: project.id, - vercelProjectId, - vercelProjectName, - userId, - }); + if (selectResult.isErr()) { + logger.error("Failed to select Vercel project", { error: selectResult.error }); + return json({ + error: "Failed to connect Vercel project. Please try again.", + }); + } + + const { integration, syncResult } = selectResult.value; if (!syncResult.success && syncResult.errors.length > 0) { logger.warn("Failed to send trigger secrets to Vercel", { @@ -365,51 +378,61 @@ export async function action({ request, params }: ActionFunctionArgs) { integrationId: integration.id, syncErrors: syncResult.errors, }); - } catch (error) { - logger.error("Failed to select Vercel project", { error }); - return json({ - error: "Failed to connect Vercel project. Please try again.", - }); } - } - if (actionType === "disable-auto-assign") { - try { - const orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationForProject( - project.id - ); + case "disable-auto-assign": { + const disableResult = await fromPromise( + (async () => { + const orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationForProject( + project.id + ); - if (!orgIntegration) { - return redirectWithErrorMessage(settingsPath, request, "No Vercel integration found"); - } + if (!orgIntegration) { + return { success: false as const, errorMessage: "No Vercel integration found" }; + } - const client = await VercelIntegrationRepository.getVercelClient(orgIntegration); - const projectIntegration = await vercelService.getVercelProjectIntegration(project.id); + const client = await VercelIntegrationRepository.getVercelClient(orgIntegration); + const projectIntegration = await vercelService.getVercelProjectIntegration(project.id); - if (!projectIntegration) { - return redirectWithErrorMessage(settingsPath, request, "No Vercel project connected"); - } + if (!projectIntegration) { + return { success: false as const, errorMessage: "No Vercel project connected" }; + } + + const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration); + const result = await VercelIntegrationRepository.disableAutoAssignCustomDomains( + client, + projectIntegration.parsedIntegrationData.vercelProjectId, + teamId + ); - const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration); - const result = await VercelIntegrationRepository.disableAutoAssignCustomDomains( - client, - projectIntegration.parsedIntegrationData.vercelProjectId, - teamId + return { success: result.success, errorMessage: null }; + })(), + (error) => error ); - if (result.success) { + if (disableResult.isErr()) { + logger.error("Failed to disable auto-assign custom domains", { error: disableResult.error }); + return redirectWithErrorMessage(settingsPath, request, "Failed to disable auto-assign custom domains"); + } + + const { success: disableSuccess, errorMessage } = disableResult.value; + + if (disableSuccess) { return redirectWithSuccessMessage(settingsPath, request, "Auto-assign custom domains disabled"); } - return redirectWithErrorMessage(settingsPath, request, "Failed to disable auto-assign custom domains"); - } catch (error) { - logger.error("Failed to disable auto-assign custom domains", { error }); - return redirectWithErrorMessage(settingsPath, request, "Failed to disable auto-assign custom domains"); + return redirectWithErrorMessage( + settingsPath, + request, + errorMessage ?? "Failed to disable auto-assign custom domains" + ); + } + + default: { + submission.value satisfies never; + return redirectBackWithErrorMessage(request, "Failed to process request"); } } - - submission.value satisfies never; - return redirectBackWithErrorMessage(request, "Failed to process request"); } export function vercelResourcePath( diff --git a/apps/webapp/app/routes/vercel.connect.tsx b/apps/webapp/app/routes/vercel.connect.tsx index 8e1c3b5a9d..3fdf9653f2 100644 --- a/apps/webapp/app/routes/vercel.connect.tsx +++ b/apps/webapp/app/routes/vercel.connect.tsx @@ -1,5 +1,6 @@ import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; import { redirect } from "@remix-run/server-runtime"; +import { fromPromise } from "neverthrow"; import { z } from "zod"; import { prisma } from "~/db.server"; import { VercelIntegrationRepository, type TokenResponse } from "~/models/vercelIntegration.server"; @@ -143,23 +144,26 @@ export async function loader({ request }: LoaderFunctionArgs) { { slug: environment.slug } ); - try { - await createOrFindVercelIntegration(stateData.organizationId, stateData.projectId, tokenResponse, configurationId, origin); + const result = await fromPromise( + createOrFindVercelIntegration(stateData.organizationId, stateData.projectId, tokenResponse, configurationId, origin), + (error) => error + ); - logger.info("Vercel organization integration created successfully", { - organizationId: stateData.organizationId, - projectId: stateData.projectId, - teamId: tokenResponse.teamId, - }); + if (result.isErr()) { + logger.error("Failed to complete Vercel integration", { error: result.error }); + throw redirect(settingsPath); + } - const params = new URLSearchParams({ vercelOnboarding: "true", origin }); - if (next) { - params.set("next", next); - } + logger.info("Vercel organization integration created successfully", { + organizationId: stateData.organizationId, + projectId: stateData.projectId, + teamId: tokenResponse.teamId, + }); - return redirect(`${settingsPath}?${params.toString()}`); - } catch (error) { - logger.error("Failed to complete Vercel integration", { error }); - throw redirect(settingsPath); + const params = new URLSearchParams({ vercelOnboarding: "true", origin }); + if (next) { + params.set("next", next); } + + return redirect(`${settingsPath}?${params.toString()}`); } diff --git a/apps/webapp/app/routes/vercel.onboarding.tsx b/apps/webapp/app/routes/vercel.onboarding.tsx index 5f4def4191..d9406f25b1 100644 --- a/apps/webapp/app/routes/vercel.onboarding.tsx +++ b/apps/webapp/app/routes/vercel.onboarding.tsx @@ -1,5 +1,6 @@ import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; import { json, redirect } from "@remix-run/server-runtime"; +import { fromPromise } from "neverthrow"; import { useEffect, useState } from "react"; import { Form, useNavigation } from "@remix-run/react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; @@ -234,31 +235,34 @@ export async function action({ request }: ActionFunctionArgs) { return json({ error: "Environment not found" }, { status: 404 }); } - try { - const state = await generateVercelOAuthState({ + const stateResult = await fromPromise( + generateVercelOAuthState({ organizationId: project.organizationId, projectId: project.id, environmentSlug: environment.slug, organizationSlug: project.organization.slug, projectSlug: project.slug, - }); - - const params = new URLSearchParams(); - params.set("state", state); - params.set("code", code); - if (configurationId) { - params.set("configurationId", configurationId); - } - params.set("origin", "marketplace"); - if (next) { - params.set("next", next); - } + }), + (error) => error + ); - return redirect(`/vercel/connect?${params.toString()}`, 303); - } catch (error) { - logger.error("Failed to generate Vercel OAuth state", { error }); + if (stateResult.isErr()) { + logger.error("Failed to generate Vercel OAuth state", { error: stateResult.error }); return json({ error: "Failed to generate installation state" }, { status: 500 }); } + + const params = new URLSearchParams(); + params.set("state", stateResult.value); + params.set("code", code); + if (configurationId) { + params.set("configurationId", configurationId); + } + params.set("origin", "marketplace"); + if (next) { + params.set("next", next); + } + + return redirect(`/vercel/connect?${params.toString()}`, 303); } export default function VercelOnboardingPage() {