diff --git a/apps/webapp/app/components/integrations/VercelLink.tsx b/apps/webapp/app/components/integrations/VercelLink.tsx new file mode 100644 index 00000000000..4a74e599f3d --- /dev/null +++ b/apps/webapp/app/components/integrations/VercelLink.tsx @@ -0,0 +1,22 @@ +import { VercelLogo } from "./VercelLogo"; +import { LinkButton } from "~/components/primitives/Buttons"; +import { SimpleTooltip } from "~/components/primitives/Tooltip"; + +export function VercelLink({ vercelDeploymentUrl }: { vercelDeploymentUrl: string }) { + return ( + } + iconSpacing="gap-x-1" + to={vercelDeploymentUrl} + className="pl-1" + > + Vercel + + } + content="View on Vercel" + /> + ); +} diff --git a/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx b/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx index dc6996dab14..f3635dbd08e 100644 --- a/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx +++ b/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx @@ -44,7 +44,7 @@ import { } from "~/v3/vercel/vercelProjectIntegrationSchema"; import { type VercelCustomEnvironment } from "~/models/vercelIntegration.server"; import { type VercelOnboardingData } from "~/presenters/v3/VercelSettingsPresenter.server"; -import { vercelAppInstallPath, v3ProjectSettingsPath, githubAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder"; +import { vercelAppInstallPath, v3ProjectSettingsIntegrationsPath, githubAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder"; import type { loader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel"; import { useEffect, useState, useCallback, useRef } from "react"; import { usePostHogTracking } from "~/hooks/usePostHog"; @@ -102,6 +102,7 @@ export function VercelOnboardingModal({ hasOrgIntegration, nextUrl, onDataReload, + vercelManageAccessUrl, }: { isOpen: boolean; onClose: () => void; @@ -114,6 +115,7 @@ export function VercelOnboardingModal({ hasOrgIntegration: boolean; nextUrl?: string; onDataReload?: (vercelStagingEnvironment?: string) => void; + vercelManageAccessUrl?: string; }) { const { capture, startSessionRecording } = usePostHogTracking(); const navigation = useNavigation(); @@ -122,7 +124,8 @@ export function VercelOnboardingModal({ const completeOnboardingFetcher = useFetcher(); const { Form: CompleteOnboardingForm } = completeOnboardingFetcher; const [searchParams] = useSearchParams(); - const fromMarketplaceContext = searchParams.get("origin") === "marketplace"; + const origin = searchParams.get("origin"); + const fromMarketplaceContext = origin === "marketplace"; const availableProjects = onboardingData?.availableProjects || []; const hasProjectSelected = onboardingData?.hasProjectSelected ?? false; @@ -543,8 +546,15 @@ export function VercelOnboardingModal({ if (!isGitHubConnectedForOnboarding) { setState("github-connection"); + capture("vercel onboarding github step viewed", { + origin: fromMarketplaceContext ? "marketplace" : "dashboard", + step: "github-connection", + organization_slug: organizationSlug, + project_slug: projectSlug, + github_app_installed: gitHubAppInstallations.length > 0, + }); } - }, [vercelStagingEnvironment, pullEnvVarsBeforeBuild, atomicBuilds, discoverEnvVars, syncEnvVarsMapping, nextUrl, fromMarketplaceContext, isGitHubConnectedForOnboarding, completeOnboardingFetcher, actionUrl, trackOnboarding]); + }, [vercelStagingEnvironment, pullEnvVarsBeforeBuild, atomicBuilds, discoverEnvVars, syncEnvVarsMapping, nextUrl, fromMarketplaceContext, isGitHubConnectedForOnboarding, completeOnboardingFetcher, actionUrl, trackOnboarding, capture, organizationSlug, projectSlug, gitHubAppInstallations.length]); const handleFinishOnboarding = useCallback((e: React.FormEvent) => { e.preventDefault(); @@ -639,7 +649,7 @@ export function VercelOnboardingModal({ onClose(); } }}> - + e.preventDefault()}>
@@ -727,14 +737,25 @@ export function VercelOnboardingModal({ - {fetcher.state !== "idle" ? "Connecting..." : "Connect Project"} - +
+ {vercelManageAccessUrl && !origin && ( + + Manage access + + )} + +
} cancelButton={
+
+
+ +
+
diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index db86425b67b..d7ac89573be 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -11,6 +11,7 @@ import { Cog8ToothIcon, CogIcon, ExclamationTriangleIcon, + PuzzlePieceIcon, FolderIcon, FolderOpenIcon, GlobeAmericasIcon, @@ -74,7 +75,8 @@ import { v3LogsPath, v3ProjectAlertsPath, v3ProjectPath, - v3ProjectSettingsPath, + v3ProjectSettingsGeneralPath, + v3ProjectSettingsIntegrationsPath, v3QueuesPath, v3RunsPath, v3SchedulesPath, @@ -589,13 +591,34 @@ export function SideMenu({ data-action="limits" isCollapsed={isCollapsed} /> + + + + diff --git a/apps/webapp/app/components/navigation/sideMenuTypes.ts b/apps/webapp/app/components/navigation/sideMenuTypes.ts index 64afdf58e65..8dc722a37d1 100644 --- a/apps/webapp/app/components/navigation/sideMenuTypes.ts +++ b/apps/webapp/app/components/navigation/sideMenuTypes.ts @@ -1,7 +1,7 @@ import { z } from "zod"; // Valid section IDs that can have their collapsed state toggled -export const SideMenuSectionIdSchema = z.enum(["manage", "metrics"]); +export const SideMenuSectionIdSchema = z.enum(["manage", "metrics", "project-settings"]); // Inferred type from the schema export type SideMenuSectionId = z.infer; diff --git a/apps/webapp/app/models/vercelIntegration.server.ts b/apps/webapp/app/models/vercelIntegration.server.ts index c31b8bde27a..7bf46286808 100644 --- a/apps/webapp/app/models/vercelIntegration.server.ts +++ b/apps/webapp/app/models/vercelIntegration.server.ts @@ -975,6 +975,13 @@ export class VercelIntegrationRepository { return { created: 0, updated: 0, errors: [] as string[] }; } + await this.removeAllVercelEnvVarsByKey({ + client, + vercelProjectId: params.vercelProjectId, + teamId: params.teamId, + key: "TRIGGER_SECRET_KEY", + }); + const result = await this.batchUpsertVercelEnvVars({ client, vercelProjectId: params.vercelProjectId, @@ -1526,6 +1533,35 @@ export class VercelIntegrationRepository { return { created, updated, errors }; } + private static async removeAllVercelEnvVarsByKey(params: { + client: Vercel; + vercelProjectId: string; + teamId: string | null; + key: string; + }): Promise { + const { client, vercelProjectId, teamId, key } = params; + + const existingEnvs = await client.projects.filterProjectEnvs({ + idOrName: vercelProjectId, + ...(teamId && { teamId }), + }); + + const envs = extractVercelEnvs(existingEnvs); + const idsToRemove = envs + .filter((env) => env.key === key && env.id) + .map((env) => env.id!); + + if (idsToRemove.length === 0) { + return; + } + + await client.projects.batchRemoveProjectEnv({ + idOrName: vercelProjectId, + ...(teamId && { teamId }), + requestBody: { ids: idsToRemove }, + }); + } + private static async upsertVercelEnvVar(params: { client: Vercel; vercelProjectId: string; diff --git a/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts b/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts index 1f5996f9967..9abc0ed0ab9 100644 --- a/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts @@ -10,7 +10,10 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { type User } from "~/models/user.server"; import { processGitMetadata } from "./BranchesPresenter.server"; import { BranchTrackingConfigSchema, getTrackedBranchForEnvironment } from "~/v3/github"; -import { VercelProjectIntegrationDataSchema } from "~/v3/vercel/vercelProjectIntegrationSchema"; +import { + VercelProjectIntegrationDataSchema, + buildVercelDeploymentUrl, +} from "~/v3/vercel/vercelProjectIntegrationSchema"; const pageSize = 20; @@ -232,8 +235,11 @@ LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`; let vercelDeploymentUrl: string | null = null; if (hasVercelIntegration && deployment.integrationDeploymentId && vercelTeamSlug && vercelProjectName) { - const vercelId = deployment.integrationDeploymentId.replace(/^dpl_/, ""); - vercelDeploymentUrl = `https://vercel.com/${vercelTeamSlug}/${vercelProjectName}/${vercelId}`; + vercelDeploymentUrl = buildVercelDeploymentUrl( + vercelTeamSlug, + vercelProjectName, + deployment.integrationDeploymentId + ); } return { diff --git a/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts b/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts index bc494c118aa..c5414290e4e 100644 --- a/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts @@ -12,6 +12,7 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { type User } from "~/models/user.server"; import { getUsername } from "~/utils/username"; import { processGitMetadata } from "./BranchesPresenter.server"; +import { VercelProjectIntegrationDataSchema } from "~/v3/vercel/vercelProjectIntegrationSchema"; import { S2 } from "@s2-dev/streamstore"; import { env } from "~/env.server"; import { createRedisClient } from "~/redis.server"; @@ -161,6 +162,51 @@ export class DeploymentPresenter { }); const gitMetadata = processGitMetadata(deployment.git); + + // Look up Vercel integration data to construct a deployment URL + let vercelDeploymentUrl: string | undefined; + const vercelProjectIntegration = + await this.#prismaClient.organizationProjectIntegration.findFirst({ + where: { + projectId: project.id, + deletedAt: null, + organizationIntegration: { + service: "VERCEL", + deletedAt: null, + }, + }, + select: { + integrationData: true, + }, + }); + + if (vercelProjectIntegration) { + const parsed = VercelProjectIntegrationDataSchema.safeParse( + vercelProjectIntegration.integrationData + ); + + if (parsed.success && parsed.data.vercelTeamSlug) { + const integrationDeployment = + await this.#prismaClient.integrationDeployment.findFirst({ + where: { + deploymentId: deployment.id, + integrationName: "vercel", + }, + select: { + integrationDeploymentId: true, + }, + orderBy: { + createdAt: "desc", + }, + }); + + if (integrationDeployment) { + const vercelId = integrationDeployment.integrationDeploymentId.replace(/^dpl_/, ""); + vercelDeploymentUrl = `https://vercel.com/${parsed.data.vercelTeamSlug}/${parsed.data.vercelProjectName}/${vercelId}`; + } + } + } + const externalBuildData = deployment.externalBuildData ? ExternalBuildData.safeParse(deployment.externalBuildData) : undefined; @@ -227,6 +273,7 @@ export class DeploymentPresenter { type: deployment.type, git: gitMetadata, triggeredVia: deployment.triggeredVia, + vercelDeploymentUrl, }, }; } diff --git a/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts b/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts index 26688d41fdd..4a57e3ec0ef 100644 --- a/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts @@ -40,6 +40,8 @@ export type VercelSettingsResult = { customEnvironments: VercelCustomEnvironment[]; /** Whether autoAssignCustomDomains is enabled on the Vercel project. null if unknown. */ autoAssignCustomDomains?: boolean | null; + /** URL to manage Vercel integration access (project sharing) on vercel.com */ + vercelManageAccessUrl?: string; }; export type VercelAvailableProject = { @@ -242,11 +244,12 @@ export class VercelSettingsPresenter extends BasePresenter { checkPreviewEnvironment(), getVercelProjectIntegration(), ]).andThen(([hasOrgIntegration, isGitHubConnected, hasStagingEnvironment, hasPreviewEnvironment, connectedProject]) => { - const fetchCustomEnvsAndProjectSettings = async (): Promise<{ + const fetchVercelData = async (): Promise<{ customEnvironments: VercelCustomEnvironment[]; autoAssignCustomDomains: boolean | null; + vercelManageAccessUrl?: string; }> => { - if (!connectedProject || !orgIntegration) { + if (!orgIntegration) { return { customEnvironments: [], autoAssignCustomDomains: null }; } const clientResult = await VercelIntegrationRepository.getVercelClient(orgIntegration); @@ -255,6 +258,26 @@ export class VercelSettingsPresenter extends BasePresenter { } const client = clientResult.value; const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration); + + // Build manage access URL + let vercelManageAccessUrl: string | undefined; + const appSlug = env.VERCEL_INTEGRATION_APP_SLUG; + const integrationData = orgIntegration.integrationData as Record | null; + const installationId = + typeof integrationData?.installationId === "string" + ? integrationData.installationId + : undefined; + if (appSlug && installationId && teamId) { + const teamSlugResult = await VercelIntegrationRepository.getTeamSlug(client, teamId); + if (teamSlugResult.isOk()) { + vercelManageAccessUrl = `https://vercel.com/${teamSlugResult.value}/~/integrations/${appSlug}/${installationId}`; + } + } + + if (!connectedProject) { + return { customEnvironments: [], autoAssignCustomDomains: null, vercelManageAccessUrl }; + } + const [customEnvsResult, autoAssignResult] = await Promise.all([ VercelIntegrationRepository.getVercelCustomEnvironments( client, @@ -270,13 +293,14 @@ export class VercelSettingsPresenter extends BasePresenter { return { customEnvironments: customEnvsResult.isOk() ? customEnvsResult.value : [], autoAssignCustomDomains: autoAssignResult.isOk() ? autoAssignResult.value : null, + vercelManageAccessUrl, }; }; return fromPromise( - fetchCustomEnvsAndProjectSettings(), + fetchVercelData(), (error) => ({ type: "other" as const, cause: error }) - ).map(({ customEnvironments, autoAssignCustomDomains }) => ({ + ).map(({ customEnvironments, autoAssignCustomDomains, vercelManageAccessUrl }) => ({ enabled: true, hasOrgIntegration, authInvalid: false, @@ -286,6 +310,7 @@ export class VercelSettingsPresenter extends BasePresenter { hasPreviewEnvironment, customEnvironments, autoAssignCustomDomains, + vercelManageAccessUrl, } as VercelSettingsResult)); }).mapErr((error) => { // Log the error and return a safe fallback diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx index 9d32d89fd56..4a7d1df5ce8 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx @@ -15,6 +15,7 @@ import { } from "lucide-react"; import { ExitIcon } from "~/assets/icons/ExitIcon"; import { GitMetadata } from "~/components/GitMetadata"; +import { VercelLink } from "~/components/integrations/VercelLink"; import { RuntimeIcon } from "~/components/RuntimeIcon"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; @@ -516,6 +517,16 @@ export default function Page() { })()} + {deployment.vercelDeploymentUrl && ( + + Linked + +
+ +
+
+
+ )}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx index 61e789e138b..a42b39c4573 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx @@ -19,7 +19,7 @@ import { useEffect } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { PromoteIcon } from "~/assets/icons/PromoteIcon"; -import { VercelLogo } from "~/components/integrations/VercelLogo"; +import { VercelLink } from "~/components/integrations/VercelLink"; import { DeploymentsNone, DeploymentsNoneDev } from "~/components/BlankStatePanels"; import { OctoKitty } from "~/components/GitHubLoginButton"; import { GitMetadata } from "~/components/GitMetadata"; @@ -56,7 +56,6 @@ import { TableHeaderCell, TableRow, } from "~/components/primitives/Table"; -import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { DeploymentStatus, deploymentStatusDescription, @@ -76,7 +75,7 @@ import { EnvironmentParamSchema, docsPath, v3DeploymentPath, - v3ProjectSettingsPath, + v3ProjectSettingsIntegrationsPath, } from "~/utils/pathBuilder"; import { createSearchParams } from "~/utils/searchParams"; import { compareDeploymentVersions } from "~/v3/utils/deploymentVersions"; @@ -314,20 +313,14 @@ export default function Page() { {hasVercelIntegration && ( {deployment.vercelDeploymentUrl ? ( - e.stopPropagation()} - > - - - } - content="View on Vercel" - /> +
e.stopPropagation()} + > + +
) : ( "–" )} @@ -377,7 +370,7 @@ export default function Page() { )} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx new file mode 100644 index 00000000000..371ddd52fe8 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx @@ -0,0 +1,293 @@ +import { conform, useForm } from "@conform-to/react"; +import { parse } from "@conform-to/zod"; +import { ExclamationTriangleIcon, FolderIcon, TrashIcon } from "@heroicons/react/20/solid"; +import { Form, useActionData, useNavigation } from "@remix-run/react"; +import { type ActionFunction, json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { InlineCode } from "~/components/code/InlineCode"; +import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout"; +import { Button } from "~/components/primitives/Buttons"; +import { ClipboardField } from "~/components/primitives/ClipboardField"; +import { Fieldset } from "~/components/primitives/Fieldset"; +import { FormButtons } from "~/components/primitives/FormButtons"; +import { FormError } from "~/components/primitives/FormError"; +import { Header2 } from "~/components/primitives/Headers"; +import { Hint } from "~/components/primitives/Hint"; +import { Input } from "~/components/primitives/Input"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { Label } from "~/components/primitives/Label"; +import { SpinnerWhite } from "~/components/primitives/Spinner"; +import { useProject } from "~/hooks/useProject"; +import { + redirectWithErrorMessage, + redirectWithSuccessMessage, +} from "~/models/message.server"; +import { ProjectSettingsService } from "~/services/projectSettings.server"; +import { logger } from "~/services/logger.server"; +import { requireUserId } from "~/services/session.server"; +import { organizationPath, v3ProjectPath } from "~/utils/pathBuilder"; +import { useState } from "react"; + +function createSchema( + constraints: { + getSlugMatch?: (slug: string) => { isMatch: boolean; projectSlug: string }; + } = {} +) { + return z.discriminatedUnion("action", [ + z.object({ + action: z.literal("rename"), + projectName: z.string().min(3, "Project name must have at least 3 characters").max(50), + }), + z.object({ + action: z.literal("delete"), + projectSlug: z.string().superRefine((slug, ctx) => { + if (constraints.getSlugMatch === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: conform.VALIDATION_UNDEFINED, + }); + } else { + const { isMatch, projectSlug } = constraints.getSlugMatch(slug); + if (isMatch) { + return; + } + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `The slug must match ${projectSlug}`, + }); + } + }), + }), + ]); +} + +export const action: ActionFunction = async ({ request, params }) => { + const userId = await requireUserId(request); + const { organizationSlug, projectParam } = params; + if (!organizationSlug || !projectParam) { + return json({ errors: { body: "organizationSlug is required" } }, { status: 400 }); + } + + const formData = await request.formData(); + + const schema = createSchema({ + getSlugMatch: (slug) => { + return { isMatch: slug === projectParam, projectSlug: projectParam }; + }, + }); + const submission = parse(formData, { schema }); + + if (!submission.value || submission.intent !== "submit") { + return json(submission); + } + + const projectSettingsService = new ProjectSettingsService(); + const membershipResultOrFail = await projectSettingsService.verifyProjectMembership( + organizationSlug, + projectParam, + userId + ); + + if (membershipResultOrFail.isErr()) { + return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 }); + } + + const { projectId } = membershipResultOrFail.value; + + switch (submission.value.action) { + case "rename": { + const resultOrFail = await projectSettingsService.renameProject( + projectId, + submission.value.projectName + ); + + if (resultOrFail.isErr()) { + switch (resultOrFail.error.type) { + case "other": + default: { + resultOrFail.error.type satisfies "other"; + + logger.error("Failed to rename project", { + error: resultOrFail.error, + }); + return json({ errors: { body: "Failed to rename project" } }, { status: 400 }); + } + } + } + + return redirectWithSuccessMessage( + v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), + request, + `Project renamed to ${submission.value.projectName}` + ); + } + case "delete": { + const resultOrFail = await projectSettingsService.deleteProject(projectParam, userId); + + if (resultOrFail.isErr()) { + switch (resultOrFail.error.type) { + case "other": + default: { + resultOrFail.error.type satisfies "other"; + + logger.error("Failed to delete project", { + error: resultOrFail.error, + }); + return redirectWithErrorMessage( + v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), + request, + `Project ${projectParam} could not be deleted` + ); + } + } + } + + return redirectWithSuccessMessage( + organizationPath({ slug: organizationSlug }), + request, + "Project deleted" + ); + } + } +}; + +export default function GeneralSettingsPage() { + const project = useProject(); + const lastSubmission = useActionData(); + const navigation = useNavigation(); + + const [hasRenameFormChanges, setHasRenameFormChanges] = useState(false); + + const [renameForm, { projectName }] = useForm({ + id: "rename-project", + // TODO: type this + lastSubmission: lastSubmission as any, + shouldRevalidate: "onSubmit", + onValidate({ formData }) { + return parse(formData, { + schema: createSchema(), + }); + }, + }); + + const isRenameLoading = + navigation.formData?.get("action") === "rename" && + (navigation.state === "submitting" || navigation.state === "loading"); + + const [deleteForm, { projectSlug }] = useForm({ + id: "delete-project", + // TODO: type this + lastSubmission: lastSubmission as any, + shouldValidate: "onInput", + shouldRevalidate: "onSubmit", + onValidate({ formData }) { + return parse(formData, { + schema: createSchema({ + getSlugMatch: (slug) => ({ isMatch: slug === project.slug, projectSlug: project.slug }), + }), + }); + }, + }); + + const isDeleteLoading = + navigation.formData?.get("action") === "delete" && + (navigation.state === "submitting" || navigation.state === "loading"); + + const [deleteInputValue, setDeleteInputValue] = useState(""); + + return ( + +
+
+ General +
+
+ + + + + This goes in your{" "} + trigger.config file. + + +
+
+
+ + + { + setHasRenameFormChanges(e.target.value !== project.name); + }} + /> + {projectName.error} + + + Save + + } + /> +
+
+
+
+ +
+ Danger zone +
+
+
+ + + setDeleteInputValue(e.target.value)} + /> + {projectSlug.error} + {deleteForm.error} + + This change is irreversible, so please be certain. Type in the Project slug + {project.slug} and then press + Delete. + + + + Delete + + } + /> +
+
+
+
+
+
+ ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx new file mode 100644 index 00000000000..edb6cf9c159 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx @@ -0,0 +1,500 @@ +import { conform, useForm } from "@conform-to/react"; +import { parse } from "@conform-to/zod"; +import { Form, useActionData, useNavigation } from "@remix-run/react"; +import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; +import { typedjson, useTypedLoaderData, useTypedFetcher } from "remix-typedjson"; +import { z } from "zod"; +import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout"; +import { Button } from "~/components/primitives/Buttons"; +import { CheckboxWithLabel } from "~/components/primitives/Checkbox"; +import { Fieldset } from "~/components/primitives/Fieldset"; +import { FormButtons } from "~/components/primitives/FormButtons"; +import { FormError } from "~/components/primitives/FormError"; +import { Header2 } from "~/components/primitives/Headers"; +import { Hint } from "~/components/primitives/Hint"; +import { Input } from "~/components/primitives/Input"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { Label } from "~/components/primitives/Label"; +import { SpinnerWhite } from "~/components/primitives/Spinner"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { + redirectBackWithErrorMessage, + redirectBackWithSuccessMessage, +} from "~/models/message.server"; +import { ProjectSettingsService } from "~/services/projectSettings.server"; +import { ProjectSettingsPresenter } from "~/services/projectSettingsPresenter.server"; +import { logger } from "~/services/logger.server"; +import { requireUserId } from "~/services/session.server"; +import { EnvironmentParamSchema, v3BillingPath, vercelResourcePath } from "~/utils/pathBuilder"; +import React, { useEffect, useState, useCallback, useRef } from "react"; +import { useSearchParams } from "@remix-run/react"; +import { type BuildSettings } from "~/v3/buildSettings"; +import { GitHubSettingsPanel } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github"; +import { + VercelSettingsPanel, + VercelOnboardingModal, +} 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"; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const { projectParam, organizationSlug } = EnvironmentParamSchema.parse(params); + + const projectSettingsPresenter = new ProjectSettingsPresenter(); + const resultOrFail = await projectSettingsPresenter.getProjectSettings( + organizationSlug, + projectParam, + userId + ); + + if (resultOrFail.isErr()) { + switch (resultOrFail.error.type) { + case "project_not_found": { + throw new Response(undefined, { + status: 404, + statusText: "Project not found", + }); + } + case "other": + default: { + resultOrFail.error.type satisfies "other"; + + logger.error("Failed loading project settings", { + error: resultOrFail.error, + }); + throw new Response(undefined, { + status: 400, + statusText: "Something went wrong, please try again!", + }); + } + } + } + + const { gitHubApp, buildSettings } = resultOrFail.value; + + return typedjson({ + githubAppEnabled: gitHubApp.enabled, + buildSettings, + vercelIntegrationEnabled: OrgIntegrationRepository.isVercelSupported, + }); +}; + +const UpdateBuildSettingsFormSchema = z.object({ + action: z.literal("update-build-settings"), + triggerConfigFilePath: z + .string() + .trim() + .optional() + .transform((val) => (val ? val.replace(/^\/+/, "") : val)) + .refine((val) => !val || val.length <= 255, { + message: "Config file path must not exceed 255 characters", + }), + installCommand: z + .string() + .trim() + .optional() + .refine((val) => !val || !val.includes("\n"), { + message: "Install command must be a single line", + }) + .refine((val) => !val || val.length <= 500, { + message: "Install command must not exceed 500 characters", + }), + preBuildCommand: z + .string() + .trim() + .optional() + .refine((val) => !val || !val.includes("\n"), { + message: "Pre-build command must be a single line", + }) + .refine((val) => !val || val.length <= 500, { + message: "Pre-build command must not exceed 500 characters", + }), + useNativeBuildServer: z + .string() + .optional() + .transform((val) => val === "on"), +}); + +export const action: ActionFunction = async ({ request, params }) => { + const userId = await requireUserId(request); + const { organizationSlug, projectParam } = params; + if (!organizationSlug || !projectParam) { + return json({ errors: { body: "organizationSlug is required" } }, { status: 400 }); + } + + const formData = await request.formData(); + const submission = parse(formData, { schema: UpdateBuildSettingsFormSchema }); + + if (!submission.value || submission.intent !== "submit") { + return json(submission); + } + + const projectSettingsService = new ProjectSettingsService(); + const membershipResultOrFail = await projectSettingsService.verifyProjectMembership( + organizationSlug, + projectParam, + userId + ); + + if (membershipResultOrFail.isErr()) { + return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 }); + } + + const { projectId } = membershipResultOrFail.value; + + const { installCommand, preBuildCommand, triggerConfigFilePath, useNativeBuildServer } = + submission.value; + + const resultOrFail = await projectSettingsService.updateBuildSettings(projectId, { + installCommand: installCommand || undefined, + preBuildCommand: preBuildCommand || undefined, + triggerConfigFilePath: triggerConfigFilePath || undefined, + useNativeBuildServer: useNativeBuildServer, + }); + + if (resultOrFail.isErr()) { + switch (resultOrFail.error.type) { + case "other": + default: { + resultOrFail.error.type satisfies "other"; + + logger.error("Failed to update build settings", { + error: resultOrFail.error, + }); + return redirectBackWithErrorMessage(request, "Failed to update build settings"); + } + } + } + + return redirectBackWithSuccessMessage(request, "Build settings updated successfully"); +}; + +export default function IntegrationsSettingsPage() { + const { githubAppEnabled, buildSettings, vercelIntegrationEnabled } = + useTypedLoaderData(); + const project = useProject(); + const organization = useOrganization(); + const environment = useEnvironment(); + const [searchParams, setSearchParams] = useSearchParams(); + + // Vercel onboarding modal state + const hasQueryParam = searchParams.get("vercelOnboarding") === "true"; + const nextUrl = searchParams.get("next"); + 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 === undefined) { + // 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]); + + return ( + <> + +
+ {githubAppEnabled && ( + +
+ Git settings +
+ +
+
+ + {vercelIntegrationEnabled && ( +
+ Vercel integration +
+ +
+
+ )} + +
+ Build settings +
+ +
+
+
+ )} +
+
+ + {/* Vercel Onboarding Modal */} + {vercelIntegrationEnabled && ( + { + vercelFetcher.load( + `${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true${ + vercelEnvironmentId ? `&vercelEnvironmentId=${vercelEnvironmentId}` : "" + }` + ); + }} + /> + )} + + ); +} + +function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings }) { + const lastSubmission = useActionData() as any; + const navigation = useNavigation(); + + const [hasBuildSettingsChanges, setHasBuildSettingsChanges] = useState(false); + const [buildSettingsValues, setBuildSettingsValues] = useState({ + preBuildCommand: buildSettings?.preBuildCommand || "", + installCommand: buildSettings?.installCommand || "", + triggerConfigFilePath: buildSettings?.triggerConfigFilePath || "", + useNativeBuildServer: buildSettings?.useNativeBuildServer || false, + }); + + useEffect(() => { + const hasChanges = + buildSettingsValues.preBuildCommand !== (buildSettings?.preBuildCommand || "") || + buildSettingsValues.installCommand !== (buildSettings?.installCommand || "") || + buildSettingsValues.triggerConfigFilePath !== (buildSettings?.triggerConfigFilePath || "") || + buildSettingsValues.useNativeBuildServer !== (buildSettings?.useNativeBuildServer || false); + setHasBuildSettingsChanges(hasChanges); + }, [buildSettingsValues, buildSettings]); + + const [buildSettingsForm, fields] = useForm({ + id: "update-build-settings", + lastSubmission: lastSubmission, + shouldRevalidate: "onSubmit", + onValidate({ formData }) { + return parse(formData, { + schema: UpdateBuildSettingsFormSchema, + }); + }, + }); + + const isBuildSettingsLoading = + navigation.formData?.get("action") === "update-build-settings" && + (navigation.state === "submitting" || navigation.state === "loading"); + + return ( +
+
+ + + { + setBuildSettingsValues((prev) => ({ + ...prev, + triggerConfigFilePath: e.target.value, + })); + }} + /> + + Path to your Trigger configuration file, relative to the root directory of your repo. + + + {fields.triggerConfigFilePath.error} + + + + + + { + setBuildSettingsValues((prev) => ({ + ...prev, + installCommand: e.target.value, + })); + }} + /> + + Command to install your project dependencies. This will be run from the root directory + of your repo. Auto-detected by default. + + {fields.installCommand.error} + + + + { + setBuildSettingsValues((prev) => ({ + ...prev, + preBuildCommand: e.target.value, + })); + }} + /> + + Any command that needs to run before we build and deploy your project. This will be run + from the root directory of your repo. + + {fields.preBuildCommand.error} + +
+ + { + setBuildSettingsValues((prev) => ({ + ...prev, + useNativeBuildServer: isChecked, + })); + }} + /> + + Native build server builds do not rely on external build providers and will become the + default in the future. Version 4.2.0 or newer is required. + + + {fields.useNativeBuildServer.error} + + +
+ {buildSettingsForm.error} + + Save + + } + /> +
+
+ ); +} 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 a5a70c39af6..cc85dbb4acc 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 @@ -1,57 +1,13 @@ -import { conform, useForm } from "@conform-to/react"; -import { parse } from "@conform-to/zod"; -import { ExclamationTriangleIcon, FolderIcon, TrashIcon } from "@heroicons/react/20/solid"; -import { Form, type MetaFunction, useActionData, useNavigation } from "@remix-run/react"; -import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; -import { typedjson, useTypedLoaderData } from "remix-typedjson"; -import { z } from "zod"; -import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; -import { InlineCode } from "~/components/code/InlineCode"; -import { - MainHorizontallyCenteredContainer, - PageBody, - PageContainer, -} from "~/components/layout/AppLayout"; -import { Button } from "~/components/primitives/Buttons"; -import { CheckboxWithLabel } from "~/components/primitives/Checkbox"; -import { ClipboardField } from "~/components/primitives/ClipboardField"; -import { Fieldset } from "~/components/primitives/Fieldset"; -import { FormButtons } from "~/components/primitives/FormButtons"; -import { FormError } from "~/components/primitives/FormError"; -import { Header2 } from "~/components/primitives/Headers"; -import { Hint } from "~/components/primitives/Hint"; -import { Input } from "~/components/primitives/Input"; -import { InputGroup } from "~/components/primitives/InputGroup"; -import { Label } from "~/components/primitives/Label"; +import { Outlet, type MetaFunction } from "@remix-run/react"; +import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime"; +import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; import * as Property from "~/components/primitives/PropertyTable"; -import { SpinnerWhite } from "~/components/primitives/Spinner"; -import { useOrganization } from "~/hooks/useOrganizations"; +import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { useProject } from "~/hooks/useProject"; -import { - redirectBackWithErrorMessage, - redirectBackWithSuccessMessage, - redirectWithErrorMessage, - redirectWithSuccessMessage, -} from "~/models/message.server"; -import { ProjectSettingsService } from "~/services/projectSettings.server"; -import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; -import { organizationPath, v3ProjectPath, EnvironmentParamSchema, v3BillingPath, vercelResourcePath } from "~/utils/pathBuilder"; -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, -} 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"; +import { EnvironmentParamSchema, v3ProjectSettingsGeneralPath, v3ProjectSettingsIntegrationsPath } from "~/utils/pathBuilder"; export const meta: MetaFunction = () => { return [ @@ -62,397 +18,28 @@ export const meta: MetaFunction = () => { }; export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); - const { projectParam, organizationSlug } = EnvironmentParamSchema.parse(params); - - const projectSettingsPresenter = new ProjectSettingsPresenter(); - const resultOrFail = await projectSettingsPresenter.getProjectSettings( - organizationSlug, - projectParam, - userId - ); - - if (resultOrFail.isErr()) { - switch (resultOrFail.error.type) { - case "project_not_found": { - throw new Response(undefined, { - status: 404, - statusText: "Project not found", - }); - } - case "other": - default: { - resultOrFail.error.type satisfies "other"; - - logger.error("Failed loading project settings", { - error: resultOrFail.error, - }); - throw new Response(undefined, { - status: 400, - statusText: "Something went wrong, please try again!", - }); - } - } - } - - const { gitHubApp, buildSettings } = resultOrFail.value; - - return typedjson({ - githubAppEnabled: gitHubApp.enabled, - buildSettings, - vercelIntegrationEnabled: OrgIntegrationRepository.isVercelSupported, - }); -}; - -const UpdateBuildSettingsFormSchema = z.object({ - action: z.literal("update-build-settings"), - triggerConfigFilePath: z - .string() - .trim() - .optional() - .transform((val) => (val ? val.replace(/^\/+/, "") : val)) - .refine((val) => !val || val.length <= 255, { - message: "Config file path must not exceed 255 characters", - }), - installCommand: z - .string() - .trim() - .optional() - .refine((val) => !val || !val.includes("\n"), { - message: "Install command must be a single line", - }) - .refine((val) => !val || val.length <= 500, { - message: "Install command must not exceed 500 characters", - }), - preBuildCommand: z - .string() - .trim() - .optional() - .refine((val) => !val || !val.includes("\n"), { - message: "Pre-build command must be a single line", - }) - .refine((val) => !val || val.length <= 500, { - message: "Pre-build command must not exceed 500 characters", - }), - useNativeBuildServer: z - .string() - .optional() - .transform((val) => val === "on"), -}); - -type UpdateBuildSettingsFormSchema = z.infer; - -export function createSchema( - constraints: { - getSlugMatch?: (slug: string) => { isMatch: boolean; projectSlug: string }; - } = {} -) { - return z.discriminatedUnion("action", [ - z.object({ - action: z.literal("rename"), - projectName: z.string().min(3, "Project name must have at least 3 characters").max(50), - }), - z.object({ - action: z.literal("delete"), - projectSlug: z.string().superRefine((slug, ctx) => { - if (constraints.getSlugMatch === undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: conform.VALIDATION_UNDEFINED, - }); - } else { - const { isMatch, projectSlug } = constraints.getSlugMatch(slug); - if (isMatch) { - return; - } - - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `The slug must match ${projectSlug}`, - }); - } - }), - }), - UpdateBuildSettingsFormSchema, - ]); -} - -export const action: ActionFunction = async ({ request, params }) => { - const userId = await requireUserId(request); - const { organizationSlug, projectParam } = params; - if (!organizationSlug || !projectParam) { - return json({ errors: { body: "organizationSlug is required" } }, { status: 400 }); - } - - const formData = await request.formData(); + await requireUserId(request); + const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); - const schema = createSchema({ - getSlugMatch: (slug) => { - return { isMatch: slug === projectParam, projectSlug: projectParam }; - }, - }); - const submission = parse(formData, { schema }); + // Redirect /settings to /settings/general (or /settings/integrations for Vercel onboarding) + const url = new URL(request.url); + if (url.pathname.endsWith("/settings") || url.pathname.endsWith("/settings/")) { + const org = { slug: organizationSlug }; + const project = { slug: projectParam }; + const env = { slug: envParam }; - if (!submission.value || submission.intent !== "submit") { - return json(submission); - } + const basePath = url.searchParams.has("vercelOnboarding") + ? v3ProjectSettingsIntegrationsPath(org, project, env) + : v3ProjectSettingsGeneralPath(org, project, env); - const projectSettingsService = new ProjectSettingsService(); - const membershipResultOrFail = await projectSettingsService.verifyProjectMembership( - organizationSlug, - projectParam, - userId - ); - - if (membershipResultOrFail.isErr()) { - return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 }); + return redirect(`${basePath}${url.search}`); } - const { projectId } = membershipResultOrFail.value; - - switch (submission.value.action) { - case "rename": { - const resultOrFail = await projectSettingsService.renameProject( - projectId, - submission.value.projectName - ); - - if (resultOrFail.isErr()) { - switch (resultOrFail.error.type) { - case "other": - default: { - resultOrFail.error.type satisfies "other"; - - logger.error("Failed to rename project", { - error: resultOrFail.error, - }); - return json({ errors: { body: "Failed to rename project" } }, { status: 400 }); - } - } - } - - return redirectWithSuccessMessage( - v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), - request, - `Project renamed to ${submission.value.projectName}` - ); - } - case "delete": { - const resultOrFail = await projectSettingsService.deleteProject(projectParam, userId); - - if (resultOrFail.isErr()) { - switch (resultOrFail.error.type) { - case "other": - default: { - resultOrFail.error.type satisfies "other"; - - logger.error("Failed to delete project", { - error: resultOrFail.error, - }); - return redirectWithErrorMessage( - v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), - request, - `Project ${projectParam} could not be deleted` - ); - } - } - } - - return redirectWithSuccessMessage( - organizationPath({ slug: organizationSlug }), - request, - "Project deleted" - ); - } - case "update-build-settings": { - const { installCommand, preBuildCommand, triggerConfigFilePath, useNativeBuildServer } = - submission.value; - - const resultOrFail = await projectSettingsService.updateBuildSettings(projectId, { - installCommand: installCommand || undefined, - preBuildCommand: preBuildCommand || undefined, - triggerConfigFilePath: triggerConfigFilePath || undefined, - useNativeBuildServer: useNativeBuildServer, - }); - - if (resultOrFail.isErr()) { - switch (resultOrFail.error.type) { - case "other": - default: { - resultOrFail.error.type satisfies "other"; - - logger.error("Failed to update build settings", { - error: resultOrFail.error, - }); - return redirectBackWithErrorMessage(request, "Failed to update build settings"); - } - } - } - - return redirectBackWithSuccessMessage(request, "Build settings updated successfully"); - } - default: { - submission.value satisfies never; - return redirectBackWithErrorMessage(request, "Failed to process request"); - } - } + return null; }; -export default function Page() { - const { githubAppEnabled, buildSettings, vercelIntegrationEnabled } = - useTypedLoaderData(); +export default function SettingsLayout() { 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 nextUrl = searchParams.get("next"); - 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 === undefined) { - // 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); - - const [renameForm, { projectName }] = useForm({ - id: "rename-project", - // TODO: type this - lastSubmission: lastSubmission as any, - shouldRevalidate: "onSubmit", - onValidate({ formData }) { - return parse(formData, { - schema: createSchema(), - }); - }, - }); - - const isRenameLoading = - navigation.formData?.get("action") === "rename" && - (navigation.state === "submitting" || navigation.state === "loading"); - - const [deleteForm, { projectSlug }] = useForm({ - id: "delete-project", - // TODO: type this - lastSubmission: lastSubmission as any, - shouldValidate: "onInput", - shouldRevalidate: "onSubmit", - onValidate({ formData }) { - return parse(formData, { - schema: createSchema({ - getSlugMatch: (slug) => ({ isMatch: slug === project.slug, projectSlug: project.slug }), - }), - }); - }, - }); - - const isDeleteLoading = - navigation.formData?.get("action") === "delete" && - (navigation.state === "submitting" || navigation.state === "loading"); - - const [deleteInputValue, setDeleteInputValue] = useState(""); return ( @@ -479,302 +66,8 @@ export default function Page() { - -
-
- General -
-
- - - - - This goes in your{" "} - trigger.config file. - - -
-
-
- - - { - setHasRenameFormChanges(e.target.value !== project.name); - }} - /> - {projectName.error} - - - Save - - } - /> -
-
-
-
- - {githubAppEnabled && ( - -
- Git settings -
- -
-
- - {vercelIntegrationEnabled && ( -
- Vercel integration -
- -
-
- )} - -
- Build settings -
- -
-
-
- )} - -
- Danger zone -
-
-
- - - setDeleteInputValue(e.target.value)} - /> - {projectSlug.error} - {deleteForm.error} - - This change is irreversible, so please be certain. Type in the Project slug - {project.slug} and then press - Delete. - - - - Delete - - } - /> -
-
-
-
-
-
+
- - {/* Vercel Onboarding Modal */} - {vercelIntegrationEnabled && ( - { - vercelFetcher.load( - `${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true${ - vercelEnvironmentId ? `&vercelEnvironmentId=${vercelEnvironmentId}` : "" - }` - ); - }} - /> - )}
); } - -function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings }) { - const lastSubmission = useActionData() as any; - const navigation = useNavigation(); - - const [hasBuildSettingsChanges, setHasBuildSettingsChanges] = useState(false); - const [buildSettingsValues, setBuildSettingsValues] = useState({ - preBuildCommand: buildSettings?.preBuildCommand || "", - installCommand: buildSettings?.installCommand || "", - triggerConfigFilePath: buildSettings?.triggerConfigFilePath || "", - useNativeBuildServer: buildSettings?.useNativeBuildServer || false, - }); - - useEffect(() => { - const hasChanges = - buildSettingsValues.preBuildCommand !== (buildSettings?.preBuildCommand || "") || - buildSettingsValues.installCommand !== (buildSettings?.installCommand || "") || - buildSettingsValues.triggerConfigFilePath !== (buildSettings?.triggerConfigFilePath || "") || - buildSettingsValues.useNativeBuildServer !== (buildSettings?.useNativeBuildServer || false); - setHasBuildSettingsChanges(hasChanges); - }, [buildSettingsValues, buildSettings]); - - const [buildSettingsForm, fields] = useForm({ - id: "update-build-settings", - lastSubmission: lastSubmission, - shouldRevalidate: "onSubmit", - onValidate({ formData }) { - return parse(formData, { - schema: UpdateBuildSettingsFormSchema, - }); - }, - }); - - const isBuildSettingsLoading = - navigation.formData?.get("action") === "update-build-settings" && - (navigation.state === "submitting" || navigation.state === "loading"); - - return ( -
-
- - - { - setBuildSettingsValues((prev) => ({ - ...prev, - triggerConfigFilePath: e.target.value, - })); - }} - /> - - Path to your Trigger configuration file, relative to the root directory of your repo. - - - {fields.triggerConfigFilePath.error} - - - - - - { - setBuildSettingsValues((prev) => ({ - ...prev, - installCommand: e.target.value, - })); - }} - /> - - Command to install your project dependencies. This will be run from the root directory - of your repo. Auto-detected by default. - - {fields.installCommand.error} - - - - { - setBuildSettingsValues((prev) => ({ - ...prev, - preBuildCommand: e.target.value, - })); - }} - /> - - Any command that needs to run before we build and deploy your project. This will be run - from the root directory of your repo. - - {fields.preBuildCommand.error} - -
- - { - setBuildSettingsValues((prev) => ({ - ...prev, - useNativeBuildServer: isChecked, - })); - }} - /> - - Native build server builds do not rely on external build providers and will become the - default in the future. Version 4.2.0 or newer is required. - - - {fields.useNativeBuildServer.error} - - -
- {buildSettingsForm.error} - - Save - - } - /> -
-
- ); -} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.slack.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.slack.tsx new file mode 100644 index 00000000000..4ce3bf21cf0 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.slack.tsx @@ -0,0 +1,326 @@ +import type { ActionFunctionArgs, 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"; +import { DialogClose } from "@radix-ui/react-dialog"; +import { SlackIcon } from "@trigger.dev/companyicons"; +import { TrashIcon } from "@heroicons/react/20/solid"; +import { Button } from "~/components/primitives/Buttons"; +import { + Dialog, + DialogContent, + DialogDescription, + 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, + TableBody, + TableCell, + TableHeader, + TableHeaderCell, + TableRow, +} from "~/components/primitives/Table"; +import { EnabledStatus } from "~/components/runs/v3/EnabledStatus"; +import { $transaction, prisma } from "~/db.server"; +import { requireOrganization } from "~/services/org.server"; +import { OrganizationParamsSchema } from "~/utils/pathBuilder"; +import { logger } from "~/services/logger.server"; + +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); +} + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const { organizationSlug } = OrganizationParamsSchema.parse(params); + const { organization } = await requireOrganization(request, organizationSlug); + + const slackIntegration = await prisma.organizationIntegration.findFirst({ + where: { + organizationId: organization.id, + service: "SLACK", + deletedAt: null, + }, + }); + + if (!slackIntegration) { + return typedjson({ + organization, + slackIntegration: null, + alertChannels: [], + teamName: null, + }); + } + + const integrationData = slackIntegration.integrationData as any; + const teamName = integrationData?.team?.name ?? null; + + const alertChannels = await prisma.projectAlertChannel.findMany({ + where: { + type: "SLACK", + project: { organizationId: organization.id }, + OR: [ + { integrationId: slackIntegration.id }, + { + properties: { + path: ["integrationId"], + equals: slackIntegration.id, + }, + }, + ], + }, + include: { + project: { + select: { + id: true, + slug: true, + name: true, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + }); + + return typedjson({ + organization, + slackIntegration, + alertChannels, + teamName, + }); +}; + +const ActionSchema = z.object({ + intent: z.literal("uninstall"), +}); + +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 }); + } + + const slackIntegration = await prisma.organizationIntegration.findFirst({ + where: { + organizationId: organization.id, + service: "SLACK", + deletedAt: null, + }, + }); + + if (!slackIntegration) { + return json({ error: "Slack integration not found" }, { status: 404 }); + } + + const txResult = await fromPromise( + $transaction(prisma, async (tx) => { + await tx.projectAlertChannel.updateMany({ + where: { + type: "SLACK", + OR: [ + { integrationId: slackIntegration.id }, + { + properties: { + path: ["integrationId"], + equals: slackIntegration.id, + }, + }, + ], + }, + data: { + enabled: false, + integrationId: null, + }, + }); + + await tx.organizationIntegration.update({ + where: { id: slackIntegration.id }, + data: { deletedAt: new Date() }, + }); + }), + (error) => error + ); + + if (txResult.isErr()) { + logger.error("Failed to remove Slack integration", { + organizationId: organization.id, + organizationSlug, + userId, + integrationId: slackIntegration.id, + error: txResult.error instanceof Error ? txResult.error.message : String(txResult.error), + }); + + return json( + { error: "Failed to remove Slack integration. Please try again." }, + { status: 500 } + ); + } + + logger.info("Slack integration removed successfully", { + organizationId: organization.id, + organizationSlug, + userId, + integrationId: slackIntegration.id, + }); + + return redirect(`/orgs/${organizationSlug}/settings`); +}; + +export default function SlackIntegrationPage() { + const { slackIntegration, alertChannels, teamName } = + useTypedLoaderData(); + const actionData = useActionData(); + const navigation = useNavigation(); + const isUninstalling = + navigation.state === "submitting" && navigation.formData?.get("intent") === "uninstall"; + + if (!slackIntegration) { + return ( + + +
+ No Slack Integration Found + + This organization doesn't have a Slack integration configured. You can connect Slack + when setting up alert channels in your project settings. + +
+
+
+ ); + } + + return ( + + +
+ Slack Integration + + Manage your organization's Slack integration and connected alert channels. + +
+ + {/* Integration Info Section */} +
+
+
+

Integration Details

+
+ {teamName && ( +
+ Slack Workspace: {teamName} +
+ )} +
+ Installed:{" "} + {formatDate(new Date(slackIntegration.createdAt))} +
+
+
+
+ + + + + + + Remove Slack Integration + + + This will remove the Slack integration and disable all connected alert channels. + This action cannot be undone. + + + + + + } + cancelButton={ + + + + } + /> + + + {actionData?.error && ( + + {actionData.error} + + )} +
+
+
+ + {/* Connected Alert Channels Section */} +
+

