Skip to content

g1t/apps/web/app/components/secrets.tsx

416 lines18,494 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs1/**
Projects: what a workspace builds and runs, first on every page2 * A project's or a workspace's secrets and variables, as one list in the
Secrets and variables: one list, rows per environment, for workflows and deployments3 * 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.
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs7 */
Secrets and variables: one list, rows per environment, for workflows and deployments8import { Lock, Pencil, Plus, Search, SlidersHorizontal, Trash2, X } from "lucide-react";
9import { useMemo, useState } from "react";
Merge branch 'worktree-agent-a985c01f43538af01'10import { Form, Link, useLocation } from "react-router";
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs11
Secrets and variables: one list, rows per environment, for workflows and deployments12import type { Setting } from "@g1t/contracts";
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs13
14import type { SecretsAction, SecretsData } from "../lib/secrets.server";
Merge branch 'worktree-agent-a985c01f43538af01'15import { ButtonLink, EmptyState, ErrorText, SubmitButton, TimeAgo } from "./ui";
Workspace names and icons, and a component kit for every control16import { CheckboxOption } from "./ui/checkbox";
Merge branch 'worktree-agent-ae1299e92e4462012'17import { Hint } from "./ui/hint";
Workspace names and icons, and a component kit for every control18import { RadioCard, RadioGroup, RadioOption } from "./ui/radio-group";
19import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from "./ui/select";
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs20
Secrets and variables: one list, rows per environment, for workflows and deployments21/** The environments every deployment knows; workflow jobs may name others. */
22const KNOWN_ENVIRONMENTS = ["production", "preview"];
23
24const READERS: Record<string, string> = { workflows: "Workflows", deployments: "Deployments" };
25
26function environmentsLabel(environments: string[]): string {
27 if (environments.length === 0) return "All environments";
28 return environments.map((env) => env.charAt(0).toUpperCase() + env.slice(1)).join(", ");
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs29}
30
Secrets and variables: one list, rows per environment, for workflows and deployments31const SELECT =
32 "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";
33
34export function SecretsPanel({
35 data,
36 action,
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs37 scope,
38 manage,
39}: {
Secrets and variables: one list, rows per environment, for workflows and deployments40 data: SecretsData;
41 action: SecretsAction | undefined;
Projects: what a workspace builds and runs, first on every page42 scope: "project" | "workspace";
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs43 manage: boolean;
44}) {
Secrets and variables: one list, rows per environment, for workflows and deployments45 const location = useLocation();
46 const params = new URLSearchParams(location.search);
47 const editing = params.get("edit");
48 const adding = params.has("add");
49 const row = editing ? data.rows.find((r) => r.id === editing && r.scope === scope) : undefined;
50 const [query, setQuery] = useState("");
51 const [type, setType] = useState("all");
52 const [environment, setEnvironment] = useState("all");
53 const environments = useMemo(
54 () => [...new Set([...KNOWN_ENVIRONMENTS, ...data.rows.flatMap((r) => r.environments)])],
55 [data.rows],
56 );
57 const shown = data.rows.filter(
58 (r) =>
59 (!query || r.name.toLowerCase().includes(query.toLowerCase()) || r.note?.toLowerCase().includes(query.toLowerCase())) &&
60 (type === "all" || r.kind === type) &&
61 (environment === "all" || r.environments.length === 0 || r.environments.includes(environment)),
62 );
63
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs64 return (
Secrets and variables: one list, rows per environment, for workflows and deployments65 <div className="max-w-5xl">
66 <header className="flex flex-wrap items-start justify-between gap-4">
67 <div>
68 <h2 className="text-lg font-semibold tracking-tight">Secrets and variables</h2>
69 <p className="mt-1 max-w-2xl text-sm text-muted">
70 One list for everything that reads them. Each row says which environments it applies to and whether{" "}
71 <strong className="font-medium text-fg">workflows</strong> (as <code className="text-fg">secrets.KEY</code>{" "}
72 and <code className="text-fg">vars.KEY</code>), <strong className="font-medium text-fg">deployments</strong>{" "}
73 (the build's environment and the running app's <code className="text-fg">env.KEY</code>), or both read it.
74 {scope === "workspace"
Projects: what a workspace builds and runs, first on every page75 ? " Every project, or the ones you link, reads the workspace's; a project's own row of the same key wins."
76 : " Rows from the workspace are shown too; adding the same key here replaces them for this project."}{" "}
Secrets and variables: one list, rows per environment, for workflows and deployments77 <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>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs87 )}
Secrets and variables: one list, rows per environment, for workflows and deployments88 </header>
89
90 <p className="mt-4 rounded-lg border border-line bg-surface px-4 py-2.5 text-xs text-muted">
91 Built in: workflows get <code className="text-fg">secrets.G1T_TOKEN</code>, the workspace's own token for
Fast pages, required checks on the branch, self-hosted runners, honest incidents92 the run, with <code className="text-fg">secrets.GITHUB_TOKEN</code> as its alias. Agents
Secrets and variables: one list, rows per environment, for workflows and deployments93 and the merge queue never read secrets or variables, and runs for people outside the workspace get no secrets.
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs94 </p>
Secrets and variables: one list, rows per environment, for workflows and deployments95
96 <div className="mt-5 flex flex-wrap gap-2">
97 <label className="relative min-w-56 grow">
98 <Search size={14} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" />
99 <input
100 value={query}
101 onChange={(e) => setQuery(e.target.value)}
102 placeholder="Search keys and notes"
103 aria-label="Search"
104 className={`${SELECT} w-full pl-9`}
105 />
106 </label>
Workspace names and icons, and a component kit for every control107 <Select value={type} onValueChange={setType}>
108 <SelectTrigger aria-label="Type" className="h-auto w-auto py-2">
109 <SelectValue />
110 </SelectTrigger>
111 <SelectContent align="end">
112 <SelectItem value="all">All types</SelectItem>
113 <SelectSeparator />
114 <SelectItem value="secret" icon={<Lock />}>Secret</SelectItem>
115 <SelectItem value="variable" icon={<SlidersHorizontal />}>Config</SelectItem>
116 </SelectContent>
117 </Select>
118 <Select value={environment} onValueChange={setEnvironment}>
119 <SelectTrigger aria-label="Environment" className="h-auto w-auto py-2">
120 <SelectValue />
121 </SelectTrigger>
122 <SelectContent align="end">
123 <SelectItem value="all">All environments</SelectItem>
124 {environments.length > 0 && <SelectSeparator />}
125 {environments.map((env) => (
126 <SelectItem key={env} value={env}>
127 {environmentsLabel([env])}
128 </SelectItem>
129 ))}
130 </SelectContent>
131 </Select>
Secrets and variables: one list, rows per environment, for workflows and deployments132 </div>
133
134 <ErrorText>{data.error}</ErrorText>
135 {!editing && !adding && <div className="mt-2"><ErrorText>{action?.error}</ErrorText></div>}
136
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs137 <div className="mt-4">
Secrets and variables: one list, rows per environment, for workflows and deployments138 {data.rows.length === 0 ? (
139 <EmptyState title="No secrets or variables yet">
140 Add one, or paste a <code>.env</code> file into Add to bring many at once.
141 </EmptyState>
142 ) : shown.length === 0 ? (
143 <EmptyState title="Nothing matches" />
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs144 ) : (
145 <ul className="overflow-hidden rounded-xl border border-line bg-surface">
Secrets and variables: one list, rows per environment, for workflows and deployments146 {shown.map((r) => (
147 <Row key={r.id} row={r} inherited={r.scope !== scope} manage={manage} />
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs148 ))}
149 </ul>
150 )}
151 </div>
Secrets and variables: one list, rows per environment, for workflows and deployments152
153 {manage && (adding || row) && (
Merge branch 'worktree-agent-a985c01f43538af01'154 // Keyed to the row, so going from one row's edit to another's starts from that row.
155 <Drawer key={row?.id ?? "add"} row={row} scope={scope} projects={data.projects} error={action?.error} />
Secrets and variables: one list, rows per environment, for workflows and deployments156 )}
157 </div>
158 );
159}
160
161function Row({ row, inherited, manage }: { row: Setting; inherited: boolean; manage: boolean }) {
162 const secret = row.kind === "secret";
163 return (
164 <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]">
165 <div className="min-w-0">
166 <p className="truncate font-mono text-[0.8125rem]">{row.name}</p>
167 {row.note && <p className="truncate text-xs text-faint">{row.note}</p>}
168 {!secret && row.value != null && <p className="truncate font-mono text-xs text-muted">{row.value}</p>}
169 </div>
170 <span className="truncate text-muted">{environmentsLabel(row.environments)}</span>
171 <span className="hidden truncate text-xs text-muted md:block">
172 {row.availableTo.map((r) => READERS[r] ?? r).join(" · ")}
173 </span>
174 <span className="hidden items-center gap-1.5 text-xs text-muted md:flex">
175 {secret ? <Lock size={13} /> : <SlidersHorizontal size={13} />}
176 {secret ? "Secret" : "Config"}
177 </span>
178 <span className="hidden text-xs text-faint md:block">
179 <TimeAgo at={row.updatedAt} />
180 </span>
181 <span className="flex items-center justify-end gap-1">
182 {inherited ? (
183 <span className="rounded-full px-2 py-px text-xs text-muted ring-1 ring-line">Workspace</span>
184 ) : (
185 <>
Projects: what a workspace builds and runs, first on every page186 {row.projects.length > 0 && (
Merge branch 'worktree-agent-ae1299e92e4462012'187 <Hint label={row.projects.join(", ")}>
188 <span className="mr-1 text-xs text-faint">
189 {row.projects.length} {row.projects.length === 1 ? "project" : "projects"}
190 <span className="sr-only">: {row.projects.join(", ")}</span>
191 </span>
192 </Hint>
Secrets and variables: one list, rows per environment, for workflows and deployments193 )}
194 {manage && (
195 <>
196 <Link
197 to={`?edit=${row.id}`}
198 aria-label={`Edit ${row.name}`}
199 className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-fg"
200 >
201 <Pencil size={14} />
202 </Link>
203 <Form method="post">
204 <input type="hidden" name="intent" value="delete" />
205 <input type="hidden" name="id" value={row.id} />
206 <input type="hidden" name="name" value={row.name} />
Merge branch 'worktree-agent-a985c01f43538af01'207 <SubmitButton
208 icon
209 match={{ intent: "delete", id: row.id }}
Secrets and variables: one list, rows per environment, for workflows and deployments210 aria-label={`Remove ${row.name}`}
Merge branch 'worktree-agent-a985c01f43538af01'211 className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-danger disabled:opacity-50"
Secrets and variables: one list, rows per environment, for workflows and deployments212 >
213 <Trash2 size={14} />
Merge branch 'worktree-agent-a985c01f43538af01'214 </SubmitButton>
Secrets and variables: one list, rows per environment, for workflows and deployments215 </Form>
216 </>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs217 )}
Secrets and variables: one list, rows per environment, for workflows and deployments218 </>
219 )}
220 </span>
221 </li>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs222 );
223}
224
Secrets and variables: one list, rows per environment, for workflows and deployments225function Drawer({
226 row,
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs227 scope,
Projects: what a workspace builds and runs, first on every page228 projects,
Secrets and variables: one list, rows per environment, for workflows and deployments229 error,
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs230}: {
Secrets and variables: one list, rows per environment, for workflows and deployments231 row: Setting | undefined;
Projects: what a workspace builds and runs, first on every page232 scope: "project" | "workspace";
233 projects: string[];
Secrets and variables: one list, rows per environment, for workflows and deployments234 error: string | undefined;
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs235}) {
Secrets and variables: one list, rows per environment, for workflows and deployments236 const editing = !!row;
237 const [type, setType] = useState<"secret" | "config">(row?.kind === "variable" ? "config" : "secret");
238 const [some, setSome] = useState(!!row && row.environments.length > 0);
Projects: what a workspace builds and runs, first on every page239 const [reach, setReach] = useState(row && row.projects.length > 0 ? "some" : "all");
Secrets and variables: one list, rows per environment, for workflows and deployments240 const custom = row?.environments.filter((env) => !KNOWN_ENVIRONMENTS.includes(env)) ?? [];
241 const field =
242 "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";
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs243 return (
Secrets and variables: one list, rows per environment, for workflows and deployments244 <div className="fixed inset-0 z-50 flex justify-end bg-black/50" role="dialog" aria-modal="true" aria-label={editing ? "Edit" : "Add"}>
245 <Link to="?" aria-label="Close" className="grow" />
246 <Form method="post" className="flex h-full w-full max-w-xl flex-col border-l border-line bg-bg shadow-2xl">
247 <div className="flex items-center justify-between border-b border-line px-6 py-4">
248 <h3 className="font-semibold">{editing ? `Edit ${row.name}` : "Add a secret or variable"}</h3>
249 <Link to="?" aria-label="Close" className="rounded-md p-1.5 text-faint hover:bg-raised hover:text-fg">
250 <X size={16} />
251 </Link>
252 </div>
253 <div className="grow space-y-6 overflow-y-auto px-6 py-5">
254 <input type="hidden" name="intent" value="save" />
255 {row && <input type="hidden" name="id" value={row.id} />}
256
257 <fieldset>
258 <legend className="mb-2 text-sm font-medium text-muted">Type</legend>
Workspace names and icons, and a component kit for every control259 <RadioGroup
260 name="type"
261 value={type}
262 onValueChange={(value) => setType(value as typeof type)}
263 aria-label="Type"
264 className="gap-3 sm:grid-cols-2"
265 >
Secrets and variables: one list, rows per environment, for workflows and deployments266 {(
267 [
Workspace names and icons, and a component kit for every control268 ["secret", "Secret", "You can't read it again after saving. For passwords, API keys and tokens.", <Lock key="i" />],
269 ["config", "Config", "Readable by members after saving. For values that are not sensitive.", <SlidersHorizontal key="i" />],
Secrets and variables: one list, rows per environment, for workflows and deployments270 ] as const
Workspace names and icons, and a component kit for every control271 ).map(([value, title, text, icon]) => (
Secrets and variables: one list, rows per environment, for workflows and deployments272 // A secret's value is sealed: it can never become config.
Workspace names and icons, and a component kit for every control273 <RadioCard
274 key={value}
275 value={value}
276 title={title}
277 description={text}
278 icon={icon}
279 disabled={value === "config" && row?.kind === "secret"}
280 />
281 ))}
282 </RadioGroup>
Secrets and variables: one list, rows per environment, for workflows and deployments283 {row?.kind === "variable" && (
284 <p className="mt-2 text-xs text-faint">Config can become a secret; a secret cannot become config.</p>
285 )}
286 </fieldset>
287
288 <label className="block">
289 <span className="mb-1.5 block text-sm font-medium text-muted">Key</span>
290 {editing ? (
291 <input name="key" value={row.name} readOnly className={`${field} font-mono text-muted`} />
292 ) : (
293 <textarea
294 name="key"
295 required
296 rows={1}
297 placeholder="CLIENT_KEY, or paste a .env file"
298 autoComplete="off"
299 spellCheck={false}
300 className={`${field} min-h-10 font-mono`}
301 />
302 )}
303 </label>
304
305 <label className="block">
306 <span className="mb-1.5 block text-sm font-medium text-muted">Value</span>
307 <textarea
308 name="value"
309 rows={type === "secret" ? 3 : 2}
310 defaultValue={row?.kind === "variable" ? (row.value ?? "") : ""}
311 placeholder={
312 editing && row.kind === "secret" ? "Leave empty to keep the current value" : "Enter a value"
313 }
314 autoComplete="off"
315 spellCheck={false}
316 className={`${field} font-mono`}
317 />
318 </label>
319
320 <label className="block">
321 <span className="mb-1.5 block text-sm font-medium text-muted">Note (optional)</span>
322 <input name="note" defaultValue={row?.note ?? ""} placeholder="Where to rotate it, or who to ask" className={field} />
323 </label>
324
325 <fieldset>
326 <legend className="mb-2 text-sm font-medium text-muted">Environments</legend>
327 <div className="space-y-2 text-sm">
Workspace names and icons, and a component kit for every control328 <RadioGroup name="scope" value={some ? "some" : "all"} onValueChange={(value) => setSome(value === "some")} aria-label="Environments">
329 <RadioOption value="all" label="All environments" />
330 <RadioOption value="some" label="Only some" />
331 </RadioGroup>
Secrets and variables: one list, rows per environment, for workflows and deployments332 {some && (
333 <div className="ml-6 space-y-2">
334 {KNOWN_ENVIRONMENTS.map((env) => (
Workspace names and icons, and a component kit for every control335 <CheckboxOption
336 key={env}
337 name="env"
338 value={env}
339 defaultChecked={row?.environments.includes(env)}
340 label={environmentsLabel([env])}
341 />
Secrets and variables: one list, rows per environment, for workflows and deployments342 ))}
343 <input
344 name="envCustom"
345 defaultValue={custom.join(", ")}
346 placeholder="Others, comma-separated: staging, qa"
347 className={field}
348 />
349 <p className="text-xs text-faint">
350 Deployments are production and preview; a workflow job reads the row for its{" "}
351 <code>environment:</code>, and rows for all environments otherwise.
352 </p>
353 </div>
354 )}
355 </div>
356 </fieldset>
357
358 <fieldset>
359 <legend className="mb-2 text-sm font-medium text-muted">Available to</legend>
360 <div className="space-y-2 text-sm">
361 {(
362 [
363 ["workflows", "Workflows", "secrets.KEY or vars.KEY in GitHub Actions workflows"],
364 ["deployments", "Deployments", "The build's environment, and env.KEY in the running app"],
365 ] as const
366 ).map(([value, title, text]) => (
Workspace names and icons, and a component kit for every control367 <CheckboxOption
368 key={value}
369 name="availableTo"
370 value={value}
371 defaultChecked={row ? row.availableTo.includes(value) : true}
372 label={title}
373 description={text}
374 />
Secrets and variables: one list, rows per environment, for workflows and deployments375 ))}
376 </div>
377 </fieldset>
378
379 {scope === "workspace" && (
380 <fieldset>
Projects: what a workspace builds and runs, first on every page381 <legend className="mb-2 text-sm font-medium text-muted">Projects</legend>
Secrets and variables: one list, rows per environment, for workflows and deployments382 <div className="space-y-2 text-sm">
Workspace names and icons, and a component kit for every control383 <RadioGroup name="reach" value={reach} onValueChange={(value) => setReach(value as typeof reach)} aria-label="Projects">
384 <RadioOption value="all" label="Every project" />
385 <RadioOption value="some" label="Only these" />
386 </RadioGroup>
Secrets and variables: one list, rows per environment, for workflows and deployments387 {reach === "some" && (
Workspace names and icons, and a component kit for every control388 <div className="ml-6 grid max-h-48 gap-1.5 overflow-y-auto p-0.5 sm:grid-cols-2">
Projects: what a workspace builds and runs, first on every page389 {projects.map((name) => (
Workspace names and icons, and a component kit for every control390 <CheckboxOption
391 key={name}
392 name="project"
393 value={name}
394 defaultChecked={row?.projects.includes(name)}
395 label={name}
396 className="items-center"
397 labelClassName="font-mono text-xs"
398 />
Secrets and variables: one list, rows per environment, for workflows and deployments399 ))}
400 </div>
401 )}
402 </div>
403 </fieldset>
404 )}
405 <ErrorText>{error}</ErrorText>
406 </div>
407 <div className="flex items-center justify-between gap-4 border-t border-line px-6 py-4">
408 <p className="text-xs text-faint">{editing ? "" : "Paste .env contents into Key to add many."}</p>
Merge branch 'worktree-agent-a985c01f43538af01'409 <SubmitButton match={{ intent: "save" }} pending="Saving…">
Secrets and variables: one list, rows per environment, for workflows and deployments410 Save
Merge branch 'worktree-agent-a985c01f43538af01'411 </SubmitButton>
Secrets and variables: one list, rows per environment, for workflows and deployments412 </div>
413 </Form>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs414 </div>
415 );
416}

This file's history is long; its oldest lines are credited to the oldest commit read.