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