+ Connected Alert Channels ({alertChannels.length}) +

+ + {alertChannels.length === 0 ? ( +
+ + No alert channels are currently connected to this Slack integration. + +
+ ) : ( + + + + Channel Name + Project + Status + Created + + + + {alertChannels.map((channel) => ( + + {channel.name} + {channel.project.name} + + + + {formatDate(new Date(channel.createdAt))} + + ))} + +
+ )} +
+
+
+ ); +} 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 10b3f2283ce..df6f5b9859a 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 @@ -28,7 +28,7 @@ import { requireOrganization } from "~/services/org.server"; import { OrganizationParamsSchema } from "~/utils/pathBuilder"; import { logger } from "~/services/logger.server"; import { TrashIcon } from "@heroicons/react/20/solid"; -import { v3ProjectSettingsPath } from "~/utils/pathBuilder"; +import { v3ProjectSettingsIntegrationsPath } from "~/utils/pathBuilder"; import { LinkButton } from "~/components/primitives/Buttons"; function formatDate(date: Date): string { @@ -354,7 +354,7 @@ export default function VercelIntegrationPage() { { const user = await requireUser(request); @@ -39,5 +39,5 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const selector = new SelectBestEnvironmentPresenter(); const environment = await selector.selectBestEnvironment(project.id, user, project.environments); - return redirect(v3ProjectSettingsPath({ slug: organizationSlug }, project, environment)); + return redirect(v3ProjectSettingsGeneralPath({ slug: organizationSlug }, project, environment)); }; 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 afd89f33577..38ef50126cd 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 @@ -43,7 +43,7 @@ import { requireUserId } from "~/services/session.server"; import { githubAppInstallPath, EnvironmentParamSchema, - v3ProjectSettingsPath, + v3ProjectSettingsIntegrationsPath, } from "~/utils/pathBuilder"; import { cn } from "~/utils/cn"; import { type BranchTrackingConfig } from "~/v3/github"; @@ -459,7 +459,7 @@ export function ConnectGitHubRepoModal({ navigate( githubAppInstallPath( organizationSlug, - `${v3ProjectSettingsPath( + `${v3ProjectSettingsIntegrationsPath( { slug: organizationSlug }, { slug: projectSlug }, { slug: environmentSlug } @@ -567,7 +567,7 @@ export function GitHubConnectionPrompt({ redirectUrl?: string; }) { - const githubInstallationRedirect = redirectUrl || v3ProjectSettingsPath({ slug: organizationSlug }, { slug: projectSlug }, { slug: environmentSlug }); + const githubInstallationRedirect = redirectUrl || v3ProjectSettingsIntegrationsPath({ slug: organizationSlug }, { slug: projectSlug }, { slug: environmentSlug }); return (
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 bb0fca6d745..26e9ad5b3be 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,7 +44,7 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; import { sanitizeVercelNextUrl } from "~/v3/vercel/vercelUrls.server"; -import { EnvironmentParamSchema, v3ProjectSettingsPath, vercelAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder"; +import { EnvironmentParamSchema, v3ProjectSettingsIntegrationsPath, vercelAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder"; import { VercelSettingsPresenter, type VercelOnboardingData, @@ -224,7 +224,7 @@ export async function action({ request, params }: ActionFunctionArgs) { return json(submission); } - const settingsPath = v3ProjectSettingsPath( + const settingsPath = v3ProjectSettingsIntegrationsPath( { slug: organizationSlug }, { slug: projectParam }, { slug: envParam } diff --git a/apps/webapp/app/routes/vercel.connect.tsx b/apps/webapp/app/routes/vercel.connect.tsx index 7c0701edfe3..f1be58fe977 100644 --- a/apps/webapp/app/routes/vercel.connect.tsx +++ b/apps/webapp/app/routes/vercel.connect.tsx @@ -7,7 +7,7 @@ import { VercelIntegrationRepository, type TokenResponse } from "~/models/vercel import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; import { requestUrl } from "~/utils/requestUrl.server"; -import { v3ProjectSettingsPath } from "~/utils/pathBuilder"; +import { v3ProjectSettingsIntegrationsPath } from "~/utils/pathBuilder"; import { validateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server"; const VercelConnectSchema = z.object({ @@ -139,7 +139,7 @@ export async function loader({ request }: LoaderFunctionArgs) { throw new Response("Environment not found", { status: 404 }); } - const settingsPath = v3ProjectSettingsPath( + const settingsPath = v3ProjectSettingsIntegrationsPath( { slug: stateData.organizationSlug }, { slug: stateData.projectSlug }, { slug: environment.slug } diff --git a/apps/webapp/app/routes/vercel.install.tsx b/apps/webapp/app/routes/vercel.install.tsx index 6a1ca4d7a64..86fa6fe1bc8 100644 --- a/apps/webapp/app/routes/vercel.install.tsx +++ b/apps/webapp/app/routes/vercel.install.tsx @@ -4,6 +4,7 @@ import { z } from "zod"; import { $replica } from "~/db.server"; import { requireUser } from "~/services/session.server"; import { logger } from "~/services/logger.server"; +import { loopsClient } from "~/services/loops.server"; import { OrgIntegrationRepository } from "~/models/orgIntegration.server"; import { generateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server"; import { findProjectBySlug } from "~/models/project.server"; @@ -65,6 +66,15 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { projectSlug: project_slug, }); + // Send Loops.so event (fire-and-forget, don't block the redirect) + loopsClient + ?.vercelIntegrationStarted({ + userId: user.id, + email: user.email, + name: user.name, + }) + .catch(() => {}); + // Generate Vercel install URL const vercelInstallUrl = OrgIntegrationRepository.vercelInstallUrl(stateToken); diff --git a/apps/webapp/app/services/loops.server.ts b/apps/webapp/app/services/loops.server.ts index 6509d894701..78c7faad81b 100644 --- a/apps/webapp/app/services/loops.server.ts +++ b/apps/webapp/app/services/loops.server.ts @@ -22,6 +22,24 @@ class LoopsClient { }); } + async vercelIntegrationStarted({ + userId, + email, + name, + }: { + userId: string; + email: string; + name: string | null; + }) { + logger.info(`Loops send "vercel-integration" event`, { userId, email, name }); + return this.#sendEvent({ + email, + userId, + firstName: name?.split(" ").at(0), + eventName: "vercel-integration", + }); + } + async #sendEvent({ email, userId, diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 4f1c03d8d66..030faa51f7f 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -129,6 +129,10 @@ export function organizationVercelIntegrationPath(organization: OrgForPath) { return `${organizationIntegrationsPath(organization)}/vercel`; } +export function organizationSlackIntegrationPath(organization: OrgForPath) { + return `${organizationIntegrationsPath(organization)}/slack`; +} + function organizationParam(organization: OrgForPath) { return organization.slug; } @@ -499,6 +503,22 @@ export function v3ProjectSettingsPath( return `${v3EnvironmentPath(organization, project, environment)}/settings`; } +export function v3ProjectSettingsGeneralPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `${v3ProjectSettingsPath(organization, project, environment)}/general`; +} + +export function v3ProjectSettingsIntegrationsPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `${v3ProjectSettingsPath(organization, project, environment)}/integrations`; +} + export function v3LogsPath( organization: OrgForPath, project: ProjectForPath, diff --git a/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts b/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts index debb176da57..8b922f91e9f 100644 --- a/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts +++ b/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts @@ -33,14 +33,20 @@ import { ProjectAlertWebhookProperties, } from "~/models/projectAlert.server"; import { ApiRetrieveRunPresenter } from "~/presenters/v3/ApiRetrieveRunPresenter.server"; +import { + processGitMetadata, + type GitMetaLinks, +} from "~/presenters/v3/BranchesPresenter.server"; import { DeploymentPresenter } from "~/presenters/v3/DeploymentPresenter.server"; import { sendAlertEmail } from "~/services/email.server"; +import { VercelProjectIntegrationDataSchema } from "~/v3/vercel/vercelProjectIntegrationSchema"; import { logger } from "~/services/logger.server"; import { decryptSecret } from "~/services/secrets/secretStore.server"; import { v3RunPath } from "~/utils/pathBuilder"; import { alertsRateLimiter } from "~/v3/alertsRateLimiter.server"; import { alertsWorker } from "~/v3/alertsWorker.server"; import { generateFriendlyId } from "~/v3/friendlyIdentifiers"; +import { fromPromise } from "neverthrow"; import { BaseService } from "../baseService.server"; import { CURRENT_API_VERSION } from "~/api/versions"; @@ -89,6 +95,11 @@ type FoundAlert = Prisma.Result< class SkipRetryError extends Error {} +type DeploymentIntegrationMetadata = { + git: GitMetaLinks | null; + vercelDeploymentUrl: string | undefined; +}; + export class DeliverAlertService extends BaseService { public async call(alertId: string) { const alert: FoundAlert | null = await this._prisma.projectAlert.findFirst({ @@ -139,18 +150,27 @@ export class DeliverAlertService extends BaseService { return; } + const emptyMeta: DeploymentIntegrationMetadata = { git: null, vercelDeploymentUrl: undefined }; + + const deploymentMeta = + alert.type === "DEPLOYMENT_SUCCESS" || alert.type === "DEPLOYMENT_FAILURE" + ? ( + await fromPromise(this.#resolveDeploymentMetadata(alert), (e) => e) + ).unwrapOr(emptyMeta) + : emptyMeta; + try { switch (alert.channel.type) { case "EMAIL": { - await this.#sendEmail(alert); + await this.#sendEmail(alert, deploymentMeta); break; } case "SLACK": { - await this.#sendSlack(alert); + await this.#sendSlack(alert, deploymentMeta); break; } case "WEBHOOK": { - await this.#sendWebhook(alert); + await this.#sendWebhook(alert, deploymentMeta); break; } default: { @@ -177,7 +197,7 @@ export class DeliverAlertService extends BaseService { }); } - async #sendEmail(alert: FoundAlert) { + async #sendEmail(alert: FoundAlert, deploymentMeta: DeploymentIntegrationMetadata) { const emailProperties = ProjectAlertEmailProperties.safeParse(alert.channel.properties); if (!emailProperties.success) { @@ -243,6 +263,19 @@ export class DeliverAlertService extends BaseService { error: preparedError, deploymentLink: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/deployments/${alert.workerDeployment.shortCode}`, organization: alert.project.organization.title, + git: deploymentMeta.git + ? { + branchName: deploymentMeta.git.branchName, + shortSha: deploymentMeta.git.shortSha, + commitMessage: deploymentMeta.git.commitMessage, + commitUrl: deploymentMeta.git.commitUrl, + branchUrl: deploymentMeta.git.branchUrl, + pullRequestNumber: deploymentMeta.git.pullRequestNumber, + pullRequestTitle: deploymentMeta.git.pullRequestTitle, + pullRequestUrl: deploymentMeta.git.pullRequestUrl, + } + : undefined, + vercelDeploymentUrl: deploymentMeta.vercelDeploymentUrl, }); } else { logger.error("[DeliverAlert] Worker deployment not found", { @@ -264,6 +297,19 @@ export class DeliverAlertService extends BaseService { deploymentLink: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/deployments/${alert.workerDeployment.shortCode}`, taskCount: alert.workerDeployment.worker?.tasks.length ?? 0, organization: alert.project.organization.title, + git: deploymentMeta.git + ? { + branchName: deploymentMeta.git.branchName, + shortSha: deploymentMeta.git.shortSha, + commitMessage: deploymentMeta.git.commitMessage, + commitUrl: deploymentMeta.git.commitUrl, + branchUrl: deploymentMeta.git.branchUrl, + pullRequestNumber: deploymentMeta.git.pullRequestNumber, + pullRequestTitle: deploymentMeta.git.pullRequestTitle, + pullRequestUrl: deploymentMeta.git.pullRequestUrl, + } + : undefined, + vercelDeploymentUrl: deploymentMeta.vercelDeploymentUrl, }); } else { logger.error("[DeliverAlert] Worker deployment not found", { @@ -279,7 +325,7 @@ export class DeliverAlertService extends BaseService { } } - async #sendWebhook(alert: FoundAlert) { + async #sendWebhook(alert: FoundAlert, deploymentMeta: DeploymentIntegrationMetadata) { const webhookProperties = ProjectAlertWebhookProperties.safeParse(alert.channel.properties); if (!webhookProperties.success) { @@ -452,6 +498,8 @@ export class DeliverAlertService extends BaseService { name: alert.project.name, }, error: preparedError, + git: this.#buildWebhookGitObject(deploymentMeta.git), + vercel: this.#buildWebhookVercelObject(deploymentMeta.vercelDeploymentUrl), }; await this.#deliverWebhook(payload, webhookProperties.data); @@ -488,6 +536,8 @@ export class DeliverAlertService extends BaseService { name: alert.project.name, }, error: preparedError, + git: this.#buildWebhookGitObject(deploymentMeta.git), + vercel: this.#buildWebhookVercelObject(deploymentMeta.vercelDeploymentUrl), }, }; @@ -542,6 +592,8 @@ export class DeliverAlertService extends BaseService { slug: alert.project.slug, name: alert.project.name, }, + git: this.#buildWebhookGitObject(deploymentMeta.git), + vercel: this.#buildWebhookVercelObject(deploymentMeta.vercelDeploymentUrl), }; await this.#deliverWebhook(payload, webhookProperties.data); @@ -584,6 +636,8 @@ export class DeliverAlertService extends BaseService { slug: alert.project.slug, name: alert.project.name, }, + git: this.#buildWebhookGitObject(deploymentMeta.git), + vercel: this.#buildWebhookVercelObject(deploymentMeta.vercelDeploymentUrl), }, }; @@ -609,7 +663,7 @@ export class DeliverAlertService extends BaseService { } } - async #sendSlack(alert: FoundAlert) { + async #sendSlack(alert: FoundAlert, deploymentMeta: DeploymentIntegrationMetadata) { const slackProperties = ProjectAlertSlackProperties.safeParse(alert.channel.properties); if (!slackProperties.success) { @@ -694,9 +748,7 @@ export class DeliverAlertService extends BaseService { type: "section", text: { type: "mrkdwn", - text: `:rotating_light: Error in *${taskIdentifier}* __`, + text: `:rotating_light: Error in *${taskIdentifier}*`, }, }, { @@ -706,18 +758,7 @@ export class DeliverAlertService extends BaseService { text: this.#wrapInCodeBlock(error.stackTrace ?? error.message), }, }, - { - type: "context", - elements: [ - { - type: "mrkdwn", - text: `${runId} | ${taskIdentifier} | ${version}.${environment} | ${alert.project.name}`, - }, - ], - }, - { - type: "divider", - }, + this.#buildRunQuoteBlock(taskIdentifier, version, environment, runId, alert.project.name, timestamp), { type: "actions", elements: [ @@ -789,14 +830,13 @@ export class DeliverAlertService extends BaseService { await this.#postSlackMessage(integration, { channel: slackProperties.data.channelId, + text: `:rotating_light: Deployment failed *${version}.${environment}*`, blocks: [ { type: "section", text: { type: "mrkdwn", - text: `:rotating_light: Deployment failed *${version}.${environment}* __`, + text: `:rotating_light: Deployment failed *${version}.${environment}*`, }, }, { @@ -806,15 +846,7 @@ export class DeliverAlertService extends BaseService { text: this.#wrapInCodeBlock(preparedError.stack ?? preparedError.message), }, }, - { - type: "context", - elements: [ - { - type: "mrkdwn", - text: `${alert.workerDeployment.shortCode} | ${version}.${environment} | ${alert.project.name}`, - }, - ], - }, + this.#buildDeploymentQuoteBlock(alert, deploymentMeta, version, environment, timestamp), { type: "actions", elements: [ @@ -842,7 +874,6 @@ export class DeliverAlertService extends BaseService { if (alert.workerDeployment) { const version = alert.workerDeployment.version; const environment = alert.environment.slug; - const numberOfTasks = alert.workerDeployment.worker?.tasks.length ?? 0; const timestamp = alert.workerDeployment.deployedAt ?? new Date(); await this.#postSlackMessage(integration, { @@ -853,20 +884,10 @@ export class DeliverAlertService extends BaseService { type: "section", text: { type: "mrkdwn", - text: `:rocket: Deployed *${version}.${environment}* successfully __`, + text: `:rocket: Deployed *${version}.${environment}* successfully`, }, }, - { - type: "context", - elements: [ - { - type: "mrkdwn", - text: `${numberOfTasks} tasks | ${alert.workerDeployment.shortCode} | ${version}.${environment} | ${alert.project.name}`, - }, - ], - }, + this.#buildDeploymentQuoteBlock(alert, deploymentMeta, version, environment, timestamp), { type: "actions", elements: [ @@ -949,7 +970,11 @@ export class DeliverAlertService extends BaseService { ); try { - return await client.chat.postMessage(message); + return await client.chat.postMessage({ + ...message, + unfurl_links: false, + unfurl_media: false, + }); } catch (error) { if (isWebAPIRateLimitedError(error)) { logger.warn("[DeliverAlert] Slack rate limited", { @@ -1013,6 +1038,174 @@ export class DeliverAlertService extends BaseService { } } + async #resolveDeploymentMetadata( + alert: FoundAlert + ): Promise { + const deployment = alert.workerDeployment; + if (!deployment) { + return { git: null, vercelDeploymentUrl: undefined }; + } + + const git = processGitMetadata(deployment.git); + const vercelDeploymentUrl = await this.#resolveVercelDeploymentUrl( + deployment.projectId, + deployment.id + ); + + return { git, vercelDeploymentUrl }; + } + + async #resolveVercelDeploymentUrl( + projectId: string, + deploymentId: string + ): Promise { + const vercelProjectIntegration = + await this._prisma.organizationProjectIntegration.findFirst({ + where: { + projectId, + deletedAt: null, + organizationIntegration: { + service: "VERCEL", + deletedAt: null, + }, + }, + select: { + integrationData: true, + }, + }); + + if (!vercelProjectIntegration) { + return undefined; + } + + const parsed = VercelProjectIntegrationDataSchema.safeParse( + vercelProjectIntegration.integrationData + ); + + if (!parsed.success || !parsed.data.vercelTeamSlug) { + return undefined; + } + + const integrationDeployment = + await this._prisma.integrationDeployment.findFirst({ + where: { + deploymentId, + integrationName: "vercel", + }, + select: { + integrationDeploymentId: true, + }, + orderBy: { + createdAt: "desc", + }, + }); + + if (!integrationDeployment) { + return undefined; + } + + const vercelId = integrationDeployment.integrationDeploymentId.replace(/^dpl_/, ""); + return `https://vercel.com/${parsed.data.vercelTeamSlug}/${parsed.data.vercelProjectName}/${vercelId}`; + } + + #buildDeploymentQuoteBlock( + alert: FoundAlert, + deploymentMeta: DeploymentIntegrationMetadata, + version: string, + environment: string, + timestamp: Date + ) { + const git = deploymentMeta.git; + const shortCode = alert.workerDeployment!.shortCode; + const lines: string[] = []; + + // Line 1: git author + branch (if available) + if (git) { + lines.push(`> By *${git.commitAuthor}* on <${git.branchUrl}|\`${git.branchName}\`>`); + } + + // Line 2: deployment info + lines.push(`> ${shortCode} | ${version}.${environment} | ${alert.project.name} `); + + // Line 3: provider + commit link + vercel link (conditional parts) + const integrationParts: string[] = []; + if (git?.provider === "github") { + integrationParts.push(`via GitHub | <${git.commitUrl}|${git.shortSha}>`); + } + if (deploymentMeta.vercelDeploymentUrl) { + integrationParts.push(`with <${deploymentMeta.vercelDeploymentUrl}|Vercel>`); + } + if (integrationParts.length > 0) { + lines.push(`> ${integrationParts.join(" | ")} `); + } + + // Line 4: timestamp + lines.push(`> ${this.#formatTimestamp(timestamp)}`); + + return { + type: "context" as const, + elements: [ + { + type: "mrkdwn" as const, + text: lines.join("\n"), + }, + ], + }; + } + + #buildRunQuoteBlock( + taskIdentifier: string, + version: string, + environment: string, + runId: string, + projectName: string, + timestamp: Date + ) { + return { + type: "context" as const, + elements: [ + { + type: "mrkdwn" as const, + text: `> *${taskIdentifier}* | ${version}.${environment}\n> ${runId} | ${projectName}\n> ${this.#formatTimestamp(timestamp)}`, + }, + ], + }; + } + + #formatTimestamp(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); + } + + #buildWebhookGitObject(git: GitMetaLinks | null) { + if (!git) return undefined; + + return { + branch: git.branchName, + commitSha: git.shortSha, + commitMessage: git.commitMessage, + commitUrl: git.commitUrl, + branchUrl: git.branchUrl, + pullRequestNumber: git.pullRequestNumber, + pullRequestTitle: git.pullRequestTitle, + pullRequestUrl: git.pullRequestUrl, + provider: git.provider, + }; + } + + #buildWebhookVercelObject(url: string | undefined) { + if (!url) return undefined; + + return { deploymentUrl: url }; + } + #getRunError(alert: FoundAlert): TaskRunError { if (alert.taskRun) { const res = TaskRunError.safeParse(alert.taskRun.error); diff --git a/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts b/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts index 213e730c643..64ed5d55a3c 100644 --- a/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts +++ b/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts @@ -202,6 +202,15 @@ export function shouldSyncEnvVarForAnyEnvironment( return false; } +export function buildVercelDeploymentUrl( + vercelTeamSlug: string | undefined, + vercelProjectName: string, + integrationDeploymentId: string +): string { + const vercelId = integrationDeploymentId.replace(/^dpl_/, ""); + return `https://vercel.com/${vercelTeamSlug}/${vercelProjectName}/${vercelId}`; +} + export function isPullEnvVarsEnabledForEnvironment( pullEnvVarsBeforeBuild: EnvSlug[] | null | undefined, environmentType: TriggerEnvironmentType diff --git a/internal-packages/emails/emails/deployment-failure.tsx b/internal-packages/emails/emails/deployment-failure.tsx index 476208360b2..c4cf363c2e7 100644 --- a/internal-packages/emails/emails/deployment-failure.tsx +++ b/internal-packages/emails/emails/deployment-failure.tsx @@ -1,18 +1,20 @@ import { Body, CodeBlock, + Column, Container, Head, Html, Link, Preview, + Row, Text, dracula, } from "@react-email/components"; import { z } from "zod"; import { Footer } from "./components/Footer"; import { Image } from "./components/Image"; -import { anchor, container, h1, main, paragraphLight } from "./components/styles"; +import { anchor, bullets, container, grey, h1, main, paragraphLight } from "./components/styles"; export const AlertDeploymentFailureEmailSchema = z.object({ email: z.literal("alert-deployment-failure"), @@ -27,6 +29,19 @@ export const AlertDeploymentFailureEmailSchema = z.object({ stack: z.string().optional(), }), deploymentLink: z.string().url(), + git: z + .object({ + branchName: z.string(), + shortSha: z.string(), + commitMessage: z.string(), + commitUrl: z.string(), + branchUrl: z.string(), + pullRequestNumber: z.number().optional(), + pullRequestTitle: z.string().optional(), + pullRequestUrl: z.string().optional(), + }) + .optional(), + vercelDeploymentUrl: z.string().url().optional(), }); const previewDefaults = { @@ -40,10 +55,31 @@ const previewDefaults = { stack: "Error: Something went wrong\n at main.ts:12:34", }, deploymentLink: "https://trigger.dev", + git: { + branchName: "feat/new-feature", + shortSha: "abc1234", + commitMessage: "Add new background task for processing uploads", + commitUrl: "https://github.com/acme/app/commit/abc1234", + branchUrl: "https://github.com/acme/app/tree/feat/new-feature", + pullRequestNumber: 42, + pullRequestTitle: "Add upload processing", + pullRequestUrl: "https://github.com/acme/app/pull/42", + }, + vercelDeploymentUrl: "https://vercel.com/acme/app/abc1234", }; export default function Email(props: z.infer) { - const { version, environment, organization, shortCode, failedAt, error, deploymentLink } = { + const { + version, + environment, + organization, + shortCode, + failedAt, + error, + deploymentLink, + git, + vercelDeploymentUrl, + } = { ...previewDefaults, ...props, }; @@ -63,6 +99,7 @@ export default function Email(props: z.infer )} + + {git && ( + <> + + Branch + + + {git.branchName} + + + + + Commit + + + {git.shortSha} + {" "} + {git.commitMessage} + + + {git.pullRequestNumber && git.pullRequestUrl && ( + + Pull Request + + + #{git.pullRequestNumber} + + {git.pullRequestTitle ? ` ${git.pullRequestTitle}` : ""} + + + )} + + )} + {vercelDeploymentUrl && ( + + Vercel + + + View Vercel Deployment + + + + )} + Trigger.dev