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