| 1 | import { ArrowLeft, ChevronRight, Layers, Plus, Trash2, Users } from "lucide-react"; |
| 2 | import { useState } from "react"; |
| 3 | import { Form, Link, redirect } from "react-router"; |
| 4 | |
| 5 | import { MAX_ENVIRONMENT_REVIEWERS, MAX_WAIT_MINUTES } from "@g1t/contracts"; |
| 6 | import type { BranchPattern, Environment, EnvironmentReviewer } from "@g1t/contracts"; |
| 7 | |
| 8 | import type { Route } from "./+types/settings-environments"; |
| 9 | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; |
| 10 | import { SettingsSection as Section } from "../../components/settings-section"; |
| 11 | import { Button, EmptyState, ErrorText, Input, SubmitButton, TimeAgo } from "../../components/ui"; |
| 12 | import { |
| 13 | AlertDialog, |
| 14 | AlertDialogAction, |
| 15 | AlertDialogCancel, |
| 16 | AlertDialogContent, |
| 17 | AlertDialogDescription, |
| 18 | AlertDialogFooter, |
| 19 | AlertDialogHeader, |
| 20 | AlertDialogTitle, |
| 21 | AlertDialogTrigger, |
| 22 | } from "../../components/ui/alert-dialog"; |
| 23 | import { Badge } from "../../components/ui/badge"; |
| 24 | import { CheckboxOption } from "../../components/ui/checkbox"; |
| 25 | import { Hint } from "../../components/ui/hint"; |
| 26 | import { RadioGroup, RadioOption } from "../../components/ui/radio-group"; |
| 27 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select"; |
| 28 | import { environmentFromForm, environmentName, environmentSummary } from "../../lib/environments"; |
| 29 | import { page } from "../../lib/meta"; |
| 30 | import { actions } from "../../lib/services.server"; |
| 31 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; |
| 32 | import { requireCapability, requireInsider } from "../../lib/access.server"; |
| 33 | |
| 34 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 35 | return page(args, { title: `Environments · ${params.owner}/${params.repo} · g1t` }); |
| 36 | } |
| 37 | |
| 38 | /** An environment with no rules saved: what the form starts from. */ |
| 39 | function blank(name: string): Environment { |
| 40 | return { |
| 41 | name, |
| 42 | reviewers: [], |
| 43 | preventSelfReview: false, |
| 44 | waitMinutes: 0, |
| 45 | branchPolicy: "all", |
| 46 | branchPatterns: [], |
| 47 | adminsBypass: true, |
| 48 | protected: false, |
| 49 | updatedAt: null, |
| 50 | updatedBy: null, |
| 51 | }; |
| 52 | } |
| 53 | |
| 54 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 55 | // Admins; to anyone without a role here the page does not exist. |
| 56 | await requireInsider(context, params, "manage_integrations"); |
| 57 | const environments = unwrap(await actions.environments({ namespace: params.owner, name: params.repo }, getViewer(context))); |
| 58 | // `?environment=` opens one environment's rules, a new one's included. |
| 59 | const asked = new URL(request.url).searchParams.get("environment"); |
| 60 | const name = asked === null ? null : environmentName(asked); |
| 61 | return { |
| 62 | environments, |
| 63 | editing: name ? (environments.find((environment) => environment.name === name) ?? blank(name)) : null, |
| 64 | badName: asked !== null && name === null ? asked : null, |
| 65 | }; |
| 66 | } |
| 67 | |
| 68 | export async function action({ request, params, context }: Route.ActionArgs) { |
| 69 | assertSameOrigin(request); |
| 70 | const user = requireUser(context, request); |
| 71 | await requireCapability(context, params, "manage_integrations"); |
| 72 | const repo = { namespace: params.owner, name: params.repo }; |
| 73 | const form = await request.formData(); |
| 74 | const name = environmentName(String(form.get("environment") ?? "")); |
| 75 | if (!name) return { saved: false, error: "An environment's name is lowercase letters, digits, - and _, up to 40." }; |
| 76 | if (form.get("intent") === "delete") { |
| 77 | const removed = await actions.deleteEnvironment(user, repo, name); |
| 78 | if (!removed.ok) return { saved: false, error: removed.error.message }; |
| 79 | throw redirect(`/${params.owner}/${params.repo}/settings/environments`); |
| 80 | } |
| 81 | const read = environmentFromForm(form, { reviewers: MAX_ENVIRONMENT_REVIEWERS, waitMinutes: MAX_WAIT_MINUTES }); |
| 82 | if ("error" in read) return { saved: false, error: read.error }; |
| 83 | const saved = await actions.setEnvironment(user, repo, name, read.change); |
| 84 | return saved.ok ? { saved: true, error: null } : { saved: false, error: saved.error.message }; |
| 85 | } |
| 86 | |
| 87 | export default function RepoEnvironments({ loaderData, actionData, params }: Route.ComponentProps) { |
| 88 | const base = `/${params.owner}/${params.repo}`; |
| 89 | const { environments, editing, badName } = loaderData; |
| 90 | return ( |
| 91 | <div> |
| 92 | <RepoSettingsHeading base={base} /> |
| 93 | {editing ? ( |
| 94 | <EnvironmentEditor key={editing.name} environment={editing} base={base} saved={actionData?.saved ?? false} error={actionData?.error} /> |
| 95 | ) : ( |
| 96 | <EnvironmentList environments={environments} base={base} badName={badName} /> |
| 97 | )} |
| 98 | </div> |
| 99 | ); |
| 100 | } |
| 101 | |
| 102 | function EnvironmentList({ environments, base, badName }: { environments: Environment[]; base: string; badName: string | null }) { |
| 103 | return ( |
| 104 | <div className="max-w-4xl space-y-8"> |
| 105 | <p className="max-w-3xl text-sm text-muted"> |
| 106 | A job that names an environment with <code className="font-mono text-xs text-fg/85">environment:</code> waits |
| 107 | until the environment's protection rules let it through, and only then gets the environment's secrets. Rules can |
| 108 | ask for a review, hold jobs for a wait timer, and limit which branches and tags deploy. |
| 109 | </p> |
| 110 | |
| 111 | <section className="space-y-3"> |
| 112 | {environments.length === 0 ? ( |
| 113 | <EmptyState title="No environments yet"> |
| 114 | An environment appears here once a workflow, a job or a secret names it. You can also add rules to one by name |
| 115 | below. |
| 116 | </EmptyState> |
| 117 | ) : ( |
| 118 | <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> |
| 119 | {environments.map((environment) => ( |
| 120 | <li key={environment.name}> |
| 121 | <Link |
| 122 | to={`?environment=${encodeURIComponent(environment.name)}`} |
| 123 | className="group flex items-start gap-3 px-4 py-3.5 transition-colors hover:bg-raised/40" |
| 124 | > |
| 125 | <Layers size={16} className="mt-0.5 shrink-0 text-muted" /> |
| 126 | <span className="min-w-0 grow"> |
| 127 | <span className="flex flex-wrap items-center gap-2"> |
| 128 | <span className="min-w-0 truncate font-mono text-sm font-medium">{environment.name}</span> |
| 129 | {environment.protected ? <Badge tone="accent">Protected</Badge> : <Badge>No rules</Badge>} |
| 130 | </span> |
| 131 | <span className="mt-1 block text-sm text-muted"> |
| 132 | {environment.protected |
| 133 | ? environmentSummary(environment).join(" · ") |
| 134 | : "Named by a workflow or a secret. Jobs that deploy here start at once."} |
| 135 | </span> |
| 136 | </span> |
| 137 | <span className="mt-0.5 hidden shrink-0 text-xs text-muted group-hover:text-fg sm:inline"> |
| 138 | {environment.protected ? "Edit rules" : "Add rules"} |
| 139 | </span> |
| 140 | <ChevronRight size={16} className="mt-0.5 shrink-0 text-faint transition-transform group-hover:translate-x-0.5" /> |
| 141 | </Link> |
| 142 | </li> |
| 143 | ))} |
| 144 | </ul> |
| 145 | )} |
| 146 | </section> |
| 147 | |
| 148 | <Section title="Add rules to an environment" about="Name an environment to protect, whether or not a workflow names it yet."> |
| 149 | {/* A plain GET: the rules open in the form below, and nothing is saved until you save them. */} |
| 150 | <Form method="get" className="flex flex-col gap-2 sm:flex-row sm:items-start"> |
| 151 | <div className="min-w-0 grow"> |
| 152 | <Input |
| 153 | name="environment" |
| 154 | required |
| 155 | maxLength={40} |
| 156 | pattern="[A-Za-z0-9_\-]{1,40}" |
| 157 | placeholder="production" |
| 158 | aria-label="Environment name" |
| 159 | defaultValue={badName ?? undefined} |
| 160 | /> |
| 161 | <span className="mt-1.5 block text-xs text-faint">Lowercase letters, digits, - and _, up to 40.</span> |
| 162 | </div> |
| 163 | <Button type="submit" variant="quiet"> |
| 164 | <Plus size={14} /> |
| 165 | Add rules |
| 166 | </Button> |
| 167 | </Form> |
| 168 | {badName !== null && ( |
| 169 | <ErrorText>{`“${badName}” cannot be an environment's name: use lowercase letters, digits, - and _, up to 40.`}</ErrorText> |
| 170 | )} |
| 171 | </Section> |
| 172 | </div> |
| 173 | ); |
| 174 | } |
| 175 | |
| 176 | /** Rows of a list that grows and shrinks, each with an id of its own for React. */ |
| 177 | type Row<T> = T & { id: number }; |
| 178 | let nextRow = 0; |
| 179 | const rows = <T,>(items: T[]): Row<T>[] => items.map((item) => ({ ...item, id: nextRow++ })); |
| 180 | |
| 181 | function EnvironmentEditor({ |
| 182 | environment, |
| 183 | base, |
| 184 | saved, |
| 185 | error, |
| 186 | }: { |
| 187 | environment: Environment; |
| 188 | base: string; |
| 189 | saved: boolean; |
| 190 | error: string | null | undefined; |
| 191 | }) { |
| 192 | const [reviewers, setReviewers] = useState<Row<EnvironmentReviewer>[]>(() => rows(environment.reviewers)); |
| 193 | const [patterns, setPatterns] = useState<Row<BranchPattern>[]>(() => |
| 194 | rows(environment.branchPatterns.length > 0 ? environment.branchPatterns : [{ name: "", type: "branch" }]), |
| 195 | ); |
| 196 | const [policy, setPolicy] = useState(environment.branchPolicy); |
| 197 | const full = reviewers.length >= MAX_ENVIRONMENT_REVIEWERS; |
| 198 | |
| 199 | return ( |
| 200 | <div className="max-w-4xl space-y-8"> |
| 201 | <div className="space-y-2"> |
| 202 | <Link to={`${base}/settings/environments`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg"> |
| 203 | <ArrowLeft size={14} /> |
| 204 | All environments |
| 205 | </Link> |
| 206 | <h2 className="flex flex-wrap items-center gap-2 text-base font-semibold"> |
| 207 | <Layers size={16} className="text-muted" /> |
| 208 | <span className="min-w-0 break-all font-mono">{environment.name}</span> |
| 209 | {environment.protected ? <Badge tone="accent">Protected</Badge> : <Badge>No rules</Badge>} |
| 210 | </h2> |
| 211 | <p className="max-w-3xl text-sm text-muted"> |
| 212 | Jobs with <code className="font-mono text-xs text-fg/85">environment: {environment.name}</code> wait until these |
| 213 | rules let them through, and only then get its secrets. |
| 214 | {!environment.protected && " Nothing holds them until you save rules."} |
| 215 | </p> |
| 216 | </div> |
| 217 | |
| 218 | <Form method="post" className="space-y-8"> |
| 219 | <input type="hidden" name="environment" value={environment.name} /> |
| 220 | <Section |
| 221 | title="Required reviewers" |
| 222 | about={`Up to ${MAX_ENVIRONMENT_REVIEWERS} people or teams. A job waits until one of them approves it.`} |
| 223 | > |
| 224 | {reviewers.length > 0 ? ( |
| 225 | <ul className="space-y-2"> |
| 226 | {reviewers.map((reviewer, index) => ( |
| 227 | <li key={reviewer.id} className="flex items-center gap-2"> |
| 228 | <Select |
| 229 | name="reviewerType" |
| 230 | value={reviewer.type} |
| 231 | onValueChange={(type) => |
| 232 | setReviewers((list) => list.map((row) => (row.id === reviewer.id ? { ...row, type: type as EnvironmentReviewer["type"] } : row))) |
| 233 | } |
| 234 | > |
| 235 | <SelectTrigger aria-label={`Reviewer ${index + 1} is a`} className="w-24 shrink-0"> |
| 236 | <SelectValue /> |
| 237 | </SelectTrigger> |
| 238 | <SelectContent> |
| 239 | <SelectItem value="user">User</SelectItem> |
| 240 | <SelectItem value="team">Team</SelectItem> |
| 241 | </SelectContent> |
| 242 | </Select> |
| 243 | <div className="min-w-0 grow"> |
| 244 | <Input |
| 245 | name="reviewerName" |
| 246 | defaultValue={reviewer.name} |
| 247 | placeholder={reviewer.type === "team" ? "Team slug" : "Username"} |
| 248 | aria-label={`Reviewer ${index + 1}`} |
| 249 | maxLength={100} |
| 250 | /> |
| 251 | </div> |
| 252 | <RemoveRow |
| 253 | label={`Remove reviewer ${index + 1}`} |
| 254 | onClick={() => setReviewers((list) => list.filter((row) => row.id !== reviewer.id))} |
| 255 | /> |
| 256 | </li> |
| 257 | ))} |
| 258 | </ul> |
| 259 | ) : ( |
| 260 | <p className="rounded-xl border border-dashed border-line px-4 py-3 text-sm text-muted"> |
| 261 | No reviewers: jobs need no approval to deploy here. |
| 262 | </p> |
| 263 | )} |
| 264 | <div className="flex flex-wrap items-center gap-3"> |
| 265 | <Button |
| 266 | type="button" |
| 267 | variant="quiet" |
| 268 | disabled={full} |
| 269 | onClick={() => setReviewers((list) => [...list, ...rows<EnvironmentReviewer>([{ type: "user", name: "" }])])} |
| 270 | > |
| 271 | <Users size={14} /> |
| 272 | Add a reviewer |
| 273 | </Button> |
| 274 | {full && <span className="text-xs text-faint">That is the most an environment can have.</span>} |
| 275 | </div> |
| 276 | <CheckboxOption |
| 277 | name="preventSelfReview" |
| 278 | defaultChecked={environment.preventSelfReview} |
| 279 | label="Prevent self-review" |
| 280 | description="Whoever started a run cannot approve its deployments." |
| 281 | /> |
| 282 | </Section> |
| 283 | |
| 284 | <Section title="Wait timer" about="Minutes a job waits once it reaches the environment. 0 means no wait."> |
| 285 | <label className="block max-w-56"> |
| 286 | <span className="mb-1.5 block text-sm font-medium text-muted">Minutes</span> |
| 287 | <Input |
| 288 | type="number" |
| 289 | name="waitMinutes" |
| 290 | min={0} |
| 291 | max={MAX_WAIT_MINUTES} |
| 292 | step={1} |
| 293 | defaultValue={environment.waitMinutes} |
| 294 | inputMode="numeric" |
| 295 | /> |
| 296 | <span className="mt-1.5 block text-xs text-faint">From 0 to {MAX_WAIT_MINUTES.toLocaleString("en-US")} (30 days).</span> |
| 297 | </label> |
| 298 | </Section> |
| 299 | |
| 300 | <Section title="Deployment branches and tags" about="Which branches and tags may run jobs that deploy here. A job on any other fails, saying so."> |
| 301 | <RadioGroup name="branchPolicy" value={policy} onValueChange={(value) => setPolicy(value as Environment["branchPolicy"])}> |
| 302 | <RadioOption value="all" label="All branches" description="Any branch or tag can deploy here." /> |
| 303 | <RadioOption value="protected" label="Protected branches only" description="Only branches the repository's rules protect, the default branch included." /> |
| 304 | <RadioOption |
| 305 | value="selected" |
| 306 | label="Selected branches and tags" |
| 307 | description="Only branches and tags whose names match a pattern, such as release/* or v*." |
| 308 | /> |
| 309 | </RadioGroup> |
| 310 | {policy === "selected" && ( |
| 311 | <div className="space-y-2 rounded-xl border border-line bg-surface p-3"> |
| 312 | <ul className="space-y-2"> |
| 313 | {patterns.map((pattern, index) => ( |
| 314 | <li key={pattern.id} className="flex items-center gap-2"> |
| 315 | <Select |
| 316 | name="patternType" |
| 317 | value={pattern.type} |
| 318 | onValueChange={(type) => |
| 319 | setPatterns((list) => list.map((row) => (row.id === pattern.id ? { ...row, type: type as BranchPattern["type"] } : row))) |
| 320 | } |
| 321 | > |
| 322 | <SelectTrigger aria-label={`Pattern ${index + 1} matches`} className="w-24 shrink-0"> |
| 323 | <SelectValue /> |
| 324 | </SelectTrigger> |
| 325 | <SelectContent> |
| 326 | <SelectItem value="branch">Branch</SelectItem> |
| 327 | <SelectItem value="tag">Tag</SelectItem> |
| 328 | </SelectContent> |
| 329 | </Select> |
| 330 | <div className="min-w-0 grow"> |
| 331 | <Input |
| 332 | name="patternName" |
| 333 | defaultValue={pattern.name} |
| 334 | placeholder={pattern.type === "tag" ? "v*" : "release/*"} |
| 335 | aria-label={`Pattern ${index + 1}`} |
| 336 | maxLength={255} |
| 337 | /> |
| 338 | </div> |
| 339 | <RemoveRow |
| 340 | label={`Remove pattern ${index + 1}`} |
| 341 | onClick={() => setPatterns((list) => list.filter((row) => row.id !== pattern.id))} |
| 342 | /> |
| 343 | </li> |
| 344 | ))} |
| 345 | </ul> |
| 346 | <Button |
| 347 | type="button" |
| 348 | variant="quiet" |
| 349 | onClick={() => setPatterns((list) => [...list, ...rows<BranchPattern>([{ name: "", type: "branch" }])])} |
| 350 | > |
| 351 | <Plus size={14} /> |
| 352 | Add a pattern |
| 353 | </Button> |
| 354 | </div> |
| 355 | )} |
| 356 | </Section> |
| 357 | |
| 358 | <Section title="Admins" about="Whether people with the Admin role may let jobs through themselves."> |
| 359 | <CheckboxOption |
| 360 | name="adminsBypass" |
| 361 | defaultChecked={environment.adminsBypass} |
| 362 | label="Allow admins to bypass these rules" |
| 363 | description="Someone with the Admin role may approve without being a reviewer, which also skips the wait timer." |
| 364 | /> |
| 365 | </Section> |
| 366 | |
| 367 | <div className="sticky bottom-0 -mx-4 flex flex-wrap items-center gap-4 border-t border-line bg-bg/90 px-4 py-4 backdrop-blur"> |
| 368 | <SubmitButton name="intent" value="save" pending="Saving…"> |
| 369 | {environment.protected ? "Save rules" : "Add rules"} |
| 370 | </SubmitButton> |
| 371 | {saved && <span className="text-sm text-muted">Saved.</span>} |
| 372 | <ErrorText>{error}</ErrorText> |
| 373 | {environment.updatedBy && environment.updatedAt && !saved && !error && ( |
| 374 | <span className="text-xs text-faint"> |
| 375 | Last changed by <span className="font-mono">{environment.updatedBy}</span> <TimeAgo at={environment.updatedAt} /> |
| 376 | </span> |
| 377 | )} |
| 378 | </div> |
| 379 | </Form> |
| 380 | |
| 381 | {environment.protected && <RemoveRules name={environment.name} />} |
| 382 | </div> |
| 383 | ); |
| 384 | } |
| 385 | |
| 386 | function RemoveRow({ label, onClick }: { label: string; onClick: () => void }) { |
| 387 | return ( |
| 388 | <Hint label={label}> |
| 389 | <button |
| 390 | type="button" |
| 391 | aria-label={label} |
| 392 | onClick={onClick} |
| 393 | className="flex size-9 shrink-0 items-center justify-center rounded-md text-muted transition-colors hover:bg-raised hover:text-danger" |
| 394 | > |
| 395 | <Trash2 size={14} /> |
| 396 | </button> |
| 397 | </Hint> |
| 398 | ); |
| 399 | } |
| 400 | |
| 401 | function RemoveRules({ name }: { name: string }) { |
| 402 | return ( |
| 403 | <Section title="Remove rules" about="Jobs that deploy here start at once again. The environment's secrets stay."> |
| 404 | <div> |
| 405 | <AlertDialog> |
| 406 | <AlertDialogTrigger asChild> |
| 407 | <Button type="button" variant="danger"> |
| 408 | <Trash2 size={14} /> |
| 409 | Remove rules |
| 410 | </Button> |
| 411 | </AlertDialogTrigger> |
| 412 | <AlertDialogContent> |
| 413 | <Form method="post" className="grid gap-4"> |
| 414 | <input type="hidden" name="intent" value="delete" /> |
| 415 | <input type="hidden" name="environment" value={name} /> |
| 416 | <AlertDialogHeader> |
| 417 | <AlertDialogTitle>Remove the rules for {name}?</AlertDialogTitle> |
| 418 | <AlertDialogDescription> |
| 419 | Its reviewers, wait timer and branch limits are given up, and jobs that name it no longer wait. Its |
| 420 | secrets stay. |
| 421 | </AlertDialogDescription> |
| 422 | </AlertDialogHeader> |
| 423 | <AlertDialogFooter> |
| 424 | <AlertDialogCancel>Cancel</AlertDialogCancel> |
| 425 | <AlertDialogAction asChild> |
| 426 | <button type="submit"> |
| 427 | <Trash2 size={14} /> |
| 428 | Remove rules |
| 429 | </button> |
| 430 | </AlertDialogAction> |
| 431 | </AlertDialogFooter> |
| 432 | </Form> |
| 433 | </AlertDialogContent> |
| 434 | </AlertDialog> |
| 435 | </div> |
| 436 | </Section> |
| 437 | ); |
| 438 | } |