diff --git a/desktop/src-tauri/src/commands/admin/attachment.rs b/desktop/src-tauri/src/commands/admin/attachment.rs index 5f61d22941..552f233441 100644 --- a/desktop/src-tauri/src/commands/admin/attachment.rs +++ b/desktop/src-tauri/src/commands/admin/attachment.rs @@ -45,6 +45,7 @@ pub(super) async fn fetch_feedback_attachment( }, &routes::AdminQuery::default(), ); + crate::egress_guard::assert_no_key_backup(&url, "admin API request URL")?; let http_client = client::ADMIN_CLIENT .get() .ok_or_else(|| "admin client not initialised".to_string())?; diff --git a/desktop/src-tauri/src/commands/admin/error.rs b/desktop/src-tauri/src/commands/admin/error.rs index 880fdef32a..51b0470282 100644 --- a/desktop/src-tauri/src/commands/admin/error.rs +++ b/desktop/src-tauri/src/commands/admin/error.rs @@ -38,6 +38,9 @@ pub struct AdminMutationError { pub body_complete: bool, /// Refused before sending; see the type docs. pub not_sent: bool, + /// The complete error body had zero bytes: a relay without the route + /// answers this way. Derived from the received bytes, never the message. + pub body_empty: bool, } impl AdminMutationError { @@ -51,12 +54,17 @@ impl AdminMutationError { /// 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 { + pub(super) fn authoritative( + status: reqwest::StatusCode, + message: String, + body_empty: bool, + ) -> Self { Self { message, relay_status: Some(status.as_u16()), body_complete: true, not_sent: false, + body_empty, } } @@ -68,6 +76,7 @@ impl AdminMutationError { relay_status: Some(status.as_u16()), body_complete: false, not_sent: false, + body_empty: false, } } } @@ -81,6 +90,48 @@ impl From for AdminMutationError { relay_status: None, body_complete: false, not_sent: false, + body_empty: false, + } + } +} + +/// Error from a staff read command (`/communities`, `/members/...`, +/// `/events/{id}`), keeping what the UI needs to tell "this relay has no such +/// route" (complete empty 404/405), "not in this community" (a coded 404) and +/// "unknown" (truncated body or no answer) apart without reading the message. +#[derive(Debug, PartialEq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminReadError { + pub message: String, + /// The relay's HTTP status; `None` when no response arrived. + pub relay_status: Option, + /// Whether the whole error body was read. + pub body_complete: bool, + /// The complete body had zero bytes (false whenever it is incomplete). + pub body_empty: bool, + /// The relay envelope's `error.code`, when the complete body carried one. + pub code: Option, +} + +impl AdminReadError { + /// The relay answered but its body was not fully read. + pub(super) fn partial(status: reqwest::StatusCode, message: String) -> Self { + Self { + relay_status: Some(status.as_u16()), + ..Self::from(message) + } + } +} + +/// Pre-send and transport failures: no relay answer. +impl From for AdminReadError { + fn from(message: String) -> Self { + Self { + message, + relay_status: None, + body_complete: false, + body_empty: false, + code: None, } } } diff --git a/desktop/src-tauri/src/commands/admin/helpers.rs b/desktop/src-tauri/src/commands/admin/helpers.rs index 1bc64458e1..8a15f012bf 100644 --- a/desktop/src-tauri/src/commands/admin/helpers.rs +++ b/desktop/src-tauri/src/commands/admin/helpers.rs @@ -14,6 +14,7 @@ pub(super) async fn fetch_admin_json( ) -> Result, String> { use crate::relay::build_nip98_auth_header_for_keys; + crate::egress_guard::assert_no_key_backup(url, "admin API request URL")?; let keys = state.signing_keys()?; let http_client = client::ADMIN_CLIENT .get() @@ -163,6 +164,9 @@ pub(super) async fn send_admin_mutation( /// [`crate::relay::build_nip98_auth_header_for_keys`]), which is why the /// 401-retry re-invokes this rather than resending the first request. /// +/// The URL passes the key-backup egress guard before signing: the NIP-98 +/// header embeds it, and admin searches put typed text in its query. +/// /// State-free (`&Client` + `&Keys`) so the wire shape is unit-testable without /// a running Tauri app. pub(super) fn build_admin_mutation_request( @@ -172,6 +176,7 @@ pub(super) fn build_admin_mutation_request( url: &str, body: Option<&[u8]>, ) -> Result { + crate::egress_guard::assert_no_key_backup(url, "admin API request URL")?; let auth_header = crate::relay::build_nip98_auth_header_for_keys(keys, method, url, body.unwrap_or(&[])) .map_err(|e| format!("nip98 build failed: {e}"))?; @@ -378,6 +383,7 @@ async fn read_admin_mutation_response( return Err(AdminMutationError::authoritative( status, format!("admin API error: {body}"), + bytes.is_empty(), )); } diff --git a/desktop/src-tauri/src/commands/admin/mod.rs b/desktop/src-tauri/src/commands/admin/mod.rs index d63b777d56..bc43fc990b 100644 --- a/desktop/src-tauri/src/commands/admin/mod.rs +++ b/desktop/src-tauri/src/commands/admin/mod.rs @@ -58,7 +58,16 @@ use helpers::{ pub(crate) mod error; mod attachment; +#[cfg(test)] +mod read_stub; +mod reads; +mod restrictions; pub use error::AdminMutationError; +pub use reads::{ + admin_connected_community_host, admin_get_event, admin_get_member, admin_list_communities, + admin_search_members, +}; +pub use restrictions::{admin_lift_restriction, admin_list_restrictions}; // ── Typed probe result ──────────────────────────────────────────────────── @@ -159,6 +168,7 @@ async fn admin_probe_inner( ) -> Result { let origin = origin::AdminOrigin::parse(origin)?; let url = origin.route_url(&routes::AdminRoute::Probe, &routes::AdminQuery::default()); + crate::egress_guard::assert_no_key_backup(&url, "admin API request URL")?; let http_client = client::ADMIN_CLIENT .get() @@ -465,6 +475,7 @@ pub async fn admin_list_reports( scope: query.scope, cursor: None, community_host: None, + q: None, }; let url = origin.route_url(&routes::AdminRoute::ReportsList, &q); let bytes = fetch_admin_json(&url, SUCCESS_JSON_CAP, &state).await?; @@ -747,121 +758,6 @@ pub async fn admin_save_attachment( .await } -// ── Member restrictions ─────────────────────────────────────────────────── - -/// Shown by the UI when a restriction call targets a relay other than the one -/// its list loaded from. -const RELAY_SCOPE_CHANGED: &str = - "active community changed since restrictions loaded; nothing was sent. Reload to continue."; - -/// Build a restrictions-route URL scoped to the active relay's community. -/// -/// The community is named by the active relay's host authority — the same -/// `relay_url_authority` the relay's own connection binding uses — so the relay -/// resolves its tenant. The desktop's local community ids are never sent. -/// -/// `expected_relay` is the relay the caller's restriction list loaded from. The -/// native relay is read once; if it no longer matches, the call fails before -/// any request so a workspace switch cannot retarget a page or removal. -fn restrictions_url( - origin: &str, - route: &routes::AdminRoute, - cursor: Option, - expected_relay: &str, - state: &crate::app_state::AppState, -) -> Result { - let origin = origin::AdminOrigin::parse(origin)?; - let relay_base = crate::relay::relay_api_base_url_with_override(state); - if expected_relay.trim().is_empty() - || crate::relay::assert_expected_relay_scope(Some(expected_relay), &relay_base).is_err() - { - return Err(RELAY_SCOPE_CHANGED.to_string()); - } - let host = buzz_core_pkg::tenant::relay_url_authority(&relay_base); - if host.is_empty() { - return Err("admin_community_host_unresolved".to_string()); - } - let q = routes::AdminQuery { - community_host: Some(host), - cursor, - ..Default::default() - }; - Ok(origin.route_url(route, &q)) -} - -/// List active bans and timeouts for the active relay's community — -/// GET /api/admin/v1/members/restrictions?communityHost={host}[&cursor={token}]. -/// -/// Returns `{ items: [...], nextCursor: string|null }`. Pass the previous -/// page's `nextCursor` as `cursor` to fetch the next page (relay page size 200). -#[tauri::command] -pub async fn admin_list_restrictions( - origin: String, - cursor: Option, - expected_relay: String, - state: tauri::State<'_, crate::app_state::AppState>, -) -> Result { - let url = restrictions_url( - &origin, - &routes::AdminRoute::MemberRestrictionsList, - cursor, - &expected_relay, - &state, - )?; - let bytes = fetch_admin_json(&url, SUCCESS_JSON_CAP, &state).await?; - serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}")) -} - -/// Lift an active ban — DELETE /api/admin/v1/members/{pubkey}/ban?communityHost={host}. -/// -/// Returns 204 on success, 409 when no active ban exists for this member. -/// A 409 is surfaced as an `AdminMutationError` so the UI can handle it -/// gracefully ("no active ban"). -#[tauri::command] -pub async fn admin_lift_ban( - origin: String, - pubkey: String, - expected_relay: String, - state: tauri::State<'_, crate::app_state::AppState>, -) -> Result<(), AdminMutationError> { - let pubkey = - routes::Hex64::parse(&pubkey).map_err(|e| format!("invalid member pubkey: {e}"))?; - let url = restrictions_url( - &origin, - &routes::AdminRoute::MemberBanDelete { pubkey }, - None, - &expected_relay, - &state, - )?; - // 204 No Content: empty body is the success signal. delete_admin_json - // returns Ok(vec![]) for 204; we discard the bytes and return (). - let _bytes = delete_admin_json(&url, SUCCESS_JSON_CAP, &state).await?; - Ok(()) -} - -/// Lift an active timeout — DELETE /api/admin/v1/members/{pubkey}/timeout?communityHost={host}. -/// -/// Returns 204 on success, 409 when no active timeout exists for this member. -#[tauri::command] -pub async fn admin_lift_timeout( - origin: String, - pubkey: String, - expected_relay: String, - state: tauri::State<'_, crate::app_state::AppState>, -) -> Result<(), AdminMutationError> { - let pubkey = - routes::Hex64::parse(&pubkey).map_err(|e| format!("invalid member pubkey: {e}"))?; - let url = restrictions_url( - &origin, - &routes::AdminRoute::MemberTimeoutDelete { pubkey }, - None, - &expected_relay, - &state, - )?; - let _bytes = delete_admin_json(&url, SUCCESS_JSON_CAP, &state).await?; - Ok(()) -} - // ── Direct moderation actions ───────────────────────────────────────────── /// A direct ban, timeout, or delete as the confirm dialog froze it. The same diff --git a/desktop/src-tauri/src/commands/admin/mod_tests.rs b/desktop/src-tauri/src/commands/admin/mod_tests.rs index 3e5c86c374..5df9cf9981 100644 --- a/desktop/src-tauri/src/commands/admin/mod_tests.rs +++ b/desktop/src-tauri/src/commands/admin/mod_tests.rs @@ -1314,79 +1314,65 @@ async fn save_rejects_an_unrelated_content_type() { assert_eq!(saved, Err("admin_attachment_mime_mismatch".to_string())); } -// ── Restrictions community scoping ──────────────────────────────────────── - -#[test] -fn restrictions_url_names_the_active_relay_authority() { - let state = relay_state("wss://Community.Example.com:8443/ws"); - let pubkey = routes::Hex64::parse(&"ab".repeat(32)).unwrap(); - let url = restrictions_url( - "https://admin.example.com", - &routes::AdminRoute::MemberBanDelete { pubkey }, - None, - "wss://Community.Example.com:8443/ws", - &state, - ) - .unwrap(); - assert!( - url.ends_with("?communityHost=community.example.com%3A8443"), - "{url}" - ); - assert!(!url.contains("communityId"), "{url}"); -} - -#[test] -fn restrictions_url_carries_the_cursor_and_default_port_host() { - let state = relay_state("wss://relay.example.com"); - let url = restrictions_url( - "https://admin.example.com", - &routes::AdminRoute::MemberRestrictionsList, - Some("tok".to_string()), - "wss://relay.example.com", - &state, - ) - .unwrap(); - assert!( - url.ends_with("/members/restrictions?cursor=tok&communityHost=relay.example.com") - || url.ends_with("/members/restrictions?communityHost=relay.example.com&cursor=tok"), - "{url}" - ); -} - -#[test] -fn restrictions_url_errors_when_the_relay_host_is_unresolvable() { - let state = relay_state("not a url"); - let err = restrictions_url( - "https://admin.example.com", - &routes::AdminRoute::MemberRestrictionsList, - None, - "not a url", - &state, - ) - .unwrap_err(); - assert_eq!(err, "admin_community_host_unresolved"); -} - -#[test] -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::Hex64::parse(&"ab".repeat(32)).unwrap(); - for route in [ - routes::AdminRoute::MemberRestrictionsList, - routes::AdminRoute::MemberBanDelete { - pubkey: pubkey.clone(), - }, - routes::AdminRoute::MemberTimeoutDelete { pubkey }, - ] { - for expected in ["wss://relay-a.example.com", "", " "] { - let err = restrictions_url("https://admin.example.com", &route, None, expected, &state) - .unwrap_err(); - assert_eq!(err, RELAY_SCOPE_CHANGED); - } - } -} - #[path = "direct_action_tests.rs"] mod direct_action; + +// ── Key-backup guard on origin-derived URLs ─────────────────────────────── + +/// Origins whose hostname carries NIP-49 backup text. `.localhost` resolves to +/// the loopback stub, so without the guard each request would reach it. +fn backup_origins(port: u16) -> [String; 2] { + [ + format!("http://ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq.localhost:{port}"), + format!("http://NCRYPTSEC1QGG9947RLPVQU76PJ5ECREDUF9JXHSELQ.localhost:{port}"), + ] +} + +/// A stub that records every request it receives. +async fn recording_stub() -> (u16, std::sync::Arc>>>) { + use std::sync::{Arc, Mutex}; + let seen: Arc>>> = Arc::new(Mutex::new(Vec::new())); + let rec = Arc::clone(&seen); + let addr = serve_sequence_inspect( + vec![("401 Unauthorized", "WWW-Authenticate: Nostr\r\n", ""); 2], + Some(Arc::new(move |_idx, bytes: &[u8]| { + rec.lock().unwrap().push(bytes.to_vec()); + })), + ) + .await; + (addr.port(), seen) +} + +#[tokio::test] +async fn a_probe_origin_carrying_a_key_backup_is_never_sent() { + let (port, seen) = recording_stub().await; + for origin in backup_origins(port) { + let sign = |_: &str| -> Result { panic!("must not sign") }; + let err = admin_probe_inner(&origin, Some(sign)).await.unwrap_err(); + assert!(err.contains("NIP-49 key-backup"), "{err}"); + } + assert!(seen.lock().unwrap().is_empty(), "probe reached the relay"); +} + +#[tokio::test] +async fn an_attachment_origin_carrying_a_key_backup_is_never_sent() { + let (port, seen) = recording_stub().await; + for origin in backup_origins(port) { + let err = attachment::fetch_feedback_attachment( + &origin, + SAVE_FEEDBACK_ID, + &save_sha(), + "application/pdf", + 14, + &nostr::Keys::generate(), + helpers::AttachmentUse::Preview, + ) + .await + .unwrap_err(); + assert!(err.contains("NIP-49 key-backup"), "{err}"); + } + assert!( + seen.lock().unwrap().is_empty(), + "attachment fetch reached the relay" + ); +} diff --git a/desktop/src-tauri/src/commands/admin/read_stub.rs b/desktop/src-tauri/src/commands/admin/read_stub.rs new file mode 100644 index 0000000000..e78af5b851 --- /dev/null +++ b/desktop/src-tauri/src/commands/admin/read_stub.rs @@ -0,0 +1,57 @@ +//! Raw HTTP stub for the read and lift tests: each slot is the exact response +//! bytes, so a test can lie about `Content-Length` to force truncation. + +use std::sync::{Arc, Mutex}; + +/// Serve `responses` in order and record each request's raw bytes. +pub(super) fn serve_raw(responses: Vec>) -> (u16, Arc>>) { + use std::io::{Read, Write}; + super::client::init_admin_client().expect("client builds"); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let seen = Arc::new(Mutex::new(Vec::new())); + let log = Arc::clone(&seen); + std::thread::spawn(move || { + for response in responses { + let Ok((mut stream, _)) = listener.accept() else { + return; + }; + let mut buf = [0u8; 8192]; + let n = stream.read(&mut buf).unwrap_or(0); + log.lock() + .unwrap() + .push(String::from_utf8_lossy(&buf[..n]).into_owned()); + let _ = stream.write_all(&response); + let _ = stream.flush(); + } + }); + (port, seen) +} + +/// A complete response with a truthful `Content-Length`. +pub(super) fn response(status: &str, body: &str) -> Vec { + format!( + "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .into_bytes() +} + +/// The NIP-98 signer pubkey in a recorded request's Authorization header. +pub(super) fn signer_of(request: &str) -> String { + use base64::Engine as _; + let token = request + .lines() + .find_map(|l| l.strip_prefix("authorization: Nostr ")) + .or_else(|| { + request + .lines() + .find_map(|l| l.strip_prefix("Authorization: Nostr ")) + }) + .expect("NIP-98 authorization header"); + let json = base64::engine::general_purpose::STANDARD + .decode(token.trim()) + .unwrap(); + let event: serde_json::Value = serde_json::from_slice(&json).unwrap(); + event["pubkey"].as_str().unwrap().to_string() +} diff --git a/desktop/src-tauri/src/commands/admin/reads.rs b/desktop/src-tauri/src/commands/admin/reads.rs new file mode 100644 index 0000000000..86141bd494 --- /dev/null +++ b/desktop/src-tauri/src/commands/admin/reads.rs @@ -0,0 +1,221 @@ +//! Community reads: the directory, member search and lookup, and +//! the message preview before a delete. +//! +//! Each command returns a structured [`AdminReadError`] so the UI can tell a +//! relay without the route, a target absent from the community, and an +//! unknown outcome apart. Every community-scoped call validates its explicit +//! host natively; nothing is derived from the active relay. + +use super::error::AdminReadError; +use super::{client, helpers, origin, routes, ERROR_BODY_CAP, SUCCESS_JSON_CAP}; + +/// GET `url` signed by `keys`, with one fresh-nonce retry on 401. +pub(super) async fn send_admin_read( + keys: &nostr::Keys, + url: &str, + cap: u64, +) -> Result, AdminReadError> { + let http_client = client::ADMIN_CLIENT + .get() + .ok_or_else(|| "admin client not initialised".to_string())?; + let send = || async { + helpers::build_admin_mutation_request(http_client, keys, &reqwest::Method::GET, url, None)? + .send() + .await + .map_err(|e| crate::relay::classify_request_error(&e)) + }; + let mut resp = send().await?; + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + resp = send().await?; + } + read_admin_read_response(resp, cap, ERROR_BODY_CAP).await +} + +/// Read a response under the success/error caps. Only a fully read non-2xx +/// body yields `bodyComplete`, and only then are `bodyEmpty` and `code` known. +pub(super) async fn read_admin_read_response( + resp: reqwest::Response, + success_cap: u64, + error_cap: u64, +) -> Result, AdminReadError> { + use futures_util::StreamExt; + + let status = resp.status(); + if status.is_redirection() { + return Err(AdminReadError::partial( + status, + format!("admin API returned a {status} redirect (not followed)"), + )); + } + let cap = if status.is_success() { + success_cap + } else { + error_cap + }; + if resp.content_length().is_some_and(|cl| cl > cap) { + return Err(AdminReadError::partial( + status, + format!("admin response too large (cap {cap} bytes)"), + )); + } + let mut bytes: Vec = Vec::new(); + let mut stream = resp.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| { + AdminReadError::partial(status, format!("admin response stream error: {e}")) + })?; + if bytes.len() as u64 + chunk.len() as u64 > cap { + return Err(AdminReadError::partial( + status, + format!("admin response too large (cap {cap} bytes)"), + )); + } + bytes.extend_from_slice(&chunk); + } + if status.is_success() { + return Ok(bytes); + } + let code = serde_json::from_slice::(&bytes) + .ok() + .and_then(|v| v["error"]["code"].as_str().map(str::to_string)); + Err(AdminReadError { + message: format!("admin API error: {}", String::from_utf8_lossy(&bytes)), + relay_status: Some(status.as_u16()), + body_complete: true, + body_empty: bytes.is_empty(), + code, + }) +} + +/// Build the URL for `route`, validating the explicit community host. +fn read_url( + origin: &str, + route: &routes::AdminRoute, + mut q: routes::AdminQuery, +) -> Result { + let origin = origin::AdminOrigin::parse(origin)?; + if let Some(host) = q.community_host.take() { + q.community_host = Some( + buzz_core_pkg::tenant::validate_community_host(host.trim()) + .map_err(|e| format!("invalid community host: {e}"))?, + ); + } + Ok(origin.route_url(route, &q)) +} + +async fn read_json( + url: Result, + state: &crate::app_state::AppState, +) -> Result { + let url = url?; + let keys = state.signing_keys()?; + let bytes = send_admin_read(&keys, &url, SUCCESS_JSON_CAP).await?; + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}").into()) +} + +fn hex64(raw: &str, what: &str) -> Result { + routes::Hex64::parse(raw).map_err(|e| format!("invalid {what}: {e}")) +} + +/// Community directory — GET /api/admin/v1/communities?q=&cursor=&limit=. +#[tauri::command] +pub async fn admin_list_communities( + origin: String, + q: Option, + cursor: Option, + limit: Option, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let query = routes::AdminQuery { + q, + cursor, + limit, + ..Default::default() + }; + read_json( + read_url(&origin, &routes::AdminRoute::CommunitiesList, query), + &state, + ) + .await +} + +/// Name search in one community — GET /members/search?communityHost=&q=&limit=. +#[tauri::command] +pub async fn admin_search_members( + origin: String, + community_host: String, + q: String, + limit: Option, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let query = routes::AdminQuery { + community_host: Some(community_host), + q: Some(q), + limit, + ..Default::default() + }; + read_json( + read_url(&origin, &routes::AdminRoute::MembersSearch, query), + &state, + ) + .await +} + +/// One member's state in one community — GET /members/{pubkey}?communityHost=. +#[tauri::command] +pub async fn admin_get_member( + origin: String, + community_host: String, + pubkey: String, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let url = hex64(&pubkey, "member pubkey").and_then(|pubkey| { + read_url( + &origin, + &routes::AdminRoute::MemberDetail { pubkey }, + routes::AdminQuery { + community_host: Some(community_host), + ..Default::default() + }, + ) + }); + read_json(url, &state).await +} + +/// Message preview in one community — GET /events/{id}?communityHost=. +#[tauri::command] +pub async fn admin_get_event( + origin: String, + community_host: String, + id: String, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let url = hex64(&id, "event id").and_then(|id| { + read_url( + &origin, + &routes::AdminRoute::EventDetail { id }, + routes::AdminQuery { + community_host: Some(community_host), + ..Default::default() + }, + ) + }); + read_json(url, &state).await +} + +/// The community host the active relay serves, as the relay binds it. +#[tauri::command] +pub fn admin_connected_community_host( + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let base = crate::relay::relay_api_base_url_with_override(&state); + let host = buzz_core_pkg::tenant::relay_url_authority(&base); + if host.is_empty() { + return Err("admin_community_host_unresolved".to_string()); + } + Ok(host) +} + +#[cfg(test)] +#[path = "reads_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/commands/admin/reads_tests.rs b/desktop/src-tauri/src/commands/admin/reads_tests.rs new file mode 100644 index 0000000000..afa460baf4 --- /dev/null +++ b/desktop/src-tauri/src/commands/admin/reads_tests.rs @@ -0,0 +1,162 @@ +//! Native read-error tests: the production reader against a raw HTTP stub. + +use super::super::read_stub::{response, serve_raw, signer_of}; +use super::*; + +async fn read_error(responses: Vec>) -> serde_json::Value { + let (port, _) = serve_raw(responses); + let url = format!( + "http://127.0.0.1:{port}/api/admin/v1/events/{}", + "ab".repeat(32) + ); + let err = send_admin_read(&nostr::Keys::generate(), &url, SUCCESS_JSON_CAP) + .await + .unwrap_err(); + serde_json::to_value(err).unwrap() +} + +fn meta(v: &serde_json::Value) -> serde_json::Value { + serde_json::json!({ + "relayStatus": v["relayStatus"], + "bodyComplete": v["bodyComplete"], + "bodyEmpty": v["bodyEmpty"], + "code": v["code"], + }) +} + +#[tokio::test] +async fn empty_404_and_405_read_as_complete_and_empty() { + for status in ["404 Not Found", "405 Method Not Allowed"] { + let v = read_error(vec![response(status, "")]).await; + let code: u16 = status[..3].parse().unwrap(); + assert_eq!( + meta(&v), + serde_json::json!({"relayStatus": code, "bodyComplete": true, "bodyEmpty": true, "code": null}) + ); + } +} + +#[tokio::test] +async fn coded_404_carries_the_relay_code() { + let body = r#"{"error":{"code":"event_not_found","message":"no such event"}}"#; + let v = read_error(vec![response("404 Not Found", body)]).await; + assert_eq!( + meta(&v), + serde_json::json!({"relayStatus": 404, "bodyComplete": true, "bodyEmpty": false, "code": "event_not_found"}) + ); +} + +#[tokio::test] +async fn truncated_and_over_cap_bodies_are_incomplete() { + // Declares 100 bytes, sends 2, then closes: the stream errors mid-body. + let truncated = + b"HTTP/1.1 404 Not Found\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{}".to_vec(); + let over_cap = format!( + "HTTP/1.1 404 Not Found\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + ERROR_BODY_CAP + 1 + ) + .into_bytes(); + for raw in [truncated, over_cap] { + let v = read_error(vec![raw]).await; + assert_eq!( + meta(&v), + serde_json::json!({"relayStatus": 404, "bodyComplete": false, "bodyEmpty": false, "code": null}) + ); + } +} + +#[tokio::test] +async fn transport_failure_has_no_status() { + client::init_admin_client().expect("client builds"); + let port = std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); // listener dropped: connection refused + let url = format!("http://127.0.0.1:{port}/api/admin/v1/communities"); + let err = send_admin_read(&nostr::Keys::generate(), &url, SUCCESS_JSON_CAP) + .await + .unwrap_err(); + assert_eq!( + meta(&serde_json::to_value(err).unwrap()), + serde_json::json!({"relayStatus": null, "bodyComplete": false, "bodyEmpty": false, "code": null}) + ); +} + +#[tokio::test] +async fn a_401_retry_is_signed_by_the_same_keys() { + let keys = nostr::Keys::generate(); + let (port, seen) = serve_raw(vec![ + response("401 Unauthorized", ""), + response("200 OK", r#"{"items":[],"nextCursor":null}"#), + ]); + let url = format!("http://127.0.0.1:{port}/api/admin/v1/communities?q=te"); + let bytes = send_admin_read(&keys, &url, SUCCESS_JSON_CAP) + .await + .unwrap(); + assert_eq!(bytes, br#"{"items":[],"nextCursor":null}"#); + let seen = seen.lock().unwrap(); + assert_eq!(seen.len(), 2); + for request in seen.iter() { + assert!( + request.starts_with("GET /api/admin/v1/communities?q=te "), + "{request}" + ); + assert_eq!(signer_of(request), keys.public_key().to_hex()); + } +} + +#[tokio::test] +async fn a_search_carrying_a_key_backup_is_never_sent() { + client::init_admin_client().expect("client builds"); + let backup = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtc"; + for q in [ + backup.to_string(), + format!("alice {backup} bob"), + backup.to_ascii_uppercase(), + ] { + let (port, seen) = serve_raw(vec![response("200 OK", "{}")]); + let query = routes::AdminQuery { + q: Some(q), + ..Default::default() + }; + let url = format!( + "http://127.0.0.1:{port}/api/admin/v1/communities?{}", + query.to_query_string() + ); + let err = send_admin_read(&nostr::Keys::generate(), &url, SUCCESS_JSON_CAP) + .await + .unwrap_err(); + assert!(err.message.contains("NIP-49 key-backup"), "{}", err.message); + assert!(seen.lock().unwrap().is_empty(), "request reached the relay"); + } +} + +#[test] +fn read_urls_validate_the_explicit_host_and_encode_the_query() { + let url = read_url( + "https://admin.example.com", + &routes::AdminRoute::MembersSearch, + routes::AdminQuery { + community_host: Some(" Team.Example.com ".to_string()), + q: Some("al ice&x".to_string()), + ..Default::default() + }, + ) + .unwrap(); + assert!( + url.starts_with("https://admin.example.com/api/admin/v1/members/search?"), + "{url}" + ); + assert!(url.contains("communityHost=team.example.com"), "{url}"); + assert!(url.contains("q=al+ice%26x"), "{url}"); + let bad = read_url( + "https://admin.example.com", + &routes::AdminRoute::MembersSearch, + routes::AdminQuery { + community_host: Some("https://team.example.com/x".to_string()), + ..Default::default() + }, + ); + assert!(bad.unwrap_err().starts_with("invalid community host")); +} diff --git a/desktop/src-tauri/src/commands/admin/restrictions.rs b/desktop/src-tauri/src/commands/admin/restrictions.rs new file mode 100644 index 0000000000..a801866e78 --- /dev/null +++ b/desktop/src-tauri/src/commands/admin/restrictions.rs @@ -0,0 +1,143 @@ +//! Restriction list and lifts for an explicit community. +//! +//! The caller names the community (`communityHost`, validated natively) and +//! the relay and signer its list loaded under. A lift is refused before any +//! request (`notSent`) when either changed, and every attempt — including the +//! 401 retry — is signed with the one key snapshot that was checked. + +use super::error::AdminMutationError; +use super::{helpers, origin, routes, SUCCESS_JSON_CAP}; + +const RELAY_SCOPE_CHANGED: &str = + "active community changed since restrictions loaded; nothing was sent. Reload to continue."; +const SIGNER_CHANGED: &str = + "active identity changed since restrictions loaded; nothing was sent. Reload to continue."; + +/// Build a restrictions-route URL for `community_host`, refusing when the +/// active relay no longer matches `expected_relay`. +fn restrictions_url( + origin: &str, + route: &routes::AdminRoute, + community_host: &str, + cursor: Option, + expected_relay: &str, + relay_base: &str, +) -> Result { + let origin = origin::AdminOrigin::parse(origin)?; + if expected_relay.trim().is_empty() + || crate::relay::assert_expected_relay_scope(Some(expected_relay), relay_base).is_err() + { + return Err(RELAY_SCOPE_CHANGED.to_string()); + } + let host = buzz_core_pkg::tenant::validate_community_host(community_host.trim()) + .map_err(|e| format!("invalid community host: {e}"))?; + let q = routes::AdminQuery { + community_host: Some(host), + cursor, + ..Default::default() + }; + Ok(origin.route_url(route, &q)) +} + +/// List active bans and timeouts — +/// GET /api/admin/v1/members/restrictions?communityHost={host}[&cursor={token}]. +/// +/// Returns `{ items: [...], nextCursor: string|null }` (relay page size 200). +#[tauri::command] +pub async fn admin_list_restrictions( + origin: String, + community_host: String, + cursor: Option, + expected_relay: String, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let relay_base = crate::relay::relay_api_base_url_with_override(&state); + let url = restrictions_url( + &origin, + &routes::AdminRoute::MemberRestrictionsList, + &community_host, + cursor, + &expected_relay, + &relay_base, + )?; + let bytes = helpers::fetch_admin_json(&url, SUCCESS_JSON_CAP, &state).await?; + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}")) +} + +/// A lift as the restriction row froze it. +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminLiftIntent { + pub origin: String, + pub community_host: String, + pub expected_relay: String, + pub expected_signer: String, + pub kind: LiftKind, + pub pubkey: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LiftKind { + Ban, + Timeout, +} + +/// Validate `intent` against the relay and signer snapshot; every refusal +/// here happens before any request. +pub(super) fn lift_url( + intent: &AdminLiftIntent, + relay_base: &str, + signer_hex: &str, +) -> Result { + if intent.expected_signer.trim().is_empty() + || crate::relay::assert_expected_signer(Some(&intent.expected_signer), signer_hex).is_err() + { + return Err(SIGNER_CHANGED.to_string()); + } + let pubkey = + routes::Hex64::parse(&intent.pubkey).map_err(|e| format!("invalid member pubkey: {e}"))?; + let route = match intent.kind { + LiftKind::Ban => routes::AdminRoute::MemberBanDelete { pubkey }, + LiftKind::Timeout => routes::AdminRoute::MemberTimeoutDelete { pubkey }, + }; + restrictions_url( + &intent.origin, + &route, + &intent.community_host, + None, + &intent.expected_relay, + relay_base, + ) +} + +/// Lift an active ban or timeout — +/// DELETE /api/admin/v1/members/{pubkey}/{ban|timeout}?communityHost={host}. +/// +/// 204 on success; 409 when nothing is active, surfaced as `AdminMutationError`. +#[tauri::command] +pub async fn admin_lift_restriction( + intent: AdminLiftIntent, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result<(), AdminMutationError> { + let keys = state.signing_keys().map_err(AdminMutationError::not_sent)?; + let relay_base = crate::relay::relay_api_base_url_with_override(&state); + send_lift(&intent, &keys, &relay_base).await +} + +/// Send a validated lift, signing the first attempt and any retry with `keys`. +pub(super) async fn send_lift( + intent: &AdminLiftIntent, + keys: &nostr::Keys, + relay_base: &str, +) -> Result<(), AdminMutationError> { + let url = lift_url(intent, relay_base, &keys.public_key().to_hex()) + .map_err(AdminMutationError::not_sent)?; + helpers::send_admin_mutation(keys, reqwest::Method::DELETE, &url, None, SUCCESS_JSON_CAP) + .await?; + Ok(()) +} + +#[cfg(test)] +#[path = "restrictions_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/commands/admin/restrictions_tests.rs b/desktop/src-tauri/src/commands/admin/restrictions_tests.rs new file mode 100644 index 0000000000..3269665d0a --- /dev/null +++ b/desktop/src-tauri/src/commands/admin/restrictions_tests.rs @@ -0,0 +1,104 @@ +//! Explicit-host restriction and lift tests. + +use super::super::read_stub::{response, serve_raw, signer_of}; +use super::*; + +const RELAY: &str = "https://relay.example.com"; + +fn intent(origin: String, signer: &str) -> AdminLiftIntent { + AdminLiftIntent { + origin, + community_host: "Team.Example.com".to_string(), + expected_relay: "wss://relay.example.com".to_string(), + expected_signer: signer.to_string(), + kind: LiftKind::Timeout, + pubkey: "ab".repeat(32), + } +} + +#[test] +fn lift_url_sends_the_page_host_not_the_active_relay() { + let signer = "11".repeat(32); + let url = lift_url( + &intent("https://admin.example.com".into(), &signer), + RELAY, + &signer, + ) + .unwrap(); + assert!( + url.ends_with(&format!( + "/members/{}/timeout?communityHost=team.example.com", + "ab".repeat(32) + )), + "{url}" + ); +} + +#[test] +fn list_url_carries_host_and_cursor_and_refuses_a_changed_relay() { + let url = restrictions_url( + "https://admin.example.com", + &routes::AdminRoute::MemberRestrictionsList, + "other.example.com", + Some("tok".to_string()), + "wss://relay.example.com", + RELAY, + ) + .unwrap(); + assert!(url.contains("communityHost=other.example.com"), "{url}"); + assert!(url.contains("cursor=tok"), "{url}"); + for expected in ["wss://relay-b.example.com", "", " "] { + let err = restrictions_url( + "https://admin.example.com", + &routes::AdminRoute::MemberRestrictionsList, + "other.example.com", + None, + expected, + RELAY, + ) + .unwrap_err(); + assert_eq!(err, RELAY_SCOPE_CHANGED); + } +} + +#[tokio::test] +async fn lift_refuses_before_sending_on_signer_change_or_bad_host() { + // Nothing listens on this origin: any request would be a transport error, + // not the `notSent` refusal asserted here. + let keys = nostr::Keys::generate(); + let other = "22".repeat(32); + let mut bad_host = intent("http://127.0.0.1:9".into(), &keys.public_key().to_hex()); + bad_host.community_host = "https://team.example.com/x".to_string(); + for bad in [ + intent("http://127.0.0.1:9".into(), &other), + intent("http://127.0.0.1:9".into(), " "), + bad_host, + ] { + let err = send_lift(&bad, &keys, RELAY).await.unwrap_err(); + assert!(err.not_sent, "{err:?}"); + } +} + +#[tokio::test] +async fn lift_signs_the_send_and_401_retry_with_the_checked_keys() { + let keys = nostr::Keys::generate(); + let (port, seen) = serve_raw(vec![ + response("401 Unauthorized", ""), + response("204 No Content", ""), + ]); + let lift = intent( + format!("http://127.0.0.1:{port}"), + &keys.public_key().to_hex(), + ); + send_lift(&lift, &keys, RELAY).await.unwrap(); + let seen = seen.lock().unwrap(); + assert_eq!(seen.len(), 2); + for request in seen.iter() { + assert!(request.starts_with("DELETE "), "{request}"); + assert!( + request.contains("communityHost=team.example.com"), + "{request}" + ); + assert_eq!(signer_of(request), keys.public_key().to_hex()); + } +} diff --git a/desktop/src-tauri/src/commands/admin/routes.rs b/desktop/src-tauri/src/commands/admin/routes.rs index 30e2ef8ba7..ddc11fb3d4 100644 --- a/desktop/src-tauri/src/commands/admin/routes.rs +++ b/desktop/src-tauri/src/commands/admin/routes.rs @@ -100,6 +100,18 @@ pub enum AdminRoute { EventDelete { id: Hex64, }, + /// GET /communities — host-prefix directory (`q`, `cursor`, `limit`). + CommunitiesList, + /// GET /members/search — communityHost, `q`, `limit` in query. + MembersSearch, + /// GET /members/{pubkey} — communityHost in query. + MemberDetail { + pubkey: Hex64, + }, + /// GET /events/{id} — communityHost in query. + EventDetail { + id: Hex64, + }, } /// A validated 64 lowercase-hex id (a pubkey or an event id) for use as a URL @@ -158,6 +170,10 @@ impl AdminRoute { format!("/members/{}/timeout", pubkey.as_str()) } AdminRoute::EventDelete { id } => format!("/events/{}/delete", id.as_str()), + AdminRoute::CommunitiesList => "/communities".to_string(), + AdminRoute::MembersSearch => "/members/search".to_string(), + AdminRoute::MemberDetail { pubkey } => format!("/members/{}", pubkey.as_str()), + AdminRoute::EventDetail { id } => format!("/events/{}", id.as_str()), } } } @@ -183,6 +199,8 @@ pub struct AdminQuery { pub cursor: Option, /// Community host the relay resolves to its tenant (restrictions routes). pub community_host: Option, + /// Search text (directory host prefix, member name). + pub q: Option, } impl AdminQuery { @@ -220,6 +238,9 @@ impl AdminQuery { if let Some(v) = &self.cursor { parts.push(format!("cursor={}", urlencoded(v))); } + if let Some(v) = &self.q { + parts.push(format!("q={}", urlencoded(v))); + } parts.join("&") } } diff --git a/desktop/src-tauri/src/egress_guard.rs b/desktop/src-tauri/src/egress_guard.rs index 1d990477f6..e630cff207 100644 --- a/desktop/src-tauri/src/egress_guard.rs +++ b/desktop/src-tauri/src/egress_guard.rs @@ -15,6 +15,10 @@ //! | 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` | +//! | 10 | `build_admin_mutation_request` (URL of admin community reads and every mutation) | `commands/admin/helpers.rs` | +//! | 11 | `fetch_admin_json` (URL of every legacy admin GET) | `commands/admin/helpers.rs` | +//! | 12 | `admin_probe_inner` (probe URL, before the unsigned request) | `commands/admin/mod.rs` | +//! | 13 | `fetch_feedback_attachment` (attachment URL, before signing) | `commands/admin/attachment.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 5511aa8d72..c2d63b3cff 100644 --- a/desktop/src-tauri/src/egress_guard_tests.rs +++ b/desktop/src-tauri/src/egress_guard_tests.rs @@ -295,12 +295,22 @@ const EVENTS_INVENTORY: &[(&str, usize, usize)] = &[ ("src/commands/teams/pending/tests/gate.rs", 1, 0), // Accepting stub relay in the starter-channel creation tests. ("src/commands/channels/starter_tests.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`). + // Admin API: every JSON body passes the guard in + // `helpers::send_admin_mutation` (injection tests in admin `direct_action_tests.rs`), + // and every admin API request URL passes it before signing or sending: + // `build_admin_mutation_request` and `fetch_admin_json` (injection test in + // admin `reads_tests.rs`), the connection probe before its first, unsigned + // request, and the feedback-attachment fetch before its sign-and-retry loop + // (injection tests in admin `mod_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), + // test only construct URLs that flow into that guarded boundary. The + // bodyless `GET /events/{id}` preview read and its mock-relay test carry + // no key material. + ("src/commands/admin/helpers.rs", 0, 3), // boundaries 9, 10, 11 + ("src/commands/admin/mod.rs", 0, 1), // boundary 12 + ("src/commands/admin/attachment.rs", 0, 1), // boundary 13 + ("src/commands/admin/routes.rs", 3, 0), + ("src/commands/admin/reads_tests.rs", 1, 0), ("src/commands/admin/direct_action_tests.rs", 1, 0), ]; @@ -468,6 +478,10 @@ fn ncryptsec_handling_is_confined_to_allowlisted_files() { "src/native_websocket.rs", "src/commands/admin/helpers.rs", "src/commands/admin/direct_action_tests.rs", + "src/commands/admin/reads_tests.rs", + "src/commands/admin/mod.rs", + "src/commands/admin/mod_tests.rs", + "src/commands/admin/attachment.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 e9c96a2fe3..4497eb78e5 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -898,8 +898,12 @@ pub fn run() { admin_put_operator, admin_delete_operator, admin_list_restrictions, - admin_lift_ban, - admin_lift_timeout, + admin_lift_restriction, + admin_list_communities, + admin_search_members, + admin_get_member, + admin_get_event, + admin_connected_community_host, admin_direct_action, get_admin_origin, set_admin_origin, diff --git a/desktop/src/features/admin-console/AdminConsoleActionsTab.tsx b/desktop/src/features/admin-console/AdminConsoleActionsTab.tsx index c8299e8209..38374d9fd6 100644 --- a/desktop/src/features/admin-console/AdminConsoleActionsTab.tsx +++ b/desktop/src/features/admin-console/AdminConsoleActionsTab.tsx @@ -1,279 +1,458 @@ /** - * Actions tab — ban, time out, or delete without a report. + * Direct actions — ban, time out, or delete without a report — in one + * community. * - * 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. + * `DirectActionsProvider` owns the controller at panel level, so a frozen, + * pending or ambiguous intent (and its requestId) survives tab and community + * page changes; only an explicit Discard drops it. 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. + * 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 the provider + * on an identity or origin change. * - * 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. + * `ActionsSection` is the form for one community page's host. Review needs + * the target's `/members/{pk}` state (staff blocks it) or the event preview, + * each fenced by (origin, signer, community, target, generation) so a late + * answer for another community can never unlock this one. */ -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"; + createContext, + type ReactNode, + useContext, + useDeferredValue, + useEffect, + useRef, + useState, +} from "react"; +import { toast } from "sonner"; +import { containsSecretKey } from "@/features/onboarding/lib/keyImportInput"; +import { + MemberSearchResult, + memberSearchLabel, + useMemberCandidates, +} from "@/features/profile/ui/MemberSearchResult"; import { getRelayWsUrl } from "@/shared/api/tauri"; import type { UserSearchResult } from "@/shared/api/types"; -import { parsePubkeyInput } from "@/shared/lib/nostrUtils"; +import { parseEventIdInput } from "@/shared/lib/nostrUtils"; import { truncateNpub } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; import { PubKey } from "@/shared/ui/PubKey"; import { directAdminAction, + getAdminEvent, + getAdminMember, + searchAdminMembers, type AdminDirectAction, type AdminDirectIntent, - communityHostFromRelayUrl, - normalizeCommunityHost, + type AdminEventPreviewDto, + type AdminMemberDetailDto, } from "./api"; +import { + CommunityBadge, + type CommunityRef, + NotConnectedWarning, +} from "./AdminConsoleCommunityBadge"; import { adminErrorCode, adminErrorMessage, - adminMutationBodyComplete, adminMutationNotSent, - adminMutationRelayStatus, + adminReadErrorCode, + adminRouteUnsupported, + type AsyncState, + formatAbsoluteTimestamp, + formatTimestamp, preserveRequestIdOnError, - useAsyncLoad, + UNSUPPORTED_BROWSING, } 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", }; +const STAFF_COPY = + "Relay staff can't be banned or timed out. Remove their staff role first."; + 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."; + return STAFF_COPY; 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); + return adminRouteUnsupported(e) + ? "This relay doesn't support direct actions yet." + : adminErrorMessage(e); } } -/** An `nsec` or NIP-49 backup, in either case. */ -function isSecretKey(input: string): boolean { - return classifyKeyImportInput(input.toLowerCase()) !== "unknown"; +/** Why a read failed, in words; never "not found" unless the relay said so. */ +function readErrorMessage(e: unknown, notFound: string): string { + if (adminRouteUnsupported(e)) return UNSUPPORTED_BROWSING; + if (adminReadErrorCode(e) === "event_not_found") return notFound; + return adminErrorMessage(e); } -function memberLabel(user: UserSearchResult): string { - return ( - user.displayName?.trim() || - user.nip05Handle?.trim() || - truncateNpub(user.pubkey) - ); +/** + * Load `load()` for `key`; a result is shown only while its key is current. + * `null` key means nothing to load. + */ +export function useFencedLoad( + key: string | null, + load: () => Promise, +): AsyncState { + const [result, setResult] = useState<{ + key: string; + state: AsyncState; + } | null>(null); + const loadRef = useRef(load); + loadRef.current = load; + useEffect(() => { + if (key === null) return; + let active = true; + loadRef.current().then( + (data) => active && setResult({ key, state: { status: "ok", data } }), + (error: unknown) => + active && + setResult({ + key, + state: { status: "error", message: adminErrorMessage(error), error }, + }), + ); + return () => { + active = false; + }; + }, [key]); + if (key === null) return { status: "idle" }; + return result?.key === key ? result.state : { status: "loading" }; } -/** 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; +// ── Panel-level controller ──────────────────────────────────────────────── + +type Draft = { + host: string; + action: AdminDirectAction; + target: string; + member: UserSearchResult | null; + reason: string; + secs: string; +}; + +const emptyDraft = (host: string): Draft => ({ + host, + action: "ban", + target: "", + member: null, + reason: "", + secs: "", +}); + +/** The frozen intent plus what the confirm step shows about its target. */ +type Frozen = { + intent: AdminDirectIntent; + community: CommunityRef; + name: string | null; + member: AdminMemberDetailDto | null; + preview: AdminEventPreviewDto | null; +}; + +type DirectActions = { + canMutate: boolean; + origin: string; + pubkey: string; + generation: number; + draftFor: (host: string) => Draft; + setDraft: (draft: Draft) => void; + frozen: Frozen | null; + pending: boolean; + error: string | null; + submitting: boolean; + review: ( + draft: Draft, + target: string, + lookup: Omit, + ) => Promise; + confirm: () => Promise; + discard: () => void; + /** Host whose Actions section is on screen, for the hidden-failure toast. */ + shownHost: { current: string | null }; +}; + +const DirectActionsContext = createContext(null); + +export function useDirectActions(): DirectActions { + const ctx = useContext(DirectActionsContext); + if (!ctx) throw new Error("useDirectActions outside DirectActionsProvider"); + return ctx; } -export function ActionsTab({ +export function DirectActionsProvider({ canMutate, origin, pubkey, + generation, + children, }: { canMutate: boolean; origin: string; /** Active signer; frozen into the intent at review. */ pubkey: string; + generation: number; + children: ReactNode; }) { - 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 [draft, setDraft] = useState(null); + const [frozen, setFrozen] = useState(null); const [pending, setPending] = useState(false); - const [frozen, setFrozen] = useState(null); + const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); const inFlight = useRef(false); + const shownHost = useRef(null); - // 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; + const locked = async (run: () => Promise) => { + if (inFlight.current) 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; + await run(); + } finally { + inFlight.current = false; + setSubmitting(false); + } + }; + + const review: DirectActions["review"] = (d, target, lookup) => + locked(async () => { + if (frozen) return; + setError(null); + try { + const expectedRelay = await getRelayWsUrl(); + const m = d.action !== "delete" ? d.member : null; + setFrozen({ + intent: { + origin, + expectedRelay, + expectedPubkey: pubkey, + communityHost: d.host, + action: d.action, + target, + requestId: crypto.randomUUID(), + reason: d.reason.trim() || undefined, + expirationSecs: d.action === "timeout" ? Number(d.secs) : undefined, + }, + name: + m && (m.displayName || m.nip05Handle) ? memberSearchLabel(m) : null, + ...lookup, + }); + } catch (e) { + setError(adminErrorMessage(e)); } - const isMember = action !== "delete" && member; - setFrozenName( - isMember && (member.displayName || member.nip05Handle) - ? memberLabel(member) + }); + + const confirm = () => + locked(async () => { + if (!frozen) return; + const { intent } = frozen; + setError(null); + setPending(false); + try { + const result = await directAdminAction(intent); + if (result.state === "pending") { + setPending(true); + } else { + toast.success(`${ACTION_LABELS[intent.action]}: done`); + setFrozen(null); + setDraft(emptyDraft(intent.communityHost)); + } + } 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); + } + const message = directErrorMessage(e); + setError(message); + if (shownHost.current !== intent.communityHost) { + toast.error( + `${ACTION_LABELS[intent.action]} in ${intent.communityHost} failed: ${message}`, + ); + } + } + }); + + const value: DirectActions = { + canMutate, + origin, + pubkey, + generation, + draftFor: (host) => (draft?.host === host ? draft : emptyDraft(host)), + setDraft, + frozen, + pending, + error, + submitting, + review, + confirm, + discard: () => { + setFrozen(null); + setError(null); + setPending(false); + }, + shownHost, + }; + return ( + + {children} + + ); +} + +// ── Actions section ─────────────────────────────────────────────────────── + +/** Client-side checks; the relay re-validates everything. */ +function validate(draft: Draft, eventId: string | null): string | null { + if (draft.action === "delete" && !eventId) { + return "Enter a 64-hex event id, or a note1… or nevent1… link."; + } + if (draft.action !== "delete" && !draft.member) { + return "Choose a member: search by name, or paste an npub or hex key."; + } + const secs = Number(draft.secs); + if (draft.action === "timeout" && !(Number.isInteger(secs) && secs > 0)) { + return "Duration must be a whole number of seconds above zero."; + } + return null; +} + +function MemberState({ member }: { member: AdminMemberDetailDto }) { + const facts = [ + member.role === null + ? "Not on the community roster" + : `Role: ${member.role}`, + member.banned ? "Currently banned" : null, + member.mutedUntil + ? `Timed out until ${formatAbsoluteTimestamp(member.mutedUntil)}` + : null, + member.isStaff ? "Relay staff" : null, + ].filter(Boolean); + return ( +

+ {facts.join(" · ")} +

+ ); +} + +function EventPreview({ event }: { event: AdminEventPreviewDto }) { + return ( +
+ +

{event.content}

+ {event.deletedAt && ( +

+ Already deleted {formatTimestamp(event.deletedAt)}. +

+ )} +
+ ); +} + +export function ActionsSection({ community }: { community: CommunityRef }) { + const communityHost = community.host; + const c = useDirectActions(); + const { frozen } = c; + const draft = c.draftFor(communityHost); + const set = (patch: Partial) => c.setDraft({ ...draft, ...patch }); + const [invalid, setInvalid] = useState(null); + + useEffect(() => { + c.shownHost.current = communityHost; + return () => { + c.shownHost.current = null; + }; + }, [c.shownHost, communityHost]); + + const eventId = + draft.action === "delete" ? parseEventIdInput(draft.target) : null; + const memberPk = + draft.action !== "delete" ? (draft.member?.pubkey ?? null) : null; + const fence = `${c.origin}\n${c.pubkey}\n${communityHost}\n${c.generation}`; + const memberState = useFencedLoad( + memberPk && `${fence}\nmember\n${memberPk}`, + () => getAdminMember(c.origin, communityHost, memberPk ?? ""), + ); + const previewState = useFencedLoad( + eventId && `${fence}\nevent\n${eventId}`, + () => getAdminEvent(c.origin, communityHost, eventId ?? ""), + ); + const lookup = draft.action === "delete" ? previewState : memberState; + const lookupBlock = + lookup.status === "error" + ? readErrorMessage(lookup.error, "Not found in this community.") + : memberState.status === "ok" && + draft.action !== "delete" && + memberState.data.isStaff + ? STAFF_COPY + : null; + + if (frozen && frozen.intent.communityHost !== communityHost) { + return ( +
+

+ Finish or discard the pending action in{" "} + {frozen.intent.communityHost}. +

+ +
+ ); + } + + const locked = frozen !== null || c.submitting || !c.canMutate; + const handleReview = () => { + const problem = validate(draft, eventId); + setInvalid(problem); + if (problem || lookup.status !== "ok" || lookupBlock) return; + void c.review(draft, eventId ?? memberPk ?? "", { + community, + member: memberState.status === "ok" ? memberState.data : null, + preview: + draft.action === "delete" && previewState.status === "ok" + ? previewState.data : 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, + name: keyof Draft, placeholder: string, extra = "", type = "text", ) => ( set(e.target.value)} + onChange={(e) => set({ [name]: e.target.value })} placeholder={placeholder} type={type} - value={value} + value={draft[name] as string} /> ); + const error = invalid ?? c.error; return (
@@ -283,135 +462,65 @@ export function ActionsTab({ data-testid={`direct-action-${a}`} disabled={locked} key={a} - onClick={() => setAction(a)} + onClick={() => set({ action: a })} size="sm" type="button" - variant={a === action ? "default" : "outline"} + variant={a === draft.action ? "default" : "outline"} > {ACTION_LABELS[a]} ))}
- {editingHost ? ( - input("host", host, editHost, "Community host (e.g. team.example.com)") + {draft.action === "delete" ? ( + input("target", "Event id (hex), note1… or nevent1…", "font-mono") ) : ( -

- - Community: {host} - - -

- )} - {action === "delete" ? ( - input("target", target, setTarget, "Event id (hex)", "font-mono") - ) : ( - set({ member })} /> )} - {action === "timeout" && - input("duration", secs, setSecs, "Duration (seconds)", "", "number")} - {input("reason", reason, setReason, "Reason (optional)")} + {!frozen && memberState.status === "ok" && draft.action !== "delete" && ( + + )} + {!frozen && previewState.status === "ok" && draft.action === "delete" && ( + + )} + {!frozen && lookupBlock && ( +

+ {lookupBlock} +

+ )} + {draft.action === "timeout" && + input("secs", "Duration (seconds)", "", "number")} + {input("reason", "Reason (optional)")}

- {reasonAudienceCopy(frozen?.action ?? action)} + {reasonAudienceCopy(frozen?.intent.action ?? draft.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)"} -

-
- - -
-
+ ) : ( + )} + + + + ); +} + +// ── Member picking ──────────────────────────────────────────────────────── + /** - * 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. + * The admin search source: `GET /members/search` in `communityHost`, fenced + * like the lookups. A pasted secret key never leaves the device. */ -function MemberPicker({ +function useAdminMemberSearch(communityHost: string, query: string) { + const c = useDirectActions(); + const secret = containsSecretKey(query); + const state = useFencedLoad( + query && !secret + ? `${c.origin}\n${c.pubkey}\n${communityHost}\n${c.generation}\nsearch\n${query}` + : null, + async () => + (await searchAdminMembers(c.origin, communityHost, query)).items.map( + (m): UserSearchResult => ({ + pubkey: m.pubkey, + displayName: m.displayName, + avatarUrl: m.avatarUrl, + nip05Handle: m.nip05, + ownerPubkey: null, + isAgent: false, + }), + ), + ); + return { + secret, + data: state.status === "ok" ? state.data : undefined, + isLoading: state.status === "loading", + error: + state.status === "error" + ? readErrorMessage(state.error, state.message) + : null, + }; +} + +/** + * Search this community's profiles (including former members), or paste an + * npub or hex key. + */ +export function AdminMemberPicker({ + communityHost, disabled, member, onChange, - searchEnabled, }: { + communityHost: string; 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 search = useAdminMemberSearch(communityHost, deferred); + const secret = search.secret || containsSecretKey(query); + const candidates = useMemberCandidates(deferred, secret ? {} : search); - const hint = !searchEnabled && ( -

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

