Skip to content
423 linesCodeBlameRaw

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.

Secrets and variables kept from before stay with workflows1/**
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 kept from before stay with workflows3 * 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";
Merge branch 'worktree-agent-a985c01f43538af01'10import { Form, Link, useLocation } from "react-router";
Secrets and variables kept from before stay with workflows11
12import type { Setting } from "@g1t/contracts";
13
14import type { SecretsAction, SecretsData } from "../lib/secrets.server";
Merge branch 'worktree-agent-a985c01f43538af01'15import { ButtonLink, EmptyState, ErrorText, SubmitButton, TimeAgo } from "./ui";
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.16import { Card } from "./ui/card";
Workspace names and icons, and a component kit for every control17import { CheckboxOption } from "./ui/checkbox";
Merge branch 'worktree-agent-ae1299e92e4462012'18import { Hint } from "./ui/hint";
Workspace names and icons, and a component kit for every control19import { RadioCard, RadioGroup, RadioOption } from "./ui/radio-group";
20import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from "./ui/select";
Secrets and variables kept from before stay with workflows21
22/** The environments every deployment knows; workflow jobs may name others. */
23const KNOWN_ENVIRONMENTS = ["production", "preview"];
24
25const READERS: Record<string, string> = { workflows: "Workflows", deployments: "Deployments" };
26
27function environmentsLabel(environments: string[]): string {
28 if (environments.length === 0) return "All environments";
29 return environments.map((env) => env.charAt(0).toUpperCase() + env.slice(1)).join(", ");
30}
31
32const SELECT =
33 "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";
34
35export function SecretsPanel({
36 data,
37 action,
38 scope,
39 manage,
40}: {
41 data: SecretsData;
42 action: SecretsAction | undefined;
Projects: what a workspace builds and runs, first on every page43 scope: "project" | "workspace";
Secrets and variables kept from before stay with workflows44 manage: boolean;
45}) {
46 const location = useLocation();
47 const params = new URLSearchParams(location.search);
48 const editing = params.get("edit");
49 const adding = params.has("add");
50 const row = editing ? data.rows.find((r) => r.id === editing && r.scope === scope) : undefined;
51 const [query, setQuery] = useState("");
52 const [type, setType] = useState("all");
53 const [environment, setEnvironment] = useState("all");
54 const environments = useMemo(
55 () => [...new Set([...KNOWN_ENVIRONMENTS, ...data.rows.flatMap((r) => r.environments)])],
56 [data.rows],
57 );
58 const shown = data.rows.filter(
59 (r) =>
60 (!query || r.name.toLowerCase().includes(query.toLowerCase()) || r.note?.toLowerCase().includes(query.toLowerCase())) &&
61 (type === "all" || r.kind === type) &&
62 (environment === "all" || r.environments.length === 0 || r.environments.includes(environment)),
63 );
64
65 return (
66 <div className="max-w-5xl">
67 <header className="flex flex-wrap items-start justify-between gap-4">
68 <div>
69 <h2 className="text-lg font-semibold tracking-tight">Secrets and variables</h2>
70 <p className="mt-1 max-w-2xl text-sm text-muted">
71 One list for everything that reads them. Each row says which environments it applies to and whether{" "}
72 <strong className="font-medium text-fg">workflows</strong> (as <code className="text-fg">secrets.KEY</code>{" "}
73 and <code className="text-fg">vars.KEY</code>), <strong className="font-medium text-fg">deployments</strong>{" "}
74 (the build's environment and the running app's <code className="text-fg">env.KEY</code>), or both read it.
75 {scope === "workspace"
Projects: what a workspace builds and runs, first on every page76 ? " Every project, or the ones you link, reads the workspace's; a project's own row of the same key wins."
77 : " Rows from the workspace are shown too; adding the same key here replaces them for this project."}{" "}
Secrets and variables kept from before stay with workflows78 <a href="https://docs.g1t.sh/guides/secrets-and-variables/" className="text-fg hover:underline">
79 How they are read
80 </a>
81 </p>
82 </div>
83 {manage && (
84 <ButtonLink to="?add" variant="accent">
85 <Plus size={14} />
86 Add
87 </ButtonLink>
88 )}
89 </header>
90
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.91 <Card asChild radius="lg" className="mt-4 px-4 py-2.5 text-xs text-muted">
92 <p>
93 Built in: workflows get <code className="text-fg">secrets.G1T_TOKEN</code>, the workspace's own token for
94 the run, with <code className="text-fg">secrets.GITHUB_TOKEN</code> as its alias. Agents
95 and the merge queue never read secrets or variables, and runs for people outside the workspace get no secrets.
96 </p>
97 </Card>
Secrets and variables kept from before stay with workflows98
99 <div className="mt-5 flex flex-wrap gap-2">
100 <label className="relative min-w-56 grow">
101 <Search size={14} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" />
102 <input
103 value={query}
104 onChange={(e) => setQuery(e.target.value)}
105 placeholder="Search keys and notes"
106 aria-label="Search"
107 className={`${SELECT} w-full pl-9`}
108 />
109 </label>
Workspace names and icons, and a component kit for every control110 <Select value={type} onValueChange={setType}>
111 <SelectTrigger aria-label="Type" className="h-auto w-auto py-2">
112 <SelectValue />
113 </SelectTrigger>
114 <SelectContent align="end">
115 <SelectItem value="all">All types</SelectItem>
116 <SelectSeparator />
117 <SelectItem value="secret" icon={<Lock />}>Secret</SelectItem>
118 <SelectItem value="variable" icon={<SlidersHorizontal />}>Config</SelectItem>
119 </SelectContent>
120 </Select>
121 <Select value={environment} onValueChange={setEnvironment}>
122 <SelectTrigger aria-label="Environment" className="h-auto w-auto py-2">
123 <SelectValue />
124 </SelectTrigger>
125 <SelectContent align="end">
126 <SelectItem value="all">All environments</SelectItem>
127 {environments.length > 0 && <SelectSeparator />}
128 {environments.map((env) => (
129 <SelectItem key={env} value={env}>
130 {environmentsLabel([env])}
131 </SelectItem>
132 ))}
133 </SelectContent>
134 </Select>
Secrets and variables kept from before stay with workflows135 </div>
136
137 <ErrorText>{data.error}</ErrorText>
138 {!editing && !adding && <div className="mt-2"><ErrorText>{action?.error}</ErrorText></div>}
139
140 <div className="mt-4">
141 {data.rows.length === 0 ? (
142 <EmptyState title="No secrets or variables yet">
143 Add one, or paste a <code>.env</code> file into Add to bring many at once.
144 </EmptyState>
145 ) : shown.length === 0 ? (
146 <EmptyState title="Nothing matches" />
147 ) : (
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.148 <Card asChild className="overflow-hidden">
149 <ul>
150 {shown.map((r) => (
151 <Row key={r.id} row={r} inherited={r.scope !== scope} manage={manage} />
152 ))}
153 </ul>
154 </Card>
Secrets and variables kept from before stay with workflows155 )}
156 </div>
157
158 {manage && (adding || row) && (
Merge branch 'worktree-agent-a985c01f43538af01'159 // Keyed to the row, so going from one row's edit to another's starts from that row.
160 <Drawer key={row?.id ?? "add"} row={row} scope={scope} projects={data.projects} error={action?.error} />
Secrets and variables kept from before stay with workflows161 )}
162 </div>
163 );
164}
165
166function Row({ row, inherited, manage }: { row: Setting; inherited: boolean; manage: boolean }) {
167 const secret = row.kind === "secret";
168 return (
169 <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]">
170 <div className="min-w-0">
171 <p className="truncate font-mono text-[0.8125rem]">{row.name}</p>
172 {row.note && <p className="truncate text-xs text-faint">{row.note}</p>}
173 {!secret && row.value != null && <p className="truncate font-mono text-xs text-muted">{row.value}</p>}
174 </div>
175 <span className="truncate text-muted">{environmentsLabel(row.environments)}</span>
176 <span className="hidden truncate text-xs text-muted md:block">
177 {row.availableTo.map((r) => READERS[r] ?? r).join(" · ")}
178 </span>
179 <span className="hidden items-center gap-1.5 text-xs text-muted md:flex">
180 {secret ? <Lock size={13} /> : <SlidersHorizontal size={13} />}
181 {secret ? "Secret" : "Config"}
182 </span>
183 <span className="hidden text-xs text-faint md:block">
184 <TimeAgo at={row.updatedAt} />
185 </span>
186 <span className="flex items-center justify-end gap-1">
187 {inherited ? (
188 <span className="rounded-full px-2 py-px text-xs text-muted ring-1 ring-line">Workspace</span>
189 ) : (
190 <>
Projects: what a workspace builds and runs, first on every page191 {row.projects.length > 0 && (
Merge branch 'worktree-agent-ae1299e92e4462012'192 <Hint label={row.projects.join(", ")}>
193 <span className="mr-1 text-xs text-faint">
194 {row.projects.length} {row.projects.length === 1 ? "project" : "projects"}
195 <span className="sr-only">: {row.projects.join(", ")}</span>
196 </span>
197 </Hint>
Secrets and variables kept from before stay with workflows198 )}
199 {manage && (
200 <>
201 <Link
202 to={`?edit=${row.id}`}
203 aria-label={`Edit ${row.name}`}
Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar204 className="rounded-md p-1.5 text-faint max-sm:p-2.5 transition-colors hover:bg-raised hover:text-fg"
Secrets and variables kept from before stay with workflows205 >
206 <Pencil size={14} />
207 </Link>
208 <Form method="post">
209 <input type="hidden" name="intent" value="delete" />
210 <input type="hidden" name="id" value={row.id} />
211 <input type="hidden" name="name" value={row.name} />
Merge branch 'worktree-agent-a985c01f43538af01'212 <SubmitButton
213 icon
214 match={{ intent: "delete", id: row.id }}
Secrets and variables kept from before stay with workflows215 aria-label={`Remove ${row.name}`}
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.216 variant="ghost"
217 size="inline"
218 className="p-1.5 text-faint max-sm:p-2.5 hover:text-danger"
Secrets and variables kept from before stay with workflows219 >
220 <Trash2 size={14} />
Merge branch 'worktree-agent-a985c01f43538af01'221 </SubmitButton>
Secrets and variables kept from before stay with workflows222 </Form>
223 </>
224 )}
225 </>
226 )}
227 </span>
228 </li>
229 );
230}
231
232function Drawer({
233 row,
234 scope,
Projects: what a workspace builds and runs, first on every page235 projects,
Secrets and variables kept from before stay with workflows236 error,
237}: {
238 row: Setting | undefined;
Projects: what a workspace builds and runs, first on every page239 scope: "project" | "workspace";
240 projects: string[];
Secrets and variables kept from before stay with workflows241 error: string | undefined;
242}) {
243 const editing = !!row;
244 const [type, setType] = useState<"secret" | "config">(row?.kind === "variable" ? "config" : "secret");
245 const [some, setSome] = useState(!!row && row.environments.length > 0);
Projects: what a workspace builds and runs, first on every page246 const [reach, setReach] = useState(row && row.projects.length > 0 ? "some" : "all");
Secrets and variables kept from before stay with workflows247 const custom = row?.environments.filter((env) => !KNOWN_ENVIRONMENTS.includes(env)) ?? [];
248 const field =
249 "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";
250 return (
251 <div className="fixed inset-0 z-50 flex justify-end bg-black/50" role="dialog" aria-modal="true" aria-label={editing ? "Edit" : "Add"}>
252 <Link to="?" aria-label="Close" className="grow" />
Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar253 <Form method="post" className="flex h-full w-full max-w-xl flex-col border-l border-line bg-bg pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)] shadow-2xl">
Secrets and variables kept from before stay with workflows254 <div className="flex items-center justify-between border-b border-line px-6 py-4">
255 <h3 className="font-semibold">{editing ? `Edit ${row.name}` : "Add a secret or variable"}</h3>
Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar256 <Link to="?" aria-label="Close" className="flex items-center justify-center rounded-md p-1.5 text-faint hover:bg-raised hover:text-fg max-sm:size-10">
Secrets and variables kept from before stay with workflows257 <X size={16} />
258 </Link>
259 </div>
260 <div className="grow space-y-6 overflow-y-auto px-6 py-5">
261 <input type="hidden" name="intent" value="save" />
262 {row && <input type="hidden" name="id" value={row.id} />}
263
264 <fieldset>
265 <legend className="mb-2 text-sm font-medium text-muted">Type</legend>
Workspace names and icons, and a component kit for every control266 <RadioGroup
267 name="type"
268 value={type}
269 onValueChange={(value) => setType(value as typeof type)}
270 aria-label="Type"
271 className="gap-3 sm:grid-cols-2"
272 >
Secrets and variables kept from before stay with workflows273 {(
274 [
Workspace names and icons, and a component kit for every control275 ["secret", "Secret", "You can't read it again after saving. For passwords, API keys and tokens.", <Lock key="i" />],
276 ["config", "Config", "Readable by members after saving. For values that are not sensitive.", <SlidersHorizontal key="i" />],
Secrets and variables kept from before stay with workflows277 ] as const
Workspace names and icons, and a component kit for every control278 ).map(([value, title, text, icon]) => (
Secrets and variables kept from before stay with workflows279 // A secret's value is sealed: it can never become config.
Workspace names and icons, and a component kit for every control280 <RadioCard
281 key={value}
282 value={value}
283 title={title}
284 description={text}
285 icon={icon}
286 disabled={value === "config" && row?.kind === "secret"}
287 />
288 ))}
289 </RadioGroup>
Secrets and variables kept from before stay with workflows290 {row?.kind === "variable" && (
291 <p className="mt-2 text-xs text-faint">Config can become a secret; a secret cannot become config.</p>
292 )}
293 </fieldset>
294
295 <label className="block">
296 <span className="mb-1.5 block text-sm font-medium text-muted">Key</span>
297 {editing ? (
298 <input name="key" value={row.name} readOnly className={`${field} font-mono text-muted`} />
299 ) : (
300 <textarea
301 name="key"
302 required
303 rows={1}
304 placeholder="CLIENT_KEY, or paste a .env file"
305 autoComplete="off"
306 spellCheck={false}
307 className={`${field} min-h-10 font-mono`}
308 />
309 )}
310 </label>
311
312 <label className="block">
313 <span className="mb-1.5 block text-sm font-medium text-muted">Value</span>
314 <textarea
315 name="value"
316 rows={type === "secret" ? 3 : 2}
317 defaultValue={row?.kind === "variable" ? (row.value ?? "") : ""}
318 placeholder={
319 editing && row.kind === "secret" ? "Leave empty to keep the current value" : "Enter a value"
320 }
321 autoComplete="off"
322 spellCheck={false}
323 className={`${field} font-mono`}
324 />
325 </label>
326
327 <label className="block">
328 <span className="mb-1.5 block text-sm font-medium text-muted">Note (optional)</span>
329 <input name="note" defaultValue={row?.note ?? ""} placeholder="Where to rotate it, or who to ask" className={field} />
330 </label>
331
332 <fieldset>
333 <legend className="mb-2 text-sm font-medium text-muted">Environments</legend>
334 <div className="space-y-2 text-sm">
Workspace names and icons, and a component kit for every control335 <RadioGroup name="scope" value={some ? "some" : "all"} onValueChange={(value) => setSome(value === "some")} aria-label="Environments">
336 <RadioOption value="all" label="All environments" />
337 <RadioOption value="some" label="Only some" />
338 </RadioGroup>
Secrets and variables kept from before stay with workflows339 {some && (
340 <div className="ml-6 space-y-2">
341 {KNOWN_ENVIRONMENTS.map((env) => (
Workspace names and icons, and a component kit for every control342 <CheckboxOption
343 key={env}
344 name="env"
345 value={env}
346 defaultChecked={row?.environments.includes(env)}
347 label={environmentsLabel([env])}
348 />
Secrets and variables kept from before stay with workflows349 ))}
350 <input
351 name="envCustom"
352 defaultValue={custom.join(", ")}
353 placeholder="Others, comma-separated: staging, qa"
354 className={field}
355 />
356 <p className="text-xs text-faint">
357 Deployments are production and preview; a workflow job reads the row for its{" "}
358 <code>environment:</code>, and rows for all environments otherwise.
359 </p>
360 </div>
361 )}
362 </div>
363 </fieldset>
364
365 <fieldset>
366 <legend className="mb-2 text-sm font-medium text-muted">Available to</legend>
367 <div className="space-y-2 text-sm">
368 {(
369 [
370 ["workflows", "Workflows", "secrets.KEY or vars.KEY in GitHub Actions workflows"],
371 ["deployments", "Deployments", "The build's environment, and env.KEY in the running app"],
372 ] as const
373 ).map(([value, title, text]) => (
Workspace names and icons, and a component kit for every control374 <CheckboxOption
375 key={value}
376 name="availableTo"
377 value={value}
378 defaultChecked={row ? row.availableTo.includes(value) : true}
379 label={title}
380 description={text}
381 />
Secrets and variables kept from before stay with workflows382 ))}
383 </div>
384 </fieldset>
385
386 {scope === "workspace" && (
387 <fieldset>
Projects: what a workspace builds and runs, first on every page388 <legend className="mb-2 text-sm font-medium text-muted">Projects</legend>
Secrets and variables kept from before stay with workflows389 <div className="space-y-2 text-sm">
Workspace names and icons, and a component kit for every control390 <RadioGroup name="reach" value={reach} onValueChange={(value) => setReach(value as typeof reach)} aria-label="Projects">
391 <RadioOption value="all" label="Every project" />
392 <RadioOption value="some" label="Only these" />
393 </RadioGroup>
Secrets and variables kept from before stay with workflows394 {reach === "some" && (
Workspace names and icons, and a component kit for every control395 <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 page396 {projects.map((name) => (
Workspace names and icons, and a component kit for every control397 <CheckboxOption
398 key={name}
399 name="project"
400 value={name}
401 defaultChecked={row?.projects.includes(name)}
402 label={name}
403 className="items-center"
404 labelClassName="font-mono text-xs"
405 />
Secrets and variables kept from before stay with workflows406 ))}
407 </div>
408 )}
409 </div>
410 </fieldset>
411 )}
412 <ErrorText>{error}</ErrorText>
413 </div>
414 <div className="flex items-center justify-between gap-4 border-t border-line px-6 py-4">
415 <p className="text-xs text-faint">{editing ? "" : "Paste .env contents into Key to add many."}</p>
Merge branch 'worktree-agent-a985c01f43538af01'416 <SubmitButton match={{ intent: "save" }} pending="Saving…">
Secrets and variables kept from before stay with workflows417 Save
Merge branch 'worktree-agent-a985c01f43538af01'418 </SubmitButton>
Secrets and variables kept from before stay with workflows419 </div>
420 </Form>
421 </div>
422 );
423}

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