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}
-
- setHostOverride(host)}
- size="sm"
- type="button"
- variant="link"
- >
- Change
-
-
- )}
- {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)"}
-
-
- void handleConfirm()}
- size="sm"
- type="button"
- variant="destructive"
- >
- {error || pending ? "Retry" : "Confirm"}
-
- {
- setFrozen(null);
- setError(null);
- setPending(false);
- }}
- size="sm"
- type="button"
- variant="ghost"
- >
- Discard
-
-
-
+
) : (
void handleReview()}
+ disabled={
+ !c.canMutate ||
+ c.submitting ||
+ (lookup.status !== "ok" && lookup.status !== "idle") ||
+ lookupBlock !== null
+ }
+ onClick={handleReview}
size="sm"
type="button"
>
@@ -422,73 +531,157 @@ export function ActionsTab({
);
}
+/** The frozen intent, leading with its community. */
+function ConfirmStep({ readOnly = false }: { readOnly?: boolean }) {
+ const c = useDirectActions();
+ if (!c.frozen) return null;
+ const { intent, community, name, member, preview } = c.frozen;
+ return (
+
+
+ In
+
+
+
+ {ACTION_LABELS[intent.action]}{" "}
+ {intent.action === "delete" ? (
+ {intent.target}
+ ) : (
+
+ {name ? `${name} ` : ""}
+
+ {name
+ ? `(${truncateNpub(intent.target)})`
+ : truncateNpub(intent.target)}
+
+
+ )}
+ {intent.expirationSecs ? ` for ${intent.expirationSecs}s` : ""}?
+
+ {intent.action !== "delete" && (
+
+ )}
+ {member &&
}
+ {preview &&
}
+
+ Reason: {intent.reason ?? "(none)"}
+
+ {c.pending && (
+
+ Accepted; the relay is still applying it. Retry to check.
+
+ )}
+
+ {!readOnly && (
+ void c.confirm()}
+ size="sm"
+ type="button"
+ variant="destructive"
+ >
+ {c.error || c.pending ? "Retry" : "Confirm"}
+
+ )}
+
+ Discard
+
+
+
+ );
+}
+
+// ── 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)}
+
+ onChange(null)}
- poofOnRemove={false}
- testIds={{
- chip: "direct-member-remove",
- name: "direct-member-inspect",
- pubkey: "direct-member-npub",
- }}
- user={member}
- />
- {hint}
+ onClick={() => onChange(null)}
+ size="sm"
+ type="button"
+ variant="link"
+ >
+ Change
+
);
}
@@ -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) => (
-
(
+ {
+ onSelect={() => {
onChange(user);
setQuery("");
}}
- role="option"
- type="button"
- >
-
-
- {memberLabel(user)}
-
- {parsed ? (
- public key
- ) : (
-
- )}
-
+ 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) => (
+ {
+ c.setDraft({ ...emptyDraft(communityHost), action, member });
+ onAct();
+ }}
+ size="sm"
+ type="button"
+ variant="outline"
+ >
+ {action === "ban" ? "Ban" : "Time out"}
+
+ ))}
+
+ )}
+
+ );
+}
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) => (
+
+ open(c)}
+ type="button"
+ >
+ {c.host}
+ {connected && (
+ Connected
+ )}
+
+
+ );
+
+ return (
+
+
setQuery(e.target.value)}
+ placeholder="Search by host (e.g. team.example.com)"
+ value={query}
+ />
+ {pinnedRow && (
+
+ )}
+ {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 && (
+
void loadMore()}
+ size="sm"
+ type="button"
+ variant="outline"
+ >
+ {moreStatus?.busy ? (
+
+ ) : (
+ "Load more"
+ )}
+
+ )}
+
+ );
+}
+
+// ── 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) => (
+ setSection(s)}
+ size="sm"
+ type="button"
+ variant={s === section ? "default" : "outline"}
+ >
+ {s[0].toUpperCase() + s.slice(1)}
+
+ ))}
+
+ {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 (
+ open({ id, host, icon })}
+ title={`Open ${host}`}
+ type="button"
+ >
+ {content}
+
+ );
+}
+
+/** "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 ;
- }
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 && (
) : (
- `Confirm: ${actionLabel(frozenRef.current?.action ?? selectedAction)}`
+ `Confirm: ${actionLabel(frozenRef.current?.action ?? selectedAction)} in ${report.communityHost}`
)}
)}
@@ -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
void handleConfirmLiftBan()}
+ onClick={() => void handleConfirmLift()}
variant="default"
>
- Lift ban
-
-
-
-
-
-
- {/* 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
-
-
- void handleConfirmLiftTimeout()}
- variant="default"
- >
- Clear timeout
+ {pendingLift?.kind === "ban" ? "Lift ban" : "Clear timeout"}
@@ -411,8 +382,8 @@ function RestrictionsSection({
{isBanned && (
setPendingLiftBan(row)}
+ disabled={!canMutate || isWorking}
+ onClick={() => freezeLift(row.pubkey, "ban")}
size="sm"
type="button"
variant="outline"
@@ -427,8 +398,8 @@ function RestrictionsSection({
{isTimedOut && (
setPendingLiftTimeout(row)}
+ disabled={!canMutate || isWorking}
+ onClick={() => freezeLift(row.pubkey, "timeout")}
size="sm"
type="button"
variant="outline"
@@ -472,17 +443,11 @@ export function StaffingTab({
origin,
pubkey,
generation,
- canMutate,
onSelfMutation,
}: {
origin: string;
pubkey: string;
generation: number;
- /**
- * When false (disabled-auth probe), all write affordances are hidden.
- * The operator list is still readable; only add/remove/edit controls are absent.
- */
- canMutate: boolean;
/**
* Called after a successful mutation that modified the current principal's
* own operator row (role change or removal of self). The parent re-probes
@@ -491,7 +456,6 @@ export function StaffingTab({
*/
onSelfMutation?: () => void;
}) {
- const { activeCommunity } = useCommunities();
const [listGen, setListGen] = useState(0);
const [addPubkey, setAddPubkey] = useState("");
const [addRole, setAddRole] = useState<"operator" | "moderator">("moderator");
@@ -647,53 +611,51 @@ export function StaffingTab({
- {/* Add operator form — hidden in read-only (disabled-auth) mode */}
- {canMutate && (
-
-
- Add operator
-
-
- setAddPubkey(e.target.value)}
- placeholder="64-hex pubkey"
- type="text"
- value={addPubkey}
- />
-
- setAddRole(e.target.value as "operator" | "moderator")
- }
- value={addRole}
- >
- moderator
- operator
-
- void handleAdd()}
- size="sm"
- type="button"
- >
- {isAdding ? (
-
- ) : (
- "Add"
- )}
-
-
- {addError &&
{addError}
}
+ {/* Add operator form */}
+
+
+ Add operator
+
+
+ setAddPubkey(e.target.value)}
+ placeholder="64-hex pubkey"
+ type="text"
+ value={addPubkey}
+ />
+
+ setAddRole(e.target.value as "operator" | "moderator")
+ }
+ value={addRole}
+ >
+ moderator
+ operator
+
+ void handleAdd()}
+ size="sm"
+ type="button"
+ >
+ {isAdding ? (
+
+ ) : (
+ "Add"
+ )}
+
- )}
+ {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 && (
{op.effectiveRole}
)}
- {/* Remove button — hidden in read-only (disabled-auth) mode */}
- {canMutate && (
- setPendingRemove(op)}
- size="icon-xs"
- title={
- isConfigBacked
- ? "Config-backed — cannot be removed via API"
- : "Remove operator"
- }
- type="button"
- variant="ghost"
- >
- {isWorking ? (
-
- ) : (
-
- )}
-
- )}
+ {/* Remove button */}
+ setPendingRemove(op)}
+ size="icon-xs"
+ title={
+ isConfigBacked
+ ? "Config-backed — cannot be removed via API"
+ : "Remove operator"
+ }
+ type="button"
+ variant="ghost"
+ >
+ {isWorking ? (
+
+ ) : (
+
+ )}
+
);
})}
)}
-
- {/* Restrictions section — active bans and timeouts for the current community */}
-
-
-
);
}
diff --git a/desktop/src/features/admin-console/adminConsoleActionsFence.jsdom-test.mjs b/desktop/src/features/admin-console/adminConsoleActionsFence.jsdom-test.mjs
new file mode 100644
index 0000000000..8fcdcf4b53
--- /dev/null
+++ b/desktop/src/features/admin-console/adminConsoleActionsFence.jsdom-test.mjs
@@ -0,0 +1,237 @@
+/**
+ * Community fences inside one mounted Actions section: when the same section
+ * moves to another community with the same target, an unresolved read from
+ * the old community never shows in, or unlocks, the new one. Panel navigation
+ * unmounts the section and is covered separately in the Actions tests.
+ */
+import assert from "node:assert/strict";
+import { afterEach, test } from "node:test";
+import {
+ React,
+ act,
+ createRoot,
+ CommunitiesProvider,
+ CM_ORIGIN,
+ CM_PUBKEY,
+ deferred,
+ fireEvent,
+ makeQueryClient,
+ QueryClientProvider,
+ resetTestState,
+ setIpcHandler,
+ settle,
+ TEST_COMMUNITY,
+} from "./adminConsolePanelTestHelpers.jsdom.mjs";
+import {
+ ActionsSection,
+ AdminMemberPicker,
+ DirectActionsProvider,
+ useDirectActions,
+} from "./AdminConsoleActionsTab.tsx";
+
+afterEach(resetTestState);
+
+const A = TEST_COMMUNITY;
+const B = {
+ id: "22222222-2222-4222-8222-222222222222",
+ host: "beta.example.com",
+ icon: null,
+};
+const TARGET = "ab".repeat(32);
+const EVENT = "ef".repeat(32);
+const q = (c, id) => c.querySelector(`[data-testid='${id}']`);
+const member = {
+ pubkey: TARGET,
+ displayName: null,
+ avatarUrl: null,
+ nip05Handle: null,
+ ownerPubkey: null,
+ isAgent: false,
+};
+const draft = (host, action) => ({
+ host,
+ action,
+ target: action === "delete" ? EVENT : "",
+ member: action === "delete" ? null : member,
+ reason: "",
+ secs: "",
+});
+
+/** Mount `child(community)` once; `show(community)` re-renders the same tree. */
+function mountSection(child) {
+ setIpcHandler("admin_connected_community_host", () =>
+ Promise.resolve(A.host),
+ );
+ setIpcHandler("get_users_batch", () =>
+ Promise.resolve({ profiles: {}, missing: [] }),
+ );
+ const qc = makeQueryClient(CM_PUBKEY);
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ const ctl = {};
+ function Grab() {
+ ctl.current = useDirectActions();
+ return null;
+ }
+ const render = (community) =>
+ root.render(
+ React.createElement(
+ QueryClientProvider,
+ { client: qc },
+ React.createElement(
+ CommunitiesProvider,
+ null,
+ React.createElement(
+ DirectActionsProvider,
+ {
+ canMutate: true,
+ origin: CM_ORIGIN,
+ pubkey: CM_PUBKEY,
+ generation: 0,
+ },
+ React.createElement(Grab),
+ child(community),
+ ),
+ ),
+ ),
+ );
+ const show = async (community) => {
+ await act(async () => render(community));
+ await settle();
+ };
+ /** Move the draft and the section to `community` in one render, so the lookup never idles between them. */
+ const switchTo = async (community, d) => {
+ await act(async () => {
+ ctl.current.setDraft(d);
+ render(community);
+ });
+ await settle();
+ };
+ const setDraft = async (d) => {
+ await act(async () => ctl.current.setDraft(d));
+ await settle();
+ };
+ const unmount = async () => {
+ await act(async () => root.unmount());
+ document.body.removeChild(container);
+ };
+ return { container, show, setDraft, switchTo, unmount };
+}
+
+/** Per-host deferred replies for `cmd`. */
+function deferPerHost(cmd) {
+ const replies = {};
+ setIpcHandler(cmd, ({ communityHost }) => {
+ replies[communityHost] = deferred();
+ return replies[communityHost].promise;
+ });
+ return replies;
+}
+
+const actions = (community) =>
+ React.createElement(ActionsSection, { community });
+
+test("fence-member-same-section: A's late member lookup never shows or unlocks Review in B", async () => {
+ // Mutation: drop communityHost from ActionsSection's lookup fence → RED.
+ const replies = deferPerHost("admin_get_member");
+ const s = mountSection(actions);
+ try {
+ await s.show(A);
+ await s.setDraft(draft(A.host, "ban"));
+ assert.ok(replies[A.host], "A's lookup started");
+ await s.switchTo(B, draft(B.host, "ban"));
+ assert.ok(replies[B.host], "B starts its own lookup");
+ await act(async () =>
+ replies[A.host].resolve({
+ pubkey: TARGET,
+ profile: null,
+ role: "owner",
+ banned: false,
+ mutedUntil: null,
+ isStaff: false,
+ }),
+ );
+ await settle();
+ assert.ok(
+ !q(s.container, "direct-member-state"),
+ "A's member never shows in B",
+ );
+ assert.ok(q(s.container, "direct-review-btn").disabled, "B waits for B");
+ } finally {
+ await s.unmount();
+ }
+});
+
+test("fence-event-same-section: A's late event preview never shows or unlocks Review in B", async () => {
+ // Mutation: drop communityHost from ActionsSection's lookup fence → RED.
+ const replies = deferPerHost("admin_get_event");
+ const s = mountSection(actions);
+ try {
+ await s.show(A);
+ await s.setDraft(draft(A.host, "delete"));
+ assert.ok(replies[A.host], "A's preview started");
+ await s.switchTo(B, draft(B.host, "delete"));
+ assert.ok(replies[B.host], "B starts its own preview");
+ await act(async () =>
+ replies[A.host].resolve({
+ id: EVENT,
+ authorPubkey: TARGET,
+ kind: 9,
+ content: "content in a",
+ createdAt: "2026-09-30T00:00:00Z",
+ deletedAt: null,
+ channelId: null,
+ }),
+ );
+ await settle();
+ assert.doesNotMatch(s.container.textContent, /content in a/);
+ assert.ok(q(s.container, "direct-review-btn").disabled, "B waits for B");
+ } finally {
+ await s.unmount();
+ }
+});
+
+test("fence-search-same-section: A's late search results never list in B", async () => {
+ // Mutation: drop communityHost from the member-search fence → RED.
+ const replies = deferPerHost("admin_search_members");
+ const picker = (community) =>
+ React.createElement(AdminMemberPicker, {
+ communityHost: community.host,
+ disabled: false,
+ member: null,
+ onChange: () => {},
+ });
+ const s = mountSection(picker);
+ const hit = (pubkey, displayName) => ({
+ pubkey,
+ displayName,
+ avatarUrl: null,
+ nip05: null,
+ });
+ try {
+ await s.show(A);
+ await act(async () => {
+ fireEvent.change(q(s.container, "direct-member-input"), {
+ target: { value: "sam" },
+ });
+ });
+ await settle();
+ assert.ok(replies[A.host], "A's search started");
+ await s.show(B);
+ assert.ok(replies[B.host], "B searches for itself");
+ await act(async () =>
+ replies[A.host].resolve({ items: [hit("a1".repeat(32), "Sam in A")] }),
+ );
+ await settle();
+ assert.doesNotMatch(s.container.textContent, /Sam in A/);
+ await act(async () =>
+ replies[B.host].resolve({ items: [hit("b1".repeat(32), "Sam in B")] }),
+ );
+ await settle();
+ assert.match(s.container.textContent, /Sam in B/);
+ assert.doesNotMatch(s.container.textContent, /Sam in A/);
+ } finally {
+ await s.unmount();
+ }
+});
diff --git a/desktop/src/features/admin-console/adminConsolePanel.test.mjs b/desktop/src/features/admin-console/adminConsolePanel.test.mjs
index aedd9ff124..ff1b8a09d4 100644
--- a/desktop/src/features/admin-console/adminConsolePanel.test.mjs
+++ b/desktop/src/features/admin-console/adminConsolePanel.test.mjs
@@ -1015,7 +1015,7 @@ const PROBE_ROLE_ROWS = [
`role "moderator" must render; got: ${text.slice(0, 300)}`,
);
assert.equal(
- container.querySelector("[data-testid='admin-tab-staffing']"),
+ container.querySelector("[data-testid='admin-tab-operators']"),
null,
"Staffing tab must not render for moderator role",
);
@@ -1033,7 +1033,7 @@ const PROBE_ROLE_ROWS = [
},
check: (container) => {
const staffingTab = container.querySelector(
- "[data-testid='admin-tab-staffing']",
+ "[data-testid='admin-tab-operators']",
);
assert.ok(
staffingTab !== null,
@@ -1043,7 +1043,7 @@ const PROBE_ROLE_ROWS = [
},
{
name: "probe-no-role",
- desc: "disabled-mode panel renders without staffing tab",
+ desc: "disabled-mode panel renders Communities but not Staffing",
pubkey: "e4".repeat(32),
savedOrigin: "https://admin-disabled.example.com",
probeResult: { state: "disabled" },
@@ -1053,8 +1053,13 @@ const PROBE_ROLE_ROWS = [
container.querySelector("[data-testid='admin-console-panel']") !== null,
"panel must render in disabled mode",
);
+ assert.ok(
+ container.querySelector("[data-testid='admin-tab-communities']") !==
+ null,
+ "Communities tab must render in disabled mode",
+ );
assert.equal(
- container.querySelector("[data-testid='admin-tab-staffing']"),
+ container.querySelector("[data-testid='admin-tab-operators']"),
null,
"Staffing tab must not render in disabled mode",
);
diff --git a/desktop/src/features/admin-console/adminConsolePanelActions.jsdom-test.mjs b/desktop/src/features/admin-console/adminConsolePanelActions.jsdom-test.mjs
index a7a5e65262..e7719a2eae 100644
--- a/desktop/src/features/admin-console/adminConsolePanelActions.jsdom-test.mjs
+++ b/desktop/src/features/admin-console/adminConsolePanelActions.jsdom-test.mjs
@@ -11,9 +11,12 @@ import {
setIpcHandler,
resetTestState,
mutationReject,
- mountPanel,
+ mountCommunityPanel,
settle,
capturedToasts,
+ capturedErrorToasts,
+ ipcHandlers,
+ TEST_COMMUNITY,
CM_ORIGIN,
CM_PUBKEY,
TEST_RELAY_WS_URL,
@@ -23,14 +26,57 @@ import { pubkeyToNpub } from "@/shared/lib/nostrUtils.ts";
afterEach(resetTestState);
const TARGET = "ab".repeat(32);
+const HOST = TEST_COMMUNITY.host;
+const BETA = {
+ id: "22222222-2222-4222-8222-222222222222",
+ host: "beta.example.com",
+ icon: null,
+};
const q = (c, id) => c.querySelector(`[data-testid='${id}']`);
+const memberDto = (pubkey, extra = {}) => ({
+ pubkey,
+ profile: null,
+ role: "member",
+ banned: false,
+ mutedUntil: null,
+ isStaff: false,
+ ...extra,
+});
+
+const readReject = (
+ relayStatus,
+ { code = null, bodyEmpty = false, bodyComplete = true } = {},
+) =>
+ Promise.reject({
+ message: code ? `admin API error: ${code}` : "admin API error: ",
+ relayStatus,
+ bodyComplete,
+ bodyEmpty,
+ code,
+ });
+
+/** Default community reads: a plain member, no events, empty search. */
+function stubReads() {
+ for (const [cmd, fn] of [
+ ["admin_get_member", ({ pubkey }) => Promise.resolve(memberDto(pubkey))],
+ ["admin_get_event", () => readReject(404, { code: "event_not_found" })],
+ ["admin_search_members", () => Promise.resolve({ items: [] })],
+ ["admin_list_feedback", () => Promise.resolve([])],
+ [
+ "admin_list_communities",
+ () =>
+ Promise.resolve({ items: [TEST_COMMUNITY, BETA], nextCursor: null }),
+ ],
+ ]) {
+ if (!ipcHandlers.get(cmd)) setIpcHandler(cmd, fn);
+ }
+}
+
async function mountActions({ canMutate = true } = {}) {
- const panel = mountPanel({
- origin: CM_ORIGIN,
- pubkey: CM_PUBKEY,
+ stubReads();
+ const panel = mountCommunityPanel(CM_ORIGIN, CM_PUBKEY, "actions", {
canMutate,
- initialTab: "actions",
});
await panel.doRender();
await settle();
@@ -50,9 +96,12 @@ async function click(c, id) {
await settle();
}
-async function setHost(c, host) {
- if (q(c, "direct-host-change")) await click(c, "direct-host-change");
- await type(c, "direct-host-input", host);
+/** Open `community`'s page from the Communities tab, on its Actions section. */
+async function openActions(c, community = TEST_COMMUNITY) {
+ await click(c, "admin-tab-communities");
+ await settle();
+ await click(c, `community-row-${community.host}`);
+ await click(c, "community-section-actions");
}
/** Paste a key and pick its direct result. */
@@ -65,7 +114,6 @@ async function pickKey(c, key, hex = TARGET) {
async function fillTimeout(c) {
await click(c, "direct-action-timeout");
- await setHost(c, " team.example.com ");
await pickKey(c, TARGET);
await type(c, "direct-duration-input", "60");
await type(c, "direct-reason-input", "spam");
@@ -81,14 +129,10 @@ test("actions-validation: bad target or duration shows an error and sends nothin
try {
await click(c, "direct-review-btn");
assert.match(q(c, "direct-error").textContent, /Choose a member/);
- await setHost(c, "");
- await click(c, "direct-review-btn");
- assert.match(q(c, "direct-error").textContent, /community host/);
- await type(c, "direct-host-input", "team.example.com");
await click(c, "direct-action-delete");
- await type(c, "direct-target-input", TARGET.toUpperCase());
+ await type(c, "direct-target-input", "nevent1notreal");
await click(c, "direct-review-btn");
- assert.match(q(c, "direct-error").textContent, /64 lowercase hex/);
+ assert.match(q(c, "direct-error").textContent, /64-hex event id/);
await click(c, "direct-action-timeout");
await pickKey(c, TARGET);
await type(c, "direct-duration-input", "0");
@@ -101,7 +145,7 @@ test("actions-validation: bad target or duration shows an error and sends nothin
}
});
-test("actions-success: confirm sends the frozen intent with signer, relay, and typed host", async () => {
+test("actions-success: confirm sends the frozen intent with signer, relay, and the page host", async () => {
const sent = [];
setIpcHandler("admin_direct_action", ({ intent }) => {
sent.push(intent);
@@ -124,7 +168,7 @@ test("actions-success: confirm sends the frozen intent with signer, relay, and t
origin: CM_ORIGIN,
expectedRelay: TEST_RELAY_WS_URL,
expectedPubkey: CM_PUBKEY,
- communityHost: "team.example.com",
+ communityHost: HOST,
action: "timeout",
target: TARGET,
reason: "spam",
@@ -265,8 +309,8 @@ test("actions-pending: a 202 keeps the intent for a same-id retry", async () =>
}
});
-test("actions-identity: a signer change remounts the tab and drops the frozen intent", async () => {
- // Mutation: remove the ActionsTab key → frozen intent survives → RED.
+test("actions-identity: a signer change remounts the controller and drops the frozen intent", async () => {
+ // Mutation: remove the DirectActionsProvider key → frozen intent survives → RED.
let calls = 0;
setIpcHandler("admin_direct_action", () => {
calls += 1;
@@ -283,6 +327,7 @@ test("actions-identity: a signer change remounts the tab and drops the frozen in
assert.ok(q(c, "direct-confirm"));
await doRender({ origin: CM_ORIGIN, pubkey: "ee".repeat(32) });
await settle();
+ await openActions(c);
assert.ok(!q(c, "direct-confirm"), "direct-confirm must be absent");
assert.equal(q(c, "direct-member-input").value, "");
assert.equal(calls, 0);
@@ -291,26 +336,70 @@ test("actions-identity: a signer change remounts the tab and drops the frozen in
}
});
-test("actions-disabled-auth: canMutate=false keeps Review and every field off", async () => {
- // Mutation: drop !canMutate from the Review/lock gates → RED.
+test("actions-disabled-auth: with admin auth disabled, community pages render read-only", async () => {
+ // Mutation: drop `!canMutate` from any community-page control's disabled
+ // gate, or restore a Communities-tab gate on canMutate → RED.
+ const banned = "29".repeat(32);
+ setIpcHandler("admin_list_restrictions", () =>
+ Promise.resolve({
+ items: [
+ {
+ pubkey: banned,
+ banned: true,
+ banExpiresAt: null,
+ banReason: "spam",
+ mutedUntil: "2099-01-01T00:00:00Z",
+ muteReason: "noise",
+ actorPubkey: "aa".repeat(32),
+ updatedAt: "2024-06-01T09:00:00Z",
+ },
+ ],
+ nextCursor: null,
+ }),
+ );
const { container: c, unmount } = await mountActions({ canMutate: false });
try {
- assert.ok(q(c, "direct-review-btn").disabled);
- assert.ok(q(c, "direct-member-input").disabled);
- assert.ok(q(c, "direct-action-ban").disabled);
+ await openActions(c);
+ assert.ok(q(c, "community-page"), "community page renders");
+ for (const id of [
+ "direct-action-ban",
+ "direct-action-timeout",
+ "direct-action-delete",
+ "direct-member-input",
+ "direct-reason-input",
+ "direct-review-btn",
+ ]) {
+ assert.ok(q(c, id)?.disabled, `${id} must be disabled`);
+ }
+ await click(c, "community-section-members");
+ await pickKey(c, TARGET);
+ for (const id of ["member-ban", "member-timeout"]) {
+ assert.ok(q(c, id)?.disabled, `${id} must be disabled`);
+ }
+ await click(c, "community-section-restrictions");
+ await settle(50);
+ for (const id of [
+ `restrictions-lift-ban-btn-${banned}`,
+ `restrictions-lift-timeout-btn-${banned}`,
+ ]) {
+ assert.ok(q(c, id)?.disabled, `${id} must be disabled`);
+ }
} finally {
await unmount();
}
});
test("actions-review-race: a late second Review never replaces the submitted intent", async () => {
- // Mutation: drop the in-flight guard at the top of handleReview → RED
+ // Mutation: drop the in-flight guard in DirectActionsProvider.locked() → RED
// (the second Review re-freezes with a new requestId and Retry sends it).
+ // Relay reads answer at once while the page mounts, then are held from
+ // the first Review on so the two Reviews race.
const relays = [];
- setIpcHandler(
- "get_relay_ws_url",
- () =>
- new Promise((resolve) => relays.push(() => resolve(TEST_RELAY_WS_URL))),
+ let hold = false;
+ setIpcHandler("get_relay_ws_url", () =>
+ hold
+ ? new Promise((resolve) => relays.push(() => resolve(TEST_RELAY_WS_URL)))
+ : Promise.resolve(TEST_RELAY_WS_URL),
);
const ids = [];
const replies = [
@@ -322,15 +411,10 @@ test("actions-review-race: a late second Review never replaces the submitted int
ids.push(intent.requestId);
return replies[ids.length - 1]();
});
- const mounted = mountActions();
- await settle();
- // The tab reads the active relay once on mount to prefill the host.
- await act(async () => {
- for (const resolve of relays.splice(0)) resolve();
- });
- const { container: c, unmount } = await mounted;
+ const { container: c, unmount } = await mountActions();
try {
await fillTimeout(c);
+ hold = true;
await act(async () => {
fireEvent.click(q(c, "direct-review-btn"));
fireEvent.click(q(c, "direct-review-btn"));
@@ -371,8 +455,11 @@ test("actions-audience: the reason's recipients are disclosed and the frozen rea
}
});
-test("actions-host-prefill: the host follows the active relay read-only, and Change reveals the input", async () => {
- // Mutation: drop the active-relay prefill (activeHost stays null) → RED.
+test("actions-page-host: the form acts in the page's community and has no host field", async () => {
+ // Mutation: send the connected host instead of the page's → RED.
+ setIpcHandler("admin_connected_community_host", () =>
+ Promise.resolve("relay.test"),
+ );
const sent = [];
setIpcHandler("admin_direct_action", ({ intent }) => {
sent.push(intent);
@@ -384,40 +471,35 @@ test("actions-host-prefill: the host follows the active relay read-only, and Cha
});
const { container: c, unmount } = await mountActions();
try {
- assert.ok(
- q(c, "direct-host"),
- "host must be prefilled from the active relay",
- );
- assert.match(q(c, "direct-host").textContent, /Community: relay\.test/);
- assert.ok(!q(c, "direct-host-input"), "host input hidden until Change");
+ assert.ok(!q(c, "direct-host-input"), "no free-text host");
+ assert.match(q(c, "community-banner").textContent, /alpha\.example\.com/);
await pickKey(c, TARGET);
await click(c, "direct-review-btn");
+ assert.match(q(c, "direct-confirm").textContent, /alpha\.example\.com/);
await click(c, "direct-confirm-btn");
- assert.equal(sent[0].communityHost, "relay.test");
- await click(c, "direct-host-change");
- assert.equal(q(c, "direct-host-input").value, "relay.test");
+ assert.equal(sent[0].communityHost, HOST);
} finally {
await unmount();
}
});
test("actions-member-search: a name result is sent as hex and named on the confirm step", async () => {
- setIpcHandler("search_users", ({ query }) =>
- Promise.resolve({
- users: query.startsWith("ali")
+ const searched = [];
+ setIpcHandler("admin_search_members", (args) => {
+ searched.push(args);
+ return Promise.resolve({
+ items: args.q.startsWith("ali")
? [
{
pubkey: TARGET,
- display_name: "Alice",
- avatar_url: null,
- nip05_handle: null,
- owner_pubkey: null,
+ displayName: "Alice",
+ nip05: null,
+ avatarUrl: null,
},
]
: [],
- next_cursor: null,
- }),
- );
+ });
+ });
const sent = [];
setIpcHandler("admin_direct_action", ({ intent }) => {
sent.push(intent);
@@ -436,6 +518,11 @@ test("actions-member-search: a name result is sent as hex and named on the confi
assert.match(q(c, "direct-confirm-member").textContent, /^Alice \(npub1/);
await click(c, "direct-confirm-btn");
assert.equal(sent[0].target, TARGET);
+ assert.deepEqual(searched.at(-1), {
+ origin: CM_ORIGIN,
+ communityHost: HOST,
+ q: "ali",
+ });
} finally {
await unmount();
}
@@ -468,85 +555,38 @@ test("actions-member-keys: an npub and uppercase hex are both sent as lowercase
);
});
-test("actions-foreign-host: another community's host turns name search off and says why", async () => {
- let searches = 0;
- setIpcHandler("search_users", () => {
- searches += 1;
- return Promise.resolve({ users: [], next_cursor: null });
- });
- const { container: c, unmount } = await mountActions();
- try {
- assert.ok(
- !q(c, "direct-member-search-hint"),
- "no hint on the active community",
- );
- await setHost(c, "other.example.com");
- assert.match(
- q(c, "direct-member-search-hint").textContent,
- /only works in the community you're connected to/,
- );
- await type(c, "direct-member-input", "alice");
- await settle();
- assert.equal(searches, 0, "no name search against another community");
- await pickKey(c, TARGET);
- assert.ok(q(c, "direct-member-selected"), "keys still work");
- } finally {
- await unmount();
- }
-});
-
const OTHER = "cd".repeat(32);
function searchReturns(users) {
- setIpcHandler("search_users", () =>
+ setIpcHandler("admin_search_members", () =>
Promise.resolve({
- users: users.map(([pubkey, name]) => ({
+ items: users.map(([pubkey, name]) => ({
pubkey,
- display_name: name,
- avatar_url: null,
- nip05_handle: null,
- owner_pubkey: null,
+ displayName: name,
+ nip05: null,
+ avatarUrl: null,
})),
- next_cursor: null,
}),
);
}
-test("actions-host-change-drops-member: a name picked in one community can't be reviewed in another", async () => {
- // Mutation: mask the pick by host instead of clearing it → RED on A→B→A.
+test("actions-community-change-drops-member: a name picked in one community isn't carried to another", async () => {
+ // Mutation: key the draft by nothing instead of by host → RED.
searchReturns([[TARGET, "Alice"]]);
const { container: c, unmount } = await mountActions();
try {
await type(c, "direct-member-input", "ali");
await settle();
await click(c, `direct-member-result-${TARGET}`);
- await click(c, "direct-host-change");
- assert.ok(q(c, "direct-member-selected"), "Change alone keeps the pick");
- await setHost(c, "other.example.com");
+ assert.ok(q(c, "direct-member-selected"));
+ await openActions(c, BETA);
assert.ok(
!q(c, "direct-member-selected"),
- "the pick must not survive a host change",
+ "no pick in the other community",
);
- assert.equal(q(c, "direct-member-input").value, "", "query is cleared");
await click(c, "direct-review-btn");
assert.ok(!q(c, "direct-confirm"), "nothing to review");
assert.match(q(c, "direct-error").textContent, /Choose a member/);
- await setHost(c, "relay.test");
- assert.ok(
- !q(c, "direct-member-selected"),
- "returning to the first host must not restore the old pick",
- );
- await click(c, "direct-review-btn");
- assert.ok(!q(c, "direct-confirm"), "still nothing to review");
- assert.match(q(c, "direct-error").textContent, /Choose a member/);
- await setHost(c, "other.example.com");
- await pickKey(c, TARGET);
- assert.ok(
- q(c, "direct-member-search-hint"),
- "hint stays visible with a key selected",
- );
- await click(c, "direct-review-btn");
- assert.doesNotMatch(q(c, "direct-confirm-member").textContent, /Alice/);
} finally {
await unmount();
}
@@ -575,7 +615,7 @@ test("actions-same-name: two same-name results are told apart and the chosen ful
const b = q(c, `direct-member-result-${OTHER}`).textContent;
assert.notEqual(a, b, "same-name results must differ by key");
await click(c, `direct-member-result-${OTHER}`);
- assert.ok(q(c, "direct-member-inspect"), "selected chip is inspectable");
+ assert.ok(q(c, "direct-member-npub"), "selected chip shows its key");
await click(c, "direct-review-btn");
assert.ok(q(c, "direct-confirm-npub"), "confirm must show the full npub");
assert.match(
@@ -590,34 +630,15 @@ test("actions-same-name: two same-name results are told apart and the chosen ful
}
});
-test("actions-results-disabled: results showing when auth drops can't be selected", async () => {
- searchReturns([[TARGET, "Alice"]]);
- const panel = await mountActions();
- const c = panel.container;
- try {
- await type(c, "direct-member-input", "ali");
- await settle();
- await panel.doRender({ canMutate: false });
- await settle();
- const result = q(c, `direct-member-result-${TARGET}`);
- assert.ok(result, "results still shown");
- assert.ok(result.disabled, "results must be disabled");
- await click(c, `direct-member-result-${TARGET}`);
- assert.ok(!q(c, "direct-member-selected"));
- } finally {
- await panel.unmount();
- }
-});
-
test("actions-secret-key: a pasted secret key or key backup is never searched and is flagged", async () => {
- // Mutation: drop the isSecretKey gate from the picker's search → RED.
+ // Mutation: drop the containsSecretKey gate from the picker's search → RED.
// The backup prefix is assembled so this file stays outside the frontend
// key-backup source scan's allowlist; it is only ever typed into the picker.
const backup = ["ncrypt", "sec1"].join("");
const queries = [];
- setIpcHandler("search_users", ({ query }) => {
+ setIpcHandler("admin_search_members", ({ q: query }) => {
queries.push(query);
- return Promise.resolve({ users: [], next_cursor: null });
+ return Promise.resolve({ items: [] });
});
const { container: c, unmount } = await mountActions();
try {
@@ -626,6 +647,8 @@ test("actions-secret-key: a pasted secret key or key backup is never searched an
`NSEC1${"Q".repeat(58)}`,
`${backup}${"q".repeat(40)}`,
`${backup.toUpperCase()}${"Q".repeat(40)}`,
+ `ban alice ${backup}${"q".repeat(40)} please`,
+ `see:${backup.toUpperCase()}${"Q".repeat(40)}`,
]) {
await type(c, "direct-member-input", key);
await settle();
@@ -635,7 +658,7 @@ test("actions-secret-key: a pasted secret key or key backup is never searched an
`no warning for ${key.slice(0, 10)}`,
);
}
- assert.deepEqual(queries, [], "a secret key reached search_users");
+ assert.deepEqual(queries, [], "a secret key reached admin_search_members");
await type(c, "direct-member-input", "");
assert.ok(!q(c, "direct-member-secret"), "clearing drops the warning");
await type(c, "direct-member-input", "alice");
@@ -649,11 +672,12 @@ test("actions-secret-key: a pasted secret key or key backup is never searched an
});
async function toTab(c, tab) {
- await click(c, `admin-tab-${tab}`);
+ if (tab === "actions") await openActions(c);
+ else await click(c, `admin-tab-${tab}`);
}
test("actions-tab-roundtrip-pending: a 202 survives a tab switch and Retry reuses the requestId", async () => {
- // Mutation: render ActionsTab only while it is the active tab → RED.
+ // Mutation: mount DirectActionsProvider inside ActionsSection → RED.
setIpcHandler("admin_list_feedback", () => Promise.resolve([]));
const ids = [];
setIpcHandler("admin_direct_action", ({ intent }) => {
@@ -717,6 +741,11 @@ test("actions-tab-roundtrip-inflight: a send that fails on another tab shows its
await toTab(c, "feedback");
await act(async () => fail());
await settle();
+ assert.equal(capturedErrorToasts.length, 1, "a hidden failure toasts");
+ assert.match(
+ capturedErrorToasts[0],
+ /alpha\.example\.com failed: network down/,
+ );
await toTab(c, "actions");
assert.match(q(c, "direct-error")?.textContent ?? "", /network down/);
assert.equal(q(c, "direct-confirm-btn")?.textContent, "Retry");
@@ -739,7 +768,6 @@ test("actions-not-sent: a refusal before sending drops the intent and offers no
});
const { container: c, unmount } = await mountActions();
try {
- await setHost(c, "team.example.com/");
await pickKey(c, TARGET);
await click(c, "direct-review-btn");
await click(c, "direct-confirm-btn");
@@ -753,26 +781,17 @@ test("actions-not-sent: a refusal before sending drops the intent and offers no
});
test("actions-old-relay: only an empty 404 or 405 says the relay lacks direct actions", async () => {
- // Mutation: drop the empty-body condition from directErrorMessage → RED.
- for (const [status, message, expected] of [
- [
- 404,
- "admin API error: ",
- "This relay doesn't support direct actions yet.",
- ],
- [
- 405,
- "admin API error: ",
- "This relay doesn't support direct actions yet.",
- ],
- [404, "admin API error: Not Found", "admin API error: Not Found"],
- [
- 405,
- "admin API error: Method Not Allowed",
- "admin API error: Method Not Allowed",
- ],
+ // Mutation: match the message text instead of bodyEmpty → RED.
+ const unsupported = "This relay doesn't support direct actions yet.";
+ for (const [status, message, empty, expected] of [
+ [404, "admin API error: ", true, unsupported],
+ [405, "admin API error: ", true, unsupported],
+ [404, "admin API error: Not Found", false, "admin API error: Not Found"],
+ [405, "admin API error: ", false, "admin API error: "],
]) {
- setIpcHandler("admin_direct_action", () => mutationReject(message, status));
+ setIpcHandler("admin_direct_action", () =>
+ mutationReject(message, status, true, empty),
+ );
const { container: c, unmount } = await mountActions();
try {
await pickKey(c, TARGET);
@@ -821,3 +840,242 @@ test("actions-backup-reason-not-sent: a key-backup reason refusal returns to edi
await unmount();
}
});
+
+// ── Multi-community: navigation, fencing, lookups ──────────────────────────
+
+const EVENT = "ef".repeat(32);
+const preview = (content) => ({
+ id: EVENT,
+ authorPubkey: TARGET,
+ kind: 9,
+ content,
+ createdAt: "2026-09-30T00:00:00Z",
+ deletedAt: null,
+ channelId: null,
+});
+
+test("actions-pending-nav: a pending intent survives a community change and only Discard drops it", async () => {
+ // Mutation: drop the frozen-elsewhere guard in ActionsSection → RED (B's
+ // form offers Review while A's intent is pending).
+ const ids = [];
+ setIpcHandler("admin_direct_action", ({ intent }) => {
+ ids.push(intent.requestId);
+ return mutationReject("network down", null);
+ });
+ const { container: c, unmount } = await mountActions();
+ try {
+ await fillTimeout(c);
+ await click(c, "direct-review-btn");
+ await click(c, "direct-confirm-btn");
+ await openActions(c, BETA);
+ assert.ok(q(c, "direct-elsewhere"), "B shows A's pending intent");
+ assert.match(q(c, "direct-elsewhere").textContent, /alpha\.example\.com/);
+ assert.ok(!q(c, "direct-review-btn"), "B can't review while A is pending");
+ assert.ok(!q(c, "direct-confirm-btn"), "A's intent is read-only on B");
+ await openActions(c);
+ await click(c, "direct-confirm-btn");
+ assert.equal(ids.length, 2);
+ assert.equal(ids[0], ids[1], "the retry after the trip reuses the id");
+ await openActions(c, BETA);
+ await click(c, "direct-discard-btn");
+ assert.ok(!q(c, "direct-elsewhere"));
+ assert.ok(q(c, "direct-review-btn"), "Discard frees B's form");
+ } finally {
+ await unmount();
+ }
+});
+
+test("actions-fenced-lookup: a late member lookup from one community never unlocks another", async () => {
+ // Mutation: drop communityHost from the lookup fence key → RED.
+ const lookups = {};
+ setIpcHandler(
+ "admin_get_member",
+ ({ communityHost }) =>
+ new Promise((resolve) => {
+ lookups[communityHost] = resolve;
+ }),
+ );
+ const { container: c, unmount } = await mountActions();
+ try {
+ await pickKey(c, TARGET);
+ await openActions(c, BETA);
+ await pickKey(c, TARGET);
+ assert.ok(q(c, "direct-review-btn").disabled, "B waits for its lookup");
+ await act(async () => lookups[HOST](memberDto(TARGET)));
+ await settle();
+ assert.ok(q(c, "direct-review-btn").disabled, "A's answer can't unlock B");
+ await act(async () =>
+ lookups[BETA.host](memberDto(TARGET, { role: null, banned: true })),
+ );
+ await settle();
+ assert.match(
+ q(c, "direct-member-state").textContent,
+ /Not on the community roster · Currently banned/,
+ );
+ assert.ok(!q(c, "direct-review-btn").disabled);
+ } finally {
+ await unmount();
+ }
+});
+
+test("actions-same-ids: one pubkey and one event id read per community", async () => {
+ // Mutation: read the lookups with the connected host instead of the page's → RED.
+ setIpcHandler("admin_get_member", ({ communityHost, pubkey }) =>
+ Promise.resolve(
+ memberDto(pubkey, {
+ isStaff: false,
+ banned: communityHost === BETA.host,
+ }),
+ ),
+ );
+ setIpcHandler("admin_get_event", ({ communityHost }) =>
+ communityHost === HOST
+ ? Promise.resolve(preview("content in a"))
+ : readReject(404, { code: "event_not_found" }),
+ );
+ const { container: c, unmount } = await mountActions();
+ try {
+ await pickKey(c, TARGET);
+ assert.doesNotMatch(q(c, "direct-member-state").textContent, /banned/);
+ await click(c, "direct-action-delete");
+ await type(c, "direct-target-input", EVENT);
+ await settle();
+ assert.match(q(c, "direct-event-preview").textContent, /content in a/);
+ await openActions(c, BETA);
+ await pickKey(c, TARGET);
+ assert.match(q(c, "direct-member-state").textContent, /Currently banned/);
+ await click(c, "direct-action-delete");
+ await type(c, "direct-target-input", EVENT);
+ await settle();
+ assert.ok(!q(c, "direct-event-preview"), "A's message never shows in B");
+ assert.equal(
+ q(c, "direct-lookup-error").textContent,
+ "Not found in this community.",
+ );
+ assert.ok(q(c, "direct-review-btn").disabled);
+ } finally {
+ await unmount();
+ }
+});
+
+test("actions-preview-errors: only a coded 404 says not found, and no failed preview enables Review", async () => {
+ // Mutation: treat any 404 as event_not_found → RED on the empty 404.
+ for (const [reply, copy] of [
+ [
+ () => readReject(404, { code: "event_not_found" }),
+ /^Not found in this community\.$/,
+ ],
+ [
+ () => readReject(404, { bodyEmpty: true }),
+ /doesn't support community browsing/,
+ ],
+ [
+ () => readReject(405, { bodyEmpty: true }),
+ /doesn't support community browsing/,
+ ],
+ [
+ () => readReject(404, { code: "event_not_found", bodyComplete: false }),
+ /^(?!Not found)/,
+ ],
+ [() => readReject(null, { bodyComplete: false }), /^(?!Not found)/],
+ ]) {
+ setIpcHandler("admin_get_event", reply);
+ const { container: c, unmount } = await mountActions();
+ try {
+ await click(c, "direct-action-delete");
+ await type(c, "direct-target-input", EVENT);
+ await settle();
+ assert.match(q(c, "direct-lookup-error").textContent, copy);
+ assert.ok(q(c, "direct-review-btn").disabled, "Review stays off");
+ } finally {
+ await unmount();
+ }
+ }
+});
+
+test("actions-delete-link: a note link previews the message and sends its hex id", async () => {
+ // Mutation: accept only hex in parseEventIdInput → RED.
+ const { noteEncode } = await import("nostr-tools/nip19");
+ setIpcHandler("admin_get_event", ({ id }) =>
+ Promise.resolve(preview(`preview of ${id.slice(0, 4)}`)),
+ );
+ const sent = [];
+ setIpcHandler("admin_direct_action", ({ intent }) => {
+ sent.push(intent);
+ return Promise.resolve({
+ actionId: "a1",
+ state: "succeeded",
+ replayed: false,
+ });
+ });
+ const { container: c, unmount } = await mountActions();
+ try {
+ await click(c, "direct-action-delete");
+ await type(c, "direct-target-input", noteEncode(EVENT));
+ await settle();
+ assert.match(q(c, "direct-event-preview").textContent, /preview of efef/);
+ await click(c, "direct-review-btn");
+ assert.ok(
+ q(c, "direct-confirm").querySelector(
+ "[data-testid='direct-event-preview']",
+ ),
+ );
+ await click(c, "direct-confirm-btn");
+ assert.equal(sent[0].target, EVENT);
+ } finally {
+ await unmount();
+ }
+});
+
+test("actions-staff-target: a staff member can't reach Review", async () => {
+ // Mutation: drop the isStaff check from lookupBlock → RED.
+ setIpcHandler("admin_get_member", ({ pubkey }) =>
+ Promise.resolve(memberDto(pubkey, { isStaff: true })),
+ );
+ let calls = 0;
+ setIpcHandler("admin_direct_action", () => {
+ calls += 1;
+ return Promise.resolve({});
+ });
+ const { container: c, unmount } = await mountActions();
+ try {
+ await pickKey(c, TARGET);
+ assert.match(
+ q(c, "direct-lookup-error").textContent,
+ /Relay staff can't be banned/,
+ );
+ assert.ok(q(c, "direct-review-btn").disabled);
+ assert.equal(calls, 0);
+ } finally {
+ await unmount();
+ }
+});
+
+test("fenced-load: an answer for an old key never shows under the new key", async () => {
+ // Mutation: drop both the effect-local `active` flag and the result-key
+ // check from useFencedLoad → RED (A's late answer shows under key B).
+ const React = await import("react");
+ const { createRoot } = await import("react-dom/client");
+ const { useFencedLoad } = await import("./AdminConsoleActionsTab.tsx");
+ const resolvers = {};
+ const seen = [];
+ function Probe({ k }) {
+ const state = useFencedLoad(
+ k,
+ () => new Promise((resolve) => (resolvers[k] = resolve)),
+ );
+ seen.push(`${k}:${state.status}:${state.data ?? ""}`);
+ return null;
+ }
+ const el = document.createElement("div");
+ const root = createRoot(el);
+ await act(async () =>
+ root.render(React.createElement(Probe, { k: "alpha" })),
+ );
+ await act(async () => root.render(React.createElement(Probe, { k: "beta" })));
+ await act(async () => resolvers.alpha("from alpha"));
+ assert.equal(seen.at(-1), "beta:loading:", "alpha's answer must not show");
+ await act(async () => resolvers.beta("from beta"));
+ assert.equal(seen.at(-1), "beta:ok:from beta");
+ await act(async () => root.unmount());
+});
diff --git a/desktop/src/features/admin-console/adminConsolePanelCommunities.jsdom-test.mjs b/desktop/src/features/admin-console/adminConsolePanelCommunities.jsdom-test.mjs
new file mode 100644
index 0000000000..ddc96cd8f4
--- /dev/null
+++ b/desktop/src/features/admin-console/adminConsolePanelCommunities.jsdom-test.mjs
@@ -0,0 +1,431 @@
+/**
+ * Communities tab, community badges, and the lift fence on a community page.
+ */
+import assert from "node:assert/strict";
+import { afterEach, test } from "node:test";
+import {
+ act,
+ CM_ORIGIN,
+ CM_PUBKEY,
+ deferred,
+ fireEvent,
+ makeOpenReportFixtures,
+ mountCommunityPanel,
+ mountPanel,
+ resetTestState,
+ setIpcHandler,
+ settle,
+ TEST_COMMUNITY,
+} from "./adminConsolePanelTestHelpers.jsdom.mjs";
+
+afterEach(resetTestState);
+
+const q = (c, id) => c.querySelector(`[data-testid='${id}']`);
+const community = (n) => ({
+ id: `00000000-0000-4000-8000-00000000000${n}`,
+ host: `c${n}.example.com`,
+ icon: null,
+});
+
+async function click(el) {
+ await act(async () => {
+ fireEvent.click(el);
+ });
+ await settle();
+}
+
+async function mountCommunities() {
+ setIpcHandler("admin_list_reports", () => Promise.resolve([]));
+ const panel = mountPanel({
+ origin: CM_ORIGIN,
+ pubkey: CM_PUBKEY,
+ role: "operator",
+ initialTab: "communities",
+ });
+ await panel.doRender();
+ await settle();
+ return panel;
+}
+
+test("communities-directory: the connected community is pinned by exact host and Load more pages by cursor", async () => {
+ // Mutation: pin from the loaded page instead of an exact-host read → RED
+ // (the connected community is on no loaded page).
+ const calls = [];
+ setIpcHandler("admin_connected_community_host", () =>
+ Promise.resolve("c9.example.com"),
+ );
+ setIpcHandler("admin_list_communities", ({ q: query, cursor }) => {
+ calls.push({ query, cursor });
+ if (query === "c9.example.com") {
+ return Promise.resolve({ items: [community(9)], nextCursor: null });
+ }
+ return Promise.resolve(
+ cursor === "page2"
+ ? { items: [community(3)], nextCursor: null }
+ : { items: [community(1), community(2)], nextCursor: "page2" },
+ );
+ });
+ const { container: c, unmount } = await mountCommunities();
+ try {
+ assert.ok(
+ q(q(c, "communities-pinned"), "community-row-c9.example.com"),
+ "connected community pinned",
+ );
+ await click(q(c, "communities-load-more"));
+ const hosts = [
+ ...c.querySelectorAll("[data-testid^='community-row-']"),
+ ].map((el) => el.dataset.testid.replace("community-row-", ""));
+ assert.deepEqual(hosts, [
+ "c9.example.com",
+ "c1.example.com",
+ "c2.example.com",
+ "c3.example.com",
+ ]);
+ assert.ok(!q(c, "communities-load-more"), "no more pages");
+ assert.ok(calls.some((x) => x.cursor === "page2"));
+ await click(q(c, "community-row-c2.example.com"));
+ assert.match(q(c, "community-banner").textContent, /c2\.example\.com/);
+ assert.ok(q(c, "community-not-connected"), "warns it isn't connected");
+ } finally {
+ await unmount();
+ }
+});
+
+test("communities-pin-search: the connected community stays pinned only under a matching host prefix", async () => {
+ // Mutation: pin regardless of the search → RED (c9 pinned under "c1").
+ setIpcHandler("admin_connected_community_host", () =>
+ Promise.resolve("c9.example.com"),
+ );
+ setIpcHandler("admin_list_communities", ({ q: query }) =>
+ Promise.resolve({
+ items: [community(1), community(9)].filter((x) =>
+ x.host.startsWith((query ?? "").toLowerCase()),
+ ),
+ nextCursor: null,
+ }),
+ );
+ const { container: c, unmount } = await mountCommunities();
+ const search = async (text) => {
+ await act(async () => {
+ fireEvent.change(q(c, "communities-search-input"), {
+ target: { value: text },
+ });
+ });
+ await settle();
+ };
+ try {
+ await search("c1");
+ assert.equal(q(c, "communities-pinned"), null, "unrelated search");
+ assert.ok(q(c, "community-row-c1.example.com"));
+ await search(" C9 ");
+ assert.ok(
+ q(q(c, "communities-pinned"), "community-row-c9.example.com"),
+ "case-insensitive trimmed prefix keeps the pin",
+ );
+ } finally {
+ await unmount();
+ }
+});
+
+test("communities-secret-key: a pasted key backup anywhere in the search is never sent", async () => {
+ // Mutation: drop the containsSecretKey gate from the directory search → RED.
+ // The prefix is assembled so this file stays outside the frontend
+ // key-backup source scan's allowlist.
+ const backup = ["ncrypt", "sec1"].join("") + "q".repeat(40);
+ const queries = [];
+ setIpcHandler("admin_list_communities", ({ q: query }) => {
+ queries.push(query);
+ return Promise.resolve({ items: [community(1)], nextCursor: null });
+ });
+ const { container: c, unmount } = await mountCommunities();
+ const search = async (text) => {
+ await act(async () => {
+ fireEvent.change(q(c, "communities-search-input"), {
+ target: { value: text },
+ });
+ });
+ await settle();
+ };
+ try {
+ queries.length = 0;
+ for (const text of [backup, `team ${backup} host`, backup.toUpperCase()]) {
+ await search(text);
+ assert.ok(q(c, "communities-search-secret"), `no warning for ${text}`);
+ assert.equal(q(c, "community-row-c1.example.com"), null);
+ }
+ assert.deepEqual(
+ queries,
+ [],
+ "a key backup reached admin_list_communities",
+ );
+ await search("c1.example");
+ assert.equal(q(c, "communities-search-secret"), null);
+ assert.deepEqual(queries, ["c1.example"], "host search still runs");
+ assert.ok(q(c, "community-row-c1.example.com"));
+ } finally {
+ await unmount();
+ }
+});
+
+test("communities-secret-more: Load more never sends key material typed over a page that has more", async () => {
+ // Mutation: drop the secret gate from Load more → RED. For one render the
+ // deferred secret query sits beside the previous page's cursor before that
+ // page clears, too briefly to click. Recording every onClick React commits
+ // to the button and invoking each afterwards drives exactly those handlers.
+ const backup = ["ncrypt", "sec1"].join("") + "q".repeat(40);
+ const nsec = ["ns", "ec1"].join("") + "q".repeat(40);
+ const calls = [];
+ setIpcHandler("admin_list_communities", ({ q: query, cursor }) => {
+ calls.push({ query, cursor });
+ return Promise.resolve({ items: [community(1)], nextCursor: "page2" });
+ });
+ const { container: c, unmount } = await mountCommunities();
+ const type = async (text) => {
+ await act(async () => {
+ fireEvent.change(q(c, "communities-search-input"), {
+ target: { value: text },
+ });
+ });
+ await settle();
+ };
+ try {
+ for (const text of [backup, `team ${nsec} host`, backup.toUpperCase()]) {
+ await type("");
+ const more = q(c, "communities-load-more");
+ assert.ok(more, "a page with more is shown");
+ const propsKey = Object.keys(more).find((k) =>
+ k.startsWith("__reactProps$"),
+ );
+ assert.ok(propsKey, "React props key not found on Load more");
+ let props = more[propsKey];
+ assert.equal(
+ typeof props?.onClick,
+ "function",
+ "no Load more handler captured before the secret was typed",
+ );
+ const handlers = [];
+ Object.defineProperty(more, propsKey, {
+ configurable: true,
+ get: () => props,
+ set: (next) => {
+ props = next;
+ handlers.push(next.onClick);
+ },
+ });
+ await type(text);
+ assert.ok(q(c, "communities-search-secret"), `no warning for ${text}`);
+ assert.equal(q(c, "communities-load-more"), null);
+ await act(async () => {
+ for (const onClick of handlers) onClick?.();
+ });
+ await settle();
+ }
+ const leaked = calls.filter((x) => /sec1/i.test(x.query ?? ""));
+ assert.deepEqual(leaked, [], "key material reached admin_list_communities");
+ } finally {
+ await unmount();
+ }
+});
+
+test("communities-stale-more: a late page from an earlier search leaves the current search's pages alone", async () => {
+ // Mutation: drop either searchRef check in loadMore → RED (B's second page
+ // vanishes, or B shows A's error).
+ for (const settleA of ["resolve", "reject"]) {
+ const late = deferred();
+ const bPage = deferred();
+ const cursors = [];
+ setIpcHandler("admin_connected_community_host", () =>
+ Promise.resolve(null),
+ );
+ setIpcHandler("admin_list_communities", ({ q: query, cursor }) => {
+ cursors.push(cursor ?? null);
+ if (cursor === "a2") return late.promise;
+ if (cursor === "b2") return bPage.promise;
+ if (cursor === "b3") {
+ return Promise.resolve({ items: [community(6)], nextCursor: null });
+ }
+ return Promise.resolve(
+ query === "b"
+ ? { items: [community(4)], nextCursor: "b2" }
+ : { items: [community(1)], nextCursor: "a2" },
+ );
+ });
+ const { container: c, unmount } = await mountCommunities();
+ const search = async (text) => {
+ await act(async () => {
+ fireEvent.change(q(c, "communities-search-input"), {
+ target: { value: text },
+ });
+ });
+ await settle();
+ };
+ try {
+ await search("a");
+ await click(q(c, "communities-load-more")); // A's page 2 stays pending
+ await search("b");
+ await click(q(c, "communities-load-more")); // B's page 2 in flight
+ await act(async () => {
+ if (settleA === "resolve") {
+ late.resolve({ items: [community(2)], nextCursor: null });
+ } else {
+ late.reject(new Error("admin API error: stale page"));
+ }
+ });
+ await settle();
+ assert.ok(
+ q(c, "communities-load-more").disabled,
+ `B's request is still busy (${settleA})`,
+ );
+ await act(async () => {
+ bPage.resolve({ items: [community(5)], nextCursor: "b3" });
+ });
+ await settle();
+ const hosts = [
+ ...c.querySelectorAll("[data-testid^='community-row-']"),
+ ].map((el) => el.dataset.testid.replace("community-row-", ""));
+ assert.deepEqual(hosts, ["c4.example.com", "c5.example.com"], settleA);
+ assert.doesNotMatch(c.textContent, /stale page/, settleA);
+ const more = q(c, "communities-load-more");
+ assert.ok(more && !more.disabled, `B can still load more (${settleA})`);
+ await click(more);
+ assert.equal(cursors.at(-1), "b3", `B keeps its cursor (${settleA})`);
+ } finally {
+ await unmount();
+ }
+ }
+});
+
+test("communities-unsupported: an older relay's empty 404 shows the copy and the console keeps working", async () => {
+ // Mutation: drop the bodyEmpty check from adminRouteUnsupported → RED.
+ for (const [bodyEmpty, unsupported] of [
+ [true, true],
+ [false, false],
+ ]) {
+ setIpcHandler("admin_connected_community_host", () =>
+ Promise.resolve(TEST_COMMUNITY.host),
+ );
+ setIpcHandler("admin_list_communities", () =>
+ Promise.reject({
+ message: "admin API error: ",
+ relayStatus: 404,
+ bodyComplete: true,
+ bodyEmpty,
+ code: null,
+ }),
+ );
+ const { container: c, unmount } = await mountCommunities();
+ try {
+ assert.equal(Boolean(q(c, "communities-unsupported")), unsupported);
+ await click(q(c, "admin-tab-reports"));
+ assert.ok(q(c, "reports-tab"), "Reports still works");
+ } finally {
+ await unmount();
+ }
+ }
+});
+
+test("community-badge: a report's badge opens its community, with a host-initial fallback", async () => {
+ // Mutation: render the badge as a plain span when navigation exists → RED.
+ setIpcHandler("admin_connected_community_host", () =>
+ Promise.resolve(TEST_COMMUNITY.host),
+ );
+ makeOpenReportFixtures("00000000-0000-0000-0000-0000000000a1", {
+ communityId: TEST_COMMUNITY.id,
+ });
+ const panel = mountPanel({ origin: CM_ORIGIN, pubkey: CM_PUBKEY });
+ await panel.doRender();
+ await settle(30);
+ const c = panel.container;
+ try {
+ const badge = q(c, `community-badge-${TEST_COMMUNITY.host}`);
+ assert.equal(badge?.tagName, "BUTTON", "badge is its own button");
+ assert.equal(
+ badge.parentElement.closest("button"),
+ null,
+ "not nested in a row button",
+ );
+ assert.equal(q(badge, "community-badge-initial").textContent, "a");
+ await click(badge);
+ assert.match(q(c, "community-banner").textContent, /alpha\.example\.com/);
+ await settle(30);
+ assert.ok(q(c, "community-group"), "the page lists the report");
+ assert.equal(
+ q(c, "community-group-host"),
+ null,
+ "the banner already names the community",
+ );
+ } finally {
+ await panel.unmount();
+ }
+});
+
+test("community-badge-disabled-auth: with admin auth disabled badges still open the community page", async () => {
+ // Mutation: gate the panel's nav.open on canMutate → RED (badge is a span).
+ makeOpenReportFixtures("00000000-0000-0000-0000-0000000000a2", {
+ communityId: TEST_COMMUNITY.id,
+ });
+ const panel = mountPanel({
+ origin: CM_ORIGIN,
+ pubkey: CM_PUBKEY,
+ canMutate: false,
+ });
+ await panel.doRender();
+ await settle(30);
+ try {
+ const badge = q(panel.container, `community-badge-${TEST_COMMUNITY.host}`);
+ assert.equal(badge?.tagName, "BUTTON");
+ await click(badge);
+ assert.ok(q(panel.container, "community-page"), "badge opens the page");
+ } finally {
+ await panel.unmount();
+ }
+});
+
+test("restrictions-lift-signer-change: a signer change while confirming a lift sends nothing", async () => {
+ // Mutation: drop the controller's (pubkey, origin) key → RED (the dialog
+ // survives and confirms under the new signer).
+ const lifts = [];
+ setIpcHandler("admin_lift_restriction", ({ intent }) => {
+ lifts.push(intent);
+ return Promise.resolve();
+ });
+ const banned = "29".repeat(32);
+ setIpcHandler("admin_list_restrictions", () =>
+ Promise.resolve({
+ items: [
+ {
+ pubkey: banned,
+ banned: true,
+ banExpiresAt: null,
+ banReason: "spam",
+ mutedUntil: null,
+ muteReason: null,
+ actorPubkey: "aa".repeat(32),
+ updatedAt: "2024-06-01T09:00:00Z",
+ },
+ ],
+ nextCursor: null,
+ }),
+ );
+ const panel = mountCommunityPanel(CM_ORIGIN, CM_PUBKEY, "restrictions");
+ await panel.doRender();
+ await settle(50);
+ const c = panel.container;
+ try {
+ await click(q(c, `restrictions-lift-ban-btn-${banned}`));
+ assert.ok(
+ document.body.querySelector(
+ "[data-testid='restrictions-lift-ban-dialog']",
+ ),
+ );
+ await panel.doRender({ origin: CM_ORIGIN, pubkey: "ee".repeat(32) });
+ await settle(50);
+ const confirm = document.body.querySelector(
+ "[data-testid='restrictions-lift-ban-confirm']",
+ );
+ if (confirm) await click(confirm);
+ assert.deepEqual(lifts, [], "no lift under a changed signer");
+ } finally {
+ await panel.unmount();
+ }
+});
diff --git a/desktop/src/features/admin-console/adminConsolePanelFeedback.jsdom-test.mjs b/desktop/src/features/admin-console/adminConsolePanelFeedback.jsdom-test.mjs
index 244dc4e199..d029acb7ad 100644
--- a/desktop/src/features/admin-console/adminConsolePanelFeedback.jsdom-test.mjs
+++ b/desktop/src/features/admin-console/adminConsolePanelFeedback.jsdom-test.mjs
@@ -138,7 +138,7 @@ test("feedback-detail-renders-structured-fields: FeedbackDetail shows field layo
const allButtons = container.querySelectorAll("button");
for (const btn of allButtons) {
const testid = btn.getAttribute("data-testid") ?? "";
- if (testid.startsWith("admin-tab")) continue;
+ if (testid.match(/^(admin-tab|community-badge)/)) continue;
await act(async () => {
fireEvent.click(btn);
await new Promise((r) => setTimeout(r, 30));
@@ -249,7 +249,9 @@ test("feedback-grouped-by-community: multi-community feedback renders per-commun
await settle(30);
const hosts = Array.from(
- container.querySelectorAll("[data-testid='community-group-host']"),
+ container.querySelectorAll(
+ "[data-testid='community-group-host'] [data-testid='community-badge-host']",
+ ),
).map((el) => el.textContent);
assert.deepEqual(
hosts,
@@ -321,8 +323,9 @@ test("feedback-status-honest: a reviewed detail reports reviewed, never defaulti
// Navigate into the feedback detail.
const listRow = Array.from(container.querySelectorAll("button")).find(
(btn) =>
- !(btn.getAttribute("data-testid") ?? "").startsWith("admin-tab") &&
- btn.textContent?.includes("Already-triaged feedback"),
+ !(btn.getAttribute("data-testid") ?? "").match(
+ /^(admin-tab|community-badge)/,
+ ) && btn.textContent?.includes("Already-triaged feedback"),
);
assert.ok(listRow, "feedback list row must be present");
await act(async () => {
@@ -421,8 +424,9 @@ test("feedback-severed-community: a purged-source feedback row renders in list a
// The severed row still renders in the list (did not throw / vanish).
const listRow = Array.from(container.querySelectorAll("button")).find(
(btn) =>
- !(btn.getAttribute("data-testid") ?? "").startsWith("admin-tab") &&
- btn.textContent?.includes("since-purged community"),
+ !(btn.getAttribute("data-testid") ?? "").match(
+ /^(admin-tab|community-badge)/,
+ ) && btn.textContent?.includes("since-purged community"),
);
assert.ok(listRow, "the severed feedback row must render in the list");
@@ -513,8 +517,9 @@ test("feedback-list-refetches-on-back-after-mutation: changing status then navig
// Open the first feedback row.
const row = Array.from(container.querySelectorAll("button")).find(
(b) =>
- !(b.getAttribute("data-testid") ?? "").startsWith("admin-tab") &&
- b.textContent?.includes("App crashes"),
+ !(b.getAttribute("data-testid") ?? "").match(
+ /^(admin-tab|community-badge)/,
+ ) && b.textContent?.includes("App crashes"),
);
assert.ok(row, "feedback row must be present");
await act(async () => {
@@ -583,7 +588,10 @@ test("canMutate-false-feedback: feedback-status-control absent in disabled mode"
});
await settle(30);
const listBtns = Array.from(container.querySelectorAll("button")).filter(
- (b) => !(b.getAttribute("data-testid") ?? "").startsWith("admin-tab"),
+ (b) =>
+ !(b.getAttribute("data-testid") ?? "").match(
+ /^(admin-tab|community-badge)/,
+ ),
);
assert.ok(listBtns.length > 0, "feedback list item must be present");
await act(async () => {
@@ -664,7 +672,10 @@ test("feedback-status-readonly: read-only detail shows status badge, no status-c
// Click the feedback list item to open detail.
const listBtns = Array.from(container.querySelectorAll("button")).filter(
- (b) => !(b.getAttribute("data-testid") ?? "").startsWith("admin-tab"),
+ (b) =>
+ !(b.getAttribute("data-testid") ?? "").match(
+ /^(admin-tab|community-badge)/,
+ ),
);
assert.ok(listBtns.length > 0, "feedback list item must be present");
await act(async () => {
@@ -764,7 +775,10 @@ test("non-image-attachment-save-button: a non-image attachment shows a Save butt
// Navigate to feedback detail.
const allButtons = Array.from(container.querySelectorAll("button"));
const itemBtn = allButtons.find(
- (b) => !(b.getAttribute("data-testid") ?? "").startsWith("admin-tab"),
+ (b) =>
+ !(b.getAttribute("data-testid") ?? "").match(
+ /^(admin-tab|community-badge)/,
+ ),
);
assert.ok(itemBtn, "feedback item button must exist");
await act(async () => {
diff --git a/desktop/src/features/admin-console/adminConsolePanelReports.jsdom-test.mjs b/desktop/src/features/admin-console/adminConsolePanelReports.jsdom-test.mjs
index 6e89c8afff..f6ed8ee5de 100644
--- a/desktop/src/features/admin-console/adminConsolePanelReports.jsdom-test.mjs
+++ b/desktop/src/features/admin-console/adminConsolePanelReports.jsdom-test.mjs
@@ -42,7 +42,7 @@ async function openFirstDetailRow(container) {
const allButtons = container.querySelectorAll("button");
for (const btn of allButtons) {
const testid = btn.getAttribute("data-testid") ?? "";
- if (testid.startsWith("admin-tab")) continue;
+ if (testid.match(/^(admin-tab|community-badge)/)) continue;
await act(async () => {
fireEvent.click(btn);
await new Promise((r) => setTimeout(r, 30));
@@ -336,7 +336,10 @@ test("processing-report-navigable-suppresses-resolve-form: a processing report o
// The processing row must be a navigable (non-disabled) button.
const rowButtons = Array.from(container.querySelectorAll("button")).filter(
- (btn) => !(btn.getAttribute("data-testid") ?? "").startsWith("admin-tab"),
+ (btn) =>
+ !(btn.getAttribute("data-testid") ?? "").match(
+ /^(admin-tab|community-badge)/,
+ ),
);
const processingRow = rowButtons.find((btn) =>
btn.textContent?.includes("spam"),
@@ -429,7 +432,9 @@ test("reports-grouped-by-community: multi-community reports render per-community
);
const hosts = Array.from(
- container.querySelectorAll("[data-testid='community-group-host']"),
+ container.querySelectorAll(
+ "[data-testid='community-group-host'] [data-testid='community-badge-host']",
+ ),
).map((el) => el.textContent);
assert.deepEqual(
hosts,
@@ -450,7 +455,7 @@ async function openFirstReportDetail(container) {
const allButtons = container.querySelectorAll("button");
for (const btn of allButtons) {
const testid = btn.getAttribute("data-testid") ?? "";
- if (testid.startsWith("admin-tab")) continue;
+ if (testid.match(/^(admin-tab|community-badge)/)) continue;
await act(async () => {
fireEvent.click(btn);
await new Promise((r) => setTimeout(r, 30));
@@ -1500,7 +1505,10 @@ test("attachment-budget-seam: only 5 of 7 image attachments trigger native fetch
// Click the feedback list item to open detail — the first non-tab button.
const listButtons = Array.from(container.querySelectorAll("button")).filter(
- (b) => !(b.getAttribute("data-testid") ?? "").startsWith("admin-tab"),
+ (b) =>
+ !(b.getAttribute("data-testid") ?? "").match(
+ /^(admin-tab|community-badge)/,
+ ),
);
assert.ok(
listButtons.length > 0,
@@ -2782,3 +2790,132 @@ test("report-detail-event-target: event targets show and copy the raw event ID,
await unmount();
}
});
+
+test("list-load-forbidden-shows-parsed-message: a 403 list load shows the relay message, not the raw envelope", async () => {
+ // Mutation evidence: restore `e instanceof Error ? e.message : String(e)` in
+ // useAsyncLoad's error branch → the envelope renders and this goes red.
+ const forbidden = `admin API error: {"error":{"code":"forbidden","message":"request is not authorized","requestId":"00000000-0000-0000-0000-0000000000a3"}}`;
+ setIpcHandler("admin_list_reports", () => mutationReject(forbidden, 403));
+ setIpcHandler("admin_list_feedback", () => Promise.resolve([]));
+
+ const { container, doRender, unmount } = mountPanel({
+ origin: "https://admin.example.com",
+ pubkey: "a3".repeat(32),
+ });
+ await doRender();
+ await settle(30);
+
+ const text = container.textContent ?? "";
+ assert.ok(
+ text.includes("request is not authorized"),
+ `the parsed relay message must render; got: ${text.slice(0, 400)}`,
+ );
+ assert.ok(
+ !text.includes('{"error"') && !text.includes("admin API error:"),
+ `the raw envelope must not render; got: ${text.slice(0, 400)}`,
+ );
+
+ await unmount();
+});
+
+test("resolve-enforcement-failed-reloads-detail: a 422 enforcement_failed resolve shows the failed action and Cancel & reopen in place", async () => {
+ // The relay persists the report as `processing` with a failed action before
+ // answering 422. Mutation evidence: drop the `enforcement_failed` reload in
+ // ResolveReportForm's catch → the detail stays `open` and this goes red.
+ const openItem = {
+ id: "00000000-0000-0000-0000-0000000000b4",
+ communityId: "comm-1",
+ communityHost: "alpha.example.com",
+ reportEventId: "aa",
+ reporterPubkey: "bb",
+ targetKind: "event",
+ target: "cc",
+ reportType: "spam",
+ status: "open",
+ createdAt: "2024-06-01T12:00:00Z",
+ };
+ const openDetail = {
+ ...openItem,
+ channelId: "00000000-0000-0000-0000-0000000000ff",
+ note: null,
+ resolvedBy: null,
+ resolvedAt: null,
+ actionId: null,
+ activeAction: null,
+ message: null,
+ };
+ const failedDetail = {
+ ...openDetail,
+ status: "processing",
+ activeAction: {
+ id: "00000000-0000-0000-0000-0000000000b5",
+ requestId: "00000000-0000-0000-0000-0000000000b6",
+ actorPubkey: "11".repeat(32),
+ actorRole: "operator",
+ action: "kick",
+ status: "failed",
+ reason: null,
+ expiresAt: null,
+ errorMessage: "kick target was already absent before this action",
+ createdAt: "2024-06-01T12:00:00Z",
+ updatedAt: "2024-06-01T12:00:05Z",
+ },
+ };
+ let resolved = false;
+ setIpcHandler("admin_list_reports", () =>
+ Promise.resolve([
+ { ...openItem, status: resolved ? "processing" : "open" },
+ ]),
+ );
+ setIpcHandler("admin_get_report", () =>
+ Promise.resolve(resolved ? failedDetail : openDetail),
+ );
+ setIpcHandler("admin_list_feedback", () => Promise.resolve([]));
+ setIpcHandler("admin_resolve_report", () => {
+ resolved = true;
+ return mutationReject(
+ `admin API error: {"error":{"code":"enforcement_failed","message":"kick target was already absent before this action","requestId":"00000000-0000-0000-0000-0000000000b6"}}`,
+ 422,
+ );
+ });
+
+ const { container, doRender, unmount } = mountPanel({
+ origin: "https://admin.example.com",
+ pubkey: "b4".repeat(32),
+ });
+ await doRender();
+ await settle(30);
+ await openFirstReportDetail(container);
+ await settle(20);
+
+ const kickBtn = container.querySelector("[data-testid='action-btn-kick']");
+ assert.ok(kickBtn, "kick action must be present (channel is set)");
+ await act(async () => {
+ fireEvent.click(kickBtn);
+ await new Promise((r) => setTimeout(r, 10));
+ });
+ const submit = container.querySelector("[data-testid='resolve-submit-btn']");
+ assert.ok(submit, "resolve submit button must appear after selecting kick");
+ await act(async () => {
+ fireEvent.click(submit);
+ await new Promise((r) => setTimeout(r, 30));
+ });
+ await settle(30);
+
+ const text = container.textContent ?? "";
+ assert.ok(
+ capturedErrorToasts.some((m) => m.includes("already absent")),
+ `the failure toast must remain; got: ${JSON.stringify(capturedErrorToasts)}`,
+ );
+ assert.ok(
+ text.includes("Cancel & reopen"),
+ `Cancel & reopen must appear without navigating away; got: ${text.slice(0, 600)}`,
+ );
+ assert.equal(
+ container.querySelector("[data-testid='resolve-report-form']"),
+ null,
+ "the resolve form must be replaced by the reloaded processing detail",
+ );
+
+ await unmount();
+});
diff --git a/desktop/src/features/admin-console/adminConsolePanelSession.jsdom-test.mjs b/desktop/src/features/admin-console/adminConsolePanelSession.jsdom-test.mjs
index fc5fe3ddd2..6be70b7bd8 100644
--- a/desktop/src/features/admin-console/adminConsolePanelSession.jsdom-test.mjs
+++ b/desktop/src/features/admin-console/adminConsolePanelSession.jsdom-test.mjs
@@ -281,7 +281,7 @@ test("detail-navigation: stale detail result is discarded after navigating away"
let clickedReport = false;
for (const btn of allButtons) {
const testid = btn.getAttribute("data-testid") ?? "";
- if (testid.startsWith("admin-tab")) continue;
+ if (/^(admin-tab|community-badge)/.test(testid)) continue;
await act(async () => {
fireEvent.click(btn);
await new Promise((r) => setTimeout(r, 0));
@@ -428,7 +428,7 @@ test("blob-leak-on-back-navigation: loadGenRef cleanup prevents orphaned blob UR
let navigatedToDetail = false;
for (const btn of container.querySelectorAll("button")) {
const testid = btn.getAttribute("data-testid") ?? "";
- if (testid.startsWith("admin-tab")) continue;
+ if (/^(admin-tab|community-badge)/.test(testid)) continue;
await act(async () => {
fireEvent.click(btn);
await new Promise((r) => setTimeout(r, 30));
@@ -1148,7 +1148,7 @@ test("settings-card-self-demotion-reruns-probe: self-demotion through SettingsCa
// After initial probe: operator role → Staffing tab must be visible.
const staffingTabBefore = container.querySelector(
- "[data-testid='admin-tab-staffing']",
+ "[data-testid='admin-tab-operators']",
);
assert.ok(
staffingTabBefore !== null,
@@ -1188,7 +1188,7 @@ test("settings-card-self-demotion-reruns-probe: self-demotion through SettingsCa
// After the second probe returns moderator: Staffing tab must be gone.
const staffingTabAfter = container.querySelector(
- "[data-testid='admin-tab-staffing']",
+ "[data-testid='admin-tab-operators']",
);
assert.equal(
staffingTabAfter,
@@ -1257,7 +1257,7 @@ test("settings-card-other-demotion-does-not-reruns-probe: demoting a different o
// Navigate to the Staffing tab.
const staffingTab = container.querySelector(
- "[data-testid='admin-tab-staffing']",
+ "[data-testid='admin-tab-operators']",
);
assert.ok(staffingTab !== null, "Staffing tab must be visible for operator");
await act(async () => {
@@ -1289,7 +1289,7 @@ test("settings-card-other-demotion-does-not-reruns-probe: demoting a different o
// Staffing tab must remain visible (self is still operator).
const staffingTabAfter = container.querySelector(
- "[data-testid='admin-tab-staffing']",
+ "[data-testid='admin-tab-operators']",
);
assert.ok(
staffingTabAfter !== null,
@@ -1371,7 +1371,7 @@ test("settings-card-stale-self-mutation-ignored-after-origin-switch: stale self-
assert.equal(probeCount, 1, "should have probed once on mount for A");
const staffingTab = container.querySelector(
- "[data-testid='admin-tab-staffing']",
+ "[data-testid='admin-tab-operators']",
);
assert.ok(
staffingTab !== null,
@@ -1565,7 +1565,7 @@ test("settings-card-stale-self-mutation-ignored-after-session-teardown: deferred
);
const staffingTab = container.querySelector(
- "[data-testid='admin-tab-staffing']",
+ "[data-testid='admin-tab-operators']",
);
assert.ok(staffingTab !== null, "Staffing tab must be visible (operator)");
diff --git a/desktop/src/features/admin-console/adminConsolePanelStaffing.jsdom-test.mjs b/desktop/src/features/admin-console/adminConsolePanelStaffing.jsdom-test.mjs
index 303ace789f..d7f16faa26 100644
--- a/desktop/src/features/admin-console/adminConsolePanelStaffing.jsdom-test.mjs
+++ b/desktop/src/features/admin-console/adminConsolePanelStaffing.jsdom-test.mjs
@@ -1,7 +1,7 @@
/**
* Staffing tab behavior tests for AdminConsolePanel. Covers operator
* add/remove/role-change, display-name integration, self-removal callback,
- * dialog confirmation, canMutate gates, and tab reset on role downgrade.
+ * dialog confirmation, and tab reset on role downgrade.
*/
import assert from "node:assert/strict";
import { afterEach, test } from "node:test";
@@ -18,52 +18,16 @@ import {
mutationReject,
makeQueryClient,
mountPanel,
+ mountCommunityPanel,
mountStaffingPanel,
+ TEST_COMMUNITY,
settle,
- CM_ORIGIN,
- CM_PUBKEY,
- CM_OP_PUBKEY,
TEST_RELAY_WS_URL,
+ deferred,
} from "./adminConsolePanelTestHelpers.jsdom.mjs";
afterEach(resetTestState);
-test("canMutate-false-staffing: staffing add/remove absent in disabled mode", async () => {
- // Mutation: remove {canMutate && …} guards on staffing add/remove → buttons render → RED.
- setIpcHandler("admin_list_reports", () => Promise.resolve([]));
- setIpcHandler("admin_list_feedback", () => Promise.resolve([]));
- setIpcHandler("admin_list_operators", () =>
- Promise.resolve([
- { pubkey: CM_OP_PUBKEY, effectiveRole: "moderator", sources: ["db"] },
- ]),
- );
- const { container, doRender, unmount } = mountPanel({
- origin: CM_ORIGIN,
- pubkey: CM_PUBKEY,
- canMutate: false,
- role: "operator",
- initialTab: "staffing",
- });
- try {
- await doRender();
- await settle(30);
- assert.equal(
- container.querySelector("[data-testid='staffing-add-btn']"),
- null,
- "staffing-add-btn must be absent when canMutate=false",
- );
- assert.equal(
- container.querySelector(
- `[data-testid='staffing-remove-btn-${CM_OP_PUBKEY}']`,
- ),
- null,
- "staffing-remove-btn must be absent when canMutate=false",
- );
- } finally {
- await unmount();
- }
-});
-
// ── P1: Staffing remove confirmation dialog ───────────────────────────────────
//
// The trash button must open a confirmation dialog; the delete IPC must not fire
@@ -288,7 +252,7 @@ test("staffing-tab-reset-on-role-downgrade: panel shows reports content after op
origin,
pubkey,
role,
- initialTab: "staffing",
+ initialTab: "operators",
}),
),
),
@@ -349,7 +313,7 @@ test("staffing-tab-reset-on-role-downgrade: panel shows reports content after op
"reports tab button must be visible after reset",
);
const staffingTabBtn = container.querySelector(
- "[data-testid='admin-tab-staffing']",
+ "[data-testid='admin-tab-operators']",
);
assert.equal(
staffingTabBtn,
@@ -603,7 +567,7 @@ test("staffing-role-change-success: role selector change calls putAdminOperator
pubkey,
canMutate: true,
role: "operator",
- initialTab: "staffing",
+ initialTab: "operators",
});
await doRender();
await settle(30);
@@ -1031,10 +995,10 @@ test("restrictions-empty: restrictions section shows 'no active bans or timeouts
Promise.resolve({ items: [], nextCursor: null }),
);
- const { container, doRender, unmount } = mountStaffingPanel(
+ const { container, doRender, unmount } = mountCommunityPanel(
origin,
pubkey,
- [],
+ "restrictions",
);
await doRender();
await settle(50);
@@ -1077,7 +1041,11 @@ test("restrictions-unknown-host-is-an-error: an unresolved community host shows
);
});
- const { container, doRender, unmount } = mountStaffingPanel(origin, pubkey);
+ const { container, doRender, unmount } = mountCommunityPanel(
+ origin,
+ pubkey,
+ "restrictions",
+ );
await doRender();
await settle(30);
@@ -1098,9 +1066,9 @@ test("restrictions-unknown-host-is-an-error: an unresolved community host shows
);
assert.equal(listCalls.length >= 1, true);
assert.equal(
- "communityId" in listCalls[0],
- false,
- "no client community id is sent",
+ listCalls[0].communityHost,
+ TEST_COMMUNITY.host,
+ "the list names the page's community, not the active relay's",
);
} finally {
await unmount();
@@ -1120,10 +1088,10 @@ test("restrictions-rows: banned and timed-out members render with correct button
}),
);
- const { container, doRender, unmount } = mountStaffingPanel(
+ const { container, doRender, unmount } = mountCommunityPanel(
origin,
pubkey,
- [],
+ "restrictions",
);
await doRender();
await settle(50);
@@ -1175,7 +1143,7 @@ test("restrictions-lift-ban-cancel: cancel does not invoke admin_lift_ban", asyn
const bannedPubkey = "07".repeat(32);
const liftCalls = [];
- setIpcHandler("admin_lift_ban", (args) => {
+ setIpcHandler("admin_lift_restriction", ({ intent: args }) => {
liftCalls.push(args);
return Promise.resolve();
});
@@ -1183,10 +1151,10 @@ test("restrictions-lift-ban-cancel: cancel does not invoke admin_lift_ban", asyn
Promise.resolve({ items: [makeBanRecord(bannedPubkey)], nextCursor: null }),
);
- const { container, doRender, unmount } = mountStaffingPanel(
+ const { container, doRender, unmount } = mountCommunityPanel(
origin,
pubkey,
- [],
+ "restrictions",
);
await doRender();
await settle(50);
@@ -1246,7 +1214,7 @@ test("restrictions-lift-ban-confirm: confirming lift-ban calls admin_lift_ban wi
const liftCalls = [];
let remainingItems = [makeBanRecord(bannedPubkey)];
- setIpcHandler("admin_lift_ban", (args) => {
+ setIpcHandler("admin_lift_restriction", ({ intent: args }) => {
liftCalls.push(args);
remainingItems = [];
return Promise.resolve();
@@ -1255,10 +1223,10 @@ test("restrictions-lift-ban-confirm: confirming lift-ban calls admin_lift_ban wi
Promise.resolve({ items: [...remainingItems], nextCursor: null }),
);
- const { container, doRender, unmount } = mountStaffingPanel(
+ const { container, doRender, unmount } = mountCommunityPanel(
origin,
pubkey,
- [],
+ "restrictions",
);
await doRender();
await settle(50);
@@ -1296,15 +1264,17 @@ test("restrictions-lift-ban-confirm: confirming lift-ban calls admin_lift_ban wi
bannedPubkey,
`admin_lift_ban must receive the banned pubkey; got: ${liftCalls[0]?.pubkey}`,
);
- assert.equal(
- "communityId" in (liftCalls[0] ?? {}),
- false,
- "admin_lift_ban must not send a client community id; the native command derives the relay host",
- );
- assert.equal(
- liftCalls[0]?.expectedRelay,
- TEST_RELAY_WS_URL,
- "admin_lift_ban carries the relay the list loaded from",
+ assert.deepEqual(
+ liftCalls[0],
+ {
+ origin,
+ communityHost: TEST_COMMUNITY.host,
+ expectedRelay: TEST_RELAY_WS_URL,
+ expectedSigner: pubkey,
+ kind: "ban",
+ pubkey: bannedPubkey,
+ },
+ "a lift freezes the page's host and the list's relay and signer",
);
// After the lift the list refreshes and the row must be gone.
@@ -1322,6 +1292,83 @@ test("restrictions-lift-ban-confirm: confirming lift-ban calls admin_lift_ban wi
}
});
+test("restrictions-lift-frozen-during-reload: a lift confirmed while the list reloads keeps its row's relay and signer", async () => {
+ // Mutation: build the intent from the current list state at confirm time
+ // instead of freezing it at row selection → RED (empty relay and signer).
+ const origin = "https://admin-restrictions-concurrent.example.com";
+ const pubkey = "18".repeat(32);
+ const first = "31".repeat(32);
+ const second = "32".repeat(32);
+ const firstLift = deferred();
+ const reload = deferred();
+ const liftCalls = [];
+ let listCalls = 0;
+ setIpcHandler("admin_lift_restriction", ({ intent }) => {
+ liftCalls.push(intent);
+ return liftCalls.length === 1 ? firstLift.promise : Promise.resolve();
+ });
+ setIpcHandler("admin_list_restrictions", () => {
+ listCalls += 1;
+ return listCalls === 1
+ ? Promise.resolve({
+ items: [makeBanRecord(first), makeBanRecord(second)],
+ nextCursor: null,
+ })
+ : reload.promise;
+ });
+ const { container, doRender, unmount } = mountCommunityPanel(
+ origin,
+ pubkey,
+ "restrictions",
+ );
+ await doRender();
+ await settle(50);
+ const click = async (el) => {
+ assert.ok(el, "element present");
+ await act(async () => {
+ fireEvent.click(el);
+ await new Promise((r) => setTimeout(r, 10));
+ });
+ };
+ const confirm = () =>
+ document.body.querySelector(
+ "[data-testid='restrictions-lift-ban-confirm']",
+ );
+ try {
+ await click(
+ container.querySelector(
+ `[data-testid='restrictions-lift-ban-btn-${first}']`,
+ ),
+ );
+ await click(confirm()); // first lift in flight
+ await click(
+ container.querySelector(
+ `[data-testid='restrictions-lift-ban-btn-${second}']`,
+ ),
+ );
+ await act(async () => {
+ firstLift.resolve();
+ await new Promise((r) => setTimeout(r, 10));
+ });
+ assert.ok(listCalls >= 2, "the first lift started a list reload");
+ await click(confirm()); // confirmed while the reload is pending
+ assert.equal(liftCalls.length, 2);
+ assert.deepEqual(liftCalls[1], {
+ origin,
+ communityHost: TEST_COMMUNITY.host,
+ expectedRelay: TEST_RELAY_WS_URL,
+ expectedSigner: pubkey,
+ kind: "ban",
+ pubkey: second,
+ });
+ await act(async () => {
+ reload.resolve({ items: [], nextCursor: null });
+ });
+ } finally {
+ await unmount();
+ }
+});
+
test("restrictions-lift-timeout-confirm: confirming clear-timeout calls admin_lift_timeout with correct args", async () => {
const origin = "https://admin-restrictions-confirm-timeout.example.com";
const pubkey = "3a".repeat(32);
@@ -1330,7 +1377,7 @@ test("restrictions-lift-timeout-confirm: confirming clear-timeout calls admin_li
const liftCalls = [];
let remainingItems = [makeTimeoutRecord(timedOutPubkey)];
- setIpcHandler("admin_lift_timeout", (args) => {
+ setIpcHandler("admin_lift_restriction", ({ intent: args }) => {
liftCalls.push(args);
remainingItems = [];
return Promise.resolve();
@@ -1339,10 +1386,10 @@ test("restrictions-lift-timeout-confirm: confirming clear-timeout calls admin_li
Promise.resolve({ items: [...remainingItems], nextCursor: null }),
);
- const { container, doRender, unmount } = mountStaffingPanel(
+ const { container, doRender, unmount } = mountCommunityPanel(
origin,
pubkey,
- [],
+ "restrictions",
);
await doRender();
await settle(50);
@@ -1380,7 +1427,7 @@ test("restrictions-lift-timeout-confirm: confirming clear-timeout calls admin_li
assert.equal(
"communityId" in (liftCalls[0] ?? {}),
false,
- "admin_lift_timeout must not send a client community id; the native command derives the relay host",
+ "a lift sends no client community id",
);
// Row must be gone after list refresh.
@@ -1417,7 +1464,7 @@ test("restrictions-lift-409-treated-as-success: a 409 (already gone) refreshes t
// Using a flag instead of a counter avoids races from multiple initial loads
// (AdminConsolePanel\'s generation-bump useEffect causes 2 loads on mount).
let liftAttempted = false;
- setIpcHandler("admin_lift_ban", () => {
+ setIpcHandler("admin_lift_restriction", () => {
liftAttempted = true;
return mutationReject(
'admin API error: {"error":{"code":"conflict","message":"no active ban for this member"}}',
@@ -1432,10 +1479,10 @@ test("restrictions-lift-409-treated-as-success: a 409 (already gone) refreshes t
}),
);
- const { container, doRender, unmount } = mountStaffingPanel(
+ const { container, doRender, unmount } = mountCommunityPanel(
origin,
pubkey,
- [],
+ "restrictions",
);
await doRender();
await settle(50);
@@ -1501,10 +1548,10 @@ test("restrictions-load-more: second page is fetched with the cursor and appende
);
});
- const { container, doRender, unmount } = mountStaffingPanel(
+ const { container, doRender, unmount } = mountCommunityPanel(
origin,
pubkey,
- [],
+ "restrictions",
);
await doRender();
await settle(50);
@@ -1561,11 +1608,11 @@ test("restrictions-load-more-stale-error: a failed old page does not survive a s
nextCursor: lifted ? null : "next",
}),
);
- setIpcHandler("admin_lift_ban", () => {
+ setIpcHandler("admin_lift_restriction", () => {
lifted = true;
return Promise.resolve();
});
- const m = mountStaffingPanel(origin, pubkey, [], {});
+ const m = mountCommunityPanel(origin, pubkey, "restrictions");
try {
await m.doRender();
await settle(30);
@@ -1616,12 +1663,14 @@ test("restrictions-relay-changed: a removal rejected for a changed relay shows t
setIpcHandler("admin_list_restrictions", () =>
Promise.resolve({ items: [makeBanRecord(bannedPubkey)], nextCursor: null }),
);
- setIpcHandler("admin_lift_ban", () => mutationReject(scopeError, null));
+ setIpcHandler("admin_lift_restriction", () =>
+ mutationReject(scopeError, null),
+ );
- const { container, doRender, unmount } = mountStaffingPanel(
+ const { container, doRender, unmount } = mountCommunityPanel(
origin,
pubkey,
- [],
+ "restrictions",
);
await doRender();
await settle(50);
diff --git a/desktop/src/features/admin-console/adminConsolePanelTestHelpers.jsdom.mjs b/desktop/src/features/admin-console/adminConsolePanelTestHelpers.jsdom.mjs
index 61b761798e..b9fa375e85 100644
--- a/desktop/src/features/admin-console/adminConsolePanelTestHelpers.jsdom.mjs
+++ b/desktop/src/features/admin-console/adminConsolePanelTestHelpers.jsdom.mjs
@@ -47,6 +47,7 @@ if (globalThis.window && globalThis.window !== globalThis) {
// ── Production imports ───────────────────────────────────────────────────────
+import assert from "node:assert/strict";
import React from "react";
import { createRoot } from "react-dom/client";
import { act } from "react";
@@ -105,8 +106,9 @@ export function mutationReject(
message,
relayStatus,
bodyComplete = relayStatus !== null,
+ bodyEmpty = false,
) {
- return Promise.reject({ message, relayStatus, bodyComplete });
+ return Promise.reject({ message, relayStatus, bodyComplete, bodyEmpty });
}
// ── Deferred promise helper ──────────────────────────────────────────────────
@@ -179,6 +181,7 @@ export function mountPanel({
canMutate = true,
role = undefined,
initialTab = undefined,
+ initialCommunity = undefined,
onSelfMutation = undefined,
}) {
const qc = makeQueryClient(pubkey);
@@ -212,6 +215,7 @@ export function mountPanel({
pubkey: p,
...(role !== undefined ? { role } : {}),
...(initialTab !== undefined ? { initialTab } : {}),
+ ...(initialCommunity !== undefined ? { initialCommunity } : {}),
...(onSelfMutation !== undefined ? { onSelfMutation } : {}),
}),
),
@@ -280,11 +284,61 @@ export function mountStaffingPanel(
pubkey,
canMutate: true,
role: "operator",
- initialTab: "staffing",
+ initialTab: "operators",
...(onSelfMutation !== undefined ? { onSelfMutation } : {}),
});
}
+export const TEST_COMMUNITY = {
+ id: "11111111-1111-4111-8111-111111111111",
+ host: "alpha.example.com",
+ icon: null,
+};
+
+/**
+ * Mount the panel on a community page and open `section` there. `doRender`
+ * re-renders and re-opens the section, so callers use it like `mountPanel`.
+ */
+export function mountCommunityPanel(
+ origin,
+ pubkey,
+ section,
+ { community = TEST_COMMUNITY, canMutate = true } = {},
+) {
+ if (!ipcHandlers.get("admin_list_reports")) {
+ setIpcHandler("admin_list_reports", () => Promise.resolve([]));
+ }
+ if (!ipcHandlers.get("admin_connected_community_host")) {
+ setIpcHandler("admin_connected_community_host", () =>
+ Promise.resolve(community.host),
+ );
+ }
+ const m = mountPanel({
+ origin,
+ pubkey,
+ canMutate,
+ role: "operator",
+ initialTab: "communities",
+ initialCommunity: community,
+ });
+ const doRender = async (opts) => {
+ await m.doRender(opts);
+ // The page's section nav renders only after its async reads settle, so
+ // wait for it rather than racing a fixed delay on slow runners.
+ const selector = `[data-testid='community-section-${section}']`;
+ let btn = m.container.querySelector(selector);
+ for (let i = 0; !btn && i < 100; i++) {
+ await settle(10);
+ btn = m.container.querySelector(selector);
+ }
+ assert.ok(btn, `community section ${section} never rendered`);
+ await act(async () => {
+ fireEvent.click(btn);
+ });
+ };
+ return { ...m, doRender };
+}
+
export async function settle(ms = 20) {
await act(async () => {
await new Promise((r) => setTimeout(r, ms));
diff --git a/desktop/src/features/admin-console/adminConsoleReportCommunity.jsdom-test.mjs b/desktop/src/features/admin-console/adminConsoleReportCommunity.jsdom-test.mjs
new file mode 100644
index 0000000000..2e83c576e3
--- /dev/null
+++ b/desktop/src/features/admin-console/adminConsoleReportCommunity.jsdom-test.mjs
@@ -0,0 +1,77 @@
+/**
+ * Community identity on report lists and at report resolution: each group of
+ * rows is headed by its community, and confirming a resolution names the
+ * report's community and warns when it is not the one the app is connected to.
+ */
+import assert from "node:assert/strict";
+import { afterEach, test } from "node:test";
+import {
+ act,
+ fireEvent,
+ makeOpenReportFixtures,
+ mountPanel,
+ resetTestState,
+ setIpcHandler,
+ settle,
+} from "./adminConsolePanelTestHelpers.jsdom.mjs";
+
+afterEach(resetTestState);
+
+const origin = "https://admin.example.com";
+const pubkey = "e4".repeat(32);
+const ALPHA = "[data-testid='community-badge-alpha.example.com']";
+
+test("report-group-badge: a community group names its community once, in its heading", async () => {
+ const { openItem } = makeOpenReportFixtures(
+ "00000000-0000-0000-0000-0000000000e1",
+ );
+ const second = { ...openItem, id: "00000000-0000-0000-0000-0000000000e2" };
+ setIpcHandler("admin_list_reports", () =>
+ Promise.resolve([openItem, second]),
+ );
+ const { container, doRender, unmount } = mountPanel({ origin, pubkey });
+ try {
+ await doRender();
+ await settle(30);
+ const group = container.querySelector("[data-testid='community-group']");
+ assert.equal(group.querySelectorAll("ul > li").length, 2);
+ assert.equal(group.querySelectorAll(ALPHA).length, 1, "one badge");
+ assert.ok(
+ group.querySelector(`[data-testid='community-group-host'] ${ALPHA}`),
+ "the badge heads the group",
+ );
+ } finally {
+ await unmount();
+ }
+});
+
+test("report-resolve-not-connected: confirming a report from another community shows its badge and the warning", async () => {
+ setIpcHandler("admin_connected_community_host", () =>
+ Promise.resolve("beta.example.com"),
+ );
+ makeOpenReportFixtures("00000000-0000-0000-0000-0000000000e3");
+ const { container, doRender, unmount } = mountPanel({ origin, pubkey });
+ try {
+ await doRender();
+ await settle(30);
+ const row = container.querySelector(
+ "[data-testid='community-group'] ul > li button",
+ );
+ await act(async () => fireEvent.click(row));
+ await settle(30);
+ await act(async () =>
+ fireEvent.click(
+ container.querySelector("[data-testid='action-btn-dismiss']"),
+ ),
+ );
+ await settle();
+ const confirm = container.querySelector(
+ "[data-testid='resolve-community']",
+ );
+ assert.ok(confirm, "confirm names the report's community");
+ assert.ok(confirm.querySelector(ALPHA));
+ assert.ok(confirm.querySelector("[data-testid='community-not-connected']"));
+ } finally {
+ await unmount();
+ }
+});
diff --git a/desktop/src/features/admin-console/api.ts b/desktop/src/features/admin-console/api.ts
index c07e31569f..6da6369a5a 100644
--- a/desktop/src/features/admin-console/api.ts
+++ b/desktop/src/features/admin-console/api.ts
@@ -9,7 +9,7 @@
* Callers must cancel in-flight queries on pubkey or origin change.
*/
-import { getRelayWsUrl, invokeTauri } from "@/shared/api/tauri";
+import { invokeTauri } from "@/shared/api/tauri";
import { beginChannelMembershipWrite } from "@/shared/api/channelMembershipWrites";
import { invoke as invokeTauriRaw } from "@tauri-apps/api/core";
@@ -372,13 +372,15 @@ export async function resolveAdminReport(
);
// A kick removes the target from the report's channel. Reports are
// deployment-wide, so only a channel in the active community is recorded.
+ // The relay has already resolved the report, so a failed host lookup counts
+ // as another community rather than throwing.
const action = resolution.activeAction;
if (
action?.action === "kick" &&
action.status === "succeeded" &&
report.channelId &&
- communityHostFromRelayUrl(await getRelayWsUrl()) ===
- normalizeCommunityHost(report.communityHost)
+ (await getConnectedCommunityHost().catch(() => null)) ===
+ report.communityHost
) {
record(report.channelId);
}
@@ -570,64 +572,169 @@ export type AdminRestrictionsPage = {
};
/**
- * List one page of active bans and timeouts for the active relay's community.
- * `expectedRelay` is the relay the list loaded from; the native command
- * rejects the call if the active relay has since changed. The same applies
- * to `liftAdminBan` and `liftAdminTimeout`.
- * The native command names the community by the active relay's host, which
- * the relay resolves to its tenant. Pass the prior page's `nextCursor` to
- * continue.
+ * List one page of active bans and timeouts in `communityHost` (validated
+ * natively). `expectedRelay` is the relay the page was opened on; the native
+ * command refuses if the active relay has since changed.
*
* GET /api/admin/v1/members/restrictions?communityHost={host}[&cursor={token}]
*/
export async function listAdminRestrictions(
origin: string,
+ communityHost: string,
expectedRelay: string,
cursor: string | null = null,
): Promise {
return invokeTauri("admin_list_restrictions", {
origin,
+ communityHost,
cursor,
expectedRelay,
});
}
/**
- * Lift an active ban for a member of the active relay's community.
- *
- * DELETE /api/admin/v1/members/{pubkey}/ban?communityHost={host}
- *
- * Returns normally on 204. Throws an `AdminMutationError`-shaped rejection
- * on 409 ("no active ban") or other errors.
+ * A lift as its restriction row froze it. The native command refuses before
+ * sending (`notSent`) when the active relay or signer no longer matches.
*/
-export async function liftAdminBan(
- origin: string,
- pubkey: string,
- expectedRelay: string,
-): Promise {
- return invokeTauri("admin_lift_ban", { origin, pubkey, expectedRelay });
-}
+export type AdminLiftIntent = {
+ origin: string;
+ communityHost: string;
+ expectedRelay: string;
+ expectedSigner: string;
+ kind: "ban" | "timeout";
+ pubkey: string;
+};
/**
- * Clear an active timeout for a member of the active relay's community.
+ * DELETE /api/admin/v1/members/{pubkey}/{ban|timeout}?communityHost={host}
*
- * DELETE /api/admin/v1/members/{pubkey}/timeout?communityHost={host}
- *
- * Returns normally on 204. Throws an `AdminMutationError`-shaped rejection
- * on 409 ("no active timeout") or other errors.
+ * Returns normally on 204; rejects with an `AdminMutationError` otherwise
+ * (409 when nothing is active).
*/
-export async function liftAdminTimeout(
- origin: string,
- pubkey: string,
- expectedRelay: string,
+export async function liftAdminRestriction(
+ intent: AdminLiftIntent,
): Promise {
- return invokeTauri("admin_lift_timeout", {
+ return invokeTauri("admin_lift_restriction", { intent });
+}
+
+// ── Community reads ──────────────────────────────────────────────────────
+
+/**
+ * Rejection payload of the read commands below: the relay's status, whether
+ * its whole body was read, whether that body was empty, and its error code.
+ */
+export type AdminReadError = {
+ message: string;
+ relayStatus: number | null;
+ bodyComplete: boolean;
+ bodyEmpty: boolean;
+ code: string | null;
+};
+
+export type AdminCommunityDto = {
+ /** Community UUID. */
+ id: string;
+ host: string;
+ icon: string | null;
+};
+
+export type AdminCommunitiesPage = {
+ items: AdminCommunityDto[];
+ nextCursor: string | null;
+};
+
+/** GET /communities — host-prefix directory, keyset-paged. */
+export async function listAdminCommunities(
+ origin: string,
+ q: string,
+ cursor: string | null = null,
+): Promise {
+ return invokeTauri("admin_list_communities", {
origin,
- pubkey,
- expectedRelay,
+ q: q || null,
+ cursor,
});
}
+export type AdminMemberSearchResult = {
+ pubkey: string;
+ displayName: string | null;
+ nip05: string | null;
+ avatarUrl: string | null;
+};
+
+/** GET /members/search — community profiles, including former members. */
+export async function searchAdminMembers(
+ origin: string,
+ communityHost: string,
+ q: string,
+): Promise<{ items: AdminMemberSearchResult[] }> {
+ return invokeTauri<{ items: AdminMemberSearchResult[] }>(
+ "admin_search_members",
+ { origin, communityHost, q },
+ );
+}
+
+export type AdminMemberDetailDto = {
+ pubkey: string;
+ profile: {
+ displayName: string | null;
+ nip05: string | null;
+ avatarUrl: string | null;
+ about: string | null;
+ } | null;
+ /** `null`: not on the community roster. */
+ role: "owner" | "admin" | "member" | null;
+ banned: boolean;
+ mutedUntil: string | null;
+ /**
+ * Deployment-level relay staff (operator or moderator). `null` when admin
+ * auth is disabled, because the relay doesn't read the staff roster then.
+ */
+ isStaff: boolean | null;
+};
+
+/** GET /members/{pubkey} — one member's state in one community. */
+export async function getAdminMember(
+ origin: string,
+ communityHost: string,
+ pubkey: string,
+): Promise {
+ return invokeTauri("admin_get_member", {
+ origin,
+ communityHost,
+ pubkey,
+ });
+}
+
+export type AdminEventPreviewDto = {
+ id: string;
+ authorPubkey: string;
+ kind: number;
+ content: string;
+ createdAt: string;
+ deletedAt: string | null;
+ channelId: string | null;
+};
+
+/** GET /events/{id} — the message preview before a delete. */
+export async function getAdminEvent(
+ origin: string,
+ communityHost: string,
+ id: string,
+): Promise {
+ return invokeTauri("admin_get_event", {
+ origin,
+ communityHost,
+ id,
+ });
+}
+
+/** The community host the active relay serves, resolved natively. */
+export async function getConnectedCommunityHost(): Promise {
+ return invokeTauri("admin_connected_community_host");
+}
+
// ── Attachment ────────────────────────────────────────────────────────────
/**
@@ -746,27 +853,3 @@ export async function directAdminAction(
intent,
});
}
-
-/**
- * Normalize a community host the way the relay's `normalize_host` does:
- * lowercase, no default port, no trailing root dot.
- */
-export function normalizeCommunityHost(raw: string): string {
- return raw
- .trim()
- .toLowerCase()
- .replace(/:(443|80)$/, "")
- .replace(/\.$/, "");
-}
-
-/**
- * The community a relay URL serves is its `Host`: the URL's authority, with
- * the default port already dropped for `ws`/`wss`. Null when unparseable.
- */
-export function communityHostFromRelayUrl(relayUrl: string): string | null {
- try {
- return normalizeCommunityHost(new URL(relayUrl).host) || null;
- } catch {
- return null;
- }
-}
diff --git a/desktop/src/features/channels/rosterAdminKick.test.mjs b/desktop/src/features/channels/rosterAdminKick.test.mjs
index 919a835668..3b85a7d1f0 100644
--- a/desktop/src/features/channels/rosterAdminKick.test.mjs
+++ b/desktop/src/features/channels/rosterAdminKick.test.mjs
@@ -30,10 +30,14 @@ let replica;
let routes;
// Commands whose reply is held until the test releases it.
const held = new Map();
+let hostLookupFails = false;
window.__TAURI_INTERNALS__ = {
invoke: async (command, args) => {
await held.get(command)?.promise;
- if (command === "get_relay_ws_url") return "wss://alpha.example.com";
+ if (command === "admin_connected_community_host") {
+ if (hostLookupFails) throw new Error("no active relay");
+ return "alpha.example.com";
+ }
if (command === "get_channel_window") return [];
if (command === "get_channel_members") {
const strong = args.readYourWrites === true;
@@ -149,7 +153,7 @@ test("a live admin_kick system message refreshes the roster from the writer", as
});
for (const { host, recorded } of [
- { host: "Alpha.example.com:443", recorded: true },
+ { host: "alpha.example.com", recorded: true },
{ host: "beta.example.com", recorded: false },
]) {
test(`a confirmed report kick in ${host} ${recorded ? "refreshes" : "leaves"} the active community's roster`, async () => {
@@ -171,6 +175,21 @@ const kick = () =>
{ id: "report", channelId: id, communityHost: "alpha.example.com" },
{ action: "kick", requestId: "request" },
);
+test("a confirmed report kick whose host lookup fails records nothing and still returns the resolution", async () => {
+ hostLookupFails = true;
+ try {
+ let resolution;
+ const shown = await rosterAfter(async () => {
+ resolution = await kick();
+ });
+ assert.equal(resolution.status, "resolved");
+ assert.deepEqual(routes, ["replica"]);
+ assert.deepEqual(shown, [OWNER, BOT]);
+ } finally {
+ hostLookupFails = false;
+ }
+});
+
const addMember = () =>
addChannelMembers({ channelId: id, pubkeys: [BOT], role: "member" });
@@ -178,7 +197,11 @@ const addMember = () =>
// new community, whichever reply is still in flight when the switch happens.
for (const { name, write, pending } of [
{ name: "a report kick", write: kick, pending: "admin_resolve_report" },
- { name: "a report kick", write: kick, pending: "get_relay_ws_url" },
+ {
+ name: "a report kick",
+ write: kick,
+ pending: "admin_connected_community_host",
+ },
{ name: "an added member", write: addMember, pending: "add_channel_members" },
]) {
test(`${name} whose ${pending} reply lands after a community reset records nothing`, async () => {
diff --git a/desktop/src/features/community-members/ui/AddMemberDialog.tsx b/desktop/src/features/community-members/ui/AddMemberDialog.tsx
index c2a26b5df3..bc97a68b18 100644
--- a/desktop/src/features/community-members/ui/AddMemberDialog.tsx
+++ b/desktop/src/features/community-members/ui/AddMemberDialog.tsx
@@ -9,11 +9,13 @@ import {
} from "@/features/community-members/hooks";
import { useIsArchivedPredicate } from "@/features/identity-archive/hooks";
import { useUserSearchQuery } from "@/features/profile/hooks";
-import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
+import {
+ MemberSearchResult,
+ memberSearchLabel,
+} from "@/features/profile/ui/MemberSearchResult";
import { SelectedRecipientChip } from "@/features/profile/ui/SelectedRecipientChip";
import type { RelayMemberRole, UserSearchResult } from "@/shared/api/types";
import { parsePubkeyInput } from "@/shared/lib/nostrUtils";
-import { truncateNpub } from "@/shared/lib/pubkey";
import { Button } from "@/shared/ui/button";
import {
Dialog,
@@ -46,14 +48,6 @@ const ROLE_OPTIONS: Array<{
},
];
-function formatSearchUserName(user: UserSearchResult) {
- return (
- user.displayName?.trim() ||
- user.nip05Handle?.trim() ||
- truncateNpub(user.pubkey)
- );
-}
-
export function DirectAddMemberForm({
isOwner,
onAdded,
@@ -234,7 +228,7 @@ export function DirectAddMemberForm({
removeUser(user.pubkey)}
poofOnRemove={false}
testIds={{
@@ -353,15 +347,17 @@ export function DirectAddMemberForm({
) : searchResults.length > 0 || directResult ? (
<>
{directResult ? (
- selectUser(directResult)}
+ testId={`member-search-result-${directResult.pubkey}`}
user={directResult}
/>
) : null}
{searchResults.map((user) => (
- selectUser(user)}
+ testId={`member-search-result-${user.pubkey}`}
user={user}
/>
))}
@@ -418,43 +414,6 @@ export function DirectAddMemberForm({
);
}
-function SearchResult({
- onSelect,
- user,
-}: {
- onSelect: () => void;
- user: UserSearchResult;
-}) {
- const name = formatSearchUserName(user);
- const isDirectPubkey = user.displayName === null && user.nip05Handle === null;
-
- return (
-
-
-
- {name}
-
- {isDirectPubkey ? (
-
- public key
-
- ) : null}
-
- );
-}
-
export function AddMemberDialog({
isOwner,
open,
diff --git a/desktop/src/features/onboarding/lib/keyImportInput.ts b/desktop/src/features/onboarding/lib/keyImportInput.ts
index 0f6fc609ed..8ad7d83557 100644
--- a/desktop/src/features/onboarding/lib/keyImportInput.ts
+++ b/desktop/src/features/onboarding/lib/keyImportInput.ts
@@ -75,6 +75,15 @@ export function classifyKeyImportInput(input: string): KeyImportKind {
return "unknown";
}
+/**
+ * Whether `input` carries an `nsec` or NIP-49 backup anywhere, in either
+ * case. Search boxes use this to keep pasted key material on the device.
+ */
+export function containsSecretKey(input: string): boolean {
+ const lower = input.toLowerCase();
+ return lower.includes("nsec1") || lower.includes("ncryptsec1");
+}
+
/**
* Password-independent NIP-49 validation used for the automatic UI transition.
* A candidate must have canonical casing and length, a valid Bech32 checksum,
diff --git a/desktop/src/features/profile/ui/MemberSearchResult.tsx b/desktop/src/features/profile/ui/MemberSearchResult.tsx
new file mode 100644
index 0000000000..a5399addff
--- /dev/null
+++ b/desktop/src/features/profile/ui/MemberSearchResult.tsx
@@ -0,0 +1,81 @@
+/**
+ * The member picker's shared parts: one result row, its label, and the
+ * candidate list built from a pluggable search source plus a pasted
+ * npub/hex key. Add Member feeds it `useUserSearchQuery`; the Admin Console
+ * feeds it the relay's admin member search.
+ */
+
+import type { UserSearchResult } from "@/shared/api/types";
+import { parsePubkeyInput } from "@/shared/lib/nostrUtils";
+import { truncateNpub } from "@/shared/lib/pubkey";
+import { ProfileAvatar } from "./ProfileAvatar";
+
+export function memberSearchLabel(user: UserSearchResult): string {
+ return (
+ user.displayName?.trim() ||
+ user.nip05Handle?.trim() ||
+ truncateNpub(user.pubkey)
+ );
+}
+
+/** A pasted key first (unless search already found it), then search hits. */
+export function useMemberCandidates(
+ query: string,
+ source: { data?: UserSearchResult[] },
+): UserSearchResult[] {
+ const parsed = parsePubkeyInput(query);
+ const found = source.data ?? [];
+ if (parsed === null || found.some((u) => u.pubkey === parsed)) return found;
+ return [
+ {
+ pubkey: parsed,
+ displayName: null,
+ avatarUrl: null,
+ nip05Handle: null,
+ ownerPubkey: null,
+ isAgent: false,
+ },
+ ...found,
+ ];
+}
+
+export function MemberSearchResult({
+ onSelect,
+ showKey = false,
+ testId,
+ user,
+}: {
+ onSelect: () => void;
+ /** Show each named result's short npub, so same-name results differ. */
+ showKey?: boolean;
+ testId: string;
+ user: UserSearchResult;
+}) {
+ const name = memberSearchLabel(user);
+ const isDirectPubkey = user.displayName === null && user.nip05Handle === null;
+ return (
+
+
+
+ {name}
+
+ {isDirectPubkey || showKey ? (
+
+ {isDirectPubkey ? "public key" : truncateNpub(user.pubkey)}
+
+ ) : null}
+
+ );
+}
diff --git a/desktop/src/shared/lib/nostrUtils.ts b/desktop/src/shared/lib/nostrUtils.ts
index 9b6fe0fb23..b6ea0325f9 100644
--- a/desktop/src/shared/lib/nostrUtils.ts
+++ b/desktop/src/shared/lib/nostrUtils.ts
@@ -57,6 +57,28 @@ export function parsePubkeyInput(input: string): string | null {
return null;
}
+/**
+ * Parse a user-entered event reference — 64-char hex, `note1…` or
+ * `nevent1…` — into a lowercase hex event id. Relay hints in an `nevent`
+ * are ignored. Returns null for anything else (does NOT throw).
+ */
+export function parseEventIdInput(input: string): string | null {
+ const trimmed = input.trim().toLowerCase();
+ if (HEX_PUBKEY_REGEX.test(trimmed)) return trimmed;
+ try {
+ const decoded = decode(trimmed);
+ const id =
+ decoded.type === "note"
+ ? decoded.data
+ : decoded.type === "nevent"
+ ? decoded.data.id
+ : null;
+ return id && HEX_PUBKEY_REGEX.test(id) ? id : null;
+ } catch {
+ return null;
+ }
+}
+
/**
* Decode a bech32 nsec string and derive the matching npub. Returns null if
* the input is not a syntactically valid `nsec1…` (does NOT throw — this is
diff --git a/docs/admin/README.md b/docs/admin/README.md
index c15ba13b7c..12fb236e5a 100644
--- a/docs/admin/README.md
+++ b/docs/admin/README.md
@@ -319,6 +319,24 @@ server-backed: in `nip98` mode it `PATCH`es the relay and adopts the returned
status, so every operator sees the same state; in `disabled` mode it renders as
a read-only badge because the server rejects mutations.
+`just admin-seed` also seeds `beta.localhost:3000`, `gamma.localhost:3000` and
+50 `filler-NN.localhost:3000` communities, named members, one ban and one
+timeout per named community, and one pre-signed message per named community for
+the delete preview. It prints the member npubs and message `note`/`nevent` ids
+to paste. Rerunning it restores the seeded state, including deleted messages,
+restrictions, kicked message authors and feedback status. It refuses, writing
+nothing, while a seeded report or admin action is still in progress; finish or
+cancel it first. To use it from the desktop admin console with `just dev`, add
+these to `.env` and restart `just dev`:
+
+```bash
+BUZZ_ADMIN_HOST=admin.localhost:3000
+BUZZ_ADMIN_AUTH=nip98
+RELAY_OPERATOR_PUBKEYS=
+```
+
+Then set the admin origin in Settings → Admin to `http://admin.localhost:3000`.
+
## Routes
### Read routes (Operator and Moderator)
diff --git a/scripts/seed-admin-dashboard.sh b/scripts/seed-admin-dashboard.sh
index 4d42600f72..32320718fa 100755
--- a/scripts/seed-admin-dashboard.sh
+++ b/scripts/seed-admin-dashboard.sh
@@ -102,12 +102,6 @@ if ! docker exec buzz-minio mc alias set local http://localhost:9000 \
exit 1
fi
-upload_fixture "${search_image}" "${search_image_hash}" png image/png 2000x1172
-upload_fixture "${workspace_image}" "${workspace_image_hash}" png image/png 2000x1172
-upload_fixture "${quality_image}" "${quality_image_hash}" png image/png 2000x1172
-upload_fixture "${composer_diagnostics}" "${composer_diagnostics_hash}" txt text/plain ""
-upload_fixture "${workspace_diagnostics}" "${workspace_diagnostics_hash}" txt text/plain ""
-
read -r -d '' sql <<'SQL' || true
DO $$
DECLARE
@@ -190,7 +184,8 @@ BEGIN
body = EXCLUDED.body,
tags = EXCLUDED.tags,
event_created_at = EXCLUDED.event_created_at,
- received_at = EXCLUDED.received_at;
+ received_at = EXCLUDED.received_at,
+ status = 'new';
END $$;
SQL
@@ -205,6 +200,186 @@ sql="${sql//__QUALITY_IMAGE_SIZE__/$(fixture_size "${quality_image}")}"
sql="${sql//__COMPOSER_DIAGNOSTICS_SIZE__/$(fixture_size "${composer_diagnostics}")}"
sql="${sql//__WORKSPACE_DIAGNOSTICS_SIZE__/$(fixture_size "${workspace_diagnostics}")}"
-run_psql -v ON_ERROR_STOP=1 -c "${sql}"
+# Multi-community data: two named communities plus filler so the directory
+# pages past 50, named members, one ban and one timeout per named community,
+# extra reports and feedback, and one pre-signed kind-9 message each. The
+# messages were signed once with fixed test keys (secret = 5eed + the member
+# number, zero-padded to 32 bytes), so their signatures stay valid forever.
+read -r -d '' multi_sql <<'SQL' || true
+CREATE TEMP TABLE seed_community (host TEXT, channel_id UUID, author INT, event_id TEXT, sig TEXT, members INT[]);
+INSERT INTO seed_community VALUES
+ ('localhost:3000', 'c4a11e10-0000-4000-8000-000000000001', 1,
+ '0fe4de81609e02e263b268f7069882f2db68dd74c3f62074be31eaab24b0b5fc',
+ '63d4a64362bf1f91e56ea1f0001a196d4bc9a0e26712d8083516ffe054665a04fc7c3a13a554d327d0a37e0439bc67d9c0bf2252f595d9b8ce5a09587192e06d',
+ '{1,2,5,6}'),
+ ('beta.localhost:3000', 'c4a11e10-0000-4000-8000-000000000002', 3,
+ '50b89df773d8009a88d43ad707ebe0054c7e619a6c13727e0984aca0f930d03c',
+ 'b7d6c122ff4589498c05b3cabd26c7759d0acc497c747f9331130fd5f65e645203313b7c32ddfd97ac2fde0d2341aa4f189135e2b188d8bd7689a54e7c5787b4',
+ '{1,3,5,6}'),
+ ('gamma.localhost:3000', 'c4a11e10-0000-4000-8000-000000000003', 4,
+ '108f54732c522a97e2481b41b8b5a588a99c536ae25d5c0c2e9a1a0c28189f00',
+ '839d536f36f998020596c080e2356f881a43af7d046b19b6e8cd214b8c097720cdeea5cc3173a60da8b671846580c4612c5e5fa1e34002ce6c3754bba683fb71',
+ '{2,4,5,6}');
-echo "Seeded 11 moderation reports, 7 feedback entries, and 5 attachments for the local admin dashboard."
+CREATE TEMP TABLE seed_member (n INT, pubkey BYTEA, display_name TEXT);
+INSERT INTO seed_member VALUES
+ (1, decode('7a863b2e9a491321daf769357e398269d074730493cd1ca14812fbfd07cece70', 'hex'), 'Ada Lovelace'),
+ (2, decode('ef1e7fbda3e8776e2cadd5ee1fc3cd3728014b18814ea21d15748aca6a1c5c27', 'hex'), 'Grace Hopper'),
+ (3, decode('5929d0d248167876c88ded48bfd901389f644f5c47b967cab2c112ef9bf14f1a', 'hex'), 'Linus Pauling'),
+ (4, decode('b75653278afb3d4522fdf8bd8d418c2085c2995c273d0357540c21374be953d5', 'hex'), 'Rosalind Franklin'),
+ (5, decode('2ad6553f16e40b22045a68ffe6ba31ae5f400470d0e6276f377d50fc23f936a3', 'hex'), 'Spam Account'),
+ (6, decode('9eb3e44e539549ff1436b6f98b3b583b8f89326d1cbee78a4edaea042cd34057', 'hex'), 'Heated Debater');
+
+CREATE TEMP VIEW seeded AS
+SELECT s.*, c.id AS community_id
+FROM seed_community s
+JOIN communities c ON lower(c.host) = lower(s.host)
+ OR (s.host = 'localhost:3000' AND c.id = :'local_community_id');
+
+-- Refuse to reset while the admin console has unfinished work on a fixture:
+-- forcing a processing report back to open strands its action, and a pending
+-- action could re-apply after the reset. Locking admin actions blocks new
+-- claims and direct actions until this transaction commits.
+-- A claim or reopen locks its report row before writing an action, so the
+-- fixture rows are taken with NOWAIT: waiting on one would deadlock.
+LOCK TABLE relay_admin_actions IN SHARE ROW EXCLUSIVE MODE;
+CREATE TEMP VIEW seed_report AS
+SELECT r.* FROM moderation_reports r
+JOIN seeded s ON s.community_id = r.community_id
+JOIN (SELECT 'localhost:3000', to_hex(n) FROM generate_series(1, 11) n
+ UNION ALL VALUES ('beta.localhost:3000', '61'), ('beta.localhost:3000', '62'),
+ ('gamma.localhost:3000', '63')) k(host, byte)
+ ON k.host = s.host AND r.report_event_id = decode(repeat(lpad(k.byte, 2, '0'), 32), 'hex');
+DO $$
+BEGIN
+ PERFORM 1 FROM moderation_reports r JOIN seed_report f USING (community_id, id) FOR UPDATE OF r NOWAIT;
+EXCEPTION WHEN lock_not_available THEN
+ RAISE EXCEPTION 'admin-seed refused, nothing was written: moderation is active on a seeded report. Finish it in the admin console, then rerun just admin-seed.';
+END $$;
+CREATE TEMP TABLE seed_busy AS
+SELECT format('report %s in %s is %s', r.id, c.host, r.status) AS what
+FROM seed_report r JOIN communities c ON c.id = r.community_id
+WHERE r.status = 'processing' OR r.active_action_id IS NOT NULL
+UNION ALL
+SELECT format('%s action %s in %s is %s', a.action, a.id, c.host, a.state)
+FROM relay_admin_actions a JOIN communities c ON c.id = a.report_community_id
+WHERE a.state IN ('pending', 'enforcing')
+ AND a.report_community_id IN (SELECT community_id FROM seeded)
+ AND (a.enforcement_target_pubkey IN (SELECT pubkey FROM seed_member)
+ OR a.enforcement_target_event_id IN (SELECT decode(event_id, 'hex') FROM seed_community));
+DO $$
+BEGIN
+ IF EXISTS (SELECT 1 FROM seed_busy) THEN
+ RAISE EXCEPTION E'admin-seed refused, nothing was written: %\nFinish or cancel these in the admin console (Cancel & reopen for a failed report action), wait for pending actions to complete, then rerun just admin-seed.',
+ (SELECT string_agg(what, '; ') FROM seed_busy);
+ END IF;
+END $$;
+
+INSERT INTO communities (host)
+SELECT host FROM seed_community WHERE host <> 'localhost:3000'
+UNION ALL
+SELECT format('filler-%s.localhost:3000', lpad(n::text, 2, '0')) FROM generate_series(1, 50) n
+ON CONFLICT ((lower(host))) DO NOTHING;
+
+INSERT INTO channels (community_id, id, name, created_by)
+SELECT community_id, channel_id, 'seed-enforcement-channel', decode(repeat('3b', 32), 'hex') FROM seeded
+ON CONFLICT (community_id, id) DO UPDATE SET name = EXCLUDED.name;
+
+INSERT INTO users (community_id, pubkey, display_name)
+SELECT s.community_id, m.pubkey, m.display_name
+FROM seeded s JOIN seed_member m ON m.n = ANY (s.members)
+ON CONFLICT (community_id, pubkey) DO UPDATE SET display_name = EXCLUDED.display_name;
+
+INSERT INTO relay_members (community_id, pubkey, role, added_by)
+SELECT s.community_id, encode(m.pubkey, 'hex'), 'member', NULL
+FROM seeded s JOIN seed_member m ON m.n = ANY (s.members)
+ON CONFLICT (community_id, pubkey) DO NOTHING;
+
+INSERT INTO channel_members (community_id, channel_id, pubkey)
+SELECT community_id, channel_id, (SELECT pubkey FROM seed_member WHERE n = author) FROM seeded
+ON CONFLICT (community_id, channel_id, pubkey) DO UPDATE SET removed_at = NULL, removed_by = NULL;
+
+INSERT INTO community_bans (community_id, pubkey, banned, ban_reason, muted_until, mute_reason, actor_pubkey)
+SELECT s.community_id, m.pubkey, m.n = 5,
+ CASE WHEN m.n = 5 THEN 'Seeded ban: repeated spam.' END,
+ CASE WHEN m.n = 6 THEN now() + interval '1 day' END,
+ CASE WHEN m.n = 6 THEN 'Seeded timeout: heated thread.' END,
+ decode(repeat('3b', 32), 'hex')
+FROM seeded s JOIN seed_member m ON m.n = ANY (s.members)
+ON CONFLICT (community_id, pubkey) DO UPDATE SET
+ banned = EXCLUDED.banned, ban_expires_at = NULL, ban_reason = EXCLUDED.ban_reason,
+ muted_until = EXCLUDED.muted_until, mute_reason = EXCLUDED.mute_reason, updated_at = now();
+
+INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, channel_id)
+SELECT s.community_id, decode(s.event_id, 'hex'), m.pubkey, to_timestamp(1790000000), 9,
+ jsonb_build_array(jsonb_build_array('h', s.channel_id::text)),
+ format('Seeded message in %s for the admin delete preview.', s.host),
+ decode(s.sig, 'hex'), s.channel_id
+FROM seeded s JOIN seed_member m ON m.n = s.author
+ON CONFLICT (community_id, created_at, id) DO UPDATE SET deleted_at = NULL;
+
+INSERT INTO moderation_reports (
+ community_id, id, report_event_id, reporter_pubkey, target_kind,
+ target_event_id, target_pubkey, channel_id, report_type, note, status, created_at
+)
+SELECT s.community_id, ('a11d0000-0000-4000-8000-0000000001' || lpad(r.n::text, 2, '0'))::uuid,
+ decode(repeat(to_hex(96 + r.n), 32), 'hex'), decode(repeat('1c', 32), 'hex'),
+ r.kind, CASE WHEN r.kind = 'event' THEN decode(s.event_id, 'hex') END,
+ CASE WHEN r.kind = 'pubkey' THEN (SELECT pubkey FROM seed_member WHERE n = 5) END,
+ CASE WHEN r.kind = 'event' THEN s.channel_id END,
+ r.report_type, format('%s report in %s.', initcap(r.report_type), s.host), 'open',
+ now() - r.n * interval '7 minutes'
+FROM seeded s
+JOIN (VALUES (1, 'beta.localhost:3000', 'event', 'spam'),
+ (2, 'beta.localhost:3000', 'pubkey', 'impersonation'),
+ (3, 'gamma.localhost:3000', 'event', 'profanity')) r(n, host, kind, report_type)
+ ON r.host = s.host
+ON CONFLICT (community_id, report_event_id) DO UPDATE SET
+ channel_id = EXCLUDED.channel_id, status = EXCLUDED.status, resolved_by = NULL,
+ resolved_at = NULL, created_at = EXCLUDED.created_at;
+
+INSERT INTO product_feedback (id, community_id, event_id, submitter_pubkey, category, body, tags, event_created_at, received_at)
+SELECT ('feed0000-0000-4000-8000-0000000001' || lpad(f.n::text, 2, '0'))::uuid, s.community_id,
+ decode(repeat(to_hex(112 + f.n), 32), 'hex'), (SELECT pubkey FROM seed_member WHERE n = f.member),
+ f.category, format('%s (from %s)', f.body, s.host), jsonb_build_array(jsonb_build_array('category', f.category)),
+ now() - f.n * interval '3 hours', now() - f.n * interval '3 hours'
+FROM seeded s
+JOIN (VALUES (1, 'beta.localhost:3000', 3, 'bug', 'Notifications arrive twice on mobile.'),
+ (2, 'gamma.localhost:3000', 4, 'praise', 'Thread replies load instantly now.')) f(n, host, member, category, body)
+ ON f.host = s.host
+ON CONFLICT (event_id) DO UPDATE SET
+ community_id = EXCLUDED.community_id, body = EXCLUDED.body, event_created_at = EXCLUDED.event_created_at,
+ received_at = EXCLUDED.received_at, status = 'new';
+SQL
+
+# One transaction: the guard in multi_sql locks admin actions and refuses before
+# any write, so the reset cannot race a claim. Uploads only follow a commit.
+run_psql -q -1 -v ON_ERROR_STOP=1 -v local_community_id="${community_id}" \
+ <<< "${multi_sql}"$'\n'"${sql}"
+
+upload_fixture "${search_image}" "${search_image_hash}" png image/png 2000x1172
+upload_fixture "${workspace_image}" "${workspace_image_hash}" png image/png 2000x1172
+upload_fixture "${quality_image}" "${quality_image_hash}" png image/png 2000x1172
+upload_fixture "${composer_diagnostics}" "${composer_diagnostics_hash}" txt text/plain ""
+upload_fixture "${workspace_diagnostics}" "${workspace_diagnostics_hash}" txt text/plain ""
+
+cat <<'EOF'
+Seeded 14 moderation reports, 9 feedback entries, and 5 attachments, plus
+beta.localhost:3000, gamma.localhost:3000 and 50 filler-NN.localhost:3000 communities.
+
+Communities (with localhost:3000): beta.localhost:3000, gamma.localhost:3000
+Members (display name, npub, communities):
+ Ada Lovelace npub102rrkt56fyfjrkhhdy6huwvzd8g8gucyj0x3eg2gztal6p7weecqlj45d5 localhost, beta
+ Grace Hopper npub1au08l0drapmkut9d6hhpls7dxu5qzjccs982y8g4wj9v56sutsnshfethq localhost, gamma
+ Linus Pauling npub1ty5ap5jgzeu8djyda4ytlkgp8z0kgn6ug7uk0j4jcyfwlxl3fudq59tr5r beta
+ Rosalind Franklin npub1kat9xfu2lv752ghalz7c6svvyzzu9x2uyu7sx465pssnwjlf202s04ej99 gamma
+ Spam Account npub19tt920ckus9jypz6drl7dw334e05qprs6rnzwmeh04g0cglex63s34trhf all three, banned
+ Heated Debater npub1n6e7gnjnj4yl79pkkmuckw6c8w8cjvndrjlw0zjwmt4qgtxngptse52a73 all three, timed out 1 day
+Messages (rerunning the seed restores deleted ones):
+ localhost:3000 note1pljdaqtqncpwycajdrmsdxyz7tdk3ht5c0mzqa97x842kf9skh7q9whkgc
+ nevent1qqsqlex7s9sfuqhzvwex3acxnzp09kmgm46v8a3qwjlrr64tyjcttlqrmlq9s
+ beta.localhost:3000 note12zufmamnmqqf4zx58tts06lqq4x8ucv6dsfhylsfsjk2p7fs6q7q2svrv4
+ nevent1qqs9pwya7aeasqy63r2r44c8a0sq2nr7vxdxcymj0cycft9qlycdq0q2n8jdv
+ gamma.localhost:3000 note1zz84guev2g4f0cjgrdqm3dd93z5ec5m2ufw4crpwngdqc2qcnuqqd7x0uy
+ nevent1qqsppr65wvk9y25hufypksdckkjc32vu2d4wyh2upshf5xsv9qvf7qqft8acc
+EOF