- ); if (member) { return ( -
- + {memberSearchLabel(member)} + +
); } @@ -499,11 +692,7 @@ function MemberPicker({ data-testid="direct-member-input" disabled={disabled} onChange={(e) => setQuery(e.target.value)} - placeholder={ - searchEnabled - ? "Search by name, or paste an npub or hex key" - : "npub or hex key" - } + placeholder="Search by name, or paste an npub or hex key" value={query} /> {secret && ( @@ -514,45 +703,73 @@ function MemberPicker({ That's a secret key. Never paste it here.

)} - {hint} - {results.length > 0 && ( + {search.error && !secret && ( +

+ {search.error} +

+ )} + {candidates.length > 0 && (
- {results.map((user) => ( - + showKey + testId={`direct-member-result-${user.pubkey}`} + user={user} + /> ))}
)} ); } + +/** Members section: pick someone, then ban or time them out. */ +export function MembersSection({ + communityHost, + onAct, +}: { + communityHost: string; + /** Called after prefilling the Actions draft, to show that section. */ + onAct: () => void; +}) { + const c = useDirectActions(); + const [member, setMember] = useState(null); + return ( +
+ + {member && ( + + {(["ban", "timeout"] as const).map((action) => ( + + ))} + + )} +
+ ); +} diff --git a/desktop/src/features/admin-console/AdminConsoleCommunities.tsx b/desktop/src/features/admin-console/AdminConsoleCommunities.tsx new file mode 100644 index 0000000000..2b175df245 --- /dev/null +++ b/desktop/src/features/admin-console/AdminConsoleCommunities.tsx @@ -0,0 +1,287 @@ +/** + * Communities tab and the per-community page. + * + * The tab searches the relay's community directory by host prefix and pins + * the connected community, resolved natively from the active relay and + * matched exactly, independent of whichever directory page is loaded. A + * community page shows that community's Reports, Restrictions, Members and + * Actions. Every section names the community by the page's host; nothing is + * derived from the active relay. + */ + +import { useDeferredValue, useMemo, useRef, useState } from "react"; +import { ArrowLeft, LoaderCircle } from "lucide-react"; +import { containsSecretKey } from "@/features/onboarding/lib/keyImportInput"; +import { Button } from "@/shared/ui/button"; +import { listAdminCommunities, type AdminCommunityDto } from "./api"; +import { ActionsSection, MembersSection } from "./AdminConsoleActionsTab"; +import { + CommunityBadge, + type CommunityRef, + NotConnectedWarning, + useCommunityNav, +} from "./AdminConsoleCommunityBadge"; +import { + adminErrorMessage, + adminRouteUnsupported, + ErrorMessage, + LoadingSpinner, + UNSUPPORTED_BROWSING, + useAsyncLoad, +} from "./AdminConsolePanelHelpers"; +import { ReportsTab } from "./AdminConsoleReportsTab"; +import { RestrictionsSection } from "./AdminConsoleStaffingTab"; + +// ── Communities tab ─────────────────────────────────────────────────────── + +export function CommunitiesTab({ + origin, + generation, +}: { + origin: string; + generation: number; +}) { + const { open, connectedHost } = useCommunityNav(); + const [query, setQuery] = useState(""); + const q = useDeferredValue(query.trim().toLowerCase()); + // A pasted secret key never leaves the device. + const secret = containsSecretKey(q); + // The raw input leads the deferred query, so pagination checks both: an + // older page's cursor must never carry newly typed key material. + const blocked = secret || containsSecretKey(query); + // One identity per search transition, so A→B→A is three searches and a + // page requested under an earlier one can never land under a later one. + const key = `${origin}\n${q}\n${generation}`; + // biome-ignore lint/correctness/useExhaustiveDependencies: a new identity per key change is the point + const search = useMemo(() => ({}), [key]); + const searchRef = useRef(search); + searchRef.current = search; + const [more, setMore] = useState<{ + search: object; + items: AdminCommunityDto[]; + nextCursor: string | null; + } | null>(null); + const [moreState, setMoreState] = useState<{ + search: object; + busy: boolean; + error: string | null; + } | null>(null); + + const first = useAsyncLoad( + () => + secret + ? Promise.resolve({ items: [], nextCursor: null }) + : listAdminCommunities(origin, q), + [origin, q], + generation, + ); + // The connected community, by exact host match, whatever page is loaded. + const pinned = useAsyncLoad( + async () => + connectedHost + ? ((await listAdminCommunities(origin, connectedHost)).items.find( + (c) => c.host === connectedHost, + ) ?? null) + : null, + [origin, connectedHost], + generation, + ); + + if (first.status === "error" && adminRouteUnsupported(first.error)) { + return ( +

+ {UNSUPPORTED_BROWSING} +

+ ); + } + + const extra = more?.search === search ? more : null; + const moreStatus = moreState?.search === search ? moreState : null; + // Pinned only while it matches the search the way the relay does: a + // case-insensitive host prefix. + const pinnedRow = + pinned.status === "ok" && pinned.data?.host.toLowerCase().startsWith(q) + ? pinned.data + : null; + const items = + first.status === "ok" + ? [...first.data.items, ...(extra?.items ?? [])].filter( + (c) => c.id !== pinnedRow?.id, + ) + : []; + const nextCursor = extra + ? extra.nextCursor + : first.status === "ok" + ? first.data.nextCursor + : null; + + const loadMore = async () => { + if (!nextCursor || blocked) return; + setMoreState({ search, busy: true, error: null }); + try { + const page = await listAdminCommunities(origin, q, nextCursor); + if (searchRef.current !== search) return; + setMore((prev) => ({ + search, + items: [...(prev?.search === search ? prev.items : []), ...page.items], + nextCursor: page.nextCursor, + })); + setMoreState({ search, busy: false, error: null }); + } catch (e) { + if (searchRef.current !== search) return; + setMoreState({ search, busy: false, error: adminErrorMessage(e) }); + } + }; + + const row = (c: AdminCommunityDto, connected: boolean) => ( +
  • + +
  • + ); + + return ( +
    + setQuery(e.target.value)} + placeholder="Search by host (e.g. team.example.com)" + value={query} + /> + {pinnedRow && ( +
      {row(pinnedRow, true)}
    + )} + {secret && ( +

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

    + )} + {first.status === "loading" && } + {first.status === "error" && } + {first.status === "ok" && items.length === 0 && !pinnedRow && !secret && ( +

    No communities found.

    + )} +
      {items.map((c) => row(c, false))}
    + {moreStatus?.error && } + {nextCursor && !blocked && ( + + )} +
    + ); +} + +// ── Community page ──────────────────────────────────────────────────────── + +const SECTIONS = ["reports", "restrictions", "members", "actions"] as const; +type Section = (typeof SECTIONS)[number]; + +export function CommunityPage({ + community, + canMutate, + origin, + pubkey, + generation, + onBack, +}: { + community: CommunityRef; + canMutate: boolean; + origin: string; + pubkey: string; + generation: number; + onBack: () => void; +}) { + const [section, setSection] = useState
    ("reports"); + return ( +
    +
    + + + +
    +
    + {SECTIONS.map((s) => ( + + ))} +
    + {section === "reports" && ( + + )} + {section === "restrictions" && ( + + )} + {section === "members" && ( + setSection("actions")} + /> + )} + {section === "actions" && } +
    + ); +} diff --git a/desktop/src/features/admin-console/AdminConsoleCommunityBadge.tsx b/desktop/src/features/admin-console/AdminConsoleCommunityBadge.tsx new file mode 100644 index 0000000000..de92fc28c8 --- /dev/null +++ b/desktop/src/features/admin-console/AdminConsoleCommunityBadge.tsx @@ -0,0 +1,104 @@ +/** + * Community identity shared across the console: the navigation context, the + * community badge and the not-connected warning. + */ + +import { createContext, useContext } from "react"; +import { cn } from "@/shared/lib/cn"; +import { getConnectedCommunityHost } from "./api"; +import { useAsyncLoad } from "./AdminConsolePanelHelpers"; +/** A community as a row or badge knows it: id may be unknown, never host. */ +export type CommunityRef = { id: string; host: string; icon?: string | null }; + +type CommunityNav = { + /** Opens a community's page. */ + open: (community: CommunityRef) => void; + /** Host the active relay serves, resolved natively. */ + connectedHost: string | null; +}; + +export const CommunityNavContext = createContext({ + open: () => {}, + connectedHost: null, +}); + +export function useCommunityNav(): CommunityNav { + return useContext(CommunityNavContext); +} + +/** The connected community's host, resolved natively; reloads on `key`. */ +export function useConnectedHost(key: string): string | null { + const state = useAsyncLoad(getConnectedCommunityHost, [key], 0); + return state.status === "ok" ? state.data : null; +} + +/** + * Host plus icon (or the host's first letter). Opens the community's page; + * a row whose source community was purged + * (`id` null) gets a non-navigable "community removed" badge. + */ +export function CommunityBadge({ + id, + host, + icon, +}: { + id: string | null; + host: string | null; + icon?: string | null; +}) { + const { open } = useCommunityNav(); + const base = + "inline-flex max-w-full items-center gap-1.5 rounded-full border border-border/60 px-2 py-0.5 text-xs"; + if (id === null || !host) { + return ( + + community removed + + ); + } + const content = ( + <> + {icon ? ( + + ) : ( + + {host[0]} + + )} + + {host} + + + ); + return ( + + ); +} + +/** "Not the community you're connected to", when that applies. */ +export function NotConnectedWarning({ host }: { host: string }) { + const { connectedHost } = useCommunityNav(); + if (connectedHost === null || connectedHost === host) return null; + return ( + + Not the community you're connected to. + + ); +} diff --git a/desktop/src/features/admin-console/AdminConsoleFeedbackTab.tsx b/desktop/src/features/admin-console/AdminConsoleFeedbackTab.tsx index 417e9d99a9..9af3e4f253 100644 --- a/desktop/src/features/admin-console/AdminConsoleFeedbackTab.tsx +++ b/desktop/src/features/admin-console/AdminConsoleFeedbackTab.tsx @@ -23,6 +23,7 @@ import { type AdminFeedbackStatus, type AdminFeedbackSummaryDto, } from "./api"; +import { CommunityBadge } from "./AdminConsoleCommunityBadge"; import { type AsyncState, type AttachmentMeta, @@ -359,7 +360,10 @@ function FeedbackFields({ data }: { data: AdminFeedbackDto }) { - +
    + Host + +
    void; - showStaffing: boolean; + visible: Set; }) { const allTabs: Array<{ value: Tab; @@ -56,11 +66,9 @@ 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 }] - : []), - ]; + { value: "communities", label: "Communities", Icon: Building2 }, + { value: "operators", label: "Operators", Icon: Users }, + ].filter((t) => visible.has(t.value as Tab)) as typeof allTabs; return (
    {allTabs.map(({ value, label, Icon }) => ( @@ -92,6 +100,7 @@ export function AdminConsolePanel({ pubkey, role, initialTab, + initialCommunity, onSelfMutation, }: { /** @@ -119,6 +128,8 @@ export function AdminConsolePanel({ * Do not pass this prop in production code. */ initialTab?: Tab; + /** Test-only, like `initialTab`: land on this community's page. */ + initialCommunity?: CommunityRef; }) { const isOperator = role === "operator"; const [activeTab, setActiveTab] = useState(initialTab ?? "reports"); @@ -133,70 +144,99 @@ export function AdminConsolePanel({ setGeneration(generationRef.current); }, [pubkey, origin]); + const [community, setCommunity] = useState( + initialCommunity ?? null, + ); + const { activeCommunity } = useCommunities(); + const connectedHost = useConnectedHost(activeCommunity?.relayUrl ?? ""); + const nav = { + open: (next: CommunityRef) => { + setCommunity(next); + setActiveTab("communities"); + }, + connectedHost, + }; + const visibleTabs = new Set([ + "reports", + "feedback", + "communities", + ...(isOperator ? (["operators"] as Tab[]) : []), + ]); + // Reset activeTab to the default when the current tab is no longer visible - // for the current role (e.g. operator→moderator while Staffing is selected). - // Guard is written against tab-visibility (the set TabBar would render for - // this role) rather than a hard-coded role string so it generalises to - // future tabs without requiring a companion role check here. + // (e.g. operator→moderator while Operators is selected). useEffect(() => { - const visibleTabs = new Set([ - "reports", - "feedback", - "actions", - ...(isOperator ? (["staffing"] as Tab[]) : []), - ]); if (!visibleTabs.has(activeTab)) { setActiveTab("reports"); } - }, [isOperator, activeTab]); + }); return ( -
    - - {activeTab === "reports" && ( -
    - + {/* The direct-action controller stays mounted across tabs and community + pages, so a frozen, pending or in-flight action (and its requestId) + survives navigation; an identity or origin change remounts it. */} + +
    + { + if (tab === "communities") setCommunity(null); + setActiveTab(tab); + }} + visible={visibleTabs} /> + {activeTab === "reports" && ( +
    + +
    + )} + {activeTab === "feedback" && ( + + )} + {activeTab === "communities" && + (community ? ( + setCommunity(null)} + origin={origin} + pubkey={pubkey} + /> + ) : ( + + ))} + {activeTab === "operators" && isOperator && ( + + )}
    - )} - {activeTab === "feedback" && ( - - )} - {/* 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 && ( - - )} -
    + + ); } diff --git a/desktop/src/features/admin-console/AdminConsolePanelHelpers.tsx b/desktop/src/features/admin-console/AdminConsolePanelHelpers.tsx index d5c1c9d76a..ea1fa76c63 100644 --- a/desktop/src/features/admin-console/AdminConsolePanelHelpers.tsx +++ b/desktop/src/features/admin-console/AdminConsolePanelHelpers.tsx @@ -9,6 +9,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import type { ReactNode } from "react"; import { AlertCircle, LoaderCircle } from "lucide-react"; import { formatRelativeTime } from "../forum/lib/time"; +import { CommunityBadge } from "./AdminConsoleCommunityBadge"; // ── Generic async state ─────────────────────────────────────────────────── @@ -16,7 +17,7 @@ export type AsyncState = | { status: "idle" } | { status: "loading" } | { status: "ok"; data: T } - | { status: "error"; message: string }; + | { status: "error"; message: string; error?: unknown }; /** * Async load hook with effect-local active-flag cancellation. @@ -51,7 +52,8 @@ export function useAsyncLoad( if (!active) return; setState({ status: "error", - message: e instanceof Error ? e.message : String(e), + message: adminErrorMessage(e), + error: e, }); }, ); @@ -66,7 +68,7 @@ export function useAsyncLoad( // ── Admin error message parsing ─────────────────────────────────────────── /** - * Extract a human-readable message from an admin mutation error. + * Extract a human-readable message from an admin read or mutation error. * * Native admin commands reject with `admin API error: {json}` where the JSON * is the relay's error envelope (`{"error":{"code","message","requestId"}}`). @@ -158,6 +160,35 @@ export function adminMutationNotSent(e: unknown): boolean { return false; } +export const UNSUPPORTED_BROWSING = + "This relay doesn't support community browsing yet."; + +/** + * Whether a relay answered a new route the way a relay without it does: a + * complete, empty 404/405. Reads the native `bodyEmpty` bit (set from the + * received bytes by both `AdminMutationError` and `AdminReadError`), never + * the message text. + */ +export function adminRouteUnsupported(e: unknown): boolean { + const status = adminMutationRelayStatus(e); + const payload = + e && typeof e === "object" && "payload" in e + ? (e as { payload: { bodyEmpty?: unknown } | null }).payload + : null; + return ( + (status === 404 || status === 405) && + adminMutationBodyComplete(e) && + payload?.bodyEmpty === true + ); +} + +/** The `code` of a structured `AdminReadError`, or `null`. */ +export function adminReadErrorCode(e: unknown): string | null { + if (!adminMutationBodyComplete(e)) return null; + const code = (e as { payload?: { code?: unknown } }).payload?.code; + return typeof code === "string" ? code : null; +} + /** * Whether a failed mutation must reuse its idempotency `requestId` on retry. * @@ -211,17 +242,24 @@ export function ErrorMessage({ message }: { message: string }) { // ── Timestamp formatter ─────────────────────────────────────────────────── -export function formatTimestamp(raw: string | null | undefined): string { - if (!raw) return "—"; +/** Absolute local time; use for future instants, where a relative label misleads. */ +export function formatAbsoluteTimestamp(raw: string): string { const date = new Date(raw); if (Number.isNaN(date.getTime())) return raw; - const absolute = date.toLocaleString(undefined, { + return date.toLocaleString(undefined, { month: "short", day: "numeric", year: "numeric", hour: "2-digit", minute: "2-digit", }); +} + +export function formatTimestamp(raw: string | null | undefined): string { + if (!raw) return "—"; + const date = new Date(raw); + if (Number.isNaN(date.getTime())) return raw; + const absolute = formatAbsoluteTimestamp(raw); const rel = formatRelativeTime(Math.floor(date.getTime() / 1000)); // Render relative label with the absolute value inline in parentheses. return `${rel} (${absolute})`; @@ -358,27 +396,37 @@ export function groupByCommunity< /** * Render community-grouped rows under per-community headings. * - * A single community collapses to a flat list (no redundant heading); two or - * more render a labelled section each. `renderItem` produces the row for one - * entry — the caller owns row markup so navigation/testids are unchanged. + * Each group is headed by its community badge, which opens that community's + * page. `hideHeadings` drops the headings when the list already sits on one + * community's page. `renderItem` produces the row for one entry. */ export function CommunityGroupedList< T extends { communityId: string | null; communityHost: string | null }, ->({ items, renderItem }: { items: T[]; renderItem: (item: T) => ReactNode }) { +>({ + items, + renderItem, + hideHeadings = false, +}: { + items: T[]; + renderItem: (item: T) => ReactNode; + hideHeadings?: boolean; +}) { const groups = useMemo(() => groupByCommunity(items), [items]); - if (groups.length <= 1) { - return
      {items.map(renderItem)}
    ; - } return (
    {groups.map((group) => (
    -

    - {group.communityHost} -

    + {!hideHeadings && ( +

    + +

    + )}
      {group.items.map(renderItem)}
    ))} diff --git a/desktop/src/features/admin-console/AdminConsoleReportsTab.tsx b/desktop/src/features/admin-console/AdminConsoleReportsTab.tsx index e8276cc4c8..a1af40c6b6 100644 --- a/desktop/src/features/admin-console/AdminConsoleReportsTab.tsx +++ b/desktop/src/features/admin-console/AdminConsoleReportsTab.tsx @@ -28,6 +28,10 @@ import { type AdminReportDto, type AdminReportResolution, } from "./api"; +import { + CommunityBadge, + NotConnectedWarning, +} from "./AdminConsoleCommunityBadge"; import { DetailRow, ErrorMessage, @@ -35,6 +39,7 @@ import { CommunityGroupedList, formatTimestamp, useAsyncLoad, + adminErrorCode, adminErrorMessage, preserveRequestIdOnError, } from "./AdminConsolePanelHelpers"; @@ -427,6 +432,9 @@ function ResolveReportForm({ frozenRef.current = null; } toast.error(adminErrorMessage(e)); + // The relay persists the failed action before answering + // `enforcement_failed`; reload so the detail shows it and its recovery. + if (adminErrorCode(e) === "enforcement_failed") onResolved(); } finally { setIsSubmitting(false); } @@ -504,6 +512,19 @@ function ResolveReportForm({ )}
    + {selectedAction && ( +

    + In{" "} + + {report.communityHost && ( + + )} +

    + )} + {selectedAction && ( )} @@ -636,11 +657,14 @@ function ReopenReportForm({ export function ReportsTab({ canMutate, + communityId, origin, pubkey, generation, }: { canMutate: boolean; + /** Only this community's reports (a community page); all when omitted. */ + communityId?: string; origin: string; pubkey: string; generation: number; @@ -655,8 +679,8 @@ export function ReportsTab({ // 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], + () => listAdminReports(origin, { scope: "all", communityId }), + [origin, pubkey, communityId], generation + listGen, ); @@ -708,6 +732,7 @@ export function ReportsTab({ return ( { const id = report.id; @@ -778,7 +803,10 @@ function ReportFields({ data }: { data: AdminReportDetailDto }) {
    - +
    + Host + +
    {/* Reporter uses PubKey widget for valid keys; falls back to mono text. */} {isHex64(data.reporterPubkey) ? ( diff --git a/desktop/src/features/admin-console/AdminConsoleSettingsCard.tsx b/desktop/src/features/admin-console/AdminConsoleSettingsCard.tsx index 15a1a6b3f2..92578367e0 100644 --- a/desktop/src/features/admin-console/AdminConsoleSettingsCard.tsx +++ b/desktop/src/features/admin-console/AdminConsoleSettingsCard.tsx @@ -142,8 +142,8 @@ function ProbeStatusBadge({ uiState }: { uiState: ProbeUiState }) { return ( - Auth is disabled on this relay. The admin console is accessible without - a credential. + Auth is disabled on this relay. The admin console is read-only; taking + action requires BUZZ_ADMIN_AUTH=nip98. ); } diff --git a/desktop/src/features/admin-console/AdminConsoleStaffingTab.tsx b/desktop/src/features/admin-console/AdminConsoleStaffingTab.tsx index f5a110833c..cf55c02005 100644 --- a/desktop/src/features/admin-console/AdminConsoleStaffingTab.tsx +++ b/desktop/src/features/admin-console/AdminConsoleStaffingTab.tsx @@ -10,8 +10,8 @@ * 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. + * `RestrictionsSection` (active bans and timeouts, with lifts) is rendered by + * the community page for that page's community. */ import { useEffect, useRef, useState } from "react"; @@ -21,7 +21,6 @@ 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 { @@ -37,15 +36,16 @@ import { import { deleteAdminOperator, listAdminOperators, + liftAdminRestriction, listAdminRestrictions, - liftAdminBan, - liftAdminTimeout, + type AdminLiftIntent, putAdminOperator, type AdminMemberRestrictionDto, type AdminOperatorDto, } from "./api"; import { adminErrorMessage, + adminMutationRelayStatus, type AsyncState, ErrorMessage, LoadingSpinner, @@ -153,85 +153,66 @@ function RestrictionTypeBadge({ } /** - * 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. + * Active restrictions in `communityHost`. The list captures the native relay + * it loaded from; each lift freezes that relay, the host and the signer, so a + * relay or identity switch fails before sending instead of retargeting. */ -function RestrictionsSection({ +export function RestrictionsSection({ + canMutate, origin, - relayKey, + communityHost, + pubkey, generation, }: { + canMutate: boolean; origin: string; - /** Active relay identity; a change reloads the list. */ - relayKey: string; + communityHost: string; + /** Active signer; frozen into each lift. */ + pubkey: 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 lift awaiting confirmation, frozen from the list its row came from. */ + const [pendingLift, setPendingLift] = 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<{ + origin: string; + communityHost: string; relay: string; + signer: string; items: AdminMemberRestrictionDto[]; nextCursor: string | null; }> = useAsyncLoad( async () => { const relay = await getRelayWsUrl(); - return { relay, ...(await listAdminRestrictions(origin, relay)) }; + return { + origin, + communityHost, + relay, + signer: pubkey, + ...(await listAdminRestrictions(origin, communityHost, relay)), + }; }, - [origin, relayKey], + [origin, communityHost], generation + listGen, ); - const loadedRelay = listState.status === "ok" ? listState.data.relay : ""; - - const handleConfirmLiftBan = async () => { - const row = pendingLiftBan; - if (!row) return; - setPendingLiftBan(null); + const handleConfirmLift = async () => { + if (!pendingLift) return; + const intent = pendingLift; + setPendingLift(null); setLiftError(null); - setWorkingPubkey(row.pubkey); + setWorkingPubkey(intent.pubkey); try { - await liftAdminBan(origin, row.pubkey, loadedRelay); + await liftAdminRestriction(intent); 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")) { + // 409 = nothing active any more — a soft success (already gone). + if (adminMutationRelayStatus(e) === 409) { 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); + setLiftError(adminErrorMessage(e)); } } finally { setWorkingPubkey(null); @@ -269,13 +250,32 @@ function RestrictionsSection({ ? extra.nextCursor : listState.data.nextCursor : null; + // Rows render only from a loaded list, so its context is always present. + const freezeLift = (memberPubkey: string, kind: AdminLiftIntent["kind"]) => { + if (listState.status !== "ok") return; + const { origin, communityHost, relay, signer } = listState.data; + setPendingLift({ + origin, + communityHost, + expectedRelay: relay, + expectedSigner: signer, + kind, + pubkey: memberPubkey, + }); + }; const handleLoadMore = async () => { - if (!nextCursor) return; + if (!nextCursor || listState.status !== "ok") return; + const loaded = listState.data; const gen = loadGen; setMoreRequest({ gen, busy: true, error: null }); try { - const page = await listAdminRestrictions(origin, loadedRelay, nextCursor); + const page = await listAdminRestrictions( + loaded.origin, + loaded.communityHost, + loaded.relay, + nextCursor, + ); if (loadGenRef.current !== gen) return; setMore({ gen, @@ -291,76 +291,47 @@ function RestrictionsSection({ return (
    - {/* Lift-ban confirmation dialog */} { - if (!open) setPendingLiftBan(null); + if (!open) setPendingLift(null); }} > - + - Lift ban? + + {pendingLift?.kind === "ban" ? "Lift ban?" : "Clear timeout?"} + - This will remove the active ban for{" "} + This will{" "} + {pendingLift?.kind === "ban" + ? "remove the active ban" + : "clear the active timeout"}{" "} + for{" "} - {pendingLiftBan ? truncatePubkey(pendingLiftBan.pubkey) : ""} - - . They will be able to post again. + {pendingLift ? truncatePubkey(pendingLift.pubkey) : ""} + {" "} + in {pendingLift?.communityHost}. 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 - - - @@ -411,8 +382,8 @@ function RestrictionsSection({ {isBanned && ( -
    - {addError &&

    {addError}

    } + {/* Add operator form */} +
    +

    + Add operator +

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

    {addError}

    } +
    {/* Operator list */} {listState.status === "loading" && } @@ -734,8 +696,8 @@ export function StaffingTab({ ))}
    - {/* In-place role selector — hidden in read-only / config-backed mode */} - {canMutate && !isConfigBacked && ( + {/* In-place role selector — hidden for config-backed rows */} + {!isConfigBacked && (