Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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