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