Skip to content
156 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)1import { ArrowLeft } from "lucide-react";
2import { Link, data, redirect } from "react-router";
3
4import { ACCOUNT_RESTORE_DAYS, type DeletedAccount } from "@g1t/contracts";
5
6import type { Route } from "./+types/deleted-accounts";
7import { Badge, Button, EmptyState, Input, Notice, PageHeader, When } from "~/components/ui";
8import { accountWentSummary, confirmsUsername } 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 = () => [
16 { title: "Deleted accounts · sudo" },
17 { name: "robots", content: "noindex, nofollow" },
18];
19
20export async function loader({ request, context }: Route.LoaderArgs) {
21 requireStaff(context);
22 const url = new URL(request.url);
23 const deleted = await settle(accountsAdmin.deletedAccounts());
24 const done = url.searchParams.get("done");
25 const username = url.searchParams.get("username") ?? "";
26 return {
27 accounts: deleted.ok ? deleted.value : [],
28 error: deleted.ok ? null : deleted.error,
29 done: done === "restored" ? `Restored ${username}.` : done === "purged" ? `Purged ${username}.` : null,
30 now: Date.now(),
31 };
32}
33
34/**
35 * Restoring and purging. Identity checks the window, the typed username
36 * and protection again, and records each in sudo's audit log naming the
37 * staff member.
38 */
39export async function action({ request, context }: Route.ActionArgs) {
40 const staff = requireStaff(context);
41 const form = await request.formData();
42 const id = text(form, "id");
43 const username = text(form, "username");
44 const back = (done: string) => redirect(`/users/deleted?done=${done}&username=${encodeURIComponent(username)}`);
45 switch (text(form, "intent")) {
46 case "restore": {
47 const result = await accountsAdmin.restoreAccount(id, staff.email);
48 if (!result.ok) return data({ error: result.error.message, id }, { status: 422 });
49 throw back("restored");
50 }
51 case "purge": {
52 const confirm = text(form, "confirm");
53 if (!confirmsUsername(username, confirm)) return data({ error: `Type ${username} to confirm.`, id }, { status: 422 });
54 const result = await accountsAdmin.purgeAccount(id, staff.email, confirm);
55 if (!result.ok) return data({ error: result.error.message, id }, { status: 422 });
56 throw back("purged");
57 }
58 }
59 return data({ error: "Unknown action.", id }, { status: 400 });
60}
61
62export default function DeletedAccounts({ loaderData, actionData }: Route.ComponentProps) {
63 const { accounts, error, done, now } = loaderData;
64 const errorFor = (id: string) => (actionData && "id" in actionData && actionData.id === id ? actionData.error : null);
65 return (
66 <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
67 <Link to="/workspaces" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
68 <ArrowLeft size={14} />
69 Workspaces
70 </Link>
71 <div className="mt-4">
72 <PageHeader
73 title="Deleted accounts"
74 description={`Accounts deleted by the person or by staff. Each is kept for ${ACCOUNT_RESTORE_DAYS} days: restore one the person asks back, after checking they own one of its addresses. Then it is purged for good, and its username is never given out again.`}
75 />
76 </div>
77 <div className="mt-6 space-y-3">
78 {done && <Notice tone="ok">{done}</Notice>}
79 {error && <Notice tone="error">Identity did not answer: {error}</Notice>}
80 </div>
81 {accounts.length === 0 ? (
82 <div className="mt-6">
83 <EmptyState title="No deleted accounts">Accounts appear here when they are deleted, until they are purged.</EmptyState>
84 </div>
85 ) : (
86 <ul className="mt-6 space-y-3">
87 {accounts.map((account) => (
88 <DeletedRow key={account.userId} account={account} now={now} error={errorFor(account.userId)} />
89 ))}
90 </ul>
91 )}
92 </main>
93 );
94}
95
96function DeletedRow({ account, now, error }: { account: DeletedAccount; now: number; error: string | null }) {
97 const left = daysLeft(account.purgeAfter, now);
98 return (
99 <li className="rounded-lg border border-line bg-surface p-4 sm:p-5">
100 <div className="flex flex-wrap items-start justify-between gap-3">
101 <div className="min-w-0">
102 <p className="font-medium">
103 <Link to={`/users/${account.username}`} className="font-mono hover:underline">
104 {account.username}
105 </Link>
106 </p>
107 <p className="mt-1 text-sm text-muted">
108 Deleted by <span className="text-fg-soft">{account.went.staff ?? "the person"}</span>{" "}
109 <When at={account.deletedAt} time />
110 {" · "}purged <When at={account.purgeAfter} time />
111 </p>
112 {account.went.reason && <p className="mt-1 text-sm text-muted">Reason: {account.went.reason}</p>}
113 <p className="mt-1 text-sm text-muted">Left: {accountWentSummary(account.went)}</p>
114 </div>
115 <div className="flex flex-wrap gap-1.5">
116 {account.restorable ? (
117 <Badge tone="warn">
118 {left} day{left === 1 ? "" : "s"} left
119 </Badge>
120 ) : (
121 <Badge tone="danger">Being purged</Badge>
122 )}
123 </div>
124 </div>
125 {error && (
126 <div className="mt-3">
127 <Notice tone="error">{error}</Notice>
128 </div>
129 )}
130 <div className="mt-4 flex flex-col gap-3 border-t border-line pt-4 sm:flex-row sm:items-end sm:justify-between">
131 <form method="post">
132 <input type="hidden" name="intent" value="restore" />
133 <input type="hidden" name="id" value={account.userId} />
134 <input type="hidden" name="username" value={account.username} />
135 <Button type="submit" variant="lavender" disabled={!account.restorable}>
136 Restore
137 </Button>
138 </form>
139 <form method="post" className="flex flex-col gap-2 sm:flex-row sm:items-end">
140 <input type="hidden" name="intent" value="purge" />
141 <input type="hidden" name="id" value={account.userId} />
142 <input type="hidden" name="username" value={account.username} />
143 <label className="grid gap-1 text-xs text-muted">
144 <span>
145 Type <span className="font-mono text-fg">{account.username}</span> to purge it now
146 </span>
147 <Input name="confirm" autoComplete="off" spellCheck={false} className="font-mono" aria-label={`Type ${account.username} to purge it now`} />
148 </label>
149 <Button type="submit" variant="danger">
150 Purge now
151 </Button>
152 </form>
153 </div>
154 </li>
155 );
156}

This file's history is long; its oldest lines are credited to the oldest commit read.