mirror of
https://github.com/block/buzz.git
synced 2026-11-02 07:22:55 +00:00
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>
288 lines
9.1 KiB
TypeScript
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>
|
|
);
|
|
}
|