972 lines
34 KiB
TypeScript
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}
|
|
/>
|
|
);
|
|
}
|