From f001ebbb6ea5ad2cf262fcd0f05e1cd2ae83bb7f Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 30 Sep 2026 18:53:13 -0400 Subject: [PATCH] feat(desktop): add Admin Console Actions tab for direct staff actions (#7904) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on #7883 (merged). Adds an **Actions** tab to the Admin Console so relay staff can ban, time out, or delete directly through the #7883 endpoints (`POST /members/{pubkey}/ban`, `POST /members/{pubkey}/timeout`, `POST /events/{id}/delete`), with no report required. ## Design - **Frozen intent.** Review freezes the whole request (origin, relay, signer, community, action, target, reason, duration, `requestId`). Confirm and Retry resend it unchanged, so a manual retry reuses the same `requestId` and stays idempotent on the relay. The intent is cleared only on a definitive non-409 4xx (`preserveRequestIdOnError`). A 409 or a 202 pending keeps it, except `409 request_id_conflict`, which is final for that id and drops the intent so no futile Retry is offered. A 202 pending renders as a neutral notice, not an error. - **Bound to signer and relay.** The intent carries `expectedPubkey` and `expectedRelay`. Native `admin_direct_action` refuses before sending any request if either is blank or no longer matches (`assert_expected_signer` / `assert_expected_relay_scope`). The tab is keyed on pubkey + origin, so an identity or relay switch remounts it and drops any frozen intent. - **Survives tab switches.** `ActionsTab` stays mounted while the panel is open and is only hidden on other tabs, with the same pubkey + origin key, so a pending, ambiguous or in-flight action (and its `requestId`) is still there on return. - **Nothing-sent refusals.** Every native refusal before the first request leaves (malformed host, changed relay or signer, bad target or duration, the key-backup guard on Reason, a missing HTTP client, the first request build, the signing-key lookup) returns `AdminMutationError` with `notSent: true`; the tab drops the intent and offers no Retry. The request build for the retry after a 401 stays ambiguous, because the first request already went out. The changed-relay refusal says "active relay changed since the action was confirmed; nothing was sent". - **Disabled auth.** With `canMutate=false`, Review, Confirm and every field are disabled. - **Community host.** The host is prefilled from the active relay URL by `communityHostFromRelayUrl` (URL host, lowercased, default port and trailing dot stripped, matching the relay's `normalize_host`) and shown read-only as "Community: " with a Change link. It follows the active community until the operator edits it; with no relay URL the input shows directly. Review derives the host from the same `getRelayWsUrl()` value it freezes into `expectedRelay`, so the two can't disagree. The host is validated natively (`validate_community_host`) and sent only as the `communityHost` query, never in the body. The body is `{requestId, reason?, expirationSecs?}`, built by the pure `direct_action_request`; only a timeout takes a duration. - **Member picker.** Ban and timeout pick the member the way `AddMemberDialog` does: `useUserSearchQuery` name search, `parsePubkeyInput` for a pasted npub or hex in any case, and a selected chip. The target is always sent as lowercase hex. Name search is a profile search on the active relay, not a member list, so it runs only when the host is the connected community; for another host the field takes npub or hex only, and the hint stays visible even with a key selected. Editing the host to a different community clears the pick and the search query, so returning to the original host doesn't bring it back; pressing Change alone keeps it. Each named result shows a short npub so same-name profiles can be told apart, the selected chip opens the full key, and the confirm step shows "Name (npub1…abcd)" plus the full npub (`PubKey variant="full"`). A search result's name is frozen for display only; a pasted key uses `useUserProfileQuery` on the active community, otherwise the short npub. Result buttons follow the disabled state. Changing the member or host drops a frozen intent. Delete keeps its hex event id (native `Hex64` route type, shared with pubkeys). A query that starts with `nsec1` or `ncryptsec1` in any case is never searched; the picker shows "That's a secret key. Never paste it here." instead. - **Errors.** `target_is_staff` and `request_id_conflict` get dedicated copy; other codes fall back to the relay message (`adminErrorCode`). An empty 404/405 from a relay without these routes reads "This relay doesn't support direct actions yet."; an uncoded 404/405 with a body keeps its own text. Success shows a toast. - **Key-backup guard.** Every admin JSON mutation body (the three direct routes and the existing report-resolve routes) passes `egress_guard::assert_no_key_backup_bytes` in `helpers.rs` before signing. The egress inventory points at that guarded boundary. - **One signer snapshot.** `admin_direct_action` reads `signing_keys()` once, checks `expectedPubkey` against it, and signs the request and any 401 retry with that same `Keys`. - **Serialized Review.** Review and its inputs lock while the relay URL resolves; a late Review cannot replace a frozen or submitting intent. - **Reason audience.** Before confirmation the tab states that the reason is delivered to the affected user, and for channel deletes posted publicly in the room tombstone (same copy as the Reports tab). Confirmation shows the frozen reason. ## Fix → test → mutation | Fix | Test | Mutation that turns it red | |---|---|---| | Manual retry reuses `requestId` | `actions-retry` | mint a new id per attempt | | Intent preserved across retryable outcomes | `actions-retry`, `actions-errors` | always clear the frozen intent | | Confirmation tied to signer + relay | Rust `direct_action_refuses_a_changed_signer_or_relay` | skip the signer check | | Identity/origin change drops intent | `actions-identity` | drop the tab key | | Disabled auth keeps Submit off | `actions-disabled-auth` | remove the `canMutate` gate | | Key-backup bytes never leave (`ncryptsec1…` / `NCRYPTSEC1…`, zero requests; ordinary reason sends) | Rust `direct_action_refuses_key_backup_reason_before_any_request` | remove the guard call → panics (request sent) | | Checked signer is the signing key | Rust `direct_action_signs_only_with_the_validated_snapshot` (identity swapped after the signer snapshot is taken, before validation) | re-read `signing_keys()` for signing → signed with the swapped key | | Late Review cannot replace the intent | `actions-review-race` (Review, Review, Confirm ambiguous, Retry → one `requestId`) | drop the Review lock | | Pending is a neutral notice | `actions-pending-neutral` | store pending in the error state → `pending notice must render` | | `request_id_conflict` offers no Retry | `actions-request-id-conflict` | keep the intent on every 409 → `request_id_conflict`, `no Retry for a spent request id` | | Reason audience copy + frozen reason | `actions-audience` | drop the audience copy; drop the frozen reason | | Host prefilled read-only, Change reveals the input | `actions-host-prefill` | drop the prefill → `host must be prefilled from the active relay` | | Name result sent as hex and named on confirm | `actions-member-search` | — | | npub and uppercase hex sent as lowercase hex | `actions-member-keys` | drop the direct key result → `no direct result for npub1…` | | Another community turns name search off with a hint | `actions-foreign-host` | — | | A host change clears the pick, even after returning to the first host; hint stays with a key selected | `actions-host-change-drops-member` | hide the pick by host instead of clearing it → `returning to the first host must not restore the old pick` | | Same-name results differ by key; confirm shows the chosen full npub and sends that key | `actions-same-name` | drop the full npub from confirm → `confirm must show the full npub` | | Results can't be selected once auth is disabled | `actions-results-disabled` | — | | Secret key never reaches search, warning shown | `actions-secret-key` | remove the check → `a secret key reached search_users` | | Pending action survives a tab round-trip, Retry reuses `requestId` | `actions-tab-roundtrip-pending` | render the tab conditionally → `pending notice survives` | | Ambiguous failure survives a tab round-trip | `actions-tab-roundtrip-ambiguous` | render conditionally → fails | | In-flight failure on another tab shows on return | `actions-tab-roundtrip-inflight` | render conditionally → fails | | Pre-send refusal drops the intent, no Retry | `actions-not-sent`, Rust `direct_action_marks_a_pre_send_refusal_not_sent` | ignore `notSent` → `intent dropped; no Retry` | | Key-backup refusal is nothing-sent; Review editable again | Rust `direct_action_refuses_key_backup_reason_before_any_request`, `actions-backup-reason-not-sent` | drop `not_sent` on the guard → panics at the `notSent` assert; stub `notSent: false` → `no Retry after a pre-send refusal` | | Only an empty 404/405 reads as an old relay | `actions-old-relay` | drop the empty-body condition → `404 admin API error: Not Found` | --------- Signed-off-by: Will Pfleger Co-authored-by: Duncan --- .../src/commands/admin/direct_action_tests.rs | 206 +++++ desktop/src-tauri/src/commands/admin/error.rs | 17 + .../src-tauri/src/commands/admin/helpers.rs | 27 +- desktop/src-tauri/src/commands/admin/mod.rs | 127 ++- .../src-tauri/src/commands/admin/mod_tests.rs | 7 +- .../src-tauri/src/commands/admin/routes.rs | 63 +- desktop/src-tauri/src/egress_guard.rs | 1 + desktop/src-tauri/src/egress_guard_tests.rs | 9 + desktop/src-tauri/src/lib.rs | 1 + .../admin-console/AdminConsoleActionsTab.tsx | 580 ++++++++++++ .../admin-console/AdminConsolePanel.tsx | 23 +- .../AdminConsolePanelHelpers.tsx | 29 + .../admin-console/AdminConsoleReportsTab.tsx | 2 +- .../adminConsolePanelActions.jsdom-test.mjs | 823 ++++++++++++++++++ .../adminConsolePanelTestHelpers.jsdom.mjs | 8 +- desktop/src/features/admin-console/api.ts | 36 + 16 files changed, 1929 insertions(+), 30 deletions(-) create mode 100644 desktop/src-tauri/src/commands/admin/direct_action_tests.rs create mode 100644 desktop/src/features/admin-console/AdminConsoleActionsTab.tsx create mode 100644 desktop/src/features/admin-console/adminConsolePanelActions.jsdom-test.mjs diff --git a/desktop/src-tauri/src/commands/admin/direct_action_tests.rs b/desktop/src-tauri/src/commands/admin/direct_action_tests.rs new file mode 100644 index 0000000000..d9f18af18c --- /dev/null +++ b/desktop/src-tauri/src/commands/admin/direct_action_tests.rs @@ -0,0 +1,206 @@ +//! Direct moderation action tests (a child of `tests`, split for the size ratchet). + +use super::*; + +const SIGNER: &str = "11111111111111111111111111111111111111111111111111111111111111aa"; + +fn intent(action: DirectAction, expiration_secs: Option) -> AdminDirectIntent { + AdminDirectIntent { + origin: "https://admin.example.com".to_string(), + expected_relay: "wss://relay.example.com".to_string(), + expected_pubkey: SIGNER.to_string(), + community_host: "Team.Example.com".to_string(), + action, + target: "ab".repeat(32), + request_id: uuid::Uuid::nil(), + reason: Some("spam".to_string()), + expiration_secs, + } +} + +const BASE: &str = "https://relay.example.com"; + +#[test] +fn direct_action_sends_the_typed_host_only_as_the_query() { + let (url, body) = + direct_action_request(&intent(DirectAction::Timeout, Some(60)), BASE, SIGNER).unwrap(); + let t = "ab".repeat(32); + assert!( + url.ends_with(&format!( + "/api/admin/v1/members/{t}/timeout?communityHost=team.example.com" + )), + "{url}" + ); + let body: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!( + body, + serde_json::json!({ + "requestId": uuid::Uuid::nil(), + "reason": "spam", + "expirationSecs": 60, + }) + ); + let (url, body) = + direct_action_request(&intent(DirectAction::Delete, None), BASE, SIGNER).unwrap(); + assert!(url.contains(&format!("/events/{t}/delete?")), "{url}"); + assert!(!String::from_utf8(body).unwrap().contains("expirationSecs")); +} + +#[test] +fn direct_action_refuses_a_changed_signer_or_relay() { + // Confirmed under SIGNER on relay.example.com; each mismatch fails pre-send. + let other = "22".repeat(32); + for (signer, base) in [ + (other.as_str(), BASE), + (SIGNER, "https://relay-b.example.com"), + ] { + assert!(direct_action_request(&intent(DirectAction::Ban, None), base, signer).is_err()); + } + let mut blank = intent(DirectAction::Ban, None); + blank.expected_pubkey = " ".to_string(); + assert!(direct_action_request(&blank, BASE, SIGNER).is_err()); +} + +#[test] +fn direct_action_rejects_bad_host_target_and_duration() { + let mut bad_host = intent(DirectAction::Ban, None); + bad_host.community_host = "https://team.example.com/x".to_string(); + let mut bad_target = intent(DirectAction::Ban, None); + bad_target.target = "AB".repeat(32); + for bad in [ + bad_host, + bad_target, + intent(DirectAction::Timeout, None), + intent(DirectAction::Timeout, Some(0)), + intent(DirectAction::Ban, Some(60)), + ] { + assert!( + direct_action_request(&bad, BASE, SIGNER).is_err(), + "{bad:?}" + ); + } +} + +/// AppState signing as `keys` on relay.example.com, plus a direct intent +/// confirmed under `keys` that targets the loopback admin origin at `addr`. +fn direct_state_and_intent( + keys: &nostr::Keys, + addr: std::net::SocketAddr, + reason: &str, +) -> (crate::app_state::AppState, AdminDirectIntent) { + let state = crate::app_state::build_app_state(); + *state.keys.lock().unwrap() = keys.clone(); + *state.relay_url_override.lock().unwrap() = Some("wss://relay.example.com".to_string()); + let mut intent = intent(DirectAction::Ban, None); + intent.origin = format!("http://127.0.0.1:{}", addr.port()); + intent.expected_pubkey = keys.public_key().to_hex(); + intent.reason = Some(reason.to_string()); + (state, intent) +} + +/// Mutation evidence: removing the guard call in `send_admin_mutation` lets +/// both key-backup reasons reach the listener and flips the count RED. +#[tokio::test] +async fn direct_action_refuses_key_backup_reason_before_any_request() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let hits = Arc::new(AtomicUsize::new(0)); + let seen = Arc::clone(&hits); + let addr = serve_sequence_inspect( + vec![( + "200 OK", + "Content-Type: application/json\r\n", + r#"{"state":"applied"}"#, + )], + Some(Arc::new(move |_, _| { + seen.fetch_add(1, Ordering::SeqCst); + })), + ) + .await; + let keys = nostr::Keys::generate(); + for reason in ["see ncryptsec1qgg9947", "see NCRYPTSEC1QGG9947"] { + let (state, intent) = direct_state_and_intent(&keys, addr, reason); + let err = send_direct_action(&intent, keys.clone(), &state) + .await + .unwrap_err(); + assert!(format!("{err:?}").contains("key-backup"), "{err:?}"); + assert!( + err.not_sent, + "a pre-send key-backup refusal must be notSent" + ); + } + assert_eq!( + hits.load(Ordering::SeqCst), + 0, + "a key backup reached the relay" + ); + let (state, intent) = direct_state_and_intent(&keys, addr, "spam"); + send_direct_action(&intent, keys.clone(), &state) + .await + .expect("an ordinary reason still sends"); + assert_eq!(hits.load(Ordering::SeqCst), 1); +} + +/// The identity is swapped to B after the snapshot is taken; the first send +/// and the 401 retry must both still be signed by the confirmed key A. +/// +/// Mutation evidence: signing with a re-read `state.signing_keys()` inside +/// `send_direct_action` (the pre-fix shape) signs as B and flips this RED. +#[tokio::test] +async fn direct_action_signs_only_with_the_validated_snapshot() { + use std::sync::Mutex; + let auths: Arc>> = Arc::new(Mutex::new(Vec::new())); + let rec = Arc::clone(&auths); + let addr = serve_sequence_inspect( + vec![ + ("401 Unauthorized", "WWW-Authenticate: Nostr\r\n", ""), + ( + "200 OK", + "Content-Type: application/json\r\n", + r#"{"state":"applied"}"#, + ), + ], + Some(Arc::new(move |_, bytes: &[u8]| { + let text = String::from_utf8_lossy(bytes); + let auth = text + .lines() + .find_map(|l| { + l.strip_prefix("authorization: ") + .or(l.strip_prefix("Authorization: ")) + }) + .expect("NIP-98 header present"); + let pubkey = decode_nip98_event(auth.trim())["pubkey"] + .as_str() + .unwrap() + .to_string(); + rec.lock().unwrap().push(pubkey); + })), + ) + .await; + let a = nostr::Keys::generate(); + let (state, intent) = direct_state_and_intent(&a, addr, "spam"); + let snapshot = state.signing_keys().unwrap(); + *state.keys.lock().unwrap() = nostr::Keys::generate(); // identity import of B + send_direct_action(&intent, snapshot, &state) + .await + .expect("A-confirmed action sends as A"); + assert_eq!(*auths.lock().unwrap(), vec![a.public_key().to_hex(); 2]); +} + +/// A refusal before any request is typed `notSent`, so the UI can drop the +/// intent instead of offering a Retry that can never succeed. +/// +/// Mutation evidence: mapping the refusal through `AdminMutationError::from` +/// (plain `?`) leaves `not_sent` false and flips this RED. +#[tokio::test] +async fn direct_action_marks_a_pre_send_refusal_not_sent() { + let keys = nostr::Keys::generate(); + let addr = serve_sequence_inspect(vec![], None).await; + let (state, mut intent) = direct_state_and_intent(&keys, addr, "spam"); + intent.community_host = "team.example.com/".to_string(); + let err = send_direct_action(&intent, keys, &state).await.unwrap_err(); + assert!(err.not_sent, "{err:?}"); + assert_eq!( + serde_json::to_value(&err).unwrap()["notSent"], + serde_json::json!(true) + ); +} diff --git a/desktop/src-tauri/src/commands/admin/error.rs b/desktop/src-tauri/src/commands/admin/error.rs index 75e3f3a370..880fdef32a 100644 --- a/desktop/src-tauri/src/commands/admin/error.rs +++ b/desktop/src-tauri/src/commands/admin/error.rs @@ -17,6 +17,10 @@ /// the size cap) carries `bodyComplete: false`: the outcome is unknown, so the /// caller preserves idempotency and lets the retry dedupe even on a 4xx. /// +/// `notSent` is `true` only when the desktop refused the request before sending +/// it (a malformed intent, or the relay or signer changed): nothing reached the +/// relay, so retrying the same intent can never succeed. +/// /// Serialises `rename_all = "camelCase"`; the JS bridge surfaces it as the /// rejected `TauriInvokeError.payload`, from which the UI reads `relayStatus` /// and `bodyComplete`. @@ -32,9 +36,19 @@ pub struct AdminMutationError { /// Whether the relay's full response body was read. `true` only for an /// authoritative verdict; `false` when the body was lost or truncated. pub body_complete: bool, + /// Refused before sending; see the type docs. + pub not_sent: bool, } impl AdminMutationError { + /// Refused before any request was sent; retrying the same intent is futile. + pub(super) fn not_sent(message: String) -> Self { + Self { + not_sent: true, + ..Self::from(message) + } + } + /// The relay answered with an HTTP status and its full body was read — an /// authoritative verdict. pub(super) fn authoritative(status: reqwest::StatusCode, message: String) -> Self { @@ -42,6 +56,7 @@ impl AdminMutationError { message, relay_status: Some(status.as_u16()), body_complete: true, + not_sent: false, } } @@ -52,6 +67,7 @@ impl AdminMutationError { message, relay_status: Some(status.as_u16()), body_complete: false, + not_sent: false, } } } @@ -64,6 +80,7 @@ impl From for AdminMutationError { message, relay_status: None, body_complete: false, + not_sent: false, } } } diff --git a/desktop/src-tauri/src/commands/admin/helpers.rs b/desktop/src-tauri/src/commands/admin/helpers.rs index 079dcaba17..1bc64458e1 100644 --- a/desktop/src-tauri/src/commands/admin/helpers.rs +++ b/desktop/src-tauri/src/commands/admin/helpers.rs @@ -109,11 +109,32 @@ pub(super) async fn mutation_admin_json( state: &tauri::State<'_, crate::app_state::AppState>, ) -> Result, AdminMutationError> { let keys = state.signing_keys()?; + send_admin_mutation(&keys, method, url, body, cap).await +} + +/// State-free core of [`mutation_admin_json`]: every request, including the +/// 401 retry, is signed by the caller's `keys` snapshot and never re-reads the +/// active identity. The body passes the key-backup egress guard before any +/// request is built, so free-text admin fields cannot carry an `ncryptsec`. +pub(super) async fn send_admin_mutation( + keys: &nostr::Keys, + method: reqwest::Method, + url: &str, + body: Option<&[u8]>, + cap: u64, +) -> Result, AdminMutationError> { + // Every refusal before the first `.send()` is `not_sent`: nothing reached + // the relay, so resending the same intent can never succeed. + if let Some(bytes) = body { + crate::egress_guard::assert_no_key_backup_bytes(bytes, "admin API mutation") + .map_err(AdminMutationError::not_sent)?; + } let http_client = client::ADMIN_CLIENT .get() - .ok_or_else(|| "admin client not initialised".to_string())?; + .ok_or_else(|| AdminMutationError::not_sent("admin client not initialised".to_string()))?; - let resp = build_admin_mutation_request(http_client, &keys, &method, url, body)? + let resp = build_admin_mutation_request(http_client, keys, &method, url, body) + .map_err(AdminMutationError::not_sent)? .send() .await .map_err(|e| crate::relay::classify_request_error(&e))?; @@ -121,7 +142,7 @@ pub(super) async fn mutation_admin_json( if resp.status() == reqwest::StatusCode::UNAUTHORIZED { // Retry once with a freshly signed request — each build mints a new // NIP-98 nonce, so this is a distinct event, not a replay of the first. - let resp2 = build_admin_mutation_request(http_client, &keys, &method, url, body)? + let resp2 = build_admin_mutation_request(http_client, keys, &method, url, body)? .send() .await .map_err(|e| crate::relay::classify_request_error(&e))?; diff --git a/desktop/src-tauri/src/commands/admin/mod.rs b/desktop/src-tauri/src/commands/admin/mod.rs index 2f7b591d4b..d63b777d56 100644 --- a/desktop/src-tauri/src/commands/admin/mod.rs +++ b/desktop/src-tauri/src/commands/admin/mod.rs @@ -655,7 +655,7 @@ pub async fn admin_put_operator( ) -> Result { let origin = origin::AdminOrigin::parse(&origin)?; let pubkey = - routes::HexPubkey::parse(&pubkey).map_err(|e| format!("invalid operator pubkey: {e}"))?; + routes::Hex64::parse(&pubkey).map_err(|e| format!("invalid operator pubkey: {e}"))?; let url = origin.route_url( &routes::AdminRoute::OperatorPut { pubkey }, &routes::AdminQuery::default(), @@ -677,7 +677,7 @@ pub async fn admin_delete_operator( ) -> Result { let origin = origin::AdminOrigin::parse(&origin)?; let pubkey = - routes::HexPubkey::parse(&pubkey).map_err(|e| format!("invalid operator pubkey: {e}"))?; + routes::Hex64::parse(&pubkey).map_err(|e| format!("invalid operator pubkey: {e}"))?; let url = origin.route_url( &routes::AdminRoute::OperatorDelete { pubkey }, &routes::AdminQuery::default(), @@ -825,7 +825,7 @@ pub async fn admin_lift_ban( state: tauri::State<'_, crate::app_state::AppState>, ) -> Result<(), AdminMutationError> { let pubkey = - routes::HexPubkey::parse(&pubkey).map_err(|e| format!("invalid member pubkey: {e}"))?; + routes::Hex64::parse(&pubkey).map_err(|e| format!("invalid member pubkey: {e}"))?; let url = restrictions_url( &origin, &routes::AdminRoute::MemberBanDelete { pubkey }, @@ -850,7 +850,7 @@ pub async fn admin_lift_timeout( state: tauri::State<'_, crate::app_state::AppState>, ) -> Result<(), AdminMutationError> { let pubkey = - routes::HexPubkey::parse(&pubkey).map_err(|e| format!("invalid member pubkey: {e}"))?; + routes::Hex64::parse(&pubkey).map_err(|e| format!("invalid member pubkey: {e}"))?; let url = restrictions_url( &origin, &routes::AdminRoute::MemberTimeoutDelete { pubkey }, @@ -862,6 +862,125 @@ pub async fn admin_lift_timeout( Ok(()) } +// ── Direct moderation actions ───────────────────────────────────────────── + +/// A direct ban, timeout, or delete as the confirm dialog froze it. The same +/// intent (including `request_id`) is resent verbatim on every retry; only the +/// NIP-98 signature is minted fresh per attempt. +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminDirectIntent { + pub origin: String, + /// Relay the intent was confirmed against. + pub expected_relay: String, + /// Signer the intent was confirmed under. + pub expected_pubkey: String, + /// Typed community host; sent only as the `communityHost` query parameter. + pub community_host: String, + pub action: DirectAction, + /// Member pubkey for ban/timeout, event id for delete (64 lowercase hex). + pub target: String, + pub request_id: uuid::Uuid, + pub reason: Option, + pub expiration_secs: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DirectAction { + Ban, + Timeout, + Delete, +} + +/// Build the URL and body for a direct action, refusing it before any request +/// when the active relay or signer no longer matches what was confirmed. +fn direct_action_request( + intent: &AdminDirectIntent, + relay_base: &str, + signer_hex: &str, +) -> Result<(String, Vec), String> { + let origin = origin::AdminOrigin::parse(&intent.origin)?; + if intent.expected_relay.trim().is_empty() + || crate::relay::assert_expected_relay_scope(Some(&intent.expected_relay), relay_base) + .is_err() + { + return Err( + "active relay changed since the action was confirmed; nothing was sent".to_string(), + ); + } + if intent.expected_pubkey.trim().is_empty() + || crate::relay::assert_expected_signer(Some(&intent.expected_pubkey), signer_hex).is_err() + { + return Err( + "active identity changed since the action was confirmed; nothing was sent".to_string(), + ); + } + let host = buzz_core_pkg::tenant::validate_community_host(intent.community_host.trim()) + .map_err(|e| format!("invalid community host: {e}"))?; + let target = + routes::Hex64::parse(&intent.target).map_err(|e| format!("invalid target: {e}"))?; + let route = match intent.action { + DirectAction::Ban => routes::AdminRoute::MemberBan { pubkey: target }, + DirectAction::Timeout => routes::AdminRoute::MemberTimeout { pubkey: target }, + DirectAction::Delete => routes::AdminRoute::EventDelete { id: target }, + }; + let expiration_secs = match (intent.action, intent.expiration_secs) { + (DirectAction::Timeout, Some(secs)) if secs > 0 => Some(secs), + (DirectAction::Timeout, _) => return Err("timeout needs a positive duration".to_string()), + (_, Some(_)) => return Err("only a timeout takes a duration".to_string()), + (_, None) => None, + }; + let mut body = serde_json::json!({ "requestId": intent.request_id }); + if let Some(reason) = &intent.reason { + body["reason"] = reason.clone().into(); + } + if let Some(secs) = expiration_secs { + body["expirationSecs"] = secs.into(); + } + let q = routes::AdminQuery { + community_host: Some(host), + ..Default::default() + }; + let body = serde_json::to_vec(&body).map_err(|e| format!("failed to serialise body: {e}"))?; + Ok((origin.route_url(&route, &q), body)) +} + +/// Ban, time out, or delete without a report — POST +/// /api/admin/v1/{members/{pubkey}/ban|members/{pubkey}/timeout|events/{id}/delete}?communityHost=. +/// +/// Returns the relay's `{actionId, state, replayed}` (200) or `{state:"pending"}` (202). +#[tauri::command] +pub async fn admin_direct_action( + intent: AdminDirectIntent, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let keys = state.signing_keys().map_err(AdminMutationError::not_sent)?; + send_direct_action(&intent, keys, &state).await +} + +/// Validate `intent` against the `keys` snapshot and sign every request with +/// that same snapshot, so an identity swapped in after the snapshot can never +/// sign an action confirmed under the previous one. +async fn send_direct_action( + intent: &AdminDirectIntent, + keys: nostr::Keys, + state: &crate::app_state::AppState, +) -> Result { + let relay_base = crate::relay::relay_api_base_url_with_override(state); + let (url, body) = direct_action_request(intent, &relay_base, &keys.public_key().to_hex()) + .map_err(AdminMutationError::not_sent)?; + let bytes = helpers::send_admin_mutation( + &keys, + reqwest::Method::POST, + &url, + Some(&body), + SUCCESS_JSON_CAP, + ) + .await?; + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}").into()) +} + // ── Origin storage commands ─────────────────────────────────────────────── /// Core storage logic for `get_admin_origin`, parameterised by data directory diff --git a/desktop/src-tauri/src/commands/admin/mod_tests.rs b/desktop/src-tauri/src/commands/admin/mod_tests.rs index d6dd3df706..3e5c86c374 100644 --- a/desktop/src-tauri/src/commands/admin/mod_tests.rs +++ b/desktop/src-tauri/src/commands/admin/mod_tests.rs @@ -1319,7 +1319,7 @@ async fn save_rejects_an_unrelated_content_type() { #[test] fn restrictions_url_names_the_active_relay_authority() { let state = relay_state("wss://Community.Example.com:8443/ws"); - let pubkey = routes::HexPubkey::parse(&"ab".repeat(32)).unwrap(); + let pubkey = routes::Hex64::parse(&"ab".repeat(32)).unwrap(); let url = restrictions_url( "https://admin.example.com", &routes::AdminRoute::MemberBanDelete { pubkey }, @@ -1372,7 +1372,7 @@ fn restrictions_url_rejects_a_caller_relay_that_no_longer_matches() { // The list loaded from relay A; the native relay has since switched to B. // Every restriction route must fail before building a request URL. let state = relay_state("wss://relay-b.example.com"); - let pubkey = routes::HexPubkey::parse(&"ab".repeat(32)).unwrap(); + let pubkey = routes::Hex64::parse(&"ab".repeat(32)).unwrap(); for route in [ routes::AdminRoute::MemberRestrictionsList, routes::AdminRoute::MemberBanDelete { @@ -1387,3 +1387,6 @@ fn restrictions_url_rejects_a_caller_relay_that_no_longer_matches() { } } } + +#[path = "direct_action_tests.rs"] +mod direct_action; diff --git a/desktop/src-tauri/src/commands/admin/routes.rs b/desktop/src-tauri/src/commands/admin/routes.rs index e61a827ebf..30e2ef8ba7 100644 --- a/desktop/src-tauri/src/commands/admin/routes.rs +++ b/desktop/src-tauri/src/commands/admin/routes.rs @@ -72,40 +72,54 @@ pub enum AdminRoute { }, OperatorsList, OperatorPut { - pubkey: HexPubkey, + pubkey: Hex64, }, OperatorDelete { - pubkey: HexPubkey, + pubkey: Hex64, }, /// GET /members/restrictions — communityHost in query. MemberRestrictionsList, /// DELETE /members/{pubkey}/ban — communityHost in query. MemberBanDelete { - pubkey: HexPubkey, + pubkey: Hex64, }, /// DELETE /members/{pubkey}/timeout — communityHost in query. MemberTimeoutDelete { - pubkey: HexPubkey, + pubkey: Hex64, + }, + /// POST /members/{pubkey}/ban — communityHost in query. + MemberBan { + pubkey: Hex64, + }, + /// POST /members/{pubkey}/timeout — communityHost in query. + MemberTimeout { + pubkey: Hex64, + }, + /// POST /events/{id}/delete — communityHost in query. An event id has the + /// same 64 lowercase-hex grammar as a pubkey. + EventDelete { + id: Hex64, }, } -/// A validated 64 lowercase-hex character pubkey for use as a URL path segment. +/// A validated 64 lowercase-hex id (a pubkey or an event id) for use as a URL +/// path segment. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct HexPubkey(String); +pub struct Hex64(String); -impl HexPubkey { - /// Parse `raw` as a 64-character lowercase hex pubkey. +impl Hex64 { + /// Parse `raw` as a 64-character lowercase hex id. pub fn parse(raw: &str) -> Result { if raw.len() != 64 { return Err(format!( - "pubkey must be exactly 64 hex characters; got {}", + "id must be exactly 64 hex characters; got {}", raw.len() )); } if !raw.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) { - return Err("pubkey must be lowercase hex only (0-9, a-f)".to_string()); + return Err("id must be lowercase hex only (0-9, a-f)".to_string()); } - Ok(HexPubkey(raw.to_string())) + Ok(Hex64(raw.to_string())) } pub fn as_str(&self) -> &str { @@ -139,6 +153,11 @@ impl AdminRoute { AdminRoute::MemberTimeoutDelete { pubkey } => { format!("/members/{}/timeout", pubkey.as_str()) } + AdminRoute::MemberBan { pubkey } => format!("/members/{}/ban", pubkey.as_str()), + AdminRoute::MemberTimeout { pubkey } => { + format!("/members/{}/timeout", pubkey.as_str()) + } + AdminRoute::EventDelete { id } => format!("/events/{}/delete", id.as_str()), } } } @@ -428,7 +447,7 @@ mod tests { #[test] fn member_ban_delete_path() { - let pubkey = HexPubkey::parse(&"ab".repeat(32)).unwrap(); + let pubkey = Hex64::parse(&"ab".repeat(32)).unwrap(); assert_eq!( AdminRoute::MemberBanDelete { pubkey }.path(), format!("/members/{}/ban", "ab".repeat(32)) @@ -437,10 +456,28 @@ mod tests { #[test] fn member_timeout_delete_path() { - let pubkey = HexPubkey::parse(&"cd".repeat(32)).unwrap(); + let pubkey = Hex64::parse(&"cd".repeat(32)).unwrap(); assert_eq!( AdminRoute::MemberTimeoutDelete { pubkey }.path(), format!("/members/{}/timeout", "cd".repeat(32)) ); } + + #[test] + fn direct_action_paths() { + let hex = || Hex64::parse(&"ef".repeat(32)).unwrap(); + let h = "ef".repeat(32); + assert_eq!( + AdminRoute::MemberBan { pubkey: hex() }.path(), + format!("/members/{h}/ban") + ); + assert_eq!( + AdminRoute::MemberTimeout { pubkey: hex() }.path(), + format!("/members/{h}/timeout") + ); + assert_eq!( + AdminRoute::EventDelete { id: hex() }.path(), + format!("/events/{h}/delete") + ); + } } diff --git a/desktop/src-tauri/src/egress_guard.rs b/desktop/src-tauri/src/egress_guard.rs index db58ddafa0..1d990477f6 100644 --- a/desktop/src-tauri/src/egress_guard.rs +++ b/desktop/src-tauri/src/egress_guard.rs @@ -14,6 +14,7 @@ //! | 6 | `submit_engram_event` (team snapshot) | `commands/team_snapshot.rs` | //! | 7 | `submit_engram_event` (persona import) | `commands/personas/snapshot/import.rs` | //! | 8 | native websocket send loop (all webview relay WS) | `native_websocket.rs` | +//! | 9 | `send_admin_mutation` (all admin API JSON bodies) | `commands/admin/helpers.rs` | //! //! The inventory-completeness test in `egress_guard_tests.rs` asserts that //! every `/events` URL-construction site in the tree calls this guard, so a diff --git a/desktop/src-tauri/src/egress_guard_tests.rs b/desktop/src-tauri/src/egress_guard_tests.rs index b6035d2ca3..4e2c5c4132 100644 --- a/desktop/src-tauri/src/egress_guard_tests.rs +++ b/desktop/src-tauri/src/egress_guard_tests.rs @@ -293,6 +293,13 @@ const EVENTS_INVENTORY: &[(&str, usize, usize)] = &[ // Stub-relay route in the tombstone-flush gate tests; production flush // publishes through the guarded boundary-1 funnel. ("src/commands/teams/pending/tests/gate.rs", 1, 0), + // Admin API mutations: every JSON body passes the guard in + // `helpers::send_admin_mutation` (injection tests in admin `direct_action_tests.rs`). + // The `/events/{id}/delete` route + its path test and the native delete + // test only construct URLs that flow into that guarded boundary. + ("src/commands/admin/helpers.rs", 0, 1), + ("src/commands/admin/routes.rs", 2, 0), + ("src/commands/admin/direct_action_tests.rs", 1, 0), ]; // Needles are assembled at runtime so this scan file itself contains no @@ -457,6 +464,8 @@ fn ncryptsec_handling_is_confined_to_allowlisted_files() { "src/commands/team_snapshot/tests.rs", "src/commands/personas/snapshot/import.rs", "src/native_websocket.rs", + "src/commands/admin/helpers.rs", + "src/commands/admin/direct_action_tests.rs", ]; let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index a37d9c536a..bd062960e0 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -898,6 +898,7 @@ pub fn run() { admin_list_restrictions, admin_lift_ban, admin_lift_timeout, + admin_direct_action, get_admin_origin, set_admin_origin, admin_discover_origin, diff --git a/desktop/src/features/admin-console/AdminConsoleActionsTab.tsx b/desktop/src/features/admin-console/AdminConsoleActionsTab.tsx new file mode 100644 index 0000000000..4bd32d1883 --- /dev/null +++ b/desktop/src/features/admin-console/AdminConsoleActionsTab.tsx @@ -0,0 +1,580 @@ +/** + * Actions tab — ban, time out, or delete without a report. + * + * Review freezes the whole intent (origin, relay, signer, community, verb, + * target, reason, duration, requestId). Confirm and every retry resend that + * frozen intent, so the relay dedupes on the same requestId. Review and + * Confirm share one in-flight lock, so a late Review can never replace an + * intent that is frozen or being sent. The native layer + * mints a fresh NIP-98 signature per attempt and refuses the send if the + * active relay or signer moved. The panel remounts this tab on an identity or + * origin change, which drops any frozen intent. + * + * The community defaults to the active relay's host (read-only, with a + * Change escape hatch), and members are picked by name on that relay or by a + * pasted npub/hex key anywhere. + */ + +import { useDeferredValue, useRef, useState } from "react"; +import { toast } from "sonner"; +import { classifyKeyImportInput } from "@/features/onboarding/lib/keyImportInput"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { + useUserProfileQuery, + useUserSearchQuery, +} from "@/features/profile/hooks"; +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { SelectedRecipientChip } from "@/features/profile/ui/SelectedRecipientChip"; +import { getRelayWsUrl } from "@/shared/api/tauri"; +import type { UserSearchResult } from "@/shared/api/types"; +import { parsePubkeyInput } from "@/shared/lib/nostrUtils"; +import { truncateNpub } from "@/shared/lib/pubkey"; +import { Button } from "@/shared/ui/button"; +import { PubKey } from "@/shared/ui/PubKey"; +import { + directAdminAction, + type AdminDirectAction, + type AdminDirectIntent, +} from "./api"; +import { + adminErrorCode, + adminErrorMessage, + adminMutationBodyComplete, + adminMutationNotSent, + adminMutationRelayStatus, + preserveRequestIdOnError, + useAsyncLoad, +} from "./AdminConsolePanelHelpers"; +import { reasonAudienceCopy } from "./AdminConsoleReportsTab"; + +const HEX64 = /^[0-9a-f]{64}$/; + +const ACTION_LABELS: Record = { + ban: "Ban member", + timeout: "Time out member", + delete: "Delete message", +}; + +function directErrorMessage(e: unknown): string { + switch (adminErrorCode(e)) { + case "target_is_staff": + return "Relay staff can't be banned or timed out. Remove their staff role first."; + case "request_id_conflict": + return "This request id was already used for a different action. Review again to send it with a new id."; + case null: { + // A relay from before direct actions answers these routes with an empty + // 404/405; any other uncoded body keeps its own text. + const status = adminMutationRelayStatus(e); + if ( + (status === 404 || status === 405) && + adminMutationBodyComplete(e) && + adminErrorMessage(e).trim() === "admin API error:" + ) { + return "This relay doesn't support direct actions yet."; + } + return adminErrorMessage(e); + } + default: + return adminErrorMessage(e); + } +} + +/** + * Normalize a community host the way the relay's `normalize_host` does: + * lowercase, no default port, no trailing root dot. + */ +function normalizeCommunityHost(raw: string): string { + return raw + .trim() + .toLowerCase() + .replace(/:(443|80)$/, "") + .replace(/\.$/, ""); +} + +/** + * The community a relay URL serves is its `Host`: the URL's authority, with + * the default port already dropped for `ws`/`wss`. Null when unparseable. + */ +export function communityHostFromRelayUrl(relayUrl: string): string | null { + try { + return normalizeCommunityHost(new URL(relayUrl).host) || null; + } catch { + return null; + } +} + +/** An `nsec` or NIP-49 backup, in either case. */ +function isSecretKey(input: string): boolean { + return classifyKeyImportInput(input.toLowerCase()) !== "unknown"; +} + +function memberLabel(user: UserSearchResult): string { + return ( + user.displayName?.trim() || + user.nip05Handle?.trim() || + truncateNpub(user.pubkey) + ); +} + +/** Client-side checks; the relay re-validates everything. */ +function validate( + action: AdminDirectAction, + host: string, + target: string, + member: UserSearchResult | null, + secs: string, +): string | null { + if (!host.trim()) return "Enter the community host."; + if (action === "delete" && !HEX64.test(target.trim())) { + return "Event id must be 64 lowercase hex characters."; + } + if (action !== "delete" && !member) { + return "Choose a member: search by name, or paste an npub or hex key."; + } + if ( + action === "timeout" && + !(Number.isInteger(Number(secs)) && Number(secs) > 0) + ) { + return "Duration must be a whole number of seconds above zero."; + } + return null; +} + +export function ActionsTab({ + canMutate, + origin, + pubkey, +}: { + canMutate: boolean; + origin: string; + /** Active signer; frozen into the intent at review. */ + pubkey: string; +}) { + const [action, setAction] = useState("ban"); + const { activeCommunity } = useCommunities(); + /** Operator-typed host; null while following the active community. */ + const [hostOverride, setHostOverride] = useState(null); + const [target, setTarget] = useState(""); + const [member, setMember] = useState(null); + /** Display-only name for the frozen target; never sent. */ + const [frozenName, setFrozenName] = useState(null); + const [reason, setReason] = useState(""); + const [secs, setSecs] = useState(""); + const [error, setError] = useState(null); + const [pending, setPending] = useState(false); + const [frozen, setFrozen] = useState(null); + const [submitting, setSubmitting] = useState(false); + const inFlight = useRef(false); + + // Host of the active relay: the default target community. Reloads when the + // desktop switches communities. + const activeHostState = useAsyncLoad( + async () => communityHostFromRelayUrl(await getRelayWsUrl()), + [activeCommunity?.relayUrl ?? ""], + 0, + ); + const activeHost = + activeHostState.status === "ok" ? activeHostState.data : null; + + const editingHost = hostOverride !== null || !activeHost; + const host = hostOverride ?? activeHost ?? ""; + // Name search queries the active relay, so it only finds members there. + const normalizedHost = normalizeCommunityHost(host); + const onActiveCommunity = + activeHost !== null && normalizedHost === activeHost; + // Profiles are per community: a new host drops the picked member. + const editHost = (next: string) => { + if (normalizeCommunityHost(next) !== normalizedHost) setMember(null); + setHostOverride(next); + }; + + const handleReview = async () => { + if (frozen || inFlight.current) return; + const invalid = validate(action, host, target, member, secs); + setError(invalid); + if (invalid) return; + inFlight.current = true; + setSubmitting(true); + try { + const expectedRelay = await getRelayWsUrl(); + // Following the active community: derive the host from the very relay + // URL being frozen, so the two can never disagree. + const communityHost = + hostOverride?.trim() ?? communityHostFromRelayUrl(expectedRelay); + if (!communityHost) { + setError("Enter the community host."); + return; + } + const isMember = action !== "delete" && member; + setFrozenName( + isMember && (member.displayName || member.nip05Handle) + ? memberLabel(member) + : null, + ); + setFrozen({ + origin, + expectedRelay, + expectedPubkey: pubkey, + communityHost, + action, + target: isMember ? member.pubkey : target.trim(), + requestId: crypto.randomUUID(), + reason: reason.trim() || undefined, + expirationSecs: action === "timeout" ? Number(secs) : undefined, + }); + } catch (e) { + setError(adminErrorMessage(e)); + } finally { + inFlight.current = false; + setSubmitting(false); + } + }; + + const handleConfirm = async () => { + if (!frozen || inFlight.current) return; + inFlight.current = true; + setSubmitting(true); + setError(null); + setPending(false); + try { + const result = await directAdminAction(frozen); + if (result.state === "pending") { + setPending(true); + } else { + toast.success(`${ACTION_LABELS[frozen.action]}: done`); + setFrozen(null); + setTarget(""); + setMember(null); + setReason(""); + } + } catch (e) { + // Keep the frozen intent (same requestId) unless the relay definitively + // rejected it before committing. A request-id conflict is final for + // this id, and a refusal before sending (bad host, relay or signer + // moved) repeats on every resend, so neither can ever succeed. + if ( + !preserveRequestIdOnError(e) || + adminMutationNotSent(e) || + adminErrorCode(e) === "request_id_conflict" + ) { + setFrozen(null); + } + setError(directErrorMessage(e)); + } finally { + inFlight.current = false; + setSubmitting(false); + } + }; + + const locked = frozen !== null || submitting || !canMutate; + // A pasted key has no name yet; look it up where search would find it. + const confirmProfile = useUserProfileQuery( + frozen && + frozen.action !== "delete" && + !frozenName && + frozen.communityHost === activeHost + ? frozen.target + : undefined, + ); + const confirmName = + frozenName ?? confirmProfile.data?.displayName?.trim() ?? null; + const input = ( + name: string, + value: string, + set: (v: string) => void, + placeholder: string, + extra = "", + type = "text", + ) => ( + set(e.target.value)} + placeholder={placeholder} + type={type} + value={value} + /> + ); + + return ( +
+
+ {(Object.keys(ACTION_LABELS) as AdminDirectAction[]).map((a) => ( + + ))} +
+ {editingHost ? ( + input("host", host, editHost, "Community host (e.g. team.example.com)") + ) : ( +

+ + Community: {host} + + +

+ )} + {action === "delete" ? ( + input("target", target, setTarget, "Event id (hex)", "font-mono") + ) : ( + + )} + {action === "timeout" && + input("duration", secs, setSecs, "Duration (seconds)", "", "number")} + {input("reason", reason, setReason, "Reason (optional)")} +

+ {reasonAudienceCopy(frozen?.action ?? action)} +

+ {pending && ( +

+ Accepted; the relay is still applying it. Retry to check. +

+ )} + {error && ( +

+ {error} +

+ )} + {frozen ? ( +
+

+ {ACTION_LABELS[frozen.action]}{" "} + {frozen.action === "delete" ? ( + {frozen.target} + ) : ( + + {confirmName ? `${confirmName} ` : ""} + + {confirmName + ? `(${truncateNpub(frozen.target)})` + : truncateNpub(frozen.target)} + + + )}{" "} + in {frozen.communityHost} + {frozen.expirationSecs ? ` for ${frozen.expirationSecs}s` : ""}? +

+ {frozen.action !== "delete" && ( + + )} +

+ Reason: {frozen.reason ?? "(none)"} +

+
+ + +
+
+ ) : ( + + )} +
+ ); +} + +/** + * Pick a ban/timeout target: search names on the active relay, or paste an + * npub or hex key. Mirrors the Add Member dialog's picker. + */ +function MemberPicker({ + disabled, + member, + onChange, + searchEnabled, +}: { + disabled: boolean; + member: UserSearchResult | null; + onChange: (member: UserSearchResult | null) => void; + /** Off when the target community is not the active relay's. */ + searchEnabled: boolean; +}) { + const [query, setQuery] = useState(""); + const [inspecting, setInspecting] = useState(false); + const deferred = useDeferredValue(query.trim()); + const parsed = parsePubkeyInput(deferred); + // A pasted secret key must never leave the device as a search query. + const secret = isSecretKey(query) || isSecretKey(deferred); + const search = useUserSearchQuery(deferred, { + enabled: searchEnabled && !secret && deferred.length > 0 && parsed === null, + limit: 8, + }); + const results: UserSearchResult[] = parsed + ? [ + { + pubkey: parsed, + displayName: null, + avatarUrl: null, + nip05Handle: null, + ownerPubkey: null, + isAgent: false, + }, + ] + : searchEnabled && !secret + ? (search.data ?? []) + : []; + + const hint = !searchEnabled && ( +

+ Name search only works in the community you're connected to. +

+ ); + if (member) { + return ( +
+ onChange(null)} + poofOnRemove={false} + testIds={{ + chip: "direct-member-remove", + name: "direct-member-inspect", + pubkey: "direct-member-npub", + }} + user={member} + /> + {hint} +
+ ); + } + return ( +
+ setQuery(e.target.value)} + placeholder={ + searchEnabled + ? "Search by name, or paste an npub or hex key" + : "npub or hex key" + } + value={query} + /> + {secret && ( +

+ That's a secret key. Never paste it here. +

+ )} + {hint} + {results.length > 0 && ( +
+ {results.map((user) => ( + + ))} +
+ )} +
+ ); +} diff --git a/desktop/src/features/admin-console/AdminConsolePanel.tsx b/desktop/src/features/admin-console/AdminConsolePanel.tsx index 0af4facfe3..9d868308f4 100644 --- a/desktop/src/features/admin-console/AdminConsolePanel.tsx +++ b/desktop/src/features/admin-console/AdminConsolePanel.tsx @@ -2,9 +2,9 @@ * Main admin console panel — renders when probe state is `nip98Authorized` or * `disabled`. * - * Shows three tabs: Reports (deployment-wide moderation reports), Feedback - * (product feedback with optional image attachments), and Staffing (Operator- - * only operator management). + * Shows four tabs: Reports (deployment-wide moderation reports), Feedback + * (product feedback with optional image attachments), Actions (direct ban, + * timeout, and delete), and Staffing (Operator-only operator management). * * All query/UI state is keyed by `(pubkey, origin)`. In-flight native requests * are fenced by an effect-local `active` flag that is set to `false` in the @@ -23,12 +23,13 @@ */ import { useEffect, useRef, useState } from "react"; -import { MessageSquare, ShieldAlert, Users } from "lucide-react"; +import { Gavel, MessageSquare, ShieldAlert, Users } from "lucide-react"; import { cn } from "@/shared/lib/cn"; import type { AdminPrincipalRole } from "./api"; import { ReportsTab } from "./AdminConsoleReportsTab"; import { FeedbackTab } from "./AdminConsoleFeedbackTab"; import { StaffingTab } from "./AdminConsoleStaffingTab"; +import { ActionsTab } from "./AdminConsoleActionsTab"; export { parseImetaAttachments, @@ -37,7 +38,7 @@ export { // ── Tab bar ─────────────────────────────────────────────────────────────── -type Tab = "reports" | "feedback" | "staffing"; +type Tab = "reports" | "feedback" | "actions" | "staffing"; function TabBar({ activeTab, @@ -55,6 +56,7 @@ function TabBar({ }> = [ { value: "reports", label: "Reports", Icon: ShieldAlert }, { value: "feedback", label: "Feedback", Icon: MessageSquare }, + { value: "actions", label: "Actions", Icon: Gavel }, ...(showStaffing ? [{ value: "staffing" as const, label: "Staffing", Icon: Users }] : []), @@ -140,6 +142,7 @@ export function AdminConsolePanel({ const visibleTabs = new Set([ "reports", "feedback", + "actions", ...(isOperator ? (["staffing"] as Tab[]) : []), ]); if (!visibleTabs.has(activeTab)) { @@ -175,6 +178,16 @@ export function AdminConsolePanel({ generation={generation} /> )} + {/* Stays mounted across tab switches so a frozen, pending or in-flight + action (and its requestId) survives a trip to another tab. */} + {activeTab === "staffing" && isOperator && ( c.querySelector(`[data-testid='${id}']`); + +async function mountActions({ canMutate = true } = {}) { + const panel = mountPanel({ + origin: CM_ORIGIN, + pubkey: CM_PUBKEY, + canMutate, + initialTab: "actions", + }); + await panel.doRender(); + await settle(); + return panel; +} + +async function type(c, id, value) { + await act(async () => { + fireEvent.change(q(c, id), { target: { value } }); + }); +} + +async function click(c, id) { + await act(async () => { + fireEvent.click(q(c, id)); + }); + await settle(); +} + +async function setHost(c, host) { + if (q(c, "direct-host-change")) await click(c, "direct-host-change"); + await type(c, "direct-host-input", host); +} + +/** Paste a key and pick its direct result. */ +async function pickKey(c, key, hex = TARGET) { + await type(c, "direct-member-input", key); + await settle(); + assert.ok(q(c, `direct-member-result-${hex}`), `no direct result for ${key}`); + await click(c, `direct-member-result-${hex}`); +} + +async function fillTimeout(c) { + await click(c, "direct-action-timeout"); + await setHost(c, " team.example.com "); + await pickKey(c, TARGET); + await type(c, "direct-duration-input", "60"); + await type(c, "direct-reason-input", "spam"); +} + +test("actions-validation: bad target or duration shows an error and sends nothing", async () => { + let calls = 0; + setIpcHandler("admin_direct_action", () => { + calls += 1; + return Promise.resolve({}); + }); + const { container: c, unmount } = await mountActions(); + try { + await click(c, "direct-review-btn"); + assert.match(q(c, "direct-error").textContent, /Choose a member/); + await setHost(c, ""); + await click(c, "direct-review-btn"); + assert.match(q(c, "direct-error").textContent, /community host/); + await type(c, "direct-host-input", "team.example.com"); + await click(c, "direct-action-delete"); + await type(c, "direct-target-input", TARGET.toUpperCase()); + await click(c, "direct-review-btn"); + assert.match(q(c, "direct-error").textContent, /64 lowercase hex/); + await click(c, "direct-action-timeout"); + await pickKey(c, TARGET); + await type(c, "direct-duration-input", "0"); + await click(c, "direct-review-btn"); + assert.match(q(c, "direct-error").textContent, /Duration/); + assert.ok(!q(c, "direct-confirm"), "direct-confirm must be absent"); + assert.equal(calls, 0); + } finally { + await unmount(); + } +}); + +test("actions-success: confirm sends the frozen intent with signer, relay, and typed host", async () => { + const sent = []; + setIpcHandler("admin_direct_action", ({ intent }) => { + sent.push(intent); + return Promise.resolve({ + actionId: "a1", + state: "succeeded", + replayed: false, + }); + }); + const { container: c, unmount } = await mountActions(); + try { + await fillTimeout(c); + await click(c, "direct-review-btn"); + assert.equal(sent.length, 0, "review alone must not send"); + await click(c, "direct-confirm-btn"); + assert.equal(sent.length, 1); + const { requestId, ...rest } = sent[0]; + assert.match(requestId, /^[0-9a-f-]{36}$/); + assert.deepEqual(rest, { + origin: CM_ORIGIN, + expectedRelay: TEST_RELAY_WS_URL, + expectedPubkey: CM_PUBKEY, + communityHost: "team.example.com", + action: "timeout", + target: TARGET, + reason: "spam", + expirationSecs: 60, + }); + assert.deepEqual(capturedToasts, ["Time out member: done"]); + assert.ok(!q(c, "direct-confirm"), "direct-confirm must be absent"); + } finally { + await unmount(); + } +}); + +test("actions-retry: an ambiguous failure keeps the intent and a manual retry reuses the requestId", async () => { + // Mutation: mint requestId in handleConfirm, or drop preserveRequestIdOnError → RED. + const ids = []; + const replies = [ + () => mutationReject("network down", null), + () => + mutationReject( + 'admin API error: {"error":{"code":"internal","message":"boom"}}', + 500, + ), + () => + Promise.resolve({ actionId: "a1", state: "succeeded", replayed: true }), + ]; + setIpcHandler("admin_direct_action", ({ intent }) => { + ids.push(intent.requestId); + return replies[ids.length - 1](); + }); + const { container: c, unmount } = await mountActions(); + try { + await fillTimeout(c); + await click(c, "direct-review-btn"); + await click(c, "direct-confirm-btn"); + assert.equal(q(c, "direct-confirm-btn").textContent, "Retry"); + assert.ok( + q(c, "direct-member-remove").disabled, + "fields stay locked to the frozen intent", + ); + await click(c, "direct-confirm-btn"); + await click(c, "direct-confirm-btn"); + assert.equal(ids.length, 3); + assert.equal( + new Set(ids).size, + 1, + "every retry carries the same requestId", + ); + } finally { + await unmount(); + } +}); + +test("actions-errors: relay codes map to copy; a definitive 4xx unlocks the form", async () => { + const cases = [ + ["target_is_staff", 409, /Relay staff can't be banned/, true], + ["request_id_conflict", 409, /already used for a different action/, false], + ["event_not_in_community", 404, /not in that community/, false], + ["unknown_community_host", 400, /unknown host/, false], + ["enforcement_failed", 422, /enforcement broke/, false], + ]; + for (const [code, status, copy, keepsIntent] of cases) { + const message = copy.source.replace(/\\/g, ""); + setIpcHandler("admin_direct_action", () => + mutationReject( + `admin API error: {"error":{"code":"${code}","message":"${message}"}}`, + status, + ), + ); + const { container: c, unmount } = await mountActions(); + try { + await fillTimeout(c); + await click(c, "direct-review-btn"); + await click(c, "direct-confirm-btn"); + assert.match(q(c, "direct-error").textContent, copy, code); + assert.equal(Boolean(q(c, "direct-confirm")), keepsIntent, code); + } finally { + await unmount(); + } + } +}); + +test("actions-pending-neutral: a 202 renders as a neutral notice, not an error", async () => { + setIpcHandler("admin_direct_action", () => + Promise.resolve({ state: "pending" }), + ); + const { container: c, unmount } = await mountActions(); + try { + await fillTimeout(c); + await click(c, "direct-review-btn"); + await click(c, "direct-confirm-btn"); + const notice = q(c, "direct-pending"); + assert.ok(notice, "pending notice must render"); + assert.ok(!notice.className.includes("text-destructive")); + assert.ok(!q(c, "direct-error"), "pending must not render as an error"); + assert.equal(q(c, "direct-confirm-btn").textContent, "Retry"); + } finally { + await unmount(); + } +}); + +test("actions-request-id-conflict: a 409 conflict drops the intent and offers no Retry", async () => { + setIpcHandler("admin_direct_action", () => + mutationReject( + 'admin API error: {"error":{"code":"request_id_conflict","message":"conflict"}}', + 409, + ), + ); + const { container: c, unmount } = await mountActions(); + try { + await fillTimeout(c); + await click(c, "direct-review-btn"); + await click(c, "direct-confirm-btn"); + assert.match( + q(c, "direct-error").textContent, + /Review again to send it with a new id/, + ); + assert.ok(!q(c, "direct-confirm-btn"), "no Retry for a spent request id"); + assert.ok(q(c, "direct-review-btn"), "the form is back to Review"); + } finally { + await unmount(); + } +}); + +test("actions-pending: a 202 keeps the intent for a same-id retry", async () => { + setIpcHandler("admin_direct_action", () => + Promise.resolve({ state: "pending" }), + ); + const { container: c, unmount } = await mountActions(); + try { + await fillTimeout(c); + await click(c, "direct-review-btn"); + await click(c, "direct-confirm-btn"); + assert.match(q(c, "direct-pending").textContent, /still applying/); + assert.ok(q(c, "direct-confirm")); + assert.deepEqual(capturedToasts, []); + } finally { + await unmount(); + } +}); + +test("actions-identity: a signer change remounts the tab and drops the frozen intent", async () => { + // Mutation: remove the ActionsTab key → frozen intent survives → RED. + let calls = 0; + setIpcHandler("admin_direct_action", () => { + calls += 1; + return Promise.resolve({ + actionId: "a1", + state: "succeeded", + replayed: false, + }); + }); + const { container: c, doRender, unmount } = await mountActions(); + try { + await fillTimeout(c); + await click(c, "direct-review-btn"); + assert.ok(q(c, "direct-confirm")); + await doRender({ origin: CM_ORIGIN, pubkey: "ee".repeat(32) }); + await settle(); + assert.ok(!q(c, "direct-confirm"), "direct-confirm must be absent"); + assert.equal(q(c, "direct-member-input").value, ""); + assert.equal(calls, 0); + } finally { + await unmount(); + } +}); + +test("actions-disabled-auth: canMutate=false keeps Review and every field off", async () => { + // Mutation: drop !canMutate from the Review/lock gates → RED. + const { container: c, unmount } = await mountActions({ canMutate: false }); + try { + assert.ok(q(c, "direct-review-btn").disabled); + assert.ok(q(c, "direct-member-input").disabled); + assert.ok(q(c, "direct-action-ban").disabled); + } finally { + await unmount(); + } +}); + +test("actions-review-race: a late second Review never replaces the submitted intent", async () => { + // Mutation: drop the in-flight guard at the top of handleReview → RED + // (the second Review re-freezes with a new requestId and Retry sends it). + const relays = []; + setIpcHandler( + "get_relay_ws_url", + () => + new Promise((resolve) => relays.push(() => resolve(TEST_RELAY_WS_URL))), + ); + const ids = []; + const replies = [ + () => mutationReject("network down", null), + () => + Promise.resolve({ actionId: "a1", state: "succeeded", replayed: true }), + ]; + setIpcHandler("admin_direct_action", ({ intent }) => { + ids.push(intent.requestId); + return replies[ids.length - 1](); + }); + const mounted = mountActions(); + await settle(); + // The tab reads the active relay once on mount to prefill the host. + await act(async () => { + for (const resolve of relays.splice(0)) resolve(); + }); + const { container: c, unmount } = await mounted; + try { + await fillTimeout(c); + await act(async () => { + fireEvent.click(q(c, "direct-review-btn")); + fireEvent.click(q(c, "direct-review-btn")); + }); + const pending = relays.splice(0); + await act(async () => pending[0]()); + await settle(); + await click(c, "direct-confirm-btn"); + await act(async () => { + for (const resolve of pending.slice(1)) resolve(); + }); + await settle(); + await click(c, "direct-confirm-btn"); + assert.equal(ids.length, 2); + assert.equal(ids[0], ids[1], "retry must replay the submitted requestId"); + } finally { + await unmount(); + } +}); + +test("actions-audience: the reason's recipients are disclosed and the frozen reason is shown", async () => { + // Mutation: remove the direct-reason-audience line or the confirm reason → RED. + const { container: c, unmount } = await mountActions(); + try { + const audience = () => q(c, "direct-reason-audience").textContent; + assert.equal(audience(), "Sent verbatim to the affected user."); + await click(c, "direct-action-delete"); + assert.equal( + audience(), + "Sent verbatim to the affected user and posted publicly in the room.", + ); + await fillTimeout(c); + await click(c, "direct-review-btn"); + assert.equal(audience(), "Sent verbatim to the affected user."); + assert.equal(q(c, "direct-confirm-reason").textContent, "Reason: spam"); + } finally { + await unmount(); + } +}); + +test("actions-host-prefill: the host follows the active relay read-only, and Change reveals the input", async () => { + // Mutation: drop the active-relay prefill (activeHost stays null) → RED. + const sent = []; + setIpcHandler("admin_direct_action", ({ intent }) => { + sent.push(intent); + return Promise.resolve({ + actionId: "a1", + state: "succeeded", + replayed: false, + }); + }); + const { container: c, unmount } = await mountActions(); + try { + assert.ok( + q(c, "direct-host"), + "host must be prefilled from the active relay", + ); + assert.match(q(c, "direct-host").textContent, /Community: relay\.test/); + assert.ok(!q(c, "direct-host-input"), "host input hidden until Change"); + await pickKey(c, TARGET); + await click(c, "direct-review-btn"); + await click(c, "direct-confirm-btn"); + assert.equal(sent[0].communityHost, "relay.test"); + await click(c, "direct-host-change"); + assert.equal(q(c, "direct-host-input").value, "relay.test"); + } finally { + await unmount(); + } +}); + +test("actions-member-search: a name result is sent as hex and named on the confirm step", async () => { + setIpcHandler("search_users", ({ query }) => + Promise.resolve({ + users: query.startsWith("ali") + ? [ + { + pubkey: TARGET, + display_name: "Alice", + avatar_url: null, + nip05_handle: null, + owner_pubkey: null, + }, + ] + : [], + next_cursor: null, + }), + ); + const sent = []; + setIpcHandler("admin_direct_action", ({ intent }) => { + sent.push(intent); + return Promise.resolve({ + actionId: "a1", + state: "succeeded", + replayed: false, + }); + }); + const { container: c, unmount } = await mountActions(); + try { + await type(c, "direct-member-input", "ali"); + await settle(); + await click(c, `direct-member-result-${TARGET}`); + await click(c, "direct-review-btn"); + assert.match(q(c, "direct-confirm-member").textContent, /^Alice \(npub1/); + await click(c, "direct-confirm-btn"); + assert.equal(sent[0].target, TARGET); + } finally { + await unmount(); + } +}); + +test("actions-member-keys: an npub and uppercase hex are both sent as lowercase hex", async () => { + // Mutation: accept only lowercase hex instead of parsePubkeyInput → RED. + const sent = []; + setIpcHandler("admin_direct_action", ({ intent }) => { + sent.push(intent); + return Promise.resolve({ + actionId: "a1", + state: "succeeded", + replayed: false, + }); + }); + for (const key of [pubkeyToNpub(TARGET), TARGET.toUpperCase()]) { + const { container: c, unmount } = await mountActions(); + try { + await pickKey(c, key); + await click(c, "direct-review-btn"); + await click(c, "direct-confirm-btn"); + } finally { + await unmount(); + } + } + assert.deepEqual( + sent.map((i) => i.target), + [TARGET, TARGET], + ); +}); + +test("actions-foreign-host: another community's host turns name search off and says why", async () => { + let searches = 0; + setIpcHandler("search_users", () => { + searches += 1; + return Promise.resolve({ users: [], next_cursor: null }); + }); + const { container: c, unmount } = await mountActions(); + try { + assert.ok( + !q(c, "direct-member-search-hint"), + "no hint on the active community", + ); + await setHost(c, "other.example.com"); + assert.match( + q(c, "direct-member-search-hint").textContent, + /only works in the community you're connected to/, + ); + await type(c, "direct-member-input", "alice"); + await settle(); + assert.equal(searches, 0, "no name search against another community"); + await pickKey(c, TARGET); + assert.ok(q(c, "direct-member-selected"), "keys still work"); + } finally { + await unmount(); + } +}); + +const OTHER = "cd".repeat(32); + +function searchReturns(users) { + setIpcHandler("search_users", () => + Promise.resolve({ + users: users.map(([pubkey, name]) => ({ + pubkey, + display_name: name, + avatar_url: null, + nip05_handle: null, + owner_pubkey: null, + })), + next_cursor: null, + }), + ); +} + +test("actions-host-change-drops-member: a name picked in one community can't be reviewed in another", async () => { + // Mutation: mask the pick by host instead of clearing it → RED on A→B→A. + searchReturns([[TARGET, "Alice"]]); + const { container: c, unmount } = await mountActions(); + try { + await type(c, "direct-member-input", "ali"); + await settle(); + await click(c, `direct-member-result-${TARGET}`); + await click(c, "direct-host-change"); + assert.ok(q(c, "direct-member-selected"), "Change alone keeps the pick"); + await setHost(c, "other.example.com"); + assert.ok( + !q(c, "direct-member-selected"), + "the pick must not survive a host change", + ); + assert.equal(q(c, "direct-member-input").value, "", "query is cleared"); + await click(c, "direct-review-btn"); + assert.ok(!q(c, "direct-confirm"), "nothing to review"); + assert.match(q(c, "direct-error").textContent, /Choose a member/); + await setHost(c, "relay.test"); + assert.ok( + !q(c, "direct-member-selected"), + "returning to the first host must not restore the old pick", + ); + await click(c, "direct-review-btn"); + assert.ok(!q(c, "direct-confirm"), "still nothing to review"); + assert.match(q(c, "direct-error").textContent, /Choose a member/); + await setHost(c, "other.example.com"); + await pickKey(c, TARGET); + assert.ok( + q(c, "direct-member-search-hint"), + "hint stays visible with a key selected", + ); + await click(c, "direct-review-btn"); + assert.doesNotMatch(q(c, "direct-confirm-member").textContent, /Alice/); + } finally { + await unmount(); + } +}); + +test("actions-same-name: two same-name results are told apart and the chosen full key is confirmed", async () => { + // Mutation: drop the full PubKey from the confirm step → RED. + searchReturns([ + [TARGET, "Alice"], + [OTHER, "Alice"], + ]); + const sent = []; + setIpcHandler("admin_direct_action", ({ intent }) => { + sent.push(intent); + return Promise.resolve({ + actionId: "a1", + state: "succeeded", + replayed: false, + }); + }); + const { container: c, unmount } = await mountActions(); + try { + await type(c, "direct-member-input", "ali"); + await settle(); + const a = q(c, `direct-member-result-${TARGET}`).textContent; + const b = q(c, `direct-member-result-${OTHER}`).textContent; + assert.notEqual(a, b, "same-name results must differ by key"); + await click(c, `direct-member-result-${OTHER}`); + assert.ok(q(c, "direct-member-inspect"), "selected chip is inspectable"); + await click(c, "direct-review-btn"); + assert.ok(q(c, "direct-confirm-npub"), "confirm must show the full npub"); + assert.match( + q(c, "direct-confirm-npub").textContent, + new RegExp(pubkeyToNpub(OTHER)), + "confirm shows the chosen member's full npub", + ); + await click(c, "direct-confirm-btn"); + assert.equal(sent[0].target, OTHER); + } finally { + await unmount(); + } +}); + +test("actions-results-disabled: results showing when auth drops can't be selected", async () => { + searchReturns([[TARGET, "Alice"]]); + const panel = await mountActions(); + const c = panel.container; + try { + await type(c, "direct-member-input", "ali"); + await settle(); + await panel.doRender({ canMutate: false }); + await settle(); + const result = q(c, `direct-member-result-${TARGET}`); + assert.ok(result, "results still shown"); + assert.ok(result.disabled, "results must be disabled"); + await click(c, `direct-member-result-${TARGET}`); + assert.ok(!q(c, "direct-member-selected")); + } finally { + await panel.unmount(); + } +}); + +test("actions-secret-key: a pasted secret key or key backup is never searched and is flagged", async () => { + // Mutation: drop the isSecretKey gate from the picker's search → RED. + // The backup prefix is assembled so this file stays outside the frontend + // key-backup source scan's allowlist; it is only ever typed into the picker. + const backup = ["ncrypt", "sec1"].join(""); + const queries = []; + setIpcHandler("search_users", ({ query }) => { + queries.push(query); + return Promise.resolve({ users: [], next_cursor: null }); + }); + const { container: c, unmount } = await mountActions(); + try { + for (const key of [ + `nsec1${"q".repeat(58)}`, + `NSEC1${"Q".repeat(58)}`, + `${backup}${"q".repeat(40)}`, + `${backup.toUpperCase()}${"Q".repeat(40)}`, + ]) { + await type(c, "direct-member-input", key); + await settle(); + assert.match( + q(c, "direct-member-secret")?.textContent ?? "", + /secret key/, + `no warning for ${key.slice(0, 10)}`, + ); + } + assert.deepEqual(queries, [], "a secret key reached search_users"); + await type(c, "direct-member-input", ""); + assert.ok(!q(c, "direct-member-secret"), "clearing drops the warning"); + await type(c, "direct-member-input", "alice"); + await settle(); + assert.deepEqual(queries, ["alice"], "name search still runs"); + await pickKey(c, pubkeyToNpub(TARGET)); + assert.ok(q(c, "direct-member-selected"), "npub still works"); + } finally { + await unmount(); + } +}); + +async function toTab(c, tab) { + await click(c, `admin-tab-${tab}`); +} + +test("actions-tab-roundtrip-pending: a 202 survives a tab switch and Retry reuses the requestId", async () => { + // Mutation: render ActionsTab only while it is the active tab → RED. + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + const ids = []; + setIpcHandler("admin_direct_action", ({ intent }) => { + ids.push(intent.requestId); + return Promise.resolve({ state: "pending" }); + }); + const { container: c, unmount } = await mountActions(); + try { + await fillTimeout(c); + await click(c, "direct-review-btn"); + await click(c, "direct-confirm-btn"); + await toTab(c, "feedback"); + await toTab(c, "actions"); + assert.ok(q(c, "direct-pending"), "pending notice survives"); + await click(c, "direct-confirm-btn"); + assert.equal(ids.length, 2); + assert.equal(ids[0], ids[1], "Retry after a tab trip reuses the id"); + } finally { + await unmount(); + } +}); + +test("actions-tab-roundtrip-ambiguous: an ambiguous failure survives a tab switch", async () => { + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + const ids = []; + setIpcHandler("admin_direct_action", ({ intent }) => { + ids.push(intent.requestId); + return mutationReject("network down", null); + }); + const { container: c, unmount } = await mountActions(); + try { + await fillTimeout(c); + await click(c, "direct-review-btn"); + await click(c, "direct-confirm-btn"); + await toTab(c, "feedback"); + await toTab(c, "actions"); + assert.equal(q(c, "direct-confirm-btn")?.textContent, "Retry"); + await click(c, "direct-confirm-btn"); + assert.equal(ids.length, 2); + assert.equal(ids[0], ids[1]); + } finally { + await unmount(); + } +}); + +test("actions-tab-roundtrip-inflight: a send that fails on another tab shows its error on return", async () => { + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + let fail; + setIpcHandler( + "admin_direct_action", + () => + new Promise((_, reject) => { + fail = () => reject({ message: "network down", relayStatus: null }); + }), + ); + const { container: c, unmount } = await mountActions(); + try { + await fillTimeout(c); + await click(c, "direct-review-btn"); + await click(c, "direct-confirm-btn"); + await toTab(c, "feedback"); + await act(async () => fail()); + await settle(); + await toTab(c, "actions"); + assert.match(q(c, "direct-error")?.textContent ?? "", /network down/); + assert.equal(q(c, "direct-confirm-btn")?.textContent, "Retry"); + } finally { + await unmount(); + } +}); + +test("actions-not-sent: a refusal before sending drops the intent and offers no Retry", async () => { + // Mutation: drop the adminMutationNotSent check from handleConfirm → RED. + let calls = 0; + setIpcHandler("admin_direct_action", () => { + calls += 1; + return Promise.reject({ + message: "invalid community host: path not allowed", + relayStatus: null, + bodyComplete: false, + notSent: true, + }); + }); + const { container: c, unmount } = await mountActions(); + try { + await setHost(c, "team.example.com/"); + await pickKey(c, TARGET); + await click(c, "direct-review-btn"); + await click(c, "direct-confirm-btn"); + assert.equal(calls, 1); + assert.ok(!q(c, "direct-confirm"), "intent dropped; no Retry"); + assert.match(q(c, "direct-error").textContent, /invalid community host/); + assert.ok(q(c, "direct-review-btn"), "back to Review"); + } finally { + await unmount(); + } +}); + +test("actions-old-relay: only an empty 404 or 405 says the relay lacks direct actions", async () => { + // Mutation: drop the empty-body condition from directErrorMessage → RED. + for (const [status, message, expected] of [ + [ + 404, + "admin API error: ", + "This relay doesn't support direct actions yet.", + ], + [ + 405, + "admin API error: ", + "This relay doesn't support direct actions yet.", + ], + [404, "admin API error: Not Found", "admin API error: Not Found"], + [ + 405, + "admin API error: Method Not Allowed", + "admin API error: Method Not Allowed", + ], + ]) { + setIpcHandler("admin_direct_action", () => mutationReject(message, status)); + const { container: c, unmount } = await mountActions(); + try { + await pickKey(c, TARGET); + await click(c, "direct-review-btn"); + await click(c, "direct-confirm-btn"); + assert.equal( + q(c, "direct-error").textContent, + expected, + `${status} ${message}`, + ); + } finally { + await unmount(); + } + } +}); + +test("actions-backup-reason-not-sent: a key-backup reason refusal returns to editable Review", async () => { + // Mirrors the native backup-guard refusal (Rust asserts notSent there). + // Mutation: return notSent: false, as before the fix → RED. + const backup = "see " + "ncrypt" + "sec1qgg9947"; + let calls = 0; + setIpcHandler("admin_direct_action", () => { + calls += 1; + return Promise.reject({ + message: "admin API mutation: refused to send key-backup material", + relayStatus: null, + bodyComplete: false, + notSent: true, + }); + }); + const { container: c, unmount } = await mountActions(); + try { + await pickKey(c, TARGET); + await type(c, "direct-reason-input", backup); + await click(c, "direct-review-btn"); + await click(c, "direct-confirm-btn"); + assert.equal(calls, 1); + assert.ok(!q(c, "direct-confirm"), "no Retry after a pre-send refusal"); + assert.ok(q(c, "direct-review-btn"), "back to Review"); + assert.equal( + q(c, "direct-reason-input").disabled, + false, + "Reason is editable again", + ); + } finally { + await unmount(); + } +}); diff --git a/desktop/src/features/admin-console/adminConsolePanelTestHelpers.jsdom.mjs b/desktop/src/features/admin-console/adminConsolePanelTestHelpers.jsdom.mjs index e16b23f99d..61b761798e 100644 --- a/desktop/src/features/admin-console/adminConsolePanelTestHelpers.jsdom.mjs +++ b/desktop/src/features/admin-console/adminConsolePanelTestHelpers.jsdom.mjs @@ -193,7 +193,11 @@ export function mountPanel({ const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); - const doRender = async ({ origin: o, pubkey: p } = { origin, pubkey }) => { + const doRender = async ({ + origin: o = origin, + pubkey: p = pubkey, + canMutate: m = canMutate, + } = {}) => { await act(async () => { root.render( React.createElement( @@ -203,7 +207,7 @@ export function mountPanel({ CommunitiesProvider, null, React.createElement(AdminConsolePanel, { - canMutate, + canMutate: m, origin: o, pubkey: p, ...(role !== undefined ? { role } : {}), diff --git a/desktop/src/features/admin-console/api.ts b/desktop/src/features/admin-console/api.ts index 4845c4543c..684b77ba12 100644 --- a/desktop/src/features/admin-console/api.ts +++ b/desktop/src/features/admin-console/api.ts @@ -696,3 +696,39 @@ export async function saveAdminAttachment( expectedSize, }); } + +// ── Direct moderation actions ───────────────────────────────────────────── + +export type AdminDirectAction = "ban" | "timeout" | "delete"; + +/** A direct action as confirmed; resent verbatim on every retry. */ +export type AdminDirectIntent = { + origin: string; + expectedRelay: string; + expectedPubkey: string; + communityHost: string; + action: AdminDirectAction; + /** Member pubkey (ban/timeout) or event id (delete), 64 lowercase hex. */ + target: string; + requestId: string; + reason?: string; + expirationSecs?: number; +}; + +export type AdminDirectActionResult = + | { actionId: string; state: "succeeded"; replayed: boolean } + | { state: "pending" }; + +/** + * Ban, time out, or delete without a report. The native command refuses the + * intent before sending if the active relay or signer changed since confirm. + * + * POST /api/admin/v1/{members/{pubkey}/ban|members/{pubkey}/timeout|events/{id}/delete}?communityHost= + */ +export async function directAdminAction( + intent: AdminDirectIntent, +): Promise { + return invokeTauri("admin_direct_action", { + intent, + }); +}