diff --git a/desktop/src-tauri/src/commands/admin/mod.rs b/desktop/src-tauri/src/commands/admin/mod.rs index 9e854e449a..90b6e8749a 100644 --- a/desktop/src-tauri/src/commands/admin/mod.rs +++ b/desktop/src-tauri/src/commands/admin/mod.rs @@ -488,6 +488,33 @@ pub async fn admin_resolve_report( serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}")) } +/// Reopen a resolved report — POST /api/admin/v1/reports/{id}/reopen. +/// +/// Body: `{request_id, reason?}`. The `request_id` is a client-generated UUID +/// for idempotency; the caller must generate once per reopen attempt and reuse +/// on retry. Reopen is re-triage only — it moves a `resolved`/`dismissed`/ +/// `escalated` report back to `open` and does not reverse any enforcement +/// action (bans, deletions) taken while it was resolved. +#[tauri::command] +pub async fn admin_reopen_report( + origin: String, + id: String, + body: serde_json::Value, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let origin = origin::AdminOrigin::parse(&origin)?; + let id = + uuid::Uuid::parse_str(&id).map_err(|_| "report id must be a valid UUID".to_string())?; + let url = origin.route_url( + &routes::AdminRoute::ReportReopen { id }, + &routes::AdminQuery::default(), + ); + let body_bytes = + serde_json::to_vec(&body).map_err(|e| format!("failed to serialise request body: {e}"))?; + let bytes = post_admin_json(&url, &body_bytes, SUCCESS_JSON_CAP, &state).await?; + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}")) +} + /// Update feedback status — PATCH /api/admin/v1/feedback/{id}. /// /// Body: `{status}` where status ∈ {"new","reviewed","archived"}. diff --git a/desktop/src-tauri/src/commands/admin/routes.rs b/desktop/src-tauri/src/commands/admin/routes.rs index 40fcd1a414..41f49d5c67 100644 --- a/desktop/src-tauri/src/commands/admin/routes.rs +++ b/desktop/src-tauri/src/commands/admin/routes.rs @@ -50,6 +50,9 @@ pub enum AdminRoute { ReportResolve { id: uuid::Uuid, }, + ReportReopen { + id: uuid::Uuid, + }, FeedbackList, FeedbackDetail { id: uuid::Uuid, @@ -101,6 +104,7 @@ impl AdminRoute { AdminRoute::ReportsList => "/reports".to_string(), AdminRoute::ReportDetail { id } => format!("/reports/{id}"), AdminRoute::ReportResolve { id } => format!("/reports/{id}/resolve"), + AdminRoute::ReportReopen { id } => format!("/reports/{id}/reopen"), AdminRoute::FeedbackList => "/feedback".to_string(), AdminRoute::FeedbackDetail { id } => format!("/feedback/{id}"), AdminRoute::FeedbackAttachment { id, sha256 } => { @@ -246,6 +250,15 @@ mod tests { ); } + #[test] + fn report_reopen_path() { + let id = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap(); + assert_eq!( + AdminRoute::ReportReopen { id }.path(), + "/reports/00000000-0000-0000-0000-000000000003/reopen" + ); + } + #[test] fn feedback_attachment_path() { let id = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap(); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index bf36658bce..6ee71eacfa 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -841,6 +841,7 @@ pub fn run() { admin_get_feedback, admin_fetch_feedback_attachment, admin_resolve_report, + admin_reopen_report, admin_patch_feedback, admin_list_operators, admin_put_operator, diff --git a/desktop/src/features/admin-console/AdminConsoleFeedbackTab.tsx b/desktop/src/features/admin-console/AdminConsoleFeedbackTab.tsx index 9e534b4795..2ff1292dbd 100644 --- a/desktop/src/features/admin-console/AdminConsoleFeedbackTab.tsx +++ b/desktop/src/features/admin-console/AdminConsoleFeedbackTab.tsx @@ -8,6 +8,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { AlertCircle, ChevronLeft, Download, LoaderCircle } from "lucide-react"; +import { toast } from "sonner"; import { Button } from "@/shared/ui/button"; import { Badge } from "@/shared/ui/badge"; import { cn } from "@/shared/lib/cn"; @@ -335,6 +336,7 @@ function FeedbackStatusControl({ setIsWorking(true); try { await patchAdminFeedback(origin, feedbackId, newStatus); + toast.success(`Feedback marked ${newStatus}`); onStatusChanged(newStatus); } catch (e) { setError(e instanceof Error ? e.message : String(e)); diff --git a/desktop/src/features/admin-console/AdminConsolePanel.tsx b/desktop/src/features/admin-console/AdminConsolePanel.tsx index 194cd5cb0b..6695852dab 100644 --- a/desktop/src/features/admin-console/AdminConsolePanel.tsx +++ b/desktop/src/features/admin-console/AdminConsolePanel.tsx @@ -31,12 +31,14 @@ import { ShieldAlert, Users, } from "lucide-react"; +import { toast } from "sonner"; import { Button } from "@/shared/ui/button"; import { Badge } from "@/shared/ui/badge"; import { cn } from "@/shared/lib/cn"; import { getAdminReport, listAdminReports, + reopenAdminReport, resolveAdminReport, type AdminPrincipalRole, type AdminPrincipalSource, @@ -194,6 +196,7 @@ function EnforcementStateBlock({ expirationSecs: activeAction.expirationSecs ?? undefined, reason: activeAction.reason ?? undefined, }); + toast.success("Enforcement retried"); onActionComplete(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); @@ -213,6 +216,7 @@ function EnforcementStateBlock({ requestId: crypto.randomUUID(), reason: "cancelled", }); + toast.success("Enforcement cancelled"); onActionComplete(); } catch (e) { // A rejected cancel is authoritative — the enforcement may have landed. @@ -325,6 +329,7 @@ function ResolveReportForm({ : undefined, reason: reason.trim() || undefined, }); + toast.success(`Report resolved: ${actionLabel(selectedAction)}`); onResolved(); } catch (e) { // On error, reset requestId so the next submit generates a new one. @@ -424,6 +429,114 @@ function ResolveReportForm({ ); } +// ── Reopen report form ──────────────────────────────────────────────────── + +/** + * Reopen form — shown on terminal reports (`resolved` | `dismissed` | + * `escalated`). Moves the report back to `open` for re-triage. + * + * Reopen is re-triage only: it does NOT reverse any enforcement already taken + * (no un-ban, no un-timeout, no message restore). The copy states this + * explicitly, and more emphatically when the report carries an `actionId` + * (an enforcement action was applied while it was resolved). + * + * A `requestId` is generated per attempt and reused on retry so a lost + * response is idempotent, mirroring the resolve flow. A 409 (report not + * reopenable — e.g. it moved to `processing`) preserves the `requestId`. + */ +function ReopenReportForm({ + report, + origin, + onReopened, +}: { + report: AdminReportDto; + origin: string; + onReopened: () => void; +}) { + const [reason, setReason] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + // Stable requestId per attempt; reused on retry after a lost response. + const requestIdRef = useRef(null); + + // An enforcement action was applied while this report was resolved. + const wasEnforced = report.actionId != null; + + const handleSubmit = async () => { + setError(null); + setIsSubmitting(true); + + if (!requestIdRef.current) { + requestIdRef.current = crypto.randomUUID(); + } + + try { + await reopenAdminReport(origin, report.id, { + requestId: requestIdRef.current, + reason: reason.trim() || undefined, + }); + toast.success("Report reopened"); + onReopened(); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + // A 409 means the report is not reopenable (e.g. it moved to + // `processing`). Preserve the requestId so a genuine retry reuses it; + // reset otherwise so the next attempt generates a fresh one. + if (!msg.includes("409") && !msg.includes("processing")) { + requestIdRef.current = null; + } + setError(msg); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+

