Environment Variables Manager
blockdevopsVercel and Supabase style environment variables and secrets manager with environment scoping, mask/reveal toggles, bulk import, and inline editing.
Installation
$pnpm dlx shadcn@latest add https://uipkge.dev/r/react/environment-variables-manager.json$npx shadcn@latest add https://uipkge.dev/r/react/environment-variables-manager.json$yarn dlx shadcn@latest add https://uipkge.dev/r/react/environment-variables-manager.json$bunx shadcn@latest add https://uipkge.dev/r/react/environment-variables-manager.jsonNamed registry:
npx shadcn@latest add @uipkge-react/environment-variables-managerInstalls to:components/blocks/Variants
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
initialVariables | EnvironmentVariable[] | — | optional |
initialAddOpen | boolean | — | optional |
initialImportOpen | boolean | — | optional |
defaultEnvironment | 'all' | EnvironmentScope | — | optional |
className | string | — | optional |
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
EnvironmentVariableinterface EnvironmentVariable {
id: string
key: string
value: string
environments: EnvironmentScope[]
isSecret: boolean
updatedAt: string
updatedBy: string
}Files installed (1)
components/blocks/EnvironmentVariablesManager.tsx40.1 kB
'use client' import * as React from 'react' import { AlertCircle, Check, Copy, Download, Eye, EyeOff, FileCode, KeyRound, Lock, MoreHorizontal, Pencil, Plus, Search, ShieldCheck, Trash2, Upload, X, } from 'lucide-react' import { cn } from '@/lib/utils' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Checkbox } from '@/components/ui/checkbox' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Input } from '@/components/ui/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Textarea } from '@/components/ui/textarea' export type EnvironmentScope = 'production' | 'preview' | 'development' export type EnvVarTypeFilter = 'all' | 'secret' | 'plain' export interface EnvironmentVariable { id: string key: string value: string environments: EnvironmentScope[] isSecret: boolean updatedAt: string updatedBy: string } const defaultVariables: EnvironmentVariable[] = [ { id: 'env-1', key: 'DATABASE_URL', value: 'postgresql://postgres:p4ssw0rd_secure_vault@db.prod.aws.internal:5432/primary_db', environments: ['production', 'preview', 'development'], isSecret: true, updatedAt: 'Updated 2h ago', updatedBy: 'alex.chen', }, { id: 'env-2', key: 'STRIPE_SECRET_KEY', value: 'mock_key_51NwY2xK9mPqL8vR4tZa0bCeFgHiJkLmNoPqRsTuVwXyZ', environments: ['production'], isSecret: true, updatedAt: 'Updated 3d ago', updatedBy: 'sarah.dev', }, { id: 'env-3', key: 'NEXT_PUBLIC_APP_URL', value: 'https://app.uipkge.dev', environments: ['production', 'preview', 'development'], isSecret: false, updatedAt: 'Updated 5d ago', updatedBy: 'uday.craft', }, { id: 'env-4', key: 'REDIS_PASSWORD', value: 'redis_auth_98f4b6201e9d4a87b32c', environments: ['production', 'preview'], isSecret: true, updatedAt: 'Updated 1w ago', updatedBy: 'alex.chen', }, { id: 'env-5', key: 'SENTRY_DSN', value: 'https://o45089@sentry.io/45089234871923', environments: ['production', 'preview', 'development'], isSecret: false, updatedAt: 'Updated 2w ago', updatedBy: 'sarah.dev', }, { id: 'env-6', key: 'AWS_SECRET_ACCESS_KEY', value: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', environments: ['production'], isSecret: true, updatedAt: 'Updated 1mo ago', updatedBy: 'infra-bot', }, ] export interface EnvironmentVariablesManagerProps { initialVariables?: EnvironmentVariable[] initialAddOpen?: boolean initialImportOpen?: boolean defaultEnvironment?: 'all' | EnvironmentScope className?: string } function maskString(val: string): string { if (val.length <= 12) return '••••••••••••' return '••••••••••••••••••••' } export function EnvironmentVariablesManager({ initialVariables, initialAddOpen = false, initialImportOpen = false, defaultEnvironment = 'all', className, }: EnvironmentVariablesManagerProps) { const [variables, setVariables] = React.useState<EnvironmentVariable[]>(() => initialVariables ? initialVariables.map((v) => ({ ...v })) : defaultVariables.map((v) => ({ ...v })), ) const [activeTab, setActiveTab] = React.useState<'all' | EnvironmentScope>(defaultEnvironment) const [searchQuery, setSearchQuery] = React.useState('') const [typeFilter, setTypeFilter] = React.useState<EnvVarTypeFilter>('all') const [revealAll, setRevealAll] = React.useState(false) const [revealedMap, setRevealedMap] = React.useState<Record<string, boolean>>({}) const [isAddOpen, setIsAddOpen] = React.useState(initialAddOpen) const [isImportOpen, setIsImportOpen] = React.useState(initialImportOpen) const [editingVariable, setEditingVariable] = React.useState<EnvironmentVariable | null>(null) // Add variable state const [newKey, setNewKey] = React.useState('') const [newValue, setNewValue] = React.useState('') const [newIsSecret, setNewIsSecret] = React.useState(true) const [newEnvs, setNewEnvs] = React.useState<EnvironmentScope[]>(['production', 'preview', 'development']) // Import state const [importText, setImportText] = React.useState('') const [importEnvs, setImportEnvs] = React.useState<EnvironmentScope[]>(['production', 'preview', 'development']) const [importEncryptAll, setImportEncryptAll] = React.useState(true) // Copy state const [copiedId, setCopiedId] = React.useState<string | null>(null) const copyTimer = React.useRef<number | undefined>(undefined) const filteredVariables = React.useMemo(() => { return variables.filter((item) => { // Environment filter if (activeTab !== 'all' && !item.environments.includes(activeTab)) { return false } // Type filter if (typeFilter === 'secret' && !item.isSecret) return false if (typeFilter === 'plain' && item.isSecret) return false // Search query if (searchQuery.trim()) { const q = searchQuery.toLowerCase().trim() const keyMatch = item.key.toLowerCase().includes(q) const authorMatch = item.updatedBy.toLowerCase().includes(q) if (!keyMatch && !authorMatch) return false } return true }) }, [variables, activeTab, typeFilter, searchQuery]) const counts = React.useMemo(() => { return { all: variables.length, production: variables.filter((v) => v.environments.includes('production')).length, preview: variables.filter((v) => v.environments.includes('preview')).length, development: variables.filter((v) => v.environments.includes('development')).length, } }, [variables]) function isRevealed(id: string): boolean { if (revealAll) return true return Boolean(revealedMap[id]) } function toggleReveal(id: string) { setRevealedMap((prev) => ({ ...prev, [id]: !prev[id] })) } function toggleRevealAll() { setRevealAll((prev) => { const next = !prev if (!next) setRevealedMap({}) return next }) } async function copyValue(text: string, id: string) { try { await navigator.clipboard.writeText(text) setCopiedId(id) window.clearTimeout(copyTimer.current) copyTimer.current = window.setTimeout(() => { setCopiedId(null) }, 1600) } catch { // Clipboard fallback } } function toggleNewEnv(env: EnvironmentScope) { setNewEnvs((prev) => { if (prev.includes(env)) { return prev.length > 1 ? prev.filter((e) => e !== env) : prev } return [...prev, env] }) } function resetAddForm() { setNewKey('') setNewValue('') setNewIsSecret(true) setNewEnvs(['production', 'preview', 'development']) setIsAddOpen(false) } function handleAddVariable() { const key = newKey.trim().toUpperCase() const val = newValue.trim() if (!key || !val) return const newVar: EnvironmentVariable = { id: `env-${Date.now()}`, key, value: val, environments: [...newEnvs], isSecret: newIsSecret, updatedAt: 'Just now', updatedBy: 'you', } setVariables((prev) => [newVar, ...prev]) resetAddForm() } function handleDuplicate(item: EnvironmentVariable) { const duplicateVar: EnvironmentVariable = { ...item, id: `env-${Date.now()}`, key: `${item.key}_COPY`, updatedAt: 'Just now', updatedBy: 'you', } setVariables((prev) => { const index = prev.findIndex((v) => v.id === item.id) if (index >= 0) { const next = [...prev] next.splice(index + 1, 0, duplicateVar) return next } return [duplicateVar, ...prev] }) } function handleDelete(id: string) { setVariables((prev) => prev.filter((v) => v.id !== id)) } function openEdit(item: EnvironmentVariable) { setEditingVariable({ ...item, environments: [...item.environments], }) } function saveEdit() { if (!editingVariable) return const key = editingVariable.key.trim().toUpperCase() const val = editingVariable.value.trim() if (!key || !val) return setVariables((prev) => prev.map((v) => v.id === editingVariable.id ? { ...editingVariable, key, value: val, updatedAt: 'Just now', updatedBy: 'you', } : v, ), ) setEditingVariable(null) } function toggleEditEnv(env: EnvironmentScope) { if (!editingVariable) return const current = editingVariable.environments const next = current.includes(env) ? current.length > 1 ? current.filter((e) => e !== env) : current : [...current, env] setEditingVariable({ ...editingVariable, environments: next }) } function toggleImportEnv(env: EnvironmentScope) { setImportEnvs((prev) => { if (prev.includes(env)) { return prev.length > 1 ? prev.filter((e) => e !== env) : prev } return [...prev, env] }) } const parsedImportItems = React.useMemo(() => { if (!importText.trim()) return [] const lines = importText.split('\n') const results: { key: string; value: string; isSecret: boolean }[] = [] for (const rawLine of lines) { const line = rawLine.trim() if (!line || line.startsWith('#')) continue const eqIdx = line.indexOf('=') if (eqIdx > 0) { const k = line.slice(0, eqIdx).trim().toUpperCase() let v = line.slice(eqIdx + 1).trim() if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) { v = v.slice(1, -1) } if (k) { const isSecret = importEncryptAll || k.includes('SECRET') || k.includes('KEY') || k.includes('PASSWORD') || k.includes('TOKEN') || k.includes('PRIVATE') || k.includes('AUTH') results.push({ key: k, value: v, isSecret }) } } } return results }, [importText, importEncryptAll]) function handleImport() { const items = parsedImportItems if (items.length === 0) return const newVars: EnvironmentVariable[] = items.map((item, index) => ({ id: `env-imported-${Date.now()}-${index}`, key: item.key, value: item.value, environments: [...importEnvs], isSecret: item.isSecret, updatedAt: 'Imported just now', updatedBy: 'you', })) setVariables((prev) => [...newVars, ...prev]) setImportText('') setIsImportOpen(false) } function exportEnvFile() { const lines = variables.map((v) => `# ${v.environments.join(', ')}\n${v.key}=${v.value}`) const blob = new Blob([lines.join('\n\n')], { type: 'text/plain;charset=utf-8' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = '.env.production' a.click() URL.revokeObjectURL(url) } return ( <div data-slot="environment-variables-manager" className={cn('w-full space-y-6', className)}> {/* Header Section */} <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div className="space-y-1"> <div className="flex items-center gap-2.5"> <div className="bg-primary/10 text-primary grid size-8 place-items-center rounded-lg border"> <KeyRound className="size-4" /> </div> <h2 className="text-xl font-semibold tracking-tight sm:text-2xl">Environment Variables</h2> </div> <p className="text-muted-foreground text-sm"> Manage encrypted secrets, API keys, and configuration for your deployments. </p> </div> <div className="flex flex-wrap items-center gap-2"> <Button variant="outline" size="sm" className="gap-1.5 shadow-xs" onClick={() => setIsImportOpen(true)}> <Upload className="size-3.5" /> <span>Import .env</span> </Button> <Button aria-label="Download attachment" variant="outline" size="sm" className="gap-1.5 shadow-xs" onClick={exportEnvFile} > <Download className="size-3.5" /> <span>Export</span> </Button> <Button size="sm" className="gap-1.5 shadow-xs" variant={isAddOpen ? 'secondary' : 'default'} onClick={() => setIsAddOpen(!isAddOpen)} > {!isAddOpen ? <Plus className="size-4" /> : <X className="size-4" />} <span>{isAddOpen ? 'Close Form' : 'Add Variable'}</span> </Button> </div> </div> {/* Add Variable Expandable Card */} {isAddOpen && ( <Card className="border-primary/30 bg-card/60 relative shadow-sm"> <CardHeader className="pb-3"> <div className="flex items-center justify-between"> <div> <CardTitle className="text-base font-semibold">New Environment Variable</CardTitle> <CardDescription className="text-xs"> Add a new secret key or configuration parameter to your environment scopes. </CardDescription> </div> <Button variant="ghost" size="icon-sm" aria-label="Cancel adding variable" onClick={resetAddForm}> <X className="size-4" /> </Button> </div> </CardHeader> <CardContent className="space-y-4"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <div className="space-y-1.5"> <label className="text-foreground text-xs font-medium">Variable Key</label> <Input value={newKey} onChange={(e) => setNewKey(e.target.value)} placeholder="e.g. STRIPE_SECRET_KEY" className="font-mono text-xs uppercase" onKeyDown={(e) => e.key === 'Enter' && handleAddVariable()} /> <p className="text-muted-foreground text-xs">Uppercase characters and underscores recommended.</p> </div> <div className="space-y-1.5"> <label className="text-foreground text-xs font-medium">Variable Value</label> <Input value={newValue} onChange={(e) => setNewValue(e.target.value)} type={newIsSecret ? 'password' : 'text'} placeholder="Enter secret token or value..." className="font-mono text-xs" onKeyDown={(e) => e.key === 'Enter' && handleAddVariable()} /> <p className="text-muted-foreground text-xs">Values are encrypted at rest with AES-256-GCM.</p> </div> </div> <div className="flex flex-col gap-4 pt-1 sm:flex-row sm:items-center sm:justify-between"> <div className="space-y-2"> <span className="text-foreground text-xs font-medium">Environment Scopes</span> <div className="flex flex-wrap items-center gap-3"> <label className={cn( 'hover:bg-accent/50 flex cursor-pointer items-center gap-2 rounded-md border px-2.5 py-1.5 text-xs transition-colors select-none', newEnvs.includes('production') && 'border-success/40 bg-success/5', )} > <Checkbox checked={newEnvs.includes('production')} onCheckedChange={() => toggleNewEnv('production')} /> <span className="text-success font-medium">Production</span> </label> <label className={cn( 'hover:bg-accent/50 flex cursor-pointer items-center gap-2 rounded-md border px-2.5 py-1.5 text-xs transition-colors select-none', newEnvs.includes('preview') && 'border-info/40 bg-info/5', )} > <Checkbox checked={newEnvs.includes('preview')} onCheckedChange={() => toggleNewEnv('preview')} /> <span className="text-info font-medium">Preview</span> </label> <label className={cn( 'hover:bg-accent/50 flex cursor-pointer items-center gap-2 rounded-md border px-2.5 py-1.5 text-xs transition-colors select-none', newEnvs.includes('development') && 'border-chart-2/40 bg-chart-2/5', )} > <Checkbox checked={newEnvs.includes('development')} onCheckedChange={() => toggleNewEnv('development')} /> <span className="text-chart-2 font-medium">Development</span> </label> </div> </div> <div className="flex items-center gap-2"> <Checkbox id="react-new-is-secret" checked={newIsSecret} onCheckedChange={(c) => setNewIsSecret(Boolean(c))} /> <label htmlFor="react-new-is-secret" className="cursor-pointer text-xs font-medium select-none"> Encrypt as Secret </label> </div> </div> <div className="flex items-center justify-end gap-2 border-t pt-3"> <Button aria-label="Cancel adding variable" variant="ghost" size="sm" onClick={resetAddForm}> Cancel </Button> <Button size="sm" disabled={!newKey.trim() || !newValue.trim()} onClick={handleAddVariable}> Save Variable </Button> </div> </CardContent> </Card> )} {/* Environment Tabs & Search / Filter Controls */} <div className="space-y-3"> <Tabs value={activeTab} onValueChange={(val) => setActiveTab(val as 'all' | EnvironmentScope)} className="w-full" > <TabsList className="w-full justify-start overflow-x-auto sm:w-auto"> <TabsTrigger value="all" className="text-xs"> All Environments <Badge variant="secondary" className="ml-1.5 px-1.5 py-0 text-xs font-semibold"> {counts.all} </Badge> </TabsTrigger> <TabsTrigger value="production" className="text-xs"> Production <Badge variant="secondary" className="ml-1.5 px-1.5 py-0 text-xs font-semibold"> {counts.production} </Badge> </TabsTrigger> <TabsTrigger value="preview" className="text-xs"> Preview <Badge variant="secondary" className="ml-1.5 px-1.5 py-0 text-xs font-semibold"> {counts.preview} </Badge> </TabsTrigger> <TabsTrigger value="development" className="text-xs"> Development <Badge variant="secondary" className="ml-1.5 px-1.5 py-0 text-xs font-semibold"> {counts.development} </Badge> </TabsTrigger> </TabsList> </Tabs> <div className="flex flex-col gap-2.5 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-1 flex-wrap items-center gap-2"> <div className="relative max-w-sm min-w-[200px] flex-1"> <Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2" /> <Input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="Search variables by key..." className="h-8.5 pl-8 text-xs shadow-xs" /> </div> <Select value={typeFilter} onValueChange={(val) => setTypeFilter(val as EnvVarTypeFilter)}> <SelectTrigger className="h-8.5 w-[140px] text-xs shadow-xs"> <SelectValue placeholder="All types" /> </SelectTrigger> <SelectContent> <SelectItem value="all">All Types</SelectItem> <SelectItem value="secret">Encrypted Secrets</SelectItem> <SelectItem value="plain">Plaintext</SelectItem> </SelectContent> </Select> </div> <div className="flex items-center gap-2"> <Button variant="outline" size="sm" className={cn('h-8.5 gap-1.5 text-xs shadow-xs', revealAll && 'border-primary text-primary')} onClick={toggleRevealAll} > {revealAll ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />} <span>{revealAll ? 'Hide All' : 'Reveal All Values'}</span> </Button> </div> </div> </div> {/* Variables Table */} <div className="bg-card overflow-hidden rounded-lg border shadow-xs"> <div className="overflow-x-auto"> <Table> <TableHeader> <TableRow className="bg-muted/40 hover:bg-muted/40"> <TableHead className="text-muted-foreground text-xs font-medium">Variable Key</TableHead> <TableHead className="text-muted-foreground text-xs font-medium">Value</TableHead> <TableHead className="text-muted-foreground text-xs font-medium">Environments</TableHead> <TableHead className="text-muted-foreground text-xs font-medium">Type</TableHead> <TableHead className="text-muted-foreground hidden text-xs font-medium md:table-cell"> Updated </TableHead> <TableHead className="w-[70px] text-right text-xs font-medium"> <span className="sr-only">Actions</span> </TableHead> </TableRow> </TableHeader> <TableBody> {filteredVariables.map((item) => ( <TableRow key={item.id} className="group hover:bg-muted/30 transition-colors"> {/* Key */} <TableCell className="py-3.5"> <div className="flex items-center gap-2"> <span className="text-foreground font-mono text-xs font-semibold tracking-tight select-all sm:text-sm"> {item.key} </span> <button type="button" aria-label="Copy variable key" className="text-muted-foreground hover:text-foreground opacity-0 transition-opacity group-hover:opacity-100 focus:opacity-100" onClick={() => copyValue(item.key, `key-${item.id}`)} > {copiedId === `key-${item.id}` ? ( <Check className="text-success size-3.5" /> ) : ( <Copy className="size-3.5" /> )} </button> </div> </TableCell> {/* Value Mask / Plain */} <TableCell className="py-3.5"> <div className="flex max-w-[280px] items-center gap-1.5 sm:max-w-xs md:max-w-sm"> <code className="bg-muted/60 text-foreground max-w-[200px] min-w-0 truncate rounded border px-2 py-1 font-mono text-xs sm:max-w-[240px]" title={isRevealed(item.id) ? item.value : 'Masked secret value'} > {isRevealed(item.id) ? item.value : maskString(item.value)} </code> <Button variant="ghost" size="icon-xs" className="text-muted-foreground hover:text-foreground size-7 shrink-0" aria-label={isRevealed(item.id) ? 'Hide value' : 'Reveal value'} onClick={() => toggleReveal(item.id)} > {isRevealed(item.id) ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />} </Button> <Button variant="ghost" size="icon-xs" className="text-muted-foreground hover:text-foreground size-7 shrink-0" aria-label={copiedId === item.id ? 'Copied' : 'Copy value'} onClick={() => copyValue(item.value, item.id)} > {copiedId === item.id ? ( <Check className="text-success size-3.5" /> ) : ( <Copy className="size-3.5" /> )} </Button> </div> </TableCell> {/* Environments */} <TableCell className="py-3.5"> <div className="flex flex-wrap items-center gap-1"> {item.environments.includes('production') && ( <Badge variant="outline" className="border-success/30 bg-success/10 text-success px-2 py-0 text-xs font-medium" > Production </Badge> )} {item.environments.includes('preview') && ( <Badge variant="outline" className="border-info/30 bg-info/10 text-info px-2 py-0 text-xs font-medium" > Preview </Badge> )} {item.environments.includes('development') && ( <Badge variant="outline" className="border-chart-2/30 bg-chart-2/10 text-chart-2 px-2 py-0 text-xs font-medium" > Development </Badge> )} </div> </TableCell> {/* Type */} <TableCell className="py-3.5"> {item.isSecret ? ( <Badge variant="secondary" className="border-warning/30 bg-warning/10 text-warning gap-1 px-2 py-0 text-xs font-medium" > <Lock className="size-3" /> Encrypted Secret </Badge> ) : ( <Badge variant="outline" className="text-muted-foreground gap-1 px-2 py-0 text-xs font-medium"> <FileCode className="size-3" /> Plaintext </Badge> )} </TableCell> {/* Updated */} <TableCell className="text-muted-foreground hidden py-3.5 text-xs whitespace-nowrap md:table-cell"> <span>{item.updatedAt}</span> <span className="block text-xs opacity-75">by {item.updatedBy}</span> </TableCell> {/* Actions Dropdown */} <TableCell className="py-3.5 text-right"> <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" size="icon-sm" className="text-muted-foreground hover:text-foreground size-8" > <MoreHorizontal className="size-4" /> <span className="sr-only">Actions for {item.key}</span> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-44 text-xs"> <DropdownMenuItem onClick={() => openEdit(item)}> <Pencil className="mr-2 size-3.5" /> <span>Edit variable</span> </DropdownMenuItem> <DropdownMenuItem onClick={() => handleDuplicate(item)}> <Copy className="mr-2 size-3.5" /> <span>Duplicate</span> </DropdownMenuItem> <DropdownMenuItem onClick={() => copyValue(item.value, item.id)}> {copiedId === item.id ? ( <Check className="text-success mr-2 size-3.5" /> ) : ( <Copy className="mr-2 size-3.5" /> )} <span>Copy value</span> </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem className="text-destructive focus:text-destructive" onClick={() => handleDelete(item.id)} > <Trash2 className="mr-2 size-3.5" /> <span>Delete</span> </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> </TableCell> </TableRow> ))} {/* Empty State */} {filteredVariables.length === 0 && ( <TableRow> <TableCell colSpan={6} className="h-44 text-center"> <div className="flex flex-col items-center justify-center space-y-2 py-6"> <div className="bg-muted text-muted-foreground grid size-10 place-items-center rounded-full border"> <AlertCircle className="size-5" /> </div> <p className="text-foreground text-sm font-medium">No environment variables found</p> <p className="text-muted-foreground max-w-xs text-xs"> {searchQuery || typeFilter !== 'all' || activeTab !== 'all' ? 'Try adjusting your filters or search keywords.' : 'Add your first environment secret or configuration key.'} </p> {!searchQuery && typeFilter === 'all' && activeTab === 'all' && ( <Button size="sm" className="mt-2 text-xs" onClick={() => setIsAddOpen(true)}> <Plus className="mr-1.5 size-3.5" /> Add Variable </Button> )} </div> </TableCell> </TableRow> )} </TableBody> </Table> </div> </div> {/* Edit Variable Dialog */} <Dialog open={Boolean(editingVariable)} onOpenChange={(open) => !open && setEditingVariable(null)}> {editingVariable && ( <DialogContent className="sm:max-w-md"> <DialogHeader> <DialogTitle>Edit Environment Variable</DialogTitle> <DialogDescription>Update variable value and configured environment scopes.</DialogDescription> </DialogHeader> <div className="space-y-4 py-2"> <div className="space-y-1.5"> <label className="text-xs font-medium">Variable Key</label> <Input value={editingVariable.key} onChange={(e) => setEditingVariable({ ...editingVariable, key: e.target.value })} className="font-mono text-xs uppercase" /> </div> <div className="space-y-1.5"> <label className="text-xs font-medium">Variable Value</label> <Input value={editingVariable.value} onChange={(e) => setEditingVariable({ ...editingVariable, value: e.target.value })} type={editingVariable.isSecret ? 'password' : 'text'} className="font-mono text-xs" /> </div> <div className="space-y-2"> <span className="text-xs font-medium">Environments</span> <div className="flex flex-wrap items-center gap-2"> <label className={cn( 'hover:bg-accent/50 flex cursor-pointer items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs select-none', editingVariable.environments.includes('production') && 'border-success/40 bg-success/5', )} > <Checkbox checked={editingVariable.environments.includes('production')} onCheckedChange={() => toggleEditEnv('production')} /> <span className="text-success font-medium">Production</span> </label> <label className={cn( 'hover:bg-accent/50 flex cursor-pointer items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs select-none', editingVariable.environments.includes('preview') && 'border-info/40 bg-info/5', )} > <Checkbox checked={editingVariable.environments.includes('preview')} onCheckedChange={() => toggleEditEnv('preview')} /> <span className="text-info font-medium">Preview</span> </label> <label className={cn( 'hover:bg-accent/50 flex cursor-pointer items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs select-none', editingVariable.environments.includes('development') && 'border-chart-2/40 bg-chart-2/5', )} > <Checkbox checked={editingVariable.environments.includes('development')} onCheckedChange={() => toggleEditEnv('development')} /> <span className="text-chart-2 font-medium">Development</span> </label> </div> </div> <div className="flex items-center gap-2 pt-1"> <Checkbox id="react-edit-is-secret" checked={editingVariable.isSecret} onCheckedChange={(c) => setEditingVariable({ ...editingVariable, isSecret: Boolean(c) })} /> <label htmlFor="react-edit-is-secret" className="cursor-pointer text-xs font-medium select-none"> Encrypt as Secret </label> </div> </div> <DialogFooter> <Button variant="ghost" size="sm" onClick={() => setEditingVariable(null)}> Cancel </Button> <Button size="sm" onClick={saveEdit}> Save Changes </Button> </DialogFooter> </DialogContent> )} </Dialog> {/* Import .env Dialog */} <Dialog open={isImportOpen} onOpenChange={setIsImportOpen}> <DialogContent className="sm:max-w-lg"> <DialogHeader> <div className="flex items-center gap-2"> <div className="bg-primary/10 text-primary grid size-7 place-items-center rounded-md border"> <Upload className="size-3.5" /> </div> <DialogTitle>Import .env File</DialogTitle> </div> <DialogDescription> Paste your raw .env, .env.local, or .env.production file contents to bulk add variables. </DialogDescription> </DialogHeader> <div className="space-y-4 py-2"> <div className="space-y-1.5"> <label className="text-xs font-medium">.env File Content</label> <Textarea value={importText} onValueChange={(v) => setImportText(v)} rows={6} placeholder="DATABASE_URL=postgresql://user:pass@host:5432/db STRIPE_SECRET_KEY=mock_key_... NEXT_PUBLIC_APP_URL=https://app.example.com" className="font-mono text-xs" /> </div> <div className="space-y-2"> <span className="text-xs font-medium">Assign to Environments</span> <div className="flex flex-wrap items-center gap-2"> <label className={cn( 'hover:bg-accent/50 flex cursor-pointer items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs select-none', importEnvs.includes('production') && 'border-success/40 bg-success/5', )} > <Checkbox checked={importEnvs.includes('production')} onCheckedChange={() => toggleImportEnv('production')} /> <span className="text-success font-medium">Production</span> </label> <label className={cn( 'hover:bg-accent/50 flex cursor-pointer items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs select-none', importEnvs.includes('preview') && 'border-info/40 bg-info/5', )} > <Checkbox checked={importEnvs.includes('preview')} onCheckedChange={() => toggleImportEnv('preview')} /> <span className="text-info font-medium">Preview</span> </label> <label className={cn( 'hover:bg-accent/50 flex cursor-pointer items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs select-none', importEnvs.includes('development') && 'border-chart-2/40 bg-chart-2/5', )} > <Checkbox checked={importEnvs.includes('development')} onCheckedChange={() => toggleImportEnv('development')} /> <span className="text-chart-2 font-medium">Development</span> </label> </div> </div> <div className="flex items-center gap-2 pt-1"> <Checkbox id="react-import-encrypt-all" checked={importEncryptAll} onCheckedChange={(c) => setImportEncryptAll(Boolean(c))} /> <label htmlFor="react-import-encrypt-all" className="cursor-pointer text-xs font-medium select-none"> Encrypt all variables by default </label> </div> {parsedImportItems.length > 0 && ( <div className="bg-muted/60 flex items-center gap-2 rounded-md border px-3 py-2 text-xs"> <ShieldCheck className="text-success size-4 shrink-0" /> <span> Ready to import <strong>{parsedImportItems.length}</strong> variable(s). </span> </div> )} </div> <DialogFooter> <Button variant="ghost" size="sm" onClick={() => setIsImportOpen(false)}> Cancel </Button> <Button size="sm" disabled={parsedImportItems.length === 0} onClick={handleImport}> Import {parsedImportItems.length > 0 ? `(${parsedImportItems.length})` : ''} </Button> </DialogFooter> </DialogContent> </Dialog> </div> ) }
Raw manifest:https://uipkge.dev/r/react/environment-variables-manager.json