| 1 | /** |
| 2 | * A project's or a workspace's secrets and variables, separated by scope: |
| 3 | * 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 | */ |
| 8 | import { Lock, Pencil, Plus, Search, SlidersHorizontal, Trash2, X } from "lucide-react"; |
| 9 | import { useMemo, useState } from "react"; |
| 10 | import { Form, Link, useLocation } from "react-router"; |
| 11 | |
| 12 | import type { Setting } from "@g1t/contracts"; |
| 13 | |
| 14 | import { settingOverride } from "../lib/setting-overrides"; |
| 15 | import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table"; |
| 16 | |
| 17 | import type { SecretsAction, SecretsData } from "../lib/secrets.server"; |
| 18 | import { ButtonLink, EmptyState, ErrorText, SubmitButton, TimeAgo } from "./ui"; |
| 19 | import { Badge } from "./ui/badge"; |
| 20 | import { Card } from "./ui/card"; |
| 21 | import { CheckboxOption } from "./ui/checkbox"; |
| 22 | import { Hint } from "./ui/hint"; |
| 23 | import { RadioCard, RadioGroup, RadioOption } from "./ui/radio-group"; |
| 24 | import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from "./ui/select"; |
| 25 | |
| 26 | /** The environments every deployment knows; workflow jobs may name others. */ |
| 27 | const KNOWN_ENVIRONMENTS = ["production", "preview"]; |
| 28 | |
| 29 | const READERS: Record<string, string> = { workflows: "Workflows", deployments: "Deployments" }; |
| 30 | |
| 31 | function environmentsLabel(environments: string[]): string { |
| 32 | if (environments.length === 0) return "All environments"; |
| 33 | return environments.map((env) => env.charAt(0).toUpperCase() + env.slice(1)).join(", "); |
| 34 | } |
| 35 | |
| 36 | const SELECT = |
| 37 | "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"; |
| 38 | |
| 39 | export function SecretsPanel({ |
| 40 | data, |
| 41 | action, |
| 42 | scope, |
| 43 | manage, |
| 44 | workspacePath, |
| 45 | }: { |
| 46 | data: SecretsData; |
| 47 | action: SecretsAction | undefined; |
| 48 | scope: "project" | "workspace"; |
| 49 | manage: boolean; |
| 50 | workspacePath?: string; |
| 51 | }) { |
| 52 | const location = useLocation(); |
| 53 | const params = new URLSearchParams(location.search); |
| 54 | const editing = params.get("edit"); |
| 55 | const adding = params.has("add"); |
| 56 | const row = editing ? data.rows.find((r) => r.id === editing && r.scope === scope) : undefined; |
| 57 | const [query, setQuery] = useState(""); |
| 58 | const [type, setType] = useState("all"); |
| 59 | const [environment, setEnvironment] = useState("all"); |
| 60 | const environments = useMemo( |
| 61 | () => [...new Set([...KNOWN_ENVIRONMENTS, ...data.rows.flatMap((r) => r.environments)])], |
| 62 | [data.rows], |
| 63 | ); |
| 64 | const shown = data.rows.filter( |
| 65 | (r) => |
| 66 | (!query || r.name.toLowerCase().includes(query.toLowerCase()) || r.note?.toLowerCase().includes(query.toLowerCase())) && |
| 67 | (type === "all" || r.kind === type) && |
| 68 | (environment === "all" || r.environments.length === 0 || r.environments.includes(environment)), |
| 69 | ); |
| 70 | |
| 71 | return ( |
| 72 | <div className="min-w-0 max-w-5xl"> |
| 73 | <header className="flex flex-wrap items-start justify-between gap-4"> |
| 74 | <div> |
| 75 | <h1 className="text-lg font-semibold tracking-tight">{scope === "project" ? "Environment variables" : "Secrets and variables"}</h1> |
| 76 | <p className="mt-1 max-w-2xl text-sm text-muted"> |
| 77 | Secrets and configuration for workflows and deployments, scoped by environment. |
| 78 | {scope === "workspace" |
| 79 | ? " Share values with every project or only the projects you choose." |
| 80 | : " Project keys override workspace keys of the same type for each shared reader."}{" "} |
| 81 | <a href="https://docs.g1t.sh/guides/secrets-and-variables/" className="text-fg hover:underline"> |
| 82 | How they are read |
| 83 | </a> |
| 84 | </p> |
| 85 | </div> |
| 86 | {manage && ( |
| 87 | <ButtonLink to="?add" variant="accent"> |
| 88 | <Plus size={14} /> |
| 89 | Add |
| 90 | </ButtonLink> |
| 91 | )} |
| 92 | </header> |
| 93 | |
| 94 | <Card asChild radius="lg" className="mt-4 px-4 py-2.5 text-xs text-muted"> |
| 95 | <p> |
| 96 | Built in: workflows get <code className="text-fg">secrets.G1T_TOKEN</code>, the workspace's own token for |
| 97 | the run, with <code className="text-fg">secrets.GITHUB_TOKEN</code> as its alias. The merge |
| 98 | queue never reads secrets or variables, and runs for people outside the workspace get no secrets. |
| 99 | </p> |
| 100 | </Card> |
| 101 | |
| 102 | <div className="mt-5 flex flex-wrap gap-2"> |
| 103 | <label className="relative min-w-56 grow"> |
| 104 | <Search size={14} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" /> |
| 105 | <input |
| 106 | value={query} |
| 107 | onChange={(e) => setQuery(e.target.value)} |
| 108 | placeholder="Search keys and notes" |
| 109 | aria-label="Search" |
| 110 | className={`${SELECT} w-full pl-9`} |
| 111 | /> |
| 112 | </label> |
| 113 | <Select value={type} onValueChange={setType}> |
| 114 | <SelectTrigger aria-label="Type" className="h-auto w-auto py-2"> |
| 115 | <SelectValue /> |
| 116 | </SelectTrigger> |
| 117 | <SelectContent align="end"> |
| 118 | <SelectItem value="all">All types</SelectItem> |
| 119 | <SelectSeparator /> |
| 120 | <SelectItem value="secret" icon={<Lock />}>Secret</SelectItem> |
| 121 | <SelectItem value="variable" icon={<SlidersHorizontal />}>Config</SelectItem> |
| 122 | </SelectContent> |
| 123 | </Select> |
| 124 | <Select value={environment} onValueChange={setEnvironment}> |
| 125 | <SelectTrigger aria-label="Environment" className="h-auto w-auto py-2"> |
| 126 | <SelectValue /> |
| 127 | </SelectTrigger> |
| 128 | <SelectContent align="end"> |
| 129 | <SelectItem value="all">All environments</SelectItem> |
| 130 | {environments.length > 0 && <SelectSeparator />} |
| 131 | {environments.map((env) => ( |
| 132 | <SelectItem key={env} value={env}> |
| 133 | {environmentsLabel([env])} |
| 134 | </SelectItem> |
| 135 | ))} |
| 136 | </SelectContent> |
| 137 | </Select> |
| 138 | </div> |
| 139 | |
| 140 | <ErrorText>{data.error}</ErrorText> |
| 141 | {!editing && !adding && <div className="mt-2"><ErrorText>{action?.error}</ErrorText></div>} |
| 142 | |
| 143 | <div className="mt-6 space-y-8"> |
| 144 | {(scope === "project" ? ["project", "workspace"] as const : ["workspace"] as const).map((section) => { |
| 145 | const rows = shown.filter((r) => r.scope === section); |
| 146 | const total = data.rows.filter((r) => r.scope === section).length; |
| 147 | return ( |
| 148 | <section key={section} aria-label={`${section === "project" ? "Project" : "Workspace"} secrets and variables`}> |
| 149 | <div className="mb-3 flex flex-wrap items-center justify-between gap-2"> |
| 150 | <div> |
| 151 | <h2 className="text-sm font-semibold">{section === "project" ? "Project secrets and variables" : "Workspace secrets and variables"}</h2> |
| 152 | {scope === "project" && section === "workspace" && <p className="mt-1 text-xs text-muted">Inherited values shared with this project. A project key replaces the workspace value for its readers, even outside its selected environments.</p>} |
| 153 | </div> |
| 154 | {scope === "project" && section === "workspace" && workspacePath && <ButtonLink to={workspacePath} variant="outline">View workspace variables</ButtonLink>} |
| 155 | </div> |
| 156 | {rows.length === 0 ? ( |
| 157 | <EmptyState title={total > 0 ? "Nothing matches these filters" : section === "project" ? "No project secrets or variables" : "No workspace secrets or variables"} /> |
| 158 | ) : ( |
| 159 | <Card className="min-w-0 overflow-hidden"> |
| 160 | <Table className="min-w-[850px] table-fixed" frameProps={{ tabIndex: 0, role: "region", "aria-label": `${section} secrets and variables` }}> |
| 161 | <colgroup><col style={{ width: "32%" }} /><col style={{ width: "19%" }} /><col style={{ width: "18%" }} /><col style={{ width: "10%" }} /><col style={{ width: "11%" }} /><col style={{ width: "10%" }} /></colgroup> |
| 162 | <TableHeader><TableRow> |
| 163 | <TableHead>Name / value</TableHead><TableHead>Environments</TableHead><TableHead>Used by</TableHead><TableHead>Type</TableHead><TableHead>Updated</TableHead><TableHead><span className="sr-only">Actions</span></TableHead> |
| 164 | </TableRow></TableHeader> |
| 165 | <TableBody>{rows.map((r) => <Row key={r.id} row={r} inherited={r.scope !== scope} manage={manage} override={scope === "project" ? settingOverride(r, data.rows) : null} />)}</TableBody> |
| 166 | </Table> |
| 167 | </Card> |
| 168 | )} |
| 169 | </section> |
| 170 | ); |
| 171 | })} |
| 172 | </div> |
| 173 | |
| 174 | {manage && (adding || row) && ( |
| 175 | // Keyed to the row, so going from one row's edit to another's starts from that row. |
| 176 | <Drawer key={row?.id ?? "add"} row={row} scope={scope} projects={data.projects} error={action?.error} /> |
| 177 | )} |
| 178 | </div> |
| 179 | ); |
| 180 | } |
| 181 | |
| 182 | function Row({ row, inherited, manage, override }: { row: Setting; inherited: boolean; manage: boolean; override: string | null }) { |
| 183 | const secret = row.kind === "secret"; |
| 184 | const hosts = row.brokeredHosts ?? []; |
| 185 | return ( |
| 186 | <TableRow> |
| 187 | <TableCell className="align-middle"> |
| 188 | <div className="min-w-0"> |
| 189 | <div className="flex min-w-0 items-center gap-2"> |
| 190 | <p className="min-w-0 break-all font-mono text-[0.8125rem]">{row.name}</p> |
| 191 | {secret && hosts.length > 0 && ( |
| 192 | <Hint label={`Filled in for ${hosts.join(", ")}`}> |
| 193 | <Badge tone="accent"> |
| 194 | Brokered |
| 195 | <span className="sr-only">: filled in for {hosts.join(", ")}</span> |
| 196 | </Badge> |
| 197 | </Hint> |
| 198 | )} |
| 199 | </div> |
| 200 | {row.note && <p className="truncate text-xs text-faint" title={row.note}>{row.note}</p>} |
| 201 | {override && <p className="mt-1 text-xs text-accent">{override}</p>} |
| 202 | {!secret && row.value != null && <p className="truncate font-mono text-xs text-muted">{row.value}</p>} |
| 203 | </div> |
| 204 | </TableCell> |
| 205 | <TableCell className="align-middle break-words text-muted">{environmentsLabel(row.environments)}</TableCell> |
| 206 | <TableCell className="align-middle text-xs text-muted">{row.availableTo.map((r) => READERS[r] ?? r).join(", ")}</TableCell> |
| 207 | <TableCell className="align-middle text-xs text-muted"><span className="flex items-center gap-1.5"> |
| 208 | {secret ? <Lock size={13} className="shrink-0" /> : <SlidersHorizontal size={13} className="shrink-0" />}{secret ? "Secret" : "Config"} |
| 209 | </span></TableCell> |
| 210 | <TableCell className="align-middle text-xs text-faint"><TimeAgo at={row.updatedAt} /></TableCell> |
| 211 | <TableCell className="align-middle"><div className="flex flex-wrap items-center justify-end gap-1"> |
| 212 | {!inherited && ( |
| 213 | <> |
| 214 | {row.projects.length > 0 && ( |
| 215 | <Hint label={row.projects.join(", ")}> |
| 216 | <span className="mr-1 text-xs text-faint"> |
| 217 | {row.projects.length} {row.projects.length === 1 ? "project" : "projects"} |
| 218 | <span className="sr-only">: {row.projects.join(", ")}</span> |
| 219 | </span> |
| 220 | </Hint> |
| 221 | )} |
| 222 | {manage && ( |
| 223 | <> |
| 224 | <Link |
| 225 | to={`?edit=${row.id}`} |
| 226 | aria-label={`Edit ${row.name}`} |
| 227 | className="rounded-md p-1.5 text-faint max-sm:p-2.5 transition-colors hover:bg-raised hover:text-fg" |
| 228 | > |
| 229 | <Pencil size={14} /> |
| 230 | </Link> |
| 231 | <Form method="post"> |
| 232 | <input type="hidden" name="intent" value="delete" /> |
| 233 | <input type="hidden" name="id" value={row.id} /> |
| 234 | <input type="hidden" name="name" value={row.name} /> |
| 235 | <SubmitButton |
| 236 | icon |
| 237 | match={{ intent: "delete", id: row.id }} |
| 238 | aria-label={`Remove ${row.name}`} |
| 239 | variant="ghost" |
| 240 | size="inline" |
| 241 | className="p-1.5 text-faint max-sm:p-2.5 hover:text-danger" |
| 242 | > |
| 243 | <Trash2 size={14} /> |
| 244 | </SubmitButton> |
| 245 | </Form> |
| 246 | </> |
| 247 | )} |
| 248 | </> |
| 249 | )} |
| 250 | </div></TableCell> |
| 251 | </TableRow> |
| 252 | ); |
| 253 | } |
| 254 | |
| 255 | function Drawer({ |
| 256 | row, |
| 257 | scope, |
| 258 | projects, |
| 259 | error, |
| 260 | }: { |
| 261 | row: Setting | undefined; |
| 262 | scope: "project" | "workspace"; |
| 263 | projects: string[]; |
| 264 | error: string | undefined; |
| 265 | }) { |
| 266 | const editing = !!row; |
| 267 | const [type, setType] = useState<"secret" | "config">(row?.kind === "variable" ? "config" : "secret"); |
| 268 | const [some, setSome] = useState(!!row && row.environments.length > 0); |
| 269 | const [reach, setReach] = useState(row && row.projects.length > 0 ? "some" : "all"); |
| 270 | const [brokered, setBrokered] = useState((row?.brokeredHosts?.length ?? 0) > 0); |
| 271 | // Only a secret is brokered, and only workflows read one. |
| 272 | const brokering = type === "secret" && brokered; |
| 273 | const custom = row?.environments.filter((env) => !KNOWN_ENVIRONMENTS.includes(env)) ?? []; |
| 274 | const field = |
| 275 | "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"; |
| 276 | return ( |
| 277 | <div className="fixed inset-0 z-50 flex justify-end bg-black/50" role="dialog" aria-modal="true" aria-label={editing ? "Edit" : "Add"}> |
| 278 | <Link to="?" aria-label="Close" className="grow" /> |
| 279 | <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"> |
| 280 | <div className="flex items-center justify-between border-b border-line px-6 py-4"> |
| 281 | <h3 className="font-semibold">{editing ? `Edit ${row.name}` : "Add a secret or variable"}</h3> |
| 282 | <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"> |
| 283 | <X size={16} /> |
| 284 | </Link> |
| 285 | </div> |
| 286 | <div className="grow space-y-6 overflow-y-auto px-6 py-5"> |
| 287 | <input type="hidden" name="intent" value="save" /> |
| 288 | {row && <input type="hidden" name="id" value={row.id} />} |
| 289 | |
| 290 | <fieldset> |
| 291 | <legend className="mb-2 text-sm font-medium text-muted">Type</legend> |
| 292 | <RadioGroup |
| 293 | name="type" |
| 294 | value={type} |
| 295 | onValueChange={(value) => setType(value as typeof type)} |
| 296 | aria-label="Type" |
| 297 | className="gap-3 sm:grid-cols-2" |
| 298 | > |
| 299 | {( |
| 300 | [ |
| 301 | ["secret", "Secret", "You can't read it again after saving. For passwords, API keys and tokens.", <Lock key="i" />], |
| 302 | ["config", "Config", "Readable by members after saving. For values that are not sensitive.", <SlidersHorizontal key="i" />], |
| 303 | ] as const |
| 304 | ).map(([value, title, text, icon]) => ( |
| 305 | // A secret's value is sealed: it can never become config. |
| 306 | <RadioCard |
| 307 | key={value} |
| 308 | value={value} |
| 309 | title={title} |
| 310 | description={text} |
| 311 | icon={icon} |
| 312 | disabled={value === "config" && row?.kind === "secret"} |
| 313 | /> |
| 314 | ))} |
| 315 | </RadioGroup> |
| 316 | {row?.kind === "variable" && ( |
| 317 | <p className="mt-2 text-xs text-faint">Config can become a secret; a secret cannot become config.</p> |
| 318 | )} |
| 319 | </fieldset> |
| 320 | |
| 321 | <label className="block"> |
| 322 | <span className="mb-1.5 block text-sm font-medium text-muted">Key</span> |
| 323 | {editing ? ( |
| 324 | <input name="key" value={row.name} readOnly className={`${field} font-mono text-muted`} /> |
| 325 | ) : ( |
| 326 | <textarea |
| 327 | name="key" |
| 328 | required |
| 329 | rows={1} |
| 330 | placeholder="CLIENT_KEY, or paste a .env file" |
| 331 | autoComplete="off" |
| 332 | spellCheck={false} |
| 333 | className={`${field} min-h-10 font-mono`} |
| 334 | /> |
| 335 | )} |
| 336 | </label> |
| 337 | |
| 338 | <label className="block"> |
| 339 | <span className="mb-1.5 block text-sm font-medium text-muted">Value</span> |
| 340 | <textarea |
| 341 | name="value" |
| 342 | rows={type === "secret" ? 3 : 2} |
| 343 | defaultValue={row?.kind === "variable" ? (row.value ?? "") : ""} |
| 344 | placeholder={ |
| 345 | editing && row.kind === "secret" ? "Leave empty to keep the current value" : "Enter a value" |
| 346 | } |
| 347 | autoComplete="off" |
| 348 | spellCheck={false} |
| 349 | className={`${field} font-mono`} |
| 350 | /> |
| 351 | </label> |
| 352 | |
| 353 | {type === "secret" && ( |
| 354 | <fieldset> |
| 355 | <legend className="mb-2 text-sm font-medium text-muted">Brokered</legend> |
| 356 | <div className="space-y-2 text-sm"> |
| 357 | <CheckboxOption |
| 358 | name="brokered" |
| 359 | checked={brokered} |
| 360 | onCheckedChange={(checked) => setBrokered(checked === true)} |
| 361 | label="Only for these hosts" |
| 362 | description="The job gets a stand-in; g1t puts the real value on requests to these hosts, so it never enters the sandbox." |
| 363 | /> |
| 364 | {brokered && ( |
| 365 | <div className="ml-6 space-y-2"> |
| 366 | <textarea |
| 367 | name="brokeredHosts" |
| 368 | rows={2} |
| 369 | required |
| 370 | defaultValue={row?.brokeredHosts?.join("\n") ?? ""} |
| 371 | placeholder={"api.example.com\n*.example.com"} |
| 372 | autoComplete="off" |
| 373 | spellCheck={false} |
| 374 | aria-label="Hosts" |
| 375 | className={`${field} font-mono`} |
| 376 | /> |
| 377 | <p className="text-xs text-faint"> |
| 378 | One per line. Filled in in headers and the query string, for workflow jobs on g1t's machines.{" "} |
| 379 | <a href="https://docs.g1t.sh/guides/secrets-and-variables/#brokered-secrets" className="text-fg hover:underline"> |
| 380 | What it covers |
| 381 | </a> |
| 382 | </p> |
| 383 | </div> |
| 384 | )} |
| 385 | </div> |
| 386 | </fieldset> |
| 387 | )} |
| 388 | |
| 389 | <label className="block"> |
| 390 | <span className="mb-1.5 block text-sm font-medium text-muted">Note (optional)</span> |
| 391 | <input name="note" defaultValue={row?.note ?? ""} placeholder="Where to rotate it, or who to ask" className={field} /> |
| 392 | </label> |
| 393 | |
| 394 | <fieldset> |
| 395 | <legend className="mb-2 text-sm font-medium text-muted">Environments</legend> |
| 396 | <div className="space-y-2 text-sm"> |
| 397 | <RadioGroup name="scope" value={some ? "some" : "all"} onValueChange={(value) => setSome(value === "some")} aria-label="Environments"> |
| 398 | <RadioOption value="all" label="All environments" /> |
| 399 | <RadioOption value="some" label="Only some" /> |
| 400 | </RadioGroup> |
| 401 | {some && ( |
| 402 | <div className="ml-6 space-y-2"> |
| 403 | {KNOWN_ENVIRONMENTS.map((env) => ( |
| 404 | <CheckboxOption |
| 405 | key={env} |
| 406 | name="env" |
| 407 | value={env} |
| 408 | defaultChecked={row?.environments.includes(env)} |
| 409 | label={environmentsLabel([env])} |
| 410 | /> |
| 411 | ))} |
| 412 | <input |
| 413 | name="envCustom" |
| 414 | defaultValue={custom.join(", ")} |
| 415 | placeholder="Others, comma-separated: staging, qa" |
| 416 | className={field} |
| 417 | /> |
| 418 | <p className="text-xs text-faint"> |
| 419 | Deployments are production and preview; a workflow job reads the row for its{" "} |
| 420 | <code>environment:</code>, and rows for all environments otherwise. |
| 421 | </p> |
| 422 | </div> |
| 423 | )} |
| 424 | </div> |
| 425 | </fieldset> |
| 426 | |
| 427 | <fieldset> |
| 428 | <legend className="mb-2 text-sm font-medium text-muted">Available to</legend> |
| 429 | <div className="space-y-2 text-sm"> |
| 430 | {( |
| 431 | [ |
| 432 | ["workflows", "Workflows", "secrets.KEY or vars.KEY in GitHub Actions workflows"], |
| 433 | ["deployments", "Deployments", "The build's environment, and env.KEY in the running app"], |
| 434 | ] as const |
| 435 | ).map(([value, title, text]) => { |
| 436 | // A deployment's app needs the value itself, so a brokered |
| 437 | // secret is never one of its. |
| 438 | const closed = brokering && value === "deployments"; |
| 439 | return ( |
| 440 | <CheckboxOption |
| 441 | key={`${value}${closed ? "-closed" : ""}`} |
| 442 | name="availableTo" |
| 443 | value={value} |
| 444 | defaultChecked={closed ? false : row ? row.availableTo.includes(value) : true} |
| 445 | disabled={closed} |
| 446 | label={title} |
| 447 | description={closed ? "Not for a brokered secret: a deployment's app needs the value itself." : text} |
| 448 | /> |
| 449 | ); |
| 450 | })} |
| 451 | </div> |
| 452 | </fieldset> |
| 453 | |
| 454 | {scope === "workspace" && ( |
| 455 | <fieldset> |
| 456 | <legend className="mb-2 text-sm font-medium text-muted">Projects</legend> |
| 457 | <div className="space-y-2 text-sm"> |
| 458 | <RadioGroup name="reach" value={reach} onValueChange={(value) => setReach(value as typeof reach)} aria-label="Projects"> |
| 459 | <RadioOption value="all" label="Every project" /> |
| 460 | <RadioOption value="some" label="Only these" /> |
| 461 | </RadioGroup> |
| 462 | {reach === "some" && ( |
| 463 | <div className="ml-6 grid max-h-48 gap-1.5 overflow-y-auto p-0.5 sm:grid-cols-2"> |
| 464 | {projects.map((name) => ( |
| 465 | <CheckboxOption |
| 466 | key={name} |
| 467 | name="project" |
| 468 | value={name} |
| 469 | defaultChecked={row?.projects.includes(name)} |
| 470 | label={name} |
| 471 | className="items-center" |
| 472 | labelClassName="font-mono text-xs" |
| 473 | /> |
| 474 | ))} |
| 475 | </div> |
| 476 | )} |
| 477 | </div> |
| 478 | </fieldset> |
| 479 | )} |
| 480 | <ErrorText>{error}</ErrorText> |
| 481 | </div> |
| 482 | <div className="flex items-center justify-between gap-4 border-t border-line px-6 py-4"> |
| 483 | <p className="text-xs text-faint">{editing ? "" : "Paste .env contents into Key to add many."}</p> |
| 484 | <SubmitButton match={{ intent: "save" }} pending="Saving…"> |
| 485 | Save |
| 486 | </SubmitButton> |
| 487 | </div> |
| 488 | </Form> |
| 489 | </div> |
| 490 | ); |
| 491 | } |