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