Skip to content
300 linesCodeBlameRaw
1import { ArrowLeft } from "lucide-react";
2import { Form, Link, data, redirect } from "react-router";
3
4import { ACCOUNT_RESTORE_DAYS, type AdminUser, 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 { accountWentSummary, confirmsUsername, staffDeletionRefusal } from "~/lib/deleted-accounts";
9import { daysLeft } from "~/lib/deleted-workspaces";
10import { text } from "~/lib/forms";
11import { accountsAdmin } from "~/lib/services.server";
12import { settle } from "~/lib/settle";
13import { requireStaff } from "~/lib/staff";
14
15export const meta: Route.MetaFunction = ({ params }) => [
16 { title: `${params.username} · sudo` },
17 { name: "robots", content: "noindex, nofollow" },
18];
19
20export async function loader({ params, request, context }: Route.LoaderArgs) {
21 requireStaff(context);
22 const result = await settle(accountsAdmin.user(params.username));
23 if (result.ok && !result.value) throw data("No such account.", { status: 404 });
24 const url = new URL(request.url);
25 const done = url.searchParams.get("done");
26 return {
27 user: result.ok ? result.value : null,
28 error: result.ok ? null : result.error,
29 removed: url.searchParams.get("removed"),
30 done: done === "deleted" ? "Deleted the account." : done === "restored" ? "Restored the account." : null,
31 now: Date.now(),
32 };
33}
34
35/**
36 * Removes an address (the reason is required, recorded and shown to the
37 * person), or deletes, restores or purges the account. Identity checks
38 * each again: protection, the workspaces it owns alone, the typed
39 * username, the restore window.
40 */
41export async function action({ params, request, context }: Route.ActionArgs) {
42 const staff = requireStaff(context);
43 const form = await request.formData();
44 const intent = text(form, "intent");
45 const back = (done: string) => redirect(`/users/${encodeURIComponent(params.username)}?done=${done}`);
46 if (intent === "delete-account") {
47 const reason = text(form, "reason");
48 const confirm = text(form, "confirm");
49 if (!reason) return data({ error: "Say why the account is being deleted.", account: true }, { status: 422 });
50 if (!confirmsUsername(params.username, confirm)) {
51 return data({ error: `Type ${params.username} to confirm.`, account: true }, { status: 422 });
52 }
53 const result = await accountsAdmin.deleteAccount(params.username, reason, confirm, staff.email);
54 if (!result.ok) return data({ error: result.error.message, account: true }, { status: 422 });
55 throw back("deleted");
56 }
57 if (intent === "restore-account") {
58 const result = await accountsAdmin.restoreAccount(text(form, "id"), staff.email);
59 if (!result.ok) return data({ error: result.error.message, account: true }, { status: 422 });
60 throw back("restored");
61 }
62 if (intent === "purge-account") {
63 const confirm = text(form, "confirm");
64 if (!confirmsUsername(params.username, confirm)) {
65 return data({ error: `Type ${params.username} to confirm.`, account: true }, { status: 422 });
66 }
67 const result = await accountsAdmin.purgeAccount(text(form, "id"), staff.email, confirm);
68 if (!result.ok) return data({ error: result.error.message, account: true }, { status: 422 });
69 throw redirect(`/users/deleted?done=purged&username=${encodeURIComponent(params.username)}`);
70 }
71 const email = String(form.get("email") ?? "").trim();
72 const reason = String(form.get("reason") ?? "").trim();
73 if (!reason) return data({ error: "Say why. The person sees the reason in their security log.", email }, { status: 422 });
74 const result = await accountsAdmin.removeEmail(params.username, email, reason, staff.email);
75 if (!result.ok) return data({ error: result.error.message, email }, { status: 422 });
76 throw redirect(`/users/${encodeURIComponent(params.username)}?removed=${encodeURIComponent(email)}`);
77}
78
79export default function User({ loaderData, actionData }: Route.ComponentProps) {
80 const { user, error, removed, done, now } = loaderData;
81 const accountError = actionData && "account" in actionData ? actionData.error : null;
82 if (!user) {
83 return (
84 <main className="mx-auto max-w-4xl px-4 py-8 sm:py-10">
85 <Notice tone="error">Could not load the account: {error}</Notice>
86 </main>
87 );
88 }
89 return (
90 <main className="mx-auto max-w-4xl px-4 py-8 sm:py-10">
91 <Link to="/workspaces" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
92 <ArrowLeft size={14} /> Workspaces
93 </Link>
94 <PageHeader
95 title={
96 <span className="flex flex-wrap items-center gap-2">
97 {user.username}
98 {user.deleted && <Badge tone="danger">Deleted</Badge>}
99 {user.deletion.protected && <Badge tone="info">Protected</Badge>}
100 </span>
101 }
102 description={
103 <>
104 Account <span className="font-mono">{user.id}</span>, made <When at={user.createdAt} />.{" "}
105 {user.privateEmail ? "Keeps its address private on commits." : "Shows its primary address on commits."}
106 </>
107 }
108 />
109 {removed && (
110 <div className="mt-5">
111 <Notice tone="ok">Removed {removed}. The person was told, with the reason.</Notice>
112 </div>
113 )}
114 {done && (
115 <div className="mt-5">
116 <Notice tone="ok">{done}</Notice>
117 </div>
118 )}
119
120 <Section
121 id="emails"
122 title="Email addresses"
123 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."
124 className="mt-6"
125 >
126 <ul className="divide-y divide-line rounded-md border border-line">
127 {user.emails.map((email) => (
128 <li key={email.email} className="space-y-3 px-4 py-3">
129 <div className="flex flex-wrap items-center gap-2 text-sm">
130 <span className="font-mono break-all">{email.email}</span>
131 {email.primary && <Badge tone="lavender">Primary</Badge>}
132 {email.backup && <Badge>Backup</Badge>}
133 {email.verified ? <Badge tone="mint">Confirmed</Badge> : <Badge tone="warn">Unconfirmed</Badge>}
134 <span className="text-xs text-faint">
135 added <When at={email.createdAt} />
136 </span>
137 </div>
138 <Form method="post" className="flex flex-col gap-2 sm:flex-row sm:items-end">
139 <input type="hidden" name="email" value={email.email} />
140 <div className="grow">
141 <Field label="Reason (the person sees it)">
142 <Input
143 name="reason"
144 required
145 maxLength={200}
146 placeholder="Another account needs this address"
147 />
148 </Field>
149 </div>
150 <Button variant="danger" type="submit">
151 Remove
152 </Button>
153 </Form>
154 {actionData && "email" in actionData && actionData.email === email.email && actionData.error && <Notice tone="error">{actionData.error}</Notice>}
155 </li>
156 ))}
157 </ul>
158 </Section>
159
160 <Section id="log" title="Security log" description="What happened to the account's addresses and password, newest first." className="mt-6">
161 {user.log.length === 0 ? (
162 <EmptyState title="Nothing yet" />
163 ) : (
164 <ul className="divide-y divide-line text-sm">
165 {user.log.map((event, at) => (
166 <li key={`${event.createdAt}:${at}`} className="flex flex-col gap-1 py-2 sm:flex-row sm:justify-between sm:gap-4">
167 <span className="min-w-0 break-words">
168 {securityEventLabel(event)}
169 {event.staff && (
170 <span className="text-muted">
171 {" "}
172 · by {event.staff}
173 {event.reason ? `: ${event.reason}` : ""}
174 </span>
175 )}
176 </span>
177 <span className="shrink-0 text-xs text-faint">
178 <When at={event.createdAt} time />
179 </span>
180 </li>
181 ))}
182 </ul>
183 )}
184 </Section>
185
186 <AccountSection user={user} error={accountError} now={now} />
187 </main>
188 );
189}
190
191/**
192 * Deleting the account, or, once it is deleted, restoring or purging it.
193 * Every form is plain HTML: sudo ships no JavaScript.
194 */
195function AccountSection({ user, error, now }: { user: AdminUser; error: string | null; now: number }) {
196 const deleted = user.deleted;
197 if (deleted) {
198 const left = daysLeft(deleted.purgeAfter, now);
199 return (
200 <Section
201 id="account"
202 title="Deleted account"
203 description={`Deleted ${deleted.went.staff ? `by ${deleted.went.staff}` : "by the person"}. Kept ${ACCOUNT_RESTORE_DAYS} days for a restore, then purged.`}
204 className="mt-6"
205 >
206 <div className="space-y-3 text-sm">
207 <p className="flex flex-wrap items-center gap-2 text-muted">
208 <span>
209 Deleted <When at={deleted.deletedAt} time /> · purged <When at={deleted.purgeAfter} time />
210 </span>
211 {deleted.restorable ? (
212 <Badge tone="warn">
213 {left} day{left === 1 ? "" : "s"} left
214 </Badge>
215 ) : (
216 <Badge tone="danger">Being purged</Badge>
217 )}
218 </p>
219 {deleted.went.reason && <p className="text-muted">Reason: {deleted.went.reason}</p>}
220 <p className="text-muted">Left: {accountWentSummary(deleted.went)}</p>
221 {error && <Notice tone="error">{error}</Notice>}
222 <div className="flex flex-col gap-3 border-t border-line pt-4 sm:flex-row sm:items-end sm:justify-between">
223 <form method="post">
224 <input type="hidden" name="intent" value="restore-account" />
225 <input type="hidden" name="id" value={deleted.userId} />
226 <Button type="submit" variant="lavender" disabled={!deleted.restorable}>
227 Restore
228 </Button>
229 </form>
230 {user.deletion.protected ? (
231 <p className="text-muted">Protected: it can never be purged.</p>
232 ) : (
233 <form method="post" className="flex flex-col gap-2 sm:flex-row sm:items-end">
234 <input type="hidden" name="intent" value="purge-account" />
235 <input type="hidden" name="id" value={deleted.userId} />
236 <label className="grid gap-1 text-xs text-muted">
237 <span>
238 Type <span className="font-mono text-fg">{user.username}</span> to purge it now
239 </span>
240 <Input name="confirm" autoComplete="off" spellCheck={false} className="font-mono" />
241 </label>
242 <Button type="submit" variant="danger">
243 Purge now
244 </Button>
245 </form>
246 )}
247 </div>
248 </div>
249 </Section>
250 );
251 }
252 const refusal = staffDeletionRefusal(user.deletion);
253 return (
254 <Section
255 id="account"
256 title="Delete account"
257 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, or for abuse, with a reason.`}
258 className="mt-6"
259 >
260 {refusal ? (
261 <div className="space-y-3 text-sm">
262 <Notice tone="warn">{refusal}</Notice>
263 {user.deletion.sole_owner_of.length > 0 && (
264 <ul className="divide-y divide-line rounded-md border border-line">
265 {user.deletion.sole_owner_of.map((workspace) => (
266 <li key={workspace.slug} className="flex flex-wrap items-center justify-between gap-2 px-4 py-2">
267 <Link to={`/workspaces/${workspace.slug}`} className="text-fg hover:underline">
268 {workspace.name} <span className="font-mono text-xs text-muted">{workspace.slug}</span>
269 </Link>
270 <span className="text-xs text-faint">
271 {workspace.members} member{workspace.members === 1 ? "" : "s"}
272 </span>
273 </li>
274 ))}
275 </ul>
276 )}
277 </div>
278 ) : (
279 <details className="rounded-md border border-danger/30 px-3 py-2" open={Boolean(error)}>
280 <summary className="cursor-pointer text-sm text-danger">Delete this account</summary>
281 <form method="post" className="mt-3 grid gap-3 sm:max-w-md">
282 <input type="hidden" name="intent" value="delete-account" />
283 <Field label="Reason (kept in sudo's audit log)">
284 <Input name="reason" required maxLength={200} placeholder="The person asked from their primary address" />
285 </Field>
286 <Field label={`Type ${user.username} to confirm`}>
287 <Input name="confirm" required autoComplete="off" spellCheck={false} className="font-mono" />
288 </Field>
289 {error && <Notice tone="error">{error}</Notice>}
290 <div>
291 <Button type="submit" variant="danger">
292 Delete account
293 </Button>
294 </div>
295 </form>
296 </details>
297 )}
298 </Section>
299 );
300}