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; 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 = {}; 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({ 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>(new Set()); const [selectedBranchId, setSelectedBranchId] = useState(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>({ 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 ( { if (!o) { navigate(v3EnvironmentVariablesPath(organization, project, environment)); } }} > New environment variables
{selectedBranchId ? ( ) : ( Array.from(selectedEnvironmentIds).map((id) => ( )) )}
{nonBranchEnvironments.map((environment) => writableEnvironmentIdSet.has(environment.id) ? ( handleEnvironmentChange(environment.id, isChecked, environment.type) } label={} variant="button" /> ) : (
} variant="button" />
With your current role, you can't manage{" "} {environmentFullTitle(environment)} environment variables.
) )} {!hasStaging && ( <> Upgrade your plan to add a Staging environment. Upgrade your plan to add Preview branches. )}
{environmentIds.errors} Dev environment variables specified here will be overridden by ones in your .env file when running locally.
{previewIsSelected && (
{selectedBranchId !== "all" && selectedBranchId !== undefined && (
Select a branch to override variables in the Preview environment.
)} Secret value} /> If enabled, you and your team will not be able to view the values after creation.
setRevealAll(e.valueOf())} />
{variables.errors}
{form.errors}
} cancelButton={ Cancel } />
); } function FieldLayout({ children }: { children: React.ReactNode }) { return
{children}
; } function VariableFields({ revealValues, formId, variablesFields, form, }: { revealValues: boolean; formId?: string; variablesFields: FieldMetadata; form: FormMetadata; }) { const { items, append, update, delete: remove, insertAfter, } = useList([{ key: "", value: "" }]); const handlePaste = useCallback((index: number, e: React.ClipboardEvent) => { 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 ( 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} /> ); })}
Tip: Paste all your .env values at once into this form to populate it.
); } 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) => void; onDelete: () => void; showDeleteButton: boolean; showValue: boolean; config: FieldMetadata; }) { const fields = config.getFieldset(); const baseFieldName = `variables[${index}]`; return (
onChange({ ...value, key: e.currentTarget.value })} autoFocus={index === 0} onPaste={onPaste} /> {fields.key.errors}
onChange({ ...value, value: e.currentTarget.value })} /> {fields.value.errors}
{showDeleteButton && (
); }