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.

72 linesCodeBlameRaw
1import { Form, Link, data } from "react-router";
2import type { Route } from "./+types/privacy";
3import { Button, Input, Notice, PageHeader, Textarea, When } from "~/components/ui";
4import { privacy } from "~/lib/services.server";
5import { requireStaff } from "~/lib/staff";
6
7const evidenceFields = ["identity", "downstream", "shared_content", "processors", "backups", "response", "retention"] as const;
8
9export async function loader({ context }: Route.LoaderArgs) {
10 requireStaff(context);
11 const [requests, pending] = await Promise.all([privacy.requests(), privacy.pending()]);
12 return { requests, pending, now: new Date().toISOString() };
13}
14
15export async function action({ request, context }: Route.ActionArgs) {
16 // The worker refuses a post from anywhere but sudo's own pages (lib/access.ts isSameOrigin).
17 const staff = requireStaff(context);
18 const form = await request.formData();
19 const status = String(form.get("status") ?? "");
20 if (status === "intake") {
21 const result = await privacy.intake(String(form.get("username") ?? ""), String(form.get("received_at") ?? ""), staff.email, String(form.get("notes") ?? ""));
22 return result.ok ? { saved: true } : data({ error: result.error.message }, { status: 422 });
23 }
24 const evidence = status === "completed"
25 ? JSON.stringify(Object.fromEntries(evidenceFields.map(key => [key, String(form.get(key) ?? "")])))
26 : String(form.get("notes") ?? "");
27 const result = await privacy.review(String(form.get("id") ?? ""), staff.email, status, evidence);
28 return result.ok ? { saved: true } : data({ error: result.error.message }, { status: 422 });
29}
30
31export default function Privacy({ loaderData, actionData }: Route.ComponentProps) {
32 return <main className="mx-auto max-w-5xl space-y-6 px-4 py-8">
33 <PageHeader title="Privacy requests" description="Respond without undue delay, by the recorded deadline. Automatic cleanup is only part of fulfilling an erasure request." />
34 {actionData && "error" in actionData && <Notice tone="error">{actionData.error}</Notice>}
35 {actionData && "saved" in actionData && <Notice tone="ok">Review recorded.</Notice>}
36 <p className="text-sm text-muted">Verify the request and assess shared content and lawful retention before using <Link to="/users/deleted" className="underline">account purge</Link>. A completed event publication still needs downstream verification. Follow <code>docs/operations/privacy-erasure.md</code>.</p>
37 <details><summary className="cursor-pointer">Record a request received elsewhere</summary><Form method="post" className="my-3 space-y-3">
38 <label className="block text-sm">Username<Input name="username" required /></label>
39 <label className="block text-sm">Original receipt time (UTC)<Input name="received_at" required placeholder="2026-10-14T15:30:00.000Z" /></label>
40 <label className="block text-sm">Source and verification notes<Textarea name="notes" required rows={3} maxLength={16000} /></label>
41 <Button name="status" value="intake">Record request</Button>
42 </Form></details>
43 {loaderData.requests.length === 0 && <p>No erasure requests recorded.</p>}
44 {loaderData.requests.map(request => <section key={request.id} className="space-y-3 rounded-lg border border-line p-4">
45 <h2 className="font-medium"><Link to={`/users/${request.username}`} className="underline">{request.username}</Link> — {request.status}</h2>
46 <p className="text-sm">{request.id}. Received <When at={request.requested_at} />. Response due <When at={request.due_at} />{!request.closed_at && request.due_at < loaderData.now && <strong className="text-danger"> — overdue</strong>}.</p>
47 <p className="text-sm text-muted">{request.scope}</p>
48 {request.reviews.length > 0 && <details><summary className="cursor-pointer text-sm">Case trail ({request.reviews.length})</summary>
49 <ol className="my-2 space-y-2">{request.reviews.map((review, i) => <li key={i} className="text-sm">
50 <When at={review.at} time /> · {review.status} · {review.staff}
51 <pre className="whitespace-pre-wrap break-words text-xs">{review.evidence}</pre>
52 </li>)}</ol>
53 </details>}
54 {!request.closed_at && <Form method="post" className="space-y-3">
55 <input type="hidden" name="id" value={request.id} />
56 <label className="block text-sm">Review notes or refusal grounds, response and appeal information<Textarea name="notes" rows={3} maxLength={16000} /></label>
57 <div className="flex gap-2"><Button name="status" value="reviewing">Save review</Button><Button name="status" value="refused" variant="quiet">Record refusal</Button></div>
58 <details><summary className="cursor-pointer text-sm">Completion evidence</summary>
59 <p className="my-3 text-sm text-muted">Record the actual outcome for each area, including evidence references, retained records and their reason and review date. Never paste credentials or unnecessary personal content. The response must already have been sent.</p>
60 {evidenceFields.map(key => <label key={key} className="my-3 block text-sm">{key.replaceAll("_", " ")}<Textarea name={key} rows={2} maxLength={2000} /></label>)}
61 <Button name="status" value="completed">Record completion</Button>
62 </details>
63 </Form>}
64 </section>)}
65 <section className="space-y-3"><h2 className="font-medium">Pending cleanup</h2>
66 <p className="text-sm text-muted">Retries run every 15 minutes with backoff, up to 16 hours. Failed work remains here until it succeeds. This list does not prove that downstream event consumers have finished.</p>
67 {loaderData.pending.length === 0 ? <p className="text-sm">No pending jobs.</p> : <ul className="space-y-2">{loaderData.pending.map(job => <li key={`${job.subject_id}:${job.target}`} className="text-sm">
68 <code>{job.subject_id}</code> · {job.target} · {job.attempts} attempts · retry <When at={job.retry_at} time />{job.last_error && ` · ${job.last_error}`}
69 </li>)}</ul>}
70 </section>
71 </main>;
72}