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.

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