g1t/apps/sudo/app/routes/deleted-workspaces.tsx

156 lines6,889 bytesCodeBlame
1import { ArrowLeft } from "lucide-react";
2import { Link, data, redirect } from "react-router";
3
4import { WORKSPACE_RESTORE_DAYS, type DeletedWorkspace } from "@g1t/contracts";
5
6import type { Route } from "./+types/deleted-workspaces";
7import { Badge, Button, EmptyState, Input, Notice, PageHeader, When } from "~/components/ui";
8import { confirmsPurge, daysLeft, wentSummary } from "~/lib/deleted-workspaces";
9import { text } from "~/lib/forms";
10import { identity } from "~/lib/services.server";
11import { settle } from "~/lib/settle";
12import { requireStaff } from "~/lib/staff";
13
14export const meta: Route.MetaFunction = () => [
15 { title: "Deleted workspaces · sudo" },
16 { name: "robots", content: "noindex, nofollow" },
17];
18
19export async function loader({ request, context }: Route.LoaderArgs) {
20 requireStaff(context);
21 const url = new URL(request.url);
22 const deleted = await settle(identity.deletedWorkspaces());
23 const done = url.searchParams.get("done");
24 const slug = url.searchParams.get("slug") ?? "";
25 return {
26 workspaces: deleted.ok ? deleted.value : [],
27 error: deleted.ok ? null : deleted.error,
28 done: done === "restored" ? `Restored ${slug}.` : done === "purged" ? `Purged ${slug}.` : null,
29 now: Date.now(),
30 };
31}
32
33/**
34 * Restoring and purging. Identity checks the window, the typed slug and
35 * protection again, and records each in the workspace's audit log and in
36 * sudo's, naming the staff member.
37 */
38export async function action({ request, context }: Route.ActionArgs) {
39 const staff = requireStaff(context);
40 const form = await request.formData();
41 const id = text(form, "id");
42 const slug = text(form, "slug");
43 const back = (done: string) => redirect(`/workspaces/deleted?done=${done}&slug=${encodeURIComponent(slug)}`);
44 switch (text(form, "intent")) {
45 case "restore": {
46 const result = await identity.restoreWorkspace(id, staff.email);
47 if (!result.ok) return data({ error: result.error.message, id }, { status: 422 });
48 throw back("restored");
49 }
50 case "purge": {
51 const confirm = text(form, "confirm");
52 if (!confirmsPurge(slug, confirm)) return data({ error: `Type ${slug} to confirm.`, id }, { status: 422 });
53 const result = await identity.purgeWorkspace(id, staff.email, confirm);
54 if (!result.ok) return data({ error: result.error.message, id }, { status: 422 });
55 throw back("purged");
56 }
57 }
58 return data({ error: "Unknown action.", id }, { status: 400 });
59}
60
61export default function DeletedWorkspaces({ loaderData, actionData }: Route.ComponentProps) {
62 const { workspaces, error, done, now } = loaderData;
63 const errorFor = (id: string) => (actionData && "id" in actionData && actionData.id === id ? actionData.error : null);
64 return (
65 <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
66 <Link to="/workspaces" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
67 <ArrowLeft size={14} />
68 Workspaces
69 </Link>
70 <div className="mt-4">
71 <PageHeader
72 title="Deleted workspaces"
73 description={`Workspaces their owners deleted, with everything in them. Each is kept for ${WORKSPACE_RESTORE_DAYS} days: restore one an owner asks back, as after a deletion they did not mean or did not make. Then it is purged for good.`}
74 />
75 </div>
76 <div className="mt-6 space-y-3">
77 {done && <Notice tone="ok">{done}</Notice>}
78 {error && <Notice tone="error">Identity did not answer: {error}</Notice>}
79 </div>
80 {workspaces.length === 0 ? (
81 <div className="mt-6">
82 <EmptyState title="No deleted workspaces">Workspaces appear here when an owner deletes one, until they are purged.</EmptyState>
83 </div>
84 ) : (
85 <ul className="mt-6 space-y-3">
86 {workspaces.map((workspace) => (
87 <DeletedRow key={workspace.workspaceId} workspace={workspace} now={now} error={errorFor(workspace.workspaceId)} />
88 ))}
89 </ul>
90 )}
91 </main>
92 );
93}
94
95function DeletedRow({ workspace, now, error }: { workspace: DeletedWorkspace; now: number; error: string | null }) {
96 const left = daysLeft(workspace.purgeAfter, now);
97 return (
98 <li className="rounded-lg border border-line bg-surface p-4 sm:p-5">
99 <div className="flex flex-wrap items-start justify-between gap-3">
100 <div className="min-w-0">
101 <p className="font-medium">
102 {workspace.name} <span className="font-mono text-sm text-muted">{workspace.slug}</span>
103 </p>
104 <p className="mt-1 text-sm text-muted">
105 Deleted by <span className="text-fg-soft">{workspace.deletedBy || "an owner"}</span> <When at={workspace.deletedAt} time />
106 {" · "}purged <When at={workspace.purgeAfter} time />
107 </p>
108 <p className="mt-1 text-sm text-muted">Went with it: {wentSummary(workspace.went)}</p>
109 </div>
110 <div className="flex flex-wrap gap-1.5">
111 {workspace.went.protected && <Badge tone="info">Protected</Badge>}
112 {workspace.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={workspace.workspaceId} />
130 <input type="hidden" name="slug" value={workspace.slug} />
131 <Button type="submit" variant="lavender" disabled={!workspace.restorable}>
132 Restore
133 </Button>
134 </form>
135 {workspace.went.protected ? (
136 <p className="text-sm text-muted">Protected: it can never be purged.</p>
137 ) : (
138 <form method="post" className="flex flex-col gap-2 sm:flex-row sm:items-end">
139 <input type="hidden" name="intent" value="purge" />
140 <input type="hidden" name="id" value={workspace.workspaceId} />
141 <input type="hidden" name="slug" value={workspace.slug} />
142 <label className="grid gap-1 text-xs text-muted">
143 <span>
144 Type <span className="font-mono text-fg">{workspace.slug}</span> to purge it now
145 </span>
146 <Input name="confirm" autoComplete="off" spellCheck={false} className="font-mono" aria-label={`Type ${workspace.slug} to purge it now`} />
147 </label>
148 <Button type="submit" variant="danger">
149 Purge now
150 </Button>
151 </form>
152 )}
153 </div>
154 </li>
155 );
156}