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

424 lines19,046 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";
16
17/** 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(", ");
25}
26
27const 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,
33 scope,
34 manage,
35}: {
36 data: SecretsData;
37 action: SecretsAction | undefined;
38 scope: "project" | "workspace";
39 manage: boolean;
40}) {
41 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
60 return (
61 <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"
71 ? " 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."}{" "}
73 <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>
83 )}
84 </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.
90 </p>
91
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
121 <div className="mt-4">
122 {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" />
128 ) : (
129 <ul className="overflow-hidden rounded-xl border border-line bg-surface">
130 {shown.map((r) => (
131 <Row key={r.id} row={r} inherited={r.scope !== scope} manage={manage} />
132 ))}
133 </ul>
134 )}
135 </div>
136
137 {manage && (adding || row) && (
138 <Drawer row={row} scope={scope} projects={data.projects} error={action?.error} />
139 )}
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 <>
170 {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"}
173 </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 </>
198 )}
199 </>
200 )}
201 </span>
202 </li>
203 );
204}
205
206function Drawer({
207 row,
208 scope,
209 projects,
210 error,
211}: {
212 row: Setting | undefined;
213 scope: "project" | "workspace";
214 projects: string[];
215 error: string | undefined;
216}) {
217 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);
221 const [reach, setReach] = useState(row && row.projects.length > 0 ? "some" : "all");
222 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";
225 return (
226 <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>
384 <legend className="mb-2 text-sm font-medium text-muted">Projects</legend>
385 <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" />
388 Every project
389 </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">
396 {projects.map((name) => (
397 <label key={name} className="flex items-center gap-2 font-mono text-xs">
398 <input
399 type="checkbox"
400 name="project"
401 value={name}
402 defaultChecked={row?.projects.includes(name)}
403 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>
422 </div>
423 );
424}