feat(webapp): Vercel / Slack integrations improvements (#3108)

##  Checklist

- [x] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [x] The PR title follows the convention.
- [x] I ran and tested the code works

---

## Testing

Slack + GitHub + Vercel + Builds + Deployments

---

## Changelog

Settings changes:
- Split general from integrations
- Add new Slack section to org level integrations
Vercel improvements:
- bugfix for TRIGGER_SECRET_KEY collision
- onboarding improvements for connecting to projects
- new loops event
Slack improvements:
- nicer alerts
Webhook/Email alerts:
- rich events with Github & Vercel integration data

---

## Screenshots


<img width="2550" height="652" alt="Screenshot 2026-02-20 at 21 53 34"
src="https://github.com/user-attachments/assets/8d7c9f1d-5fe9-4516-8fb3-885460b4207f"
/>
<img width="843" height="710" alt="Screenshot 2026-02-23 at 10 55 54"
src="https://github.com/user-attachments/assets/8ea72c1f-431b-493c-b9a9-8076cce12262"
/>
<img width="765" height="466" alt="Screenshot 2026-02-20 at 21 52 46"
src="https://github.com/user-attachments/assets/157fafb8-b7bf-499d-8953-c2aed5e44ce0"
/>
<img width="691" height="261" alt="Screenshot 2026-02-20 at 22 04 24"
src="https://github.com/user-attachments/assets/3aea7369-2008-4af8-a9c0-5fbfa2cc381d"
/>
<img width="2032" height="1114" alt="Screenshot 2026-02-19 at 14 48 49"
src="https://github.com/user-attachments/assets/dc10c14e-cd15-445a-b5be-d694d29d20e5"
/>
<img width="2032" height="1114" alt="Screenshot 2026-02-19 at 14 49 04"
src="https://github.com/user-attachments/assets/1ef591fd-fd00-430a-9649-8b18cff9586d"
/>
<img width="1583" height="1115" alt="Screenshot 2026-02-19 at 17 32 56"
src="https://github.com/user-attachments/assets/c5c8f318-d193-4dd4-86f7-1cc4bbcc4e0c"
/>
<img width="422" height="187" alt="Screenshot 2026-02-20 at 21 57 41"
src="https://github.com/user-attachments/assets/37865cb6-4c0d-40ef-9c60-7b057d546c61"
/>
<img width="1583" height="1115" alt="Screenshot 2026-02-19 at 17 33 06"
src="https://github.com/user-attachments/assets/e9180e8e-e611-4734-9232-80c62ff863ad"
/>

💯
This commit is contained in:
Oskar Otwinowski
2026-02-23 14:48:09 +01:00
committed by GitHub
parent 3a7054628f
commit 69dc7bcde8
29 changed files with 1891 additions and 839 deletions
@@ -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 (
<SimpleTooltip
button={
<LinkButton
variant="minimal/small"
LeadingIcon={<VercelLogo className="size-3.5" />}
iconSpacing="gap-x-1"
to={vercelDeploymentUrl}
className="pl-1"
>
Vercel
</LinkButton>
}
content="View on Vercel"
/>
);
}
@@ -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<HTMLFormElement>) => {
e.preventDefault();
@@ -639,7 +649,7 @@ export function VercelOnboardingModal({
onClose();
}
}}>
<DialogContent className="max-w-lg">
<DialogContent className="max-w-lg" onInteractOutside={(e) => e.preventDefault()}>
<DialogHeader>
<div className="flex items-center gap-2">
<VercelLogo className="size-5" />
@@ -727,14 +737,25 @@ export function VercelOnboardingModal({
<FormButtons
confirmButton={
<Button
variant="primary/medium"
onClick={handleProjectSelection}
disabled={!selectedVercelProject || fetcher.state !== "idle"}
LeadingIcon={fetcher.state !== "idle" ? SpinnerWhite : undefined}
>
{fetcher.state !== "idle" ? "Connecting..." : "Connect Project"}
</Button>
<div className="flex items-center gap-2">
{vercelManageAccessUrl && !origin && (
<LinkButton
to={vercelManageAccessUrl}
variant="tertiary/medium"
target="_self"
>
Manage access
</LinkButton>
)}
<Button
variant="primary/medium"
onClick={handleProjectSelection}
disabled={!selectedVercelProject || fetcher.state !== "idle"}
LeadingIcon={fetcher.state !== "idle" ? SpinnerWhite : undefined}
>
{fetcher.state !== "idle" ? "Connecting..." : "Connect Project"}
</Button>
</div>
}
cancelButton={
<Button
@@ -813,6 +834,7 @@ export function VercelOnboardingModal({
<Header3>Pull Environment Variables</Header3>
<Paragraph className="text-sm">
Select which environment variables to pull from Vercel now. This is a one-time pull.
Later on environment variables can be pulled before each build.
</Paragraph>
<div className="flex gap-4 text-sm">
@@ -1057,7 +1079,7 @@ export function VercelOnboardingModal({
</Callout>
{(() => {
const baseSettingsPath = v3ProjectSettingsPath(
const baseSettingsPath = v3ProjectSettingsIntegrationsPath(
{ slug: organizationSlug },
{ slug: projectSlug },
{ slug: environmentSlug }
@@ -1081,6 +1103,7 @@ export function VercelOnboardingModal({
)}
variant="secondary/medium"
LeadingIcon={OctoKitty}
onClick={() => trackOnboarding("vercel onboarding github app install clicked")}
>
Install GitHub app
</LinkButton>
@@ -1110,6 +1133,7 @@ export function VercelOnboardingModal({
<Button
variant="primary/medium"
onClick={() => {
trackOnboarding("vercel onboarding github completed");
setState("completed");
const validUrl = safeRedirectUrl(nextUrl);
if (validUrl) {
@@ -1123,6 +1147,7 @@ export function VercelOnboardingModal({
<Button
variant="tertiary/medium"
onClick={() => {
trackOnboarding("vercel onboarding github skipped");
setState("completed");
if (fromMarketplaceContext && nextUrl) {
const validUrl = safeRedirectUrl(nextUrl);
@@ -1141,6 +1166,7 @@ export function VercelOnboardingModal({
<Button
variant="tertiary/medium"
onClick={() => {
trackOnboarding("vercel onboarding github skipped");
setState("completed");
}}
>
@@ -3,15 +3,17 @@ import {
ChartBarIcon,
Cog8ToothIcon,
CreditCardIcon,
PuzzlePieceIcon,
UserGroupIcon,
} from "@heroicons/react/20/solid";
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
import { SlackIcon } from "@trigger.dev/companyicons";
import { VercelLogo } from "~/components/integrations/VercelLogo";
import { useFeatures } from "~/hooks/useFeatures";
import { type MatchedOrganization } from "~/hooks/useOrganizations";
import { cn } from "~/utils/cn";
import {
organizationSettingsPath,
organizationSlackIntegrationPath,
organizationTeamPath,
organizationVercelIntegrationPath,
rootPath,
@@ -115,13 +117,25 @@ export function OrganizationSettingsSideMenu({
to={organizationSettingsPath(organization)}
data-action="settings"
/>
</div>
<div className="flex flex-col">
<div className="mb-1">
<SideMenuHeader title="Integrations" />
</div>
<SideMenuItem
name="Integrations"
icon={PuzzlePieceIcon}
activeIconColor="text-blue-500"
name="Vercel"
icon={VercelLogo}
activeIconColor="text-white"
to={organizationVercelIntegrationPath(organization)}
data-action="integrations"
/>
<SideMenuItem
name="Slack"
icon={SlackIcon}
activeIconColor="text-white"
to={organizationSlackIntegrationPath(organization)}
data-action="integrations"
/>
</div>
<div className="flex flex-col gap-1">
<SideMenuHeader title="App version" />
@@ -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}
/>
</SideMenuSection>
<SideMenuSection
title="Project settings"
isSideMenuCollapsed={isCollapsed}
itemSpacingClassName="space-y-0"
initialCollapsed={getSectionCollapsed(
user.dashboardPreferences.sideMenu,
"project-settings"
)}
onCollapseToggle={handleSectionToggle("project-settings")}
>
<SideMenuItem
name="Project settings"
name="General"
icon={Cog8ToothIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3ProjectSettingsPath(organization, project, environment)}
data-action="project-settings"
to={v3ProjectSettingsGeneralPath(organization, project, environment)}
data-action="project-settings-general"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Integrations"
icon={PuzzlePieceIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3ProjectSettingsIntegrationsPath(organization, project, environment)}
data-action="project-settings-integrations"
isCollapsed={isCollapsed}
/>
</SideMenuSection>
@@ -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<typeof SideMenuSectionIdSchema>;
@@ -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<void> {
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;
@@ -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 {
@@ -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;
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,
},
};
}
@@ -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<string, unknown> | 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
@@ -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() {
})()}
</Property.Value>
</Property.Item>
{deployment.vercelDeploymentUrl && (
<Property.Item>
<Property.Label>Linked</Property.Label>
<Property.Value>
<div className="-ml-1 mt-0.5 flex flex-col">
<VercelLink vercelDeploymentUrl={deployment.vercelDeploymentUrl} />
</div>
</Property.Value>
</Property.Item>
)}
</Property.Table>
</div>
@@ -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 && (
<TableCell isSelected={isSelected}>
{deployment.vercelDeploymentUrl ? (
<SimpleTooltip
button={
<a
href={deployment.vercelDeploymentUrl}
target="_blank"
rel="noreferrer noopener"
className="flex items-center text-text-dimmed transition-colors hover:text-text-bright"
onClick={(e) => e.stopPropagation()}
>
<VercelLogo className="size-3.5" />
</a>
}
content="View on Vercel"
/>
<div
className="-ml-1 flex items-center"
onClick={(e) => e.stopPropagation()}
>
<VercelLink
vercelDeploymentUrl={deployment.vercelDeploymentUrl}
/>
</div>
) : (
""
)}
@@ -377,7 +370,7 @@ export default function Page() {
<LinkButton
variant="minimal/small"
LeadingIcon={CogIcon}
to={v3ProjectSettingsPath(organization, project, environment)}
to={v3ProjectSettingsIntegrationsPath(organization, project, environment)}
/>
</div>
)}
@@ -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 and projectParam are 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(projectId, 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 (
<MainHorizontallyCenteredContainer className="md:mt-6">
<div className="flex flex-col gap-6">
<div>
<Header2 spacing>General</Header2>
<div className="w-full rounded-sm border border-grid-dimmed p-4">
<Fieldset className="mb-5">
<InputGroup fullWidth>
<Label>Project ref</Label>
<ClipboardField value={project.externalRef} variant={"secondary/medium"} />
<Hint>
This goes in your{" "}
<InlineCode variant="extra-extra-small">trigger.config</InlineCode> file.
</Hint>
</InputGroup>
</Fieldset>
<Form method="post" {...renameForm.props}>
<Fieldset>
<InputGroup fullWidth>
<Label htmlFor={projectName.id}>Project name</Label>
<Input
{...conform.input(projectName, { type: "text" })}
defaultValue={project.name}
placeholder="Project name"
icon={FolderIcon}
autoFocus
onChange={(e) => {
setHasRenameFormChanges(e.target.value !== project.name);
}}
/>
<FormError id={projectName.errorId}>{projectName.error}</FormError>
</InputGroup>
<FormButtons
confirmButton={
<Button
type="submit"
name="action"
value="rename"
variant={"secondary/small"}
disabled={isRenameLoading || !hasRenameFormChanges}
LeadingIcon={isRenameLoading ? SpinnerWhite : undefined}
>
Save
</Button>
}
/>
</Fieldset>
</Form>
</div>
</div>
<div>
<Header2 spacing>Danger zone</Header2>
<div className="w-full rounded-sm border border-rose-500/40 p-4">
<Form method="post" {...deleteForm.props}>
<Fieldset>
<InputGroup fullWidth>
<Label htmlFor={projectSlug.id}>Delete project</Label>
<Input
{...conform.input(projectSlug, { type: "text" })}
placeholder="Your project slug"
icon={ExclamationTriangleIcon}
onChange={(e) => setDeleteInputValue(e.target.value)}
/>
<FormError id={projectSlug.errorId}>{projectSlug.error}</FormError>
<FormError>{deleteForm.error}</FormError>
<Hint>
This change is irreversible, so please be certain. Type in the Project slug
<InlineCode variant="extra-small">{project.slug}</InlineCode> and then press
Delete.
</Hint>
</InputGroup>
<FormButtons
confirmButton={
<Button
type="submit"
name="action"
value="delete"
variant={"danger/small"}
LeadingIcon={isDeleteLoading ? SpinnerWhite : TrashIcon}
leadingIconClassName="text-white"
disabled={isDeleteLoading || deleteInputValue !== project.slug}
>
Delete
</Button>
}
/>
</Fieldset>
</Form>
</div>
</div>
</div>
</MainHorizontallyCenteredContainer>
);
}
@@ -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 and projectParam are 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<typeof loader>();
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<typeof vercelLoader>();
// 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 (
<>
<MainHorizontallyCenteredContainer className="md:mt-6">
<div className="flex flex-col gap-6">
{githubAppEnabled && (
<React.Fragment>
<div>
<Header2 spacing>Git settings</Header2>
<div className="w-full rounded-sm border border-grid-dimmed p-4">
<GitHubSettingsPanel
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
billingPath={v3BillingPath({ slug: organization.slug })}
/>
</div>
</div>
{vercelIntegrationEnabled && (
<div>
<Header2 spacing>Vercel integration</Header2>
<div className="w-full rounded-sm border border-grid-dimmed p-4">
<VercelSettingsPanel
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
onOpenVercelModal={handleOpenVercelModal}
isLoadingVercelData={vercelFetcher.state === "loading" || vercelFetcher.state === "submitting"}
/>
</div>
</div>
)}
<div>
<Header2 spacing>Build settings</Header2>
<div className="w-full rounded-sm border border-grid-dimmed p-4">
<BuildSettingsForm buildSettings={buildSettings ?? {}} />
</div>
</div>
</React.Fragment>
)}
</div>
</MainHorizontallyCenteredContainer>
{/* Vercel Onboarding Modal */}
{vercelIntegrationEnabled && (
<VercelOnboardingModal
isOpen={isModalOpen}
onClose={closeVercelOnboarding}
onboardingData={vercelFetcher.data?.onboardingData ?? null}
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
hasStagingEnvironment={vercelFetcher.data?.hasStagingEnvironment ?? false}
hasPreviewEnvironment={vercelFetcher.data?.hasPreviewEnvironment ?? false}
hasOrgIntegration={vercelFetcher.data?.hasOrgIntegration ?? false}
nextUrl={nextUrl ?? undefined}
vercelManageAccessUrl={vercelFetcher.data?.vercelManageAccessUrl}
onDataReload={(vercelEnvironmentId) => {
vercelFetcher.load(
`${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true${
vercelEnvironmentId ? `&vercelEnvironmentId=${encodeURIComponent(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 (
<Form method="post" {...buildSettingsForm.props}>
<Fieldset>
<InputGroup fullWidth>
<Label htmlFor={fields.triggerConfigFilePath.id}>Trigger config file</Label>
<Input
{...conform.input(fields.triggerConfigFilePath, { type: "text" })}
defaultValue={buildSettings?.triggerConfigFilePath || ""}
placeholder="trigger.config.ts"
onChange={(e) => {
setBuildSettingsValues((prev) => ({
...prev,
triggerConfigFilePath: e.target.value,
}));
}}
/>
<Hint>
Path to your Trigger configuration file, relative to the root directory of your repo.
</Hint>
<FormError id={fields.triggerConfigFilePath.errorId}>
{fields.triggerConfigFilePath.error}
</FormError>
</InputGroup>
<InputGroup fullWidth>
<Label htmlFor={fields.installCommand.id}>Install command</Label>
<Input
{...conform.input(fields.installCommand, { type: "text" })}
defaultValue={buildSettings?.installCommand || ""}
placeholder="e.g., `npm install`, `pnpm install`, or `bun install`"
onChange={(e) => {
setBuildSettingsValues((prev) => ({
...prev,
installCommand: e.target.value,
}));
}}
/>
<Hint>
Command to install your project dependencies. This will be run from the root directory
of your repo. Auto-detected by default.
</Hint>
<FormError id={fields.installCommand.errorId}>{fields.installCommand.error}</FormError>
</InputGroup>
<InputGroup fullWidth>
<Label htmlFor={fields.preBuildCommand.id}>Pre-build command</Label>
<Input
{...conform.input(fields.preBuildCommand, { type: "text" })}
defaultValue={buildSettings?.preBuildCommand || ""}
placeholder="e.g., `npm run prisma:generate`"
onChange={(e) => {
setBuildSettingsValues((prev) => ({
...prev,
preBuildCommand: e.target.value,
}));
}}
/>
<Hint>
Any command that needs to run before we build and deploy your project. This will be run
from the root directory of your repo.
</Hint>
<FormError id={fields.preBuildCommand.errorId}>{fields.preBuildCommand.error}</FormError>
</InputGroup>
<div className="border-t border-grid-dimmed pt-4">
<InputGroup>
<CheckboxWithLabel
id={fields.useNativeBuildServer.id}
{...conform.input(fields.useNativeBuildServer, { type: "checkbox" })}
label="Use native build server"
variant="simple/small"
defaultChecked={buildSettings?.useNativeBuildServer || false}
onChange={(isChecked) => {
setBuildSettingsValues((prev) => ({
...prev,
useNativeBuildServer: isChecked,
}));
}}
/>
<Hint>
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.
</Hint>
<FormError id={fields.useNativeBuildServer.errorId}>
{fields.useNativeBuildServer.error}
</FormError>
</InputGroup>
</div>
<FormError>{buildSettingsForm.error}</FormError>
<FormButtons
confirmButton={
<Button
type="submit"
name="action"
value="update-build-settings"
variant="secondary/small"
disabled={isBuildSettingsLoading || !hasBuildSettingsChanges}
LeadingIcon={isBuildSettingsLoading ? SpinnerWhite : undefined}
>
Save
</Button>
}
/>
</Fieldset>
</Form>
);
}
@@ -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);
await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const projectSettingsPresenter = new ProjectSettingsPresenter();
const resultOrFail = await projectSettingsPresenter.getProjectSettings(
organizationSlug,
projectParam,
userId
);
// 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 (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";
const basePath = url.searchParams.has("vercelOnboarding")
? v3ProjectSettingsIntegrationsPath(org, project, env)
: v3ProjectSettingsGeneralPath(org, project, env);
logger.error("Failed loading project settings", {
error: resultOrFail.error,
});
throw new Response(undefined, {
status: 400,
statusText: "Something went wrong, please try again!",
});
}
}
return redirect(`${basePath}${url.search}`);
}
const { gitHubApp, buildSettings } = resultOrFail.value;
return typedjson({
githubAppEnabled: gitHubApp.enabled,
buildSettings,
vercelIntegrationEnabled: OrgIntegrationRepository.isVercelSupported,
});
return null;
};
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<typeof UpdateBuildSettingsFormSchema>;
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();
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"
);
}
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");
}
}
};
export default function Page() {
const { githubAppEnabled, buildSettings, vercelIntegrationEnabled } =
useTypedLoaderData<typeof loader>();
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<typeof vercelLoader>();
// 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 (
<PageContainer>
@@ -479,302 +66,8 @@ export default function Page() {
</NavBar>
<PageBody>
<MainHorizontallyCenteredContainer className="md:mt-6">
<div className="flex flex-col gap-6">
<div>
<Header2 spacing>General</Header2>
<div className="w-full rounded-sm border border-grid-dimmed p-4">
<Fieldset className="mb-5">
<InputGroup fullWidth>
<Label>Project ref</Label>
<ClipboardField value={project.externalRef} variant={"secondary/medium"} />
<Hint>
This goes in your{" "}
<InlineCode variant="extra-extra-small">trigger.config</InlineCode> file.
</Hint>
</InputGroup>
</Fieldset>
<Form method="post" {...renameForm.props}>
<Fieldset>
<InputGroup fullWidth>
<Label htmlFor={projectName.id}>Project name</Label>
<Input
{...conform.input(projectName, { type: "text" })}
defaultValue={project.name}
placeholder="Project name"
icon={FolderIcon}
autoFocus
onChange={(e) => {
setHasRenameFormChanges(e.target.value !== project.name);
}}
/>
<FormError id={projectName.errorId}>{projectName.error}</FormError>
</InputGroup>
<FormButtons
confirmButton={
<Button
type="submit"
name="action"
value="rename"
variant={"secondary/small"}
disabled={isRenameLoading || !hasRenameFormChanges}
LeadingIcon={isRenameLoading ? SpinnerWhite : undefined}
>
Save
</Button>
}
/>
</Fieldset>
</Form>
</div>
</div>
{githubAppEnabled && (
<React.Fragment>
<div>
<Header2 spacing>Git settings</Header2>
<div className="w-full rounded-sm border border-grid-dimmed p-4">
<GitHubSettingsPanel
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
billingPath={v3BillingPath({ slug: organization.slug })}
/>
</div>
</div>
{vercelIntegrationEnabled && (
<div>
<Header2 spacing>Vercel integration</Header2>
<div className="w-full rounded-sm border border-grid-dimmed p-4">
<VercelSettingsPanel
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
onOpenVercelModal={handleOpenVercelModal}
isLoadingVercelData={vercelFetcher.state === "loading" || vercelFetcher.state === "submitting"}
/>
</div>
</div>
)}
<div>
<Header2 spacing>Build settings</Header2>
<div className="w-full rounded-sm border border-grid-dimmed p-4">
<BuildSettingsForm buildSettings={buildSettings ?? {}} />
</div>
</div>
</React.Fragment>
)}
<div>
<Header2 spacing>Danger zone</Header2>
<div className="w-full rounded-sm border border-rose-500/40 p-4">
<Form method="post" {...deleteForm.props}>
<Fieldset>
<InputGroup fullWidth>
<Label htmlFor={projectSlug.id}>Delete project</Label>
<Input
{...conform.input(projectSlug, { type: "text" })}
placeholder="Your project slug"
icon={ExclamationTriangleIcon}
onChange={(e) => setDeleteInputValue(e.target.value)}
/>
<FormError id={projectSlug.errorId}>{projectSlug.error}</FormError>
<FormError>{deleteForm.error}</FormError>
<Hint>
This change is irreversible, so please be certain. Type in the Project slug
<InlineCode variant="extra-small">{project.slug}</InlineCode> and then press
Delete.
</Hint>
</InputGroup>
<FormButtons
confirmButton={
<Button
type="submit"
name="action"
value="delete"
variant={"danger/small"}
LeadingIcon={isDeleteLoading ? SpinnerWhite : TrashIcon}
leadingIconClassName="text-white"
disabled={isDeleteLoading || deleteInputValue !== project.slug}
>
Delete
</Button>
}
/>
</Fieldset>
</Form>
</div>
</div>
</div>
</MainHorizontallyCenteredContainer>
<Outlet />
</PageBody>
{/* Vercel Onboarding Modal */}
{vercelIntegrationEnabled && (
<VercelOnboardingModal
isOpen={isModalOpen}
onClose={closeVercelOnboarding}
onboardingData={vercelFetcher.data?.onboardingData ?? null}
organizationSlug={organization.slug}
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) => {
vercelFetcher.load(
`${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true${
vercelEnvironmentId ? `&vercelEnvironmentId=${vercelEnvironmentId}` : ""
}`
);
}}
/>
)}
</PageContainer>
);
}
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 (
<Form method="post" {...buildSettingsForm.props}>
<Fieldset>
<InputGroup fullWidth>
<Label htmlFor={fields.triggerConfigFilePath.id}>Trigger config file</Label>
<Input
{...conform.input(fields.triggerConfigFilePath, { type: "text" })}
defaultValue={buildSettings?.triggerConfigFilePath || ""}
placeholder="trigger.config.ts"
onChange={(e) => {
setBuildSettingsValues((prev) => ({
...prev,
triggerConfigFilePath: e.target.value,
}));
}}
/>
<Hint>
Path to your Trigger configuration file, relative to the root directory of your repo.
</Hint>
<FormError id={fields.triggerConfigFilePath.errorId}>
{fields.triggerConfigFilePath.error}
</FormError>
</InputGroup>
<InputGroup fullWidth>
<Label htmlFor={fields.installCommand.id}>Install command</Label>
<Input
{...conform.input(fields.installCommand, { type: "text" })}
defaultValue={buildSettings?.installCommand || ""}
placeholder="e.g., `npm install`, `pnpm install`, or `bun install`"
onChange={(e) => {
setBuildSettingsValues((prev) => ({
...prev,
installCommand: e.target.value,
}));
}}
/>
<Hint>
Command to install your project dependencies. This will be run from the root directory
of your repo. Auto-detected by default.
</Hint>
<FormError id={fields.installCommand.errorId}>{fields.installCommand.error}</FormError>
</InputGroup>
<InputGroup fullWidth>
<Label htmlFor={fields.preBuildCommand.id}>Pre-build command</Label>
<Input
{...conform.input(fields.preBuildCommand, { type: "text" })}
defaultValue={buildSettings?.preBuildCommand || ""}
placeholder="e.g., `npm run prisma:generate`"
onChange={(e) => {
setBuildSettingsValues((prev) => ({
...prev,
preBuildCommand: e.target.value,
}));
}}
/>
<Hint>
Any command that needs to run before we build and deploy your project. This will be run
from the root directory of your repo.
</Hint>
<FormError id={fields.preBuildCommand.errorId}>{fields.preBuildCommand.error}</FormError>
</InputGroup>
<div className="border-t border-grid-dimmed pt-4">
<InputGroup>
<CheckboxWithLabel
id={fields.useNativeBuildServer.id}
{...conform.input(fields.useNativeBuildServer, { type: "checkbox" })}
label="Use native build server"
variant="simple/small"
defaultChecked={buildSettings?.useNativeBuildServer || false}
onChange={(isChecked) => {
setBuildSettingsValues((prev) => ({
...prev,
useNativeBuildServer: isChecked,
}));
}}
/>
<Hint>
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.
</Hint>
<FormError id={fields.useNativeBuildServer.errorId}>
{fields.useNativeBuildServer.error}
</FormError>
</InputGroup>
</div>
<FormError>{buildSettingsForm.error}</FormError>
<FormButtons
confirmButton={
<Button
type="submit"
name="action"
value="update-build-settings"
variant="secondary/small"
disabled={isBuildSettingsLoading || !hasBuildSettingsChanges}
LeadingIcon={isBuildSettingsLoading ? SpinnerWhite : undefined}
>
Save
</Button>
}
/>
</Fieldset>
</Form>
);
}
@@ -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, organizationSettingsPath } 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(organizationSettingsPath({ slug: organizationSlug }));
};
export default function SlackIntegrationPage() {
const { slackIntegration, alertChannels, teamName } =
useTypedLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isUninstalling =
navigation.state === "submitting" && navigation.formData?.get("intent") === "uninstall";
if (!slackIntegration) {
return (
<PageContainer>
<PageBody>
<div className="flex flex-col items-center justify-center py-8">
<Header1>No Slack Integration Found</Header1>
<Paragraph className="mt-2 text-center text-text-dimmed">
This organization doesn't have a Slack integration configured. You can connect Slack
when setting up alert channels in your project settings.
</Paragraph>
</div>
</PageBody>
</PageContainer>
);
}
return (
<PageContainer>
<PageBody>
<div className="mb-8">
<Header1>Slack Integration</Header1>
<Paragraph className="mt-2 text-text-dimmed">
Manage your organization's Slack integration and connected alert channels.
</Paragraph>
</div>
{/* Integration Info Section */}
<div className="mb-8 rounded-lg border border-grid-bright bg-background-bright p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-medium text-text-bright">Integration Details</h2>
<div className="mt-2 space-y-1 text-sm text-text-dimmed">
{teamName && (
<div>
<span className="font-medium">Slack Workspace:</span> {teamName}
</div>
)}
<div>
<span className="font-medium">Installed:</span>{" "}
{formatDate(new Date(slackIntegration.createdAt))}
</div>
</div>
</div>
<div className="flex flex-col items-end gap-2">
<Dialog>
<DialogTrigger asChild>
<Button variant="danger/medium" LeadingIcon={TrashIcon} disabled={isUninstalling}>
Remove Integration
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Remove Slack Integration</DialogTitle>
</DialogHeader>
<DialogDescription>
This will remove the Slack integration and disable all connected alert channels.
This action cannot be undone.
</DialogDescription>
<FormButtons
confirmButton={
<Form method="post">
<input type="hidden" name="intent" value="uninstall" />
<Button
variant="danger/medium"
LeadingIcon={TrashIcon}
type="submit"
disabled={isUninstalling}
>
{isUninstalling ? "Removing..." : "Remove Integration"}
</Button>
</Form>
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
}
/>
</DialogContent>
</Dialog>
{actionData?.error && (
<Paragraph variant="small" className="text-error">
{actionData.error}
</Paragraph>
)}
</div>
</div>
</div>
{/* Connected Alert Channels Section */}
<div>
<h2 className="mb-4 text-lg font-medium text-text-bright">
Connected Alert Channels ({alertChannels.length})
</h2>
{alertChannels.length === 0 ? (
<div className="rounded-lg border border-grid-bright bg-background-bright p-6 text-center">
<Paragraph className="text-text-dimmed">
No alert channels are currently connected to this Slack integration.
</Paragraph>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Channel Name</TableHeaderCell>
<TableHeaderCell>Project</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Created</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{alertChannels.map((channel) => (
<TableRow key={channel.id}>
<TableCell>{channel.name}</TableCell>
<TableCell>{channel.project.name}</TableCell>
<TableCell>
<EnabledStatus enabled={channel.enabled} />
</TableCell>
<TableCell>{formatDate(new Date(channel.createdAt))}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</PageBody>
</PageContainer>
);
}
@@ -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() {
<TableCell>
<LinkButton
variant="minimal/small"
to={v3ProjectSettingsPath(
to={v3ProjectSettingsIntegrationsPath(
organization,
projectIntegration.project,
{ slug: "prod" } // Default to production environment
@@ -2,7 +2,7 @@ import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { prisma } from "~/db.server";
import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server";
import { requireUser } from "~/services/session.server";
import { ProjectParamSchema, v3ProjectSettingsPath } from "~/utils/pathBuilder";
import { ProjectParamSchema, v3ProjectSettingsGeneralPath } from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
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));
};
@@ -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 (
<Fieldset>
<InputGroup fullWidth>
@@ -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 }
+2 -2
View File
@@ -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 }
+10
View File
@@ -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);
+18
View File
@@ -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,
@@ -30,10 +30,10 @@ export class ProjectSettingsService {
);
}
deleteProject(projectSlug: string, userId: string) {
deleteProject(projectId: string, userId: string) {
const deleteProjectService = new DeleteProjectService(this.#prismaClient);
return fromPromise(deleteProjectService.call({ projectSlug, userId }), (error) => ({
return fromPromise(deleteProjectService.call({ projectId, userId }), (error) => ({
type: "other" as const,
cause: error,
}));
+20
View File
@@ -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,
@@ -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}* _<!date^${Math.round(
timestamp.getTime() / 1000
)}^at {date_num} {time_secs}|${timestamp.toLocaleString()}>_`,
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}* _<!date^${Math.round(
timestamp.getTime() / 1000
)}^at {date_num} {time_secs}|${timestamp.toLocaleString()}>_`,
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 _<!date^${Math.round(
timestamp.getTime() / 1000
)}^at {date_num} {time_secs}|${timestamp.toLocaleString()}>_`,
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<DeploymentIntegrationMetadata> {
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<string | undefined> {
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);
@@ -202,6 +202,15 @@ export function shouldSyncEnvVarForAnyEnvironment(
return false;
}
export function buildVercelDeploymentUrl(
vercelTeamSlug: string,
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
@@ -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<typeof AlertDeploymentFailureEmailSchema>) {
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<typeof AlertDeploymentFailureEmailS
{error.stack && (
<CodeBlock code={error.stack} theme={dracula} lineNumbers language="log" />
)}
<Link
href={deploymentLink}
target="_blank"
@@ -75,6 +112,49 @@ export default function Email(props: z.infer<typeof AlertDeploymentFailureEmailS
Investigate this error
</Link>
{git && (
<>
<Row>
<Column style={{ ...bullets, width: "33%" }}>Branch</Column>
<Column style={{ ...bullets, ...grey, width: "66%" }}>
<Link href={git.branchUrl} style={anchor}>
{git.branchName}
</Link>
</Column>
</Row>
<Row>
<Column style={{ ...bullets, width: "33%" }}>Commit</Column>
<Column style={{ ...bullets, ...grey, width: "66%" }}>
<Link href={git.commitUrl} style={anchor}>
{git.shortSha}
</Link>{" "}
{git.commitMessage}
</Column>
</Row>
{git.pullRequestNumber && git.pullRequestUrl && (
<Row>
<Column style={{ ...bullets, width: "33%" }}>Pull Request</Column>
<Column style={{ ...bullets, ...grey, width: "66%" }}>
<Link href={git.pullRequestUrl} style={anchor}>
#{git.pullRequestNumber}
</Link>
{git.pullRequestTitle ? ` ${git.pullRequestTitle}` : ""}
</Column>
</Row>
)}
</>
)}
{vercelDeploymentUrl && (
<Row>
<Column style={{ ...bullets, width: "33%" }}>Vercel</Column>
<Column style={{ ...bullets, ...grey, width: "66%" }}>
<Link href={vercelDeploymentUrl} style={anchor}>
View Vercel Deployment
</Link>
</Column>
</Row>
)}
<Image path="/emails/logo-mono.png" width="120" height="22" alt="Trigger.dev" />
<Footer />
</Container>
@@ -1,8 +1,18 @@
import { Body, Container, Head, Html, Link, Preview, Text } from "@react-email/components";
import {
Body,
Column,
Container,
Head,
Html,
Link,
Preview,
Row,
Text,
} from "@react-email/components";
import { z } from "zod";
import { Footer } from "./components/Footer";
import { Image } from "./components/Image";
import { anchor, container, h1, main } from "./components/styles";
import { anchor, bullets, container, grey, h1, main } from "./components/styles";
export const AlertDeploymentSuccessEmailSchema = z.object({
email: z.literal("alert-deployment-success"),
@@ -13,6 +23,19 @@ export const AlertDeploymentSuccessEmailSchema = z.object({
deployedAt: z.date(),
taskCount: z.number(),
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 = {
@@ -23,10 +46,31 @@ const previewDefaults = {
deployedAt: new Date().toISOString(),
taskCount: 3,
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<typeof AlertDeploymentSuccessEmailSchema>) {
const { version, environment, organization, shortCode, deployedAt, taskCount, deploymentLink } = {
const {
version,
environment,
organization,
shortCode,
deployedAt,
taskCount,
deploymentLink,
git,
vercelDeploymentUrl,
} = {
...previewDefaults,
...props,
};
@@ -53,6 +97,49 @@ export default function Email(props: z.infer<typeof AlertDeploymentSuccessEmailS
View Deployment
</Link>
{git && (
<>
<Row>
<Column style={{ ...bullets, width: "33%" }}>Branch</Column>
<Column style={{ ...bullets, ...grey, width: "66%" }}>
<Link href={git.branchUrl} style={anchor}>
{git.branchName}
</Link>
</Column>
</Row>
<Row>
<Column style={{ ...bullets, width: "33%" }}>Commit</Column>
<Column style={{ ...bullets, ...grey, width: "66%" }}>
<Link href={git.commitUrl} style={anchor}>
{git.shortSha}
</Link>{" "}
{git.commitMessage}
</Column>
</Row>
{git.pullRequestNumber && git.pullRequestUrl && (
<Row>
<Column style={{ ...bullets, width: "33%" }}>Pull Request</Column>
<Column style={{ ...bullets, ...grey, width: "66%" }}>
<Link href={git.pullRequestUrl} style={anchor}>
#{git.pullRequestNumber}
</Link>
{git.pullRequestTitle ? ` ${git.pullRequestTitle}` : ""}
</Column>
</Row>
)}
</>
)}
{vercelDeploymentUrl && (
<Row>
<Column style={{ ...bullets, width: "33%" }}>Vercel</Column>
<Column style={{ ...bullets, ...grey, width: "66%" }}>
<Link href={vercelDeploymentUrl} style={anchor}>
View Vercel Deployment
</Link>
</Column>
</Row>
)}
<Image path="/emails/logo-mono.png" width="120" height="22" alt="Trigger.dev" />
<Footer />
</Container>
+20
View File
@@ -117,6 +117,26 @@ const deploymentCommonProperties = {
slug: z.string(),
name: z.string(),
}),
/** Git metadata for the deployment source code */
git: z
.object({
branch: z.string(),
commitSha: z.string(),
commitMessage: z.string(),
commitUrl: z.string(),
branchUrl: z.string(),
pullRequestNumber: z.number().optional(),
pullRequestTitle: z.string().optional(),
pullRequestUrl: z.string().optional(),
provider: z.string().optional(),
})
.optional(),
/** Vercel integration data */
vercel: z
.object({
deploymentUrl: z.string(),
})
.optional(),
};
const deploymentDeploymentCommonProperties = {