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.

156 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(
22 String(form.get("username") ?? ""),
23 String(form.get("received_at") ?? ""),
24 staff.email,
25 String(form.get("notes") ?? ""),
26 );
27 return result.ok ? { saved: true } : data({ error: result.error.message }, { status: 422 });
28 }
29 const evidence =
30 status === "completed"
31 ? JSON.stringify(Object.fromEntries(evidenceFields.map((key) => [key, String(form.get(key) ?? "")])))
32 : String(form.get("notes") ?? "");
33 const result = await privacy.review(String(form.get("id") ?? ""), staff.email, status, evidence);
34 return result.ok ? { saved: true } : data({ error: result.error.message }, { status: 422 });
35}
36
37export default function Privacy({ loaderData, actionData }: Route.ComponentProps) {
38 return (
39 <main className="mx-auto max-w-5xl space-y-6 px-4 py-8">
40 <PageHeader
41 title="Privacy requests"
42 description="Respond without undue delay, by the recorded deadline. Automatic cleanup is only part of fulfilling an erasure request."
43 />
44 {actionData && "error" in actionData && <Notice tone="error">{actionData.error}</Notice>}
45 {actionData && "saved" in actionData && <Notice tone="ok">Review recorded.</Notice>}
46 <p className="text-sm text-muted">
47 Verify the request and assess shared content and lawful retention before using{" "}
48 <Link to="/users/deleted" className="underline">
49 account purge
50 </Link>
51 . A completed event publication still needs downstream verification. Follow <code>docs/operations/privacy-erasure.md</code>.
52 </p>
53 <details>
54 <summary className="cursor-pointer">Record a request received elsewhere</summary>
55 <Form method="post" className="my-3 space-y-3">
56 <label className="block text-sm">
57 Username
58 <Input name="username" required />
59 </label>
60 <label className="block text-sm">
61 Original receipt time (UTC)
62 <Input name="received_at" required placeholder="2026-10-14T15:30:00.000Z" />
63 </label>
64 <label className="block text-sm">
65 Source and verification notes
66 <Textarea name="notes" required rows={3} maxLength={16000} />
67 </label>
68 <Button name="status" value="intake">
69 Record request
70 </Button>
71 </Form>
72 </details>
73 {loaderData.requests.length === 0 && <p>No erasure requests recorded.</p>}
74 {loaderData.requests.map((request) => (
75 <section key={request.id} className="space-y-3 rounded-lg border border-line p-4">
76 <h2 className="font-medium">
77 <Link to={`/users/${request.username}`} className="underline">
78 {request.username}
79 </Link>{" "}
80 — {request.status}
81 </h2>
82 <p className="text-sm">
83 {request.id}. Received <When at={request.requested_at} />. Response due <When at={request.due_at} />
84 {!request.closed_at && request.due_at < loaderData.now && <strong className="text-danger"> — overdue</strong>}.
85 </p>
86 <p className="text-sm text-muted">{request.scope}</p>
87 {request.reviews.length > 0 && (
88 <details>
89 <summary className="cursor-pointer text-sm">Case trail ({request.reviews.length})</summary>
90 <ol className="my-2 space-y-2">
91 {request.reviews.map((review, i) => (
92 <li key={i} className="text-sm">
93 <When at={review.at} time /> · {review.status} · {review.staff}
94 <pre className="whitespace-pre-wrap break-words text-xs">{review.evidence}</pre>
95 </li>
96 ))}
97 </ol>
98 </details>
99 )}
100 {!request.closed_at && (
101 <Form method="post" className="space-y-3">
102 <input type="hidden" name="id" value={request.id} />
103 <label className="block text-sm">
104 Review notes or refusal grounds, response and appeal information
105 <Textarea name="notes" rows={3} maxLength={16000} />
106 </label>
107 <div className="flex gap-2">
108 <Button name="status" value="reviewing">
109 Save review
110 </Button>
111 <Button name="status" value="refused" variant="quiet">
112 Record refusal
113 </Button>
114 </div>
115 <details>
116 <summary className="cursor-pointer text-sm">Completion evidence</summary>
117 <p className="my-3 text-sm text-muted">
118 Record the actual outcome for each area, including evidence references, retained records and their reason and review date. Never paste
119 credentials or unnecessary personal content. The response must already have been sent.
120 </p>
121 {evidenceFields.map((key) => (
122 <label key={key} className="my-3 block text-sm">
123 {key.replaceAll("_", " ")}
124 <Textarea name={key} rows={2} maxLength={2000} />
125 </label>
126 ))}
127 <Button name="status" value="completed">
128 Record completion
129 </Button>
130 </details>
131 </Form>
132 )}
133 </section>
134 ))}
135 <section className="space-y-3">
136 <h2 className="font-medium">Pending cleanup</h2>
137 <p className="text-sm text-muted">
138 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
139 consumers have finished.
140 </p>
141 {loaderData.pending.length === 0 ? (
142 <p className="text-sm">No pending jobs.</p>
143 ) : (
144 <ul className="space-y-2">
145 {loaderData.pending.map((job) => (
146 <li key={`${job.subject_id}:${job.target}`} className="text-sm">
147 <code>{job.subject_id}</code> · {job.target} · {job.attempts} attempts · retry <When at={job.retry_at} time />
148 {job.last_error && ` · ${job.last_error}`}
149 </li>
150 ))}
151 </ul>
152 )}
153 </section>
154 </main>
155 );
156}