page.tsx148 lines · main
1import Link from 'next/link';
2
3import { ShieldCheckIcon } from '@/components/ui/shield-check';
4
5import { apiJson } from '@/lib/api';
6import { toValidDate } from '@/lib/utils';
7
8import { EmptyState } from '../_components/empty-state';
9import { Section } from '../_components/section';
10import { TriageActions } from './triage-actions';
11
12type Resolution = 'no_action' | 'warned' | 'suspended' | 'banned';
13
14interface AbuseReport {
15 reportId: string;
16 targetUrl: string;
17 reason: string;
18 severity: string;
19 status: 'open' | 'triaged' | 'resolved';
20 reporterContact: string | null;
21 createdAt: string;
22 lastActionAt: string;
23 resolution: Resolution | null;
24}
25
26const STATUS_FILTERS = [
27 { label: 'open', value: 'open' },
28 { label: 'triaged', value: 'triaged' },
29 { label: 'resolved', value: 'resolved' },
30 { label: 'all', value: '' },
31] as const;
32
33export const dynamic = 'force-dynamic';
34
35function publicApiOrigin(): string {
36 return process.env.NEXT_PUBLIC_BRIVEN_API_ORIGIN ?? '';
37}
38
39export default async function AbuseReportsAdminPage({
40 searchParams,
41}: {
42 searchParams: Promise<{ status?: string }>;
43}) {
44 const { status } = await searchParams;
45 const filter = status && status !== '' ? `?status=${encodeURIComponent(status)}` : '';
46 const { reports } = await apiJson<{ reports: AbuseReport[] }>(
47 `/v1/admin/abuse-reports${filter}`,
48 ).catch(() => ({ reports: [] as AbuseReport[] }));
49 const apiOrigin = publicApiOrigin();
50
51 return (
52 <div className="flex flex-col gap-10">
53 <header className="flex flex-col gap-2">
54 <div className="flex items-center gap-2">
55 <span className="text-[var(--color-primary)]">
56 <ShieldCheckIcon size={20} />
57 </span>
58 <h1 className="font-mono text-xl tracking-tight">abuse reports</h1>
59 </div>
60 <p className="max-w-prose font-mono text-sm text-[var(--color-text-muted)]">
61 reports filed against hosted content. triage what&apos;s new, then resolve with an
62 outcome — optionally suspending the offending project in the same step. mutations
63 require fresh step-up auth.
64 </p>
65 </header>
66
67 <Section
68 title={`${reports.length} report${reports.length === 1 ? '' : 's'} in view`}
69 icon={<ShieldCheckIcon size={16} />}
70 right={
71 <div className="flex gap-2 font-mono text-xs">
72 {STATUS_FILTERS.map((f) => {
73 const active = (status ?? 'open') === f.value || (!status && f.value === 'open');
74 return (
75 <Link
76 key={f.value || 'all'}
77 href={f.value ? `?status=${f.value}` : `?status=`}
78 className={`rounded-full border px-3 py-1 transition-colors ${
79 active
80 ? 'border-[var(--color-primary)] bg-[var(--color-primary-subtle)] text-[var(--color-primary)]'
81 : 'border-[var(--color-border-subtle)] text-[var(--color-text-muted)] hover:border-[var(--color-border-strong)] hover:text-[var(--color-text)]'
82 }`}
83 >
84 {f.label}
85 </Link>
86 );
87 })}
88 </div>
89 }
90 >
91 {reports.length === 0 ? (
92 <EmptyState
93 icon={<ShieldCheckIcon size={28} />}
94 title="the queue is clear"
95 message="no reports in this view."
96 />
97 ) : (
98 <ul className="flex flex-col gap-6">
99 {reports.map((r) => (
100 <li
101 key={r.reportId}
102 className="flex flex-col gap-4 rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6 sm:flex-row sm:items-start sm:justify-between"
103 >
104 <div className="min-w-0 flex-1">
105 <div className="flex flex-wrap items-center gap-2">
106 <span
107 className={`rounded-full px-2 py-0.5 font-mono text-[10px] uppercase tracking-wider ${
108 r.severity === 'csam' || r.severity === 'malware'
109 ? 'bg-[var(--color-text-error)] text-[var(--color-text-inverse)]'
110 : 'border border-[var(--color-border-subtle)] text-[var(--color-text-muted)]'
111 }`}
112 >
113 {r.severity}
114 </span>
115 <span className="font-mono text-xs text-[var(--color-text-subtle)]">
116 {r.reportId}
117 </span>
118 {r.resolution ? (
119 <span className="font-mono text-xs text-[var(--color-text-muted)]">
120 → {r.resolution}
121 </span>
122 ) : null}
123 </div>
124 <p className="mt-3 truncate font-mono text-sm text-[var(--color-text)]">
125 {r.targetUrl}
126 </p>
127 <p className="mt-1.5 font-mono text-xs text-[var(--color-text-muted)]">
128 {r.reason}
129 </p>
130 <p className="mt-1.5 font-mono text-[11px] text-[var(--color-text-subtle)]">
131 reported{' '}
132 {toValidDate(r.createdAt)?.toISOString().slice(0, 16).replace('T', ' ') ?? '—'}
133 {r.reporterContact ? ` · contact: ${r.reporterContact}` : ' · anonymous'}
134 </p>
135 </div>
136 <TriageActions
137 reportId={r.reportId}
138 currentStatus={r.status}
139 apiOrigin={apiOrigin}
140 />
141 </li>
142 ))}
143 </ul>
144 )}
145 </Section>
146 </div>
147 );
148}