diff --git a/desktop/src/features/admin-console/AdminConsoleFeedbackTab.tsx b/desktop/src/features/admin-console/AdminConsoleFeedbackTab.tsx index dd91c02c6..9e534b479 100644 --- a/desktop/src/features/admin-console/AdminConsoleFeedbackTab.tsx +++ b/desktop/src/features/admin-console/AdminConsoleFeedbackTab.tsx @@ -24,6 +24,7 @@ import { import { type AsyncState, type AttachmentMeta, + CommunityGroupedList, DetailRow, ErrorMessage, LoadingSpinner, @@ -75,8 +76,9 @@ export function FeedbackTab({ } return ( - + }} + /> ); } diff --git a/desktop/src/features/admin-console/AdminConsolePanel.tsx b/desktop/src/features/admin-console/AdminConsolePanel.tsx index 3b2276123..194cd5cb0 100644 --- a/desktop/src/features/admin-console/AdminConsolePanel.tsx +++ b/desktop/src/features/admin-console/AdminConsolePanel.tsx @@ -48,6 +48,7 @@ import { DetailRow, ErrorMessage, LoadingSpinner, + CommunityGroupedList, formatTimestamp, useAsyncLoad, } from "./AdminConsolePanelHelpers"; @@ -468,8 +469,9 @@ function ReportsTab({ } return ( - + }} + /> ); } diff --git a/desktop/src/features/admin-console/AdminConsolePanelHelpers.tsx b/desktop/src/features/admin-console/AdminConsolePanelHelpers.tsx index 74ff8c689..a4d0a6f33 100644 --- a/desktop/src/features/admin-console/AdminConsolePanelHelpers.tsx +++ b/desktop/src/features/admin-console/AdminConsolePanelHelpers.tsx @@ -6,6 +6,7 @@ */ import { useEffect, useRef, useState } from "react"; +import type { ReactNode } from "react"; import { AlertCircle, LoaderCircle } from "lucide-react"; import { formatRelativeTime } from "../forum/lib/time"; @@ -173,3 +174,75 @@ export function parseImetaAttachments(tags: unknown): AttachmentMeta[] { } return result; } + +// ── Community grouping ───────────────────────────────────────────────────── + +/** A run of rows that share one community, tagged with its display host. */ +export type CommunityGroup = { + /** Stable community identifier — used as the React key. */ + communityId: string; + /** Human-facing host label rendered as the group heading. */ + communityHost: string; + items: T[]; +}; + +/** + * Group deployment-wide rows by community, preserving each community's + * first-seen order and the server's row order within it. + * + * The admin API returns reports and feedback across every community on the + * deployment; operators triage per community, so rows are bucketed by + * `communityId` (stable) and labelled by `communityHost` (display). A blank + * host falls back to the id so a group is never headed by an empty string. + */ +export function groupByCommunity< + T extends { communityId: string; communityHost: string }, +>(items: T[]): CommunityGroup[] { + const groups: CommunityGroup[] = []; + const byId = new Map>(); + for (const item of items) { + let group = byId.get(item.communityId); + if (!group) { + group = { + communityId: item.communityId, + communityHost: item.communityHost || item.communityId, + items: [], + }; + byId.set(item.communityId, group); + groups.push(group); + } + group.items.push(item); + } + return groups; +} + +/** + * 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. + */ +export function CommunityGroupedList< + T extends { communityId: string; communityHost: string }, +>({ items, renderItem }: { items: T[]; renderItem: (item: T) => ReactNode }) { + const groups = groupByCommunity(items); + if (groups.length <= 1) { + return
    {items.map(renderItem)}
