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

412 lines18,332 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";
17import { RadioCard, RadioGroup, RadioOption } from "./ui/radio-group";
18import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from "./ui/select";
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs19
Secrets and variables: one list, rows per environment, for workflows and deployments20/** The environments every deployment knows; workflow jobs may name others. */
21const KNOWN_ENVIRONMENTS = ["production", "preview"];
22
23const READERS: Record<string, string> = { workflows: "Workflows", deployments: "Deployments" };
24
25function environmentsLabel(environments: string[]): string {
26 if (environments.length === 0) return "All environments";
27 return environments.map((env) => env.charAt(0).toUpperCase() + env.slice(1)).join(", ");
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs28}
29
Secrets and variables: one list, rows per environment, for workflows and deployments30const SELECT =
31 "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";
32
33export function SecretsPanel({
34 data,
35 action,
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs36 scope,
37 manage,
38}: {
Secrets and variables: one list, rows per environment, for workflows and deployments39 data: SecretsData;
40 action: SecretsAction | undefined;
Projects: what a workspace builds and runs, first on every page41 scope: "project" | "workspace";
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs42 manage: boolean;
43}) {
Secrets and variables: one list, rows per environment, for workflows and deployments44 const location = useLocation();
45 const params = new URLSearchParams(location.search);
46 const editing = params.get("edit");
47 const adding = params.has("add");
48 const row = editing ? data.rows.find((r) => r.id === editing && r.scope === scope) : undefined;
49 const [query, setQuery] = useState("");
50 const [type, setType] = useState("all");
51 const [environment, setEnvironment] = useState("all");
52 const environments = useMemo(
53 () => [...new Set([...KNOWN_ENVIRONMENTS, ...data.rows.flatMap((r) => r.environments)])],
54 [data.rows],
55 );
56 const shown = data.rows.filter(
57 (r) =>
58 (!query || r.name.toLowerCase().includes(query.toLowerCase()) || r.note?.toLowerCase().includes(query.toLowerCase())) &&
59 (type === "all" || r.kind === type) &&
60 (environment === "all" || r.environments.length === 0 || r.environments.includes(environment)),
61 );
62
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs63 return (
Secrets and variables: one list, rows per environment, for workflows and deployments64 <div className="max-w-5xl">
65 <header className="flex flex-wrap items-start justify-between gap-4">
66 <div>
67 <h2 className="text-lg font-semibold tracking-tight">Secrets and variables</h2>
68 <p className="mt-1 max-w-2xl text-sm text-muted">
69 One list for everything that reads them. Each row says which environments it applies to and whether{" "}
70 <strong className="font-medium text-fg">workflows</strong> (as <code className="text-fg">secrets.KEY</code>{" "}
71 and <code className="text-fg">vars.KEY</code>), <strong className="font-medium text-fg">deployments</strong>{" "}
72 (the build's environment and the running app's <code className="text-fg">env.KEY</code>), or both read it.
73 {scope === "workspace"
Projects: what a workspace builds and runs, first on every page74 ? " Every project, or the ones you link, reads the workspace's; a project's own row of the same key wins."
75 : " 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 deployments76 <a href="https://docs.g1t.sh/guides/secrets-and-variables/" className="text-fg hover:underline">
77 How they are read
78 </a>
79 </p>
80 </div>
81 {manage && (
82 <ButtonLink to="?add" variant="accent">
83 <Plus size={14} />
84 Add
85 </ButtonLink>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs86 )}
Secrets and variables: one list, rows per environment, for workflows and deployments87 </header>
88
89 <p className="mt-4 rounded-lg border border-line bg-surface px-4 py-2.5 text-xs text-muted">
90 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 incidents91 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 deployments92 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 docs93 </p>
Secrets and variables: one list, rows per environment, for workflows and deployments94
95 <div className="mt-5 flex flex-wrap gap-2">
96 <label className="relative min-w-56 grow">
97 <Search size={14} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" />
98 <input
99 value={query}
100 onChange={(e) => setQuery(e.target.value)}
101 placeholder="Search keys and notes"
102 aria-label="Search"
103 className={`${SELECT} w-full pl-9`}
104 />
105 </label>
Workspace names and icons, and a component kit for every control106 <Select value={type} onValueChange={setType}>
107 <SelectTrigger aria-label="Type" className="h-auto w-auto py-2">
108 <SelectValue />
109 </SelectTrigger>
110 <SelectContent align="end">
111 <SelectItem value="all">All types</SelectItem>
112 <SelectSeparator />
113 <SelectItem value="secret" icon={<Lock />}>Secret</SelectItem>
114 <SelectItem value="variable" icon={<SlidersHorizontal />}>Config</SelectItem>
115 </SelectContent>
116 </Select>
117 <Select value={environment} onValueChange={setEnvironment}>
118 <SelectTrigger aria-label="Environment" className="h-auto w-auto py-2">
119 <SelectValue />
120 </SelectTrigger>
121 <SelectContent align="end">
122 <SelectItem value="all">All environments</SelectItem>
123 {environments.length > 0 && <SelectSeparator />}
124 {environments.map((env) => (
125 <SelectItem key={env} value={env}>
126 {environmentsLabel([env])}
127 </SelectItem>
128 ))}
129 </SelectContent>
130 </Select>
Secrets and variables: one list, rows per environment, for workflows and deployments131 </div>
132
133 <ErrorText>{data.error}</ErrorText>
134 {!editing && !adding && <div className="mt-2"><ErrorText>{action?.error}</ErrorText></div>}
135
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs136 <div className="mt-4">
Secrets and variables: one list, rows per environment, for workflows and deployments137 {data.rows.length === 0 ? (
138 <EmptyState title="No secrets or variables yet">
139 Add one, or paste a <code>.env</code> file into Add to bring many at once.
140 </EmptyState>
141 ) : shown.length === 0 ? (
142 <EmptyState title="Nothing matches" />
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs143 ) : (
144 <ul className="overflow-hidden rounded-xl border border-line bg-surface">
Secrets and variables: one list, rows per environment, for workflows and deployments145 {shown.map((r) => (
146 <Row key={r.id} row={r} inherited={r.scope !== scope} manage={manage} />
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs147 ))}
148 </ul>
149 )}
150 </div>
Secrets and variables: one list, rows per environment, for workflows and deployments151
152 {manage && (adding || row) && (
Merge branch 'worktree-agent-a985c01f43538af01'153 // Keyed to the row, so going from one row's edit to another's starts from that row.
154 <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 deployments155 )}
156 </div>
157 );
158}
159
160function Row({ row, inherited, manage }: { row: Setting; inherited: boolean; manage: boolean }) {
161 const secret = row.kind === "secret";
162 return (
163 <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]">
164 <div className="min-w-0">
165 <p className="truncate font-mono text-[0.8125rem]">{row.name}</p>
166 {row.note && <p className="truncate text-xs text-faint">{row.note}</p>}
167 {!secret && row.value != null && <p className="truncate font-mono text-xs text-muted">{row.value}</p>}
168 </div>
169 <span className="truncate text-muted">{environmentsLabel(row.environments)}</span>
170 <span className="hidden truncate text-xs text-muted md:block">
171 {row.availableTo.map((r) => READERS[r] ?? r).join(" · ")}
172 </span>
173 <span className="hidden items-center gap-1.5 text-xs text-muted md:flex">
174 {secret ? <Lock size={13} /> : <SlidersHorizontal size={13} />}
175 {secret ? "Secret" : "Config"}
176 </span>
177 <span className="hidden text-xs text-faint md:block">
178 <TimeAgo at={row.updatedAt} />
179 </span>
180 <span className="flex items-center justify-end gap-1">
181 {inherited ? (
182 <span className="rounded-full px-2 py-px text-xs text-muted ring-1 ring-line">Workspace</span>
183 ) : (
184 <>
Projects: what a workspace builds and runs, first on every page185 {row.projects.length > 0 && (
186 <span className="mr-1 text-xs text-faint" title={row.projects.join(", ")}>
187 {row.projects.length} {row.projects.length === 1 ? "project" : "projects"}
Secrets and variables: one list, rows per environment, for workflows and deployments188 </span>
189 )}
190 {manage && (
191 <>
192 <Link
193 to={`?edit=${row.id}`}
194 aria-label={`Edit ${row.name}`}
195 className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-fg"
196 >
197 <Pencil size={14} />
198 </Link>
199 <Form method="post">
200 <input type="hidden" name="intent" value="delete" />
201 <input type="hidden" name="id" value={row.id} />
202 <input type="hidden" name="name" value={row.name} />
Merge branch 'worktree-agent-a985c01f43538af01'203 <SubmitButton
204 icon
205 match={{ intent: "delete", id: row.id }}
Secrets and variables: one list, rows per environment, for workflows and deployments206 aria-label={`Remove ${row.name}`}
Merge branch 'worktree-agent-a985c01f43538af01'207 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 deployments208 >
209 <Trash2 size={14} />
Merge branch 'worktree-agent-a985c01f43538af01'210 </SubmitButton>
Secrets and variables: one list, rows per environment, for workflows and deployments211 </Form>
212 </>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs213 )}
Secrets and variables: one list, rows per environment, for workflows and deployments214 </>
215 )}
216 </span>
217 </li>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs218 );
219}
220
Secrets and variables: one list, rows per environment, for workflows and deployments221function Drawer({
222 row,
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs223 scope,
Projects: what a workspace builds and runs, first on every page224 projects,
Secrets and variables: one list, rows per environment, for workflows and deployments225 error,
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs226}: {
Secrets and variables: one list, rows per environment, for workflows and deployments227 row: Setting | undefined;
Projects: what a workspace builds and runs, first on every page228 scope: "project" | "workspace";
229 projects: string[];
Secrets and variables: one list, rows per environment, for workflows and deployments230 error: string | undefined;
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs231}) {
Secrets and variables: one list, rows per environment, for workflows and deployments232 const editing = !!row;
233 const [type, setType] = useState<"secret" | "config">(row?.kind === "variable" ? "config" : "secret");
234 const [some, setSome] = useState(!!row && row.environments.length > 0);
Projects: what a workspace builds and runs, first on every page235 const [reach, setReach] = useState(row && row.projects.length > 0 ? "some" : "all");
Secrets and variables: one list, rows per environment, for workflows and deployments236 const custom = row?.environments.filter((env) => !KNOWN_ENVIRONMENTS.includes(env)) ?? [];
237 const field =
238 "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 docs239 return (
Secrets and variables: one list, rows per environment, for workflows and deployments240 <div className="fixed inset-0 z-50 flex justify-end bg-black/50" role="dialog" aria-modal="true" aria-label={editing ? "Edit" : "Add"}>
241 <Link to="?" aria-label="Close" className="grow" />
242 <Form method="post" className="flex h-full w-full max-w-xl flex-col border-l border-line bg-bg shadow-2xl">
243 <div className="flex items-center justify-between border-b border-line px-6 py-4">
244 <h3 className="font-semibold">{editing ? `Edit ${row.name}` : "Add a secret or variable"}</h3>
245 <Link to="?" aria-label="Close" className="rounded-md p-1.5 text-faint hover:bg-raised hover:text-fg">
246 <X size={16} />
247 </Link>
248 </div>
249 <div className="grow space-y-6 overflow-y-auto px-6 py-5">
250 <input type="hidden" name="intent" value="save" />
251 {row && <input type="hidden" name="id" value={row.id} />}
252
253 <fieldset>
254 <legend className="mb-2 text-sm font-medium text-muted">Type</legend>
Workspace names and icons, and a component kit for every control255 <RadioGroup
256 name="type"
257 value={type}
258 onValueChange={(value) => setType(value as typeof type)}
259 aria-label="Type"
260 className="gap-3 sm:grid-cols-2"
261 >
Secrets and variables: one list, rows per environment, for workflows and deployments262 {(
263 [
Workspace names and icons, and a component kit for every control264 ["secret", "Secret", "You can't read it again after saving. For passwords, API keys and tokens.", <Lock key="i" />],
265 ["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 deployments266 ] as const
Workspace names and icons, and a component kit for every control267 ).map(([value, title, text, icon]) => (
Secrets and variables: one list, rows per environment, for workflows and deployments268 // A secret's value is sealed: it can never become config.
Workspace names and icons, and a component kit for every control269 <RadioCard
270 key={value}
271 value={value}
272 title={title}
273 description={text}
274 icon={icon}
275 disabled={value === "config" && row?.kind === "secret"}
276 />
277 ))}
278 </RadioGroup>
Secrets and variables: one list, rows per environment, for workflows and deployments279 {row?.kind === "variable" && (
280 <p className="mt-2 text-xs text-faint">Config can become a secret; a secret cannot become config.</p>
281 )}
282 </fieldset>
283
284 <label className="block">
285 <span className="mb-1.5 block text-sm font-medium text-muted">Key</span>
286 {editing ? (
287 <input name="key" value={row.name} readOnly className={`${field} font-mono text-muted`} />
288 ) : (
289 <textarea
290 name="key"
291 required
292 rows={1}
293 placeholder="CLIENT_KEY, or paste a .env file"
294 autoComplete="off"
295 spellCheck={false}
296 className={`${field} min-h-10 font-mono`}
297 />
298 )}
299 </label>
300
301 <label className="block">
302 <span className="mb-1.5 block text-sm font-medium text-muted">Value</span>
303 <textarea
304 name="value"
305 rows={type === "secret" ? 3 : 2}
306 defaultValue={row?.kind === "variable" ? (row.value ?? "") : ""}
307 placeholder={
308 editing && row.kind === "secret" ? "Leave empty to keep the current value" : "Enter a value"
309 }
310 autoComplete="off"
311 spellCheck={false}
312 className={`${field} font-mono`}
313 />
314 </label>
315
316 <label className="block">
317 <span className="mb-1.5 block text-sm font-medium text-muted">Note (optional)</span>
318 <input name="note" defaultValue={row?.note ?? ""} placeholder="Where to rotate it, or who to ask" className={field} />
319 </label>
320
321 <fieldset>
322 <legend className="mb-2 text-sm font-medium text-muted">Environments</legend>
323 <div className="space-y-2 text-sm">
Workspace names and icons, and a component kit for every control324 <RadioGroup name="scope" value={some ? "some" : "all"} onValueChange={(value) => setSome(value === "some")} aria-label="Environments">
325 <RadioOption value="all" label="All environments" />
326 <RadioOption value="some" label="Only some" />
327 </RadioGroup>
Secrets and variables: one list, rows per environment, for workflows and deployments328 {some && (
329 <div className="ml-6 space-y-2">
330 {KNOWN_ENVIRONMENTS.map((env) => (
Workspace names and icons, and a component kit for every control331 <CheckboxOption
332 key={env}
333 name="env"
334 value={env}
335 defaultChecked={row?.environments.includes(env)}
336 label={environmentsLabel([env])}
337 />
Secrets and variables: one list, rows per environment, for workflows and deployments338 ))}
339 <input
340 name="envCustom"
341 defaultValue={custom.join(", ")}
342 placeholder="Others, comma-separated: staging, qa"
343 className={field}
344 />
345 <p className="text-xs text-faint">
346 Deployments are production and preview; a workflow job reads the row for its{" "}
347 <code>environment:</code>, and rows for all environments otherwise.
348 </p>
349 </div>
350 )}
351 </div>
352 </fieldset>
353
354 <fieldset>
355 <legend className="mb-2 text-sm font-medium text-muted">Available to</legend>
356 <div className="space-y-2 text-sm">
357 {(
358 [
359 ["workflows", "Workflows", "secrets.KEY or vars.KEY in GitHub Actions workflows"],
360 ["deployments", "Deployments", "The build's environment, and env.KEY in the running app"],
361 ] as const
362 ).map(([value, title, text]) => (
Workspace names and icons, and a component kit for every control363 <CheckboxOption
364 key={value}
365 name="availableTo"
366 value={value}
367 defaultChecked={row ? row.availableTo.includes(value) : true}
368 label={title}
369 description={text}
370 />
Secrets and variables: one list, rows per environment, for workflows and deployments371 ))}
372 </div>
373 </fieldset>
374
375 {scope === "workspace" && (
376 <fieldset>
Projects: what a workspace builds and runs, first on every page377 <legend className="mb-2 text-sm font-medium text-muted">Projects</legend>
Secrets and variables: one list, rows per environment, for workflows and deployments378 <div className="space-y-2 text-sm">
Workspace names and icons, and a component kit for every control379 <RadioGroup name="reach" value={reach} onValueChange={(value) => setReach(value as typeof reach)} aria-label="Projects">
380 <RadioOption value="all" label="Every project" />
381 <RadioOption value="some" label="Only these" />
382 </RadioGroup>
Secrets and variables: one list, rows per environment, for workflows and deployments383 {reach === "some" && (
Workspace names and icons, and a component kit for every control384 <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 page385 {projects.map((name) => (
Workspace names and icons, and a component kit for every control386 <CheckboxOption
387 key={name}
388 name="project"
389 value={name}
390 defaultChecked={row?.projects.includes(name)}
391 label={name}
392 className="items-center"
393 labelClassName="font-mono text-xs"
394 />
Secrets and variables: one list, rows per environment, for workflows and deployments395 ))}
396 </div>
397 )}
398 </div>
399 </fieldset>
400 )}
401 <ErrorText>{error}</ErrorText>
402 </div>
403 <div className="flex items-center justify-between gap-4 border-t border-line px-6 py-4">
404 <p className="text-xs text-faint">{editing ? "" : "Paste .env contents into Key to add many."}</p>
Merge branch 'worktree-agent-a985c01f43538af01'405 <SubmitButton match={{ intent: "save" }} pending="Saving…">
Secrets and variables: one list, rows per environment, for workflows and deployments406 Save
Merge branch 'worktree-agent-a985c01f43538af01'407 </SubmitButton>
Secrets and variables: one list, rows per environment, for workflows and deployments408 </div>
409 </Form>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs410 </div>
411 );
412}

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