Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

524 linesCodeBlameRaw
1import { ArrowLeft } from "lucide-react";
2import { Form, Link, data, redirect } from "react-router";
3
4import { ACCOUNT_RESTORE_DAYS, type AdminUser, WORKSPACE_RESTORE_DAYS, securityEventLabel } from "@g1t/contracts";
5
6import type { Route } from "./+types/user";
7import { Badge, Button, EmptyState, Field, Input, Notice, PageHeader, Section, When } from "~/components/ui";
8import {
9 type DeletedWithAccount,
10 accountWentSummary,
11 confirmsUsername,
12 deletedWithAccount,
13 soleOwnerNote,
14 soleWorkspaceRefusal,
15 soleWorkspacesRefusal,
16 staffDeleteProblem,
17 staffDeletionRefusal,
18} from "~/lib/deleted-accounts";
19import { confirmsPurge, daysLeft } from "~/lib/deleted-workspaces";
20import { text } from "~/lib/forms";
21import { joinedThrough } from "~/lib/invites";
22import { accountsAdmin, identity } from "~/lib/services.server";
23import { settle } from "~/lib/settle";
24import { requireStaff } from "~/lib/staff";
25import { SUSPEND_ACCOUNT_ABOUT, parseReason } from "~/lib/suspension";
26
27export const meta: Route.MetaFunction = ({ params }) => [{ title: `${params.username} · sudo` }, { name: "robots", content: "noindex, nofollow" }];
28
29export async function loader({ params, request, context }: Route.LoaderArgs) {
30 requireStaff(context);
31 const result = await settle(accountsAdmin.user(params.username));
32 if (result.ok && !result.value) throw data("No such account.", { status: 404 });
33 const url = new URL(request.url);
34 const done = url.searchParams.get("done");
35 const slug = url.searchParams.get("slug") ?? "";
36 // The workspaces staff deleted with it, each with its deletion while it
37 // waits to be purged.
38 const user = result.ok ? result.value : null;
39 const went = user?.deleted?.went;
40 let workspaces: DeletedWithAccount[] = [];
41 let workspacesError: string | null = null;
42 if (went && (went.deletedWorkspaces ?? []).length > 0) {
43 const deleted = await settle(identity.deletedWorkspaces());
44 workspaces = deletedWithAccount(went, deleted.ok ? deleted.value : []);
45 workspacesError = deleted.ok ? null : deleted.error;
46 }
47 const messages: Record<string, string> = {
48 deleted: "Deleted the account.",
49 "deleted-with-workspaces": "Deleted the account and the workspaces it alone owned.",
50 restored: "Restored the account.",
51 "workspace-purged": `Purged ${slug}.`,
52 suspended: "Suspended the account. It was signed out everywhere.",
53 lifted: "Lifted the suspension. The account can sign in again.",
54 };
55 return {
56 user,
57 error: result.ok ? null : result.error,
58 removed: url.searchParams.get("removed"),
59 done: done ? (messages[done] ?? null) : null,
60 workspaces,
61 workspacesError,
62 now: Date.now(),
63 };
64}
65
66/**
67 * Removes an address (the reason is required, recorded and shown to the
68 * person), or deletes, restores or purges the account, or purges a
69 * workspace deleted with it. Identity checks each again: protection, the
70 * workspaces it owns alone and whether they can go, the typed username or
71 * slug, the restore window.
72 */
73export async function action({ params, request, context }: Route.ActionArgs) {
74 const staff = requireStaff(context);
75 const form = await request.formData();
76 const intent = text(form, "intent");
77 const back = (done: string) => redirect(`/users/${encodeURIComponent(params.username)}?done=${done}`);
78 if (intent === "delete-account") {
79 const reason = text(form, "reason");
80 const confirm = text(form, "confirm");
81 const withWorkspaces = text(form, "with-workspaces") === "1";
82 const problem = staffDeleteProblem({
83 username: params.username,
84 reason,
85 confirm,
86 withWorkspaces,
87 acknowledged: text(form, "acknowledge") === "on",
88 });
89 if (problem) return data({ error: problem, account: true }, { status: 422 });
90 const result = await accountsAdmin.deleteAccount(params.username, reason, confirm, staff.email, withWorkspaces);
91 if (!result.ok) return data({ error: result.error.message, account: true }, { status: 422 });
92 throw back(withWorkspaces ? "deleted-with-workspaces" : "deleted");
93 }
94 if (intent === "purge-workspace") {
95 const id = text(form, "id");
96 const slug = text(form, "slug");
97 const confirm = text(form, "confirm");
98 if (!confirmsPurge(slug, confirm)) return data({ error: `Type ${slug} to confirm.`, workspace: id }, { status: 422 });
99 const result = await identity.purgeWorkspace(id, staff.email, confirm);
100 if (!result.ok) return data({ error: result.error.message, workspace: id }, { status: 422 });
101 throw redirect(`/users/${encodeURIComponent(params.username)}?done=workspace-purged&slug=${encodeURIComponent(slug)}`);
102 }
103 if (intent === "suspend" || intent === "lift") {
104 const suspend = intent === "suspend";
105 const reason = suspend ? parseReason(text(form, "reason")) : { ok: true as const, value: "" };
106 if (!reason.ok) return data({ error: reason.error, account: true }, { status: 422 });
107 const result = await identity.suspendUser(params.username, suspend, reason.value, staff.email);
108 if (!result.ok) return data({ error: result.error.message, account: true }, { status: 422 });
109 throw back(suspend ? "suspended" : "lifted");
110 }
111 if (intent === "restore-account") {
112 const result = await accountsAdmin.restoreAccount(text(form, "id"), staff.email);
113 if (!result.ok) return data({ error: result.error.message, account: true }, { status: 422 });
114 throw back("restored");
115 }
116 if (intent === "purge-account") {
117 const confirm = text(form, "confirm");
118 if (!confirmsUsername(params.username, confirm)) {
119 return data({ error: `Type ${params.username} to confirm.`, account: true }, { status: 422 });
120 }
121 const result = await accountsAdmin.purgeAccount(text(form, "id"), staff.email, confirm);
122 if (!result.ok) return data({ error: result.error.message, account: true }, { status: 422 });
123 throw redirect(`/users/deleted?done=purged&username=${encodeURIComponent(params.username)}`);
124 }
125 const email = String(form.get("email") ?? "").trim();
126 const reason = String(form.get("reason") ?? "").trim();
127 if (!reason) return data({ error: "Say why. The person sees the reason in their security log.", email }, { status: 422 });
128 const result = await accountsAdmin.removeEmail(params.username, email, reason, staff.email);
129 if (!result.ok) return data({ error: result.error.message, email }, { status: 422 });
130 throw redirect(`/users/${encodeURIComponent(params.username)}?removed=${encodeURIComponent(email)}`);
131}
132
133export default function User({ loaderData, actionData }: Route.ComponentProps) {
134 const { user, error, removed, done, workspaces, workspacesError, now } = loaderData;
135 const accountError = actionData && "account" in actionData ? actionData.error : null;
136 const workspaceError = actionData && "workspace" in actionData ? { id: actionData.workspace, error: actionData.error } : null;
137 if (!user) {
138 return (
139 <main className="mx-auto max-w-4xl px-4 py-8 sm:py-10">
140 <Notice tone="error">Could not load the account: {error}</Notice>
141 </main>
142 );
143 }
144 return (
145 <main className="mx-auto max-w-4xl px-4 py-8 sm:py-10">
146 <Link to="/workspaces" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
147 <ArrowLeft size={14} /> Workspaces
148 </Link>
149 <PageHeader
150 title={
151 <span className="flex flex-wrap items-center gap-2">
152 {user.username}
153 {user.deleted && <Badge tone="danger">Deleted</Badge>}
154 {user.suspended && <Badge tone="danger">Suspended</Badge>}
155 {user.deletion.protected && <Badge tone="info">Protected</Badge>}
156 </span>
157 }
158 description={
159 <>
160 Account <span className="font-mono">{user.id}</span>, made <When at={user.createdAt} />.{" "}
161 {user.joinedThrough && <>{joinedThrough(user.joinedThrough.label)}. </>}
162 {user.privateEmail ? "Keeps its address private on commits." : "Shows its primary address on commits."}
163 </>
164 }
165 />
166 {removed && (
167 <div className="mt-5">
168 <Notice tone="ok">Removed {removed}. The person was told, with the reason.</Notice>
169 </div>
170 )}
171 {done && (
172 <div className="mt-5">
173 <Notice tone="ok">{done}</Notice>
174 </div>
175 )}
176
177 <Section
178 id="emails"
179 title="Email addresses"
180 description="Remove an address someone else needs, or one that is compromised. Never the last confirmed one; removing the primary makes the oldest other confirmed address primary."
181 className="mt-6"
182 >
183 <ul className="divide-y divide-line rounded-md border border-line">
184 {user.emails.map((email) => (
185 <li key={email.email} className="space-y-3 px-4 py-3">
186 <div className="flex flex-wrap items-center gap-2 text-sm">
187 <span className="font-mono break-all">{email.email}</span>
188 {email.primary && <Badge tone="lavender">Primary</Badge>}
189 {email.backup && <Badge>Backup</Badge>}
190 {email.verified ? <Badge tone="mint">Confirmed</Badge> : <Badge tone="warn">Unconfirmed</Badge>}
191 <span className="text-xs text-faint">
192 added <When at={email.createdAt} />
193 </span>
194 </div>
195 <Form method="post" className="flex flex-col gap-2 sm:flex-row sm:items-end">
196 <input type="hidden" name="email" value={email.email} />
197 <div className="grow">
198 <Field label="Reason (the person sees it)">
199 <Input name="reason" required maxLength={200} placeholder="Another account needs this address" />
200 </Field>
201 </div>
202 <Button variant="danger" type="submit">
203 Remove
204 </Button>
205 </Form>
206 {actionData && "email" in actionData && actionData.email === email.email && actionData.error && <Notice tone="error">{actionData.error}</Notice>}
207 </li>
208 ))}
209 </ul>
210 </Section>
211
212 {!user.deleted && (
213 <Section id="suspend" title="Suspension" description={SUSPEND_ACCOUNT_ABOUT} className="mt-6">
214 {user.suspended ? (
215 <Form method="post" className="space-y-3">
216 <Notice tone="error">
217 Suspended <When at={user.suspended.at} /> by {user.suspended.by}: {user.suspended.reason}
218 </Notice>
219 {accountError && <Notice tone="error">{accountError}</Notice>}
220 <Button type="submit" name="intent" value="lift" variant="quiet">
221 Lift the suspension
222 </Button>
223 </Form>
224 ) : (
225 <Form method="post" className="space-y-3">
226 <Field label="Why" hint="Kept with the suspension and in the audit log.">
227 <Input name="reason" required maxLength={500} placeholder="e.g. Scripted sign-ups for free compute" />
228 </Field>
229 {accountError && <Notice tone="error">{accountError}</Notice>}
230 <Button type="submit" name="intent" value="suspend" variant="danger">
231 Suspend {user.username}
232 </Button>
233 </Form>
234 )}
235 </Section>
236 )}
237
238 <Section id="log" title="Security log" description="What happened to the account's addresses and password, newest first." className="mt-6">
239 {user.log.length === 0 ? (
240 <EmptyState title="Nothing yet" />
241 ) : (
242 <ul className="divide-y divide-line text-sm">
243 {user.log.map((event, at) => (
244 <li key={`${event.createdAt}:${at}`} className="flex flex-col gap-1 py-2 sm:flex-row sm:justify-between sm:gap-4">
245 <span className="min-w-0 wrap-break-word">
246 {securityEventLabel(event)}
247 {event.staff && (
248 <span className="text-muted">
249 {" "}
250 · by {event.staff}
251 {event.reason ? `: ${event.reason}` : ""}
252 </span>
253 )}
254 </span>
255 <span className="shrink-0 text-xs text-faint">
256 <When at={event.createdAt} time />
257 </span>
258 </li>
259 ))}
260 </ul>
261 )}
262 </Section>
263
264 <AccountSection user={user} error={accountError} workspaces={workspaces} workspacesError={workspacesError} workspaceError={workspaceError} now={now} />
265 </main>
266 );
267}
268
269/**
270 * Deleting the account, or, once it is deleted, restoring or purging it
271 * and the workspaces deleted with it. Every form works as plain HTML,
272 * before any script has loaded.
273 */
274function AccountSection({
275 user,
276 error,
277 workspaces,
278 workspacesError,
279 workspaceError,
280 now,
281}: {
282 user: AdminUser;
283 error: string | null;
284 workspaces: DeletedWithAccount[];
285 workspacesError: string | null;
286 workspaceError: { id: string; error: string } | null;
287 now: number;
288}) {
289 const deleted = user.deleted;
290 if (deleted) {
291 const left = daysLeft(deleted.purgeAfter, now);
292 return (
293 <Section
294 id="account"
295 title="Deleted account"
296 description={`Deleted ${deleted.went.staff ? `by ${deleted.went.staff}` : "by the person"}. Kept ${ACCOUNT_RESTORE_DAYS} days for a restore, then purged. Staff can purge it now.`}
297 className="mt-6"
298 >
299 <div className="space-y-3 text-sm">
300 <p className="flex flex-wrap items-center gap-2 text-muted">
301 <span>
302 Deleted <When at={deleted.deletedAt} time /> · purged <When at={deleted.purgeAfter} time />
303 </span>
304 {deleted.restorable ? (
305 <Badge tone="warn">
306 {left} day{left === 1 ? "" : "s"} left
307 </Badge>
308 ) : (
309 <Badge tone="danger">Being purged</Badge>
310 )}
311 </p>
312 {deleted.went.reason && <p className="text-muted">Reason: {deleted.went.reason}</p>}
313 <p className="text-muted">Left: {accountWentSummary(deleted.went)}</p>
314 {workspaces.length > 0 && <DeletedWorkspaces workspaces={workspaces} error={workspacesError} workspaceError={workspaceError} now={now} />}
315 {error && <Notice tone="error">{error}</Notice>}
316 <div className="flex flex-col gap-3 border-t border-line pt-4 sm:flex-row sm:items-end sm:justify-between">
317 <form method="post">
318 <input type="hidden" name="intent" value="restore-account" />
319 <input type="hidden" name="id" value={deleted.userId} />
320 <Button type="submit" variant="lavender" disabled={!deleted.restorable}>
321 Restore
322 </Button>
323 </form>
324 {user.deletion.protected ? (
325 <p className="text-muted">Protected: it can never be purged.</p>
326 ) : (
327 <form method="post" className="flex flex-col gap-2 sm:flex-row sm:items-end">
328 <input type="hidden" name="intent" value="purge-account" />
329 <input type="hidden" name="id" value={deleted.userId} />
330 <label className="grid gap-1 text-xs text-muted">
331 <span>
332 Type <span className="font-mono text-fg">{user.username}</span> to purge it now
333 </span>
334 <Input name="confirm" autoComplete="off" spellCheck={false} className="font-mono" />
335 </label>
336 <Button type="submit" variant="danger">
337 Purge now
338 </Button>
339 </form>
340 )}
341 </div>
342 </div>
343 </Section>
344 );
345 }
346 const refusal = staffDeletionRefusal(user.deletion);
347 const sole = user.deletion.sole_owner_of;
348 const blocked = soleWorkspacesRefusal(user.deletion);
349 return (
350 <Section
351 id="account"
352 title="Delete account"
353 description={`Signs it out everywhere, ends its tokens and keys, and takes it out of every workspace. Kept ${ACCOUNT_RESTORE_DAYS} days for a restore, then purged; its username is never given out again. Only when the person asks, for abuse, or for an account g1t no longer uses, with a reason.`}
354 className="mt-6"
355 >
356 {refusal ? (
357 <Notice tone="warn">{refusal}</Notice>
358 ) : sole.length === 0 ? (
359 <details className="rounded-md border border-danger/30 px-3 py-2" open={Boolean(error)}>
360 <summary className="cursor-pointer text-sm text-danger">Delete this account</summary>
361 <form method="post" className="mt-3 grid gap-3 sm:max-w-md">
362 <input type="hidden" name="intent" value="delete-account" />
363 <Field label="Reason (kept in sudo's audit log)">
364 <Input name="reason" required maxLength={200} placeholder="The person asked from their primary address" />
365 </Field>
366 <Field label={`Type ${user.username} to confirm`}>
367 <Input name="confirm" required autoComplete="off" spellCheck={false} className="font-mono" />
368 </Field>
369 {error && <Notice tone="error">{error}</Notice>}
370 <div>
371 <Button type="submit" variant="danger">
372 Delete account
373 </Button>
374 </div>
375 </form>
376 </details>
377 ) : (
378 <div className="space-y-3 text-sm">
379 <Notice tone="warn">{soleOwnerNote(user.deletion)}</Notice>
380 <ul className="divide-y divide-line rounded-md border border-line">
381 {sole.map((workspace) => {
382 const why = soleWorkspaceRefusal(workspace);
383 return (
384 <li key={workspace.slug} className="space-y-1 px-4 py-2">
385 <div className="flex flex-wrap items-center justify-between gap-2">
386 <Link to={`/workspaces/${workspace.slug}`} className="text-fg hover:underline">
387 {workspace.name} <span className="font-mono text-xs text-muted">{workspace.slug}</span>
388 </Link>
389 <span className="flex flex-wrap items-center gap-1.5 text-xs text-faint">
390 {workspace.protected && <Badge tone="info">Protected</Badge>}
391 {!workspace.protected && workspace.billing && <Badge tone="danger">Billing</Badge>}
392 {workspace.members} member{workspace.members === 1 ? "" : "s"}
393 </span>
394 </div>
395 {why && <p className="text-xs text-danger">{why}</p>}
396 </li>
397 );
398 })}
399 </ul>
400 {blocked ? (
401 <>
402 <Notice tone="error">{blocked}</Notice>
403 {error && <Notice tone="error">{error}</Notice>}
404 </>
405 ) : (
406 <details className="rounded-md border border-danger/30 px-3 py-2" open={Boolean(error)}>
407 <summary className="cursor-pointer text-sm text-danger">Delete account and the workspaces it alone owns</summary>
408 <form method="post" className="mt-3 grid gap-3 sm:max-w-md">
409 <input type="hidden" name="intent" value="delete-account" />
410 <input type="hidden" name="with-workspaces" value="1" />
411 <p className="text-muted">
412 Deletes {sole.map((workspace) => workspace.slug).join(", ")} first, each as its owner would (billing closes it, its repositories go with it,
413 kept {WORKSPACE_RESTORE_DAYS} days for a restore), then the account. If a workspace cannot go, the account is not deleted.
414 </p>
415 <Field label="Reason (kept in sudo's audit log)">
416 <Input name="reason" required maxLength={200} placeholder="Retired test account" />
417 </Field>
418 <Field label={`Type ${user.username} to confirm`}>
419 <Input name="confirm" required autoComplete="off" spellCheck={false} className="font-mono" />
420 </Field>
421 <label className="flex items-start gap-2 text-sm">
422 <input type="checkbox" name="acknowledge" required className="mt-0.5 accent-(--g1t-accent)" />
423 <span>
424 {sole.length === 1 ? (
425 <>
426 The workspace <span className="font-mono">{sole[0].slug}</span> is deleted too, with everything in it.
427 </>
428 ) : (
429 <>
430 The {sole.length} workspaces <span className="font-mono">{sole.map((workspace) => workspace.slug).join(", ")}</span> are deleted too,
431 with everything in them.
432 </>
433 )}
434 </span>
435 </label>
436 {error && <Notice tone="error">{error}</Notice>}
437 <div>
438 <Button type="submit" variant="danger">
439 Delete account and {sole.length === 1 ? "its workspace" : `its ${sole.length} workspaces`}
440 </Button>
441 </div>
442 </form>
443 </details>
444 )}
445 </div>
446 )}
447 </Section>
448 );
449}
450
451/**
452 * The workspaces staff deleted with the account: each waiting to be purged
453 * can be purged now (identity purges only a workspace still deleted), or
454 * restored from Deleted workspaces.
455 */
456function DeletedWorkspaces({
457 workspaces,
458 error,
459 workspaceError,
460 now,
461}: {
462 workspaces: DeletedWithAccount[];
463 error: string | null;
464 workspaceError: { id: string; error: string } | null;
465 now: number;
466}) {
467 return (
468 <div className="space-y-2 border-t border-line pt-4">
469 <p className="text-fg">Workspaces deleted with it</p>
470 <p className="text-muted">
471 Purge them before the account if they should go now: once the account is purged, this page is gone (they stay on{" "}
472 <Link to="/workspaces/deleted" className="text-fg hover:underline">
473 Deleted workspaces
474 </Link>
475 ). To undo it all, restore the account first, then each workspace, so it comes back with its owner.
476 </p>
477 {error && <Notice tone="error">Could not load deleted workspaces: {error}</Notice>}
478 <ul className="divide-y divide-line rounded-md border border-line">
479 {workspaces.map((workspace) => {
480 const waiting = workspace.deleted;
481 const left = waiting ? daysLeft(waiting.purgeAfter, now) : 0;
482 return (
483 <li key={workspace.workspaceId} className="space-y-2 px-4 py-3">
484 <div className="flex flex-wrap items-center justify-between gap-2">
485 <span className="font-mono">{workspace.slug}</span>
486 {waiting ? (
487 waiting.restorable ? (
488 <Badge tone="warn">
489 {left} day{left === 1 ? "" : "s"} left
490 </Badge>
491 ) : (
492 <Badge tone="danger">Being purged</Badge>
493 )
494 ) : (
495 <Badge>Purged or restored</Badge>
496 )}
497 </div>
498 {waiting &&
499 (waiting.went.protected ? (
500 <p className="text-xs text-muted">Protected: it can never be purged.</p>
501 ) : (
502 <form method="post" className="flex flex-col gap-2 sm:flex-row sm:items-end">
503 <input type="hidden" name="intent" value="purge-workspace" />
504 <input type="hidden" name="id" value={workspace.workspaceId} />
505 <input type="hidden" name="slug" value={workspace.slug} />
506 <label className="grid gap-1 text-xs text-muted">
507 <span>
508 Type <span className="font-mono text-fg">{workspace.slug}</span> to purge it now
509 </span>
510 <Input name="confirm" autoComplete="off" spellCheck={false} className="font-mono" />
511 </label>
512 <Button type="submit" variant="danger">
513 Purge now
514 </Button>
515 </form>
516 ))}
517 {workspaceError && workspaceError.id === workspace.workspaceId && <Notice tone="error">{workspaceError.error}</Notice>}
518 </li>
519 );
520 })}
521 </ul>
522 </div>
523 );
524}