Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
4f1428c
feat: add env-var page design
ogzhanolguncu Mar 24, 2026
08595e8
feat: add floating env-add section
ogzhanolguncu Mar 25, 2026
c3d3a0a
feat: add env-var-expandable
ogzhanolguncu Mar 25, 2026
77d4ec5
refactor: improve styling
ogzhanolguncu Mar 27, 2026
79eb67c
Merge branch 'main' of github.com:unkeyed/unkey into env-var-page
ogzhanolguncu Mar 27, 2026
45d9563
fix: prevent adding same values
ogzhanolguncu Mar 27, 2026
8ade14b
feat: add edit
ogzhanolguncu Mar 27, 2026
14f494b
chore: remove env-vars from settings
ogzhanolguncu Mar 27, 2026
a16312c
chore: text changes
ogzhanolguncu Mar 27, 2026
1cf092f
fix: timestamp
ogzhanolguncu Mar 27, 2026
0d817e5
chore: cleanup
ogzhanolguncu Mar 27, 2026
46282f2
fix: add persistance
ogzhanolguncu Mar 27, 2026
ec9a1ea
fix: run formatter
ogzhanolguncu Mar 27, 2026
06c491f
chore: cleanup
ogzhanolguncu Mar 27, 2026
241fcb2
fix: expandable
ogzhanolguncu Mar 27, 2026
a86859f
chore: add new icon for note on env key
ogzhanolguncu Mar 30, 2026
be1d6b2
feat: move slide-panel to shared
ogzhanolguncu Mar 30, 2026
f149eb8
fix: lint and formatting
ogzhanolguncu Mar 30, 2026
427a32b
fix: minor inconsistincies
ogzhanolguncu Mar 30, 2026
3781948
fix: icon
ogzhanolguncu Mar 30, 2026
acfa3bf
fix: build issue
ogzhanolguncu Mar 30, 2026
50d8e26
Merge branch 'main' of github.com:unkeyed/unkey into env-var-page
ogzhanolguncu Mar 30, 2026
a3aff9e
fix: coderabbit comments
ogzhanolguncu Mar 30, 2026
5b4df66
fix: a11y issue
ogzhanolguncu Mar 30, 2026
5980583
Merge branch 'main' into env-var-page
ogzhanolguncu Mar 30, 2026
fd6a539
fix: bg styling issue and minor issues
ogzhanolguncu Mar 30, 2026
609c208
fix: condition
ogzhanolguncu Mar 30, 2026
a2b700d
Merge branch 'main' into env-var-page
perkinsjr Mar 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,7 @@ export const EnvVarSecretSwitch = ({
<div className="flex items-center gap-2">
<span className="text-xs text-gray-10">Secret</span>
<Switch
className="
h-4 w-8
data-[state=checked]:bg-success-9
data-[state=checked]:ring-2
data-[state=checked]:ring-successA-5
data-[state=unchecked]:bg-grayA-1
data-[state=unchecked]:ring-2
data-[state=unchecked]:ring-grayA-3
[&>span]:h-3.5 [&>span]:w-3.5
"
thumbClassName="h-[14px] w-[14px] data-[state=unchecked]:bg-grayA-12 data-[state=checked]:bg-white"
className="data-[state=checked]:bg-success-9 data-[state=checked]:ring-2 data-[state=checked]:ring-successA-5 data-[state=unchecked]:ring-2 data-[state=unchecked]:ring-grayA-3"
checked={isSecret}
onCheckedChange={onCheckedChange}
disabled={disabled}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,355 @@
import { Switch } from "@/components/ui/switch";
import { usePersistedForm } from "@/hooks/use-persisted-form";
import { collection } from "@/lib/collections";
import { zodResolver } from "@hookform/resolvers/zod";
import { eq, useLiveQuery } from "@tanstack/react-db";
import { CircleInfo, CloudUp, DoubleChevronRight, Plus } from "@unkey/icons";
import {
Button,
InfoTooltip,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
SlidePanel,
toast,
} from "@unkey/ui";
import { cn } from "@unkey/ui/src/lib/utils";
import { type ChangeEvent, useCallback, useEffect, useRef } from "react";
import { Controller, useFieldArray } from "react-hook-form";
import { useProjectData } from "../../data-provider";
import { EnvVarRow } from "./env-var-row";
import { type EnvVarsFormValues, createEmptyEntry, envVarsSchema, findConflicts } from "./schema";
import { useDropZone } from "./use-drop-zone";

import { usePreventLeave } from "@/hooks/use-prevent-leave";

type AddEnvVarExpandableProps = {
tableDistanceToTop: number;
isOpen: boolean;
onClose: () => void;
};

export const AddEnvVarExpandable = ({
tableDistanceToTop,
isOpen,
onClose,
}: AddEnvVarExpandableProps) => {
const { projectId, environments } = useProjectData();

const { data: existingEnvVars } = useLiveQuery(
(q) => q.from({ v: collection.envVars }).where(({ v }) => eq(v.projectId, projectId)),
[projectId],
);

const {
register,
handleSubmit,
formState: { isSubmitting, errors },
control,
reset,
trigger,
getValues,
setFocus,
setError,
clearPersistedData,
saveCurrentValues,
loadSavedValues,
} = usePersistedForm<EnvVarsFormValues>(
`env-vars-add-${projectId}`,
{
resolver: zodResolver(envVarsSchema),
mode: "onSubmit",
defaultValues: {
envVars: [createEmptyEntry()],
environmentId: "__all__",
secret: false,
},
},
"session",
);

const { fields, append, remove } = useFieldArray({ control, name: "envVars" });
const { ref: formRef, isDragging, importFile } = useDropZone(reset, trigger, getValues);
const fileInputRef = useRef<HTMLInputElement>(null);

usePreventLeave(isOpen);

useEffect(
function persistFormState() {
if (isOpen) {
loadSavedValues();
} else {
saveCurrentValues();
}
},
[isOpen, loadSavedValues, saveCurrentValues],
);
Comment on lines +79 to +88
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Persist/load should run on open↔close transitions, not initial mount.

At Line 83-85, the initial render with isOpen === false calls saveCurrentValues() immediately, which can overwrite an existing saved draft before the first open.

Suggested minimal patch
+  const prevIsOpenRef = useRef(isOpen);
+
   useEffect(
     function persistFormState() {
-      if (isOpen) {
+      if (isOpen && !prevIsOpenRef.current) {
         loadSavedValues();
-      } else {
+      } else if (!isOpen && prevIsOpenRef.current) {
         saveCurrentValues();
       }
+      prevIsOpenRef.current = isOpen;
     },
     [isOpen, loadSavedValues, saveCurrentValues],
   );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(
function persistFormState() {
if (isOpen) {
loadSavedValues();
} else {
saveCurrentValues();
}
},
[isOpen, loadSavedValues, saveCurrentValues],
);
const prevIsOpenRef = useRef(isOpen);
useEffect(
function persistFormState() {
if (isOpen && !prevIsOpenRef.current) {
loadSavedValues();
} else if (!isOpen && prevIsOpenRef.current) {
saveCurrentValues();
}
prevIsOpenRef.current = isOpen;
},
[isOpen, loadSavedValues, saveCurrentValues],
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@web/apps/dashboard/app/`(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/add-env-var-expandable.tsx
around lines 79 - 88, The effect persistFormState currently runs on mount and
calls saveCurrentValues when isOpen is false; change it to run only on
open/close transitions by skipping the initial mount: add a ref (e.g.,
hasMountedRef) initialized false, then inside the useEffect for persistFormState
check if hasMountedRef.current is false — if so set it true and return early;
otherwise perform the existing logic that calls loadSavedValues() when isOpen
becomes true and saveCurrentValues() when it becomes false. Ensure the ref and
checks reference the same effect that uses isOpen, loadSavedValues, and
saveCurrentValues so initial render does not invoke saveCurrentValues().


const handleFileImport = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
importFile(file);
}
e.target.value = "";
},
[importFile],
);

const onInvalid = useCallback(() => {
const envVarErrors = errors.envVars;
if (envVarErrors && Array.isArray(envVarErrors)) {
const firstErrorIndex = envVarErrors.findIndex((e) => e != null);
if (firstErrorIndex !== -1) {
setFocus(`envVars.${firstErrorIndex}.key`);
}
}
}, [errors.envVars, setFocus]);

const onSubmit = async (values: EnvVarsFormValues) => {
const nonEmpty = values.envVars.filter((v) => v.key !== "" && v.value !== "");
if (nonEmpty.length === 0) {
return;
}

const existing = (existingEnvVars ?? []).map((v) => ({
key: v.key,
environmentId: v.environmentId,
}));
const allEnvIds = environments.map((e) => e.id);
const conflicts = findConflicts(nonEmpty, values.environmentId, existing, allEnvIds);

if (conflicts.length > 0) {
for (const idx of conflicts) {
// Map back to the original form index
const originalIdx = values.envVars.findIndex(
(v) => v.key === nonEmpty[idx].key && v.value === nonEmpty[idx].value,
);
if (originalIdx !== -1) {
setError(`envVars.${originalIdx}.key`, {
message: "Variable already exists in this environment",
});
}
}
return;
}

const targetEnvIds =
values.environmentId === "__all__" ? environments.map((e) => e.id) : [values.environmentId];
const type = values.secret ? "writeonly" : "recoverable";
const flatRecords = nonEmpty.flatMap((entry) =>
targetEnvIds.map((envId) => ({
key: entry.key,
value: entry.value,
description: entry.description,
environmentId: envId,
})),
);

for (const v of flatRecords) {
collection.envVars.insert({
id: crypto.randomUUID(),
environmentId: v.environmentId,
projectId,
key: v.key,
value: v.value,
type,
description: v.description || null,
updatedAt: Date.now(),
});
}
toast.success(`Added ${flatRecords.length} variable(s)`);

clearPersistedData();
reset({
envVars: [createEmptyEntry()],
environmentId: "__all__",
secret: false,
});
onClose();
};

return (
<SlidePanel.Root isOpen={isOpen} onClose={onClose} topOffset={tableDistanceToTop}>
<SlidePanel.Header>
<div className="flex flex-col">
<span className="text-gray-12 font-medium text-base leading-8">
Add Environment Variable
</span>
<span className="text-gray-11 text-[13px] leading-5">
Set a key-value pair for your project.
</span>
</div>
<SlidePanel.Close
aria-label="Close panel"
className="mt-0.5 inline-flex items-center justify-center size-9 rounded-md hover:bg-grayA-3 transition-colors cursor-pointer"
>
<DoubleChevronRight
iconSize="lg-medium"
className="text-gray-10 transition-transform duration-300 ease-out group-hover:text-gray-12"
/>
Comment on lines +187 to +192
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

group-hover styles won’t apply without a group parent class.

At Line 191, the icon uses group-hover:text-gray-12, but the SlidePanel.Close trigger at Line 187 is missing group, so the hover color transition never activates.

Suggested minimal patch
         <SlidePanel.Close
           aria-label="Close panel"
-          className="mt-0.5 inline-flex items-center justify-center size-9 rounded-md hover:bg-grayA-3 transition-colors cursor-pointer"
+          className="group mt-0.5 inline-flex items-center justify-center size-9 rounded-md hover:bg-grayA-3 transition-colors cursor-pointer"
         >
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@web/apps/dashboard/app/`(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/add-env-var-expandable.tsx
around lines 187 - 192, The DoubleChevronRight icon uses the Tailwind
group-hover utility but its parent SlidePanel.Close trigger lacks the required
group class; update the SlidePanel.Close element (the parent with
className="mt-0.5 inline-flex ...") to include "group" (e.g., append "group" to
its className) so the group-hover:text-gray-12 on DoubleChevronRight takes
effect, preserving the existing classes and transitions.

</SlidePanel.Close>
</SlidePanel.Header>

<SlidePanel.Content>
<form
ref={formRef}
onSubmit={handleSubmit(onSubmit, onInvalid)}
className="h-full flex flex-col relative"
>
{/* Drop zone overlay */}
<div
className={cn(
"absolute inset-0 rounded-lg pointer-events-none z-10 flex items-center justify-center transition-all duration-200",
isDragging ? "bg-successA-2 opacity-100" : "opacity-0",
)}
>
<div
className={cn(
"absolute inset-4 rounded-lg border-2 border-dashed transition-all duration-200",
isDragging ? "border-successA-8 scale-100" : "border-transparent scale-[0.98]",
)}
/>
<div
className={cn(
"flex flex-col items-center gap-3 transition-all duration-200",
isDragging ? "opacity-100 scale-100" : "opacity-0 scale-95",
)}
>
<div className="size-12 rounded-xl bg-successA-3 flex items-center justify-center">
<CloudUp className="text-success-11" />
</div>
<div className="flex flex-col items-center gap-1">
<span className="text-sm font-medium text-success-11">Drop your .env file</span>
<span className="text-xs text-success-10">
We'll parse and import your variables
</span>
</div>
</div>
</div>

<div className="flex-1 overflow-y-auto pt-6 bg-grayA-2">
<div className="flex flex-col gap-4 px-8">
{fields.map((field, index) => (
<EnvVarRow
key={field.id}
index={index}
isOnly={fields.length === 1}
register={register}
onRemove={remove}
errors={errors.envVars}
/>
))}
</div>

<div className="flex py-6 px-8">
<Button
type="button"
variant="outline"
size="md"
className="font-medium"
onClick={() => append(createEmptyEntry())}
>
<Plus iconSize="sm-regular" />
Add Another
</Button>
</div>
</div>

<div className="border-t border-grayA-4">
<div className="px-8 py-6 space-y-6">
<Controller
control={control}
name="environmentId"
render={({ field }) => (
<fieldset className="flex flex-col gap-1.5 border-0 m-0 p-0">
<label htmlFor="environment-select" className="text-gray-11 text-[13px]">
Environment
</label>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger id="environment-select" className="capitalize">
<SelectValue placeholder="Select environment" />
</SelectTrigger>
<SelectContent className="z-[60]">
<SelectItem value="__all__">All Environments</SelectItem>
{environments.map((env) => (
<SelectItem key={env.id} value={env.id} className="capitalize">
{env.slug}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.environmentId?.message && (
<p className="text-error-11 text-[13px]">{errors.environmentId.message}</p>
)}
</fieldset>
)}
/>

<div className="flex items-center gap-3 pt-6">
<Controller
control={control}
name="secret"
render={({ field }) => (
<Switch
className="data-[state=checked]:bg-success-9 data-[state=checked]:ring-2 data-[state=checked]:ring-successA-5 data-[state=unchecked]:ring-2 data-[state=unchecked]:ring-grayA-3 data-[state=unchecked]:bg-gray-5"
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
/>
<span className="text-[13px] text-gray-12 font-medium">Sensitive</span>
<InfoTooltip
content="Permanently hides values after saving. Use for API keys and secrets."
position={{ side: "top" }}
className="z-60"
asChild
>
<span className="text-grayA-9">
<CircleInfo iconSize="md-regular" />
</span>
</InfoTooltip>
</div>
</div>
</div>

<div className="border-t border-gray-4 bg-white dark:bg-black px-8 py-5 flex items-center justify-between">
<div className="hidden md:flex items-center gap-3">
<input
ref={fileInputRef}
type="file"
accept=".env,.txt,text/plain"
className="hidden"
onChange={handleFileImport}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
>
<CloudUp iconSize="sm-regular" />
Import <span className="font-medium">.env</span>
</Button>
<span className="text-[13px] text-gray-11">
or drag & drop / paste (⌘V) your .env
</span>
</div>
<Button
type="submit"
variant="primary"
size="md"
className="px-3"
loading={isSubmitting}
disabled={isSubmitting}
>
Save
</Button>
</div>
</form>
</SlidePanel.Content>
</SlidePanel.Root>
);
};
Loading
Loading