Skip to content

Commit f717869

Browse files
committed
feat(webapp): allow marking environment variables as secret after creation
Add an irreversible "Make secret" toggle to the edit environment variable dialog so users can protect sensitive values that were initially created as non-secret. Once enabled, the value is hidden and the toggle becomes disabled.
1 parent 0674d74 commit f717869

File tree

3 files changed

+135
-0
lines changed

3 files changed

+135
-0
lines changed

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { Fieldset } from "~/components/primitives/Fieldset";
2828
import { FormButtons } from "~/components/primitives/FormButtons";
2929
import { FormError } from "~/components/primitives/FormError";
3030
import { Header2 } from "~/components/primitives/Headers";
31+
import { Hint } from "~/components/primitives/Hint";
3132
import { InfoPanel } from "~/components/primitives/InfoPanel";
3233
import { Input } from "~/components/primitives/Input";
3334
import { InputGroup } from "~/components/primitives/InputGroup";
@@ -69,6 +70,7 @@ import {
6970
DeleteEnvironmentVariableValue,
7071
EditEnvironmentVariableValue,
7172
EnvironmentVariable,
73+
MakeSecretEnvironmentVariableValue,
7274
} from "~/v3/environmentVariables/repository";
7375

7476
export const meta: MetaFunction = () => {
@@ -111,6 +113,10 @@ const schema = z.discriminatedUnion("action", [
111113
key: z.string(),
112114
...DeleteEnvironmentVariableValue.shape,
113115
}),
116+
z.object({
117+
action: z.literal("makeSecret"),
118+
...MakeSecretEnvironmentVariableValue.shape,
119+
}),
114120
]);
115121

116122
export const action = async ({ request, params }: ActionFunctionArgs) => {
@@ -179,6 +185,17 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
179185
`Deleted ${submission.value.key} environment variable`
180186
);
181187
}
188+
case "makeSecret": {
189+
const repository = new EnvironmentVariablesRepository(prisma);
190+
const result = await repository.makeSecret(project.id, submission.value);
191+
192+
if (!result.success) {
193+
submission.error.key = [result.error];
194+
return json(submission);
195+
}
196+
197+
return json({ ...submission, success: true });
198+
}
182199
}
183200
};
184201

