Skip to content
423 linesCodeBlameRaw
1/**
2 * A project's or a workspace's secrets and variables, as one list in the
3 * way Vercel lists environment variables: each row is a key, its type
4 * (Secret or Config), the environments it applies to and who reads it.
5 * Adding and editing happen in a side panel, opened by `?add` or
6 * `?edit=<id>` so the page works without scripts.
7 */
8import { Lock, Pencil, Plus, Search, SlidersHorizontal, Trash2, X } from "lucide-react";
9import { useMemo, useState } from "react";
10import { Form, Link, useLocation } from "react-router";
11
12import type { Setting } from "@g1t/contracts";
13
14import type { SecretsAction, SecretsData } from "../lib/secrets.server";
15import { ButtonLink, EmptyState, ErrorText, SubmitButton, TimeAgo } from "./ui";
16import { Card } from "./ui/card";
17import { CheckboxOption } from "./ui/checkbox";
18import { Hint } from "./ui/hint";
19import { RadioCard, RadioGroup, RadioOption } from "./ui/radio-group";
20import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from "./ui/select";
21
22/** The environments every deployment knows; workflow jobs may name others. */
23const KNOWN_ENVIRONMENTS = ["production", "preview"];
24
25const READERS: Record<string, string> = { workflows: "Workflows", deployments: "Deployments" };
26
27function environmentsLabel(environments: string[]): string {
28 if (environments.length === 0) return "All environments";
29 return environments.map((env) => env.charAt(0).toUpperCase() + env.slice(1)).join(", ");
30}
31
32const SELECT =
33 "rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors hover:border-line-strong focus:border-accent-dim";
34
35export function SecretsPanel({
36 data,
37 action,
38 scope,
39 manage,
40}: {
41 data: SecretsData;
42 action: SecretsAction | undefined;
43 scope: "project" | "workspace";
44 manage: boolean;
45}) {
46 const location = useLocation();
47 const params = new URLSearchParams(location.search);
48 const editing = params.get("edit");
49 const adding = params.has("add");
50 const row = editing ? data.rows.find((r) => r.id === editing && r.scope === scope) : undefined;
51 const [query, setQuery] = useState("");
52 const [type, setType] = useState("all");
53 const [environment, setEnvironment] = useState("all");
54 const environments = useMemo(
55 () => [...new Set([...KNOWN_ENVIRONMENTS, ...data.rows.flatMap((r) => r.environments)])],
56 [data.rows],
57 );
58 const shown = data.rows.filter(
59 (r) =>
60 (!query || r.name.toLowerCase().includes(query.toLowerCase()) || r.note?.toLowerCase().includes(query.toLowerCase())) &&
61 (type === "all" || r.kind === type) &&
62 (environment === "all" || r.environments.length === 0 || r.environments.includes(environment)),
63 );
64
65 return (
66 <div className="max-w-5xl">
67 <header className="flex flex-wrap items-start justify-between gap-4">
68 <div>
69 <h2 className="text-lg font-semibold tracking-tight">Secrets and variables</h2>
70 <p className="mt-1 max-w-2xl text-sm text-muted">
71 One list for everything that reads them. Each row says which environments it applies to and whether{" "}
72 <strong className="font-medium text-fg">workflows</strong> (as <code className="text-fg">secrets.KEY</code>{" "}
73 and <code className="text-fg">vars.KEY</code>), <strong className="font-medium text-fg">deployments</strong>{" "}
74 (the build's environment and the running app's <code className="text-fg">env.KEY</code>), or both read it.
75 {scope === "workspace"
76 ? " Every project, or the ones you link, reads the workspace's; a project's own row of the same key wins."
77 : " Rows from the workspace are shown too; adding the same key here replaces them for this project."}{" "}
78 <a href="https://docs.g1t.sh/guides/secrets-and-variables/" className="text-fg hover:underline">
79 How they are read
80 </a>
81 </p>
82 </div>
83 {manage && (
84 <ButtonLink to="?add" variant="accent">
85 <Plus size={14} />
86 Add
87 </ButtonLink>
88 )}
89 </header>
90
91 <Card asChild radius="lg" className="mt-4 px-4 py-2.5 text-xs text-muted">
92 <p>
93 Built in: workflows get <code className="text-fg">secrets.G1T_TOKEN</code>, the workspace's own token for
94 the run, with <code className="text-fg">secrets.GITHUB_TOKEN</code> as its alias. Agents
95 and the merge queue never read secrets or variables, and runs for people outside the workspace get no secrets.
96 </p>
97 </Card>
98
99 <div className="mt-5 flex flex-wrap gap-2">
100 <label className="relative min-w-56 grow">
101 <Search size={14} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" />
102 <input
103 value={query}
104 onChange={(e) => setQuery(e.target.value)}
105 placeholder="Search keys and notes"
106 aria-label="Search"
107 className={`${SELECT} w-full pl-9`}
108 />
109 </label>
110 <Select value={type} onValueChange={setType}>
111 <SelectTrigger aria-label="Type" className="h-auto w-auto py-2">
112 <SelectValue />
113 </SelectTrigger>
114 <SelectContent align="end">
115 <SelectItem value="all">All types</SelectItem>
116 <SelectSeparator />
117 <SelectItem value="secret" icon={<Lock />}>Secret</SelectItem>
118 <SelectItem value="variable" icon={<SlidersHorizontal />}>Config</SelectItem>
119 </SelectContent>
120 </Select>
121 <Select value={environment} onValueChange={setEnvironment}>
122 <SelectTrigger aria-label="Environment" className="h-auto w-auto py-2">
123 <SelectValue />
124 </SelectTrigger>
125 <SelectContent align="end">
126 <SelectItem value="all">All environments</SelectItem>
127 {environments.length > 0 && <SelectSeparator />}
128 {environments.map((env) => (
129 <SelectItem key={env} value={env}>
130 {environmentsLabel([env])}
131 </SelectItem>
132 ))}
133 </SelectContent>
134 </Select>
135 </div>
136
137 <ErrorText>{data.error}</ErrorText>
138 {!editing && !adding && <div className="mt-2"><ErrorText>{action?.error}</ErrorText></div>}
139
140 <div className="mt-4">
141 {data.rows.length === 0 ? (
142 <EmptyState title="No secrets or variables yet">
143 Add one, or paste a <code>.env</code> file into Add to bring many at once.
144 </EmptyState>
145 ) : shown.length === 0 ? (
146 <EmptyState title="Nothing matches" />
147 ) : (
148 <Card asChild className="overflow-hidden">
149 <ul>
150 {shown.map((r) => (
151 <Row key={r.id} row={r} inherited={r.scope !== scope} manage={manage} />
152 ))}
153 </ul>
154 </Card>
155 )}
156 </div>
157
158 {manage && (adding || row) && (
159 // Keyed to the row, so going from one row's edit to another's starts from that row.
160 <Drawer key={row?.id ?? "add"} row={row} scope={scope} projects={data.projects} error={action?.error} />
161 )}
162 </div>
163 );
164}
165
166function Row({ row, inherited, manage }: { row: Setting; inherited: boolean; manage: boolean }) {
167 const secret = row.kind === "secret";
168 return (
169 <li className="grid grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_auto] items-center gap-x-4 gap-y-1 border-t border-line px-4 py-3 text-sm first:border-t-0 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_minmax(0,1fr)_6rem_6rem_auto]">
170 <div className="min-w-0">
171 <p className="truncate font-mono text-[0.8125rem]">{row.name}</p>
172 {row.note && <p className="truncate text-xs text-faint">{row.note}</p>}
173 {!secret && row.value != null && <p className="truncate font-mono text-xs text-muted">{row.value}</p>}
174 </div>
175 <span className="truncate text-muted">{environmentsLabel(row.environments)}</span>
176 <span className="hidden truncate text-xs text-muted md:block">
177 {row.availableTo.map((r) => READERS[r] ?? r).join(" · ")}
178 </span>
179 <span className="hidden items-center gap-1.5 text-xs text-muted md:flex">
180 {secret ? <Lock size={13} /> : <SlidersHorizontal size={13} />}
181 {secret ? "Secret" : "Config"}
182 </span>
183 <span className="hidden text-xs text-faint md:block">
184 <TimeAgo at={row.updatedAt} />
185 </span>
186 <span className="flex items-center justify-end gap-1">
187 {inherited ? (
188 <span className="rounded-full px-2 py-px text-xs text-muted ring-1 ring-line">Workspace</span>
189 ) : (
190 <>
191 {row.projects.length > 0 && (
192 <Hint label={row.projects.join(", ")}>
193 <span className="mr-1 text-xs text-faint">
194 {row.projects.length} {row.projects.length === 1 ? "project" : "projects"}
195 <span className="sr-only">: {row.projects.join(", ")}</span>
196 </span>
197 </Hint>
198 )}
199 {manage && (
200 <>
201 <Link
202 to={`?edit=${row.id}`}
203 aria-label={`Edit ${row.name}`}
204 className="rounded-md p-1.5 text-faint max-sm:p-2.5 transition-colors hover:bg-raised hover:text-fg"
205 >
206 <Pencil size={14} />
207 </Link>
208 <Form method="post">
209 <input type="hidden" name="intent" value="delete" />
210 <input type="hidden" name="id" value={row.id} />
211 <input type="hidden" name="name" value={row.name} />
212 <SubmitButton
213 icon
214 match={{ intent: "delete", id: row.id }}
215 aria-label={`Remove ${row.name}`}
216 variant="ghost"
217 size="inline"
218 className="p-1.5 text-faint max-sm:p-2.5 hover:text-danger"
219 >
220 <Trash2 size={14} />
221 </SubmitButton>
222 </Form>
223 </>
224 )}
225 </>
226 )}
227 </span>
228 </li>
229 );
230}
231
232function Drawer({
233 row,
234 scope,
235 projects,
236 error,
237}: {
238 row: Setting | undefined;
239 scope: "project" | "workspace";
240 projects: string[];
241 error: string | undefined;
242}) {
243 const editing = !!row;
244 const [type, setType] = useState<"secret" | "config">(row?.kind === "variable" ? "config" : "secret");
245 const [some, setSome] = useState(!!row && row.environments.length > 0);
246 const [reach, setReach] = useState(row && row.projects.length > 0 ? "some" : "all");
247 const custom = row?.environments.filter((env) => !KNOWN_ENVIRONMENTS.includes(env)) ?? [];
248 const field =
249 "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim";
250 return (
251 <div className="fixed inset-0 z-50 flex justify-end bg-black/50" role="dialog" aria-modal="true" aria-label={editing ? "Edit" : "Add"}>
252 <Link to="?" aria-label="Close" className="grow" />
253 <Form method="post" className="flex h-full w-full max-w-xl flex-col border-l border-line bg-bg pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)] shadow-2xl">
254 <div className="flex items-center justify-between border-b border-line px-6 py-4">
255 <h3 className="font-semibold">{editing ? `Edit ${row.name}` : "Add a secret or variable"}</h3>
256 <Link to="?" aria-label="Close" className="flex items-center justify-center rounded-md p-1.5 text-faint hover:bg-raised hover:text-fg max-sm:size-10">
257 <X size={16} />
258 </Link>
259 </div>
260 <div className="grow space-y-6 overflow-y-auto px-6 py-5">
261 <input type="hidden" name="intent" value="save" />
262 {row && <input type="hidden" name="id" value={row.id} />}
263
264 <fieldset>
265 <legend className="mb-2 text-sm font-medium text-muted">Type</legend>
266 <RadioGroup
267 name="type"
268 value={type}
269 onValueChange={(value) => setType(value as typeof type)}
270 aria-label="Type"
271 className="gap-3 sm:grid-cols-2"
272 >
273 {(
274 [
275 ["secret", "Secret", "You can't read it again after saving. For passwords, API keys and tokens.", <Lock key="i" />],
276 ["config", "Config", "Readable by members after saving. For values that are not sensitive.", <SlidersHorizontal key="i" />],
277 ] as const
278 ).map(([value, title, text, icon]) => (
279 // A secret's value is sealed: it can never become config.
280 <RadioCard
281 key={value}
282 value={value}
283 title={title}
284 description={text}
285 icon={icon}
286 disabled={value === "config" && row?.kind === "secret"}
287 />
288 ))}
289 </RadioGroup>
290 {row?.kind === "variable" && (
291 <p className="mt-2 text-xs text-faint">Config can become a secret; a secret cannot become config.</p>
292 )}
293 </fieldset>
294
295 <label className="block">
296 <span className="mb-1.5 block text-sm font-medium text-muted">Key</span>
297 {editing ? (
298 <input name="key" value={row.name} readOnly className={`${field} font-mono text-muted`} />
299 ) : (
300 <textarea
301 name="key"
302 required
303 rows={1}
304 placeholder="CLIENT_KEY, or paste a .env file"
305 autoComplete="off"
306 spellCheck={false}
307 className={`${field} min-h-10 font-mono`}
308 />
309 )}
310 </label>
311
312 <label className="block">
313 <span className="mb-1.5 block text-sm font-medium text-muted">Value</span>
314 <textarea
315 name="value"
316 rows={type === "secret" ? 3 : 2}
317 defaultValue={row?.kind === "variable" ? (row.value ?? "") : ""}
318 placeholder={
319 editing && row.kind === "secret" ? "Leave empty to keep the current value" : "Enter a value"
320 }
321 autoComplete="off"
322 spellCheck={false}
323 className={`${field} font-mono`}
324 />
325 </label>
326
327 <label className="block">
328 <span className="mb-1.5 block text-sm font-medium text-muted">Note (optional)</span>
329 <input name="note" defaultValue={row?.note ?? ""} placeholder="Where to rotate it, or who to ask" className={field} />
330 </label>
331
332 <fieldset>
333 <legend className="mb-2 text-sm font-medium text-muted">Environments</legend>
334 <div className="space-y-2 text-sm">
335 <RadioGroup name="scope" value={some ? "some" : "all"} onValueChange={(value) => setSome(value === "some")} aria-label="Environments">
336 <RadioOption value="all" label="All environments" />
337 <RadioOption value="some" label="Only some" />
338 </RadioGroup>
339 {some && (
340 <div className="ml-6 space-y-2">
341 {KNOWN_ENVIRONMENTS.map((env) => (
342 <CheckboxOption
343 key={env}
344 name="env"
345 value={env}
346 defaultChecked={row?.environments.includes(env)}
347 label={environmentsLabel([env])}
348 />
349 ))}
350 <input
351 name="envCustom"
352 defaultValue={custom.join(", ")}
353 placeholder="Others, comma-separated: staging, qa"
354 className={field}
355 />
356 <p className="text-xs text-faint">
357 Deployments are production and preview; a workflow job reads the row for its{" "}
358 <code>environment:</code>, and rows for all environments otherwise.
359 </p>
360 </div>
361 )}
362 </div>
363 </fieldset>
364
365 <fieldset>
366 <legend className="mb-2 text-sm font-medium text-muted">Available to</legend>
367 <div className="space-y-2 text-sm">
368 {(
369 [
370 ["workflows", "Workflows", "secrets.KEY or vars.KEY in GitHub Actions workflows"],
371 ["deployments", "Deployments", "The build's environment, and env.KEY in the running app"],
372 ] as const
373 ).map(([value, title, text]) => (
374 <CheckboxOption
375 key={value}
376 name="availableTo"
377 value={value}
378 defaultChecked={row ? row.availableTo.includes(value) : true}
379 label={title}
380 description={text}
381 />
382 ))}
383 </div>
384 </fieldset>
385
386 {scope === "workspace" && (
387 <fieldset>
388 <legend className="mb-2 text-sm font-medium text-muted">Projects</legend>
389 <div className="space-y-2 text-sm">
390 <RadioGroup name="reach" value={reach} onValueChange={(value) => setReach(value as typeof reach)} aria-label="Projects">
391 <RadioOption value="all" label="Every project" />
392 <RadioOption value="some" label="Only these" />
393 </RadioGroup>
394 {reach === "some" && (
395 <div className="ml-6 grid max-h-48 gap-1.5 overflow-y-auto p-0.5 sm:grid-cols-2">
396 {projects.map((name) => (
397 <CheckboxOption
398 key={name}
399 name="project"
400 value={name}
401 defaultChecked={row?.projects.includes(name)}
402 label={name}
403 className="items-center"
404 labelClassName="font-mono text-xs"
405 />
406 ))}
407 </div>
408 )}
409 </div>
410 </fieldset>
411 )}
412 <ErrorText>{error}</ErrorText>
413 </div>
414 <div className="flex items-center justify-between gap-4 border-t border-line px-6 py-4">
415 <p className="text-xs text-faint">{editing ? "" : "Paste .env contents into Key to add many."}</p>
416 <SubmitButton match={{ intent: "save" }} pending="Saving…">
417 Save
418 </SubmitButton>
419 </div>
420 </Form>
421 </div>
422 );
423}