Skip to content
511 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.

Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1import { ArrowLeft } from "lucide-react";
2import { Form, Link, data, redirect } from "react-router";
3
Merge sudo: delete an account with the workspaces it alone owns, purge each4import { ACCOUNT_RESTORE_DAYS, type AdminUser, WORKSPACE_RESTORE_DAYS, securityEventLabel } from "@g1t/contracts";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look5
6import type { Route } from "./+types/user";
7import { Badge, Button, EmptyState, Field, Input, Notice, PageHeader, Section, When } from "~/components/ui";
Merge sudo: delete an account with the workspaces it alone owns, purge each8import {
9 type DeletedWithAccount,
10 accountWentSummary,
11 confirmsUsername,
12 deletedWithAccount,
13 soleOwnerNote,
14 soleWorkspaceRefusal,
15 soleWorkspacesRefusal,
16 staffDeleteProblem,
17 staffDeletionRefusal,
18} from "~/lib/deleted-accounts";
19import { confirmsPurge, daysLeft } from "~/lib/deleted-workspaces";
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)20import { text } from "~/lib/forms";
Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038)21import { joinedThrough } from "~/lib/invites";
Merge sudo: delete an account with the workspaces it alone owns, purge each22import { accountsAdmin, identity } from "~/lib/services.server";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look23import { settle } from "~/lib/settle";
24import { requireStaff } from "~/lib/staff";
25
26export const meta: Route.MetaFunction = ({ params }) => [
27 { title: `${params.username} · sudo` },
28 { name: "robots", content: "noindex, nofollow" },
29];
30
31export async function loader({ params, request, context }: Route.LoaderArgs) {
32 requireStaff(context);
33 const result = await settle(accountsAdmin.user(params.username));
34 if (result.ok && !result.value) throw data("No such account.", { status: 404 });
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)35 const url = new URL(request.url);
36 const done = url.searchParams.get("done");
Merge sudo: delete an account with the workspaces it alone owns, purge each37 const slug = url.searchParams.get("slug") ?? "";
38 // The workspaces staff deleted with it, each with its deletion while it
39 // waits to be purged.
40 const user = result.ok ? result.value : null;
41 const went = user?.deleted?.went;
42 let workspaces: DeletedWithAccount[] = [];
43 let workspacesError: string | null = null;
44 if (went && (went.deletedWorkspaces ?? []).length > 0) {
45 const deleted = await settle(identity.deletedWorkspaces());
46 workspaces = deletedWithAccount(went, deleted.ok ? deleted.value : []);
47 workspacesError = deleted.ok ? null : deleted.error;
48 }
49 const messages: Record<string, string> = {
50 deleted: "Deleted the account.",
51 "deleted-with-workspaces": "Deleted the account and the workspaces it alone owned.",
52 restored: "Restored the account.",
53 "workspace-purged": `Purged ${slug}.`,
54 };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look55 return {
Merge sudo: delete an account with the workspaces it alone owns, purge each56 user,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look57 error: result.ok ? null : result.error,
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)58 removed: url.searchParams.get("removed"),
Merge sudo: delete an account with the workspaces it alone owns, purge each59 done: done ? (messages[done] ?? null) : null,
60 workspaces,
61 workspacesError,
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)62 now: Date.now(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look63 };
64}
65
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)66/**
67 * Removes an address (the reason is required, recorded and shown to the
Merge sudo: delete an account with the workspaces it alone owns, purge each68 * person), or deletes, restores or purges the account, or purges a
69 * workspace deleted with it. Identity checks each again: protection, the
70 * workspaces it owns alone and whether they can go, the typed username or
71 * slug, the restore window.
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)72 */
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look73export async function action({ params, request, context }: Route.ActionArgs) {
74 const staff = requireStaff(context);
75 const form = await request.formData();
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)76 const intent = text(form, "intent");
77 const back = (done: string) => redirect(`/users/${encodeURIComponent(params.username)}?done=${done}`);
78 if (intent === "delete-account") {
79 const reason = text(form, "reason");
80 const confirm = text(form, "confirm");
Merge sudo: delete an account with the workspaces it alone owns, purge each81 const withWorkspaces = text(form, "with-workspaces") === "1";
82 const problem = staffDeleteProblem({
83 username: params.username,
84 reason,
85 confirm,
86 withWorkspaces,
87 acknowledged: text(form, "acknowledge") === "on",
88 });
89 if (problem) return data({ error: problem, account: true }, { status: 422 });
90 const result = await accountsAdmin.deleteAccount(params.username, reason, confirm, staff.email, withWorkspaces);
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)91 if (!result.ok) return data({ error: result.error.message, account: true }, { status: 422 });
Merge sudo: delete an account with the workspaces it alone owns, purge each92 throw back(withWorkspaces ? "deleted-with-workspaces" : "deleted");
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)93 }
Merge sudo: delete an account with the workspaces it alone owns, purge each94 if (intent === "purge-workspace") {
95 const id = text(form, "id");
96 const slug = text(form, "slug");
97 const confirm = text(form, "confirm");
98 if (!confirmsPurge(slug, confirm)) return data({ error: `Type ${slug} to confirm.`, workspace: id }, { status: 422 });
99 const result = await identity.purgeWorkspace(id, staff.email, confirm);
100 if (!result.ok) return data({ error: result.error.message, workspace: id }, { status: 422 });
101 throw redirect(`/users/${encodeURIComponent(params.username)}?done=workspace-purged&slug=${encodeURIComponent(slug)}`);
102 }
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)103 if (intent === "restore-account") {
104 const result = await accountsAdmin.restoreAccount(text(form, "id"), staff.email);
105 if (!result.ok) return data({ error: result.error.message, account: true }, { status: 422 });
106 throw back("restored");
107 }
108 if (intent === "purge-account") {
109 const confirm = text(form, "confirm");
110 if (!confirmsUsername(params.username, confirm)) {
111 return data({ error: `Type ${params.username} to confirm.`, account: true }, { status: 422 });
112 }
113 const result = await accountsAdmin.purgeAccount(text(form, "id"), staff.email, confirm);
114 if (!result.ok) return data({ error: result.error.message, account: true }, { status: 422 });
115 throw redirect(`/users/deleted?done=purged&username=${encodeURIComponent(params.username)}`);
116 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look117 const email = String(form.get("email") ?? "").trim();
118 const reason = String(form.get("reason") ?? "").trim();
119 if (!reason) return data({ error: "Say why. The person sees the reason in their security log.", email }, { status: 422 });
120 const result = await accountsAdmin.removeEmail(params.username, email, reason, staff.email);
121 if (!result.ok) return data({ error: result.error.message, email }, { status: 422 });
122 throw redirect(`/users/${encodeURIComponent(params.username)}?removed=${encodeURIComponent(email)}`);
123}
124
125export default function User({ loaderData, actionData }: Route.ComponentProps) {
Merge sudo: delete an account with the workspaces it alone owns, purge each126 const { user, error, removed, done, workspaces, workspacesError, now } = loaderData;
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)127 const accountError = actionData && "account" in actionData ? actionData.error : null;
Merge sudo: delete an account with the workspaces it alone owns, purge each128 const workspaceError = actionData && "workspace" in actionData ? { id: actionData.workspace, error: actionData.error } : null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look129 if (!user) {
130 return (
131 <main className="mx-auto max-w-4xl px-4 py-8 sm:py-10">
132 <Notice tone="error">Could not load the account: {error}</Notice>
133 </main>
134 );
135 }
136 return (
137 <main className="mx-auto max-w-4xl px-4 py-8 sm:py-10">
138 <Link to="/workspaces" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
139 <ArrowLeft size={14} /> Workspaces
140 </Link>
141 <PageHeader
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)142 title={
143 <span className="flex flex-wrap items-center gap-2">
144 {user.username}
145 {user.deleted && <Badge tone="danger">Deleted</Badge>}
146 {user.deletion.protected && <Badge tone="info">Protected</Badge>}
147 </span>
148 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look149 description={
150 <>
151 Account <span className="font-mono">{user.id}</span>, made <When at={user.createdAt} />.{" "}
Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038)152 {user.joinedThrough && (
153 <>
154 <Link to={`/invites?tab=shared#${user.joinedThrough.id}`} className="text-accent hover:underline">
155 {joinedThrough(user.joinedThrough.label)}
156 </Link>
157 .{" "}
158 </>
159 )}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look160 {user.privateEmail ? "Keeps its address private on commits." : "Shows its primary address on commits."}
161 </>
162 }
163 />
164 {removed && (
165 <div className="mt-5">
166 <Notice tone="ok">Removed {removed}. The person was told, with the reason.</Notice>
167 </div>
168 )}
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)169 {done && (
170 <div className="mt-5">
171 <Notice tone="ok">{done}</Notice>
172 </div>
173 )}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look174
175 <Section
176 id="emails"
177 title="Email addresses"
178 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."
179 className="mt-6"
180 >
181 <ul className="divide-y divide-line rounded-md border border-line">
182 {user.emails.map((email) => (
183 <li key={email.email} className="space-y-3 px-4 py-3">
184 <div className="flex flex-wrap items-center gap-2 text-sm">
185 <span className="font-mono break-all">{email.email}</span>
186 {email.primary && <Badge tone="lavender">Primary</Badge>}
187 {email.backup && <Badge>Backup</Badge>}
188 {email.verified ? <Badge tone="mint">Confirmed</Badge> : <Badge tone="warn">Unconfirmed</Badge>}
189 <span className="text-xs text-faint">
190 added <When at={email.createdAt} />
191 </span>
192 </div>
193 <Form method="post" className="flex flex-col gap-2 sm:flex-row sm:items-end">
194 <input type="hidden" name="email" value={email.email} />
195 <div className="grow">
196 <Field label="Reason (the person sees it)">
197 <Input
198 name="reason"
199 required
200 maxLength={200}
201 placeholder="Another account needs this address"
202 />
203 </Field>
204 </div>
205 <Button variant="danger" type="submit">
206 Remove
207 </Button>
208 </Form>
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)209 {actionData && "email" in actionData && actionData.email === email.email && actionData.error && <Notice tone="error">{actionData.error}</Notice>}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look210 </li>
211 ))}
212 </ul>
213 </Section>
214
215 <Section id="log" title="Security log" description="What happened to the account's addresses and password, newest first." className="mt-6">
216 {user.log.length === 0 ? (
217 <EmptyState title="Nothing yet" />
218 ) : (
219 <ul className="divide-y divide-line text-sm">
220 {user.log.map((event, at) => (
221 <li key={`${event.createdAt}:${at}`} className="flex flex-col gap-1 py-2 sm:flex-row sm:justify-between sm:gap-4">
222 <span className="min-w-0 break-words">
223 {securityEventLabel(event)}
224 {event.staff && (
225 <span className="text-muted">
226 {" "}
227 · by {event.staff}
228 {event.reason ? `: ${event.reason}` : ""}
229 </span>
230 )}
231 </span>
232 <span className="shrink-0 text-xs text-faint">
233 <When at={event.createdAt} time />
234 </span>
235 </li>
236 ))}
237 </ul>
238 )}
239 </Section>
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)240
Merge sudo: delete an account with the workspaces it alone owns, purge each241 <AccountSection
242 user={user}
243 error={accountError}
244 workspaces={workspaces}
245 workspacesError={workspacesError}
246 workspaceError={workspaceError}
247 now={now}
248 />
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look249 </main>
250 );
251}
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)252
253/**
Merge sudo: delete an account with the workspaces it alone owns, purge each254 * Deleting the account, or, once it is deleted, restoring or purging it
255 * and the workspaces deleted with it. Every form is plain HTML: sudo ships
256 * no JavaScript.
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)257 */
Merge sudo: delete an account with the workspaces it alone owns, purge each258function AccountSection({
259 user,
260 error,
261 workspaces,
262 workspacesError,
263 workspaceError,
264 now,
265}: {
266 user: AdminUser;
267 error: string | null;
268 workspaces: DeletedWithAccount[];
269 workspacesError: string | null;
270 workspaceError: { id: string; error: string } | null;
271 now: number;
272}) {
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)273 const deleted = user.deleted;
274 if (deleted) {
275 const left = daysLeft(deleted.purgeAfter, now);
276 return (
277 <Section
278 id="account"
279 title="Deleted account"
Merge sudo: delete an account with the workspaces it alone owns, purge each280 description={`Deleted ${deleted.went.staff ? `by ${deleted.went.staff}` : "by the person"}. Kept ${ACCOUNT_RESTORE_DAYS} days for a restore, then purged. Staff can purge it now.`}
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)281 className="mt-6"
282 >
283 <div className="space-y-3 text-sm">
284 <p className="flex flex-wrap items-center gap-2 text-muted">
285 <span>
286 Deleted <When at={deleted.deletedAt} time /> · purged <When at={deleted.purgeAfter} time />
287 </span>
288 {deleted.restorable ? (
289 <Badge tone="warn">
290 {left} day{left === 1 ? "" : "s"} left
291 </Badge>
292 ) : (
293 <Badge tone="danger">Being purged</Badge>
294 )}
295 </p>
296 {deleted.went.reason && <p className="text-muted">Reason: {deleted.went.reason}</p>}
297 <p className="text-muted">Left: {accountWentSummary(deleted.went)}</p>
Merge sudo: delete an account with the workspaces it alone owns, purge each298 {workspaces.length > 0 && (
299 <DeletedWorkspaces workspaces={workspaces} error={workspacesError} workspaceError={workspaceError} now={now} />
300 )}
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)301 {error && <Notice tone="error">{error}</Notice>}
302 <div className="flex flex-col gap-3 border-t border-line pt-4 sm:flex-row sm:items-end sm:justify-between">
303 <form method="post">
304 <input type="hidden" name="intent" value="restore-account" />
305 <input type="hidden" name="id" value={deleted.userId} />
306 <Button type="submit" variant="lavender" disabled={!deleted.restorable}>
307 Restore
308 </Button>
309 </form>
310 {user.deletion.protected ? (
311 <p className="text-muted">Protected: it can never be purged.</p>
312 ) : (
313 <form method="post" className="flex flex-col gap-2 sm:flex-row sm:items-end">
314 <input type="hidden" name="intent" value="purge-account" />
315 <input type="hidden" name="id" value={deleted.userId} />
316 <label className="grid gap-1 text-xs text-muted">
317 <span>
318 Type <span className="font-mono text-fg">{user.username}</span> to purge it now
319 </span>
320 <Input name="confirm" autoComplete="off" spellCheck={false} className="font-mono" />
321 </label>
322 <Button type="submit" variant="danger">
323 Purge now
324 </Button>
325 </form>
326 )}
327 </div>
328 </div>
329 </Section>
330 );
331 }
332 const refusal = staffDeletionRefusal(user.deletion);
Merge sudo: delete an account with the workspaces it alone owns, purge each333 const sole = user.deletion.sole_owner_of;
334 const blocked = soleWorkspacesRefusal(user.deletion);
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)335 return (
336 <Section
337 id="account"
338 title="Delete account"
Merge sudo: delete an account with the workspaces it alone owns, purge each339 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, for abuse, or for an account g1t no longer uses, with a reason.`}
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)340 className="mt-6"
341 >
342 {refusal ? (
Merge sudo: delete an account with the workspaces it alone owns, purge each343 <Notice tone="warn">{refusal}</Notice>
344 ) : sole.length === 0 ? (
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)345 <details className="rounded-md border border-danger/30 px-3 py-2" open={Boolean(error)}>
346 <summary className="cursor-pointer text-sm text-danger">Delete this account</summary>
347 <form method="post" className="mt-3 grid gap-3 sm:max-w-md">
348 <input type="hidden" name="intent" value="delete-account" />
349 <Field label="Reason (kept in sudo's audit log)">
350 <Input name="reason" required maxLength={200} placeholder="The person asked from their primary address" />
351 </Field>
352 <Field label={`Type ${user.username} to confirm`}>
353 <Input name="confirm" required autoComplete="off" spellCheck={false} className="font-mono" />
354 </Field>
355 {error && <Notice tone="error">{error}</Notice>}
356 <div>
357 <Button type="submit" variant="danger">
358 Delete account
359 </Button>
360 </div>
361 </form>
362 </details>
Merge sudo: delete an account with the workspaces it alone owns, purge each363 ) : (
364 <div className="space-y-3 text-sm">
365 <Notice tone="warn">{soleOwnerNote(user.deletion)}</Notice>
366 <ul className="divide-y divide-line rounded-md border border-line">
367 {sole.map((workspace) => {
368 const why = soleWorkspaceRefusal(workspace);
369 return (
370 <li key={workspace.slug} className="space-y-1 px-4 py-2">
371 <div className="flex flex-wrap items-center justify-between gap-2">
372 <Link to={`/workspaces/${workspace.slug}`} className="text-fg hover:underline">
373 {workspace.name} <span className="font-mono text-xs text-muted">{workspace.slug}</span>
374 </Link>
375 <span className="flex flex-wrap items-center gap-1.5 text-xs text-faint">
376 {workspace.protected && <Badge tone="info">Protected</Badge>}
377 {!workspace.protected && workspace.billing && <Badge tone="danger">Billing</Badge>}
378 {workspace.members} member{workspace.members === 1 ? "" : "s"}
379 </span>
380 </div>
381 {why && <p className="text-xs text-danger">{why}</p>}
382 </li>
383 );
384 })}
385 </ul>
386 {blocked ? (
387 <>
388 <Notice tone="error">{blocked}</Notice>
389 {error && <Notice tone="error">{error}</Notice>}
390 </>
391 ) : (
392 <details className="rounded-md border border-danger/30 px-3 py-2" open={Boolean(error)}>
393 <summary className="cursor-pointer text-sm text-danger">Delete account and the workspaces it alone owns</summary>
394 <form method="post" className="mt-3 grid gap-3 sm:max-w-md">
395 <input type="hidden" name="intent" value="delete-account" />
396 <input type="hidden" name="with-workspaces" value="1" />
397 <p className="text-muted">
398 Deletes {sole.map((workspace) => workspace.slug).join(", ")} first, each as its owner would (billing closes it, its
399 repositories go with it, kept {WORKSPACE_RESTORE_DAYS} days for a restore), then the account. If a workspace cannot go,
400 the account is not deleted.
401 </p>
402 <Field label="Reason (kept in sudo's audit log)">
403 <Input name="reason" required maxLength={200} placeholder="Retired test account" />
404 </Field>
405 <Field label={`Type ${user.username} to confirm`}>
406 <Input name="confirm" required autoComplete="off" spellCheck={false} className="font-mono" />
407 </Field>
408 <label className="flex items-start gap-2 text-sm">
409 <input type="checkbox" name="acknowledge" required className="mt-0.5 accent-[var(--g1t-accent)]" />
410 <span>
411 {sole.length === 1 ? (
412 <>
413 The workspace <span className="font-mono">{sole[0].slug}</span> is deleted too, with everything in it.
414 </>
415 ) : (
416 <>
417 The {sole.length} workspaces <span className="font-mono">{sole.map((workspace) => workspace.slug).join(", ")}</span> are
418 deleted too, with everything in them.
419 </>
420 )}
421 </span>
422 </label>
423 {error && <Notice tone="error">{error}</Notice>}
424 <div>
425 <Button type="submit" variant="danger">
426 Delete account and {sole.length === 1 ? "its workspace" : `its ${sole.length} workspaces`}
427 </Button>
428 </div>
429 </form>
430 </details>
431 )}
432 </div>
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)433 )}
434 </Section>
435 );
436}
Merge sudo: delete an account with the workspaces it alone owns, purge each437
438/**
439 * The workspaces staff deleted with the account: each waiting to be purged
440 * can be purged now (identity purges only a workspace still deleted), or
441 * restored from Deleted workspaces.
442 */
443function DeletedWorkspaces({
444 workspaces,
445 error,
446 workspaceError,
447 now,
448}: {
449 workspaces: DeletedWithAccount[];
450 error: string | null;
451 workspaceError: { id: string; error: string } | null;
452 now: number;
453}) {
454 return (
455 <div className="space-y-2 border-t border-line pt-4">
456 <p className="text-fg">Workspaces deleted with it</p>
457 <p className="text-muted">
458 Purge them before the account if they should go now: once the account is purged, this page is gone (they stay on{" "}
459 <Link to="/workspaces/deleted" className="text-fg hover:underline">
460 Deleted workspaces
461 </Link>
462 ). To undo it all, restore the account first, then each workspace, so it comes back with its owner.
463 </p>
464 {error && <Notice tone="error">Could not load deleted workspaces: {error}</Notice>}
465 <ul className="divide-y divide-line rounded-md border border-line">
466 {workspaces.map((workspace) => {
467 const waiting = workspace.deleted;
468 const left = waiting ? daysLeft(waiting.purgeAfter, now) : 0;
469 return (
470 <li key={workspace.workspaceId} className="space-y-2 px-4 py-3">
471 <div className="flex flex-wrap items-center justify-between gap-2">
472 <span className="font-mono">{workspace.slug}</span>
473 {waiting ? (
474 waiting.restorable ? (
475 <Badge tone="warn">
476 {left} day{left === 1 ? "" : "s"} left
477 </Badge>
478 ) : (
479 <Badge tone="danger">Being purged</Badge>
480 )
481 ) : (
482 <Badge>Purged or restored</Badge>
483 )}
484 </div>
485 {waiting &&
486 (waiting.went.protected ? (
487 <p className="text-xs text-muted">Protected: it can never be purged.</p>
488 ) : (
489 <form method="post" className="flex flex-col gap-2 sm:flex-row sm:items-end">
490 <input type="hidden" name="intent" value="purge-workspace" />
491 <input type="hidden" name="id" value={workspace.workspaceId} />
492 <input type="hidden" name="slug" value={workspace.slug} />
493 <label className="grid gap-1 text-xs text-muted">
494 <span>
495 Type <span className="font-mono text-fg">{workspace.slug}</span> to purge it now
496 </span>
497 <Input name="confirm" autoComplete="off" spellCheck={false} className="font-mono" />
498 </label>
499 <Button type="submit" variant="danger">
500 Purge now
501 </Button>
502 </form>
503 ))}
504 {workspaceError && workspaceError.id === workspace.workspaceId && <Notice tone="error">{workspaceError.error}</Notice>}
505 </li>
506 );
507 })}
508 </ul>
509 </div>
510 );
511}

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