From 8c97d18d12afb2bed5a015e9faa32b2e53131c60 Mon Sep 17 00:00:00 2001 From: Joan La Rosa <89815407+JoanLaRosa@users.noreply.github.com> Date: Thu, 29 Jan 2026 10:17:51 -0500 Subject: [PATCH 1/3] feat(webapp): allow marking environment variables as secret after creation Move the secret toggle into the edit form so it submits on Save instead of firing a separate request immediately. Remove the standalone makeSecret action/method and include isSecret as an optional field on the existing editValue flow. --- .../route.tsx | 19 +++++++++++++++++++ .../environmentVariablesRepository.server.ts | 1 + .../app/v3/environmentVariables/repository.ts | 1 + 3 files changed, 21 insertions(+) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx index 1974e2051fb..e83b882f47a 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx @@ -36,6 +36,7 @@ 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 { Hint } from "~/components/primitives/Hint"; import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; @@ -808,6 +809,7 @@ function EditEnvironmentVariablePanel({ revealAll: boolean; }) { const [isOpen, setIsOpen] = useState(false); + const [isSecret, setIsSecret] = useState(variable.isSecret); const fetcher = useFetcher(); const lastSubmission = fetcher.data as any; @@ -840,6 +842,7 @@ function EditEnvironmentVariablePanel({ Edit environment variable + + + Secret value} + checked={isSecret} + disabled={variable.isSecret} + className="-ml-2 inline-flex w-fit" + onCheckedChange={setIsSecret} + /> + + {variable.isSecret + ? "This variable is secret and cannot be changed back." + : "Once enabled, the value will be hidden and cannot be revealed again."} + + + val === "true" || val === true, z.boolean()).optional(), }); export type EditEnvironmentVariableValue = z.infer; From da75a16ac43507e1e7ef303d007f3bed624d5583 Mon Sep 17 00:00:00 2001 From: Joan La Rosa <89815407+JoanLaRosa@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:14:19 -0400 Subject: [PATCH 2/3] fix(webapp): address secret env var review feedback --- .../mark-environment-variables-secret.md | 6 +++ .../route.tsx | 40 +++++++++++++++++-- .../app/v3/environmentVariables/repository.ts | 5 ++- 3 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 .server-changes/mark-environment-variables-secret.md diff --git a/.server-changes/mark-environment-variables-secret.md b/.server-changes/mark-environment-variables-secret.md new file mode 100644 index 00000000000..7824628cb08 --- /dev/null +++ b/.server-changes/mark-environment-variables-secret.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Existing environment variables can now be permanently marked as secret from the dashboard. diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx index e83b882f47a..a4b61275aba 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx @@ -20,7 +20,15 @@ import { 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 { + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type FormEvent, + type RefObject, +} from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { UserAvatar } from "~/components/UserProfilePhoto"; @@ -815,6 +823,26 @@ function EditEnvironmentVariablePanel({ const isLoading = fetcher.state !== "idle"; + function handleOpenChange(open: boolean) { + if (open) { + setIsSecret(variable.isSecret); + } + + setIsOpen(open); + } + + function handleSubmit(event: FormEvent) { + if ( + isSecret && + !variable.isSecret && + !window.confirm( + "Making this variable secret is irreversible. The value will be hidden and cannot be revealed again. Continue?" + ) + ) { + event.preventDefault(); + } + } + // Close dialog on successful submission useEffect(() => { if (lastSubmission?.success && fetcher.state === "idle") { @@ -834,13 +862,13 @@ function EditEnvironmentVariablePanel({ }); return ( - + } cancelButton={ - } diff --git a/apps/webapp/app/v3/environmentVariables/repository.ts b/apps/webapp/app/v3/environmentVariables/repository.ts index 75dd28ae4b1..036fdd18bb8 100644 --- a/apps/webapp/app/v3/environmentVariables/repository.ts +++ b/apps/webapp/app/v3/environmentVariables/repository.ts @@ -69,7 +69,10 @@ export const EditEnvironmentVariableValue = z.object({ environmentId: z.string(), value: z.string(), lastUpdatedBy: EnvironmentVariableUpdaterSchema.optional(), - isSecret: z.preprocess((val) => val === "true" || val === true, z.boolean()).optional(), + isSecret: z.preprocess( + (val) => (val === undefined ? undefined : val === "true" || val === true), + z.boolean().optional() + ), }); export type EditEnvironmentVariableValue = z.infer; From d5c454a0d64c9275eefd40d046b903968fa83ec0 Mon Sep 17 00:00:00 2001 From: Joan La Rosa <89815407+JoanLaRosa@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:02:10 -0400 Subject: [PATCH 3/3] test(webapp): cover marking env vars as secret --- .../environmentVariablesRepository.test.ts | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/apps/webapp/test/environmentVariablesRepository.test.ts b/apps/webapp/test/environmentVariablesRepository.test.ts index b5661c89cbe..d0ca3c1a593 100644 --- a/apps/webapp/test/environmentVariablesRepository.test.ts +++ b/apps/webapp/test/environmentVariablesRepository.test.ts @@ -257,3 +257,112 @@ describe("EnvironmentVariablesRepository.getVariableValuesForKeys", () => { } ); }); + +describe("EnvironmentVariablesRepository.editValue", () => { + postgresTest( + "permanently marks an existing value as secret while updating its value", + async ({ prisma }) => { + const { user, organization, project } = await createTestOrgProjectWithMember(prisma); + const environment = await createRuntimeEnvironment(prisma, { + projectId: project.id, + organizationId: organization.id, + type: "PRODUCTION", + }); + const repository = new EnvironmentVariablesRepository(prisma, prisma); + + await createEnvironmentVariable(repository, project.id, { + environmentId: environment.id, + key: "BECOMES_SECRET", + value: "plain-value", + userId: user.id, + }); + + const variable = await prisma.environmentVariable.findFirstOrThrow({ + where: { projectId: project.id, key: "BECOMES_SECRET" }, + include: { values: { where: { environmentId: environment.id } } }, + }); + const originalVersion = variable.values[0]!.version; + + const result = await repository.editValue(project.id, { + id: variable.id, + environmentId: environment.id, + value: "new-secret-value", + isSecret: true, + lastUpdatedBy: { type: "user", userId: user.id }, + }); + + expect(result).toEqual({ success: true }); + + const updatedValue = await prisma.environmentVariableValue.findUniqueOrThrow({ + where: { + variableId_environmentId: { + variableId: variable.id, + environmentId: environment.id, + }, + }, + }); + expect(updatedValue.isSecret).toBe(true); + expect(updatedValue.version).toBe(originalVersion + 1); + + const unredacted = await repository.getEnvironment(project.id, environment.id); + expect(unredacted).toEqual([ + expect.objectContaining({ key: "BECOMES_SECRET", value: "new-secret-value" }), + ]); + + const redacted = await repository.getEnvironmentWithRedactedSecrets( + project.id, + environment.id + ); + expect(redacted).toEqual([ + expect.objectContaining({ key: "BECOMES_SECRET", value: "", isSecret: true }), + ]); + } + ); + + postgresTest("does not change an existing secret value back to plaintext", async ({ prisma }) => { + const { user, organization, project } = await createTestOrgProjectWithMember(prisma); + const environment = await createRuntimeEnvironment(prisma, { + projectId: project.id, + organizationId: organization.id, + type: "PRODUCTION", + }); + const repository = new EnvironmentVariablesRepository(prisma, prisma); + + await createEnvironmentVariable(repository, project.id, { + environmentId: environment.id, + key: "STAYS_SECRET", + value: "original-secret", + isSecret: true, + userId: user.id, + }); + + const variable = await prisma.environmentVariable.findFirstOrThrow({ + where: { projectId: project.id, key: "STAYS_SECRET" }, + }); + + const result = await repository.editValue(project.id, { + id: variable.id, + environmentId: environment.id, + value: "updated-secret", + isSecret: false, + lastUpdatedBy: { type: "user", userId: user.id }, + }); + + expect(result).toEqual({ success: true }); + + const updatedValue = await prisma.environmentVariableValue.findUniqueOrThrow({ + where: { + variableId_environmentId: { + variableId: variable.id, + environmentId: environment.id, + }, + }, + }); + expect(updatedValue.isSecret).toBe(true); + + const unredacted = await repository.getEnvironment(project.id, environment.id); + expect(unredacted).toEqual([ + expect.objectContaining({ key: "STAYS_SECRET", value: "updated-secret" }), + ]); + }); +});