@@ -405,9 +422,11 @@ function EditEnvironmentVariablePanel({
405422
}) {
406423
const [isOpen, setIsOpen] = useState(false);
407424
const fetcher = useFetcher<typeof action>();
425+
const makeSecretFetcher = useFetcher<typeof action>();
408426
const lastSubmission = fetcher.data as any;
409427

410428
const isLoading = fetcher.state !== "idle";
429+
const isMakingSecret = makeSecretFetcher.state !== "idle";
411430

412431
// Close dialog on successful submission
413432
useEffect(() => {
@@ -426,6 +445,25 @@ function EditEnvironmentVariablePanel({
426445
shouldRevalidate: "onSubmit",
427446
});
428447

448+
const handleMakeSecret = () => {
449+
if (
450+
!confirm(
451+
"Are you sure you want to make this variable secret? This action is irreversible — the value will be hidden and cannot be revealed again."
452+
)
453+
) {
454+
return;
455+
}
456+
457+
makeSecretFetcher.submit(
458+
{
459+
action: "makeSecret",
460+
id: variable.id,
461+
environmentId: variable.environment.id,
462+
},
463+
{ method: "post" }
464+
);
465+
};
466+
429467
return (
430468
<Dialog open={isOpen} onOpenChange={setIsOpen}>
431469
<DialogTrigger asChild>
@@ -466,6 +504,25 @@ function EditEnvironmentVariablePanel({
466504
<FormError id={value.errorId}>{value.error}</FormError>
467505
</InputGroup>
468506

507+
<InputGroup fullWidth>
508+
<Switch
509+
variant="medium"
510+
label={<span className="text-text-bright">Secret value</span>}
511+
checked={variable.isSecret}
512+
disabled={variable.isSecret || isMakingSecret}
513+
onCheckedChange={(checked) => {
514+
if (checked) {
515+
handleMakeSecret();
516+
}
517+
}}
518+
/>
519+
<Hint>
520+
{variable.isSecret
521+
? "This variable is secret and cannot be changed back."
522+
: "Once enabled, the value will be hidden and cannot be revealed again."}
523+
</Hint>
524+
</InputGroup>
525+
469526
<FormError>{form.error}</FormError>
470527

471528
<FormButtons

apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
type DeleteEnvironmentVariableValue,
1212
type EnvironmentVariable,
1313
type EnvironmentVariableWithSecret,
14+
type MakeSecretEnvironmentVariableValue,
1415
type ProjectEnvironmentVariable,
1516
type Repository,
1617
type Result,
@@ -630,6 +631,76 @@ export class EnvironmentVariablesRepository implements Repository {
630631
return this.#getSecretEnvironmentVariables(projectId, environmentId, parentEnvironmentId);
631632
}
632633

634+
async makeSecret(
635+
projectId: string,
636+
options: MakeSecretEnvironmentVariableValue
637+
): Promise<Result> {
638+
const project = await this.prismaClient.project.findFirst({
639+
where: {
640+
id: projectId,
641+
deletedAt: null,
642+
},
643+
select: {
644+
id: true,
645+
},
646+
});
647+
648+
if (!project) {
649+
return { success: false as const, error: "Project not found" };
650+
}
651+
652+
const environmentVariable = await this.prismaClient.environmentVariable.findFirst({
653+
where: {
654+
id: options.id,
655+
projectId,
656+
},
657+
select: {
658+
id: true,
659+
values: {
660+
where: {
661+
environmentId: options.environmentId,
662+
},
663+
select: {
664+
id: true,
665+
isSecret: true,
666+
},
667+
},
668+
},
669+
});
670+
671+
if (!environmentVariable) {
672+
return { success: false as const, error: "Environment variable not found" };
673+
}
674+
675+
const value = environmentVariable.values[0];
676+
677+
if (!value) {
678+
return { success: false as const, error: "Environment variable value not found" };
679+
}
680+
681+
if (value.isSecret) {
682+
return { success: false as const, error: "Variable is already secret" };
683+
}
684+
685+
try {
686+
await this.prismaClient.environmentVariableValue.update({
687+
where: {
688+
id: value.id,
689+
},
690+
data: {
691+
isSecret: true,
692+
},
693+
});
694+
695+
return { success: true as const };
696+
} catch (error) {
697+
return {
698+
success: false as const,
699+
error: error instanceof Error ? error.message : "Something went wrong",
700+
};
701+
}
702+
}
703+
633704
async delete(projectId: string, options: DeleteEnvironmentVariable): Promise<Result> {
634705
const project = await this.prismaClient.project.findFirst({
635706
where: {

apps/webapp/app/v3/environmentVariables/repository.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ export const DeleteEnvironmentVariableValue = z.object({
4747
});
4848
export type DeleteEnvironmentVariableValue = z.infer<typeof DeleteEnvironmentVariableValue>;
4949

50+
export const MakeSecretEnvironmentVariableValue = z.object({
51+
id: z.string(),
52+
environmentId: z.string(),
53+
});
54+
export type MakeSecretEnvironmentVariableValue = z.infer<typeof MakeSecretEnvironmentVariableValue>;
55+
5056
export const EditEnvironmentVariableValue = z.object({
5157
id: z.string(),
5258
environmentId: z.string(),
@@ -105,4 +111,5 @@ export interface Repository {
105111
getEnvironmentVariables(projectId: string, environmentId: string): Promise<EnvironmentVariable[]>;
106112
delete(projectId: string, options: DeleteEnvironmentVariable): Promise<Result>;
107113
deleteValue(projectId: string, options: DeleteEnvironmentVariableValue): Promise<Result>;
114+
makeSecret(projectId: string, options: MakeSecretEnvironmentVariableValue): Promise<Result>;
108115
}

0 commit comments

Comments
 (0)