From 8f8c4dfadecb2298a9140af5d323487aaad844ab Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 24 Sep 2026 17:34:25 -0400 Subject: [PATCH] feat(desktop): relay admin console for the /api/admin/v1 operator surface (#4768) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a relay admin console under Settings — the single surface for relay-level operator work — replacing the previously unreachable moderation section. Operators authenticate with their own Nostr key over NIP-98 — no browser extension, no bearer token — to view deployment-wide moderation reports and product feedback, resolve/dismiss/escalate/reopen reports, update feedback status, and manage the operator/moderator staffing roster. ## Nav reachability The old `moderation` section id was defined in `settingsSections` but never wired into any group in `settingsNavGroups`, so the sidebar never rendered it — unreachable since #1617. This PR replaces it with a `relay-admin` section wired into the Communities nav group, pointing at the relay admin console, and drops the separate `admin-console` section id so a single surface owns relay-level trust & safety. The Relay admin nav entry is always visible. Auth gates the panel itself; the nav entry is a door, not a credential — hiding it behind discovery state would strand an operator whose relay transiently fails NIP-11 discovery with no path back to the connection settings. ## Discovery and connection On mount the console reads the connected relay's NIP-11 document for the optional `admin_api` field. When a valid origin is discovered **and its host matches the connected relay's host** (case-insensitive, host identity only), it is automatically saved and probed — the panel renders immediately with no Save step required. A cross-host advertisement is pre-fill only: it populates the manual origin field under Advanced and is never saved or probed without an explicit Save, so a relay cannot direct an unconsented NIP-98 signature at a third-party origin; residual exposure on the same-host path is bounded by NIP-98's URL/method/payload binding. Saving is only required for manual origin changes. The advertised origin is untrusted input: revalidated through `AdminOrigin::parse`, falling back to manual entry when absent or invalid. A saved manual origin always takes precedence over discovery. ## Auth and transport (Rust, `src-tauri/src/commands/admin/`) - `AdminOrigin` value object: validates scheme + host + optional port, rejects credentials/path/query/fragment; `http://` only for loopback. The relay side normalizes its configured admin host at config load and compares inbound `Host`/`Origin` hosts case-insensitively. - `AdminRoute` closed enum: no IPC surface accepts arbitrary URLs, so the NIP-98-signed URL is byte-identical to the fetched URL. - Dedicated no-redirect `reqwest` client as an SSRF guard: a relay `3xx` is an error and the NIP-98 header never crosses origins. - `admin_probe` signs `GET /probe` and returns a typed state (`nip98Authorized` / `nip98Denied` / `disabled` / `notAdminApi` / `networkOrIntercepted`). The response body is validated against the full relay contract — `nip98Authorized` only when the complete NIP-98 invariant holds, `disabled` only on a coherent disabled-mode body; any incoherent or unrelated JSON classifies as `notAdminApi`, so the console never fails open at the network boundary. Unknown fields are tolerated for forward compatibility. - NIP-98 signing via `AppState::signing_keys()`, one retry on `401` with a fresh signed event; response size bounded by a `Content-Length` preflight and a streaming byte counter; per-pubkey origin storage with atomic `0o600` writes. ## Relay admin console The console is gated behind the relay `OPERATOR`/`MODERATOR` roles from #3777; every `/api/admin/v1/*` route returns `403` to anyone else. Community self-administration (`community-members` / Invites) and in-channel enforcement (the 9040–9043 signed commands) are a separate axis and are untouched. The previously-duplicate community report queue (`ModerationQueueCard`, `moderationQueue.ts`, and three dead hooks) was unreachable dead code and is deleted in its own commit; `useBanMemberMutation` and the rest of `features/moderation/hooks.ts` remain in use by the members sidebar and message menu. ## Console UI (TypeScript, `src/features/admin-console/`) - `AdminConsoleSettingsCard`: discovery + probe flow, honest copy for every probe state, manual origin under an `Advanced` disclosure at the bottom. The role (operator/moderator) is rendered in the connection status line as plain text ("Connected as operator") — no badge-shaped elements above the tab row. The origin provenance (relay config / database) appears inside the Advanced card as small muted text. Section title changed to `Admin`. - `AdminConsolePanel` with Reports / Feedback / Staffing tabs (Staffing gated on `role === "operator"`); reports and feedback grouped by community for cross-community triage. - **Reports**: always requests `scope=all` so the existing resolve/cancel/reopen controls can reach every status (`open`, `processing`, `resolved`, `dismissed`, `escalated`). The relay's omitted-scope default is `escalated`-only (the platform-safety backstop); `scope=all` is explicit and scoped to this console. Full action matrix per `target_kind` (event → delete/kick/ban/timeout/dismiss/escalate; pubkey → ban/timeout/dismiss/escalate; blob → dismiss/escalate); kick is suppressed when the report carries no `channelId`. A `processing` row stays navigable — enforcement state lives in the detail view. A `failed` action (always pre-mutation) offers a single **Cancel & reopen** via `POST /reports/{id}/cancel` with `{actionId}` fencing; there is no client-side retry. `pending`/`enforcing` actions belong to the relay recovery worker and offer no button; a `409` reloads detail. Lists refetch on back-navigation after a mutation. - **Report failure UX**: `reportErrorMessage()` strips 4xx/5xx status prefixes and surfaces the relay's own error reason in the toast (e.g. relay returns `"400: you cannot report this content"` → toast shows `"you cannot report this content"`). Self-report blocking UI removed — the relay has no self-report gate, and the failure was purely client-side error surfacing. - **Reason audience disclosure**: the Reason (optional) field shows exact copy under the input based on selected action — Delete discloses that reason is sent verbatim to the affected user and posted publicly in the room; Kick/Ban/Timeout disclose affected-user only; Dismiss/Escalate disclose reporter only. These mirror the actual relay notice paths. - **Enforcement history**: the report detail carries the governing action even after the report leaves `processing` — a report enforced then reopened is `open` yet still shows its `succeeded` action as executed history alongside the resolve form; a reopen does not un-happen a ban. The resolve form gates on `open` status alone. - **Reopen**: terminal reports (`resolved`/`dismissed`/`escalated`) can be returned to `open` for re-triage via `POST /reports/{id}/reopen`. Reopen never reverses applied enforcement, and the copy says so. - **Feedback**: list, detail, and status control (`new`/`reviewed`/`archived`) with a generation-fenced attachment viewer. A purged source community severs provenance to `null` rather than deleting the row; severed rows bucket under a "source community removed" heading. - **Staffing**: Add is create-only with display names resolved via `useUsersBatchQuery` and npub cross-fade on hover (`HoverStaffingIdentity`). An entered key that already appears in the authoritative roster (config, owner-fallback, or DB) is rejected locally with an inline duplicate-key message and no request is issued; a `409 Conflict` from add, role change, or remove surfaces the relay's parsed error body via `adminErrorMessage(e)` — distinguishing config-backed key conflicts from last-operator conflicts without HTTP-status inference. Roster with `config`/`owner_fallback`/`db` source labels; config-backed entries disable remove client-side. In-place role change via ` setExpirationSecs(e.target.value)} + placeholder="e.g. 3600" + type="number" + value={expirationSecs} + /> + + )} + +
+ setReason(e.target.value)} + placeholder="Reason (optional)" + type="text" + value={reason} + /> + {selectedAction && ( +

+ {reasonAudienceCopy(selectedAction)} +

+ )} +
+ + {selectedAction && ( + + )} + + ); +} + +// ── 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); + // 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 () => { + 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) { + // Preserve the requestId on an ambiguous outcome (409, 5xx, lost + // response, or a transport failure with no relay answer) so a retry + // reuses the same idempotency key; reset only on a definitive pre-commit + // rejection (a non-409 4xx). + if (!preserveRequestIdOnError(e)) { + requestIdRef.current = null; + } + toast.error(adminErrorMessage(e)); + } 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} + /> + + +
+ ); +} + +// ── Reports tab ─────────────────────────────────────────────────────────── + +export function ReportsTab({ + canMutate, + origin, + pubkey, + generation, +}: { + canMutate: boolean; + origin: string; + pubkey: string; + generation: number; +}) { + const [selectedId, setSelectedId] = useState(null); + // List refresh fence: bumped whenever a mutation completes in the detail + // view, so returning to the list shows fresh status without a tab switch. + const [listGen, setListGen] = useState(0); + + const listState = useAsyncLoad( + // Request the full workflow queue: open, processing, resolved, dismissed, + // escalated. The relay's omitted-scope default is escalated-only (the + // platform-safety backstop); scope=all gives this console access to the + // states its own resolve/cancel/reopen controls act on. + () => listAdminReports(origin, { scope: "all" }), + [origin, pubkey], + generation + listGen, + ); + + // Batch-resolve display names for the people on each row (reporter plus the + // reported person) so list rows show names rather than truncated hex. Event + // IDs and blob hashes are identifiers, never profile keys. + const listedPubkeys = + listState.status === "ok" + ? [ + ...new Set( + listState.data.flatMap((r) => + [r.reporterPubkey, reportedPersonPubkey(r)].filter( + (k): k is string => typeof k === "string" && k.length === 64, + ), + ), + ), + ] + : []; + const profilesQuery = useUsersBatchQuery(listedPubkeys, { + enabled: listedPubkeys.length > 0, + }); + + if (selectedId) { + return ( + setSelectedId(null)} + onMutated={() => setListGen((g) => g + 1)} + /> + ); + } + + if (listState.status === "loading") { + return ; + } + if (listState.status === "error") { + return ; + } + if (listState.status !== "ok") return null; + + const reports = listState.data; + if (!Array.isArray(reports) || reports.length === 0) { + return

No reports found.

