| 1 | import { ArrowLeft } from "lucide-react"; |
| 2 | import { Form, Link, data, redirect } from "react-router"; |
| 3 | |
| 4 | import type { AdminRunner, IngestToken } from "@g1t/contracts"; |
| 5 | |
| 6 | import type { Route } from "./+types/workspace-abuse"; |
| 7 | import { Badge, Button, EmptyState, Field, Input, Notice, PageHeader, Section, When } from "~/components/ui"; |
| 8 | import { parseSlug, text } from "~/lib/forms"; |
| 9 | import { admin, identity, runnersAdmin, telemetry, workspaceProjects } from "~/lib/services.server"; |
| 10 | import { settle } from "~/lib/settle"; |
| 11 | import { requireStaff } from "~/lib/staff"; |
| 12 | import { SUSPEND_WORKSPACE_ABOUT, isSuspensionHold, parseReason, suspensionHold } from "~/lib/suspension"; |
| 13 | |
| 14 | export const meta: Route.MetaFunction = ({ params }) => [ |
| 15 | { title: `Suspend and clean up · ${params.slug} · sudo` }, |
| 16 | { name: "robots", content: "noindex, nofollow" }, |
| 17 | ]; |
| 18 | |
| 19 | function slugOf(raw: string): string { |
| 20 | const parsed = parseSlug(raw); |
| 21 | if (!parsed.ok) throw data("That is not a workspace slug.", { status: 404 }); |
| 22 | return parsed.value; |
| 23 | } |
| 24 | |
| 25 | /** Each project's ingest tokens, the ones people made (g1t's own for deployments are not listed). */ |
| 26 | async function telemetryTokens(slug: string): Promise<{ project: { id: string; slug: string; name: string }; tokens: IngestToken[] }[]> { |
| 27 | const projects = await workspaceProjects(slug); |
| 28 | if (!projects.ok) throw new Error(projects.error.message); |
| 29 | return Promise.all( |
| 30 | projects.value.map(async (project) => ({ |
| 31 | project: { id: project.id, slug: project.slug, name: project.name }, |
| 32 | tokens: await telemetry.tokens({ project_id: project.id }).catch(() => [] as IngestToken[]), |
| 33 | })), |
| 34 | ); |
| 35 | } |
| 36 | |
| 37 | export async function loader({ params, request, context }: Route.LoaderArgs) { |
| 38 | requireStaff(context); |
| 39 | const slug = slugOf(params.slug); |
| 40 | const [detail, runners, tokens] = await Promise.all([ |
| 41 | settle(identity.workspace(slug)), |
| 42 | settle(runnersAdmin.list(slug)), |
| 43 | settle(telemetryTokens(slug)), |
| 44 | ]); |
| 45 | if (detail.ok && !detail.value) throw data(`There is no workspace called ${slug}.`, { status: 404 }); |
| 46 | const done = new URL(request.url).searchParams.get("done"); |
| 47 | const DONE: Record<string, string> = { |
| 48 | suspended: `Suspended ${slug}, and put a hold on its compute.`, |
| 49 | lifted: `Lifted the suspension of ${slug}, and the hold it put on.`, |
| 50 | runners: "Removed the runners. What they were running failed; their credentials stopped at once.", |
| 51 | tokens: "Revoked the tokens. Anything sending with them is refused from now on.", |
| 52 | }; |
| 53 | return { |
| 54 | slug, |
| 55 | suspended: detail.ok ? (detail.value?.suspended ?? null) : null, |
| 56 | runners: runners.ok ? runners.value : ([] as AdminRunner[]), |
| 57 | runnersError: runners.ok ? null : runners.error, |
| 58 | tokens: tokens.ok ? tokens.value : [], |
| 59 | tokensError: tokens.ok ? null : tokens.error, |
| 60 | error: detail.ok ? null : detail.error, |
| 61 | done: done ? (DONE[done] ?? null) : null, |
| 62 | }; |
| 63 | } |
| 64 | |
| 65 | /** Puts the suspension's hold on, or takes it off, keeping every other allowance. */ |
| 66 | async function setHold(slug: string, hold: string | null, staff: string, note: string): Promise<string | null> { |
| 67 | const account = await admin.account(slug); |
| 68 | if (!account.ok) return account.error.message; |
| 69 | const { allowances, id, kind } = account.value.summary.account; |
| 70 | if (kind === "enterprise") return null; |
| 71 | const current = allowances?.hold ?? null; |
| 72 | // Lifting takes off only a hold a suspension put there. |
| 73 | if (hold === null && !isSuspensionHold(current)) return null; |
| 74 | const set = await admin.setAllowances(id, { plan: false, ossRepoMicros: null, trialMicros: null, ...allowances, hold }, note, staff); |
| 75 | return set.ok ? null : set.error.message; |
| 76 | } |
| 77 | |
| 78 | export async function action({ params, request, context }: Route.ActionArgs) { |
| 79 | const staff = requireStaff(context); |
| 80 | const slug = slugOf(params.slug); |
| 81 | const form = await request.formData(); |
| 82 | const intent = text(form, "intent"); |
| 83 | const back = (done: string) => redirect(`/workspaces/${encodeURIComponent(slug)}/abuse?done=${done}`); |
| 84 | if (intent === "suspend") { |
| 85 | const reason = parseReason(text(form, "reason")); |
| 86 | if (!reason.ok) return data({ error: reason.error, section: "suspend" }, { status: 422 }); |
| 87 | const result = await identity.suspendWorkspace(slug, true, reason.value, staff.email); |
| 88 | if (!result.ok) return data({ error: result.error.message, section: "suspend" }, { status: 422 }); |
| 89 | const held = await setHold(slug, suspensionHold(reason.value), staff.email, `Suspended: ${reason.value}`); |
| 90 | if (held) return data({ error: `Suspended, but the hold on its compute was not set: ${held}`, section: "suspend" }, { status: 422 }); |
| 91 | throw back("suspended"); |
| 92 | } |
| 93 | if (intent === "lift") { |
| 94 | const result = await identity.suspendWorkspace(slug, false, "", staff.email); |
| 95 | if (!result.ok) return data({ error: result.error.message, section: "suspend" }, { status: 422 }); |
| 96 | const held = await setHold(slug, null, staff.email, "Suspension lifted"); |
| 97 | if (held) return data({ error: `Lifted, but the hold on its compute was not taken off: ${held}`, section: "suspend" }, { status: 422 }); |
| 98 | throw back("lifted"); |
| 99 | } |
| 100 | if (intent === "remove-runners") { |
| 101 | const id = text(form, "id") || null; |
| 102 | const result = await runnersAdmin.remove(slug, id, staff.email); |
| 103 | if (!result.ok) return data({ error: result.error.message, section: "runners" }, { status: 422 }); |
| 104 | throw back("runners"); |
| 105 | } |
| 106 | if (intent === "revoke-tokens") { |
| 107 | const project = text(form, "project"); |
| 108 | const only = text(form, "id"); |
| 109 | const tokens = await telemetry.tokens({ project_id: project }); |
| 110 | for (const token of tokens.filter((token) => !only || token.id === only)) { |
| 111 | await telemetry.revokeToken({ project_id: project, id: token.id }); |
| 112 | } |
| 113 | console.log(`sudo: ${staff.email} revoked ingest tokens of ${slug}'s project ${project}`); |
| 114 | throw back("tokens"); |
| 115 | } |
| 116 | return data({ error: "Unknown action.", section: "top" }, { status: 400 }); |
| 117 | } |
| 118 | |
| 119 | export default function WorkspaceAbuse({ loaderData, actionData }: Route.ComponentProps) { |
| 120 | const { slug, suspended, runners, runnersError, tokens, tokensError, error, done } = loaderData; |
| 121 | const errorFor = (section: string) => (actionData && "section" in actionData && actionData.section === section ? actionData.error : null); |
| 122 | return ( |
| 123 | <main className="mx-auto max-w-4xl px-4 py-8 sm:py-10"> |
| 124 | <Link to={`/workspaces/${slug}`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg"> |
| 125 | <ArrowLeft size={14} /> {slug} |
| 126 | </Link> |
| 127 | <PageHeader title="Suspend and clean up" description={`What staff can stop in ${slug} after abuse: the workspace itself, its self-hosted runners, and its projects' telemetry tokens.`} /> |
| 128 | <div className="mt-5 space-y-3 empty:hidden"> |
| 129 | {done && <Notice tone="ok">{done}</Notice>} |
| 130 | {error && <Notice tone="warn">Identity did not answer: {error}</Notice>} |
| 131 | </div> |
| 132 | |
| 133 | <Section id="suspend" title="Suspension" description={SUSPEND_WORKSPACE_ABOUT} className="mt-6"> |
| 134 | {suspended ? ( |
| 135 | <Form method="post" className="space-y-3"> |
| 136 | <Notice tone="error"> |
| 137 | Suspended <When at={suspended.at} /> by {suspended.by}: {suspended.reason} |
| 138 | </Notice> |
| 139 | {errorFor("suspend") && <Notice tone="error">{errorFor("suspend")}</Notice>} |
| 140 | <Button type="submit" name="intent" value="lift" variant="quiet"> |
| 141 | Lift the suspension |
| 142 | </Button> |
| 143 | </Form> |
| 144 | ) : ( |
| 145 | <Form method="post" className="space-y-3"> |
| 146 | <Field label="Why" hint="Kept with the suspension and in the audit log."> |
| 147 | <Input name="reason" required maxLength={500} placeholder="e.g. Mining on self-hosted runners" /> |
| 148 | </Field> |
| 149 | {errorFor("suspend") && <Notice tone="error">{errorFor("suspend")}</Notice>} |
| 150 | <Button type="submit" name="intent" value="suspend" variant="danger"> |
| 151 | Suspend {slug} |
| 152 | </Button> |
| 153 | </Form> |
| 154 | )} |
| 155 | </Section> |
| 156 | |
| 157 | <Section id="runners" title="Self-hosted runners" description="Removing a runner fails what it was running and stops its credential at once. Removing all of them also ends the registration tokens the workspace has out." className="mt-6"> |
| 158 | {runnersError && <Notice tone="warn">Actions did not answer: {runnersError}</Notice>} |
| 159 | {errorFor("runners") && <Notice tone="error">{errorFor("runners")}</Notice>} |
| 160 | {runners.length === 0 ? ( |
| 161 | <EmptyState title="No runners">{slug} has no self-hosted runners.</EmptyState> |
| 162 | ) : ( |
| 163 | <div className="space-y-2"> |
| 164 | {runners.map((runner) => ( |
| 165 | <Form method="post" key={runner.id} className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-line bg-surface px-3 py-2 text-sm"> |
| 166 | <span className="min-w-0"> |
| 167 | <span className="font-mono">{runner.name}</span> |
| 168 | {runner.repo && <span className="text-muted"> · {runner.repo}</span>} |
| 169 | <span className="block text-xs text-faint"> |
| 170 | {runner.os} · {runner.version} · last seen {runner.lastSeenAt ? <When at={runner.lastSeenAt} /> : "never"} |
| 171 | {runner.createdBy ? ` · added by ${runner.createdBy}` : ""} |
| 172 | </span> |
| 173 | </span> |
| 174 | <span className="flex items-center gap-2"> |
| 175 | {runner.busy && <Badge tone="warn">Running a job</Badge>} |
| 176 | <input type="hidden" name="id" value={runner.id} /> |
| 177 | <Button type="submit" name="intent" value="remove-runners" variant="danger"> |
| 178 | Remove |
| 179 | </Button> |
| 180 | </span> |
| 181 | </Form> |
| 182 | ))} |
| 183 | <Form method="post"> |
| 184 | <Button type="submit" name="intent" value="remove-runners" variant="danger"> |
| 185 | Remove all {runners.length} and end registration tokens |
| 186 | </Button> |
| 187 | </Form> |
| 188 | </div> |
| 189 | )} |
| 190 | </Section> |
| 191 | |
| 192 | <Section id="telemetry" title="Telemetry ingest tokens" description="Tokens people made to send OpenTelemetry to a project. Revoking one refuses anything sent with it from then on. g1t's own token for its deployments is not listed." className="mt-6"> |
| 193 | {tokensError && <Notice tone="warn">Projects or telemetry did not answer: {tokensError}</Notice>} |
| 194 | {tokens.every((row) => row.tokens.length === 0) ? ( |
| 195 | <EmptyState title="No ingest tokens">No project in {slug} has one.</EmptyState> |
| 196 | ) : ( |
| 197 | <div className="space-y-4"> |
| 198 | {tokens |
| 199 | .filter((row) => row.tokens.length > 0) |
| 200 | .map((row) => ( |
| 201 | <div key={row.project.id} className="rounded-lg border border-line bg-surface p-3 text-sm"> |
| 202 | <div className="flex flex-wrap items-center justify-between gap-2"> |
| 203 | <span className="font-medium">{row.project.name}</span> |
| 204 | <Form method="post"> |
| 205 | <input type="hidden" name="project" value={row.project.id} /> |
| 206 | <Button type="submit" name="intent" value="revoke-tokens" variant="danger"> |
| 207 | Revoke all {row.tokens.length} |
| 208 | </Button> |
| 209 | </Form> |
| 210 | </div> |
| 211 | <ul className="mt-2 space-y-1"> |
| 212 | {row.tokens.map((token) => ( |
| 213 | <li key={token.id} className="flex flex-wrap items-center justify-between gap-2"> |
| 214 | <span> |
| 215 | {token.name} <span className="font-mono text-xs text-faint">{token.hint}</span> |
| 216 | </span> |
| 217 | <Form method="post"> |
| 218 | <input type="hidden" name="project" value={row.project.id} /> |
| 219 | <input type="hidden" name="id" value={token.id} /> |
| 220 | <Button type="submit" name="intent" value="revoke-tokens" variant="quiet"> |
| 221 | Revoke |
| 222 | </Button> |
| 223 | </Form> |
| 224 | </li> |
| 225 | ))} |
| 226 | </ul> |
| 227 | </div> |
| 228 | ))} |
| 229 | </div> |
| 230 | )} |
| 231 | </Section> |
| 232 | </main> |
| 233 | ); |
| 234 | } |