diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/env-variables-section/components/env-var-secret-switch.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/env-variables-section/components/env-var-secret-switch.tsx index dfd38c1f07..e1d5d2848f 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/env-variables-section/components/env-var-secret-switch.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/env-variables-section/components/env-var-secret-switch.tsx @@ -13,17 +13,7 @@ export const EnvVarSecretSwitch = ({
Secret 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( + `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(null); + + usePreventLeave(isOpen); + + useEffect( + function persistFormState() { + if (isOpen) { + loadSavedValues(); + } else { + saveCurrentValues(); + } + }, + [isOpen, loadSavedValues, saveCurrentValues], + ); + + const handleFileImport = useCallback( + (e: ChangeEvent) => { + 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 ( + + +
+ + Add Environment Variable + + + Set a key-value pair for your project. + +
+ + + +
+ + +
+ {/* Drop zone overlay */} +
+
+
+
+ +
+
+ Drop your .env file + + We'll parse and import your variables + +
+
+
+ +
+
+ {fields.map((field, index) => ( + + ))} +
+ +
+ +
+
+ +
+
+ ( +
+ + + {errors.environmentId?.message && ( +

{errors.environmentId.message}

+ )} +
+ )} + /> + +
+ ( + + )} + /> + Sensitive + + + + + +
+
+
+ +
+
+ + + + or drag & drop / paste (⌘V) your .env + +
+ +
+ + + + ); +}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/env-var-action-menu.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/env-var-action-menu.tsx new file mode 100644 index 0000000000..56d792ee1f --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/env-var-action-menu.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { type MenuItem, TableActionPopover } from "@/components/logs/table-action.popover"; +import { collection } from "@/lib/collections"; +import { trpc } from "@/lib/trpc/client"; +import { Clone, PenWriting3, Trash } from "@unkey/icons"; +import { toast } from "@unkey/ui"; + +type EnvVarActionMenuProps = { + envVarId: string; + variableKey: string; + type: "writeonly" | "recoverable"; + onEdit: () => void; +}; + +export function EnvVarActionMenu({ envVarId, variableKey, type, onEdit }: EnvVarActionMenuProps) { + const decryptMutation = trpc.deploy.envVar.decrypt.useMutation(); + + const menuItems: MenuItem[] = [ + { + id: "edit", + label: "Edit", + icon: , + onClick: (e) => { + e.stopPropagation(); + onEdit(); + }, + }, + { + id: "delete", + label: "Delete", + icon: , + divider: true, + onClick: (e) => { + e.stopPropagation(); + try { + collection.envVars.delete(envVarId); + toast.success("Variable deleted"); + } catch { + toast.error("Failed to delete variable"); + } + }, + }, + { + id: "copy", + label: "Copy to Clipboard", + icon: , + disabled: type === "writeonly", + tooltip: type === "writeonly" ? "Write-only variables cannot be copied" : undefined, + onClick: async (e) => { + e.stopPropagation(); + try { + const result = await decryptMutation.mutateAsync({ envVarId }); + navigator.clipboard.writeText(`${variableKey}=${result.value}`); + toast.success("Copied to clipboard"); + } catch { + toast.error("Failed to decrypt value"); + } + }, + }, + ]; + + return ; +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/env-var-edit-row.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/env-var-edit-row.tsx new file mode 100644 index 0000000000..1c9db622a7 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/env-var-edit-row.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { collection } from "@/lib/collections"; +import { trpc } from "@/lib/trpc/client"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button, FormInput, FormTextarea, toast } from "@unkey/ui"; +import { useCallback, useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +const editEnvVarSchema = z.object({ + key: z.string().min(1, "Variable name is required"), + value: z.string(), + description: z.string().optional(), +}); + +type EditEnvVarFormValues = z.infer; + +type EnvVarEditRowProps = { + envVarId: string; + variableKey: string; + type: "writeonly" | "recoverable"; + note: string | null; + onClose: () => void; +}; + +export function EnvVarEditRow({ envVarId, variableKey, type, note, onClose }: EnvVarEditRowProps) { + const isWriteonly = type === "writeonly"; + const decryptMutation = trpc.deploy.envVar.decrypt.useMutation(); + + const { + register, + handleSubmit, + setValue, + formState: { isSubmitting, errors }, + } = useForm({ + resolver: zodResolver(editEnvVarSchema), + defaultValues: { + key: variableKey, + value: "", + description: note ?? "", + }, + }); + + // biome-ignore lint/correctness/useExhaustiveDependencies: decryptMutation is not stable + useEffect( + function decryptValue() { + if (isWriteonly) { + return; + } + let cancelled = false; + decryptMutation.mutateAsync({ envVarId }).then( + (result) => { + if (!cancelled) { + setValue("value", result.value); + } + }, + () => { + if (!cancelled) { + toast.error("Failed to decrypt value"); + } + }, + ); + return () => { + cancelled = true; + }; + }, + [envVarId, isWriteonly, setValue], + ); + + const onSubmit = useCallback( + async (values: EditEnvVarFormValues) => { + if (isWriteonly && !values.value) { + onClose(); + return; + } + + try { + collection.envVars.update(envVarId, (draft) => { + draft.key = values.key; + draft.value = values.value; + draft.description = values.description || null; + }); + toast.success("Variable updated"); + onClose(); + } catch { + toast.error("Failed to update variable"); + } + }, + [envVarId, isWriteonly, onClose], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + } + }, + [onClose], + ); + + return ( +
+
+ + + +
+ + +
+ +
+ ); +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/env-var-name-cell.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/env-var-name-cell.tsx new file mode 100644 index 0000000000..504189e1c3 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/env-var-name-cell.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { trpc } from "@/lib/trpc/client"; +import { Note3 } from "@unkey/icons"; +import { Badge, InfoTooltip, toast } from "@unkey/ui"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { HighlightMatch } from "./highlight-match"; + +type EnvVarNameCellProps = { + envVarId: string; + variableKey: string; + environmentName: string; + note?: string | null; + searchQuery: string; + type: "writeonly" | "recoverable"; +}; + +export const EnvVarNameCell = ({ + envVarId, + variableKey, + environmentName, + note, + searchQuery, + type, +}: EnvVarNameCellProps) => { + const [copied, setCopied] = useState(false); + const copyTimeoutRef = useRef>(undefined); + const decryptMutation = trpc.deploy.envVar.decrypt.useMutation(); + + useEffect(() => { + return () => { + clearTimeout(copyTimeoutRef.current); + }; + }, []); + + const handleCopy = useCallback( + async (e: React.MouseEvent) => { + e.stopPropagation(); + try { + if (type === "recoverable") { + const result = await decryptMutation.mutateAsync({ envVarId }); + navigator.clipboard.writeText(`${variableKey}=${result.value}`); + } else { + navigator.clipboard.writeText(variableKey); + } + setCopied(true); + toast.success("Copied to clipboard"); + clearTimeout(copyTimeoutRef.current); + copyTimeoutRef.current = setTimeout(() => setCopied(false), 2000); + } catch { + toast.error("Failed to copy variable"); + } + }, + [variableKey, type, envVarId, decryptMutation], + ); + + return ( +
+
+
+ + + + {type === "writeonly" && ( + + Sensitive + + )} + {note && ( + + + + + + )} +
+
{environmentName}
+
+
+ ); +}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/env-var-row.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/env-var-row.tsx new file mode 100644 index 0000000000..99cd67150b --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/env-var-row.tsx @@ -0,0 +1,68 @@ +import { Plus, Trash } from "@unkey/icons"; +import { Button, FormInput } from "@unkey/ui"; +import type { FieldErrors, UseFormRegister } from "react-hook-form"; +import type { EnvVarsFormValues } from "./schema"; + +type EnvVarRowProps = { + index: number; + isOnly: boolean; + register: UseFormRegister; + onRemove: (index: number) => void; + errors?: FieldErrors["envVars"]; +}; + +export const EnvVarRow = ({ index, isOnly, register, onRemove, errors }: EnvVarRowProps) => { + const fieldErrors = errors?.[index]; + + return ( +
+ {/* Key + Value + Delete side by side */} +
+ + + {!isOnly && ( + + )} +
+ +
+ + + + Add Note + + Note + +
+ +
+
+
+ ); +}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/env-var-value-cell.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/env-var-value-cell.tsx new file mode 100644 index 0000000000..00c55deeb0 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/env-var-value-cell.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { trpc } from "@/lib/trpc/client"; +import { Eye, EyeSlash } from "@unkey/icons"; +import { InfoTooltip, Loading, toast } from "@unkey/ui"; +import { memo, useCallback, useEffect, useRef, useState } from "react"; + +const AUTO_HIDE_MS = 10_000; + +type EnvVarValueCellProps = { + envVarId: string; + type: "writeonly" | "recoverable"; +}; + +export const EnvVarValueCell = memo(function EnvVarValueCell({ + envVarId, + type, +}: EnvVarValueCellProps) { + const [visible, setVisible] = useState(false); + const [copied, setCopied] = useState(false); + const [decryptedValue, setDecryptedValue] = useState(); + const copyTimeoutRef = useRef>(undefined); + const hideTimeoutRef = useRef>(undefined); + + const decryptMutation = trpc.deploy.envVar.decrypt.useMutation(); + const isWriteonly = type === "writeonly"; + + useEffect(() => { + return () => { + clearTimeout(copyTimeoutRef.current); + clearTimeout(hideTimeoutRef.current); + }; + }, []); + + const startAutoHide = useCallback(() => { + clearTimeout(hideTimeoutRef.current); + hideTimeoutRef.current = setTimeout(() => { + setVisible(false); + setDecryptedValue(undefined); + }, AUTO_HIDE_MS); + }, []); + + const handleToggleReveal = useCallback( + async (e: React.MouseEvent) => { + e.stopPropagation(); + + if (visible) { + setVisible(false); + setDecryptedValue(undefined); + clearTimeout(hideTimeoutRef.current); + return; + } + + if (decryptedValue !== undefined) { + setVisible(true); + startAutoHide(); + return; + } + + try { + const result = await decryptMutation.mutateAsync({ envVarId }); + setDecryptedValue(result.value); + setVisible(true); + startAutoHide(); + } catch { + toast.error("Failed to decrypt value"); + } + }, + [visible, decryptedValue, envVarId, decryptMutation, startAutoHide], + ); + + const handleCopy = useCallback( + async (e: React.MouseEvent) => { + e.stopPropagation(); + if (decryptedValue === undefined) { + return; + } + try { + await navigator.clipboard.writeText(decryptedValue); + setCopied(true); + toast.success("Copied to clipboard"); + clearTimeout(copyTimeoutRef.current); + copyTimeoutRef.current = setTimeout(() => setCopied(false), 2000); + startAutoHide(); + } catch { + toast.error("Failed to copy to clipboard"); + } + }, + [decryptedValue, startAutoHide], + ); + + if (isWriteonly) { + return null; + } + + return ( +
+
+ +
+ {visible && decryptedValue !== undefined ? ( + + + + ) : ( + •••••••••••• + )} +
+ ); +}); diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/env-vars-empty.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/env-vars-empty.tsx new file mode 100644 index 0000000000..108725262f --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/env-vars-empty.tsx @@ -0,0 +1,25 @@ +import { Empty } from "@unkey/ui"; + +type EnvVarsEmptyProps = { + searchQuery: string; +}; + +export function EnvVarsEmpty({ searchQuery }: EnvVarsEmptyProps) { + return ( +
+
+ + + + {searchQuery ? "No Matching Variables" : "No Environment Variables"} + + + {searchQuery + ? `No variables matching "${searchQuery}". Try a different search term.` + : "Environment variables will appear here once you add them. Store API keys, tokens, and config securely."} + + +
+
+ ); +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/env-vars-header.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/env-vars-header.tsx new file mode 100644 index 0000000000..f086001708 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/env-vars-header.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { Plus } from "@unkey/icons"; +import { Button } from "@unkey/ui"; + +type EnvVarsHeaderProps = { + isAddOpen: boolean; + onToggleAdd: () => void; +}; + +export function EnvVarsHeader({ isAddOpen, onToggleAdd }: EnvVarsHeaderProps) { + return ( +
+
+

Environment Variables

+

+ Store API keys, tokens, and config securely. Changes apply on next deploy. +

+
+ +
+ ); +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/env-vars-list.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/env-vars-list.tsx new file mode 100644 index 0000000000..07078427df --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/env-vars-list.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { collection } from "@/lib/collections"; +import type { Environment } from "@/lib/collections/deploy/environments"; +import { eq, useLiveQuery } from "@tanstack/react-db"; +import { ChartActivity2 } from "@unkey/icons"; +import { Badge, TimestampInfo } from "@unkey/ui"; +import { useCallback, useDeferredValue, useMemo, useState } from "react"; +import { EnvVarActionMenu } from "./env-var-action-menu"; +import { EnvVarEditRow } from "./env-var-edit-row"; +import { EnvVarNameCell } from "./env-var-name-cell"; +import { EnvVarValueCell } from "./env-var-value-cell"; +import { EnvVarsEmpty } from "./env-vars-empty"; +import { EnvVarsSkeleton } from "./env-vars-skeleton"; +import type { EnvironmentFilter, SortOption } from "./env-vars-toolbar"; + +type EnvVarsListProps = { + projectId: string; + environments: Environment[]; + searchQuery: string; + environmentFilter: EnvironmentFilter; + sortBy: SortOption; +}; + +export function EnvVarsList({ + projectId, + environments, + searchQuery, + environmentFilter, + sortBy, +}: EnvVarsListProps) { + const [editingId, setEditingId] = useState(null); + const closeEdit = useCallback(() => setEditingId(null), []); + const deferredQuery = useDeferredValue(searchQuery); + + const { data: envVarData, isLoading } = useLiveQuery( + (q) => q.from({ v: collection.envVars }).where(({ v }) => eq(v.projectId, projectId)), + [projectId], + ); + + const envMap = useMemo(() => { + const map = new Map(); + for (const env of environments) { + map.set(env.id, env.slug); + } + return map; + }, [environments]); + + const indexedVars = useMemo(() => { + if (!envVarData) { + return []; + } + return envVarData.map((v) => ({ + id: v.id, + key: v.key, + keyLower: v.key.toLowerCase(), + type: v.type, + environmentId: v.environmentId, + updatedAt: v.updatedAt, + note: v.description, + })); + }, [envVarData]); + + const filteredData = useMemo(() => { + const query = deferredQuery.toLowerCase(); + + const result = []; + for (const v of indexedVars) { + if (query && !v.keyLower.includes(query)) { + continue; + } + if (environmentFilter !== "all" && v.environmentId !== environmentFilter) { + continue; + } + result.push({ + id: v.id, + key: v.key, + environmentId: v.environmentId, + environmentName: envMap.get(v.environmentId) ?? "Unknown", + type: v.type, + updatedAt: v.updatedAt, + note: v.note, + }); + } + + if (sortBy === "name-asc") { + result.sort((a, b) => a.key.localeCompare(b.key)); + } else { + result.sort((a, b) => b.updatedAt - a.updatedAt); + } + + return result; + }, [indexedVars, envMap, deferredQuery, environmentFilter, sortBy]); + + if (isLoading) { + return ; + } + + if (filteredData.length === 0) { + return ; + } + + return ( +
+ {filteredData.map((item) => ( +
+
+
+ +
+
+ +
+
+ + + + +
+
+ setEditingId(item.id)} + /> +
+
+ {editingId === item.id && ( +
+
+ +
+
+ )} +
+ ))} +
+ ); +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/env-vars-skeleton.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/env-vars-skeleton.tsx new file mode 100644 index 0000000000..f9920aa082 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/env-vars-skeleton.tsx @@ -0,0 +1,33 @@ +import { Dots } from "@unkey/icons"; + +export function EnvVarsSkeleton() { + return ( +
+ {Array.from({ length: 10 }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: skeleton items don't need stable keys +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+ ))} +
+ ); +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/env-vars-toolbar.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/env-vars-toolbar.tsx new file mode 100644 index 0000000000..745ae8f66e --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/env-vars-toolbar.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { BarsFilter, ChevronDown, Layers3, Magnifier } from "@unkey/icons"; +import { + FormInput, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@unkey/ui"; + +const SORT_OPTIONS = ["last-updated", "name-asc"] as const; +export type SortOption = (typeof SORT_OPTIONS)[number]; +export type EnvironmentFilter = "all" | string; + +function isSortOption(value: string): value is SortOption { + return (SORT_OPTIONS as readonly string[]).includes(value); +} + +type EnvVarsToolbarProps = { + searchQuery: string; + onSearchChange: (value: string) => void; + environmentFilter: EnvironmentFilter; + onEnvironmentFilterChange: (value: EnvironmentFilter) => void; + environments: { id: string; slug: string }[]; + sortBy: SortOption; + onSortChange: (value: SortOption) => void; +}; + +export function EnvVarsToolbar({ + searchQuery, + onSearchChange, + environmentFilter, + onEnvironmentFilterChange, + environments, + sortBy, + onSortChange, +}: EnvVarsToolbarProps) { + return ( +
+
+ onSearchChange(e.target.value)} + className="[&_input]:h-9 [&_input]:text-[13px] w-full bg-gray-1 [&_input]:bg-gray-1" + leftIcon={} + /> +
+
+ +
+
+ +
+
+ ); +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/highlight-match.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/highlight-match.tsx new file mode 100644 index 0000000000..cb37078360 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/highlight-match.tsx @@ -0,0 +1,31 @@ +type HighlightMatchProps = { + text: string; + query: string; +}; + +function escapeRegExp(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function HighlightMatch({ text, query }: HighlightMatchProps) { + if (!query) { + return <>{text}; + } + + const parts = text.split(new RegExp(`(${escapeRegExp(query)})`, "gi")); + + return ( + <> + {parts.map((part, i) => + part.toLowerCase() === query.toLowerCase() ? ( + // biome-ignore lint/suspicious/noArrayIndexKey: parts are derived from splitting text by query — stable order, no reordering + + {part} + + ) : ( + part + ), + )} + + ); +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/schema.ts b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/schema.ts new file mode 100644 index 0000000000..7651e39a77 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/schema.ts @@ -0,0 +1,79 @@ +import { z } from "zod"; + +export const envVarEntrySchema = z.object({ + key: z.string().trim().min(1, "Variable name is required"), + value: z.string(), + description: z.string().optional(), +}); + +export const envVarsSchema = z.object({ + envVars: z + .array(envVarEntrySchema) + .min(1) + .superRefine((vars, ctx) => { + const seen = new Map(); + for (let i = 0; i < vars.length; i++) { + if (!vars[i].key) { + continue; + } + const indices = seen.get(vars[i].key); + if (indices) { + indices.push(i); + } else { + seen.set(vars[i].key, [i]); + } + } + for (const indices of seen.values()) { + if (indices.length > 1) { + for (const i of indices) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Duplicate variable name", + path: [i, "key"], + }); + } + } + } + }), + environmentId: z.string().min(1, "Environment is required"), + secret: z.boolean(), +}); + +export type EnvVarsFormValues = z.infer; + +export function createEmptyEntry(): EnvVarsFormValues["envVars"][number] { + return { + key: "", + value: "", + description: "", + }; +} + +type ExistingEnvVar = { key: string; environmentId: string }; + +/** + * Returns indices of form entries whose key already exists in the given environment(s). + */ +export function findConflicts( + entries: { key: string }[], + environmentId: string, + existingVars: ExistingEnvVar[], + allEnvironmentIds: string[], +): number[] { + const targetEnvIds = environmentId === "__all__" ? allEnvironmentIds : [environmentId]; + const existingSet = new Set(existingVars.map((v) => `${v.key}\0${v.environmentId}`)); + + const conflictIndices: number[] = []; + for (let i = 0; i < entries.length; i++) { + if (!entries[i].key) { + continue; + } + for (const envId of targetEnvIds) { + if (existingSet.has(`${entries[i].key}\0${envId}`)) { + conflictIndices.push(i); + break; + } + } + } + return conflictIndices; +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/use-drop-zone.ts b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/use-drop-zone.ts similarity index 54% rename from web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/use-drop-zone.ts rename to web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/use-drop-zone.ts index 3a5f91a0d4..81dcdf0810 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/use-drop-zone.ts +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/components/use-drop-zone.ts @@ -1,9 +1,9 @@ import { toast } from "@unkey/ui"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { UseFormGetValues, UseFormReset, UseFormTrigger } from "react-hook-form"; import type { EnvVarsFormValues } from "./schema"; -const parseEnvText = (text: string): Array<{ key: string; value: string; secret: boolean }> => { +export const parseEnvText = (text: string): Array<{ key: string; value: string }> => { const lines = text.trim().split("\n"); return lines .map((line) => { @@ -27,24 +27,81 @@ const parseEnvText = (text: string): Array<{ key: string; value: string; secret: value = value.slice(1, -1); } - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { - return null; - } - - return { key, value, secret: false }; + return { key, value }; }) .filter((v): v is NonNullable => v !== null); }; +const isEnvFile = (file: File) => + file.name.endsWith(".env") || file.type === "text/plain" || file.type === ""; + export function useDropZone( reset: UseFormReset, trigger: UseFormTrigger, getValues: UseFormGetValues, - defaultEnvironmentId: string, ) { const [isDragging, setIsDragging] = useState(false); const ref = useRef(null); + const importParsed = useCallback( + (parsed: Array<{ key: string; value: string }>) => { + // Deduplicate within the batch — last occurrence wins (standard .env behavior) + const deduped = new Map(); + for (const row of parsed) { + deduped.set(row.key, row); + } + + const allRows = getValues("envVars"); + const existing = allRows.filter( + (row) => row.key !== "" || row.value !== "" || (row.description ?? "") !== "", + ); + const existingKeys = new Set(allRows.filter((row) => row.key !== "").map((row) => row.key)); + + const newRows = [...deduped.values()].filter((row) => !existingKeys.has(row.key)); + const skipped = deduped.size - newRows.length; + + if (newRows.length === 0) { + toast.info("All variables already exist"); + return; + } + + reset( + { + ...getValues(), + envVars: [...existing, ...newRows.map((row) => ({ ...row, description: "" }))], + }, + { keepDefaultValues: true }, + ); + + if (skipped > 0) { + toast.success(`Imported ${newRows.length} variable(s), ${skipped} duplicate(s) skipped`); + } else { + toast.success(`Imported ${newRows.length} variable(s)`); + } + trigger(); + }, + [getValues, reset, trigger], + ); + + const importText = useCallback( + (text: string) => { + const parsed = parseEnvText(text); + if (parsed.length > 0) { + importParsed(parsed); + } else { + toast.error("No valid environment variables found"); + } + }, + [importParsed], + ); + + const importFile = useCallback( + async (file: File) => { + importText(await file.text()); + }, + [importText], + ); + useEffect(() => { const dropZone = ref.current; if (!dropZone) { @@ -52,6 +109,11 @@ export function useDropZone( } const handlePaste = async (e: ClipboardEvent) => { + const target = e.target; + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { + return; + } + const clipboardData = e.clipboardData; if (!clipboardData) { return; @@ -60,19 +122,9 @@ export function useDropZone( const files = clipboardData.files; if (files.length > 0) { const file = files[0]; - if (file.name.endsWith(".env") || file.type === "text/plain" || file.type === "") { + if (isEnvFile(file)) { e.preventDefault(); - const text = await file.text(); - const parsed = parseEnvText(text); - if (parsed.length > 0) { - const existing = getValues("envVars").filter((row) => row.key !== ""); - const newRows = parsed.map((row) => ({ ...row, environmentId: defaultEnvironmentId })); - reset({ envVars: [...existing, ...newRows] }, { keepDefaultValues: true }); - toast.success(`Imported ${parsed.length} variable(s)`); - trigger(); - } else { - toast.error("No valid environment variables found"); - } + importText(await file.text()); return; } } @@ -80,16 +132,7 @@ export function useDropZone( const text = clipboardData.getData("text/plain"); if (text?.includes("\n") && text?.includes("=")) { e.preventDefault(); - const parsed = parseEnvText(text); - if (parsed.length > 0) { - const existing = getValues("envVars").filter((row) => row.key !== ""); - const newRows = parsed.map((row) => ({ ...row, environmentId: defaultEnvironmentId })); - reset({ envVars: [...existing, ...newRows] }, { keepDefaultValues: true }); - toast.success(`Imported ${parsed.length} variable(s)`); - trigger(); - } else { - toast.error("No valid environment variables found"); - } + importText(text); } }; @@ -107,7 +150,11 @@ export function useDropZone( const handleDragLeave = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); - if (e.currentTarget === dropZone && !dropZone.contains(e.relatedTarget as Node)) { + const related = e.relatedTarget; + if ( + e.currentTarget === dropZone && + !(related instanceof Node && dropZone.contains(related)) + ) { setIsDragging(false); } }; @@ -123,18 +170,8 @@ export function useDropZone( } const file = files[0]; - if (file.name.endsWith(".env") || file.type === "text/plain" || file.type === "") { - const text = await file.text(); - const parsed = parseEnvText(text); - if (parsed.length > 0) { - const existing = getValues("envVars").filter((row) => row.key !== ""); - const newRows = parsed.map((row) => ({ ...row, environmentId: defaultEnvironmentId })); - reset({ envVars: [...existing, ...newRows] }, { keepDefaultValues: true }); - toast.success(`Imported ${parsed.length} variable(s)`); - trigger(); - } else { - toast.error("No valid environment variables found"); - } + if (isEnvFile(file)) { + importText(await file.text()); } else { toast.error("Please drop a .env or text file"); } @@ -153,7 +190,7 @@ export function useDropZone( dropZone.removeEventListener("dragleave", handleDragLeave); dropZone.removeEventListener("drop", handleDrop); }; - }, [reset, getValues, defaultEnvironmentId, trigger]); + }, [importText]); - return { ref, isDragging }; + return { ref, isDragging, importFile }; } diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/page.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/page.tsx new file mode 100644 index 0000000000..6c73c23f1c --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/env-vars/page.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { useState } from "react"; +import { ProjectContentWrapper } from "../../components/project-content-wrapper"; +import { useProjectData } from "../data-provider"; +import { useProjectLayout } from "../layout-provider"; +import { PendingRedeployBanner } from "../settings/pending-redeploy-banner"; +import { AddEnvVarExpandable } from "./components/add-env-var-expandable"; +import { EnvVarsHeader } from "./components/env-vars-header"; +import { EnvVarsList } from "./components/env-vars-list"; +import { + EnvVarsToolbar, + type EnvironmentFilter, + type SortOption, +} from "./components/env-vars-toolbar"; + +export default function EnvVarsPage() { + const { projectId, environments } = useProjectData(); + const { tableDistanceToTop } = useProjectLayout(); + const [searchQuery, setSearchQuery] = useState(""); + const [environmentFilter, setEnvironmentFilter] = useState("all"); + const [sortBy, setSortBy] = useState("last-updated"); + const [isAddOpen, setIsAddOpen] = useState(false); + + return ( + <> + +
+ setIsAddOpen((prev) => !prev)} /> + setIsAddOpen(false)} + /> + + +
+
+ + + ); +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/layout-provider.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/layout-provider.tsx index 500e63f4c4..bdd2707975 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/layout-provider.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/layout-provider.tsx @@ -3,6 +3,7 @@ import { createContext, useContext } from "react"; type ProjectLayoutContextType = { isDetailsOpen: boolean; setIsDetailsOpen: (open: boolean) => void; + tableDistanceToTop: number; }; export const ProjectLayoutContext = createContext(null); diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/navigations/create-deployment-button.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/navigations/create-deployment-button.tsx index 996606bbaf..09c0bb754b 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/navigations/create-deployment-button.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/navigations/create-deployment-button.tsx @@ -144,9 +144,14 @@ export const CreateDeploymentButton = ({ return ( <> - setIsOpen(true)}> - - Create new deployment + setIsOpen(true)} + > + - +
diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/navigations/use-breadcrumb-config.ts b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/navigations/use-breadcrumb-config.ts index d78dd80cf2..32250c00bc 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/navigations/use-breadcrumb-config.ts +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/navigations/use-breadcrumb-config.ts @@ -66,6 +66,12 @@ export const useBreadcrumbConfig = ({ href: `${basePath}/${projectId}/logs`, segment: "logs", }, + { + id: "env-vars", + label: "Environment Variables", + href: `${basePath}/${projectId}/env-vars`, + segment: "env-vars", + }, { id: "settings", label: "Settings", diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/env-var-row.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/env-var-row.tsx deleted file mode 100644 index b495da6dcb..0000000000 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/env-var-row.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { cn } from "@/lib/utils"; -import { ChevronDown, Eye, EyeSlash, Plus } from "@unkey/icons"; -import { - Button, - FormCheckbox, - FormInput, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@unkey/ui"; -import { memo, useState } from "react"; -import { - type Control, - Controller, - type UseFormRegister, - type UseFormTrigger, - useWatch, -} from "react-hook-form"; -import { RemoveButton } from "../../shared/remove-button"; -import type { EnvVarsFormValues } from "./schema"; -import type { EnvVarItem } from "./utils"; - -type EnvVarRowProps = { - index: number; - isFirst: boolean; - isOnly: boolean; - keyError: string | undefined; - environmentError: string | undefined; - defaultEnvVars: EnvVarItem[]; - environments: { id: string; slug: string }[]; - control: Control; - register: UseFormRegister; - trigger: UseFormTrigger; - onAdd: () => void; - onRemove: (index: number) => void; -}; - -export const EnvVarRow = memo(function EnvVarRow({ - index, - isFirst, - isOnly, - keyError, - environmentError, - defaultEnvVars, - environments, - control, - register, - trigger, - onAdd, - onRemove, -}: EnvVarRowProps) { - const [isVisible, setIsVisible] = useState(false); - - // Watch this specific row's data - fixes index shift bug on delete - const currentVar = useWatch({ control, name: `envVars.${index}` }); - const isSecret = currentVar?.secret ?? false; - const isPreviouslyAdded = Boolean( - currentVar?.id && defaultEnvVars.some((v) => v.id === currentVar.id && v.key !== ""), - ); - - const inputType = isPreviouslyAdded ? (isVisible ? "text" : "password") : "text"; - - const eyeButton = - isPreviouslyAdded && !isSecret ? ( - - ) : undefined; - - return ( -
-
- ( - - )} - /> -
- - -
- ( - - )} - /> -
-
- onRemove(index)} - className={cn( - "absolute left-0 transition-opacity duration-150", - isOnly ? "opacity-0 pointer-events-none" : "opacity-100", - )} - /> - -
-
- ); -}); diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/index.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/index.tsx deleted file mode 100644 index c1c36e78f9..0000000000 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/index.tsx +++ /dev/null @@ -1,330 +0,0 @@ -"use client"; - -import { collection } from "@/lib/collections"; -import { cn } from "@/lib/utils"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { eq, useLiveQuery } from "@tanstack/react-db"; -import { useVirtualizer } from "@tanstack/react-virtual"; -import { Nodes2 } from "@unkey/icons"; -import { FormInput, toast } from "@unkey/ui"; -import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; -import { useFieldArray, useForm } from "react-hook-form"; -import { useProjectData } from "../../../../data-provider"; -import { useEnvironmentSettings } from "../../../environment-provider"; -import { WideContent } from "../../shared/form-blocks"; -import { FormSettingCard, resolveSaveState } from "../../shared/form-setting-card"; -import { EnvVarRow } from "./env-var-row"; -import { type EnvVarsFormValues, createEmptyRow, envVarsSchema } from "./schema"; -import { useDecryptedValues } from "./use-decrypted-values"; -import { useDropZone } from "./use-drop-zone"; -import { computeEnvVarsDiff, groupByEnvironment, toTrpcType } from "./utils"; - -export const EnvVars = () => { - const { projectId, environments } = useProjectData(); - const { settings } = useEnvironmentSettings(); - const defaultEnvironmentId = settings.environmentId; - - const { data: envVarData } = useLiveQuery( - (q) => q.from({ v: collection.envVars }).where(({ v }) => eq(v.projectId, projectId)), - [projectId], - ); - - const allVariables = useMemo(() => { - if (!envVarData) { - return []; - } - return [...envVarData] - .sort((a, b) => b.createdAt - a.createdAt) - .map((v) => ({ - id: v.id, - key: v.key, - type: v.type, - environmentId: v.environmentId, - createdAt: v.createdAt, - })); - }, [envVarData]); - - const { decryptedValues, isDecrypting } = useDecryptedValues(allVariables); - - const defaultValues = useMemo(() => { - if (allVariables.length === 0) { - return { envVars: [createEmptyRow(defaultEnvironmentId)] }; - } - return { - envVars: allVariables.map((v) => ({ - id: v.id, - environmentId: v.environmentId, - key: v.key, - value: v.type === "writeonly" ? "" : (decryptedValues[v.id] ?? ""), - secret: v.type === "writeonly", - })), - }; - }, [allVariables, decryptedValues, defaultEnvironmentId]); - - return ( - - ); -}; - -const ROW_HEIGHT = 44; // h-9 (36px) + gap-2 (8px) -const EnvVarsForm = ({ - defaultValues, - defaultEnvironmentId, - environments, - projectId, - isDecrypting, -}: { - defaultValues: EnvVarsFormValues; - defaultEnvironmentId: string; - environments: { id: string; slug: string }[]; - projectId: string; - isDecrypting: boolean; -}) => { - const { - register, - handleSubmit, - formState: { isValid, isSubmitting, errors, isDirty }, - control, - reset, - trigger, - getValues, - } = useForm({ - resolver: zodResolver(envVarsSchema), - mode: "onChange", - defaultValues, - }); - - const prevDefaultsRef = useRef(defaultValues); - - useEffect(() => { - if (prevDefaultsRef.current !== defaultValues) { - prevDefaultsRef.current = defaultValues; - reset(defaultValues); - } - }, [defaultValues, reset]); - - const { ref, isDragging } = useDropZone(reset, trigger, getValues, defaultEnvironmentId); - - const { fields, prepend, remove } = useFieldArray({ control, name: "envVars" }); - - const [searchQuery, setSearchQuery] = useState(""); - const deferredSearch = useDeferredValue(searchQuery); - - const filteredIndices = useMemo(() => { - if (!deferredSearch) { - return fields.map((_, i) => i); - } - const query = deferredSearch.toLowerCase(); - return fields.reduce((acc, _, i) => { - const values = getValues(`envVars.${i}`); - if (values?.key?.toLowerCase().includes(query)) { - acc.push(i); - } - return acc; - }, []); - }, [fields, deferredSearch, getValues]); - - const scrollContainerRef = useRef(null); - const triggerTimerRef = useRef>(undefined); - - const debouncedTrigger = useCallback(() => { - clearTimeout(triggerTimerRef.current); - triggerTimerRef.current = setTimeout(() => { - trigger("envVars"); - }, 100); - }, [trigger]); - - useEffect(() => { - return () => clearTimeout(triggerTimerRef.current); - }, []); - - const virtualizer = useVirtualizer({ - count: filteredIndices.length, - getScrollElement: () => scrollContainerRef.current, - estimateSize: () => ROW_HEIGHT, - overscan: 5, - }); - - const handleAdd = useCallback(() => { - prepend(createEmptyRow(defaultEnvironmentId)); - }, [prepend, defaultEnvironmentId]); - - const handleRemove = useCallback( - (index: number) => { - remove(index); - debouncedTrigger(); - }, - [remove, debouncedTrigger], - ); - - const onSubmit = async (values: EnvVarsFormValues) => { - const { toDelete, toCreate, toUpdate } = computeEnvVarsDiff( - defaultValues.envVars, - values.envVars, - ); - - const createsByEnv = groupByEnvironment(toCreate); - - toast.promise( - Promise.all([ - ...toDelete.map(async (id) => { - collection.envVars.delete(id); - }), - ...[...createsByEnv.entries()].map(async ([envId, vars]) => { - for (const v of vars) { - collection.envVars.insert({ - id: crypto.randomUUID(), - environmentId: envId, - projectId, - key: v.key, - value: v.value, - type: toTrpcType(v.secret) as "recoverable" | "writeonly", - description: null, - createdAt: Date.now(), - }); - } - }), - ...toUpdate.map(async (v) => { - collection.envVars.update(v.id as string, (draft) => { - draft.key = v.key; - draft.value = v.value; - draft.type = toTrpcType(v.secret) as "recoverable" | "writeonly"; - }); - }), - ]), - { - loading: "Saving environment variable(s)...", - success: `Saved ${toDelete.length + toCreate.length + toUpdate.length} variable(s)`, - error: (err) => ({ - message: "Failed to save environment variable(s)", - description: err instanceof Error ? err.message : "An unexpected error occurred", - }), - }, - ); - }; - - const saveState = resolveSaveState([ - [isSubmitting, { status: "saving" }], - [isDecrypting, { status: "disabled", reason: "Decrypting values…" }], - [!isValid, { status: "disabled", reason: "Fix validation errors above" }], - [!isDirty, { status: "disabled", reason: "No changes to save" }], - ]); - - const varCount = defaultValues.envVars.filter((v) => v.key !== "").length; - const displayValue = - varCount === 0 ? null : ( -
- {varCount} - variable{varCount !== 1 ? "s" : ""} -
- ); - - return ( - } - title="Environment Variables" - description="Set environment variables available at runtime. Changes apply on next deploy." - displayValue={displayValue} - onSubmit={handleSubmit(onSubmit)} - saveState={saveState} - ref={ref} - contentRef={scrollContainerRef} - className={cn("relative", isDragging && "bg-primary/5")} - stickyHeader={ - fields.length > 1 ? ( -
-

- Drag & drop your .env file - or paste env vars (⌘V / - Ctrl+V) -

- setSearchQuery(e.target.value)} - className="[&_input]:h-8 [&_input]:text-xs mb-2" - /> -
- ) : undefined - } - > - -
-
- {fields.length <= 1 && ( -

- Drag & drop your .env file - or paste env vars (⌘V / - Ctrl+V) -

- )} - -
-
- Environment - Key - Value - Sensitive -
-
- -
- {filteredIndices.length === 0 && searchQuery ? ( -
-

- No variables matching “{searchQuery}” -

-
- ) : ( - virtualizer.getVirtualItems().map((virtualRow) => { - const fieldIndex = filteredIndices[virtualRow.index]; - const field = fields[fieldIndex]; - const index = fieldIndex; - return ( -
- -
- ); - }) - )} -
-
-
- - - ); -}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/schema.ts b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/schema.ts deleted file mode 100644 index b171662d9e..0000000000 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/schema.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { z } from "zod"; - -export const envVarEntrySchema = z.object({ - id: z.string().optional(), - environmentId: z.string().min(1, "Environment is required"), - key: z.string().min(1, "Key is required"), - value: z.string(), - secret: z.boolean(), -}); - -export const envVarsSchema = z.object({ - envVars: z - .array(envVarEntrySchema) - .min(1) - .superRefine((vars, ctx) => { - const groups = new Map(); - for (let i = 0; i < vars.length; i++) { - const v = vars[i]; - if (!v.key) { - continue; - } - const compound = `${v.environmentId}::${v.key}`; - const indices = groups.get(compound); - if (indices) { - indices.push(i); - } else { - groups.set(compound, [i]); - } - } - for (const indices of groups.values()) { - if (indices.length > 1) { - for (const i of indices) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Duplicate key in the same environment", - path: [i, "key"], - }); - } - } - } - }), -}); - -export type EnvVarsFormValues = z.infer; - -export function createEmptyRow(environmentId: string): EnvVarsFormValues["envVars"][number] { - return { - key: "", - value: "", - secret: false, - environmentId, - }; -} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/use-decrypted-values.ts b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/use-decrypted-values.ts deleted file mode 100644 index 8667a78ff0..0000000000 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/use-decrypted-values.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { trpc } from "@/lib/trpc/client"; -import { useEffect, useMemo, useState } from "react"; - -export type EnvVariable = { - id: string; - key: string; - type: "writeonly" | "recoverable"; -}; - -export function useDecryptedValues(variables: EnvVariable[]) { - const decryptMutation = trpc.deploy.envVar.decrypt.useMutation(); - const [decryptedValues, setDecryptedValues] = useState>({}); - const [isDecrypting, setIsDecrypting] = useState(false); - - const variableFingerprint = useMemo( - () => - variables - .map((v) => v.id) - .sort() - .join(","), - [variables], - ); - - // biome-ignore lint/correctness/useExhaustiveDependencies: its safe to keep - useEffect(() => { - if (variables.length === 0) { - return; - } - - const recoverableVars = variables.filter((v) => v.type === "recoverable"); - if (recoverableVars.length === 0) { - return; - } - - setIsDecrypting(true); - Promise.all( - recoverableVars.map((v) => - decryptMutation.mutateAsync({ envVarId: v.id }).then((r) => [v.id, r.value] as const), - ), - ) - .then((entries) => { - setDecryptedValues(Object.fromEntries(entries)); - }) - .finally(() => { - setIsDecrypting(false); - }); - }, [variableFingerprint]); - - return { decryptedValues, isDecrypting }; -} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/utils.ts b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/utils.ts deleted file mode 100644 index f17ce5f410..0000000000 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/utils.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { EnvVarsFormValues } from "./schema"; - -export type EnvVarItem = EnvVarsFormValues["envVars"][number]; - -export const toTrpcType = (secret: boolean) => (secret ? "writeonly" : "recoverable"); - -export function computeEnvVarsDiff(original: EnvVarItem[], current: EnvVarItem[]) { - const originalVars = original.filter((v) => v.id); - const originalIds = new Set(originalVars.map((v) => v.id as string)); - const originalMap = new Map(originalVars.map((v) => [v.id as string, v])); - - const currentIds = new Set(current.filter((v) => v.id).map((v) => v.id as string)); - - const toDelete = [...originalIds].filter((id) => !currentIds.has(id)); - - const toCreate = current.filter((v) => !v.id && v.key !== "" && v.value !== ""); - - const toUpdate = current.filter((v) => { - if (!v.id) { - return false; - } - const orig = originalMap.get(v.id); - if (!orig) { - return false; - } - if (v.value === "") { - return false; - } - return v.key !== orig.key || v.value !== orig.value || v.secret !== orig.secret; - }); - - return { toDelete, toCreate, toUpdate, originalMap }; -} - -export function groupByEnvironment(items: EnvVarItem[]): Map { - const map = new Map(); - for (const item of items) { - const existing = map.get(item.environmentId); - if (existing) { - existing.push(item); - } else { - map.set(item.environmentId, [item]); - } - } - return map; -} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/deployment-settings.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/deployment-settings.tsx index aef08ae5f9..ade6bb983d 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/deployment-settings.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/deployment-settings.tsx @@ -16,7 +16,6 @@ import { Port } from "./components/runtime-settings/port-settings"; import { Regions } from "./components/runtime-settings/regions"; import { CustomDomains } from "./components/advanced-settings/custom-domains"; -import { EnvVars } from "./components/advanced-settings/env-vars"; import { OpenapiSpecPath } from "./components/advanced-settings/openapi-spec-path"; import { Keyspaces } from "./components/sentinel-settings/keyspaces"; @@ -69,7 +68,6 @@ export const DeploymentSettings = ({ defaultExpanded={Boolean(sections.advanced)} > - diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/pending-redeploy-banner.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/pending-redeploy-banner.tsx index dfb7399766..8efb78991b 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/pending-redeploy-banner.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/pending-redeploy-banner.tsx @@ -61,9 +61,9 @@ export function PendingRedeployBanner() {
- Settings changed + Changes detected - Redeploy to apply your latest settings to production. + Redeploy to apply your latest changes to production.