Skip to content
166 linesCodeBlameRaw
1import { Link, useFetcher } from "react-router";
2
3import { CODE_SCANNING_GATES, type CodeScanningGate, type RepoSecuritySettings, type ReviewFailOn } from "@g1t/contracts";
4
5import type { Route } from "./+types/security-settings";
6import { page } from "../../lib/meta";
7import { ActivationPrompt, CARD, SectionHeader } from "../../components/security-suite";
8import { Button } from "../../components/ui/button";
9import { Hint } from "../../components/ui/hint";
10import { SelectField } from "../../components/ui/select";
11import { Switch } from "../../components/ui/switch";
12import { securitySuite } from "../../lib/services.server";
13import { assertSameOrigin, getViewer, managesSecurity, requireUser, unwrap } from "../../lib/session.server";
14import { refusal, requireInsider } from "../../lib/access.server";
15import { whyNot } from "../../lib/access";
16import { planPrice } from "../../lib/security-suite.server";
17
18export function meta({ params, ...args }: Route.MetaArgs) {
19 return page(args, { title: `Security settings · ${params.owner}/${params.repo} · g1t` });
20}
21
22export async function loader({ params, context, request }: Route.LoaderArgs) {
23 const viewer = getViewer(context) ?? requireUser(context, request);
24 const { access } = await requireInsider(context, params, "push");
25 const repo = { namespace: params.owner, name: params.repo };
26 const [view, price] = await Promise.all([securitySuite.settings(repo, viewer), planPrice(params.owner, viewer)]);
27 return { view: unwrap(view), price, can: access.can, owner: managesSecurity(viewer, params.owner) };
28}
29
30const GATES = new Set(CODE_SCANNING_GATES.map((gate) => gate.gate));
31const FAIL_ON = new Set(["critical", "high", "medium", "low", "none"]);
32
33/** The settings a form posts. */
34export function settingsFrom(form: FormData): RepoSecuritySettings | string {
35 const gate = String(form.get("codeScanningGate") ?? "");
36 const failOn = String(form.get("reviewFailOn") ?? "");
37 if (!GATES.has(gate as CodeScanningGate)) return "Choose when code scanning fails.";
38 if (!FAIL_ON.has(failOn)) return "Choose when dependency review fails.";
39 return {
40 codeScanningGate: gate as CodeScanningGate,
41 dependencyReview: form.get("dependencyReview") === "on",
42 reviewFailOn: failOn as ReviewFailOn,
43 reviewDenyLicenses: String(form.get("reviewDenyLicenses") ?? "")
44 .split(/[\s,]+/)
45 .map((id) => id.trim())
46 .filter(Boolean)
47 .slice(0, 50),
48 reviewComment: form.get("reviewComment") === "on",
49 };
50}
51
52export async function action({ params, context, request }: Route.ActionArgs) {
53 assertSameOrigin(request);
54 const user = requireUser(context, request);
55 const refused = await refusal(context, params, "manage_settings");
56 if (refused) return { ok: false, error: refused };
57 const settings = settingsFrom(await request.formData());
58 if (typeof settings === "string") return { ok: false, error: settings };
59 const saved = await securitySuite.setSettings(user, { namespace: params.owner, name: params.repo }, settings);
60 return saved.ok ? { ok: true } : { ok: false, error: saved.error.message };
61}
62
63const SELECT = "w-full sm:w-72";
64
65export default function SecuritySettings({ loaderData, params }: Route.ComponentProps) {
66 const { view, price, can, owner } = loaderData;
67 const base = `/${params.owner}/${params.repo}`;
68 const fetcher = useFetcher<{ ok: boolean; error?: string }>();
69 const { settings } = view;
70 const disabled = !can.manage_settings || !view.entitled;
71 return (
72 <div className="max-w-3xl space-y-6">
73 <SectionHeader
74 title="Security settings"
75 about="When the pull request checks this repository's security suite reports fail. Require them in branch protection to block merges on them, for people and agents alike."
76 />
77 {!view.entitled && <ActivationPrompt workspace={params.owner} feature="Code scanning and dependency review" monthlyCents={price} isOwner={owner} />}
78 <fetcher.Form method="post" className="space-y-4">
79 <fieldset disabled={disabled} className={`${CARD} space-y-3 p-4 disabled:opacity-60`}>
80 <legend className="sr-only">Code scanning</legend>
81 <p className="text-sm font-medium">Code scanning results</p>
82 <p className="text-sm text-muted">The Code scanning check fails when a pull request brings new results on the lines it changes at this level.</p>
83 <SelectField
84 name="codeScanningGate"
85 defaultValue={settings.codeScanningGate}
86 disabled={disabled}
87 className={SELECT}
88 aria-label="When code scanning fails"
89 options={CODE_SCANNING_GATES.map((gate) => ({ value: gate.gate, label: gate.label }))}
90 />
91 </fieldset>
92 <fieldset disabled={disabled} className={`${CARD} space-y-3 p-4 disabled:opacity-60`}>
93 <legend className="sr-only">Dependency review</legend>
94 <label className="flex items-start justify-between gap-4">
95 <span>
96 <span className="block text-sm font-medium">Dependency review</span>
97 <span className="mt-1 block text-sm text-muted">
98 Pull requests that change a lockfile get the Dependency review check, which fails when they add a package with a
99 known vulnerability or a license you do not allow.
100 </span>
101 </span>
102 <Switch name="dependencyReview" defaultChecked={settings.dependencyReview} className="mt-0.5" />
103 </label>
104 <label className="block">
105 <span className="mb-1.5 block text-xs font-medium text-muted">Fail on vulnerabilities of</span>
106 <SelectField
107 name="reviewFailOn"
108 defaultValue={settings.reviewFailOn}
109 disabled={disabled}
110 className={SELECT}
111 options={[
112 { value: "critical", label: "Critical severity" },
113 { value: "high", label: "High severity or higher" },
114 { value: "medium", label: "Medium severity or higher" },
115 { value: "low", label: "Any severity" },
116 { value: "none", label: "Never fail on vulnerabilities" },
117 ]}
118 />
119 </label>
120 <label className="block">
121 <span className="mb-1.5 block text-xs font-medium text-muted">Licenses not allowed (SPDX ids)</span>
122 <input
123 name="reviewDenyLicenses"
124 defaultValue={settings.reviewDenyLicenses.join(", ")}
125 placeholder="GPL-3.0-only, AGPL-3.0-only"
126 className="h-9 w-full rounded-md border border-line bg-bg px-2.5 font-mono text-sm outline-none hover:border-line-strong focus:border-accent-dim"
127 />
128 </label>
129 <label className="flex items-center justify-between gap-4">
130 <span className="text-sm">Comment the review's summary on the pull request</span>
131 <Switch name="reviewComment" defaultChecked={settings.reviewComment} />
132 </label>
133 </fieldset>
134 <div className="flex flex-wrap items-center gap-3">
135 <Hint label={whyNot(can, "manage_settings")} disabled={!can.manage_settings}>
136 <Button
137 type="submit"
138 disabled={disabled || fetcher.state !== "idle"}>
139 {fetcher.state !== "idle" ? "Saving…" : "Save"}
140 </Button>
141 </Hint>
142 <Link to={`${base}/settings/branches`} className="text-sm text-muted underline underline-offset-2 hover:text-fg">
143 Require the checks in branch protection
144 </Link>
145 {fetcher.data?.ok && <span className="text-sm text-success">Saved.</span>}
146 {fetcher.data?.error && <span className="text-sm text-danger">{fetcher.data.error}</span>}
147 </div>
148 </fetcher.Form>
149 <p className="text-sm text-muted">
150 Security updates are on the{" "}
151 <Link to={`${base}/security/vulnerabilities`} className="underline underline-offset-2 hover:text-fg">
152 Vulnerabilities
153 </Link>{" "}
154 page, and version updates on{" "}
155 <Link to={`${base}/security/dependency-updates`} className="underline underline-offset-2 hover:text-fg">
156 Dependency updates
157 </Link>
158 . Delegated bypass and validity checks are the workspace's, in{" "}
159 <Link to={`/${params.owner}/-/security/settings`} className="underline underline-offset-2 hover:text-fg">
160 its Security settings
161 </Link>
162 .
163 </p>
164 </div>
165 );
166}