; + } + return ( +
+ {groups.map((group) => ( +
+

+ {group.communityHost} +

+
    {group.items.map(renderItem)}
+
+ ))} +
+ ); +} diff --git a/desktop/src/features/admin-console/AdminConsoleSettingsCard.tsx b/desktop/src/features/admin-console/AdminConsoleSettingsCard.tsx index 4f7d87117..4c9dce7a8 100644 --- a/desktop/src/features/admin-console/AdminConsoleSettingsCard.tsx +++ b/desktop/src/features/admin-console/AdminConsoleSettingsCard.tsx @@ -23,6 +23,7 @@ import { AlertCircle, Check, CheckCircle2, + ChevronRight, Copy, Info, LoaderCircle, @@ -228,8 +229,8 @@ export function AdminConsoleSettingsCard() { data-testid="settings-admin-console" > {pubkeyHex ? ( @@ -412,58 +413,66 @@ function AdminConsoleSettingsSession({ pubkeyHex }: { pubkeyHex: string }) { return ( <>
-
- { - setOriginInput(e.target.value); - // General reset: abort and clear probe state on every input - // change, not only when state is `probing`. This prevents a - // stale probe result from a previous value being committed. - abortAndResetProbe(); - }} - placeholder="https://admin.yourrelay.example.com" - spellCheck={false} - type="url" - value={originInput} - onKeyDown={(e) => { - if (e.key === "Enter") void handleSave(); - }} - /> - - {savedOrigin && ( - + {savedOrigin && ( + )} - data-testid="admin-probe-refresh" - disabled={probeUiState.kind === "probing"} - onClick={() => runProbe(savedOrigin)} - size="sm" - type="button" - variant="ghost" - > - Re-probe - - )} -
+
+ +
diff --git a/desktop/src/features/admin-console/adminConsolePanelEvents.jsdom-test.mjs b/desktop/src/features/admin-console/adminConsolePanelEvents.jsdom-test.mjs index 95ca6e748..1c2e6d445 100644 --- a/desktop/src/features/admin-console/adminConsolePanelEvents.jsdom-test.mjs +++ b/desktop/src/features/admin-console/adminConsolePanelEvents.jsdom-test.mjs @@ -1696,3 +1696,129 @@ test("discovery-skipped: a saved origin takes precedence and discovery is not at await unmount(); }); + +// ── community grouping ──────────────────────────────────────────────────── + +test("reports-grouped-by-community: multi-community reports render per-community headings", async () => { + // The admin API returns deployment-wide reports; the console buckets them + // by community for triage. Two communities → two group headings; rows stay + // navigable (the first non-tab, non-processing report opens its detail). + // + // Mutation evidence: revert ReportsTab to a flat
    → community-group + // headings vanish and this test goes red. + + const origin = "https://admin.example.com"; + const pubkey = "a7".repeat(32); + + const reports = [ + { + id: "00000000-0000-0000-0000-0000000000a1", + 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", + }, + { + id: "00000000-0000-0000-0000-0000000000a2", + communityId: "comm-2", + communityHost: "beta.example.com", + reportEventId: "dd", + reporterPubkey: "ee", + targetKind: "event", + target: "ff", + reportType: "abuse", + status: "open", + createdAt: "2024-06-02T12:00:00Z", + }, + ]; + + setIpcHandler("admin_list_reports", () => Promise.resolve(reports)); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + + const groups = container.querySelectorAll("[data-testid='community-group']"); + assert.equal( + groups.length, + 2, + `two communities must render two groups; got ${groups.length}`, + ); + + const hosts = Array.from( + container.querySelectorAll("[data-testid='community-group-host']"), + ).map((el) => el.textContent); + assert.deepEqual( + hosts, + ["alpha.example.com", "beta.example.com"], + `group headings must show each community host in first-seen order; got: ${JSON.stringify(hosts)}`, + ); + + await unmount(); +}); + +test("feedback-grouped-by-community: multi-community feedback renders per-community headings", async () => { + // Same grouping contract for the Feedback tab. + // + // Mutation evidence: revert FeedbackTab to a flat
      → group headings + // vanish and this test goes red. + + const origin = "https://admin.example.com"; + const pubkey = "b8".repeat(32); + + const feedback = [ + { + id: "00000000-0000-0000-0000-0000000000b1", + communityId: "comm-1", + communityHost: "alpha.example.com", + submitterPubkey: "sub1", + category: "bug", + bodySummary: "Alpha feedback body", + receivedAt: "2024-06-01T09:00:00Z", + }, + { + id: "00000000-0000-0000-0000-0000000000b2", + communityId: "comm-2", + communityHost: "beta.example.com", + submitterPubkey: "sub2", + category: "idea", + bodySummary: "Beta feedback body", + receivedAt: "2024-06-02T09:00:00Z", + }, + ]; + + setIpcHandler("admin_list_reports", () => Promise.resolve([])); + setIpcHandler("admin_list_feedback", () => Promise.resolve(feedback)); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + + // Switch to the Feedback tab. + const feedbackTab = container.querySelector( + "[data-testid='admin-tab-feedback']", + ); + assert.ok(feedbackTab, "Feedback tab must be present"); + await act(async () => { + fireEvent.click(feedbackTab); + await new Promise((r) => setTimeout(r, 30)); + }); + await settle(30); + + const hosts = Array.from( + container.querySelectorAll("[data-testid='community-group-host']"), + ).map((el) => el.textContent); + assert.deepEqual( + hosts, + ["alpha.example.com", "beta.example.com"], + `feedback group headings must show each community host; got: ${JSON.stringify(hosts)}`, + ); + + await unmount(); +}); diff --git a/desktop/src/features/admin-console/grouping.test.mjs b/desktop/src/features/admin-console/grouping.test.mjs new file mode 100644 index 000000000..b3f3cfc8b --- /dev/null +++ b/desktop/src/features/admin-console/grouping.test.mjs @@ -0,0 +1,56 @@ +/** + * Unit tests for community grouping of deployment-wide admin rows. + * + * The admin API returns reports and feedback across every community on the + * deployment; the console buckets them by community for triage. Grouping must + * preserve first-seen community order and server row order within a community, + * and never head a group with an empty host. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { groupByCommunity } from "./AdminConsolePanelHelpers.tsx"; + +test("group-empty-returns-empty: no rows → no groups", () => { + assert.deepEqual(groupByCommunity([]), []); +}); + +test("group-single-community-one-bucket: rows sharing a community collapse to one group", () => { + const rows = [ + { communityId: "c1", communityHost: "a.example.com", id: "r1" }, + { communityId: "c1", communityHost: "a.example.com", id: "r2" }, + ]; + const groups = groupByCommunity(rows); + assert.equal(groups.length, 1); + assert.equal(groups[0].communityId, "c1"); + assert.equal(groups[0].communityHost, "a.example.com"); + assert.deepEqual( + groups[0].items.map((r) => r.id), + ["r1", "r2"], + ); +}); + +test("group-preserves-first-seen-order: communities keep the order they first appear", () => { + const rows = [ + { communityId: "c2", communityHost: "b.example.com", id: "r1" }, + { communityId: "c1", communityHost: "a.example.com", id: "r2" }, + { communityId: "c2", communityHost: "b.example.com", id: "r3" }, + ]; + const groups = groupByCommunity(rows); + assert.deepEqual( + groups.map((g) => g.communityId), + ["c2", "c1"], + ); + // Interleaved rows for c2 stay together in server order. + assert.deepEqual( + groups[0].items.map((r) => r.id), + ["r1", "r3"], + ); +}); + +test("group-blank-host-falls-back-to-id: an empty host never heads a group", () => { + const rows = [{ communityId: "c1", communityHost: "", id: "r1" }]; + const groups = groupByCommunity(rows); + assert.equal(groups[0].communityHost, "c1"); +}); diff --git a/desktop/src/features/admin-console/hooks.ts b/desktop/src/features/admin-console/hooks.ts new file mode 100644 index 000000000..3ecdfd11a --- /dev/null +++ b/desktop/src/features/admin-console/hooks.ts @@ -0,0 +1,56 @@ +import { useQuery } from "@tanstack/react-query"; + +import { useIdentityQuery } from "@/shared/api/hooks"; +import { discoverAdminOrigin, getAdminOrigin, probeAdminOrigin } from "./api"; +import type { ModerationNavResolution } from "./nav"; + +export const moderationNavResolutionQueryKey = (pubkeyHex: string) => + ["moderationNavResolution", pubkeyHex] as const; + +/** + * Resolve the origin + probe state that decide whether the Moderation nav + * entry is visible. Mirrors the settings card's mount resolution: a saved + * manual origin wins outright (and short-circuits the probe, since the gate + * shows the entry regardless); otherwise NIP-11 discovery is attempted and, + * when it advertises an origin, probed so the gate can distinguish an + * authorized relay from a definitive non-admin verdict. + * + * Keyed by pubkey so an identity switch re-resolves. Errors from the probe + * resolve to a `"error"` outcome rather than rejecting — a transport flake + * must keep the entry visible, not make the whole query fail. + */ +export function useModerationNavResolution(): + | ModerationNavResolution + | undefined { + const { data: identity } = useIdentityQuery(); + const pubkeyHex = identity?.pubkey ?? ""; + + const query = useQuery({ + enabled: pubkeyHex.length > 0, + queryKey: moderationNavResolutionQueryKey(pubkeyHex), + staleTime: 60_000, + queryFn: async (): Promise => { + const saved = await getAdminOrigin(pubkeyHex); + if (saved) { + return { originSource: "saved", probe: null }; + } + let discovered: string | null = null; + try { + discovered = await discoverAdminOrigin(); + } catch { + discovered = null; + } + if (!discovered) { + return { originSource: "none", probe: null }; + } + try { + const result = await probeAdminOrigin(discovered); + return { originSource: "advertised", probe: result.state }; + } catch { + return { originSource: "advertised", probe: "error" }; + } + }, + }); + + return query.data; +} diff --git a/desktop/src/features/admin-console/nav.test.mjs b/desktop/src/features/admin-console/nav.test.mjs new file mode 100644 index 000000000..4e79e5793 --- /dev/null +++ b/desktop/src/features/admin-console/nav.test.mjs @@ -0,0 +1,88 @@ +/** + * Unit tests for the Settings → Moderation nav visibility gate. + * + * The gate decides whether ordinary members ever see the Moderation entry. + * Its two load-bearing rules are: + * 1. A saved manual origin always shows the entry — the Advanced control that + * edits/clears a bad saved URL lives inside the surface, so hiding it would + * permanently lock a user out of fixing their own state. + * 2. An advertised-only origin follows the probe: visible on a plausible + * authorization or a transport flake, hidden on a definitive non-admin + * verdict. Flake must never silently hide the entry. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { shouldShowModerationNav } from "./nav.ts"; + +test("no-origin-hides-entry: neither advertised nor saved → hidden", () => { + assert.equal( + shouldShowModerationNav({ originSource: "none", probe: null }), + false, + ); +}); + +test("saved-origin-authorized-shows-entry: saved manual origin visible", () => { + assert.equal( + shouldShowModerationNav({ + originSource: "saved", + probe: "nip98Authorized", + }), + true, + ); +}); + +test("saved-origin-bad-probe-still-shows-entry: notAdminApi under a saved origin stays visible so the user can fix it", () => { + for (const probe of ["notAdminApi", "nip98Denied", "tokenMode", "error"]) { + assert.equal( + shouldShowModerationNav({ originSource: "saved", probe }), + true, + `saved origin must stay visible for probe=${probe}`, + ); + } +}); + +test("advertised-authorized-shows-entry: advertised origin that authorizes is visible", () => { + assert.equal( + shouldShowModerationNav({ + originSource: "advertised", + probe: "nip98Authorized", + }), + true, + ); +}); + +test("advertised-disabled-shows-entry: advertised origin in auth-disabled mode is visible", () => { + assert.equal( + shouldShowModerationNav({ originSource: "advertised", probe: "disabled" }), + true, + ); +}); + +test("advertised-flake-shows-entry: transport flake never silently hides an advertised entry", () => { + for (const probe of ["networkOrIntercepted", "error"]) { + assert.equal( + shouldShowModerationNav({ originSource: "advertised", probe }), + true, + `advertised origin must stay visible for flake probe=${probe}`, + ); + } +}); + +test("advertised-denied-hides-entry: a definitive non-admin verdict hides an advertised-only entry", () => { + for (const probe of ["nip98Denied", "tokenMode", "notAdminApi"]) { + assert.equal( + shouldShowModerationNav({ originSource: "advertised", probe }), + false, + `advertised origin must hide for definitive verdict probe=${probe}`, + ); + } +}); + +test("advertised-null-probe-hides-entry: fail-closed on an unresolved probe", () => { + assert.equal( + shouldShowModerationNav({ originSource: "advertised", probe: null }), + false, + ); +}); diff --git a/desktop/src/features/admin-console/nav.ts b/desktop/src/features/admin-console/nav.ts new file mode 100644 index 000000000..fba66c2ed --- /dev/null +++ b/desktop/src/features/admin-console/nav.ts @@ -0,0 +1,51 @@ +/** + * Pure visibility logic for the Settings → Moderation nav entry. + * + * Kept free of React and IO so the gate decision is unit-testable in + * isolation; `hooks.ts` resolves the origin + probe state that feed it. + */ + +import type { AdminProbeState } from "./api"; + +/** Where the admin origin came from for the active identity. */ +export type ModerationOriginSource = "saved" | "advertised" | "none"; + +/** + * Probe outcome for the resolved origin. `"error"` distinguishes a probe that + * threw (transport flake) from a definitive relay verdict; `null` means no + * probe was run (no origin, or a saved origin that wins without probing). + */ +export type ModerationProbeOutcome = AdminProbeState | "error" | null; + +export type ModerationNavResolution = { + originSource: ModerationOriginSource; + probe: ModerationProbeOutcome; +}; + +/** + * Decide whether the Moderation nav entry is visible. + * + * - No origin (neither saved-manual nor advertised) → hidden. Ordinary members + * never see a dead entry. + * - A saved manual origin always shows the entry, regardless of probe state: + * the Advanced affordance that edits/clears the origin lives inside the + * surface, so hiding it would lock a user out of fixing a bad saved URL. + * - An advertised-only origin shows the entry when the probe plausibly + * authorizes (`nip98Authorized`/`disabled`) or is a transport flake + * (`networkOrIntercepted`/`error` — never a silent disappear on flake), and + * hides it on a definitive non-admin verdict (`nip98Denied`/`tokenMode`/ + * `notAdminApi`). Fail-closed: any unrecognised outcome hides the entry. + */ +export function shouldShowModerationNav(res: ModerationNavResolution): boolean { + if (res.originSource === "none") return false; + if (res.originSource === "saved") return true; + switch (res.probe) { + case "nip98Authorized": + case "disabled": + case "networkOrIntercepted": + case "error": + return true; + default: + return false; + } +} diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index b869d8357..4a50de548 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -14,7 +14,6 @@ import { MonitorCog, Moon, ShieldAlert, - ShieldCheck, Smartphone, Smile, Sun, @@ -66,7 +65,6 @@ import { ExperimentalFeaturesCard } from "./ExperimentalFeaturesCard"; import { KeyboardShortcutsCard } from "./KeyboardShortcutsCard"; import { MeshComputeSettingsCard } from "@/features/mesh-compute/ui/MeshComputeSettingsCard"; import { MobilePairingCard } from "./MobilePairingCard"; -import { ModerationQueueCard } from "./ModerationQueueCard"; import { NotificationSettingsCard } from "./NotificationSettingsCard"; import { AgentsSettingsPanel } from "./AgentsSettingsPanel"; import { HostedCommunitiesSettingsCard } from "./HostedCommunitiesSettingsCard"; @@ -90,7 +88,6 @@ export type SettingsSection = | "hosted-communities" | "community-members" | "moderation" - | "admin-console" | "custom-emoji" | "local-archive" | "mobile" @@ -111,7 +108,6 @@ const SETTINGS_SECTION_VALUES: readonly SettingsSection[] = [ "hosted-communities", "community-members", "moderation", - "admin-console", "custom-emoji", "local-archive", "mobile", @@ -211,11 +207,6 @@ export const settingsSections: SettingsSectionDescriptor[] = [ label: "Moderation", icon: ShieldAlert, }, - { - value: "admin-console", - label: "Moderation console", - icon: ShieldCheck, - }, { value: "custom-emoji", label: "Custom emoji", @@ -871,8 +862,6 @@ export function renderSettingsSection( ); case "moderation": - return ; - case "admin-console": return ; case "custom-emoji": return ; diff --git a/desktop/src/features/settings/ui/SettingsView.tsx b/desktop/src/features/settings/ui/SettingsView.tsx index e5e6ba1ae..1b4a66417 100644 --- a/desktop/src/features/settings/ui/SettingsView.tsx +++ b/desktop/src/features/settings/ui/SettingsView.tsx @@ -3,6 +3,8 @@ import { getVersion } from "@tauri-apps/api/app"; import { AlertCircle, ArrowLeft, LoaderCircle, RefreshCw } from "lucide-react"; import { useMyRelayMembershipLookupQuery } from "@/features/community-members/hooks"; +import { useModerationNavResolution } from "@/features/admin-console/hooks"; +import { shouldShowModerationNav } from "@/features/admin-console/nav"; import { canManageCommunityMembers, shouldWarnMissingMembershipSnapshot, @@ -67,18 +69,11 @@ export const settingsNavGroups: Array<{ }, { label: "Communities", - sections: ["hosted-communities", "community-members"], + sections: ["hosted-communities", "community-members", "moderation"], }, { label: "App", - sections: [ - "agents", - "compute", - "experimental", - "mobile", - "updates", - "admin-console", - ], + sections: ["agents", "compute", "experimental", "mobile", "updates"], }, ]; @@ -136,6 +131,7 @@ export function SettingsView({ }: SettingsViewProps) { const { isMobile, open: sidebarOpen, setOpen: setSidebarOpen } = useSidebar(); const myMembershipQuery = useMyRelayMembershipLookupQuery(); + const moderationNav = useModerationNavResolution(); const featureState = useFeatureSnapshot(); const visibleSections = React.useMemo(() => { return settingsSections.filter((s) => { @@ -153,9 +149,14 @@ export function SettingsView({ if (s.value === "community-members") { return canManageCommunityMembers(myMembershipQuery.data); } + // Moderation surfaces the relay admin console. Hidden until the origin + // resolves (no flash of a dead entry); then gated by origin + probe. + if (s.value === "moderation") { + return moderationNav != null && shouldShowModerationNav(moderationNav); + } return true; }); - }, [myMembershipQuery.data, featureState]); + }, [myMembershipQuery.data, moderationNav, featureState]); const [isLoaded, setIsLoaded] = React.useState(false); const [appVersion, setAppVersion] = React.useState(null); diff --git a/desktop/src/features/settings/ui/settingsNavGroups.test.mjs b/desktop/src/features/settings/ui/settingsNavGroups.test.mjs index 1760a9f5f..1a25adffc 100644 --- a/desktop/src/features/settings/ui/settingsNavGroups.test.mjs +++ b/desktop/src/features/settings/ui/settingsNavGroups.test.mjs @@ -3,22 +3,42 @@ import test from "node:test"; import { settingsNavGroups } from "./SettingsView.tsx"; -test("admin-console is present in the App nav group", () => { - const appGroup = settingsNavGroups.find((g) => g.label === "App"); - assert.ok(appGroup, "App group must exist in settingsNavGroups"); +test("moderation is wired into the Communities nav group", () => { + const communitiesGroup = settingsNavGroups.find( + (g) => g.label === "Communities", + ); assert.ok( - appGroup.sections.includes("admin-console"), - `expected "admin-console" in App group sections, got: ${JSON.stringify(appGroup.sections)}`, + communitiesGroup, + "Communities group must exist in settingsNavGroups", + ); + assert.ok( + communitiesGroup.sections.includes("moderation"), + `expected "moderation" in Communities group sections, got: ${JSON.stringify(communitiesGroup.sections)}`, ); }); -test("admin-console is the last entry in the App nav group", () => { - const appGroup = settingsNavGroups.find((g) => g.label === "App"); - assert.ok(appGroup, "App group must exist in settingsNavGroups"); - const last = appGroup.sections.at(-1); - assert.equal( - last, - "admin-console", - `expected "admin-console" to be last in App group, got: ${last}`, +test("moderation follows community-members in the Communities nav group", () => { + const communitiesGroup = settingsNavGroups.find( + (g) => g.label === "Communities", + ); + assert.ok( + communitiesGroup, + "Communities group must exist in settingsNavGroups", + ); + const membersIndex = communitiesGroup.sections.indexOf("community-members"); + const moderationIndex = communitiesGroup.sections.indexOf("moderation"); + assert.ok(membersIndex !== -1, "community-members must be present"); + assert.ok( + moderationIndex > membersIndex, + `expected "moderation" after "community-members", got: ${JSON.stringify(communitiesGroup.sections)}`, ); }); + +test("the removed admin-console id is not wired into any nav group", () => { + for (const group of settingsNavGroups) { + assert.ok( + !group.sections.includes("admin-console"), + `"admin-console" must not appear in the "${group.label}" group`, + ); + } +});