triage-actions.tsx156 lines · main
1'use client';
2
3import { useRouter } from 'next/navigation';
4import { useState, useTransition } from 'react';
5
6import { StepUpPrompt } from '@/components/step-up-prompt';
7
8const RESOLUTIONS = ['no_action', 'warned', 'suspended', 'banned'] as const;
9type Resolution = (typeof RESOLUTIONS)[number];
10
11interface Props {
12 reportId: string;
13 currentStatus: 'open' | 'triaged' | 'resolved';
14 apiOrigin: string;
15}
16
17type Pending =
18 | { kind: 'triage' }
19 | { kind: 'resolve'; resolution: Resolution; projectId: string | undefined };
20
21export function TriageActions({ reportId, currentStatus, apiOrigin }: Props) {
22 const router = useRouter();
23 const [busy, setBusy] = useState(false);
24 const [error, setError] = useState<string | null>(null);
25 const [resolution, setResolution] = useState<Resolution>('no_action');
26 const [projectId, setProjectId] = useState('');
27 const [pending, setPending] = useState<Pending | null>(null);
28 const [, startTransition] = useTransition();
29
30 async function run(action: Pending) {
31 setBusy(true);
32 setError(null);
33 try {
34 const body =
35 action.kind === 'triage'
36 ? { action: 'triage' }
37 : {
38 action: 'resolve',
39 resolution: action.resolution,
40 projectId: action.projectId,
41 };
42 const res = await fetch(`${apiOrigin}/v1/admin/abuse-reports/${reportId}`, {
43 method: 'PATCH',
44 credentials: 'include',
45 headers: { 'content-type': 'application/json' },
46 body: JSON.stringify(body),
47 });
48 if (res.status === 403) {
49 const j = (await res.json().catch(() => null)) as { code?: string } | null;
50 if (j?.code === 'step_up_required') {
51 setPending(action);
52 return;
53 }
54 }
55 if (!res.ok) {
56 const text = await res.text().catch(() => '');
57 throw new Error(text || `${action.kind} failed: ${res.status}`);
58 }
59 startTransition(() => router.refresh());
60 } catch (err) {
61 setError(err instanceof Error ? err.message : `${action.kind} failed`);
62 } finally {
63 setBusy(false);
64 }
65 }
66
67 function triage() {
68 void run({ kind: 'triage' });
69 }
70 function resolve() {
71 const trimmed = projectId.trim();
72 const willSuspend = resolution === 'suspended' || resolution === 'banned';
73 void run({
74 kind: 'resolve',
75 resolution,
76 projectId: willSuspend && trimmed ? trimmed : undefined,
77 });
78 }
79
80 const showProjectInput = resolution === 'suspended' || resolution === 'banned';
81
82 if (currentStatus === 'resolved') {
83 return <span className="font-mono text-xs text-[var(--color-text-subtle)]">resolved</span>;
84 }
85
86 return (
87 <div className="flex flex-col items-end gap-2">
88 <div className="flex items-center gap-2">
89 {currentStatus === 'open' ? (
90 <button
91 type="button"
92 onClick={triage}
93 disabled={busy}
94 className="rounded-md border border-[var(--color-border)] px-3 py-1 font-mono text-xs hover:bg-[var(--color-surface)] disabled:opacity-60"
95 >
96 {busy && pending?.kind === 'triage' ? '…' : 'triage'}
97 </button>
98 ) : null}
99 <select
100 value={resolution}
101 onChange={(e) => setResolution(e.target.value as Resolution)}
102 disabled={busy}
103 className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2 py-1 font-mono text-xs"
104 >
105 {RESOLUTIONS.map((r) => (
106 <option key={r} value={r}>
107 {r}
108 </option>
109 ))}
110 </select>
111 <button
112 type="button"
113 onClick={resolve}
114 disabled={busy}
115 className="rounded-md bg-[var(--color-primary)] px-3 py-1 font-mono text-xs text-[var(--color-text-inverse)] hover:bg-[var(--color-primary-hover)] disabled:opacity-60"
116 >
117 {busy && pending?.kind === 'resolve' ? '…' : 'resolve'}
118 </button>
119 </div>
120 {showProjectInput ? (
121 <div className="flex flex-col items-end gap-1">
122 <input
123 type="text"
124 value={projectId}
125 onChange={(e) => setProjectId(e.currentTarget.value)}
126 placeholder="p_… (auto-suspend target)"
127 disabled={busy}
128 className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 font-mono text-xs disabled:opacity-60"
129 />
130 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">
131 optional — when set, the project is suspended in the same step
132 </span>
133 </div>
134 ) : null}
135 {error ? (
136 <span className="font-mono text-xs text-[var(--color-text-error)]">{error}</span>
137 ) : null}
138 {pending ? (
139 <StepUpPrompt
140 apiOrigin={apiOrigin}
141 reason={
142 pending.kind === 'triage'
143 ? 'moving a report to triage requires fresh step-up auth.'
144 : 'resolving a report (and any auto-suspension it triggers) requires fresh step-up auth.'
145 }
146 onSuccess={async () => {
147 const action = pending;
148 setPending(null);
149 await run(action);
150 }}
151 onCancel={() => setPending(null)}
152 />
153 ) : null}
154 </div>
155 );
156}