| 1 | import { data, useFetcher } from "react-router"; |
| 2 | |
| 3 | import type { Route } from "./+types/security-settings"; |
| 4 | import { page } from "../../lib/meta"; |
| 5 | import { ActivationPrompt, CARD } from "../../components/security-suite"; |
| 6 | import { WorkspaceSecurityHeading, WorkspaceSecurityTabs } from "../../components/workspace-security-tabs"; |
| 7 | import { Switch } from "../../components/ui/switch"; |
| 8 | import { securitySuite } from "../../lib/services.server"; |
| 9 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; |
| 10 | import { activationPrice } from "../../lib/security-suite.server"; |
| 11 | |
| 12 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 13 | return page(args, { title: `Security settings · ${params.owner} · g1t` }); |
| 14 | } |
| 15 | |
| 16 | export async function loader({ params, context }: Route.LoaderArgs) { |
| 17 | const viewer = getViewer(context); |
| 18 | const role = roleIn(viewer, params.owner); |
| 19 | if (!role) throw data(null, { status: 404 }); |
| 20 | const [view, price] = await Promise.all([securitySuite.workspaceSettings(params.owner, viewer), activationPrice(params.owner, viewer)]); |
| 21 | return { view: unwrap(view), price, owner: role === "owner" }; |
| 22 | } |
| 23 | |
| 24 | export async function action({ params, context, request }: Route.ActionArgs) { |
| 25 | assertSameOrigin(request); |
| 26 | const user = requireUser(context, request); |
| 27 | const form = await request.formData(); |
| 28 | const saved = await securitySuite.setWorkspaceSettings(user, params.owner, { |
| 29 | delegatedBypass: form.get("delegatedBypass") === "on", |
| 30 | validityChecks: form.get("validityChecks") === "on", |
| 31 | }); |
| 32 | return saved.ok ? { ok: true } : { ok: false, error: saved.error.message }; |
| 33 | } |
| 34 | |
| 35 | function Row({ name, title, about, checked, disabled }: { name: string; title: string; about: string; checked: boolean; disabled: boolean }) { |
| 36 | return ( |
| 37 | <label className={`${CARD} flex items-start justify-between gap-4 p-4 ${disabled ? "opacity-60" : ""}`}> |
| 38 | <span> |
| 39 | <span className="block text-sm font-medium">{title}</span> |
| 40 | <span className="mt-1 block text-sm text-muted">{about}</span> |
| 41 | </span> |
| 42 | <Switch name={name} defaultChecked={checked} disabled={disabled} className="mt-0.5" /> |
| 43 | </label> |
| 44 | ); |
| 45 | } |
| 46 | |
| 47 | export default function WorkspaceSecuritySettings({ loaderData, params }: Route.ComponentProps) { |
| 48 | const { view, price, owner } = loaderData; |
| 49 | const fetcher = useFetcher<{ ok: boolean; error?: string }>(); |
| 50 | const disabled = !owner; |
| 51 | return ( |
| 52 | <div> |
| 53 | <WorkspaceSecurityHeading title="Security settings" about="How push protection treats bypasses, and whether g1t asks secrets' issuers if they still work, in every repository of the workspace." /> |
| 54 | <WorkspaceSecurityTabs owner={params.owner} /> |
| 55 | {!view.activated && ( |
| 56 | <div className="mb-4"> |
| 57 | <ActivationPrompt workspace={params.owner} feature="Delegated bypass and validity checks" monthlyCents={price} isOwner={owner} /> |
| 58 | </div> |
| 59 | )} |
| 60 | <fetcher.Form method="post" className="space-y-3"> |
| 61 | <Row |
| 62 | name="delegatedBypass" |
| 63 | title="Delegated bypass" |
| 64 | about="Someone who pushes a blocked secret asks to push it anyway, and an owner or the repository's admins approve or deny the request, told in their inbox. Off: they bypass it themselves, with a reason." |
| 65 | checked={view.settings.delegatedBypass} |
| 66 | disabled={disabled} |
| 67 | /> |
| 68 | <Row |
| 69 | name="validityChecks" |
| 70 | title="Validity checks" |
| 71 | about="Ask each secret's issuer whether it still works (GitHub, GitLab, Stripe, Slack, npm, OpenAI, Anthropic and SendGrid tokens), weekly and on request, and mark the alert active or inactive. The check is the issuer's own read-only call, over HTTPS; the secret goes nowhere else." |
| 72 | checked={view.settings.validityChecks} |
| 73 | disabled={disabled} |
| 74 | /> |
| 75 | <div className="flex items-center gap-3 pt-1"> |
| 76 | <button type="submit" disabled={disabled || fetcher.state !== "idle"} className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white disabled:opacity-50"> |
| 77 | {fetcher.state !== "idle" ? "Saving…" : "Save"} |
| 78 | </button> |
| 79 | {!owner && <span className="text-sm text-muted">Only an owner can change these.</span>} |
| 80 | {fetcher.data?.ok && <span className="text-sm text-success">Saved.</span>} |
| 81 | {fetcher.data?.error && <span className="text-sm text-danger">{fetcher.data.error}</span>} |
| 82 | </div> |
| 83 | </fetcher.Form> |
| 84 | <p className="mt-4 text-xs text-faint">On private repositories both need the Security and quality activation; on public ones they are free.</p> |
| 85 | </div> |
| 86 | ); |
| 87 | } |