flagon-io/g1t

public

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

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

413 lines18,277 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";
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
91 the run, with <code className="text-fg">secrets.GITHUB_TOKEN</code> as its alias. Agents, acceptance checks
92 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) && (
Projects: what a workspace builds and runs, first on every page153 <Drawer row={row} scope={scope} projects={data.projects} error={action?.error} />
Secrets and variables: one list, rows per environment, for workflows and deployments154 )}
155 </div>
156 );
157}
158
159function Row({ row, inherited, manage }: { row: Setting; inherited: boolean; manage: boolean }) {
160 const busy = useNavigation().state === "submitting";
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} />
203 <button
204 type="submit"
205 disabled={busy}
206 aria-label={`Remove ${row.name}`}
207 className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-danger"
208 >
209 <Trash2 size={14} />
210 </button>
211 </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 busy = useNavigation().state === "submitting";
233 const editing = !!row;
234 const [type, setType] = useState<"secret" | "config">(row?.kind === "variable" ? "config" : "secret");
235 const [some, setSome] = useState(!!row && row.environments.length > 0);
Projects: what a workspace builds and runs, first on every page236 const [reach, setReach] = useState(row && row.projects.length > 0 ? "some" : "all");
Secrets and variables: one list, rows per environment, for workflows and deployments237 const custom = row?.environments.filter((env) => !KNOWN_ENVIRONMENTS.includes(env)) ?? [];
238 const field =
239 "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 docs240 return (
Secrets and variables: one list, rows per environment, for workflows and deployments241 <div className="fixed inset-0 z-50 flex justify-end bg-black/50" role="dialog" aria-modal="true" aria-label={editing ? "Edit" : "Add"}>
242 <Link to="?" aria-label="Close" className="grow" />
243 <Form method="post" className="flex h-full w-full max-w-xl flex-col border-l border-line bg-bg shadow-2xl">
244 <div className="flex items-center justify-between border-b border-line px-6 py-4">
245 <h3 className="font-semibold">{editing ? `Edit ${row.name}` : "Add a secret or variable"}</h3>
246 <Link to="?" aria-label="Close" className="rounded-md p-1.5 text-faint hover:bg-raised hover:text-fg">
247 <X size={16} />
248 </Link>
249 </div>
250 <div className="grow space-y-6 overflow-y-auto px-6 py-5">
251 <input type="hidden" name="intent" value="save" />
252 {row && <input type="hidden" name="id" value={row.id} />}
253
254 <fieldset>
255 <legend className="mb-2 text-sm font-medium text-muted">Type</legend>
Workspace names and icons, and a component kit for every control256 <RadioGroup
257 name="type"
258 value={type}
259 onValueChange={(value) => setType(value as typeof type)}
260 aria-label="Type"
261 className="gap-3 sm:grid-cols-2"
262 >
Secrets and variables: one list, rows per environment, for workflows and deployments263 {(
264 [
Workspace names and icons, and a component kit for every control265 ["secret", "Secret", "You can't read it again after saving. For passwords, API keys and tokens.", <Lock key="i" />],
266 ["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 deployments267 ] as const
Workspace names and icons, and a component kit for every control268 ).map(([value, title, text, icon]) => (
Secrets and variables: one list, rows per environment, for workflows and deployments269 // A secret's value is sealed: it can never become config.
Workspace names and icons, and a component kit for every control270 <RadioCard
271 key={value}
272 value={value}
273 title={title}
274 description={text}
275 icon={icon}
276 disabled={value === "config" && row?.kind === "secret"}
277 />
278 ))}
279 </RadioGroup>
Secrets and variables: one list, rows per environment, for workflows and deployments280 {row?.kind === "variable" && (
281 <p className="mt-2 text-xs text-faint">Config can become a secret; a secret cannot become config.</p>
282 )}
283 </fieldset>
284
285 <label className="block">
286 <span className="mb-1.5 block text-sm font-medium text-muted">Key</span>
287 {editing ? (
288 <input name="key" value={row.name} readOnly className={`${field} font-mono text-muted`} />
289 ) : (
290 <textarea
291 name="key"
292 required
293 rows={1}
294 placeholder="CLIENT_KEY, or paste a .env file"
295 autoComplete="off"
296 spellCheck={false}
297 className={`${field} min-h-10 font-mono`}
298 />
299 )}
300 </label>
301
302 <label className="block">
303 <span className="mb-1.5 block text-sm font-medium text-muted">Value</span>
304 <textarea
305 name="value"
306 rows={type === "secret" ? 3 : 2}
307 defaultValue={row?.kind === "variable" ? (row.value ?? "") : ""}
308 placeholder={
309 editing && row.kind === "secret" ? "Leave empty to keep the current value" : "Enter a value"
310 }
311 autoComplete="off"
312 spellCheck={false}
313 className={`${field} font-mono`}
314 />
315 </label>
316
317 <label className="block">
318 <span className="mb-1.5 block text-sm font-medium text-muted">Note (optional)</span>
319 <input name="note" defaultValue={row?.note ?? ""} placeholder="Where to rotate it, or who to ask" className={field} />
320 </label>
321
322 <fieldset>
323 <legend className="mb-2 text-sm font-medium text-muted">Environments</legend>
324 <div className="space-y-2 text-sm">
Workspace names and icons, and a component kit for every control325 <RadioGroup name="scope" value={some ? "some" : "all"} onValueChange={(value) => setSome(value === "some")} aria-label="Environments">
326 <RadioOption value="all" label="All environments" />
327 <RadioOption value="some" label="Only some" />
328 </RadioGroup>
Secrets and variables: one list, rows per environment, for workflows and deployments329 {some && (
330 <div className="ml-6 space-y-2">
331 {KNOWN_ENVIRONMENTS.map((env) => (
Workspace names and icons, and a component kit for every control332 <CheckboxOption
333 key={env}
334 name="env"
335 value={env}
336 defaultChecked={row?.environments.includes(env)}
337 label={environmentsLabel([env])}
338 />
Secrets and variables: one list, rows per environment, for workflows and deployments339 ))}
340 <input
341 name="envCustom"
342 defaultValue={custom.join(", ")}
343 placeholder="Others, comma-separated: staging, qa"
344 className={field}
345 />
346 <p className="text-xs text-faint">
347 Deployments are production and preview; a workflow job reads the row for its{" "}
348 <code>environment:</code>, and rows for all environments otherwise.
349 </p>
350 </div>
351 )}
352 </div>
353 </fieldset>
354
355 <fieldset>
356 <legend className="mb-2 text-sm font-medium text-muted">Available to</legend>
357 <div className="space-y-2 text-sm">
358 {(
359 [
360 ["workflows", "Workflows", "secrets.KEY or vars.KEY in GitHub Actions workflows"],
361 ["deployments", "Deployments", "The build's environment, and env.KEY in the running app"],
362 ] as const
363 ).map(([value, title, text]) => (
Workspace names and icons, and a component kit for every control364 <CheckboxOption
365 key={value}
366 name="availableTo"
367 value={value}
368 defaultChecked={row ? row.availableTo.includes(value) : true}
369 label={title}
370 description={text}
371 />
Secrets and variables: one list, rows per environment, for workflows and deployments372 ))}
373 </div>
374 </fieldset>
375
376 {scope === "workspace" && (
377 <fieldset>
Projects: what a workspace builds and runs, first on every page378 <legend className="mb-2 text-sm font-medium text-muted">Projects</legend>
Secrets and variables: one list, rows per environment, for workflows and deployments379 <div className="space-y-2 text-sm">
Workspace names and icons, and a component kit for every control380 <RadioGroup name="reach" value={reach} onValueChange={(value) => setReach(value as typeof reach)} aria-label="Projects">
381 <RadioOption value="all" label="Every project" />
382 <RadioOption value="some" label="Only these" />
383 </RadioGroup>
Secrets and variables: one list, rows per environment, for workflows and deployments384 {reach === "some" && (
Workspace names and icons, and a component kit for every control385 <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 page386 {projects.map((name) => (
Workspace names and icons, and a component kit for every control387 <CheckboxOption
388 key={name}
389 name="project"
390 value={name}
391 defaultChecked={row?.projects.includes(name)}
392 label={name}
393 className="items-center"
394 labelClassName="font-mono text-xs"
395 />
Secrets and variables: one list, rows per environment, for workflows and deployments396 ))}
397 </div>
398 )}
399 </div>
400 </fieldset>
401 )}
402 <ErrorText>{error}</ErrorText>
403 </div>
404 <div className="flex items-center justify-between gap-4 border-t border-line px-6 py-4">
405 <p className="text-xs text-faint">{editing ? "" : "Paste .env contents into Key to add many."}</p>
406 <Button type="submit" disabled={busy}>
407 Save
408 </Button>
409 </div>
410 </Form>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs411 </div>
412 );
413}