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.

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