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
1/**
2 * A project's or a workspace's secrets and variables, as one list in the
3 * 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.
7 */
8import { Lock, Pencil, Plus, Search, SlidersHorizontal, Trash2, X } from "lucide-react";
9import { useMemo, useState } from "react";
10import { Form, Link, useLocation, useNavigation } from "react-router";
11
12import type { Setting } from "@g1t/contracts";
13
14import type { SecretsAction, SecretsData } from "../lib/secrets.server";
15import { Button, ButtonLink, EmptyState, ErrorText, TimeAgo } from "./ui";
16import { CheckboxOption } from "./ui/checkbox";
17import { RadioCard, RadioGroup, RadioOption } from "./ui/radio-group";
18import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from "./ui/select";
19
20/** 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(", ");
28}
29
30const 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,
36 scope,
37 manage,
38}: {
39 data: SecretsData;
40 action: SecretsAction | undefined;
41 scope: "project" | "workspace";
42 manage: boolean;
43}) {
44 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
63 return (
64 <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"
74 ? " 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."}{" "}
76 <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>
86 )}
87 </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.
93 </p>
94
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>
106 <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>
131 </div>
132
133 <ErrorText>{data.error}</ErrorText>
134 {!editing && !adding && <div className="mt-2"><ErrorText>{action?.error}</ErrorText></div>}
135
136 <div className="mt-4">
137 {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" />
143 ) : (
144 <ul className="overflow-hidden rounded-xl border border-line bg-surface">
145 {shown.map((r) => (
146 <Row key={r.id} row={r} inherited={r.scope !== scope} manage={manage} />
147 ))}
148 </ul>
149 )}
150 </div>
151
152 {manage && (adding || row) && (
153 <Drawer row={row} scope={scope} projects={data.projects} error={action?.error} />
154 )}
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 <>
185 {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"}
188 </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 </>
213 )}
214 </>
215 )}
216 </span>
217 </li>
218 );
219}
220
221function Drawer({
222 row,
223 scope,
224 projects,
225 error,
226}: {
227 row: Setting | undefined;
228 scope: "project" | "workspace";
229 projects: string[];
230 error: string | undefined;
231}) {
232 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);
236 const [reach, setReach] = useState(row && row.projects.length > 0 ? "some" : "all");
237 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";
240 return (
241 <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>
256 <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 >
263 {(
264 [
265 ["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" />],
267 ] as const
268 ).map(([value, title, text, icon]) => (
269 // A secret's value is sealed: it can never become config.
270 <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>
280 {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">
325 <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>
329 {some && (
330 <div className="ml-6 space-y-2">
331 {KNOWN_ENVIRONMENTS.map((env) => (
332 <CheckboxOption
333 key={env}
334 name="env"
335 value={env}
336 defaultChecked={row?.environments.includes(env)}
337 label={environmentsLabel([env])}
338 />
339 ))}
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]) => (
364 <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 />
372 ))}
373 </div>
374 </fieldset>
375
376 {scope === "workspace" && (
377 <fieldset>
378 <legend className="mb-2 text-sm font-medium text-muted">Projects</legend>
379 <div className="space-y-2 text-sm">
380 <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>
384 {reach === "some" && (
385 <div className="ml-6 grid max-h-48 gap-1.5 overflow-y-auto p-0.5 sm:grid-cols-2">
386 {projects.map((name) => (
387 <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 />
396 ))}
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>
411 </div>
412 );
413}