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.

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