+ Reopen report +

+

+ Moves this report back to the open queue for re-triage.{" "} + {wasEnforced + ? "The enforcement action already taken is not reversed — reopening does not un-ban, un-timeout, or restore a deleted message." + : "Reopening does not reverse any enforcement action."} +

+
+ + setReason(e.target.value)} + placeholder="Reason (optional)" + type="text" + value={reason} + /> + + + + {error &&

{error}

} +
+ ); +} + // ── Reports tab ─────────────────────────────────────────────────────────── function ReportsTab({ @@ -575,6 +688,10 @@ function ReportDetail({ const data = detailState.status === "ok" ? detailState.data : null; const isProcessing = data?.status === "processing"; const isOpen = data?.status === "open"; + const isReopenable = + data?.status === "resolved" || + data?.status === "dismissed" || + data?.status === "escalated"; const activeAction = data?.activeAction ?? null; return ( @@ -615,6 +732,14 @@ function ReportDetail({ onResolved={() => setResolveGen((g) => g + 1)} /> )} + {/* Reopen form: only for terminal (resolved/dismissed/escalated) reports */} + {isReopenable && ( + setResolveGen((g) => g + 1)} + /> + )} )} diff --git a/desktop/src/features/admin-console/adminConsolePanelEvents.jsdom-test.mjs b/desktop/src/features/admin-console/adminConsolePanelEvents.jsdom-test.mjs index 1c2e6d445e..749e9ee2fe 100644 --- a/desktop/src/features/admin-console/adminConsolePanelEvents.jsdom-test.mjs +++ b/desktop/src/features/admin-console/adminConsolePanelEvents.jsdom-test.mjs @@ -75,10 +75,24 @@ import { createRoot } from "react-dom/client"; import { act } from "react"; import { fireEvent } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { toast } from "sonner"; import { AdminConsoleSettingsCard } from "./AdminConsoleSettingsCard.tsx"; import { AdminConsolePanel } from "./AdminConsolePanel.tsx"; +// ── Success-toast capture ──────────────────────────────────────────────────── +// +// sonner's `toast` is a shared singleton object across import paths (verified), +// so replacing `toast.success` here is observed by the production components. +// Captured messages are asserted by the toast tests and cleared in afterEach. + +/** @type {string[]} */ +const capturedToasts = []; +toast.success = (msg) => { + capturedToasts.push(String(msg)); + return 0; +}; + // ── Deferred promise helper ────────────────────────────────────────────────── function deferred() { @@ -159,6 +173,7 @@ async function settle(ms = 20) { afterEach(() => { clearIpcHandlers(); + capturedToasts.length = 0; }); // ── origin-edit ────────────────────────────────────────────────────────────── @@ -1822,3 +1837,338 @@ test("feedback-grouped-by-community: multi-community feedback renders per-commun await unmount(); }); + +// ── reopen ──────────────────────────────────────────────────────────────── + +/** + * Mount the panel, wait for the list, then click the first non-tab report row + * to open its detail. Returns after the detail has settled. + */ +async function openFirstReportDetail(container) { + const allButtons = container.querySelectorAll("button"); + for (const btn of allButtons) { + const testid = btn.getAttribute("data-testid") ?? ""; + if (testid.startsWith("admin-tab")) continue; + await act(async () => { + fireEvent.click(btn); + await new Promise((r) => setTimeout(r, 30)); + }); + return; + } + throw new Error("no navigable report row found"); +} + +test("reopen-form-gated-by-status: resolved report shows the reopen form, open report does not", async () => { + // The reopen form must render only for terminal reports + // (resolved | dismissed | escalated) and never for an open report — an open + // report shows the resolve form instead. + // + // Mutation evidence: drop the `isReopenable` gate → the form renders for + // open reports too and the second assertion goes red. + + const origin = "https://admin.example.com"; + const pubkey = "c1".repeat(32); + + const resolvedItem = { + id: "00000000-0000-0000-0000-0000000000c1", + communityId: "comm-1", + communityHost: "alpha.example.com", + reportEventId: "aa", + reporterPubkey: "bb", + targetKind: "event", + target: "cc", + reportType: "spam", + status: "resolved", + createdAt: "2024-06-01T12:00:00Z", + }; + const resolvedDetail = { + ...resolvedItem, + channelId: null, + note: null, + resolvedBy: "mod_pubkey", + resolvedAt: "2024-06-02T08:00:00Z", + actionId: null, + message: null, + }; + + setIpcHandler("admin_list_reports", () => Promise.resolve([resolvedItem])); + setIpcHandler("admin_get_report", () => Promise.resolve(resolvedDetail)); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + await openFirstReportDetail(container); + await settle(20); + + assert.ok( + container.querySelector("[data-testid='reopen-report-form']"), + "reopen form must render for a resolved report", + ); + assert.equal( + container.querySelector("[data-testid='resolve-report-form']"), + null, + "resolve form must NOT render for a resolved report", + ); + + await unmount(); +}); + +test("reopen-submit: calls admin_reopen_report with requestId+reason, toasts, and refreshes", async () => { + // The reopen submit must POST {requestId, reason} to admin_reopen_report, + // fire a success toast, and bump the resolve generation so the detail + // reloads (verified here by a second admin_get_report call returning the + // now-open report, which flips the UI to the resolve form). + // + // Mutation evidence: remove `onReopened()` → no reload, detail stays + // resolved, and the resolve-form assertion goes red. Remove the toast → + // capturedToasts assertion goes red. + + const origin = "https://admin.example.com"; + const pubkey = "c2".repeat(32); + + const base = { + id: "00000000-0000-0000-0000-0000000000c2", + communityId: "comm-1", + communityHost: "alpha.example.com", + reportEventId: "aa", + reporterPubkey: "bb", + targetKind: "event", + target: "cc", + reportType: "spam", + createdAt: "2024-06-01T12:00:00Z", + }; + const dismissedItem = { ...base, status: "dismissed" }; + const dismissedDetail = { + ...dismissedItem, + channelId: null, + note: null, + resolvedBy: "mod_pubkey", + resolvedAt: "2024-06-02T08:00:00Z", + actionId: null, + message: null, + }; + const openDetail = { + ...base, + status: "open", + channelId: null, + note: null, + resolvedBy: null, + resolvedAt: null, + actionId: null, + message: null, + }; + + setIpcHandler("admin_list_reports", () => Promise.resolve([dismissedItem])); + // First detail load: dismissed. After reopen, the generation bump reloads + // and the report is now open. + let detailCalls = 0; + setIpcHandler("admin_get_report", () => { + detailCalls += 1; + return Promise.resolve(detailCalls === 1 ? dismissedDetail : openDetail); + }); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + let reopenArgs = null; + setIpcHandler("admin_reopen_report", (args) => { + reopenArgs = args; + return Promise.resolve({ status: "open" }); + }); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + await openFirstReportDetail(container); + await settle(20); + + // Type a reason. + const reasonInput = container.querySelector( + "[data-testid='reopen-reason-input']", + ); + assert.ok(reasonInput, "reopen reason input must be present"); + await act(async () => { + fireEvent.change(reasonInput, { target: { value: "new evidence" } }); + await new Promise((r) => setTimeout(r, 5)); + }); + + // Submit. + const submit = container.querySelector("[data-testid='reopen-submit-btn']"); + assert.ok(submit, "reopen submit button must be present"); + await act(async () => { + fireEvent.click(submit); + await new Promise((r) => setTimeout(r, 30)); + }); + await settle(20); + + assert.ok(reopenArgs, "admin_reopen_report must be invoked"); + assert.equal(reopenArgs.origin, origin, "origin must be forwarded"); + assert.equal(reopenArgs.id, base.id, "report id must be forwarded"); + assert.equal( + reopenArgs.body?.reason, + "new evidence", + "reason must be forwarded in the body", + ); + assert.ok( + typeof reopenArgs.body?.requestId === "string" && + reopenArgs.body.requestId.length > 0, + `requestId must be a non-empty string; got: ${JSON.stringify(reopenArgs.body?.requestId)}`, + ); + + assert.ok( + capturedToasts.some((m) => m.toLowerCase().includes("reopen")), + `a reopen success toast must fire; got: ${JSON.stringify(capturedToasts)}`, + ); + + // Refresh: detail reloaded (call 2) and the report is now open → resolve form. + assert.ok( + detailCalls >= 2, + "detail must reload after reopen (generation bump)", + ); + assert.ok( + container.querySelector("[data-testid='resolve-report-form']"), + "after reopen, the now-open report must show the resolve form", + ); + + await unmount(); +}); + +test("reopen-enforced-copy: a report with an actionId warns enforcement is not reversed", async () => { + // Reopen is re-triage only. When the report carries an actionId (enforcement + // was applied), the copy must say the enforcement is not reversed. + // + // Mutation evidence: collapse the `wasEnforced` branch to the generic copy → + // the "not reversed" wording for un-ban/un-timeout/restore disappears. + + const origin = "https://admin.example.com"; + const pubkey = "c3".repeat(32); + + const escalatedItem = { + id: "00000000-0000-0000-0000-0000000000c3", + communityId: "comm-1", + communityHost: "alpha.example.com", + reportEventId: "aa", + reporterPubkey: "bb", + targetKind: "pubkey", + target: "cc", + reportType: "abuse", + status: "escalated", + createdAt: "2024-06-01T12:00:00Z", + }; + const escalatedDetail = { + ...escalatedItem, + channelId: null, + note: null, + resolvedBy: "mod_pubkey", + resolvedAt: "2024-06-02T08:00:00Z", + actionId: "00000000-0000-0000-0000-0000000000ff", + message: null, + }; + + setIpcHandler("admin_list_reports", () => Promise.resolve([escalatedItem])); + setIpcHandler("admin_get_report", () => Promise.resolve(escalatedDetail)); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + await openFirstReportDetail(container); + await settle(20); + + const form = container.querySelector("[data-testid='reopen-report-form']"); + assert.ok(form, "reopen form must render for an escalated report"); + const text = form.textContent ?? ""; + assert.ok( + text.toLowerCase().includes("not reversed"), + `enforced-report copy must state the action is not reversed; got: ${text}`, + ); + + await unmount(); +}); + +test("reopen-409-preserves-requestId: a not-reopenable conflict reuses the same requestId on retry", async () => { + // A 409 (report is not reopenable — e.g. it moved to processing) is an + // idempotency-relevant failure: the same requestId must be reused on retry + // so the relay can dedupe. A non-409 failure resets it. + // + // Mutation evidence: drop the 409 branch in the catch → requestId resets and + // the two attempts carry different ids, going red. + + const origin = "https://admin.example.com"; + const pubkey = "c4".repeat(32); + + const resolvedItem = { + id: "00000000-0000-0000-0000-0000000000c4", + communityId: "comm-1", + communityHost: "alpha.example.com", + reportEventId: "aa", + reporterPubkey: "bb", + targetKind: "event", + target: "cc", + reportType: "spam", + status: "resolved", + createdAt: "2024-06-01T12:00:00Z", + }; + const resolvedDetail = { + ...resolvedItem, + channelId: null, + note: null, + resolvedBy: "mod_pubkey", + resolvedAt: "2024-06-02T08:00:00Z", + actionId: null, + message: null, + }; + + setIpcHandler("admin_list_reports", () => Promise.resolve([resolvedItem])); + setIpcHandler("admin_get_report", () => Promise.resolve(resolvedDetail)); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const requestIds = []; + setIpcHandler("admin_reopen_report", (args) => { + requestIds.push(args?.body?.requestId); + return Promise.reject( + new Error("409 report is not reopenable (current status: processing)"), + ); + }); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + await openFirstReportDetail(container); + await settle(20); + + const submit = container.querySelector("[data-testid='reopen-submit-btn']"); + assert.ok(submit, "reopen submit button must be present"); + + // First attempt → 409. + await act(async () => { + fireEvent.click(submit); + await new Promise((r) => setTimeout(r, 20)); + }); + // Second attempt → 409 again; requestId must be identical. + await act(async () => { + fireEvent.click(submit); + await new Promise((r) => setTimeout(r, 20)); + }); + + assert.equal(requestIds.length, 2, "two reopen attempts must have been made"); + assert.equal( + requestIds[0], + requestIds[1], + `requestId must be preserved across a 409 retry; got: ${JSON.stringify(requestIds)}`, + ); + + // No success toast on a 409. + assert.deepEqual( + capturedToasts, + [], + `no success toast on a 409; got: ${JSON.stringify(capturedToasts)}`, + ); + // The error is surfaced. + const text = container.textContent ?? ""; + assert.ok( + text.includes("not reopenable"), + `the 409 error message must surface; got: ${text.slice(0, 400)}`, + ); + + await unmount(); +}); diff --git a/desktop/src/features/admin-console/api.ts b/desktop/src/features/admin-console/api.ts index 7db4e7bc89..550fc419e4 100644 --- a/desktop/src/features/admin-console/api.ts +++ b/desktop/src/features/admin-console/api.ts @@ -305,6 +305,46 @@ export async function resolveAdminReport( }); } +/** + * Body for POST /api/admin/v1/reports/{id}/reopen. + * + * `requestId` is a client-generated UUID; generate once per reopen attempt and + * reuse the **same** UUID on retry after a lost response, mirroring resolve + * idempotency. + */ +export type AdminReopenReportBody = { + requestId: string; + reason?: string; +}; + +/** The status returned by a successful reopen — always `"open"`. */ +export type AdminReopenReportResult = { + status: string; +}; + +/** + * Reopen a resolved report — POST /api/admin/v1/reports/{id}/reopen. + * + * Moves a `resolved` | `dismissed` | `escalated` report back to `open` for + * re-triage. A `processing` report is not reopenable and yields 409. Reopen + * does **not** reverse enforcement (no un-ban, no un-delete) — it only + * re-queues the report. + * + * The caller must generate a UUID `requestId` per reopen attempt and reuse the + * **same** UUID on retry after a lost response. + */ +export async function reopenAdminReport( + origin: string, + id: string, + body: AdminReopenReportBody, +): Promise { + return invokeTauri("admin_reopen_report", { + origin, + id, + body, + }); +} + // ── Feedback status ─────────────────────────────────────────────────────── export type AdminFeedbackStatus = "new" | "reviewed" | "archived";