Blocksblock
Environment variables
Secret variables with per-row reveal, edit, delete, .env import, and download.
Preview
Open full screenInstallation
npx shadcn@latest add @particular/env-variablesnpm dependencies
- lucide-react@^1.51.0
Installs these registry items
Usage
"use client"
import * as React from "react"
import { EnvVariables, type EnvVariable } from "@/components/env-variables"
import { minutesAgo } from "@/registry/particular/examples/demo-data"
import { toast } from "@/components/ui/toast"
function wait(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms))
}
export function Demo() {
const [values, setValues] = React.useState<Record<string, string>>({
DATABASE_URL: "postgres://app:s3cr3t@ep-cool-forest.us-east-1.aws.neon.tech/neondb?sslmode=require",
STRIPE_SECRET_KEY: "sk_test_51Hx",
NEXT_PUBLIC_APP_URL: "https://app.particular.tech",
})
const [updated, setUpdated] = React.useState<Record<string, Date>>({
DATABASE_URL: minutesAgo(60 * 24 * 3),
STRIPE_SECRET_KEY: minutesAgo(90),
NEXT_PUBLIC_APP_URL: minutesAgo(60 * 24 * 20),
})
const variables: EnvVariable[] = Object.keys(values)
.sort()
.map((name) => ({ name, updatedAt: updated[name] ?? new Date() }))
const save = (entries: Record<string, string>) => {
setValues((current) => ({ ...current, ...entries }))
setUpdated((current) => ({ ...current, ...Object.fromEntries(Object.keys(entries).map((name) => [name, new Date()])) }))
}
return (
<div className="mx-auto w-full max-w-4xl p-6">
<EnvVariables
variables={variables}
canWrite
downloadHref="#"
onReveal={async (name) => {
await wait(400)
const value = values[name]
if (value === undefined) throw new Error(`${name} no longer exists`)
return value
}}
onSave={async (name, value) => {
await wait(500)
if (name === "FAIL") throw new Error("The vault is read-only right now.")
save({ [name]: value })
toast.add({ title: `Saved ${name}`, type: "success" })
}}
onImport={async (entries) => {
await wait(600)
save(Object.fromEntries(entries.map((entry) => [entry.key, entry.value])))
toast.add({ title: `Imported ${entries.length} variables`, type: "success" })
}}
onDelete={async (name) => {
await wait(500)
setValues((current) => Object.fromEntries(Object.entries(current).filter(([key]) => key !== name)))
}}
/>
</div>
)
}Source
"use client"
import * as React from "react"
import { DownloadIcon, EyeIcon, EyeOffIcon, PencilIcon, PlusIcon, Trash2Icon, UploadIcon, VariableIcon } from "lucide-react"
import { isValidEnvKey, type EnvVar } from "@/lib/env-parser"
import { Alert, AlertDescription } from "@/components/ui/alert"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { CopyButton } from "@/components/ui/copy-button"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import { EnvVarEditor } from "@/components/ui/env-var-editor"
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { RelativeTime } from "@/components/ui/relative-time"
import { Spinner } from "@/components/ui/spinner"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Textarea } from "@/components/ui/textarea"
export type EnvVariable = { name: string; updatedAt: Date | string }
export type EnvVariablesProps = {
/** Names only; values are fetched one at a time with `onReveal`. */
variables: EnvVariable[]
/** Show add, import, edit, and delete controls. */
canWrite: boolean
onReveal: (name: string) => Promise<string>
/** Create or replace one variable. */
onSave: (name: string, value: string) => Promise<void>
onDelete: (name: string) => Promise<void>
/** Create or replace several variables at once. */
onImport: (variables: EnvVar[]) => Promise<void>
/** Link to a `.env` download of the whole environment. */
downloadHref?: string
}
function messageOf(error: unknown) {
return error instanceof Error ? error.message : "Something went wrong. Try again."
}
function EditDialog({
open,
initial,
existingNames,
onOpenChange,
onSave,
}: {
open: boolean
/** Editing an existing variable: its name is fixed and its value prefilled when already revealed. */
initial: { name: string; value: string } | null
existingNames: Set<string>
onOpenChange: (open: boolean) => void
onSave: EnvVariablesProps["onSave"]
}) {
const [pending, setPending] = React.useState(false)
const [error, setError] = React.useState<string | null>(null)
const editing = initial !== null && existingNames.has(initial.name)
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
const form = new FormData(event.currentTarget)
const name = editing && initial ? initial.name : String(form.get("name") ?? "").trim()
const value = String(form.get("value") ?? "")
if (!isValidEnvKey(name)) return setError("Use letters, digits, and underscores, not starting with a digit.")
if (!editing && existingNames.has(name)) return setError(`${name} already exists. Edit it instead.`)
setPending(true)
setError(null)
try {
await onSave(name, value)
onOpenChange(false)
} catch (caught) {
setError(messageOf(caught))
} finally {
setPending(false)
}
}
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (pending) return
if (!next) setError(null)
onOpenChange(next)
}}
>
<DialogContent className="sm:max-w-lg">
<form onSubmit={submit} className="grid gap-4" key={initial?.name ?? "new"}>
<DialogHeader>
<DialogTitle>{editing ? `Edit ${initial?.name}` : "Add variable"}</DialogTitle>
<DialogDescription>{editing ? "Saving replaces the current value." : "Values are encrypted before they are stored."}</DialogDescription>
</DialogHeader>
{!editing && (
<Field data-invalid={error ? true : undefined}>
<FieldLabel htmlFor="variable-name">Name</FieldLabel>
<Input id="variable-name" name="name" placeholder="DATABASE_URL" autoComplete="off" spellCheck={false} className="font-mono" autoFocus defaultValue={initial?.name} />
</Field>
)}
<Field data-invalid={editing && error ? true : undefined}>
<FieldLabel htmlFor="variable-value">Value</FieldLabel>
<Textarea
id="variable-value"
name="value"
rows={3}
spellCheck={false}
autoComplete="off"
className="font-mono text-sm"
defaultValue={initial?.value}
autoFocus={editing}
placeholder={editing && !initial?.value ? "Enter a new value" : undefined}
/>
{error ? <FieldError>{error}</FieldError> : <FieldDescription>Multi-line values are kept as entered.</FieldDescription>}
</Field>
<DialogFooter>
<Button type="button" variant="outline" disabled={pending} onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={pending}>
{pending && <Spinner data-icon="inline-start" />}
{editing ? "Save" : "Add variable"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
function ImportDialog({ open, onOpenChange, onImport }: { open: boolean; onOpenChange: (open: boolean) => void; onImport: EnvVariablesProps["onImport"] }) {
const [entries, setEntries] = React.useState<EnvVar[]>([{ key: "", value: "" }])
const [pending, setPending] = React.useState(false)
const [error, setError] = React.useState<string | null>(null)
const filled = entries.filter((entry) => entry.key || entry.value)
const close = (next: boolean) => {
if (pending) return
if (!next) {
setEntries([{ key: "", value: "" }])
setError(null)
}
onOpenChange(next)
}
const submit = async () => {
const invalid = filled.find((entry) => !isValidEnvKey(entry.key))
if (invalid) return setError(`"${invalid.key || "(empty)"}" is not a valid name.`)
if (new Set(filled.map((entry) => entry.key)).size !== filled.length) return setError("Each name may appear only once.")
if (filled.length === 0) return setError("Paste a .env file or add at least one variable.")
setPending(true)
setError(null)
try {
await onImport(filled)
setPending(false)
close(false)
} catch (caught) {
setError(messageOf(caught))
setPending(false)
}
}
return (
<Dialog open={open} onOpenChange={close}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Import variables</DialogTitle>
<DialogDescription>Paste a .env file into a row, or type entries. Existing names are replaced; all entries are saved together or not at all.</DialogDescription>
</DialogHeader>
<EnvVarEditor value={entries} onChange={setEntries} disabled={pending} />
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button type="button" variant="outline" disabled={pending} onClick={() => close(false)}>
Cancel
</Button>
<Button disabled={pending} onClick={() => void submit()}>
{pending && <Spinner data-icon="inline-start" />}
Import {filled.length > 0 ? filled.length : ""} {filled.length === 1 ? "variable" : "variables"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function ValueCell({ name, revealed, onReveal, onHide }: { name: string; revealed: string | undefined; onReveal: () => Promise<void>; onHide: () => void }) {
const [pending, setPending] = React.useState(false)
const [error, setError] = React.useState<string | null>(null)
if (revealed !== undefined) {
return (
<div className="flex min-w-0 items-center gap-1">
<code className="min-w-0 flex-1 truncate rounded-md bg-muted/50 px-2 py-1 font-mono text-xs" title={revealed}>
{revealed === "" ? <span className="text-muted-foreground italic">empty</span> : revealed}
</code>
<CopyButton value={revealed} label={`Copy ${name}`} />
<Button variant="ghost" size="icon-sm" aria-label={`Hide ${name}`} onClick={onHide}>
<EyeOffIcon />
</Button>
</div>
)
}
return (
<div className="flex items-center gap-1">
<span aria-hidden className="font-mono text-xs tracking-widest text-muted-foreground">
โขโขโขโขโขโขโขโขโขโข
</span>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Reveal ${name}`}
disabled={pending}
onClick={async () => {
setPending(true)
setError(null)
try {
await onReveal()
} catch (caught) {
setError(messageOf(caught))
} finally {
setPending(false)
}
}}
>
{pending ? <Spinner /> : <EyeIcon />}
</Button>
{error && <span className="text-xs text-destructive">{error}</span>}
</div>
)
}
function EnvVariables({ variables, canWrite, onReveal, onSave, onDelete, onImport, downloadHref }: EnvVariablesProps) {
const [revealed, setRevealed] = React.useState<Record<string, string>>({})
const [editing, setEditing] = React.useState<{ name: string; value: string } | null>(null)
const [editOpen, setEditOpen] = React.useState(false)
const [importOpen, setImportOpen] = React.useState(false)
const [deleting, setDeleting] = React.useState<string | null>(null)
const [deletePending, setDeletePending] = React.useState(false)
const [deleteError, setDeleteError] = React.useState<string | null>(null)
const names = new Set(variables.map((variable) => variable.name))
const forget = (name: string) =>
setRevealed((current) => {
const next = { ...current }
delete next[name]
return next
})
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="font-heading text-base font-semibold">Variables</h2>
<p className="text-sm text-muted-foreground">
{variables.length} {variables.length === 1 ? "variable" : "variables"}, encrypted at rest
</p>
</div>
<div className="flex flex-wrap gap-2">
{downloadHref && variables.length > 0 && (
<Button variant="outline" nativeButton={false} render={<a href={downloadHref} download />}>
<DownloadIcon data-icon="inline-start" />
Download .env
</Button>
)}
{canWrite && (
<>
<Button variant="outline" onClick={() => setImportOpen(true)}>
<UploadIcon data-icon="inline-start" />
Import
</Button>
<Button
onClick={() => {
setEditing(null)
setEditOpen(true)
}}
>
<PlusIcon data-icon="inline-start" />
Add variable
</Button>
</>
)}
</div>
</div>
{variables.length === 0 ? (
<Empty className="border">
<EmptyHeader>
<EmptyMedia variant="icon">
<VariableIcon />
</EmptyMedia>
<EmptyTitle>No variables yet</EmptyTitle>
<EmptyDescription>Add variables one by one or paste a .env file.</EmptyDescription>
</EmptyHeader>
{canWrite && (
<EmptyContent>
<Button onClick={() => setImportOpen(true)}>Import a .env file</Button>
</EmptyContent>
)}
</Empty>
) : (
<div className="overflow-hidden rounded-lg border bg-card">
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="w-full">Value</TableHead>
<TableHead className="hidden sm:table-cell">Updated</TableHead>
{canWrite && (
<TableHead className="w-0">
<span className="sr-only">Actions</span>
</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{variables.map((variable) => (
<TableRow key={variable.name}>
<TableCell className="font-mono text-xs font-medium">{variable.name}</TableCell>
<TableCell className="max-w-0">
<ValueCell
name={variable.name}
revealed={revealed[variable.name]}
onReveal={async () => {
const value = await onReveal(variable.name)
setRevealed((current) => ({ ...current, [variable.name]: value }))
}}
onHide={() => forget(variable.name)}
/>
</TableCell>
<TableCell className="hidden text-muted-foreground sm:table-cell">
<RelativeTime date={variable.updatedAt} />
</TableCell>
{canWrite && (
<TableCell>
<div className="flex items-center">
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit ${variable.name}`}
onClick={() => {
setEditing({ name: variable.name, value: revealed[variable.name] ?? "" })
setEditOpen(true)
}}
>
<PencilIcon />
</Button>
<Button variant="ghost" size="icon-sm" className="text-destructive hover:text-destructive" aria-label={`Delete ${variable.name}`} onClick={() => setDeleting(variable.name)}>
<Trash2Icon />
</Button>
</div>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<EditDialog
open={editOpen}
initial={editing}
existingNames={names}
onOpenChange={setEditOpen}
onSave={async (name, value) => {
await onSave(name, value)
if (name in revealed) setRevealed((current) => ({ ...current, [name]: value }))
}}
/>
<ImportDialog
open={importOpen}
onOpenChange={setImportOpen}
onImport={async (entries) => {
await onImport(entries)
for (const entry of entries) if (entry.key in revealed) setRevealed((current) => ({ ...current, [entry.key]: entry.value }))
}}
/>
<AlertDialog
open={deleting !== null}
onOpenChange={(open) => {
if (deletePending || open) return
setDeleteError(null)
setDeleting(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {deleting}?</AlertDialogTitle>
<AlertDialogDescription>Anything that reads this variable from the environment stops getting it. This cannot be undone.</AlertDialogDescription>
</AlertDialogHeader>
{deleteError && (
<Alert variant="destructive">
<AlertDescription>{deleteError}</AlertDescription>
</Alert>
)}
<AlertDialogFooter>
<AlertDialogCancel disabled={deletePending}>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deletePending}
onClick={async (event) => {
event.preventDefault()
if (!deleting) return
setDeletePending(true)
setDeleteError(null)
try {
await onDelete(deleting)
forget(deleting)
setDeleting(null)
} catch (caught) {
setDeleteError(messageOf(caught))
} finally {
setDeletePending(false)
}
}}
>
{deletePending && <Spinner data-icon="inline-start" />}
Delete variable
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
export { EnvVariables }