; + } + + return ( + { + const id = report.id; + const summary = report.reportType || "Report"; + const status = report.status; + const isProcessing = status === "processing"; + // Show a brief reporter → target snippet so rows are distinguishable. + const snippet = [ + report.reporterPubkey + ? `reporter: ${formatDisplayName(report.reporterPubkey, profilesQuery.data?.profiles[report.reporterPubkey])}` + : null, + report.target + ? `target: ${formatReportTarget(report, profilesQuery.data?.profiles)}` + : null, + ] + .filter(Boolean) + .join(" · "); + return ( +
  • + {/* Processing rows stay navigable: the enforcement state (progress, + retry, cancel) lives inside the detail view, so disabling the row + would hide exactly the controls an operator needs while an action + is pending. Detail suppresses only the resolve form for a + non-open report. */} + +
  • + ); + }} + /> + ); +} + +function ReportFields({ data }: { data: AdminReportDetailDto }) { + const status = data.status ?? ""; + // Valid 64-char hex pubkeys get the PubKey widget (compact npub + hover + // popover). Shorter/invalid strings (test fixtures, placeholder values) + // fall back to the plain mono DetailRow so existing behaviour is preserved. + const isHex64 = (s: string | undefined | null): s is string => + typeof s === "string" && /^[0-9a-f]{64}$/.test(s.toLowerCase()); + + return ( +
    +
    + {status && {status}} + {data.reportType && {data.reportType}} +
    + + + + + {/* Reporter uses PubKey widget for valid keys; falls back to mono text. */} + {isHex64(data.reporterPubkey) ? ( +
    + Reporter + +
    + ) : ( + + )} + + {/* Pubkey targets get the PubKey widget; event IDs are not keys, so they + render as raw hex with a copy control. */} + {isHex64(data.target) && data.targetKind?.toLowerCase() === "pubkey" ? ( +
    + Target + +
    + ) : data.targetKind?.toLowerCase() === "event" ? ( +
    + Target + + {data.target} + + +
    + ) : ( + + )} + + + + + + + {data.message != null && ( +
    +

    + Reported message + {data.message.deletedAt != null && ( + (deleted) + )} +

    + {isHex64(data.message.authorPubkey) ? ( +
    + + Author + + +
    + ) : ( + + )} + + +
    + )} +
    + ); +} + +function ReportDetail({ + canMutate, + origin, + pubkey, + generation, + reportId, + onBack, + onMutated, +}: { + canMutate: boolean; + origin: string; + pubkey: string; + generation: number; + reportId: string; + onBack: () => void; + /** Called after any mutation completes so the parent list can refetch. */ + onMutated: () => void; +}) { + // Resolution generation: bump to reload detail after an action completes. + const [resolveGen, setResolveGen] = useState(0); + + // Reload the detail AND signal the parent list on every completed mutation, + // so back-nav shows fresh status without the tab-switch workaround. + const handleMutated = () => { + setResolveGen((g) => g + 1); + onMutated(); + }; + + const detailState = useAsyncLoad( + () => getAdminReport(origin, reportId), + [origin, pubkey, reportId], + generation + resolveGen, + ); + + const data = detailState.status === "ok" ? detailState.data : null; + const isOpen = data?.status === "open"; + const isReopenable = + data?.status === "resolved" || + data?.status === "dismissed" || + data?.status === "escalated"; + const activeAction = data?.activeAction ?? null; + + return ( +
    + + {detailState.status === "loading" && } + {detailState.status === "error" && ( + + )} + {detailState.status === "ok" && ( + <> + + {/* Enforcement state / history. The detail LATERAL returns an action + whenever one governs the report: a live action (pending/enforcing) + or a cancellable failed action while processing, or a succeeded + action as executed-enforcement history on a terminal or reopened + report (honest history — a later dismissal/reopen does not + un-happen the ban that ran). Cancel is offered only on failed, + inside the block. A cancelled action never reaches this read. */} + {activeAction && ( + + )} + {/* Resolve form: shown for open reports. A reopened-after-enforcement + report is open yet carries a succeeded activeAction (history); + the form must still show so the operator can re-triage — the + enforcement block above renders that history alongside it. Only a + live/failed action keeps the report `processing` (not open), so + `isOpen` alone never surfaces the form on an in-flight action. */} + {isOpen && canMutate && ( + + )} + {/* Reopen form: only for terminal (resolved/dismissed/escalated) reports */} + {isReopenable && canMutate && ( + + )} + + )} +
    + ); +} diff --git a/desktop/src/features/admin-console/AdminConsoleSettingsCard.tsx b/desktop/src/features/admin-console/AdminConsoleSettingsCard.tsx new file mode 100644 index 0000000000..15a1a6b3f2 --- /dev/null +++ b/desktop/src/features/admin-console/AdminConsoleSettingsCard.tsx @@ -0,0 +1,578 @@ +/** + * Settings card for the desktop admin console. + * + * Lets an operator enter the admin console URL (the value of `BUZZ_ADMIN_HOST` + * on their relay), then probes it to determine auth mode and whether the + * current app identity is on the allowlist. + * + * Identity boundary: the stateful body is rendered as + * `` so that React + * synchronously unmounts A's entire state tree before B is rendered. Logout + * (pubkeyHex → empty string) renders nothing, so A's probe state, saved + * origin, and panel are torn down at the render level — not in a passive effect. + * + * Renders the full admin panel when probe state is `nip98Authorized` or + * `disabled`. The `disabled` state means the relay does not require or + * validate a credential on the admin API — the desktop still signs outgoing + * requests, but the relay accepts them unconditionally. The panel works the + * same way in both states. + */ + +import { useEffect, useRef, useState } from "react"; +import { + AlertCircle, + Check, + CheckCircle2, + ChevronRight, + Copy, + Info, + LoaderCircle, +} from "lucide-react"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; +import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader"; +import { cn } from "@/shared/lib/cn"; +import { copyTextToClipboard } from "@/shared/lib/clipboard"; +import { + getAdminOrigin, + probeAdminOrigin, + setAdminOrigin, + discoverAdminOrigin, + type AdminPrincipalRole, + type AdminPrincipalSource, + type AdminProbeState, +} from "./api"; +import { AdminConsolePanel } from "./AdminConsolePanel"; +import { useIdentityQuery } from "@/shared/api/hooks"; + +// ── Probe state → UI copy ───────────────────────────────────────────────── + +// ── DeniedBadge — copy-icon button for the pubkey ───────────────────────── + +function DeniedBadge({ pubkeyHex }: { pubkeyHex: string }) { + const [copied, setCopied] = useState(false); + const resetTimer = useRef(undefined); + useEffect(() => () => window.clearTimeout(resetTimer.current), []); + + return ( + + + + Access denied + + + Your pubkey is not authorized as an operator on this relay. Ask a relay + operator to add: + + + + {pubkeyHex} + + + + + Other possible causes: clock skew > 60 s or a relay config mismatch. + + + ); +} + +type ProbeUiState = + | { kind: "idle" } + | { kind: "probing" } + | { + kind: "authorized"; + origin: string; + role?: AdminPrincipalRole | null; + source?: AdminPrincipalSource | null; + } + | { kind: "denied"; pubkeyHex: string } + | { kind: "disabled"; origin: string } + | { kind: "notAdminApi" } + | { kind: "networkOrIntercepted" } + | { kind: "error"; message: string }; + +function ProbeStatusBadge({ uiState }: { uiState: ProbeUiState }) { + if (uiState.kind === "idle") return null; + if (uiState.kind === "probing") { + return ( + + + Probing… + + ); + } + if (uiState.kind === "authorized") { + const roleLabel = uiState.role ?? "operator"; + return ( + + + Connected as {roleLabel} + + ); + } + if (uiState.kind === "denied") { + return ; + } + if (uiState.kind === "disabled") { + return ( + + + Auth is disabled on this relay. The admin console is accessible without + a credential. + + ); + } + if (uiState.kind === "notAdminApi") { + return ( + + + No admin API found at this origin. Check the URL matches{" "} + BUZZ_ADMIN_HOST. + + ); + } + if (uiState.kind === "networkOrIntercepted") { + return ( + + + Could not reach the relay. Check: network, TLS certificate, DNS, or + whether a VPN/SSO layer (e.g. Cloudflare Access) intercepts this host. + + ); + } + // error + return ( + + + {uiState.message} + + ); +} + +function probeStateToUiState( + result: { + state: AdminProbeState; + role?: AdminPrincipalRole | null; + source?: AdminPrincipalSource | null; + }, + origin: string, + pubkeyHex: string, +): ProbeUiState { + switch (result.state) { + case "nip98Authorized": + return { + kind: "authorized", + origin, + role: result.role, + source: result.source, + }; + case "nip98Denied": + return { kind: "denied", pubkeyHex }; + case "disabled": + return { kind: "disabled", origin }; + case "notAdminApi": + return { kind: "notAdminApi" }; + case "networkOrIntercepted": + return { kind: "networkOrIntercepted" }; + } +} + +// ── Main card ───────────────────────────────────────────────────────────── + +export function AdminConsoleSettingsCard() { + const { data: identity } = useIdentityQuery(); + const pubkeyHex = identity?.pubkey ?? ""; + + return ( +
    + + {pubkeyHex ? ( + + ) : null} +
    + ); +} + +// ── Stateful session — keyed by pubkeyHex ───────────────────────────────── +// +// React's `key` prop causes the parent to unmount this component entirely when +// the pubkey changes. That means: +// - A→B switch: A's entire state tree (originInput, savedOrigin, probeUiState, +// isSaving, in-flight probes) is destroyed synchronously before B mounts. +// - Logout (pubkeyHex → ""): the parent renders `null`, so A's state is gone +// before any new render begins. +// +// This eliminates the passive-effect reset race where the parent rendered with +// B's pubkey and A's stale origin/authorized state for one render cycle. + +function AdminConsoleSettingsSession({ pubkeyHex }: { pubkeyHex: string }) { + const [originInput, setOriginInput] = useState(""); + const [savedOrigin, setSavedOrigin] = useState(null); + const [probeUiState, setProbeUiState] = useState({ + kind: "idle", + }); + const [isSaving, setIsSaving] = useState(false); + // Whether the Advanced (origin entry) disclosure is open. Auto-opens when a + // relay-advertised origin pre-fills the input so the operator sees the value + // awaiting their explicit Save. + const [advancedOpen, setAdvancedOpen] = useState(false); + + // In-flight probe abort controller. Does not cancel the Tauri native request + // (not cancellable), but prevents a stale probe result from updating UI state. + const probeAbortRef = useRef(null); + + // Save/probe context token: captures (pubkey, origin) at the time a save + // starts. handleSave checks this before committing any state so a delayed + // save cannot repopulate the wrong session. + // + // On unmount, the cleanup effect below sets sessionTokenRef.current = null. + // Every handleSave continuation leg checks `sessionTokenRef.current !== token` + // (null !== token object) → returns early on all paths. This is StrictMode-safe: + // StrictMode's simulated cleanup fires the null assignment, then the re-mount + // re-arms the ref when the next handleSave sets `sessionTokenRef.current = token`. + type SessionToken = { pubkey: string; origin: string }; + const sessionTokenRef = useRef(null); + + // Mirrors savedOrigin state as a ref so closures can read the current value + // without stale capture. Mutated synchronously alongside setSavedOrigin via + // the setSavedOriginBoth helper below; never set directly. + const savedOriginRef = useRef(null); + + // Use this in place of bare setSavedOrigin to keep the ref in sync. + function setSavedOriginBoth(v: string | null) { + savedOriginRef.current = v; + setSavedOrigin(v); + } + + // Synchronously abort any active probe and reset probe UI state. + // Call before starting a new probe or on any input change. + function abortAndResetProbe() { + probeAbortRef.current?.abort(); + probeAbortRef.current = null; + setProbeUiState({ kind: "idle" }); + } + + // Null both sessionTokenRef and savedOriginRef on unmount. + // + // sessionTokenRef: A's deferred handleSave continuation fails the token check + // on all legs after A's component is torn down. + // + // savedOriginRef: A's deferred self-mutation completion (handleConfirmRemove in + // StaffingTab) calls onSelfMutation?.() which closes over savedOriginRef. If + // the ref still holds A's origin after teardown, the fence + // `savedOriginRef.current === originAtRender` is A === A → true → runProbe(A) + // fires, signing a NIP-98 request with the now-active identity's keys. Nulling + // the ref makes the fence false (null !== A) regardless of which origin was + // active at session mount, closing the post-teardown signing leak. + // + // StrictMode safety: the simulated cleanup nulls both refs before the second + // mount's load effect resolves. setSavedOriginBoth is called again by that + // effect, re-arming the ref for the live session. + useEffect(() => { + return () => { + sessionTokenRef.current = null; + savedOriginRef.current = null; + }; + }, []); + + // Load saved origin on mount (runs once per session because the component + // is keyed by pubkeyHex — re-mount = new pubkey). When nothing is saved, + // attempt NIP-11 auto-discovery of the admin origin from the connected + // relay. A discovered origin is auto-saved and probed without requiring an + // explicit Save ONLY when it is same-host (its admin_api host matches the + // connected relay's host): that relay is a trusted source and + // AdminOrigin::parse validates the value on the Rust side before it is + // stored or signed against. A cross-host advertisement is pre-fill only — we + // never auto-save or auto-probe it, because probing signs a NIP-98 header + // with the operator's key and a malicious relay must not coax an unconsented + // signature for an origin it does not own (the operator reviews the pre-filled + // value under Advanced and Saves explicitly if they trust it). The operator + // only needs to interact with the Advanced disclosure to change or clear the + // origin. + // biome-ignore lint/correctness/useExhaustiveDependencies: intentional mount-once effect; identity boundary is the key prop on this component — it unmounts/remounts on pubkey change, so [] is correct. + useEffect(() => { + let active = true; + void (async () => { + try { + const saved = await getAdminOrigin(pubkeyHex); + if (!active) return; + if (saved) { + // A persisted origin (manual fallback or previously auto-saved + // discovery) takes precedence — probe it immediately. + setSavedOriginBoth(saved); + setOriginInput(saved); + runProbe(saved); + return; + } + // No saved origin: auto-discover from the relay's NIP-11 `admin_api`. + // Best-effort — a relay error, an absent field, or an advertised value + // that fails validation falls back to manual entry, never an error. + let discovered: { origin: string; sameHost: boolean } | null = null; + try { + discovered = await discoverAdminOrigin(); + } catch { + discovered = null; + } + if (!active) return; + if (discovered) { + if (!discovered.sameHost) { + // Cross-host advertisement: the relay points the admin_api at a + // host it does not own. Never auto-save or auto-probe it — probing + // would sign a NIP-98 header with the operator's key for an origin + // the connected relay cannot vouch for. Pre-fill the manual field + // and open Advanced so the operator can review and Save explicitly. + setOriginInput(discovered.origin); + setAdvancedOpen(true); + setSavedOriginBoth(null); + return; + } + // Same-host advertisement: auto-save the discovered origin (same path + // as an explicit Save), then probe. This lets the panel render + // immediately on first open when the relay advertises its own + // admin_api, with no Save required. The operator still sees the + // Advanced disclosure if they need to change or clear the value. + try { + const canonical = await setAdminOrigin( + discovered.origin, + pubkeyHex, + ); + if (!active) return; + if (canonical) { + setSavedOriginBoth(canonical); + setOriginInput(canonical); + runProbe(canonical); + return; + } + } catch { + // Discovery save failed (e.g. invalid origin per AdminOrigin::parse). + // Fall through to manual-entry state. + } + if (!active) return; + // Save failed: pre-fill only so the operator can review and Save manually. + setOriginInput(discovered.origin); + setAdvancedOpen(true); + } + setSavedOriginBoth(null); + } catch (e) { + if (!active) return; + // Surface storage/signing errors rather than silently degrading. + setProbeUiState({ + kind: "error", + message: e instanceof Error ? e.message : String(e), + }); + setSavedOriginBoth(null); + setOriginInput(""); + } + })(); + return () => { + active = false; + }; + }, []); // Empty: runs once per session mount; identity boundary is the key prop. + + function runProbe(origin: string) { + probeAbortRef.current?.abort(); + const controller = new AbortController(); + probeAbortRef.current = controller; + + setProbeUiState({ kind: "probing" }); + + void (async () => { + try { + const result = await probeAdminOrigin(origin); + if (controller.signal.aborted) return; + setProbeUiState(probeStateToUiState(result, origin, pubkeyHex)); + } catch (e) { + if (controller.signal.aborted) return; + setProbeUiState({ + kind: "error", + message: e instanceof Error ? e.message : String(e), + }); + } + })(); + } + + async function handleSave() { + const trimmed = originInput.trim(); + // Capture (pubkey, origin) token at save-start time. The check below + // ensures a delayed completion cannot write into a different session. + const token: SessionToken = { pubkey: pubkeyHex, origin: trimmed }; + sessionTokenRef.current = token; + + setIsSaving(true); + abortAndResetProbe(); + try { + if (!trimmed) { + const canonical = await setAdminOrigin(null, pubkeyHex); + // Discard if the session changed while the native call was in flight. + if (sessionTokenRef.current !== token) return; + setSavedOriginBoth(canonical); + setProbeUiState({ kind: "idle" }); + return; + } + const canonical = await setAdminOrigin(trimmed, pubkeyHex); + if (sessionTokenRef.current !== token) return; + setSavedOriginBoth(canonical); + if (canonical) { + runProbe(canonical); + } else { + setProbeUiState({ kind: "idle" }); + } + } catch (e) { + if (sessionTokenRef.current !== token) return; + setProbeUiState({ + kind: "error", + message: e instanceof Error ? e.message : String(e), + }); + } finally { + if (sessionTokenRef.current === token) setIsSaving(false); + } + } + + const inputChanged = originInput.trim() !== (savedOrigin ?? ""); + const isPanelVisible = + (probeUiState.kind === "authorized" || probeUiState.kind === "disabled") && + savedOrigin !== null; + + return ( + <> + {/* Probe status badge — always shown above the panel/disclosure */} +
    + +
    + + {isPanelVisible && savedOrigin && ( + { + // Fence: the panel was mounted for `savedOrigin`. If the origin + // changed while Staffing's mutation was in flight (e.g. operator + // saved a new origin before A's DELETE resolved), the captured + // value no longer matches the current session — ignore the + // completion rather than probing the stale relay and overwriting + // the new session's authorized state. + const originAtRender = savedOrigin; + if (savedOriginRef.current === originAtRender) { + runProbe(originAtRender); + } + }} + /> + )} + + {/* Advanced: admin origin — moved below the action panel; happy path + never needs it so it lives at the bottom of the section. */} +
    +
    setAdvancedOpen(e.currentTarget.open)} + > + + + Advanced: admin origin + +
    +
    + { + setOriginInput(e.target.value); + // General reset: abort and clear probe state on every input + // change, not only when state is `probing`. This prevents a + // stale probe result from a previous value being committed. + abortAndResetProbe(); + }} + placeholder="https://admin.yourrelay.example.com" + spellCheck={false} + type="url" + value={originInput} + onKeyDown={(e) => { + if (e.key === "Enter") void handleSave(); + }} + /> + + {savedOrigin && ( + + )} +
    + {probeUiState.kind === "authorized" && probeUiState.source && ( +

    + Origin resolved from{" "} + {probeUiState.source === "config" + ? "relay config" + : probeUiState.source === "owner_fallback" + ? "relay config (owner fallback)" + : "database"} + . +

    + )} +
    +
    +
    + + ); +} diff --git a/desktop/src/features/admin-console/AdminConsoleStaffingTab.tsx b/desktop/src/features/admin-console/AdminConsoleStaffingTab.tsx new file mode 100644 index 0000000000..f5a110833c --- /dev/null +++ b/desktop/src/features/admin-console/AdminConsoleStaffingTab.tsx @@ -0,0 +1,799 @@ +/** + * Staffing tab — Operator-only UI for managing relay_operators rows. + * + * Source badges distinguish config-backed entries (immutable via API) from + * DB-managed entries (can be added/removed/updated). 409 conflicts from the + * server (config-backed key modification attempts) are surfaced with a clear + * message. + * + * Display-name resolution follows the same pattern as the Invites surface: + * names come from `useUsersBatchQuery`; hovering a name cross-fades to the + * truncated npub so the raw identity is always one interaction away. + * + * A "Restrictions" section below the operator list lets operators lift active + * bans and timeouts for the currently active community. + */ + +import { useEffect, useRef, useState } from "react"; +import { nip19 } from "nostr-tools"; +import { LoaderCircle, Trash2 } from "lucide-react"; +import { Button } from "@/shared/ui/button"; +import { Badge } from "@/shared/ui/badge"; +import { truncatePubkey } from "@/shared/lib/pubkey"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { getRelayWsUrl } from "@/shared/api/tauri"; +import type { UserProfileSummary } from "@/shared/api/types"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; +import { + deleteAdminOperator, + listAdminOperators, + listAdminRestrictions, + liftAdminBan, + liftAdminTimeout, + putAdminOperator, + type AdminMemberRestrictionDto, + type AdminOperatorDto, +} from "./api"; +import { + adminErrorMessage, + type AsyncState, + ErrorMessage, + LoadingSpinner, + useAsyncLoad, +} from "./AdminConsolePanelHelpers"; + +// ── Display-name helpers (mirrors CommunityMembersSettingsCard) ─────────── + +function formatDisplayName( + pubkey: string, + profile?: UserProfileSummary | null, +): string { + const trimmed = profile?.displayName?.trim(); + if (trimmed && !trimmed.toLowerCase().startsWith("npub1")) { + return trimmed; + } + return truncatePubkey(pubkey); +} + +function npubFromPubkey(pubkey: string): string | null { + try { + return nip19.npubEncode(pubkey); + } catch { + return null; + } +} + +/** + * Cross-fades between display name and npub on hover — same animation as the + * Invites / CommunityMembersSettingsCard surface so the two feel identical. + */ +function HoverStaffingIdentity({ + pubkey, + displayName, +}: { + pubkey: string; + displayName: string; +}) { + const npub = npubFromPubkey(pubkey) ?? pubkey; + return ( + + + {displayName} + + + {truncatePubkey(npub)} + + + ); +} + +// ── Source badge ────────────────────────────────────────────────────────── + +/** Source badge for an operator entry. */ +function SourceBadge({ + source, +}: { + source: "config" | "owner_fallback" | "db"; +}) { + const label: Record = { + config: "config", + owner_fallback: "owner (fallback)", + db: "db", + }; + const variant: Record = { + config: "secondary", + owner_fallback: "secondary", + db: "outline", + }; + return ( + + {label[source] ?? source} + + ); +} + +// ── Restrictions section ────────────────────────────────────────────────── + +/** + * Restriction type label for a row. A row can be banned, timed-out, or both. + */ +function RestrictionTypeBadge({ + record, +}: { + record: AdminMemberRestrictionDto; +}) { + const now = new Date(); + const isBanned = + record.banned && + (record.banExpiresAt === null || new Date(record.banExpiresAt) > now); + const isTimedOut = + record.mutedUntil !== null && new Date(record.mutedUntil) > now; + return ( + + {isBanned && banned} + {isTimedOut && timeout} + + ); +} + +/** + * Active restrictions for the active relay's community. The native commands + * name the community by the active relay's host, so there is no client-side + * community id to get wrong; an unresolvable host surfaces as an error. + * Operators can lift bans and timeouts per member row. + */ +function RestrictionsSection({ + origin, + relayKey, + generation, +}: { + origin: string; + /** Active relay identity; a change reloads the list. */ + relayKey: string; + generation: number; +}) { + const [listGen, setListGen] = useState(0); + const [liftError, setLiftError] = useState(null); + const [workingPubkey, setWorkingPubkey] = useState(null); + /** Row pending a lift-ban confirmation. */ + const [pendingLiftBan, setPendingLiftBan] = + useState(null); + /** Row pending a lift-timeout confirmation. */ + const [pendingLiftTimeout, setPendingLiftTimeout] = + useState(null); + + // The list captures the native relay it loaded from; every later page and + // removal carries it so a relay switch fails loudly instead of retargeting. + const listState: AsyncState<{ + relay: string; + items: AdminMemberRestrictionDto[]; + nextCursor: string | null; + }> = useAsyncLoad( + async () => { + const relay = await getRelayWsUrl(); + return { relay, ...(await listAdminRestrictions(origin, relay)) }; + }, + [origin, relayKey], + generation + listGen, + ); + const loadedRelay = listState.status === "ok" ? listState.data.relay : ""; + + const handleConfirmLiftBan = async () => { + const row = pendingLiftBan; + if (!row) return; + setPendingLiftBan(null); + setLiftError(null); + setWorkingPubkey(row.pubkey); + try { + await liftAdminBan(origin, row.pubkey, loadedRelay); + setListGen((g) => g + 1); + } catch (e) { + const msg = adminErrorMessage(e); + // 409 = no active ban — treat as a soft success (already gone). + if (msg.includes("no active ban") || msg.includes("conflict")) { + setListGen((g) => g + 1); + } else { + setLiftError(msg); + } + } finally { + setWorkingPubkey(null); + } + }; + + const handleConfirmLiftTimeout = async () => { + const row = pendingLiftTimeout; + if (!row) return; + setPendingLiftTimeout(null); + setLiftError(null); + setWorkingPubkey(row.pubkey); + try { + await liftAdminTimeout(origin, row.pubkey, loadedRelay); + setListGen((g) => g + 1); + } catch (e) { + const msg = adminErrorMessage(e); + // 409 = no active timeout — treat as a soft success (already gone). + if (msg.includes("no active timeout") || msg.includes("conflict")) { + setListGen((g) => g + 1); + } else { + setLiftError(msg); + } + } finally { + setWorkingPubkey(null); + } + }; + + // Pages past the first, tied to the load generation so any reload (e.g. + // after a lift) drops them and restarts from page one. + const loadGen = generation + listGen; + const [more, setMore] = useState<{ + gen: number; + items: AdminMemberRestrictionDto[]; + nextCursor: string | null; + } | null>(null); + // Busy/error for the in-flight extra page, fenced by the same generation: + // a page request that settles after a reload must not touch the new list. + const [moreRequest, setMoreRequest] = useState<{ + gen: number; + busy: boolean; + error: string | null; + } | null>(null); + const loadGenRef = useRef(loadGen); + useEffect(() => { + loadGenRef.current = loadGen; + }, [loadGen]); + const extra = more?.gen === loadGen ? more : null; + const moreStatus = moreRequest?.gen === loadGen ? moreRequest : null; + const items = + listState.status === "ok" + ? [...listState.data.items, ...(extra?.items ?? [])] + : []; + const nextCursor = + listState.status === "ok" + ? extra + ? extra.nextCursor + : listState.data.nextCursor + : null; + + const handleLoadMore = async () => { + if (!nextCursor) return; + const gen = loadGen; + setMoreRequest({ gen, busy: true, error: null }); + try { + const page = await listAdminRestrictions(origin, loadedRelay, nextCursor); + if (loadGenRef.current !== gen) return; + setMore({ + gen, + items: [...(extra?.items ?? []), ...page.items], + nextCursor: page.nextCursor, + }); + setMoreRequest({ gen, busy: false, error: null }); + } catch (e) { + if (loadGenRef.current !== gen) return; + setMoreRequest({ gen, busy: false, error: adminErrorMessage(e) }); + } + }; + + return ( +
    + {/* Lift-ban confirmation dialog */} + { + if (!open) setPendingLiftBan(null); + }} + > + + + Lift ban? + + This will remove the active ban for{" "} + + {pendingLiftBan ? truncatePubkey(pendingLiftBan.pubkey) : ""} + + . They will be able to post again. + + + + + Cancel + + + + + + + + + {/* Lift-timeout confirmation dialog */} + { + if (!open) setPendingLiftTimeout(null); + }} + > + + + Clear timeout? + + This will clear the active timeout for{" "} + + {pendingLiftTimeout + ? truncatePubkey(pendingLiftTimeout.pubkey) + : ""} + + . They will be able to post again. + + + + + Cancel + + + + + + + + +

    + Active restrictions +

    + + {listState.status === "loading" && } + {listState.status === "error" && ( + + )} + {liftError && } + {listState.status === "ok" && items.length === 0 && ( +

    + No active bans or timeouts. +

    + )} + {listState.status === "ok" && items.length > 0 && ( +
      + {items.map((row) => { + const isWorking = workingPubkey === row.pubkey; + const now = new Date(); + const isBanned = + row.banned && + (row.banExpiresAt === null || new Date(row.banExpiresAt) > now); + const isTimedOut = + row.mutedUntil !== null && new Date(row.mutedUntil) > now; + return ( +
    • +
      +

      + {truncatePubkey(row.pubkey)} +

      +
      + +
      +
      + {isBanned && ( + + )} + {isTimedOut && ( + + )} +
    • + ); + })} +
    + )} + {moreStatus?.error && } + {nextCursor && ( + + )} +
    + ); +} + +// ── Staffing tab ────────────────────────────────────────────────────────── + +export function StaffingTab({ + origin, + pubkey, + generation, + canMutate, + onSelfMutation, +}: { + origin: string; + pubkey: string; + generation: number; + /** + * When false (disabled-auth probe), all write affordances are hidden. + * The operator list is still readable; only add/remove/edit controls are absent. + */ + canMutate: boolean; + /** + * Called after a successful mutation that modified the current principal's + * own operator row (role change or removal of self). The parent re-probes + * the admin origin so the displayed role and visible tabs reflect the new + * server state. + */ + onSelfMutation?: () => void; +}) { + const { activeCommunity } = useCommunities(); + const [listGen, setListGen] = useState(0); + const [addPubkey, setAddPubkey] = useState(""); + const [addRole, setAddRole] = useState<"operator" | "moderator">("moderator"); + const [isAdding, setIsAdding] = useState(false); + const [addError, setAddError] = useState(null); + const [actionError, setActionError] = useState(null); + const [workingPubkey, setWorkingPubkey] = useState(null); + /** Operator pending removal confirmation; null when dialog is closed. */ + const [pendingRemove, setPendingRemove] = useState( + null, + ); + + const listState: AsyncState = useAsyncLoad( + () => listAdminOperators(origin), + [origin, pubkey], + generation + listGen, + ); + + // Resolve display names using the shared hook — normalised delta-fetch with + // per-pubkey caching, persisted-label seeding, and focus-recovery retry. + // CommunitiesProvider wraps the entire Settings tree (main.tsx) so this is + // always in context. + const listedPubkeys = + listState.status === "ok" ? listState.data.map((op) => op.pubkey) : []; + const profilesQuery = useUsersBatchQuery(listedPubkeys, { + enabled: listedPubkeys.length > 0, + }); + + const handleAdd = async () => { + const trimmed = addPubkey.trim().toLowerCase(); + if (!trimmed) return; + // Enforce create-only invariant: reject if the roster hasn't loaded — + // the disabled button is the primary UI gate, but this guard closes the + // boundary at the mutation call site itself. + if (listState.status !== "ok") return; + setAddError(null); + + // Reject any pubkey already present in the authoritative roster. + const existing = listState.data.find((op) => op.pubkey === trimmed); + if (existing) { + setAddError( + `Already an operator: ${existing.effectiveRole}. Change role with the edit control on their row.`, + ); + return; + } + + setIsAdding(true); + try { + await putAdminOperator(origin, trimmed, addRole); + setAddPubkey(""); + setListGen((g) => g + 1); + } catch (e) { + // Surface the relay's parsed error message — a 409 may indicate either + // a config-backed key (immutable) or a last-operator conflict, both of + // which the relay communicates with distinct messages. + setAddError(adminErrorMessage(e)); + } finally { + setIsAdding(false); + } + }; + + const handleRoleChange = async ( + op: AdminOperatorDto, + newRole: "operator" | "moderator", + ) => { + if (newRole === op.effectiveRole) return; + setActionError(null); + setWorkingPubkey(op.pubkey); + try { + await putAdminOperator(origin, op.pubkey, newRole); + setListGen((g) => g + 1); + // Self-demotion: re-probe so the parent updates role badge and tab + // visibility to reflect the new server state. + if (op.pubkey === pubkey) { + onSelfMutation?.(); + } + } catch (e) { + setActionError(adminErrorMessage(e)); + } finally { + setWorkingPubkey(null); + } + }; + + const handleConfirmRemove = async () => { + const op = pendingRemove; + if (!op) return; + setPendingRemove(null); + setActionError(null); + setWorkingPubkey(op.pubkey); + try { + await deleteAdminOperator(origin, op.pubkey); + setListGen((g) => g + 1); + // Self-removal: re-probe so the parent updates role badge and tab + // visibility to reflect the new server state. + if (op.pubkey === pubkey) { + onSelfMutation?.(); + } + } catch (e) { + setActionError(adminErrorMessage(e)); + } finally { + setWorkingPubkey(null); + } + }; + + const isSelf = pendingRemove?.pubkey === pubkey; + + return ( +
    + {/* Remove confirmation dialog */} + { + if (!open) setPendingRemove(null); + }} + > + + + Remove operator? + +
    +

    + This will remove{" "} + + {pendingRemove ? truncatePubkey(pendingRemove.pubkey) : ""} + {" "} + ({pendingRemove?.effectiveRole}) from the operator list. +

    + {isSelf && ( +

    + You are removing your own operator access. Once removed, you + may lose the ability to undo this action. +

    + )} +
    +
    +
    + + + Cancel + + + + + +
    +
    + + {/* Add operator form — hidden in read-only (disabled-auth) mode */} + {canMutate && ( +
    +

    + Add operator +

    +
    + setAddPubkey(e.target.value)} + placeholder="64-hex pubkey" + type="text" + value={addPubkey} + /> + + +
    + {addError &&

    {addError}

    } +
    + )} + + {/* Operator list */} + {listState.status === "loading" && } + {listState.status === "error" && ( + + )} + {actionError && } + {listState.status === "ok" && ( +
      + {listState.data.length === 0 && ( +

      + No operators configured. +

      + )} + {listState.data.map((op: AdminOperatorDto) => { + const isConfigBacked = op.sources.some( + (s) => s === "config" || s === "owner_fallback", + ); + const isWorking = workingPubkey === op.pubkey; + const profile = profilesQuery.data?.profiles[op.pubkey]; + const displayName = formatDisplayName(op.pubkey, profile); + return ( +
    • +
      +

      + +

      +
      + {op.sources.map((s) => ( + + ))} +
      +
      + {/* In-place role selector — hidden in read-only / config-backed mode */} + {canMutate && !isConfigBacked && ( + + )} + {/* Config-backed: show role as badge (not editable) */} + {isConfigBacked && ( + {op.effectiveRole} + )} + {/* Remove button — hidden in read-only (disabled-auth) mode */} + {canMutate && ( + + )} +
    • + ); + })} +
    + )} + + {/* Restrictions section — active bans and timeouts for the current community */} +
    + +
    +
    + ); +} diff --git a/desktop/src/features/admin-console/adminConsolePanel.test.mjs b/desktop/src/features/admin-console/adminConsolePanel.test.mjs new file mode 100644 index 0000000000..aedd9ff124 --- /dev/null +++ b/desktop/src/features/admin-console/adminConsolePanel.test.mjs @@ -0,0 +1,1220 @@ +/** + * Behavior and race tests for AdminConsoleSettingsCard / AdminConsoleSettingsSession. + * + * Tests mount the REAL production components (including the key-prop session + * boundary, sessionTokenRef fence, and abortAndResetProbe wiring) against a + * mocked Tauri IPC bridge and a real QueryClientProvider. + * + * This file uses the hand-rolled MinimalDocument shim (same pattern as + * useLoadArchivedObserverEvents.test.mjs) and covers prop-driven and query- + * driven tests that do NOT require native event dispatch through React 19's + * container-level delegation: + * + * What makes these tests authoritative — they fail if: + * - `pubkeyHex ? : null` render gate removed (authorized-logout-teardown) + * - `key={pubkeyHex}` boundary is removed (identity-switch test) + * - `active` flag cleanup is removed from useAsyncLoad (old-list-after-new-list) + * - the `getAdminOrigin()` catch is changed to silent-degrade (storage-error test) + * + * authorized-logout-teardown lives here (MinimalDocument, not jsdom) because the test is + * query-driven (act + qc.setQueryData + settle), not event-driven. The MinimalDocument + * suite handles async transitions cleanly without the jsdom global scheduler. + * + * Cross-identity delayed-save and all event-driven tests (origin-edit, detail-navigation, + * blob-leak-on-back-navigation, same-session-save-race) live in adminConsolePanelSession.jsdom-test.mjs + * where fireEvent dispatches native events through React 19's container-level delegation. + * + * Also covers: + * - parseImetaAttachments wire contract (imported from AdminConsolePanel) + */ +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; + +// ── Minimal DOM shim ────────────────────────────────────────────────────────── +// +// Installs the minimum DOM surface that React + react-dom/client need. +// Uses the same pattern as useLoadArchivedObserverEvents.test.mjs to avoid +// jsdom background timers that prevent the process from exiting cleanly. + +function installDOMShim() { + class MinimalEventTarget { + constructor() { + this._listeners = {}; + } + addEventListener(type, fn) { + if (!this._listeners[type]) this._listeners[type] = []; + this._listeners[type].push(fn); + } + removeEventListener(type, fn) { + if (this._listeners[type]) { + this._listeners[type] = this._listeners[type].filter((f) => f !== fn); + } + } + dispatchEvent(e) { + for (const fn of this._listeners[e.type] ?? []) fn(e); + return true; + } + } + + class MinimalNode extends MinimalEventTarget { + constructor(tagName) { + super(); + this.tagName = tagName?.toUpperCase?.() ?? tagName; + this.nodeName = this.tagName; + this.children = []; + this.childNodes = []; + this.style = {}; + this.nodeType = 1; + this.parentNode = null; + this.attributes = []; + this._data = {}; + } + get ownerDocument() { + return globalThis.document; + } + get firstChild() { + return this.childNodes[0] ?? null; + } + get lastChild() { + return this.childNodes[this.childNodes.length - 1] ?? null; + } + get nextSibling() { + return null; + } + get previousSibling() { + return null; + } + get nodeValue() { + return null; + } + set nodeValue(_v) {} + get textContent() { + return this.childNodes.map((c) => c.textContent ?? "").join(""); + } + set textContent(v) { + this.childNodes = []; + if (v) { + const t = globalThis.document.createTextNode(v); + this.appendChild(t); + } + } + appendChild(child) { + child.parentNode = this; + this.childNodes.push(child); + if (child.nodeType === 1) this.children.push(child); + return child; + } + removeChild(child) { + this.childNodes = this.childNodes.filter((c) => c !== child); + this.children = this.children.filter((c) => c !== child); + return child; + } + insertBefore(newNode, refNode) { + if (!refNode) return this.appendChild(newNode); + const i = this.childNodes.indexOf(refNode); + if (i < 0) return this.appendChild(newNode); + newNode.parentNode = this; + this.childNodes.splice(i, 0, newNode); + if (newNode.nodeType === 1) this.children.push(newNode); + return newNode; + } + replaceChild(newNode, oldNode) { + const i = this.childNodes.indexOf(oldNode); + if (i >= 0) { + newNode.parentNode = this; + this.childNodes[i] = newNode; + const j = this.children.indexOf(oldNode); + if (j >= 0) this.children[j] = newNode; + } + return oldNode; + } + contains(node) { + if (!node) return false; + return this === node || this.childNodes.some((c) => c?.contains?.(node)); + } + setAttribute(name, value) { + this._data[name] = value; + } + getAttribute(name) { + return this._data[name] ?? null; + } + hasAttribute(name) { + return Object.hasOwn(this._data, name); + } + removeAttribute(name) { + delete this._data[name]; + } + querySelector(selector) { + // Support [data-testid='...'] and simple tag selectors. + const attrMatch = selector.match(/\[([^\]=']+)(?:='([^']*)')?\]/); + const tagMatch = selector.match(/^([a-zA-Z]+)$/); + for (const node of this._allElements()) { + if (attrMatch) { + const [, attrName, attrVal] = attrMatch; + const nodeVal = node.getAttribute?.(attrName); + if (attrVal === undefined ? nodeVal !== null : nodeVal === attrVal) { + return node; + } + } else if (tagMatch) { + if (node.tagName?.toLowerCase() === tagMatch[1].toLowerCase()) { + return node; + } + } + } + return null; + } + querySelectorAll(selector) { + const attrMatch = selector.match(/\[([^\]=']+)(?:='([^']*)')?\]/); + const results = []; + for (const node of this._allElements()) { + if (attrMatch) { + const [, attrName, attrVal] = attrMatch; + const nodeVal = node.getAttribute?.(attrName); + if (attrVal === undefined ? nodeVal !== null : nodeVal === attrVal) { + results.push(node); + } + } + } + return results; + } + *_allElements() { + for (const child of this.childNodes) { + yield child; + if (child._allElements) yield* child._allElements(); + } + } + get innerHTML() { + return this.childNodes + .map((c) => c.outerHTML ?? c.textContent ?? "") + .join(""); + } + set innerHTML(_v) {} + get outerHTML() { + return `<${this.tagName?.toLowerCase() ?? "div"}>...`; + } + focus() {} + blur() {} + getBoundingClientRect() { + return { top: 0, left: 0, bottom: 0, right: 0, width: 0, height: 0 }; + } + cloneNode() { + return new MinimalNode(this.tagName); + } + get value() { + return this._value ?? ""; + } + set value(v) { + this._value = v; + } + get disabled() { + return this._disabled ?? false; + } + set disabled(v) { + this._disabled = v; + } + get type() { + return this._type ?? ""; + } + set type(v) { + this._type = v; + } + get checked() { + return this._checked ?? false; + } + set checked(v) { + this._checked = v; + } + get className() { + return this._className ?? ""; + } + set className(v) { + this._className = v; + } + get id() { + return this._id ?? ""; + } + set id(v) { + this._id = v; + } + get placeholder() { + return this._placeholder ?? ""; + } + set placeholder(v) { + this._placeholder = v; + } + get readOnly() { + return this._readOnly ?? false; + } + set readOnly(v) { + this._readOnly = v; + } + get tabIndex() { + return this._tabIndex ?? -1; + } + set tabIndex(v) { + this._tabIndex = v; + } + get href() { + return this._href ?? ""; + } + set href(v) { + this._href = v; + } + get src() { + return this._src ?? ""; + } + set src(v) { + this._src = v; + } + get alt() { + return this._alt ?? ""; + } + set alt(v) { + this._alt = v; + } + } + + class MinimalTextNode extends MinimalEventTarget { + constructor(value) { + super(); + this.nodeType = 3; + this.nodeName = "#text"; + this.nodeValue = value; + this.parentNode = null; + } + get textContent() { + return this.nodeValue; + } + set textContent(v) { + this.nodeValue = v; + } + contains(node) { + return this === node; + } + } + + class MinimalDocument extends MinimalEventTarget { + constructor() { + super(); + this.nodeType = 9; + this.nodeName = "#document"; + this._body = null; + this._head = null; + } + createElement(tagName) { + return new MinimalNode(tagName); + } + createTextNode(value) { + return new MinimalTextNode(value); + } + createComment(value) { + const n = new MinimalNode("#comment"); + n.nodeType = 8; + n.nodeValue = value; + return n; + } + createElementNS(_ns, tagName) { + return this.createElement(tagName); + } + get body() { + if (!this._body) { + this._body = this.createElement("body"); + } + return this._body; + } + get head() { + if (!this._head) { + this._head = this.createElement("head"); + } + return this._head; + } + get activeElement() { + return null; + } + contains(node) { + return node != null; + } + querySelector(sel) { + return this.body.querySelector(sel); + } + querySelectorAll(sel) { + return this.body.querySelectorAll(sel); + } + get documentElement() { + return this.body; + } + } + + const doc = new MinimalDocument(); + globalThis.document = doc; + globalThis.HTMLElement = MinimalNode; + globalThis.HTMLInputElement = MinimalNode; + globalThis.HTMLButtonElement = MinimalNode; + globalThis.HTMLDivElement = MinimalNode; + globalThis.HTMLSpanElement = MinimalNode; + globalThis.HTMLAnchorElement = MinimalNode; + globalThis.HTMLFormElement = MinimalNode; + globalThis.HTMLIFrameElement = MinimalNode; + globalThis.SVGElement = MinimalNode; + globalThis.SVGSVGElement = MinimalNode; + globalThis.Text = MinimalTextNode; + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + process.env.IS_REACT_ACT_ENVIRONMENT = "true"; + + globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0); + globalThis.cancelAnimationFrame = (id) => clearTimeout(id); + + globalThis.MutationObserver = class { + observe() {} + disconnect() {} + takeRecords() { + return []; + } + }; + + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + + globalThis.IntersectionObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + + globalThis.getComputedStyle = () => ({ + getPropertyValue: () => "", + setProperty: () => {}, + }); + + if (typeof globalThis.window === "undefined") { + Object.defineProperty(globalThis, "window", { + value: globalThis, + configurable: true, + }); + } + if (!Object.getOwnPropertyDescriptor(globalThis, "navigator")?.value) { + Object.defineProperty(globalThis, "navigator", { + value: { userAgent: "node" }, + configurable: true, + }); + } + // CommunitiesProvider reads localStorage on mount; provide a minimal no-op + // shim so tests that wrap components needing CommunitiesProvider don't throw. + if (typeof globalThis.localStorage === "undefined") { + const _store = Object.create(null); + Object.defineProperty(globalThis, "localStorage", { + value: { + getItem: (k) => _store[k] ?? null, + setItem: (k, v) => { + _store[k] = String(v); + }, + removeItem: (k) => { + delete _store[k]; + }, + clear: () => { + for (const k of Object.keys(_store)) delete _store[k]; + }, + get length() { + return Object.keys(_store).length; + }, + key: (i) => Object.keys(_store)[i] ?? null, + }, + configurable: true, + }); + } +} + +installDOMShim(); + +// ── Tauri IPC interceptor ───────────────────────────────────────────────────── + +/** @type {Map Promise>} */ +const ipcHandlers = new Map(); + +function setIpcHandler(cmd, fn) { + ipcHandlers.set(cmd, fn); +} +function clearIpcHandlers() { + ipcHandlers.clear(); +} + +globalThis.__TAURI_INTERNALS__ = { + invoke(cmd, args) { + const handler = ipcHandlers.get(cmd); + if (handler) return handler(args); + return Promise.reject(new Error(`unmocked Tauri command: ${cmd}`)); + }, + transformCallback(_cb) { + return Math.random(); + }, +}; + +// ── Production imports ──────────────────────────────────────────────────────── + +import React from "react"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { CommunitiesProvider } from "@/features/communities/useCommunities.tsx"; + +import { AdminConsoleSettingsCard } from "./AdminConsoleSettingsCard.tsx"; +import { + AdminConsolePanel, + parseImetaAttachments, +} from "./AdminConsolePanel.tsx"; +import { applyAttachmentBudget } from "./AdminConsoleFeedbackTab.tsx"; + +// ── Deferred promise helper ─────────────────────────────────────────────────── + +function deferred() { + let resolve, reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +// ── Mount helpers ───────────────────────────────────────────────────────────── + +function makeQueryClient(pubkeyHex) { + const qc = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, staleTime: Infinity }, + }, + }); + // Always set identity to an object (even for empty pubkey) so React Query + // never calls queryFn = getIdentity (which would hit the unmocked IPC). + // Component reads pubkeyHex = identity?.pubkey ?? "" — so { pubkey: "" } + // gives pubkeyHex = "" (logged-out state). + qc.setQueryData(["identity"], { pubkey: pubkeyHex }); + return qc; +} + +function mountCard(qc) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const doRender = async () => { + // ReportsTab calls useUsersBatchQuery which needs a get_users_batch handler. + if (!ipcHandlers.get("get_users_batch")) { + setIpcHandler("get_users_batch", () => + Promise.resolve({ profiles: {}, missing: [] }), + ); + } + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: qc }, + React.createElement( + CommunitiesProvider, + null, + React.createElement(AdminConsoleSettingsCard), + ), + ), + ); + }); + }; + const unmount = async () => { + await act(async () => { + root.unmount(); + }); + qc.clear(); + document.body.removeChild(container); + }; + return { container, doRender, unmount }; +} + +/** + * Mount AdminConsolePanel directly (not through the settings card). + * Used for panel-level race tests (list, detail, attachment). + */ +function mountPanel({ + origin, + pubkey, + canMutate = true, + initialTab = undefined, +}) { + const container = document.createElement("div"); + document.body.appendChild(container); + const qc = makeQueryClient(pubkey); + const root = createRoot(container); + const doRender = async ({ origin: o, pubkey: p } = { origin, pubkey }) => { + // ReportsTab calls useUsersBatchQuery which needs QueryClientProvider + + // CommunitiesProvider. Provide a default no-op handler so profile lookups + // resolve without error when individual tests don't override get_users_batch. + if (!ipcHandlers.get("get_users_batch")) { + setIpcHandler("get_users_batch", () => + Promise.resolve({ profiles: {}, missing: [] }), + ); + } + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: qc }, + React.createElement( + CommunitiesProvider, + null, + React.createElement(AdminConsolePanel, { + canMutate, + origin: o, + pubkey: p, + ...(initialTab !== undefined ? { initialTab } : {}), + }), + ), + ), + ); + }); + }; + const unmount = async () => { + await act(async () => { + root.unmount(); + }); + qc.clear(); + document.body.removeChild(container); + }; + return { container, doRender, unmount }; +} + +// Flush React effects and timers. +async function settle(ms = 20) { + await act(async () => { + await new Promise((r) => setTimeout(r, ms)); + }); +} + +afterEach(() => { + clearIpcHandlers(); +}); + +// ── parseImetaAttachments ───────────────────────────────────────────────────── + +test("parseImetaAttachments: parses a well-formed imeta tag", () => { + const sha256 = "a".repeat(64); + const tags = [ + [ + "imeta", + `url https://example.com/a.jpg`, + `m image/jpeg`, + `x ${sha256}`, + "size 1234", + ], + ]; + const result = parseImetaAttachments(tags); + assert.equal(result.length, 1); + assert.equal(result[0].sha256, sha256); + assert.equal(result[0].mime, "image/jpeg"); + assert.equal(result[0].size, 1234); +}); + +test("parseImetaAttachments: skips or rejects invalid inputs", () => { + // Table of inputs that must produce an empty result. Retains every input + // from the original standalone tests, including zero/negative sizes and + // null/object/string non-array values. + const sha256 = "a".repeat(64); + const IMETA_REJECTION_ROWS = [ + { + label: "non-imeta tags are skipped", + tags: [ + ["p", "abc123"], + ["e", "def456"], + ], + }, + { + label: "uppercase x hash rejected", + tags: [["imeta", `x ${"A".repeat(64)}`, "m image/png", "size 100"]], + }, + { + label: "hash shorter than 64 chars rejected", + tags: [["imeta", `x ${"a".repeat(63)}`, "m image/png", "size 100"]], + }, + { + label: "hash longer than 64 chars rejected", + tags: [["imeta", `x ${"a".repeat(65)}`, "m image/png", "size 100"]], + }, + { + label: "missing m field rejected", + tags: [["imeta", `x ${"b".repeat(64)}`, "size 100"]], + }, + { + label: "missing size field rejected", + tags: [["imeta", `x ${"c".repeat(64)}`, "m image/png"]], + }, + { + label: "zero size rejected", + tags: [["imeta", `x ${sha256}`, "m image/png", "size 0"]], + }, + { + label: "negative size rejected", + tags: [["imeta", `x ${sha256}`, "m image/png", "size -1"]], + }, + { label: "null input returns empty array", tags: null }, + { label: "object input returns empty array", tags: {} }, + { label: "string input returns empty array", tags: "imeta" }, + ]; + for (const row of IMETA_REJECTION_ROWS) { + assert.deepEqual( + parseImetaAttachments(row.tags), + [], + `row must return []: ${row.label}`, + ); + } +}); + +test("parseImetaAttachments: parses multiple imeta tags", () => { + const sha1 = "e".repeat(64); + const sha2 = "f".repeat(64); + const tags = [ + ["imeta", `x ${sha1}`, "m image/png", "size 111"], + ["imeta", `x ${sha2}`, "m image/jpeg", "size 222"], + ]; + const result = parseImetaAttachments(tags); + assert.equal(result.length, 2); + assert.equal(result[0].sha256, sha1); + assert.equal(result[1].sha256, sha2); +}); + +// ── Component-level session boundary and race tests ─────────────────────────── +// +// Each test below mounts the production AdminConsoleSettingsCard (including +// AdminConsoleSettingsSession keyed by pubkeyHex) and drives Tauri IPC calls +// via deferred promises. These tests fail if the identity boundary or fences +// are removed from the production code. + +test("authorized-logout-teardown: A's session is gone when pubkeyHex becomes empty", async () => { + // Verifies the `pubkeyHex ? : null` render + // gate in AdminConsoleSettingsCard. Drives the full authorized→logout transition: + // mount with a real identity A, drive to authorized (input visible, panel rendered), + // then switch pubkeyHex to "" and assert both input and panel are gone. + // + // Fails if the render gate is removed: after the transition to pubkeyHex="", + // AdminConsoleSettingsSession re-mounts with empty pubkey and the input remains. + // + // Design: identical to identity-switch — act + qc.setQueryData + settle. + // React Query's notifyManager fires onStoreChange via setTimeout(0), which + // act() drains during the inner settle(). The MinimalDocument environment + // handles this cleanly without the jsdom global scheduler side-effects. + + const pubkeyA = "a".repeat(64); + const originA = "https://admin-a.example.com"; + + setIpcHandler("get_admin_origin", (args) => { + if (args?.expectedPubkey === pubkeyA) return Promise.resolve(originA); + return Promise.resolve(null); + }); + setIpcHandler("admin_probe", () => + Promise.resolve({ state: "nip98Authorized" }), + ); + setIpcHandler("admin_list_reports", () => Promise.resolve([])); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const qc = makeQueryClient(pubkeyA); + const { container, doRender, unmount } = mountCard(qc); + await doRender(); + await settle(50); + + // A is authorized — input and panel must be present. + const inputA = container.querySelector("[data-testid='admin-origin-input']"); + assert.ok(inputA, "input must render for pubkeyA in authorized state"); + const panelA = container.querySelector("[data-testid='admin-console-panel']"); + assert.ok(panelA, "admin-console-panel must render when A is authorized"); + + // Transition to logout — same pattern as identity-switch. + await act(async () => { + qc.setQueryData(["identity"], { pubkey: "" }); + await new Promise((r) => setTimeout(r, 25)); + }); + + // After the transition: gate renders null, both input and panel must be gone. + const inputAfter = container.querySelector( + "[data-testid='admin-origin-input']", + ); + const panelAfter = container.querySelector( + "[data-testid='admin-console-panel']", + ); + + await unmount(); + + assert.equal( + inputAfter, + null, + "admin origin input must not render when pubkeyHex is empty — render gate missing", + ); + assert.equal( + panelAfter, + null, + "admin-console-panel must not render after logout — render gate missing", + ); +}); +test("identity-switch: fresh session mounts with empty input on pubkey change", async () => { + // Verifies the key-prop boundary. Without `key={pubkeyHex}`, React reuses + // the component and A's origin state survives the switch to B. + + const pubkeyA = "a".repeat(64); + const pubkeyB = "b".repeat(64); + const originA = "https://admin-a.example.com"; + + setIpcHandler("get_admin_origin", (args) => { + if (args?.expectedPubkey === pubkeyA) return Promise.resolve(originA); + return Promise.resolve(null); + }); + setIpcHandler("admin_probe", () => Promise.resolve({ state: "disabled" })); + + const qc = makeQueryClient(pubkeyA); + const { container, doRender, unmount } = mountCard(qc); + await doRender(); + await settle(25); + + const inputA = container.querySelector("[data-testid='admin-origin-input']"); + assert.ok(inputA, "input must render for pubkeyA"); + assert.equal( + inputA.value, + originA, + "input must show A's saved origin after mount", + ); + + // Switch to pubkeyB — key prop causes a full remount of AdminConsoleSettingsSession. + // B has no saved origin, so the input must be empty. + setIpcHandler("get_admin_origin", (args) => { + if (args?.expectedPubkey === pubkeyB) return Promise.resolve(null); + // Reject any call with A's pubkey — must not fire after the switch. + return Promise.reject(new Error("unexpected pubkey after identity switch")); + }); + + await act(async () => { + qc.setQueryData(["identity"], { pubkey: pubkeyB }); + await new Promise((r) => setTimeout(r, 25)); + }); + + const inputB = container.querySelector("[data-testid='admin-origin-input']"); + assert.ok(inputB, "input must render for pubkeyB"); + assert.equal( + inputB.value, + "", + "input must be empty for pubkeyB — key boundary ensures fresh state, not stale A origin", + ); + await unmount(); +}); + +test("storage-error surfaced: getAdminOrigin rejection shows error in UI", async () => { + // Verifies the mount-effect catch sets `{ kind: 'error', message }`. + // Removing error propagation from the catch (silent degrade) causes the + // error text to not appear. + + const pubkey = "c".repeat(64); + const errorMsg = "stored admin console origin is invalid (removed): bad json"; + setIpcHandler("get_admin_origin", () => Promise.reject(new Error(errorMsg))); + + const qc = makeQueryClient(pubkey); + const { container, doRender, unmount } = mountCard(qc); + await doRender(); + await settle(25); + + // The error or its key fragment must be visible in the rendered tree. + const bodyText = container.textContent ?? ""; + const hasError = + bodyText.includes("invalid") || + bodyText.includes("bad json") || + bodyText.includes("removed") || + bodyText.includes("admin console origin"); + assert.ok( + hasError, + `error from getAdminOrigin must appear in UI; body text: "${bodyText.slice(0, 300)}"`, + ); + await unmount(); +}); + +// origin-edit (abortAndResetProbe wired to onChange) is covered by +// adminConsolePanelSession.jsdom-test.mjs where fireEvent dispatches native +// events through React 19's container-level delegation. + +// ── AdminConsolePanel race tests ────────────────────────────────────────────── +// +// These tests mount AdminConsolePanel directly (bypassing the settings card) +// and use deferred promises to simulate in-flight native requests. They verify +// the effect-local `active` flag cancellation in useAsyncLoad, the generation +// fence in AdminConsolePanel, and the loadGenRef cleanup in AttachmentViewer. + +test("old-list-after-new-list: stale list result does not replace new list after pubkey change", async () => { + // Verifies the effect-local `active` flag in useAsyncLoad. + // + // Scenario: panel renders with pubkeyA/originA → list query starts (deferred). + // Before it resolves, panel re-renders with pubkeyB/originB → a new list + // query starts. Then the old (A's) deferred resolves: the active flag in + // A's effect closure is already false (effect re-ran with B's deps), so + // A's result is discarded. Only B's result may commit. + // + // This test fails if useAsyncLoad's active-flag cleanup is removed, because + // A's result would overwrite B's list state. + + const originA = "https://admin-a.example.com"; + const originB = "https://admin-b.example.com"; + const pubkeyA = "a".repeat(64); + const pubkeyB = "b".repeat(64); + + const listDeferredA = deferred(); + const listDeferredB = deferred(); + + // First call returns A's deferred; subsequent calls return B's. + let callCount = 0; + setIpcHandler("admin_list_reports", () => { + callCount += 1; + if (callCount === 1) return listDeferredA.promise; + return listDeferredB.promise; + }); + + const { container, doRender, unmount } = mountPanel({ + origin: originA, + pubkey: pubkeyA, + }); + + // Render with A — list query starts and stays pending (no settle; would hang). + await act(async () => { + await doRender({ origin: originA, pubkey: pubkeyA }); + await new Promise((r) => setTimeout(r, 0)); + }); + + // Switch to B — triggers generation bump + effect cleanup (active = false for A). + // Re-render causes the effect to re-run with B's deps. + await act(async () => { + await doRender({ origin: originB, pubkey: pubkeyB }); + await new Promise((r) => setTimeout(r, 0)); + }); + + // Now resolve A's stale list with a distinct marker item. + listDeferredA.resolve([ + { + id: "00000000-0000-0000-0000-000000000001", + communityId: "00000000-0000-0000-0000-000000000002", + communityHost: "relay.example.com", + reportEventId: "aabb", + reporterPubkey: "ccdd", + targetKind: "message", + target: "eeff", + reportType: "spam", + status: "STALE-A-RESULT", + createdAt: "2024-01-01T00:00:00Z", + }, + ]); + + // Flush A's resolution — active is false so it must not commit. + await act(async () => { + await new Promise((r) => setTimeout(r, 30)); + }); + + // A's stale result must not appear — active flag was false. + const text = container.textContent ?? ""; + assert.ok( + !text.includes("STALE-A-RESULT"), + `stale list result from A must not appear after B renders; got: ${text.slice(0, 300)}`, + ); + + // Resolve B's list — this one is live. + listDeferredB.resolve([ + { + id: "00000000-0000-0000-0000-000000000003", + communityId: "00000000-0000-0000-0000-000000000004", + communityHost: "relay.example.com", + reportEventId: "1122", + reporterPubkey: "3344", + targetKind: "message", + target: "5566", + reportType: "feedback", + status: "LIVE-B-RESULT", + createdAt: "2024-01-02T00:00:00Z", + }, + ]); + + await act(async () => { + await new Promise((r) => setTimeout(r, 30)); + }); + + const textAfter = container.textContent ?? ""; + assert.ok( + textAfter.includes("LIVE-B-RESULT"), + `B's live list result must appear; got: ${textAfter.slice(0, 300)}`, + ); + + await unmount(); +}); + +// detail-navigation and blob-leak-on-back-navigation (useAsyncLoad active flag, +// AttachmentViewer loadGenRef cleanup) are covered by +// adminConsolePanelSession.jsdom-test.mjs where fireEvent dispatches native +// events through React 19's container-level delegation. + +// ── probe role/source gating — table-driven ────────────────────────────── +// +// Five rows cover the full probe-state → role-gate matrix. Each row shares +// the standard MinimalDocument mount; unique scheduler-sensitive assertions +// (disabled-probe-mounts-panel, probe-no-role) stay in this environment. +// +// Mutation evidence per row is preserved inline. + +const PROBE_ROLE_ROWS = [ + { + name: "disabled-probe-mounts-panel", + desc: "admin-console-panel renders when probe state is disabled", + pubkey: "f".repeat(64), + savedOrigin: "https://admin.example.com", + probeResult: { state: "disabled" }, + // Fails if render gate reverts to authorized-only (disabled state never mounts panel). + check: (container) => { + const panel = container.querySelector( + "[data-testid='admin-console-panel']", + ); + assert.ok( + panel !== null, + "admin-console-panel must mount when probe state is disabled — render gate missing", + ); + const text = container.textContent ?? ""; + assert.ok( + text.includes("Auth is disabled"), + `disabled badge must remain visible; got: ${text.slice(0, 300)}`, + ); + }, + }, + { + name: "probe-role-source-badge", + desc: "operator role and config source render in panel when probe returns them", + pubkey: "b1".repeat(32), + savedOrigin: "https://admin-role.example.com", + probeResult: { + state: "nip98Authorized", + role: "operator", + source: "config", + }, + // Mutation: remove role/source from AdminProbeResult → badges absent → RED. + check: (container) => { + const text = container.textContent ?? ""; + assert.ok( + text.includes("operator"), + `role badge "operator" must render; got: ${text.slice(0, 300)}`, + ); + assert.ok( + text.includes("config"), + `source badge "config" must render; got: ${text.slice(0, 300)}`, + ); + }, + }, + { + name: "probe-moderator-role", + desc: "moderator role renders without staffing tab", + pubkey: "c2".repeat(32), + savedOrigin: "https://admin-mod.example.com", + probeResult: { state: "nip98Authorized", role: "moderator", source: "db" }, + check: (container) => { + const text = container.textContent ?? ""; + assert.ok( + text.includes("moderator"), + `role "moderator" must render; got: ${text.slice(0, 300)}`, + ); + assert.equal( + container.querySelector("[data-testid='admin-tab-staffing']"), + null, + "Staffing tab must not render for moderator role", + ); + }, + }, + { + name: "probe-operator-role", + desc: "staffing tab renders for operator role", + pubkey: "d3".repeat(32), + savedOrigin: "https://admin-operator.example.com", + probeResult: { + state: "nip98Authorized", + role: "operator", + source: "config", + }, + check: (container) => { + const staffingTab = container.querySelector( + "[data-testid='admin-tab-staffing']", + ); + assert.ok( + staffingTab !== null, + "Staffing tab must render for operator role", + ); + }, + }, + { + name: "probe-no-role", + desc: "disabled-mode panel renders without staffing tab", + pubkey: "e4".repeat(32), + savedOrigin: "https://admin-disabled.example.com", + probeResult: { state: "disabled" }, + // Badge absence is not asserted here. + check: (container) => { + assert.ok( + container.querySelector("[data-testid='admin-console-panel']") !== null, + "panel must render in disabled mode", + ); + assert.equal( + container.querySelector("[data-testid='admin-tab-staffing']"), + null, + "Staffing tab must not render in disabled mode", + ); + }, + }, +]; + +for (const row of PROBE_ROLE_ROWS) { + test(`${row.name}: ${row.desc}`, async () => { + setIpcHandler("get_admin_origin", () => Promise.resolve(row.savedOrigin)); + setIpcHandler("admin_probe", () => Promise.resolve(row.probeResult)); + setIpcHandler("admin_list_reports", () => Promise.resolve([])); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const qc = makeQueryClient(row.pubkey); + const { container, doRender, unmount } = mountCard(qc); + await doRender(); + await settle(50); + + try { + row.check(container); + } finally { + await unmount(); + } + }); +} + +// ── denied badge copy button ────────────────────────────────────────────── + +test("denied-badge-copy-button: copy button is present next to the denied pubkey", async () => { + // Verifies item 2: the pubkey in the denied state is displayed alongside + // a copy button (data-testid="admin-denied-pubkey-copy"), not just a + // cursor-pointer select-all code block. + + const pubkey = "4".repeat(64); + const savedOrigin = "https://admin-denied.example.com"; + + setIpcHandler("get_admin_origin", () => Promise.resolve(savedOrigin)); + setIpcHandler("admin_probe", () => Promise.resolve({ state: "nip98Denied" })); + + const qc = makeQueryClient(pubkey); + const { container, doRender, unmount } = mountCard(qc); + await doRender(); + await settle(30); + + const pubkeyEl = container.querySelector( + "[data-testid='admin-denied-pubkey']", + ); + assert.ok(pubkeyEl !== null, "admin-denied-pubkey element must be present"); + assert.ok( + pubkeyEl.textContent?.includes(pubkey), + `denied pubkey element must contain the pubkey; got: ${pubkeyEl.textContent}`, + ); + + const copyBtn = container.querySelector( + "[data-testid='admin-denied-pubkey-copy']", + ); + assert.ok( + copyBtn !== null, + "admin-denied-pubkey-copy button must be present — copy-icon pattern missing", + ); + + await unmount(); +}); + +// ── P1-2: applyAttachmentBudget — count and aggregate-byte limit ────────── + +test("applyAttachmentBudget: items within count and byte limits pass through unchanged", () => { + const items = [ + { sha256: "a".repeat(64), mime: "image/png", size: 100 }, + { sha256: "b".repeat(64), mime: "image/png", size: 200 }, + ]; + const { shown, truncated } = applyAttachmentBudget(items, 5, 1000); + assert.equal(shown.length, 2); + assert.equal(truncated, 0); +}); + +test("applyAttachmentBudget: excess attachments beyond MAX_COUNT are dropped", () => { + // Build 7 attachments — limit is 5. Excess 2 must not be shown. + // This is the regression Carl required: extra imeta entries on a feedback + // item must NOT result in unbounded fetch fan-out. + const items = Array.from({ length: 7 }, (_, i) => ({ + sha256: String(i).padStart(64, "0"), + mime: "image/png", + size: 100, + })); + const { shown, truncated } = applyAttachmentBudget( + items, + 5, + 50 * 1024 * 1024, + ); + assert.equal( + shown.length, + 5, + "only 5 attachments must be shown when 7 are present", + ); + assert.equal( + truncated, + 2, + "2 excess attachments must be reported as truncated", + ); + // The 6th and 7th items must not appear in shown — verifying the fetch + // fan-out is bounded to the first 5. + assert.ok( + shown.every((a) => Number(a.sha256[0]) < 5), + "shown items must be the first 5 by position", + ); +}); + +test("applyAttachmentBudget: aggregate byte limit drops items that would exceed the ceiling", () => { + // 3 items totalling 30 MiB; cap is 25 MiB. Third item would push us over. + const TEN_MIB = 10 * 1024 * 1024; + const items = [ + { sha256: "a".repeat(64), mime: "image/png", size: TEN_MIB }, + { sha256: "b".repeat(64), mime: "image/png", size: TEN_MIB }, + { sha256: "c".repeat(64), mime: "image/png", size: TEN_MIB }, + ]; + const { shown, truncated } = applyAttachmentBudget( + items, + 5, + 25 * 1024 * 1024, + ); + assert.equal(shown.length, 2, "only 2 items fit within the 25 MiB ceiling"); + assert.equal(truncated, 1); +}); + +test("applyAttachmentBudget: empty list produces empty shown and zero truncated", () => { + const { shown, truncated } = applyAttachmentBudget([], 5, 50 * 1024 * 1024); + assert.equal(shown.length, 0); + assert.equal(truncated, 0); +}); + +// ── P2 round-6 #2: reports-list always calls scope=all ─────────────────── + +test("reports-tab-scope-all: admin_list_reports IPC call includes scope=all", async () => { + // Verifies that the ReportsTab always requests the full workflow queue via + // scope=all, not the relay's escalated-only default (scope omitted). + // + // Mutation evidence: remove `{ scope: "all" }` from the listAdminReports + // call → this test goes RED (captured query has no scope). + + const pubkey = "a9".repeat(32); + const origin = "https://admin-scope.example.com"; + + let capturedQuery = null; + setIpcHandler("admin_list_reports", (args) => { + capturedQuery = args?.query ?? null; + return Promise.resolve([]); + }); + + const { doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + + assert.ok(capturedQuery !== null, "admin_list_reports must have been called"); + assert.equal( + capturedQuery?.scope, + "all", + `reports-tab IPC query must include scope="all"; got: ${JSON.stringify(capturedQuery)}`, + ); + + await unmount(); +}); diff --git a/desktop/src/features/admin-console/adminConsolePanelFeedback.jsdom-test.mjs b/desktop/src/features/admin-console/adminConsolePanelFeedback.jsdom-test.mjs new file mode 100644 index 0000000000..244dc4e199 --- /dev/null +++ b/desktop/src/features/admin-console/adminConsolePanelFeedback.jsdom-test.mjs @@ -0,0 +1,891 @@ +/** + * Feedback tab behavior tests for AdminConsolePanel. Covers feedback detail + * rendering, community grouping, status (honest and read-only), severed + * community, list refetch on back-nav, and canMutate gates. + */ +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; +import { + act, + fireEvent, + setIpcHandler, + resetTestState, + mountPanel, + settle, + CM_ORIGIN, + CM_PUBKEY, + capturedToasts, + capturedErrorToasts, + makeCmFalseFeedback, +} from "./adminConsolePanelTestHelpers.jsdom.mjs"; + +afterEach(resetTestState); + +// ── Feedback fixture builder ────────────────────────────────────────────────── +// +// Builds a matched (summary, detail) pair with explicit overrides for the +// fields that vary across feedback test scenarios. Reload handlers, IDs, +// counters, and per-test assertions stay test-local. + +function makeFeedbackFixtures({ + id = "00000000-0000-0000-0000-000000000099", + communityId = "comm-1", + communityHost = "relay.example.com", + submitterPubkey = "sub-fixture", + category = "bug", + bodySummary = "Fixture feedback summary", + body = "Fixture feedback full body", + status = "new", + eventId = "feedevent-fixture", + eventCreatedAt = "2024-06-01T09:00:00Z", + receivedAt = "2024-06-01T09:00:00Z", +} = {}) { + const summary = { + id, + communityId, + communityHost, + submitterPubkey, + category, + bodySummary, + status, + receivedAt, + }; + const detail = { + id, + communityId, + communityHost, + eventId, + submitterPubkey, + category, + body, + status, + tags: [], + eventCreatedAt, + receivedAt, + }; + return { summary, detail }; +} + +test("feedback-detail-renders-structured-fields: FeedbackDetail shows field layout, not raw JSON", async () => { + // Verifies item 3: the feedback detail view renders data-testid='feedback-detail-fields'. + // Lives here (jsdom) because tab switching and item navigation require fireEvent.click. + // + // Mutation evidence: revert FeedbackFields →
    {JSON.stringify(...)}
    + // → this test goes red ("feedback-detail-fields element must render"). + + const origin = "https://admin.example.com"; + const pubkey = "6".repeat(64); + + // Summary shape returned by GET /admin/feedback (FeedbackSummary wire type). + const feedbackSummary = { + id: "00000000-0000-0000-0000-000000000011", + communityId: "00000000-0000-0000-0000-000000000022", + communityHost: "relay.example.com", + submitterPubkey: "submitter001pubkey", + category: "bug", + bodySummary: "App crashes on startup", + status: "new", + receivedAt: "2024-05-01T09:00:05Z", + }; + + // Full AdminFeedbackDto shape returned by GET /admin/feedback/:id. + const feedbackDetail = { + id: "00000000-0000-0000-0000-000000000011", + communityId: "00000000-0000-0000-0000-000000000022", + communityHost: "relay.example.com", + eventId: "feedevent001", + submitterPubkey: "submitter001pubkey", + category: "bug", + body: "App crashes on startup — full detail body text", + status: "new", + tags: [], + eventCreatedAt: "2024-05-01T09:00:00Z", + receivedAt: "2024-05-01T09:00:05Z", + }; + + setIpcHandler("admin_list_reports", () => Promise.resolve([])); + setIpcHandler("admin_list_feedback", () => + Promise.resolve([feedbackSummary]), + ); + setIpcHandler("admin_get_feedback", () => Promise.resolve(feedbackDetail)); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + + // Click the Feedback tab. + const feedbackTab = container.querySelector( + "[data-testid='admin-tab-feedback']", + ); + assert.ok(feedbackTab, "Feedback tab must be present"); + await act(async () => { + fireEvent.click(feedbackTab); + await new Promise((r) => setTimeout(r, 30)); + }); + + await settle(30); + + // Pre-navigation: list row shows the summary body text (bodySummary rendered). + // Mutation seam: render `body` instead of `bodySummary` → red because summary + // fixture has no `body` field → row title is blank. + const listText = container.textContent ?? ""; + assert.ok( + listText.includes("App crashes on startup"), + `list row must show bodySummary before navigation; got: ${listText.slice(0, 400)}`, + ); + + // Navigate into the feedback detail — click the first non-tab button. + 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)); + }); + break; + } + + await settle(30); + + const fields = container.querySelector( + "[data-testid='feedback-detail-fields']", + ); + assert.ok( + fields !== null, + "feedback-detail-fields element must render — JSON dump not replaced", + ); + + const text = container.textContent ?? ""; + assert.ok( + !text.includes('"body":'), + `raw JSON must not be rendered in feedback detail; got: ${text.slice(0, 400)}`, + ); + + // Real DTO fields must render. + assert.ok( + text.includes("submitter001pubkey"), + `submitterPubkey must render; got: ${text.slice(0, 600)}`, + ); + assert.ok( + text.includes("bug"), + `category must render; got: ${text.slice(0, 600)}`, + ); + assert.ok( + text.includes("App crashes on startup"), + `body must render; got: ${text.slice(0, 600)}`, + ); + + // Fake fields must NOT appear. + assert.ok( + !text.includes("appVersion"), + `invented 'appVersion' field must not render; got: ${text.slice(0, 400)}`, + ); + assert.ok( + !text.includes("authorPubkey"), + `invented 'authorPubkey' field must not render; got: ${text.slice(0, 400)}`, + ); + + // Relative timestamp: formatTimestamp output must match "Xm/h/d ago (...)" shape. + // The fixture receivedAt is far in the past, so it will be "Nd ago (...)". + assert.ok( + /\d+[mhd] ago \(/.test(text) || text.includes("just now ("), + `relative timestamp must render in "Nm/h/d ago (...)" format; got: ${text.slice(0, 600)}`, + ); + + await unmount(); +}); + +// ── NIP-11 auto-discovery ───────────────────────────────────────────────── + +test("feedback-grouped-by-community: multi-community feedback renders per-community headings", async () => { + // Same grouping contract for the Feedback tab. + // + // Mutation evidence: revert FeedbackTab to a flat