Particular.Tech
Blocksblock

Environment variables

Secret variables with per-row reveal, edit, delete, .env import, and download.

Preview

Open full screen

Installation

npx shadcn@latest add @particular/env-variables

Usage

env-variables-demo.tsx
"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

blocks/env-variables/env-variables.tsx
"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 }