Files
buzz/desktop/src/features/admin-console/AdminConsoleCommunities.tsx
Will Pfleger 836e7d0d06 feat(desktop): browse and moderate every community in the Admin Console (#8027)
The Admin Console only ever saw the community the desktop happened to be
connected to. With #8011's community reads, the console can now browse
every community on the relay and, for signed staff, moderate any of them
by name without switching connections. 🤖

## What changes

- **Communities tab.** Lists every live community on the relay,
searchable by host prefix with "Load more" paging. The connected
community is pinned at the top by exact host match. Report rows get a
community badge that opens that community.
- **Community page.** Reports, Restrictions, Members and Actions, each
scoped to the community on the page. The Actions form has no host field:
it always acts on the page's community, and the TypeScript host
normalization is gone from the Actions tab.
- **Native reads** (`admin_list_communities`, `admin_search_members`,
`admin_get_member`, `admin_get_event`). Every request validates the
explicit community host and NIP-98-signs the real method and URL.
Failures come back as a structured error (`status`, `bodyComplete`,
`bodyEmpty`, `code`), so the UI can tell an older relay's empty 404, a
coded `event_not_found` and a transport failure apart, and never says
"not found" unless the relay did.
- **Explicit-host lifts.** `admin_lift_restriction` sends the page's
community host, not the active relay's. It freezes the relay and signer
the intent was reviewed under, and refuses to send if either has
changed.
- **Pending actions survive navigation.** A panel-level
`DirectActionsProvider` holds the frozen intent, so switching
communities or tabs mid-confirm doesn't lose it. Only Discard drops it.
A signer change remounts the controller and drops it.
- **Fenced lookups.** Member and event lookups are keyed by origin,
signer, community, target and a generation counter, so a late answer for
one community can't unlock Review in another.
- **Shared member picker** (`MemberSearchResult`, also used by Add
Member), showing the truncated npub so members with the same name can be
told apart. Delete accepts a note link and previews the message before
Review. Relay staff are blocked before Review.
- **Disabled admin auth is read-only.** With `BUZZ_ADMIN_AUTH=disabled`
the Communities tab and community pages still render, and report badges
still open them, but every ban, timeout, delete, add-member and lift
control is disabled. The settings badge says the console is read-only
and that acting needs `nip98`. Staffing stays hidden.
- #7904's protections in the Actions tab are kept: frozen intent,
`requestId`, Retry, `notSent` and the key-backup guard.
- **Readable load errors.** Admin list and detail loads show the relay's
error message (for example "request is not authorized"), never the raw
JSON envelope.
- **Failed kicks reload the report.** A resolve that fails with
`enforcement_failed` reloads the report, so its failed action and
"Cancel & reopen" appear immediately.
- **Key backups never leave in a search.** Every admin API request URL
passes the NIP-49 key-backup guard before it is signed or sent:
community and legacy reads, mutations, the connection probe (before its
first, unsigned request) and feedback-attachment fetches. The community
and member search boxes, including the directory's Load more, refuse a
pasted `nsec` or `ncryptsec` anywhere in the input without calling the
relay.
- **`just admin-seed` for multi-community testing.** Besides the local
community, it seeds `beta.localhost:3000`, `gamma.localhost:3000` and 50
filler communities, with named members, a ban and a timeout in each
named community, a report with Kick available, and a message for the
delete preview. Rerunning it restores all of that to the starting state.
It refuses, writing nothing, while a moderation action is unfinished on
a seeded report.
- The bodyless `GET /events/{id}` preview is added to the events-URL
egress inventory. It sends no body, so it carries no key material.

## Tests

Native (Rust):
- `empty_404_and_405_read_as_complete_and_empty`,
`coded_404_carries_the_relay_code`,
`truncated_and_over_cap_bodies_are_incomplete`,
`transport_failure_has_no_status`: the structured read error tells an
older relay, a coded refusal, a cut-off body and a transport failure
apart.
- `a_401_retry_is_signed_by_the_same_keys`,
`lift_signs_the_send_and_401_retry_with_the_checked_keys`: the 401 retry
never switches keys.
- `read_urls_validate_the_explicit_host_and_encode_the_query`,
`lift_url_sends_the_page_host_not_the_active_relay`,
`list_url_carries_host_and_cursor_and_refuses_a_changed_relay`,
`lift_refuses_before_sending_on_signer_change_or_bad_host`: requests
target the explicit host and refuse to send after a relay or signer
change.

jsdom:
- `actions-pending-nav`: a pending intent survives a community change,
and only Discard drops it.
- `actions-fenced-lookup`, `fenced-load`: a late lookup for an old key
never shows under, or unlocks, the new one.
- `actions-same-ids`: the same pubkey and event id are read separately
in each community.
- `actions-community-change-drops-member`, `actions-page-host`,
`actions-success`, `actions-identity`: the form acts only in the page's
community, and confirm sends the frozen intent.
- `actions-preview-errors`, `actions-delete-link`,
`actions-staff-target`, `actions-disabled-auth`: preview errors, delete
by note link, the staff block, and community pages rendering read-only
with every action control disabled under disabled admin auth.
- `communities-directory`, `communities-unsupported`, `community-badge`,
`community-badge-disabled-auth`: the directory, older relays, and report
badges, which open community pages under disabled admin auth too.
- `restrictions-lift-signer-change`. The existing restriction tests now
run on the community page and use `admin_lift_restriction`.

Mutation checks: removing the pending-navigation guard turns
`actions-pending-nav` red, and dropping the host from the fence key
turns `actions-fenced-lookup`, `actions-same-ids` and `fenced-load` red.
Ungating restriction lifts on `canMutate` turns `actions-disabled-auth`
red.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-10-08 13:58:07 -04:00

288 lines
9.1 KiB
TypeScript

/**
* 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 (
<p
className="text-sm text-muted-foreground"
data-testid="communities-unsupported"
>
{UNSUPPORTED_BROWSING}
</p>
);
}
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) => (
<li key={c.id}>
<button
className="flex w-full items-center gap-2 rounded-md border border-border/60 px-3 py-2 text-left text-sm hover:bg-muted/40"
data-testid={`community-row-${c.host}`}
onClick={() => open(c)}
type="button"
>
<span className="flex-1 truncate">{c.host}</span>
{connected && (
<span className="text-xs text-muted-foreground">Connected</span>
)}
</button>
</li>
);
return (
<div className="space-y-3" data-testid="communities-tab">
<input
className="w-full rounded-md border border-border/60 bg-background px-2 py-1 text-xs"
data-testid="communities-search-input"
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by host (e.g. team.example.com)"
value={query}
/>
{pinnedRow && (
<ul data-testid="communities-pinned">{row(pinnedRow, true)}</ul>
)}
{secret && (
<p
className="text-xs text-destructive"
data-testid="communities-search-secret"
>
That's a secret key. Never paste it here.
</p>
)}
{first.status === "loading" && <LoadingSpinner />}
{first.status === "error" && <ErrorMessage message={first.message} />}
{first.status === "ok" && items.length === 0 && !pinnedRow && !secret && (
<p className="text-sm text-muted-foreground">No communities found.</p>
)}
<ul className="space-y-1">{items.map((c) => row(c, false))}</ul>
{moreStatus?.error && <ErrorMessage message={moreStatus.error} />}
{nextCursor && !blocked && (
<Button
data-testid="communities-load-more"
disabled={moreStatus?.busy ?? false}
onClick={() => void loadMore()}
size="sm"
type="button"
variant="outline"
>
{moreStatus?.busy ? (
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
) : (
"Load more"
)}
</Button>
)}
</div>
);
}
// ── 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<Section>("reports");
return (
<div className="space-y-3" data-testid="community-page">
<div
className="flex flex-wrap items-center gap-2 rounded-md border border-border/60 px-3 py-2"
data-testid="community-banner"
>
<Button
className="h-7 px-2"
data-testid="community-back"
onClick={onBack}
size="sm"
type="button"
variant="ghost"
>
<ArrowLeft className="h-3.5 w-3.5" />
</Button>
<CommunityBadge
host={community.host}
icon={community.icon}
id={community.id}
/>
<NotConnectedWarning host={community.host} />
</div>
<div className="flex gap-1">
{SECTIONS.map((s) => (
<Button
data-testid={`community-section-${s}`}
key={s}
onClick={() => setSection(s)}
size="sm"
type="button"
variant={s === section ? "default" : "outline"}
>
{s[0].toUpperCase() + s.slice(1)}
</Button>
))}
</div>
{section === "reports" && (
<ReportsTab
canMutate={canMutate}
communityId={community.id}
generation={generation}
origin={origin}
pubkey={pubkey}
/>
)}
{section === "restrictions" && (
<RestrictionsSection
canMutate={canMutate}
communityHost={community.host}
generation={generation}
origin={origin}
pubkey={pubkey}
/>
)}
{section === "members" && (
<MembersSection
communityHost={community.host}
onAct={() => setSection("actions")}
/>
)}
{section === "actions" && <ActionsSection community={community} />}
</div>
);
}