Files
2026-07-13 13:32:57 +08:00

972 lines
34 KiB
TypeScript

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<ReturnType<EnvironmentVariablesPresenter["call"]>>["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<EnvironmentVariablesPageLoaderData>();
return <EnvironmentVariablesListPage loaderData={loaderData} />;
}
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<EnvironmentVariableWithSetValues>({
items: environmentVariables,
keys: ["key", "value", "environment.type", "environment.branchName"],
filterText: urlSearch,
});
const tableScrollRef = useRef<HTMLDivElement>(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<string, number>();
// 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<string>();
const currentOccurrences = new Map<string, number>();
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 (
<PageContainer>
<NavBar>
<PageTitle title="Environment variables" />
<PageAccessories>
<LinkButton
LeadingIcon={BookOpenIcon}
to={docsPath("v3/deploy-environment-variables")}
variant="docs/small"
>
Environment variables docs
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
<div className={cn("flex h-full min-h-0 flex-col")}>
{environmentVariables.length > 0 && (
<div className="flex items-center justify-between gap-2 px-2 py-2">
<SearchInput placeholder="Search variables…" autoFocus />
<div className="flex items-center justify-end gap-1.5">
<Switch
variant="secondary/small"
label="Reveal values"
checked={revealAll}
onCheckedChange={(e) => setRevealAll(e.valueOf())}
/>
<LinkButton
to={v3NewEnvironmentVariablesPath(organization, project, environment)}
variant="primary/small"
LeadingIcon={PlusIcon}
shortcut={{ key: "n" }}
>
Add new
</LinkButton>
</div>
</div>
)}
<div
ref={tableScrollRef}
className="min-h-0 flex-1 overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
>
<Table
containerClassName={cn(
filteredItems.length === 0 && "border-t-0",
"overflow-visible"
)}
>
<TableHeader>
<TableRow>
<TableHeaderCell className={vercelIntegration?.enabled ? "w-[22%]" : "w-[25%]"}>
Key
</TableHeaderCell>
<TableHeaderCell className={vercelIntegration?.enabled ? "w-[32%]" : "w-[37%]"}>
Value
</TableHeaderCell>
<TableHeaderCell className={vercelIntegration?.enabled ? "w-[13%]" : "w-[15%]"}>
<SimpleTooltip
button={
<span className="flex items-center gap-1">
Environment
<InformationCircleIcon className="size-4 text-text-dimmed" />
</span>
}
content="Dev environment variables specified here will be overridden by ones in your .env file when running locally."
className="max-w-60"
/>
</TableHeaderCell>
{vercelIntegration?.enabled && (
<TableHeaderCell className="w-[8%]">
<SimpleTooltip
button={
<span className="flex items-center gap-1">
Sync
<InformationCircleIcon className="size-4 text-text-dimmed" />
</span>
}
content="When enabled, this variable will be pulled from Vercel during builds. Requires 'Pull env vars before build' to be enabled in settings."
/>
</TableHeaderCell>
)}
<TableHeaderCell className={vercelIntegration?.enabled ? "w-[24%]" : "w-[22%]"}>
Updated
</TableHeaderCell>
<TableHeaderCell hiddenLabel className="w-0">
Actions
</TableHeaderCell>
</TableRow>
</TableHeader>
{groupedEnvironmentVariables.length > 0 ? (
isVirtualized && shouldVirtualize ? (
<EnvironmentVariablesVirtualTableBody
groupedEnvironmentVariables={groupedEnvironmentVariables}
scrollRef={tableScrollRef}
revealAll={revealAll}
vercelIntegration={vercelIntegration}
columnCount={vercelColumnCount}
/>
) : (
<TableBody>
{staticRows.map((variable) => (
<EnvironmentVariableTableRow
key={`${variable.id}-${variable.environment.id}`}
variable={variable}
revealAll={revealAll}
vercelIntegration={vercelIntegration}
/>
))}
</TableBody>
)
) : (
<TableBody>
<TableRow>
<TableCell colSpan={vercelColumnCount}>
{environmentVariables.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-y-4 py-8">
<Header2>You haven't set any environment variables yet.</Header2>
<LinkButton
to={v3NewEnvironmentVariablesPath(organization, project, environment)}
variant="primary/medium"
LeadingIcon={PlusIcon}
shortcut={{ key: "n" }}
>
Add new
</LinkButton>
</div>
) : (
<div className="flex flex-col items-center justify-center gap-y-4 py-8">
<Paragraph>No variables match your search.</Paragraph>
</div>
)}
</TableCell>
</TableRow>
</TableBody>
)}
</Table>
</div>
</div>
</PageBody>
<Outlet />
</PageContainer>
);
}
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 (
<TableRow className={variable.isLastTime ? "after:bg-surface-control" : "after:bg-transparent"}>
<TableCell className={cellClassName}>
{variable.isFirstTime ? <CopyableText value={variable.key} className="font-mono" /> : null}
</TableCell>
<TableCell className={cn(cellClassName, borderedCellClassName, "after:left-3")}>
{variable.permissionDenied ? (
<SimpleTooltip
button={
<div className="flex items-center gap-x-1">
<NoSymbolIcon className="size-3 text-text-dimmed" />
<span className="text-xs text-text-dimmed">Permission denied</span>
</div>
}
content="With your current role, you can't view this environment's variables."
/>
) : variable.isSecret ? (
<SimpleTooltip
button={
<div className="flex items-center gap-x-1">
<LockClosedIcon className="size-3 text-text-dimmed" />
<span className="text-xs text-text-dimmed">Secret</span>
</div>
}
content="This variable is secret and cannot be revealed."
/>
) : (
<ClipboardField
secure={!revealAll}
value={variable.value}
variant={"secondary/small"}
fullWidth={true}
/>
)}
</TableCell>
<TableCell className={cn(cellClassName, borderedCellClassName)}>
<EnvironmentCombo environment={variable.environment} className="text-sm" />
</TableCell>
{vercelIntegration?.enabled && (
<TableCell className={cn(cellClassName, borderedCellClassName)}>
{variable.environment.type !== "DEVELOPMENT" && (
<VercelSyncCheckbox
envVarKey={variable.key}
environmentType={variable.environment.type as TriggerEnvironmentType}
syncEnabled={shouldSyncEnvVar(
vercelIntegration.syncEnvVarsMapping,
variable.key,
variable.environment.type as TriggerEnvironmentType
)}
pullEnvVarsEnabledForEnv={isPullEnvVarsEnabledForEnvironment(
vercelIntegration.pullEnvVarsBeforeBuild,
variable.environment.type as TriggerEnvironmentType
)}
/>
)}
</TableCell>
)}
<TableCell className={cn(cellClassName, borderedCellClassName)}>
<div className="flex items-center gap-3">
{variable.updatedAt ? (
<span className="shrink-0 text-sm tabular-nums text-text-dimmed">
<DateTime date={variable.updatedAt} includeSeconds={false} />
</span>
) : null}
{variable.updatedByUser ? (
<div className="flex min-w-0 items-center gap-2">
<UserAvatar
avatarUrl={variable.updatedByUser.avatarUrl}
name={variable.updatedByUser.name}
className="size-5 shrink-0"
/>
<span className="truncate text-sm">{variable.updatedByUser.name}</span>
</div>
) : variable.lastUpdatedBy?.type === "integration" &&
variable.lastUpdatedBy?.integration === "vercel" ? (
<div className="flex min-w-0 items-center gap-2">
<VercelLogo className="size-4 shrink-0 text-text-dimmed transition-colors group-hover/table-row:text-text-bright" />
<span className="truncate text-sm capitalize text-text-dimmed transition-colors group-hover/table-row:text-text-bright">
{variable.lastUpdatedBy.integration}
</span>
</div>
) : null}
</div>
</TableCell>
<TableCellMenu
isSticky
className="[&:has([data-hidden-buttons])]:w-auto w-0"
hiddenButtons={
// No edit/delete for environments the role can't manage the value
// is withheld, and the action enforces write:envvars independently.
variable.permissionDenied ? undefined : (
<>
<EditEnvironmentVariablePanel variable={variable} revealAll={revealAll} />
<DeleteEnvironmentVariableButton variable={variable} />
</>
)
}
/>
</TableRow>
);
}
function EnvironmentVariablesVirtualTableBody({
groupedEnvironmentVariables,
scrollRef,
revealAll,
vercelIntegration,
columnCount,
}: {
groupedEnvironmentVariables: GroupedEnvironmentVariable[];
scrollRef: RefObject<HTMLDivElement | null>;
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 (
<TableBody>
{topSpacerHeight > 0 && (
<tr aria-hidden style={{ height: topSpacerHeight }}>
<td colSpan={columnCount} />
</tr>
)}
{virtualItems.map((virtualRow) => {
const variable = groupedEnvironmentVariables[virtualRow.index];
if (!variable) {
return null;
}
return (
<EnvironmentVariableTableRow
key={`${variable.id}-${variable.environment.id}`}
variable={variable}
revealAll={revealAll}
vercelIntegration={vercelIntegration}
/>
);
})}
{bottomSpacerHeight > 0 && (
<tr aria-hidden style={{ height: bottomSpacerHeight }}>
<td colSpan={columnCount} />
</tr>
)}
</TableBody>
);
}
function EditEnvironmentVariablePanel({
variable,
revealAll,
}: {
variable: EnvironmentVariableWithSetValues;
revealAll: boolean;
}) {
const [isOpen, setIsOpen] = useState(false);
const fetcher = useFetcher<typeof action>();
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 (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
variant="small-menu-item"
LeadingIcon={PencilSquareIcon}
fullWidth
textAlignLeft
></Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>Edit environment variable</DialogHeader>
<fetcher.Form method="post" {...getFormProps(form)}>
<input type="hidden" name="action" value="edit" />
<input {...getInputProps(id, { type: "hidden" })} value={variable.id} />
<input
{...getInputProps(environmentId, { type: "hidden" })}
value={variable.environment.id}
/>
<FormError id={id.errorId}>{id.errors}</FormError>
<FormError id={environmentId.errorId}>{environmentId.errors}</FormError>
<Fieldset>
<InputGroup fullWidth className="mt-2 gap-0">
<Label>Key</Label>
<CopyableText value={variable.key} className="w-fit font-mono text-sm" />
</InputGroup>
<InputGroup fullWidth>
<Label>Environment</Label>
<EnvironmentCombo environment={variable.environment} className="text-sm" />
</InputGroup>
<InputGroup fullWidth>
<Label>Value</Label>
<Input
{...getInputProps(value, { type: "text" })}
placeholder={variable.isSecret ? "Set new secret value" : "Not set"}
defaultValue={variable.value}
type={"text"}
/>
<FormError id={value.errorId}>{value.errors}</FormError>
</InputGroup>
<FormError>{form.errors}</FormError>
<FormButtons
confirmButton={
<Button type="submit" variant="primary/medium" disabled={isLoading}>
{isLoading ? "Saving…" : "Save"}
</Button>
}
cancelButton={
<Button onClick={() => setIsOpen(false)} variant="tertiary/medium" type="button">
Cancel
</Button>
}
/>
</Fieldset>
</fetcher.Form>
</DialogContent>
</Dialog>
);
}
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 (
<Form method="post" {...getFormProps(form)}>
<input type="hidden" name="id" value={variable.id} />
<input type="hidden" name="key" value={variable.key} />
<input type="hidden" name="environmentId" value={variable.environment.id} />
<Button
name="action"
value="delete"
type="submit"
variant="small-menu-item"
fullWidth
textAlignLeft
LeadingIcon={TrashIcon}
leadingIconClassName="text-rose-500 group-hover/button:text-text-bright transition-colors"
className="ml-0.5 transition-colors group-hover/button:bg-error"
>
{isLoading ? "Deleting" : ""}
</Button>
</Form>
);
}
/**
* 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 (
<SimpleTooltip
button={<Switch variant="small" checked={false} disabled onCheckedChange={() => {}} />}
content="Enable 'Pull env vars before build' for this environment in Vercel settings."
/>
);
}
return (
<Switch
variant="small"
checked={syncEnabled}
disabled={isLoading}
onCheckedChange={handleChange}
/>
);
}