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

424 lines19,046 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";
10import { Form, Link, useLocation, useNavigation } 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";
Secrets and variables: one list, rows per environment, for workflows and deployments15import { Button, ButtonLink, EmptyState, ErrorText, TimeAgo } from "./ui";
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs16
Secrets and variables: one list, rows per environment, for workflows and deployments17/** The environments every deployment knows; workflow jobs may name others. */
18const KNOWN_ENVIRONMENTS = ["production", "preview"];
19
20const READERS: Record<string, string> = { workflows: "Workflows", deployments: "Deployments" };
21
22function environmentsLabel(environments: string[]): string {
23 if (environments.length === 0) return "All environments";
24 return environments.map((env) => env.charAt(0).toUpperCase() + env.slice(1)).join(", ");
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs25}
26
Secrets and variables: one list, rows per environment, for workflows and deployments27const SELECT =
28 "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";
29
30export function SecretsPanel({
31 data,
32 action,
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs33 scope,
34 manage,
35}: {
Secrets and variables: one list, rows per environment, for workflows and deployments36 data: SecretsData;
37 action: SecretsAction | undefined;
Projects: what a workspace builds and runs, first on every page38 scope: "project" | "workspace";
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs39 manage: boolean;
40}) {
Secrets and variables: one list, rows per environment, for workflows and deployments41 const location = useLocation();
42 const params = new URLSearchParams(location.search);
43 const editing = params.get("edit");
44 const adding = params.has("add");
45 const row = editing ? data.rows.find((r) => r.id === editing && r.scope === scope) : undefined;
46 const [query, setQuery] = useState("");
47 const [type, setType] = useState("all");
48 const [environment, setEnvironment] = useState("all");
49 const environments = useMemo(
50 () => [...new Set([...KNOWN_ENVIRONMENTS, ...data.rows.flatMap((r) => r.environments)])],
51 [data.rows],
52 );
53 const shown = data.rows.filter(
54 (r) =>
55 (!query || r.name.toLowerCase().includes(query.toLowerCase()) || r.note?.toLowerCase().includes(query.toLowerCase())) &&
56 (type === "all" || r.kind === type) &&
57 (environment === "all" || r.environments.length === 0 || r.environments.includes(environment)),
58 );
59
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs60 return (
Secrets and variables: one list, rows per environment, for workflows and deployments61 <div className="max-w-5xl">
62 <header className="flex flex-wrap items-start justify-between gap-4">
63 <div>
64 <h2 className="text-lg font-semibold tracking-tight">Secrets and variables</h2>
65 <p className="mt-1 max-w-2xl text-sm text-muted">
66 One list for everything that reads them. Each row says which environments it applies to and whether{" "}
67 <strong className="font-medium text-fg">workflows</strong> (as <code className="text-fg">secrets.KEY</code>{" "}
68 and <code className="text-fg">vars.KEY</code>), <strong className="font-medium text-fg">deployments</strong>{" "}
69 (the build's environment and the running app's <code className="text-fg">env.KEY</code>), or both read it.
70 {scope === "workspace"
Projects: what a workspace builds and runs, first on every page71 ? " Every project, or the ones you link, reads the workspace's; a project's own row of the same key wins."
72 : " 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 deployments73 <a href="https://docs.g1t.sh/guides/secrets-and-variables/" className="text-fg hover:underline">
74 How they are read
75 </a>
76 </p>
77 </div>
78 {manage && (
79 <ButtonLink to="?add" variant="accent">
80 <Plus size={14} />
81 Add
82 </ButtonLink>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs83 )}
Secrets and variables: one list, rows per environment, for workflows and deployments84 </header>
85
86 <p className="mt-4 rounded-lg border border-line bg-surface px-4 py-2.5 text-xs text-muted">
87 Built in: workflows get <code className="text-fg">secrets.G1T_TOKEN</code>, the workspace's own token for
88 the run, with <code className="text-fg">secrets.GITHUB_TOKEN</code> as its alias. Agents, acceptance checks
89 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 docs90 </p>
Secrets and variables: one list, rows per environment, for workflows and deployments91
92 <div className="mt-5 flex flex-wrap gap-2">
93 <label className="relative min-w-56 grow">
94 <Search size={14} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" />
95 <input
96 value={query}
97 onChange={(e) => setQuery(e.target.value)}
98 placeholder="Search keys and notes"
99 aria-label="Search"
100 className={`${SELECT} w-full pl-9`}
101 />
102 </label>
103 <select value={type} onChange={(e) => setType(e.target.value)} aria-label="Type" className={SELECT}>
104 <option value="all">All types</option>
105 <option value="secret">Secret</option>
106 <option value="variable">Config</option>
107 </select>
108 <select value={environment} onChange={(e) => setEnvironment(e.target.value)} aria-label="Environment" className={SELECT}>
109 <option value="all">All environments</option>
110 {environments.map((env) => (
111 <option key={env} value={env}>
112 {environmentsLabel([env])}
113 </option>
114 ))}
115 </select>
116 </div>
117
118 <ErrorText>{data.error}</ErrorText>
119 {!editing && !adding && <div className="mt-2"><ErrorText>{action?.error}</ErrorText></div>}
120
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs121 <div className="mt-4">
Secrets and variables: one list, rows per environment, for workflows and deployments122 {data.rows.length === 0 ? (
123 <EmptyState title="No secrets or variables yet">
124 Add one, or paste a <code>.env</code> file into Add to bring many at once.
125 </EmptyState>
126 ) : shown.length === 0 ? (
127 <EmptyState title="Nothing matches" />
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs128 ) : (
129 <ul className="overflow-hidden rounded-xl border border-line bg-surface">
Secrets and variables: one list, rows per environment, for workflows and deployments130 {shown.map((r) => (
131 <Row key={r.id} row={r} inherited={r.scope !== scope} manage={manage} />
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs132 ))}
133 </ul>
134 )}
135 </div>
Secrets and variables: one list, rows per environment, for workflows and deployments136
137 {manage && (adding || row) && (
Projects: what a workspace builds and runs, first on every page138 <Drawer row={row} scope={scope} projects={data.projects} error={action?.error} />
Secrets and variables: one list, rows per environment, for workflows and deployments139 )}
140 </div>
141 );
142}
143
144function Row({ row, inherited, manage }: { row: Setting; inherited: boolean; manage: boolean }) {
145 const busy = useNavigation().state === "submitting";
146 const secret = row.kind === "secret";
147 return (
148 <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]">
149 <div className="min-w-0">
150 <p className="truncate font-mono text-[0.8125rem]">{row.name}</p>
151 {row.note && <p className="truncate text-xs text-faint">{row.note}</p>}
152 {!secret && row.value != null && <p className="truncate font-mono text-xs text-muted">{row.value}</p>}
153 </div>
154 <span className="truncate text-muted">{environmentsLabel(row.environments)}</span>
155 <span className="hidden truncate text-xs text-muted md:block">
156 {row.availableTo.map((r) => READERS[r] ?? r).join(" · ")}
157 </span>
158 <span className="hidden items-center gap-1.5 text-xs text-muted md:flex">
159 {secret ? <Lock size={13} /> : <SlidersHorizontal size={13} />}
160 {secret ? "Secret" : "Config"}
161 </span>
162 <span className="hidden text-xs text-faint md:block">
163 <TimeAgo at={row.updatedAt} />
164 </span>
165 <span className="flex items-center justify-end gap-1">
166 {inherited ? (
167 <span className="rounded-full px-2 py-px text-xs text-muted ring-1 ring-line">Workspace</span>
168 ) : (
169 <>
Projects: what a workspace builds and runs, first on every page170 {row.projects.length > 0 && (
171 <span className="mr-1 text-xs text-faint" title={row.projects.join(", ")}>
172 {row.projects.length} {row.projects.length === 1 ? "project" : "projects"}
Secrets and variables: one list, rows per environment, for workflows and deployments173 </span>
174 )}
175 {manage && (
176 <>
177 <Link
178 to={`?edit=${row.id}`}
179 aria-label={`Edit ${row.name}`}
180 className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-fg"
181 >
182 <Pencil size={14} />
183 </Link>
184 <Form method="post">
185 <input type="hidden" name="intent" value="delete" />
186 <input type="hidden" name="id" value={row.id} />
187 <input type="hidden" name="name" value={row.name} />
188 <button
189 type="submit"
190 disabled={busy}
191 aria-label={`Remove ${row.name}`}
192 className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-danger"
193 >
194 <Trash2 size={14} />
195 </button>
196 </Form>
197 </>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs198 )}
Secrets and variables: one list, rows per environment, for workflows and deployments199 </>
200 )}
201 </span>
202 </li>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs203 );
204}
205
Secrets and variables: one list, rows per environment, for workflows and deployments206function Drawer({
207 row,
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs208 scope,
Projects: what a workspace builds and runs, first on every page209 projects,
Secrets and variables: one list, rows per environment, for workflows and deployments210 error,
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs211}: {
Secrets and variables: one list, rows per environment, for workflows and deployments212 row: Setting | undefined;
Projects: what a workspace builds and runs, first on every page213 scope: "project" | "workspace";
214 projects: string[];
Secrets and variables: one list, rows per environment, for workflows and deployments215 error: string | undefined;
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs216}) {
Secrets and variables: one list, rows per environment, for workflows and deployments217 const busy = useNavigation().state === "submitting";
218 const editing = !!row;
219 const [type, setType] = useState<"secret" | "config">(row?.kind === "variable" ? "config" : "secret");
220 const [some, setSome] = useState(!!row && row.environments.length > 0);
Projects: what a workspace builds and runs, first on every page221 const [reach, setReach] = useState(row && row.projects.length > 0 ? "some" : "all");
Secrets and variables: one list, rows per environment, for workflows and deployments222 const custom = row?.environments.filter((env) => !KNOWN_ENVIRONMENTS.includes(env)) ?? [];
223 const field =
224 "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 docs225 return (
Secrets and variables: one list, rows per environment, for workflows and deployments226 <div className="fixed inset-0 z-50 flex justify-end bg-black/50" role="dialog" aria-modal="true" aria-label={editing ? "Edit" : "Add"}>
227 <Link to="?" aria-label="Close" className="grow" />
228 <Form method="post" className="flex h-full w-full max-w-xl flex-col border-l border-line bg-bg shadow-2xl">
229 <div className="flex items-center justify-between border-b border-line px-6 py-4">
230 <h3 className="font-semibold">{editing ? `Edit ${row.name}` : "Add a secret or variable"}</h3>
231 <Link to="?" aria-label="Close" className="rounded-md p-1.5 text-faint hover:bg-raised hover:text-fg">
232 <X size={16} />
233 </Link>
234 </div>
235 <div className="grow space-y-6 overflow-y-auto px-6 py-5">
236 <input type="hidden" name="intent" value="save" />
237 {row && <input type="hidden" name="id" value={row.id} />}
238
239 <fieldset>
240 <legend className="mb-2 text-sm font-medium text-muted">Type</legend>
241 <div className="grid gap-3 sm:grid-cols-2">
242 {(
243 [
244 ["secret", "Secret", "You can't read it again after saving. For passwords, API keys and tokens."],
245 ["config", "Config", "Readable by members after saving. For values that are not sensitive."],
246 ] as const
247 ).map(([value, title, text]) => {
248 // A secret's value is sealed: it can never become config.
249 const locked = value === "config" && row?.kind === "secret";
250 return (
251 <label
252 key={value}
253 className={`rounded-xl border p-3.5 transition-colors ${
254 type === value ? "border-accent bg-accent/5" : "border-line hover:border-line-strong"
255 } ${locked ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}
256 >
257 <span className="flex items-center justify-between">
258 <span className="text-sm font-medium">{title}</span>
259 <input
260 type="radio"
261 name="type"
262 value={value}
263 checked={type === value}
264 disabled={locked}
265 onChange={() => setType(value)}
266 className="accent-accent"
267 />
268 </span>
269 <span className="mt-1 block text-xs text-muted">{text}</span>
270 </label>
271 );
272 })}
273 </div>
274 {row?.kind === "variable" && (
275 <p className="mt-2 text-xs text-faint">Config can become a secret; a secret cannot become config.</p>
276 )}
277 </fieldset>
278
279 <label className="block">
280 <span className="mb-1.5 block text-sm font-medium text-muted">Key</span>
281 {editing ? (
282 <input name="key" value={row.name} readOnly className={`${field} font-mono text-muted`} />
283 ) : (
284 <textarea
285 name="key"
286 required
287 rows={1}
288 placeholder="CLIENT_KEY, or paste a .env file"
289 autoComplete="off"
290 spellCheck={false}
291 className={`${field} min-h-10 font-mono`}
292 />
293 )}
294 </label>
295
296 <label className="block">
297 <span className="mb-1.5 block text-sm font-medium text-muted">Value</span>
298 <textarea
299 name="value"
300 rows={type === "secret" ? 3 : 2}
301 defaultValue={row?.kind === "variable" ? (row.value ?? "") : ""}
302 placeholder={
303 editing && row.kind === "secret" ? "Leave empty to keep the current value" : "Enter a value"
304 }
305 autoComplete="off"
306 spellCheck={false}
307 className={`${field} font-mono`}
308 />
309 </label>
310
311 <label className="block">
312 <span className="mb-1.5 block text-sm font-medium text-muted">Note (optional)</span>
313 <input name="note" defaultValue={row?.note ?? ""} placeholder="Where to rotate it, or who to ask" className={field} />
314 </label>
315
316 <fieldset>
317 <legend className="mb-2 text-sm font-medium text-muted">Environments</legend>
318 <div className="space-y-2 text-sm">
319 <label className="flex items-center gap-2">
320 <input type="radio" name="scope" value="all" checked={!some} onChange={() => setSome(false)} className="accent-accent" />
321 All environments
322 </label>
323 <label className="flex items-center gap-2">
324 <input type="radio" name="scope" value="some" checked={some} onChange={() => setSome(true)} className="accent-accent" />
325 Only some
326 </label>
327 {some && (
328 <div className="ml-6 space-y-2">
329 {KNOWN_ENVIRONMENTS.map((env) => (
330 <label key={env} className="flex items-center gap-2">
331 <input
332 type="checkbox"
333 name="env"
334 value={env}
335 defaultChecked={row?.environments.includes(env)}
336 className="accent-accent"
337 />
338 {environmentsLabel([env])}
339 </label>
340 ))}
341 <input
342 name="envCustom"
343 defaultValue={custom.join(", ")}
344 placeholder="Others, comma-separated: staging, qa"
345 className={field}
346 />
347 <p className="text-xs text-faint">
348 Deployments are production and preview; a workflow job reads the row for its{" "}
349 <code>environment:</code>, and rows for all environments otherwise.
350 </p>
351 </div>
352 )}
353 </div>
354 </fieldset>
355
356 <fieldset>
357 <legend className="mb-2 text-sm font-medium text-muted">Available to</legend>
358 <div className="space-y-2 text-sm">
359 {(
360 [
361 ["workflows", "Workflows", "secrets.KEY or vars.KEY in GitHub Actions workflows"],
362 ["deployments", "Deployments", "The build's environment, and env.KEY in the running app"],
363 ] as const
364 ).map(([value, title, text]) => (
365 <label key={value} className="flex items-start gap-2">
366 <input
367 type="checkbox"
368 name="availableTo"
369 value={value}
370 defaultChecked={row ? row.availableTo.includes(value) : true}
371 className="mt-1 accent-accent"
372 />
373 <span>
374 {title}
375 <span className="block text-xs text-faint">{text}</span>
376 </span>
377 </label>
378 ))}
379 </div>
380 </fieldset>
381
382 {scope === "workspace" && (
383 <fieldset>
Projects: what a workspace builds and runs, first on every page384 <legend className="mb-2 text-sm font-medium text-muted">Projects</legend>
Secrets and variables: one list, rows per environment, for workflows and deployments385 <div className="space-y-2 text-sm">
386 <label className="flex items-center gap-2">
387 <input type="radio" name="reach" value="all" checked={reach === "all"} onChange={() => setReach("all")} className="accent-accent" />
Projects: what a workspace builds and runs, first on every page388 Every project
Secrets and variables: one list, rows per environment, for workflows and deployments389 </label>
390 <label className="flex items-center gap-2">
391 <input type="radio" name="reach" value="some" checked={reach === "some"} onChange={() => setReach("some")} className="accent-accent" />
392 Only these
393 </label>
394 {reach === "some" && (
395 <div className="ml-6 grid max-h-48 gap-1.5 overflow-y-auto sm:grid-cols-2">
Projects: what a workspace builds and runs, first on every page396 {projects.map((name) => (
Secrets and variables: one list, rows per environment, for workflows and deployments397 <label key={name} className="flex items-center gap-2 font-mono text-xs">
398 <input
399 type="checkbox"
Projects: what a workspace builds and runs, first on every page400 name="project"
Secrets and variables: one list, rows per environment, for workflows and deployments401 value={name}
Projects: what a workspace builds and runs, first on every page402 defaultChecked={row?.projects.includes(name)}
Secrets and variables: one list, rows per environment, for workflows and deployments403 className="accent-accent"
404 />
405 {name}
406 </label>
407 ))}
408 </div>
409 )}
410 </div>
411 </fieldset>
412 )}
413 <ErrorText>{error}</ErrorText>
414 </div>
415 <div className="flex items-center justify-between gap-4 border-t border-line px-6 py-4">
416 <p className="text-xs text-faint">{editing ? "" : "Paste .env contents into Key to add many."}</p>
417 <Button type="submit" disabled={busy}>
418 Save
419 </Button>
420 </div>
421 </Form>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs422 </div>
423 );
424}