-
-
- Pull env vars before build
- {!hideSectionToggles && availableEnvSlugs.length > 1 && (
- 0 &&
- enabledSlugs.every((s) => pullEnvVarsBeforeBuild.includes(s))
- }
- onCheckedChange={(checked) => {
- onPullEnvVarsChange(checked ? [...enabledSlugs] : []);
- }}
- />
- )}
-
-
- Select which environments should pull environment variables from Vercel before each
- build.{" "}
- {envVarsConfigLink && (
- <>
- Configure which variables to pull .
- >
- )}
-
-
-
- {availableEnvSlugs.map((slug) => {
- const envType = envSlugToType(slug);
- const disabled = isSlugDisabled(slug);
- const disabledReason = disabledEnvSlugs?.[slug];
- const row = (
-
-
-
-
- {environmentFullTitle({ type: envType })}
-
-
+ {layout === "card" && (
+
+
+
+ Pull env vars before build
+ {!hideSectionToggles && availableEnvSlugs.length > 1 && (
0 &&
+ enabledSlugs.every((s) => pullEnvVarsBeforeBuild.includes(s))
+ }
onCheckedChange={(checked) => {
- onPullEnvVarsChange(
- checked
- ? [...pullEnvVarsBeforeBuild, slug]
- : pullEnvVarsBeforeBuild.filter((s) => s !== slug)
- );
+ onPullEnvVarsChange(checked ? [...enabledSlugs] : []);
}}
/>
-
- );
- if (disabled && disabledReason) {
- return
;
- }
- return row;
- })}
+ )}
+
+
+ Select which environments should pull environment variables from Vercel before each
+ build.{" "}
+ {envVarsConfigLink && (
+ <>
+ Configure which variables to pull .
+ >
+ )}
+
+
+
+ {availableEnvSlugs.map((slug) => {
+ const envType = envSlugToType(slug);
+ const disabled = isSlugDisabled(slug);
+ const disabledReason = disabledEnvSlugs?.[slug];
+ const row = (
+
+
+
+
+ {environmentFullTitle({ type: envType })}
+
+
+
{
+ onPullEnvVarsChange(
+ checked
+ ? [...pullEnvVarsBeforeBuild, slug]
+ : pullEnvVarsBeforeBuild.filter((s) => s !== slug)
+ );
+ }}
+ />
+
+ );
+ if (disabled && disabledReason) {
+ return (
+
+ );
+ }
+ return row;
+ })}
+
-
+ )}
{/* Discover new env vars */}
-
-
-
- Discover new env vars
- {!hideSectionToggles && availableEnvSlugs.length > 1 && (
- 0 &&
- enabledSlugs.every(
- (s) => discoverEnvVars.includes(s) || !pullEnvVarsBeforeBuild.includes(s)
- ) &&
- enabledSlugs.some((s) => discoverEnvVars.includes(s))
- }
- disabled={!enabledSlugs.some((s) => pullEnvVarsBeforeBuild.includes(s))}
- onCheckedChange={(checked) => {
- onDiscoverEnvVarsChange(
- checked ? enabledSlugs.filter((s) => pullEnvVarsBeforeBuild.includes(s)) : []
- );
- }}
- />
- )}
-
-
- Select which environments should automatically discover and create new environment
- variables from Vercel during builds.
-
-
-
- {availableEnvSlugs.map((slug) => {
- const envType = envSlugToType(slug);
- const disabled = isSlugDisabled(slug);
- const disabledReason = disabledEnvSlugs?.[slug];
- const isPullDisabled = !pullEnvVarsBeforeBuild.includes(slug);
- const row = (
-
-
-
-
- {environmentFullTitle({ type: envType })}
-
-
+ {layout === "card" && (
+
+
+
+ Discover new env vars
+ {!hideSectionToggles && availableEnvSlugs.length > 1 && (
0 &&
+ enabledSlugs.every(
+ (s) => discoverEnvVars.includes(s) || !pullEnvVarsBeforeBuild.includes(s)
+ ) &&
+ enabledSlugs.some((s) => discoverEnvVars.includes(s))
+ }
+ disabled={!enabledSlugs.some((s) => pullEnvVarsBeforeBuild.includes(s))}
onCheckedChange={(checked) => {
onDiscoverEnvVarsChange(
- checked
- ? [...discoverEnvVars, slug]
- : discoverEnvVars.filter((s) => s !== slug)
+ checked ? enabledSlugs.filter((s) => pullEnvVarsBeforeBuild.includes(s)) : []
);
}}
/>
-
- );
- if (disabled && disabledReason) {
- return
;
- }
- return row;
- })}
+ )}
+
+
+ Select which environments should automatically discover and create new environment
+ variables from Vercel during builds.
+
+
+
+ {availableEnvSlugs.map((slug) => {
+ const envType = envSlugToType(slug);
+ const disabled = isSlugDisabled(slug);
+ const disabledReason = disabledEnvSlugs?.[slug];
+ const isPullDisabled = !pullEnvVarsBeforeBuild.includes(slug);
+ const row = (
+
+
+
+
+ {environmentFullTitle({ type: envType })}
+
+
+
{
+ onDiscoverEnvVarsChange(
+ checked
+ ? [...discoverEnvVars, slug]
+ : discoverEnvVars.filter((s) => s !== slug)
+ );
+ }}
+ />
+
+ );
+ if (disabled && disabledReason) {
+ return (
+
+ );
+ }
+ return row;
+ })}
+
-
+ )}
+
+ {atomicSections}
{/* Atomic deployments */}
-
-
- Atomic deployments
- {
- onAtomicBuildsChange(checked ? ["prod"] : []);
- }}
- />
-
-
- When enabled, production deployments wait for Vercel deployment to complete before
- promoting the Trigger.dev deployment. This will disable the "Auto-assign Custom Production
- Domains" option in your Vercel project settings to perform staged deployments.{" "}
-
- Learn more
-
- .
-
- {currentTriggerVersion && (
+ {layout === "card" && (
+
+
+ Atomic deployments
+ {
+ onAtomicBuildsChange(checked ? ["prod"] : []);
+ }}
+ />
+
- Currently pinned to{" "}
- {currentTriggerVersion} in Vercel
- production.
+ When enabled, production deployments wait for Vercel deployment to complete before
+ promoting the Trigger.dev deployment. This will disable the "Auto-assign Custom
+ Production Domains" option in your Vercel project settings to perform staged
+ deployments.{" "}
+
+ Learn more
+
+ .
- )}
- {!currentTriggerVersion && currentTriggerVersionFetchFailed && (
-
- Couldn't read TRIGGER_VERSION from
- Vercel — check the Vercel dashboard to confirm the production pin.
-
- )}
-
+ {currentTriggerVersion && (
+
+ Currently pinned to{" "}
+ {currentTriggerVersion} in Vercel
+ production.
+
+ )}
+ {!currentTriggerVersion && currentTriggerVersionFetchFailed && (
+
+ Couldn't read TRIGGER_VERSION from
+ Vercel — check the Vercel dashboard to confirm the production pin.
+
+ )}
+
+ )}
{/* Auto promotion — only visible when atomic deployments are on */}
- {atomicBuilds.includes("prod") && onAutoPromoteChange !== undefined && (
+ {layout === "card" && atomicBuilds.includes("prod") && onAutoPromoteChange !== undefined && (
Auto promotion
@@ -248,3 +395,59 @@ export function BuildSettingsFields({
>
);
}
+
+function EnvToggleRow({
+ slug,
+ checked,
+ disabled,
+ disabledReason,
+ unlockHint,
+ unlockTarget,
+ onCheckedChange,
+}: {
+ slug: EnvSlug;
+ checked: boolean;
+ disabled: boolean;
+ disabledReason?: string;
+ unlockHint?: string;
+ unlockTarget?: string;
+ onCheckedChange: (checked: boolean) => void;
+}) {
+ const envType = envSlugToType(slug);
+
+ return (
+
+
+
+ }
+ >
+
+
+
+
+ {environmentFullTitle({ type: envType })}
+
+
+ {disabled && disabledReason ? (
+
{disabledReason}
+ ) : null}
+
+
+ );
+}
diff --git a/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx b/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx
index 85c7816b73e..cd5f9eee505 100644
--- a/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx
+++ b/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx
@@ -13,6 +13,7 @@ import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Header3 } from "~/components/primitives/Headers";
import { Hint } from "~/components/primitives/Hint";
+import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Select, SelectItem } from "~/components/primitives/Select";
@@ -797,10 +798,8 @@ export function VercelOnboardingModal({
{showProjectSelection && (
-
Select Vercel Project
-
- Choose which Vercel project to connect with this Trigger.dev project. Your API keys
- will be automatically synced to Vercel.
+
+ Choose the Vercel project to pair with this Trigger.dev project.
{availableProjects.length === 0 ? (
@@ -808,41 +807,42 @@ export function VercelOnboardingModal({
No Vercel projects found. Please create a project in Vercel first.
) : (
- {
- if (!Array.isArray(value)) {
- const project = availableProjects.find((p) => p.id === value);
- setSelectedVercelProject(project || null);
- setProjectSelectionError(null);
- }
- }}
- items={availableProjects}
- filter={availableProjects.length > 5 ? { keys: ["name"] } : undefined}
- variant="tertiary/medium"
- placeholder="Select a Vercel project"
- dropdownIcon
- text={selectedVercelProject?.name || "Select a project"}
- >
- {availableProjects.map((project) => (
-
- {project.name}
-
- ))}
-
+
+ {
+ if (!Array.isArray(value)) {
+ const project = availableProjects.find((p) => p.id === value);
+ setSelectedVercelProject(project || null);
+ setProjectSelectionError(null);
+ }
+ }}
+ items={availableProjects}
+ filter={availableProjects.length > 5 ? { keys: ["name"] } : undefined}
+ variant="secondary/medium"
+ placeholder="Select a Vercel project"
+ dropdownIcon
+ text={selectedVercelProject?.name || "Select a project"}
+ >
+ {availableProjects.map((project) => (
+
+ {project.name}
+
+ ))}
+
+
+ Your{" "}
+
+ TRIGGER_SECRET_KEY
+ {" "}
+ is synced to Vercel for each environment once connected.
+
+
)}
{projectSelectionError && {projectSelectionError} }
-
- Once connected, your{" "}
-
- TRIGGER_SECRET_KEY
- {" "}
- will be automatically synced to Vercel for each environment.
-
-
@@ -866,7 +866,7 @@ export function VercelOnboardingModal({
}
cancelButton={
-
+
Cancel
}
diff --git a/apps/webapp/app/components/primitives/Buttons.tsx b/apps/webapp/app/components/primitives/Buttons.tsx
index 323f3743826..6075d52f1bf 100644
--- a/apps/webapp/app/components/primitives/Buttons.tsx
+++ b/apps/webapp/app/components/primitives/Buttons.tsx
@@ -89,6 +89,13 @@ const theme = {
shortcut: "border-text-bright text-text-bright group-hover/button:border-text-bright/60",
icon: "text-text-bright",
},
+ warning: {
+ textColor: "text-warning transition group-disabled/button:text-warning/60",
+ button:
+ "bg-warning/10 border border-warning/20 group-hover/button:bg-warning/20 group-hover/button:border-warning/40 group-disabled/button:opacity-60 group-disabled/button:pointer-events-none",
+ shortcut: "border-warning/40 text-warning group-hover/button:border-warning/60",
+ icon: "text-warning",
+ },
docs: {
textColor: "text-blue-200/70 transition group-disabled/button:text-text-dimmed/80",
button:
@@ -133,6 +140,10 @@ const variant = {
"danger/medium": createVariant("medium", "danger"),
"danger/large": createVariant("large", "danger"),
"danger/extra-large": createVariant("extra-large", "danger"),
+ "warning/small": createVariant("small", "warning"),
+ "warning/medium": createVariant("medium", "warning"),
+ "warning/large": createVariant("large", "warning"),
+ "warning/extra-large": createVariant("extra-large", "warning"),
"docs/small": createVariant("small", "docs"),
"docs/medium": createVariant("medium", "docs"),
"docs/large": createVariant("large", "docs"),
diff --git a/apps/webapp/app/components/primitives/Select.tsx b/apps/webapp/app/components/primitives/Select.tsx
index 66b291b7524..3c4c8faad0c 100644
--- a/apps/webapp/app/components/primitives/Select.tsx
+++ b/apps/webapp/app/components/primitives/Select.tsx
@@ -38,6 +38,9 @@ const variants = {
"secondary/small": {
button: cn(sizes.small.button, style.secondary.button),
},
+ "secondary/medium": {
+ button: cn(sizes.medium.button, style.secondary.button),
+ },
"tertiary/small": {
button: cn(sizes.small.button, style.tertiary.button),
},
@@ -692,11 +695,11 @@ export function ComboBox({
...props
}: ComboBoxProps) {
return (
-
+
}
- className="flex-1 bg-transparent text-xs text-text-dimmed outline-hidden"
+ className="flex-1 border-0 bg-transparent text-xs text-text-dimmed outline-hidden focus:border-0 focus:ring-0"
{...props}
/>
{shortcut && (
diff --git a/apps/webapp/app/components/primitives/SettingsLayout.tsx b/apps/webapp/app/components/primitives/SettingsLayout.tsx
index 7d6484fb467..dae340e9835 100644
--- a/apps/webapp/app/components/primitives/SettingsLayout.tsx
+++ b/apps/webapp/app/components/primitives/SettingsLayout.tsx
@@ -1,3 +1,4 @@
+import { ExclamationCircleIcon, ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import { type ReactNode } from "react";
import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout";
import { cn } from "~/utils/cn";
@@ -100,7 +101,10 @@ export function SettingsRowTitle({
htmlFor?: string;
className?: string;
}) {
- const classes = cn("font-sans text-sm font-semibold leading-tight text-text-bright", className);
+ const classes = cn(
+ "block font-sans text-sm font-semibold leading-tight text-text-bright",
+ className
+ );
return htmlFor ? (
{children}
@@ -152,14 +156,14 @@ export function SettingsRow({
className?: string;
titleClassName?: string;
size?: RowSize;
- align?: "center" | "start";
+ align?: "center" | "start" | "end";
bordered?: boolean;
}) {
return (
+
+
+
+ {title}
+
+ {description ?
{description} : null}
+
+
+ );
+}
+
/** Right-aligned action bar, typically for a section's Save button. */
export function SettingsActions({
children,
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx
index a164d4082da..9bc54b19bf1 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx
@@ -6,18 +6,18 @@ import React, { useCallback, useEffect, useRef, useState } from "react";
import { typedjson, useTypedFetcher, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { InlineCode } from "~/components/code/InlineCode";
-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 {
+ SettingsActions,
+ SettingsContainer,
+ SettingsHeader,
+ SettingsRow,
+ SettingsSection,
+} from "~/components/primitives/SettingsLayout";
import { SpinnerWhite } from "~/components/primitives/Spinner";
+import { Switch } from "~/components/primitives/Switch";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
@@ -41,6 +41,8 @@ import {
VercelSettingsPanel,
} from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
+export const handle = { pageTitle: "Integrations" };
+
export const loader = dashboardLoader(
{
params: EnvironmentParamSchema,
@@ -215,6 +217,7 @@ export default function IntegrationsSettingsPage() {
const nextUrl = searchParams.get("next");
const [isModalOpen, setIsModalOpen] = useState(false);
const vercelFetcher = useTypedFetcher
();
+ const onboardingData = vercelFetcher.data?.onboardingData ?? null;
// Helper to open modal and ensure query param is present
const openVercelOnboarding = useCallback(() => {
@@ -245,7 +248,7 @@ export default function IntegrationsSettingsPage() {
useEffect(() => {
if (hasQueryParam && vercelIntegrationEnabled) {
// Ensure query param is present and modal is open
- if (vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") {
+ if (onboardingData && vercelFetcher.state === "idle") {
// Data is loaded, ensure modal is open (query param takes precedence)
if (!isModalOpen) {
openVercelOnboarding();
@@ -270,6 +273,7 @@ export default function IntegrationsSettingsPage() {
organization.slug,
project.slug,
environment.slug,
+ onboardingData,
vercelFetcher.data,
vercelFetcher.state,
isModalOpen,
@@ -288,7 +292,7 @@ export default function IntegrationsSettingsPage() {
// When data finishes loading (from query param), ensure modal is open
useEffect(() => {
- if (hasQueryParam && vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") {
+ if (hasQueryParam && onboardingData && vercelFetcher.state === "idle") {
// Data loaded and query param is present, ensure modal is open
if (!isModalOpen) {
openVercelOnboarding();
@@ -309,7 +313,7 @@ export default function IntegrationsSettingsPage() {
});
}
- if (vercelFetcher.data && vercelFetcher.data.onboardingData) {
+ if (onboardingData) {
// Data already loaded, open modal immediately
openVercelOnboarding();
} else {
@@ -328,6 +332,7 @@ export default function IntegrationsSettingsPage() {
project.slug,
environment.slug,
vercelFetcher,
+ onboardingData,
setSearchParams,
hasQueryParam,
openVercelOnboarding,
@@ -335,74 +340,69 @@ export default function IntegrationsSettingsPage() {
// When data loads from button click, open modal
useEffect(() => {
- if (
- waitingForButtonClickRef.current &&
- vercelFetcher.data?.onboardingData &&
- vercelFetcher.state === "idle"
- ) {
+ if (waitingForButtonClickRef.current && 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]);
+ }, [onboardingData, vercelFetcher.state, openVercelOnboarding]);
return (
<>
-
-
- {githubAppEnabled && (
-
-
-
- {vercelIntegrationEnabled && (
-
-
Vercel integration
-
-
-
-
- )}
-
-
-
Build settings
-
- These settings apply to deployments triggered from GitHub and to CLI deployments
- run with the --native-build-server {" "}
- flag.
-
-
-
-
-
-
- )}
-
-
+
+ {githubAppEnabled && (
+
+
+
+
+
+
+ {vercelIntegrationEnabled && (
+
+
+
+
+ )}
+
+
+
+ Applies to deployments triggered from GitHub, and CLI deployments run with the{" "}
+
+ --native-build-server
+ {" "}
+ flag.
+ >
+ }
+ />
+
+
+
+ )}
+
{/* Vercel Onboarding Modal */}
{vercelIntegrationEnabled && (
-
-
- Trigger config file
- {
- setBuildSettingsValues((prev) => ({
- ...prev,
- triggerConfigFilePath: e.target.value,
- }));
- }}
- />
-
- Path to your Trigger configuration file, relative to the root directory of your repo.
-
-
- {fields.triggerConfigFilePath.errors}
-
-
-
-
- Install command
- {
- setBuildSettingsValues((prev) => ({
- ...prev,
- installCommand: e.target.value,
- }));
- }}
- />
-
- Command to install your project dependencies. This will be run from the root directory
- of your repo. Auto-detected by default.
-
-
- {fields.installCommand.errors?.join(", ")}
-
-
-
- Pre-build command
- {
- setBuildSettingsValues((prev) => ({
- ...prev,
- preBuildCommand: e.target.value,
- }));
- }}
- />
-
- Any command that needs to run before we build and deploy your project. This will be run
- from the root directory of your repo.
-
-
- {fields.preBuildCommand.errors?.join(", ")}
-
-
-
-
- {
+
+ {
setBuildSettingsValues((prev) => ({
...prev,
- useNativeBuildServer: isChecked,
+ triggerConfigFilePath: e.target.value,
}));
}}
/>
-
- Native build server builds don't rely on external build providers and are used by
- default. Requires version 4.2.0 or newer.
-
-
- {fields.useNativeBuildServer.errors}
+
+ {fields.triggerConfigFilePath.errors}
-
-
- {buildSettingsForm.errors}
-
- Save
-
- }
- />
-
+
+ }
+ />
+
+
+ {
+ setBuildSettingsValues((prev) => ({
+ ...prev,
+ installCommand: e.target.value,
+ }));
+ }}
+ />
+
+ {fields.installCommand.errors?.join(", ")}
+
+
+ }
+ />
+
+ {
+ setBuildSettingsValues((prev) => ({
+ ...prev,
+ preBuildCommand: e.target.value,
+ }));
+ }}
+ />
+
+ {fields.preBuildCommand.errors?.join(", ")}
+
+
+ }
+ />
+
+ {
+ setBuildSettingsValues((prev) => ({
+ ...prev,
+ useNativeBuildServer: isChecked,
+ }));
+ }}
+ />
+ }
+ />
+
+
+ {fields.useNativeBuildServer.errors}
+
+ {buildSettingsForm.errors}
+
+
+
+ Save
+
+
);
}
+
+function SettingsControl({ children }: { children: React.ReactNode }) {
+ return {children}
;
+}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx
index f99a14f336c..65d7ab14278 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx
@@ -1,4 +1,4 @@
-import { Outlet, type MetaFunction } from "@remix-run/react";
+import { Outlet, useMatches, 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";
@@ -42,13 +42,25 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
return null;
};
+const DEFAULT_PAGE_TITLE = "Project settings";
+
+function usePageTitle() {
+ const matches = useMatches();
+ for (let i = matches.length - 1; i >= 0; i--) {
+ const pageTitle = (matches[i].handle as { pageTitle?: string } | undefined)?.pageTitle;
+ if (pageTitle) return pageTitle;
+ }
+ return DEFAULT_PAGE_TITLE;
+}
+
export default function SettingsLayout() {
const project = useProject();
+ const pageTitle = usePageTitle();
return (
-
+
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx
index 1842edf347c..61a57701fc8 100644
--- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx
+++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx
@@ -1,6 +1,12 @@
import { getFormProps, getInputProps, useForm } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
-import { CheckCircleIcon, LockClosedIcon, PlusIcon } from "@heroicons/react/20/solid";
+import {
+ ArrowUpCircleIcon,
+ ArrowUpRightIcon,
+ CheckCircleIcon,
+ LockClosedIcon,
+ PlusIcon,
+} from "@heroicons/react/20/solid";
import { DialogClose } from "@radix-ui/react-dialog";
import {
Form,
@@ -21,7 +27,7 @@ import {
environmentTextClassName,
} from "~/components/environments/EnvironmentLabel";
import { OctoKitty } from "~/components/GitHubLoginButton";
-import { Button } from "~/components/primitives/Buttons";
+import { Button, LinkButton } from "~/components/primitives/Buttons";
import { DateTime } from "~/components/primitives/DateTime";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
import { Fieldset } from "~/components/primitives/Fieldset";
@@ -34,10 +40,15 @@ import { Label } from "~/components/primitives/Label";
import { Paragraph } from "~/components/primitives/Paragraph";
import { PermissionLink } from "~/components/primitives/PermissionLink";
import { Select, SelectItem } from "~/components/primitives/Select";
+import {
+ SettingsActions,
+ SettingsBlock,
+ SettingsRow,
+ SettingsRowDescription,
+} from "~/components/primitives/SettingsLayout";
import { SpinnerWhite } from "~/components/primitives/Spinner";
import { Switch } from "~/components/primitives/Switch";
import { TextLink } from "~/components/primitives/TextLink";
-import { InfoIconTooltip } from "~/components/primitives/Tooltip";
import {
redirectBackWithErrorMessage,
redirectBackWithSuccessMessage,
@@ -386,6 +397,7 @@ export function ConnectGitHubRepoModal({
redirectUrl,
preventDismiss,
canManageGithub = true,
+ buttonVariant = "secondary/medium",
}: {
gitHubAppInstallations: GitHubAppInstallation[];
organizationSlug: string;
@@ -395,6 +407,7 @@ export function ConnectGitHubRepoModal({
/** When true, prevents closing the modal via Escape key or clicking outside */
preventDismiss?: boolean;
canManageGithub?: boolean;
+ buttonVariant?: "secondary/small" | "secondary/medium";
}) {
const [isModalOpen, setIsModalOpen] = useState(false);
const lastSubmission = useActionData() as any;
@@ -457,7 +470,7 @@ export function ConnectGitHubRepoModal({
- Cancel
+ Cancel
)
}
@@ -678,6 +691,90 @@ export function GitHubConnectionPrompt({
);
}
+function GitHubAppInstalledRow() {
+ return (
+
+
+ Installed
+
+ }
+ />
+ );
+}
+
+function GitHubSettingsRows({
+ gitHubAppInstallations,
+ organizationSlug,
+ projectSlug,
+ environmentSlug,
+ redirectUrl,
+ canManageGithub = true,
+}: {
+ gitHubAppInstallations: GitHubAppInstallation[];
+ organizationSlug: string;
+ projectSlug: string;
+ environmentSlug: string;
+ redirectUrl?: string;
+ canManageGithub?: boolean;
+}) {
+ const appInstalled = gitHubAppInstallations.length > 0;
+ const githubInstallationRedirect =
+ redirectUrl ||
+ v3ProjectSettingsIntegrationsPath(
+ { slug: organizationSlug },
+ { slug: projectSlug },
+ { slug: environmentSlug }
+ );
+
+ return (
+ <>
+ {appInstalled ? (
+
+ ) : (
+
+ Install GitHub app
+
+ }
+ />
+ )}
+
+ {appInstalled && (
+
+ }
+ />
+ )}
+ >
+ );
+}
+
export function ConnectedGitHubRepoForm({
connectedGitHubRepo,
previewEnvironmentEnabled,
@@ -736,21 +833,36 @@ export function ConnectedGitHubRepoForm({
return (
<>
-
-
-
-
- {connectedGitHubRepo.repository.fullName}
-
- {connectedGitHubRepo.repository.private && (
-
- )}
-
+
+
+ Connected
+
+ }
+ />
+
+
+
+ {connectedGitHubRepo.repository.private ? "Private" : "Public"} repo
+
+
+ View repo
+
+
+ }
+ >
+ {connectedGitHubRepo.repository.fullName}
+ {" "}
+ connected on{" "}
-
-
-
-
-
- Disconnect
-
-
-
- Disconnect GitHub repository
-
-
- Are you sure you want to disconnect{" "}
- {connectedGitHubRepo.repository.fullName} ?
- This will stop automatic deployments from GitHub.
-
-
-
- {redirectUrl && }
-
- Disconnect repository
-
-
- }
- cancelButton={
-
- Cancel
-
+ .
+ >
+ }
+ action={
+
+
+
-
-
-
-
+ >
+ Disconnect
+
+
+
+ Disconnect GitHub repository
+
+
+ Are you sure you want to disconnect{" "}
+ {connectedGitHubRepo.repository.fullName} ?
+ This will stop automatic deployments from GitHub.
+
+
+
+ {redirectUrl && (
+
+ )}
+
+ Disconnect repository
+
+
+ }
+ cancelButton={
+
+ Cancel
+
+ }
+ />
+
+
+
+ }
+ />
+
+ {fields.productionBranch?.errors}
+ {fields.stagingBranch?.errors}
+ {fields.previewDeploymentsEnabled?.errors}
+ {gitSettingsForm.errors}
+
+
+
+ Save
+
+
>
);
}
+function EnvironmentRowLabel({
+ type,
+ description,
+}: {
+ type: "PRODUCTION" | "STAGING" | "PREVIEW";
+ description?: React.ReactNode;
+}) {
+ return (
+
+
+
+
+ {environmentFullTitle({ type })}
+
+
+ {description ?
{description} : null}
+
+ );
+}
+
// ============================================================================
// Main GitHub Settings Panel Component
// ============================================================================
@@ -928,11 +1069,13 @@ export function GitHubSettingsPanel({
projectSlug,
environmentSlug,
billingPath,
+ layout = "compact",
}: {
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
billingPath: string;
+ layout?: "settings" | "compact";
}) {
const fetcher = useTypedFetcher();
const location = useLocation();
@@ -951,6 +1094,8 @@ export function GitHubSettingsPanel({
const data = fetcher.data;
+ const canManageGithub = data?.canManageGithub ?? true;
+
// Loading state
if (fetcher.state === "loading" && !data) {
return (
@@ -967,35 +1112,49 @@ export function GitHubSettingsPanel({
}
// Connected repository exists - show form
- if (data.connectedRepository) {
+ if (data?.connectedRepository) {
+ return (
+ <>
+ {layout === "settings" && }
+
+ >
+ );
+ }
+
+ // No connected repository - show connection prompt
+ if (layout === "settings") {
return (
-
);
}
- // No connected repository - show connection prompt
return (
- {!data.connectedRepository && (
- Connect your GitHub repository to automatically deploy your changes.
- )}
+ Connect a GitHub repo to automatically deploy changes.
);
}
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx
index 7a5f61a48dc..0959d61417c 100644
--- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx
+++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx
@@ -2,27 +2,31 @@ import { getFormProps, useForm } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import { CheckCircleIcon, ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import { DialogClose } from "@radix-ui/react-dialog";
-import { Form, useActionData, useLocation, useNavigation } from "@remix-run/react";
+import { Form, useActionData, useFetcher, useLocation, useNavigation } from "@remix-run/react";
import { type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { Result, fromPromise } from "neverthrow";
import { useEffect, useRef, useState } from "react";
import { typedjson, useTypedFetcher } from "remix-typedjson";
import { z } from "zod";
+import {
+ EnvironmentIcon,
+ environmentTextClassName,
+} from "~/components/environments/EnvironmentLabel";
import { BuildSettingsFields } from "~/components/integrations/VercelBuildSettings";
import { VercelLogo } from "~/components/integrations/VercelLogo";
import { Button } from "~/components/primitives/Buttons";
-import { Callout } from "~/components/primitives/Callout";
import { DateTime } from "~/components/primitives/DateTime";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
-import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
-import { Hint } from "~/components/primitives/Hint";
-import { InputGroup } from "~/components/primitives/InputGroup";
-import { Label } from "~/components/primitives/Label";
import { Paragraph } from "~/components/primitives/Paragraph";
import { PermissionLink } from "~/components/primitives/PermissionLink";
import { Select, SelectItem } from "~/components/primitives/Select";
+import {
+ SettingsActions,
+ SettingsAlertRow,
+ SettingsRow,
+} from "~/components/primitives/SettingsLayout";
import { SpinnerWhite } from "~/components/primitives/Spinner";
import {
redirectBackWithErrorMessage,
@@ -514,10 +518,33 @@ export const action = dashboardAction(
}
);
-function VercelConnectionPrompt({
+function StagingEnvOption({ name }: { name: string }) {
+ return (
+
+
+ {name}
+
+ );
+}
+
+function VercelAppInstalledRow() {
+ return (
+
+
+ Installed
+
+ }
+ />
+ );
+}
+
+function VercelSettingsRows({
organizationSlug,
projectSlug,
- environmentSlug,
+ environmentSlug: _environmentSlug,
hasOrgIntegration,
isGitHubConnected,
onOpenModal,
@@ -533,67 +560,55 @@ function VercelConnectionPrompt({
isLoading?: boolean;
canManageVercel?: boolean;
}) {
- const installPath = vercelAppInstallPath(organizationSlug, projectSlug);
-
- const handleConnectProject = () => {
- if (onOpenModal) {
- onOpenModal();
- }
- };
-
+ const noPermissionTooltip = "You don't have permission to manage the Vercel integration";
const isLoadingProjects = isLoading ?? false;
- const isDisabled = isLoadingProjects || !onOpenModal;
return (
-
-
-
-
- {hasOrgIntegration ? (
- <>
-
- : () =>
- }
- >
- {isLoadingProjects ? "Loading projects..." : "Connect Vercel project"}
-
-
- Vercel app is installed
-
- {!onOpenModal && (
-
- Please reconnect Vercel to continue
-
- )}
- >
- ) : (
- <>
-
}
- >
- Install Vercel app
-
- >
- )}
-
-
-
-
+ <>
+ {hasOrgIntegration ? (
+
+ ) : (
+ }
+ >
+ Install Vercel app
+
+ }
+ />
+ )}
+
+ {hasOrgIntegration && (
+ onOpenModal?.()}
+ disabled={isLoadingProjects || !onOpenModal || !canManageVercel}
+ tooltip={canManageVercel ? undefined : noPermissionTooltip}
+ LeadingIcon={
+ isLoadingProjects
+ ? () =>
+ : () =>
+ }
+ >
+ {isLoadingProjects ? "Loading projects…" : "Connect Vercel project"}
+
+ }
+ />
+ )}
+
+ {!isGitHubConnected && }
+ >
);
}
@@ -606,42 +621,32 @@ function VercelAuthInvalidBanner({
projectSlug: string;
canManageVercel?: boolean;
}) {
- const installUrl = vercelAppInstallPath(organizationSlug, projectSlug);
-
return (
-
-
-
-
- Vercel connection expired
-
-
- Your Vercel access token has expired or been revoked. Please reconnect to restore
- functionality.
-
-
- Reconnect Vercel
-
-
-
-
+
+ Reconnect Vercel
+
+ }
+ />
);
}
function VercelGitHubWarning() {
return (
-
-
- GitHub integration is not connected. Vercel integration cannot sync environment variables
- and link deployments without a properly installed GitHub integration.
-
-
+
);
}
@@ -776,6 +781,9 @@ function ConnectedVercelProjectForm({
navigation.formData?.get("action") === "update-config" &&
(navigation.state === "submitting" || navigation.state === "loading");
+ const disableAutoAssignFetcher = useFetcher();
+ const isDisablingAutoAssign = disableAutoAssignFetcher.state !== "idle";
+
const actionUrl = vercelResourcePath(organizationSlug, projectSlug, environmentSlug);
const availableEnvSlugs = getAvailableEnvSlugs(hasStagingEnvironment, hasPreviewEnvironment);
@@ -786,7 +794,7 @@ function ConnectedVercelProjectForm({
const disabledEnvSlugsForBuildSettings: Partial> | undefined =
hasStagingEnvironment && !configValues.vercelStagingEnvironment
- ? { stg: "Map a custom Vercel environment to Staging to enable this" }
+ ? { stg: "Set a Vercel environment for Staging first." }
: undefined;
const _formatSelectedEnvs = (
@@ -800,13 +808,23 @@ function ConnectedVercelProjectForm({
return (
<>
-
-
-
-
- {connectedProject.vercelProjectName}
+
+
+ Connected
-
+ }
+ />
+
+
+
+ Vercel project
+
+ {connectedProject.vercelProjectName} connected on{" "}
-
-
-
-
-
- Disconnect
-
-
-
- Disconnect Vercel project
-
-
- Are you sure you want to disconnect{" "}
- {connectedProject.vercelProjectName} ? This
- will stop pulling environment variables and disable atomic deployments.
-
-
-
-
- Disconnect project
-
-
- }
- cancelButton={
-
- Cancel
-
+ .
+ >
+ }
+ action={
+
+
+
-
-
-
-
+ >
+ Disconnect
+
+
+
+ Disconnect Vercel project
+
+
+ Are you sure you want to disconnect{" "}
+ {connectedProject.vercelProjectName} ? This
+ will stop pulling environment variables and disable atomic deployments.
+
+
+
+
+ Disconnect project
+
+
+ }
+ cancelButton={
+
+ Cancel
+
+ }
+ />
+
+
+
+ }
+ />
{/* Configuration form */}
+ }
+ />
+ )}
- {configForm.errors}
-
+
+ setConfigValues((prev) => ({ ...prev, pullEnvVarsBeforeBuild: slugs }))
+ }
+ discoverEnvVars={configValues.discoverEnvVars}
+ onDiscoverEnvVarsChange={(slugs) =>
+ setConfigValues((prev) => ({ ...prev, discoverEnvVars: slugs }))
+ }
+ atomicBuilds={configValues.atomicBuilds}
+ onAtomicBuildsChange={(slugs) =>
+ setConfigValues((prev) => ({ ...prev, atomicBuilds: slugs }))
+ }
+ envVarsConfigLink={`/orgs/${organizationSlug}/projects/${projectSlug}/env/${environmentSlug}/environment-variables`}
+ disabledEnvSlugs={disabledEnvSlugsForBuildSettings}
+ autoPromote={configValues.autoPromote}
+ onAutoPromoteChange={(value) =>
+ setConfigValues((prev) => ({ ...prev, autoPromote: value }))
+ }
+ currentTriggerVersion={currentTriggerVersion}
+ currentTriggerVersionFetchFailed={currentTriggerVersionFetchFailed}
+ hideSectionToggles
+ layout="settings"
+ />
- {
- if (shouldPromptClearOnSave) {
- event.preventDefault();
- setShowClearDialog(true);
- }
- }}
+ LeadingIcon={isDisablingAutoAssign ? SpinnerWhite : undefined}
+ onClick={() =>
+ disableAutoAssignFetcher.submit(
+ { action: "disable-auto-assign" },
+ { method: "post", action: actionUrl }
+ )
+ }
>
- Save
+ Disable auto-assign
}
/>
-
+ )}
+
+ {configForm.errors}
+
+
+ {
+ if (shouldPromptClearOnSave) {
+ event.preventDefault();
+ setShowClearDialog(true);
+ }
+ }}
+ >
+ Save
+
+
@@ -1162,6 +1181,7 @@ function VercelSettingsPanel({
/>
)}
{showGitHubWarning && }
+ {!showAuthInvalid && }
{!showAuthInvalid && (
+ );
+ }
+
return (
-
- {showAuthInvalid && (
-
- )}
- {!showAuthInvalid && (
- <>
-
-
- {data.hasOrgIntegration
- ? "Connect your Vercel project to pull environment variables and trigger builds automatically."
- : "Install the Vercel app to connect your projects and pull environment variables."}
-
- {!data.isGitHubConnected && (
-
- GitHub integration is not connected. Vercel integration cannot sync environment
- variables and link deployments without a properly installed GitHub integration.
-
- )}
- >
- )}
-
+
);
}
diff --git a/apps/webapp/app/routes/storybook.buttons/route.tsx b/apps/webapp/app/routes/storybook.buttons/route.tsx
index 29f4a4b078a..702bcbfa976 100644
--- a/apps/webapp/app/routes/storybook.buttons/route.tsx
+++ b/apps/webapp/app/routes/storybook.buttons/route.tsx
@@ -36,6 +36,7 @@ export default function Story() {
Tertiary button
Minimal button
Danger button
+ Warning button
Icon left
@@ -165,6 +166,7 @@ export default function Story() {
Secondary button
Tertiary button
Danger button
+ Warning button
Icon left
@@ -310,6 +312,10 @@ export default function Story() {
/>
This is a delete button
+
+
+ This is a warning button
+
@@ -335,6 +341,12 @@ export default function Story() {
/>
This is a delete button
+
+
+ This is a warning button
+
diff --git a/apps/webapp/app/routes/storybook.icons/route.tsx b/apps/webapp/app/routes/storybook.icons/route.tsx
index a465e05fb5a..36acd29b234 100644
--- a/apps/webapp/app/routes/storybook.icons/route.tsx
+++ b/apps/webapp/app/routes/storybook.icons/route.tsx
@@ -93,6 +93,7 @@ import { MoveUpIcon } from "~/assets/icons/MoveUpIcon";
import { NodejsLogoIcon } from "~/assets/icons/NodejsLogoIcon";
import { OneTreeIcon } from "~/assets/icons/OneTreeIcon";
import { PadlockIcon } from "~/assets/icons/PadlockIcon";
+import { PadlockRoundedIcon } from "~/assets/icons/PadlockRoundedIcon";
import { PauseIcon } from "~/assets/icons/PauseIcon";
import { PlaygroundIcon } from "~/assets/icons/PlaygroundIcon";
import { PlusIcon } from "~/assets/icons/PlusIcon";
@@ -233,6 +234,7 @@ const icons: IconEntry[] = [
{ name: "OneTreeIcon", render: simple(OneTreeIcon) },
{ name: "OpenAIIcon", render: simple(OpenAIIcon) },
{ name: "PadlockIcon", render: simple(PadlockIcon) },
+ { name: "PadlockRoundedIcon", render: simple(PadlockRoundedIcon) },
{ name: "PauseIcon", render: simple(PauseIcon) },
{ name: "PerplexityIcon", render: simple(PerplexityIcon) },
{ name: "PlaygroundIcon", render: simple(PlaygroundIcon) },
diff --git a/apps/webapp/app/tailwind.css b/apps/webapp/app/tailwind.css
index 2bc91d77323..ab845b63353 100644
--- a/apps/webapp/app/tailwind.css
+++ b/apps/webapp/app/tailwind.css
@@ -639,3 +639,13 @@
@apply leading-relaxed;
}
}
+
+form:has(.unlock-hint-staging-env:hover) [data-unlock-target="staging-env"],
+form:has(.unlock-hint-pull-prod:hover) [data-unlock-target="pull-prod"],
+form:has(.unlock-hint-pull-stg:hover) [data-unlock-target="pull-stg"],
+form:has(.unlock-hint-pull-preview:hover) [data-unlock-target="pull-preview"],
+form:has(.unlock-hint-pull-dev:hover) [data-unlock-target="pull-dev"] {
+ border-radius: 0.25rem;
+ outline: 2px dashed var(--color-warning);
+ outline-offset: 2px;
+}