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

679 lines
24 KiB
TypeScript

import { getFormProps, useForm, type FieldMetadata, type FormMetadata } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import {
LockClosedIcon,
LockOpenIcon,
NoSymbolIcon,
PlusIcon,
XMarkIcon,
} from "@heroicons/react/20/solid";
import { Form, useActionData, useNavigate, useNavigation } from "@remix-run/react";
import { json } from "@remix-run/server-runtime";
import dotenv from "dotenv";
import { useCallback, useState } from "react";
import { redirect } from "remix-typedjson";
import invariant from "tiny-invariant";
import { z } from "zod";
import { EnvironmentLabel, environmentFullTitle } from "~/components/environments/EnvironmentLabel";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
import { Dialog, DialogContent, DialogHeader } 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 { Input } from "~/components/primitives/Input";
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";
import { Switch } from "~/components/primitives/Switch";
import { TextLink } from "~/components/primitives/TextLink";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/primitives/Tooltip";
import { prisma } from "~/db.server";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useList } from "~/hooks/useList";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useTypedMatchesData } from "~/hooks/useTypedMatchData";
import { resolveOrgIdFromSlug } from "~/models/organization.server";
import {
environmentVariablesRouteId,
type loader as environmentVariablesLoader,
} from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route";
import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder";
import { cn } from "~/utils/cn";
import {
EnvironmentParamSchema,
v3BillingPath,
v3EnvironmentVariablesPath,
} from "~/utils/pathBuilder";
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
import { EnvironmentVariableKey } from "~/v3/environmentVariables/repository";
const Variable = z.object({
key: EnvironmentVariableKey,
value: z.string().nonempty("Value is required"),
});
type Variable = z.infer<typeof Variable>;
const schema = z.object({
override: z.preprocess((i) => {
if (i === "true") return true;
if (i === "false") return false;
return;
}, z.boolean()),
isSecret: z.preprocess((i) => {
if (i === "true") return true;
return false;
}, z.boolean()),
environmentIds: z.preprocess(
(i) => {
if (typeof i === "string") return [i];
if (Array.isArray(i)) {
const ids = i.filter((v) => typeof v === "string" && v !== "");
if (ids.length === 0) {
return;
}
return ids;
}
return;
},
z.array(z.string(), { required_error: "At least one environment is required" })
),
variables: z.preprocess((i) => {
if (!Array.isArray(i)) {
return [];
}
return i;
}, Variable.array().nonempty("At least one variable is required")),
});
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
// environments come 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 for every targeted environment, so a role
// that can't write a deployed tier can't create vars there via a direct
// POST (the disabled checkboxes are not the boundary).
const targetEnvironments = await prisma.runtimeEnvironment.findMany({
where: { id: { in: submission.value.environmentIds } },
select: { type: true },
});
const hasDeniedEnvironment = targetEnvironments.some(
(env) => !ability.can("write", { type: "envvars", envType: env.type })
);
if (hasDeniedEnvironment) {
return json(
submission.reply({
fieldErrors: {
environmentIds: [
"You don't have permission to manage environment variables in one of the selected environments.",
],
},
})
);
}
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"] }));
}
const repository = new EnvironmentVariablesRepository(prisma);
const result = await repository.create(project.id, {
...submission.value,
lastUpdatedBy: {
type: "user",
userId,
},
});
if (!result.success) {
const fieldErrors: Record<string, string[]> = {};
if (result.variableErrors) {
for (const { key, error } of result.variableErrors) {
const index = submission.value.variables.findIndex((v) => v.key === key);
if (index !== -1) {
fieldErrors[`variables[${index}].key`] = [error];
}
}
} else {
fieldErrors.variables = [result.error];
}
return json(submission.reply({ fieldErrors }));
}
return redirect(
v3EnvironmentVariablesPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam }
)
);
}
);
export default function Page() {
const [isOpen, _setIsOpen] = useState(true);
const parentData = useTypedMatchesData<typeof environmentVariablesLoader>({
id: environmentVariablesRouteId,
});
invariant(
parentData,
"Environment variables page loader data must be defined when rendering the create dialog"
);
const { environments, hasStaging, writableEnvironmentIds } = parentData;
// Creating a variable is a write, so gate the targets on write access.
const writableEnvironmentIdSet = new Set(writableEnvironmentIds);
const lastSubmission = useActionData();
const navigation = useNavigation();
const navigate = useNavigate();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const [selectedEnvironmentIds, setSelectedEnvironmentIds] = useState<Set<string>>(new Set());
const [selectedBranchId, setSelectedBranchId] = useState<string | undefined>(undefined);
// TODO for no we only support branch-specific env vars for Preview environments
// Mostly to keep the UX for setting consistent env-vars across Dev/Staging/Prod easier
const previewBranches = environments.filter(
(env) => env.type === "PREVIEW" && env.parentEnvironmentId !== null
);
const nonBranchEnvironments = environments.filter((env) => env.parentEnvironmentId === null);
const selectedEnvironments = environments.filter((env) => selectedEnvironmentIds.has(env.id));
const previewIsSelected = selectedEnvironments.some((env) => env.type === "PREVIEW");
const isLoading = navigation.state !== "idle" && navigation.formMethod === "post";
const [form, fields] = useForm<z.infer<typeof schema>>({
id: "create-environment-variables",
// TODO: type this
lastResult: lastSubmission as any,
onValidate({ formData }) {
return parseWithZod(formData, { schema });
},
shouldRevalidate: "onSubmit",
defaultValue: {
variables: [{ key: "", value: "" }],
},
});
const { environmentIds, variables } = fields;
const handleEnvironmentChange = (
environmentId: string,
isChecked: boolean,
environmentType?: string
) => {
setSelectedEnvironmentIds((prev) => {
const newSet = new Set(prev);
if (isChecked) {
if (environmentType === "PREVIEW") {
// If PREVIEW is checked, clear all other selections including branches
newSet.clear();
newSet.add(environmentId);
} else {
// If a non-PREVIEW environment is checked, remove PREVIEW if it's selected
const previewEnv = environments.find((env) => env.type === "PREVIEW");
if (previewEnv) {
newSet.delete(previewEnv.id);
}
newSet.add(environmentId);
setSelectedBranchId(undefined);
}
} else {
newSet.delete(environmentId);
}
return newSet;
});
};
const handleBranchChange = (branchId: string) => {
if (branchId === "all") {
setSelectedBranchId(undefined);
} else {
setSelectedBranchId(branchId);
}
};
const [revealAll, setRevealAll] = useState(true);
return (
<Dialog
open={isOpen}
onOpenChange={(o) => {
if (!o) {
navigate(v3EnvironmentVariablesPath(organization, project, environment));
}
}}
>
<DialogContent className="p-0 pt-2.5 md:max-w-2xl lg:max-w-3xl">
<DialogHeader className="px-4">New environment variables</DialogHeader>
<Form method="post" {...getFormProps(form)}>
<Fieldset className="max-h-[70vh] overflow-y-auto p-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<InputGroup fullWidth>
<Label>Environments</Label>
{selectedBranchId ? (
<input type="hidden" name="environmentIds" value={selectedBranchId} />
) : (
Array.from(selectedEnvironmentIds).map((id) => (
<input key={id} type="hidden" name="environmentIds" value={id} />
))
)}
<div className="flex items-center gap-2">
{nonBranchEnvironments.map((environment) =>
writableEnvironmentIdSet.has(environment.id) ? (
<CheckboxWithLabel
key={environment.id}
id={environment.id}
value={environment.id}
defaultChecked={selectedEnvironmentIds.has(environment.id)}
onChange={(isChecked) =>
handleEnvironmentChange(environment.id, isChecked, environment.type)
}
label={<EnvironmentLabel environment={environment} className="text-sm" />}
variant="button"
/>
) : (
<TooltipProvider key={environment.id}>
<Tooltip>
<TooltipTrigger asChild>
<div>
<CheckboxWithLabel
id={environment.id}
value={environment.id}
disabled
defaultChecked={false}
label={
<EnvironmentLabel environment={environment} className="text-sm" />
}
variant="button"
/>
</div>
</TooltipTrigger>
<TooltipContent className="flex items-center gap-2">
<NoSymbolIcon className="size-4 text-text-dimmed" />
With your current role, you can't manage{" "}
{environmentFullTitle(environment)} environment variables.
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
)}
{!hasStaging && (
<>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<TextLink
to={v3BillingPath(organization)}
className="flex w-fit cursor-pointer items-center gap-2 rounded border border-dashed border-border-bright py-2.5 pl-3 pr-4 transition hover:border-border-brightest hover:bg-background-dimmed"
>
<LockClosedIcon className="size-4 text-text-faint" />
<EnvironmentLabel
environment={{ type: "STAGING" }}
className="text-sm"
/>
</TextLink>
</TooltipTrigger>
<TooltipContent className="flex items-center gap-2">
<LockOpenIcon className="size-4 text-indigo-500" />
Upgrade your plan to add a Staging environment.
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<TextLink
to={v3BillingPath(organization)}
className="flex w-fit cursor-pointer items-center gap-2 rounded border border-dashed border-border-bright py-2.5 pl-3 pr-4 transition hover:border-border-brightest hover:bg-background-dimmed"
>
<LockClosedIcon className="size-4 text-text-faint" />
<EnvironmentLabel
environment={{ type: "PREVIEW" }}
className="text-sm"
/>
</TextLink>
</TooltipTrigger>
<TooltipContent className="flex items-center gap-2">
<LockOpenIcon className="size-4 text-indigo-500" />
Upgrade your plan to add Preview branches.
</TooltipContent>
</Tooltip>
</TooltipProvider>
</>
)}
</div>
<FormError id={environmentIds.errorId}>{environmentIds.errors}</FormError>
<Hint>
Dev environment variables specified here will be overridden by ones in your .env
file when running locally.
</Hint>
</InputGroup>
{previewIsSelected && (
<InputGroup fullWidth>
<Label>Select branch</Label>
<div className="flex items-center gap-1">
<Select
variant="tertiary/medium"
value={selectedBranchId ?? "all"}
setValue={handleBranchChange}
placeholder="All branches"
items={[{ id: "all", branchName: "All branches" }, ...previewBranches]}
className="w-fit min-w-52"
filter={{
keys: [
(item) => item.branchName?.replace(/\//g, " ").replace(/_/g, " ") ?? "",
],
}}
text={(val) =>
val ? previewBranches.find((b) => b.id === val)?.branchName : null
}
dropdownIcon
>
{(matches) =>
matches?.map((env) => (
<SelectItem key={env.id} value={env.id}>
{env.branchName}
</SelectItem>
))
}
</Select>
{selectedBranchId !== "all" && selectedBranchId !== undefined && (
<Button
variant="minimal/medium"
type="button"
onClick={() => setSelectedBranchId(undefined)}
LeadingIcon={XMarkIcon}
/>
)}
</div>
<Hint>Select a branch to override variables in the Preview environment.</Hint>
</InputGroup>
)}
<InputGroup className="w-auto">
<Switch
name="isSecret"
variant="medium"
defaultChecked={false}
value="true"
className="-ml-2 inline-flex w-fit"
label={<span className="text-text-bright">Secret value</span>}
/>
<Hint className="-mt-1">
If enabled, you and your team will not be able to view the values after creation.
</Hint>
</InputGroup>
<InputGroup fullWidth>
<FieldLayout>
<Label>Keys</Label>
<div className="flex justify-between gap-1">
<Label>Values</Label>
<Switch
variant="small"
label="Reveal"
checked={revealAll}
onCheckedChange={(e) => setRevealAll(e.valueOf())}
/>
</div>
</FieldLayout>
<VariableFields
revealValues={revealAll}
formId={form.id}
form={form}
variablesFields={variables}
/>
<FormError id={variables.errorId}>{variables.errors}</FormError>
</InputGroup>
<FormError>{form.errors}</FormError>
</Fieldset>
<FormButtons
className="px-4 pb-4"
confirmButton={
<div className="flex flex-row-reverse items-center gap-2">
<Button
type="submit"
variant="primary/medium"
disabled={isLoading}
name="override"
value="false"
>
{isLoading ? "Saving" : "Save"}
</Button>
<Button
variant="secondary/medium"
disabled={isLoading}
name="override"
value="true"
>
{isLoading ? "Overriding" : "Override"}
</Button>
</div>
}
cancelButton={
<LinkButton
to={v3EnvironmentVariablesPath(organization, project, environment)}
variant="tertiary/medium"
>
Cancel
</LinkButton>
}
/>
</Form>
</DialogContent>
</Dialog>
);
}
function FieldLayout({ children }: { children: React.ReactNode }) {
return <div className="grid w-full grid-cols-[1fr_1fr] gap-1.5">{children}</div>;
}
function VariableFields({
revealValues,
formId,
variablesFields,
form,
}: {
revealValues: boolean;
formId?: string;
variablesFields: FieldMetadata<Variable[]>;
form: FormMetadata<any>;
}) {
const {
items,
append,
update,
delete: remove,
insertAfter,
} = useList<Variable>([{ key: "", value: "" }]);
const handlePaste = useCallback((index: number, e: React.ClipboardEvent<HTMLInputElement>) => {
const clipboardData = e.clipboardData;
if (!clipboardData) return;
let text = clipboardData.getData("text");
if (!text) return;
const variables = dotenv.parse(text);
const keyValuePairs = Object.entries(variables).map(([key, value]) => ({ key, value }));
//do the default paste
if (keyValuePairs.length === 0) return;
//prevent default pasting
e.preventDefault();
const [firstPair, ...rest] = keyValuePairs;
update(index, firstPair);
for (const _pair of rest) {
form.insert({ name: variablesFields.name });
}
insertAfter(index, rest);
}, []);
const fields = variablesFields.getFieldList();
return (
<>
{fields.map((field, index) => {
const item = items[index];
return (
<VariableField
formId={formId}
key={index}
index={index}
value={item}
onChange={(value) => update(index, value)}
onPaste={(e) => handlePaste(index, e)}
onDelete={() => {
form.remove({ name: variablesFields.name, index });
remove(index);
}}
showDeleteButton={items.length > 1}
showValue={revealValues}
config={field}
/>
);
})}
<div className="flex items-center justify-between gap-4">
<Paragraph variant="extra-small">
Tip: Paste all your .env values at once into this form to populate it.
</Paragraph>
<Button
variant="tertiary/medium"
className="w-fit"
type="button"
onClick={() => {
form.insert({ name: variablesFields.name });
append([{ key: "", value: "" }]);
}}
LeadingIcon={PlusIcon}
>
Add another
</Button>
</div>
</>
);
}
function VariableField({
formId,
index,
value,
onChange,
onPaste,
onDelete,
showDeleteButton,
showValue,
config,
}: {
formId?: string;
index: number;
value: Variable;
onChange: (value: Variable) => void;
onPaste: (e: React.ClipboardEvent<HTMLInputElement>) => void;
onDelete: () => void;
showDeleteButton: boolean;
showValue: boolean;
config: FieldMetadata<Variable>;
}) {
const fields = config.getFieldset();
const baseFieldName = `variables[${index}]`;
return (
<fieldset>
<FieldLayout>
<div className="space-y-2">
<Input
id={`${formId}-${baseFieldName}.key`}
name={`${baseFieldName}.key`}
placeholder="e.g. CLIENT_KEY"
value={value.key}
onChange={(e) => onChange({ ...value, key: e.currentTarget.value })}
autoFocus={index === 0}
onPaste={onPaste}
/>
<FormError id={fields.key.errorId}>{fields.key.errors}</FormError>
</div>
<div className={cn("flex items-start gap-1")}>
<div className="grow space-y-2">
<Input
id={`${formId}-${baseFieldName}.value`}
name={`${baseFieldName}.value`}
type={showValue ? "text" : "password"}
placeholder="Not set"
value={value.value}
onChange={(e) => onChange({ ...value, value: e.currentTarget.value })}
/>
<FormError id={fields.value.errorId}>{fields.value.errors}</FormError>
</div>
{showDeleteButton && (
<Button
variant="minimal/medium"
type="button"
onClick={() => onDelete()}
LeadingIcon={XMarkIcon}
/>
)}
</div>
</FieldLayout>
</fieldset>
);
}