import { getFormProps, getInputProps, useForm } from "@conform-to/react"; import { parseWithZod } from "@conform-to/zod"; import { BookOpenIcon, InformationCircleIcon, LockClosedIcon, NoSymbolIcon, PencilSquareIcon, PlusIcon, TrashIcon, } from "@heroicons/react/20/solid"; import { Form, Outlet, useActionData, useFetcher, useNavigation, useRevalidator, type MetaFunction, } from "@remix-run/react"; import { json } from "@remix-run/server-runtime"; import { useVirtualizer } from "@tanstack/react-virtual"; import { fromPromise } from "neverthrow"; import { useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { UserAvatar } from "~/components/UserProfilePhoto"; import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; import { VercelLogo } from "~/components/integrations/VercelLogo"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { ClipboardField } from "~/components/primitives/ClipboardField"; import { CopyableText } from "~/components/primitives/CopyableText"; import { DateTime } from "~/components/primitives/DateTime"; import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; import { Fieldset } from "~/components/primitives/Fieldset"; import { FormButtons } from "~/components/primitives/FormButtons"; import { FormError } from "~/components/primitives/FormError"; import { Header2 } from "~/components/primitives/Headers"; import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; import { SearchInput } from "~/components/primitives/SearchInput"; import { Switch } from "~/components/primitives/Switch"; import { Table, TableBody, TableCell, TableCellMenu, TableHeader, TableHeaderCell, TableRow, } from "~/components/primitives/Table"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { prisma } from "~/db.server"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useFuzzyFilter } from "~/hooks/useFuzzyFilter"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { useSearchParams } from "~/hooks/useSearchParam"; import { redirectWithSuccessMessage } from "~/models/message.server"; import { resolveOrgIdFromSlug } from "~/models/organization.server"; import { EnvironmentVariablesPresenter, type EnvironmentVariableWithSetValues, } from "~/presenters/v3/EnvironmentVariablesPresenter.server"; import { type EnvironmentVariablesEnvironment } from "~/presenters/v3/environmentVariablesEnvironments.server"; import { logger } from "~/services/logger.server"; import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { VercelIntegrationService } from "~/services/vercelIntegration.server"; import { cn } from "~/utils/cn"; import { EnvironmentParamSchema, docsPath, v3EnvironmentVariablesPath, v3NewEnvironmentVariablesPath, } from "~/utils/pathBuilder"; import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; import { DeleteEnvironmentVariableValue, EditEnvironmentVariableValue, } from "~/v3/environmentVariables/repository"; import { isPullEnvVarsEnabledForEnvironment, shouldSyncEnvVar, type TriggerEnvironmentType, } from "~/v3/vercel/vercelProjectIntegrationSchema"; export const meta: MetaFunction = () => { return [ { title: `Environment variables | Trigger.dev`, }, ]; }; type PageVercelIntegration = NonNullable< Awaited>["vercelIntegration"] >; // A value the current role can't read for its environment tier is masked // server-side: the value is withheld and the cell renders "Permission denied". export type MaskedEnvironmentVariable = EnvironmentVariableWithSetValues & { permissionDenied?: boolean; }; export type EnvironmentVariablesPageLoaderData = { environmentVariables: MaskedEnvironmentVariable[]; environments: EnvironmentVariablesEnvironment[]; hasStaging: boolean; vercelIntegration: PageVercelIntegration | null; // Environment ids whose env vars the current role can read. accessibleEnvironmentIds: string[]; // Environment ids whose env vars the current role can write (create/edit/delete). writableEnvironmentIds: string[]; }; export const environmentVariablesRouteId = "routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables"; export const loader = dashboardLoader( { params: EnvironmentParamSchema, context: async (params) => { const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); return organizationId ? { organizationId } : {}; }, // No hard authorization: the page lists every environment. Values in // environments the role can't read are masked per-tier below. }, async ({ params, user, ability }) => { const { projectParam } = params; try { const presenter = new EnvironmentVariablesPresenter(); const { environmentVariables, environments, hasStaging, vercelIntegration } = await presenter.call({ userId: user.id, projectSlug: projectParam, }); const accessibleEnvironmentIds = environments .filter((env) => ability.can("read", { type: "envvars", envType: env.type })) .map((env) => env.id); const accessible = new Set(accessibleEnvironmentIds); // Write access is a separate grant from read: gate write controls (edit, // delete, create) on this set, not on read-accessibility. const writableEnvironmentIds = environments .filter((env) => ability.can("write", { type: "envvars", envType: env.type })) .map((env) => env.id); // Withhold values (and the "who/when" metadata) for environments the // role can't read — never serialize them to the client. const masked: MaskedEnvironmentVariable[] = environmentVariables.map((variable) => accessible.has(variable.environment.id) ? variable : { ...variable, value: "", isSecret: false, permissionDenied: true, lastUpdatedBy: null, updatedByUser: null, } ); return typedjson({ environmentVariables: masked, environments, hasStaging, vercelIntegration, accessibleEnvironmentIds, writableEnvironmentIds, }); } catch (error) { console.error(error); throw new Response(undefined, { status: 400, statusText: "Something went wrong, if this problem persists please contact support.", }); } } ); const schema = z.discriminatedUnion("action", [ z.object({ action: z.literal("edit"), ...EditEnvironmentVariableValue.shape }), z.object({ action: z.literal("delete"), key: z.string(), ...DeleteEnvironmentVariableValue.shape, }), z.object({ action: z.literal("update-vercel-sync"), key: z.string(), environmentType: z.enum(["PRODUCTION", "STAGING", "PREVIEW", "DEVELOPMENT"]), syncEnabled: z .union([z.literal("true"), z.literal("false")]) .transform((val) => val === "true"), }), ]); export const action = dashboardAction( { params: EnvironmentParamSchema, context: async (params) => { const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); return organizationId ? { organizationId } : {}; }, // Per-environment write:envvars is enforced in the handler — the target // environment tier comes from the submission, not the route params. }, async ({ request, params, user, ability }) => { const userId = user.id; const { organizationSlug, projectParam, envParam } = params; if (request.method.toUpperCase() !== "POST") { throw new Response("Method Not Allowed", { status: 405 }); } const formData = await request.formData(); const submission = parseWithZod(formData, { schema }); if (submission.status !== "success") { return json(submission.reply()); } // Enforce env-tier write:envvars on the targeted environment, so a role // that can't write a deployed tier can't mutate it via a direct POST. const targetEnvType = submission.value.action === "update-vercel-sync" ? submission.value.environmentType : ( await prisma.runtimeEnvironment.findFirst({ where: { id: submission.value.environmentId }, select: { type: true }, }) )?.type; if (targetEnvType && !ability.can("write", { type: "envvars", envType: targetEnvType })) { return json( submission.reply({ formErrors: [ "You don't have permission to manage environment variables in this environment.", ], }) ); } const project = await prisma.project.findUnique({ where: { slug: params.projectParam, organization: { members: { some: { userId, }, }, }, }, select: { id: true, }, }); if (!project) { return json(submission.reply({ formErrors: ["Project not found"] })); } switch (submission.value.action) { case "edit": { const repository = new EnvironmentVariablesRepository(prisma); const result = await repository.editValue(project.id, { ...submission.value, lastUpdatedBy: { type: "user", userId, }, }); if (!result.success) { return json(submission.reply({ formErrors: [result.error] })); } return json({ ...submission.reply(), success: true }); } case "delete": { const repository = new EnvironmentVariablesRepository(prisma); const result = await repository.deleteValue(project.id, submission.value); if (!result.success) { return json(submission.reply({ formErrors: [result.error] })); } // Clean up syncEnvVarsMapping if Vercel integration exists (best-effort) const { environmentId, key } = submission.value; const vercelService = new VercelIntegrationService(); await fromPromise( (async () => { const integration = await vercelService.getVercelProjectIntegration(project.id); if (integration) { const runtimeEnv = await prisma.runtimeEnvironment.findUnique({ where: { id: environmentId }, select: { type: true }, }); if (runtimeEnv) { await vercelService.removeSyncEnvVarForEnvironment( project.id, key, runtimeEnv.type as TriggerEnvironmentType ); } } })(), (error) => error ).mapErr((error) => { logger.error("Failed to remove Vercel sync mapping", { error }); return error; }); return redirectWithSuccessMessage( v3EnvironmentVariablesPath( { slug: organizationSlug }, { slug: projectParam }, { slug: envParam } ), request, `Deleted ${submission.value.key} environment variable` ); } case "update-vercel-sync": { const vercelService = new VercelIntegrationService(); const integration = await vercelService.getVercelProjectIntegration(project.id); if (!integration) { return json(submission.reply({ formErrors: ["Vercel integration not found"] })); } // Update the sync mapping for the specific env var and environment await vercelService.updateSyncEnvVarForEnvironment( project.id, submission.value.key, submission.value.environmentType, submission.value.syncEnabled ); return json({ success: true }); } } } ); const SSR_ROW_WINDOW = 50; const ROW_ESTIMATE_HEIGHT = 44; const VIRTUAL_OVERSCAN = 10; type GroupedEnvironmentVariable = MaskedEnvironmentVariable & { isFirstTime: boolean; isLastTime: boolean; occurences: number; }; export default function Page() { const loaderData = useTypedLoaderData(); return ; } function EnvironmentVariablesListPage({ loaderData, }: { loaderData: EnvironmentVariablesPageLoaderData; }) { const [revealAll, setRevealAll] = useState(false); const { environmentVariables, vercelIntegration } = loaderData; const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); const { value } = useSearchParams(); const urlSearch = value("search") ?? ""; const { filteredItems } = useFuzzyFilter({ items: environmentVariables, keys: ["key", "value", "environment.type", "environment.branchName"], filterText: urlSearch, }); const tableScrollRef = useRef(null); // Add isFirst and isLast to each environment variable // They're set based on if they're the first or last time that `key` has been seen in the list const groupedEnvironmentVariables = useMemo((): GroupedEnvironmentVariable[] => { // Create a map to track occurrences of each key const keyOccurrences = new Map(); // First pass: count total occurrences of each key filteredItems.forEach((variable) => { keyOccurrences.set(variable.key, (keyOccurrences.get(variable.key) || 0) + 1); }); // Second pass: add isFirstTime, isLastTime, and occurrences flags const seenKeys = new Set(); const currentOccurrences = new Map(); return filteredItems.map((variable) => { // Track current occurrence number for this key const currentCount = (currentOccurrences.get(variable.key) || 0) + 1; currentOccurrences.set(variable.key, currentCount); const totalOccurrences = keyOccurrences.get(variable.key) || 1; const isFirstTime = !seenKeys.has(variable.key); const isLastTime = currentCount === totalOccurrences; if (isFirstTime) { seenKeys.add(variable.key); } return { ...variable, isFirstTime, isLastTime, occurences: totalOccurrences, }; }); }, [filteredItems]); const shouldVirtualize = groupedEnvironmentVariables.length > SSR_ROW_WINDOW; const [isVirtualized, setIsVirtualized] = useState(false); useLayoutEffect(() => { setIsVirtualized(shouldVirtualize); }, [shouldVirtualize]); const staticRows = useMemo(() => { if (shouldVirtualize) { return groupedEnvironmentVariables.slice(0, SSR_ROW_WINDOW); } return groupedEnvironmentVariables; }, [groupedEnvironmentVariables, shouldVirtualize]); const vercelColumnCount = vercelIntegration?.enabled ? 6 : 5; return ( Environment variables docs
{environmentVariables.length > 0 && (
setRevealAll(e.valueOf())} /> Add new
)}
Key Value Environment } content="Dev environment variables specified here will be overridden by ones in your .env file when running locally." className="max-w-60" /> {vercelIntegration?.enabled && ( Sync } content="When enabled, this variable will be pulled from Vercel during builds. Requires 'Pull env vars before build' to be enabled in settings." /> )} Updated Actions {groupedEnvironmentVariables.length > 0 ? ( isVirtualized && shouldVirtualize ? ( ) : ( {staticRows.map((variable) => ( ))} ) ) : ( {environmentVariables.length === 0 ? (
You haven't set any environment variables yet. Add new
) : (
No variables match your search.
)}
)}
); } function getBorderedCellClassName(variable: GroupedEnvironmentVariable) { if (variable.occurences <= 1) { return ""; } if (variable.isLastTime) { return ""; } return "relative after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-bright group-hover/table-row:after:bg-grid-bright group-hover/table-row:before:bg-grid-bright"; } function EnvironmentVariableTableRow({ variable, revealAll, vercelIntegration, }: { variable: GroupedEnvironmentVariable; revealAll: boolean; vercelIntegration: PageVercelIntegration | null; }) { const cellClassName = "py-2"; const borderedCellClassName = getBorderedCellClassName(variable); return ( {variable.isFirstTime ? : null} {variable.permissionDenied ? ( Permission denied } content="With your current role, you can't view this environment's variables." /> ) : variable.isSecret ? ( Secret } content="This variable is secret and cannot be revealed." /> ) : ( )} {vercelIntegration?.enabled && ( {variable.environment.type !== "DEVELOPMENT" && ( )} )}
{variable.updatedAt ? ( ) : null} {variable.updatedByUser ? (
{variable.updatedByUser.name}
) : variable.lastUpdatedBy?.type === "integration" && variable.lastUpdatedBy?.integration === "vercel" ? (
{variable.lastUpdatedBy.integration}
) : null}
) } />
); } function EnvironmentVariablesVirtualTableBody({ groupedEnvironmentVariables, scrollRef, revealAll, vercelIntegration, columnCount, }: { groupedEnvironmentVariables: GroupedEnvironmentVariable[]; scrollRef: RefObject; revealAll: boolean; vercelIntegration: PageVercelIntegration | null; columnCount: number; }) { const rowVirtualizer = useVirtualizer({ count: groupedEnvironmentVariables.length, getScrollElement: () => scrollRef.current, estimateSize: () => ROW_ESTIMATE_HEIGHT, overscan: VIRTUAL_OVERSCAN, }); const virtualItems = rowVirtualizer.getVirtualItems(); const topSpacerHeight = virtualItems[0]?.start ?? 0; const bottomSpacerHeight = rowVirtualizer.getTotalSize() - (virtualItems.at(-1)?.end ?? 0); return ( {topSpacerHeight > 0 && ( )} {virtualItems.map((virtualRow) => { const variable = groupedEnvironmentVariables[virtualRow.index]; if (!variable) { return null; } return ( ); })} {bottomSpacerHeight > 0 && ( )} ); } function EditEnvironmentVariablePanel({ variable, revealAll, }: { variable: EnvironmentVariableWithSetValues; revealAll: boolean; }) { const [isOpen, setIsOpen] = useState(false); const fetcher = useFetcher(); const lastSubmission = fetcher.data as any; const isLoading = fetcher.state !== "idle"; // Close dialog on successful submission useEffect(() => { if (lastSubmission?.success && fetcher.state === "idle") { setIsOpen(false); } }, [lastSubmission?.success, fetcher.state]); const [form, { id, environmentId, value }] = useForm({ id: `edit-environment-variable-${variable.id}-${variable.environment.id}`, // TODO: type this lastResult: lastSubmission as any, onValidate({ formData }) { return parseWithZod(formData, { schema }); }, shouldRevalidate: "onSubmit", }); return ( Edit environment variable {id.errors} {environmentId.errors}
{value.errors} {form.errors} {isLoading ? "Saving…" : "Save"} } cancelButton={ } />
); } function DeleteEnvironmentVariableButton({ variable, }: { variable: EnvironmentVariableWithSetValues; }) { const lastSubmission = useActionData(); const navigation = useNavigation(); const isLoading = navigation.state !== "idle" && navigation.formMethod === "post" && navigation.formData?.get("action") === "delete"; const [form] = useForm({ id: "delete-environment-variable", // TODO: type this lastResult: lastSubmission as any, onValidate({ formData }) { return parseWithZod(formData, { schema }); }, shouldRevalidate: "onSubmit", }); return (
); } /** * Toggle component for controlling whether an environment variable is pulled from Vercel. * * When enabled, the variable will be pulled from Vercel during builds. * By default, all variables are pulled unless explicitly disabled. * * Note: If the env slug is missing from syncEnvVarsMapping, all vars are pulled by default. * Only when syncEnvVarsMapping[envSlug][envVarName] = false, the env var is skipped during builds. */ function VercelSyncCheckbox({ envVarKey, environmentType, syncEnabled, pullEnvVarsEnabledForEnv, }: { envVarKey: string; environmentType: "PRODUCTION" | "STAGING" | "PREVIEW" | "DEVELOPMENT"; syncEnabled: boolean; pullEnvVarsEnabledForEnv: boolean; }) { const fetcher = useFetcher(); const revalidator = useRevalidator(); const isLoading = fetcher.state !== "idle"; // Revalidate loader data after successful submission (without full page reload) useEffect(() => { if (fetcher.state === "idle" && fetcher.data) { const data = fetcher.data as { success?: boolean }; if (data.success) { revalidator.revalidate(); } } }, [fetcher.state, fetcher.data, revalidator]); const handleChange = (checked: boolean) => { fetcher.submit( { action: "update-vercel-sync", key: envVarKey, environmentType, syncEnabled: checked.toString(), }, { method: "post" } ); }; // If pull env vars is disabled for this environment, show disabled state if (!pullEnvVarsEnabledForEnv) { return ( {}} />} content="Enable 'Pull env vars before build' for this environment in Vercel settings." /> ); } return ( ); }