diff --git a/scripts/ci/fixtures/d1-dynamic-bind-sites.json b/scripts/ci/fixtures/d1-dynamic-bind-sites.json index ca76e1c6f..8cc416c5d 100644 --- a/scripts/ci/fixtures/d1-dynamic-bind-sites.json +++ b/scripts/ci/fixtures/d1-dynamic-bind-sites.json @@ -530,6 +530,14 @@ "strategy": "exact-chunk", "fixedParams": 10 }, + { + "key": "src/shared/src/db/queries/community/channel.ts:listServerChannelDirectoryForAdmin:inArray:1", + "strategy": "fixed-literal" + }, + { + "key": "src/shared/src/db/queries/community/channel.ts:listServerChannelDirectoryForAdmin:inArray:2", + "strategy": "fixed-literal" + }, { "key": "src/shared/src/db/queries/community/channel.ts:resolveVisibleChannelIdSet:inArray:1", "strategy": "exact-chunk", diff --git a/src/shared/src/db/queries/community/channel.ts b/src/shared/src/db/queries/community/channel.ts index f63436c72..214993779 100644 --- a/src/shared/src/db/queries/community/channel.ts +++ b/src/shared/src/db/queries/community/channel.ts @@ -11,7 +11,8 @@ import { import type { Database } from "../../index"; import { nanoid } from "nanoid"; import { PARTICIPANT_SOURCE, type ParticipantSource } from "../../../constants/community"; -import { canSeePrivateChannel, visibilityIsDmParticipant } from "../../../utils/community-roles"; +import { canSeePrivateChannel, visibilityIsDmParticipant, ROLES } from "../../../utils/community-roles"; +import { formatHandle } from "../../../lib/discriminator"; import { user } from "../../schema"; import { chunk, D1_MAX_IN_PARAMS, maxInParams, maxRowsPerInsert } from "../_chunk"; @@ -321,6 +322,40 @@ export async function listServerChannels(db: Database, serverId: string) { return rows; } +export async function listServerChannelDirectoryForAdmin(db: Database, serverId: string, userId: string) { + const rows = await db + .select({ + id: communityChannel.id, + name: communityChannel.name, + type: communityChannel.type, + category: { id: communityCategory.id, name: communityCategory.name, private: communityCategory.private }, + creator: { name: user.name, discriminator: user.discriminator }, + createdAt: communityChannel.createdAt, + }) + .from(communityChannel) + .innerJoin(communityServerMember, and( + eq(communityServerMember.serverId, communityChannel.serverId), + eq(communityServerMember.userId, userId), + inArray(communityServerMember.role, [ROLES.OWNER, ROLES.ADMIN]), + )) + .leftJoin(communityCategory, and( + eq(communityCategory.id, communityChannel.categoryId), + eq(communityCategory.serverId, serverId), + )) + .leftJoin(user, and(eq(user.id, communityChannel.creatorId), isNull(user.deletedAt))) + .where(and( + eq(communityChannel.serverId, serverId), + isNull(communityChannel.parentChannelId), + inArray(communityChannel.type, ["text", "forum"]), + )) + .orderBy(asc(communityCategory.position), asc(communityCategory.id), asc(communityChannel.position), asc(communityChannel.id)); + return rows.map(({ creator, category, ...row }) => ({ + ...row, + category: category ? { ...category, private: !!category.private } : null, + creator: creator ? { name: creator.name, handle: formatHandle(creator.name, creator.discriminator) } : null, + })); +} + /** * `resolveTargetForMember`'s channel-name resolver: matches by NAME only, * scoped to top-level channels (`parentChannelId IS NULL`) — mirrors the diff --git a/src/shared/test/queries/community-channel-admin-directory.test.ts b/src/shared/test/queries/community-channel-admin-directory.test.ts new file mode 100644 index 000000000..7e0801e9a --- /dev/null +++ b/src/shared/test/queries/community-channel-admin-directory.test.ts @@ -0,0 +1,80 @@ +import Sqlite from "better-sqlite3"; +import { drizzle } from "drizzle-orm/better-sqlite3"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { Database } from "../../src/db"; +import { listServerChannelDirectoryForAdmin } from "../../src/db/queries/community/channel"; + +describe("listServerChannelDirectoryForAdmin", () => { + let sqlite: Sqlite.Database; + let db: Database; + + beforeEach(() => { + sqlite = new Sqlite(":memory:"); + sqlite.exec(` + CREATE TABLE user (id TEXT PRIMARY KEY, name TEXT, discriminator TEXT, email TEXT, deletedAt TEXT); + CREATE TABLE community_server_member (id TEXT PRIMARY KEY, server_id TEXT, user_id TEXT, role TEXT); + CREATE TABLE community_category (id TEXT PRIMARY KEY, server_id TEXT, name TEXT, position INTEGER, private INTEGER); + CREATE TABLE community_channel ( + id TEXT PRIMARY KEY, server_id TEXT, category_id TEXT, name TEXT, type TEXT, + position INTEGER, parent_channel_id TEXT, creator_id TEXT, created_at TEXT, topic TEXT + ); + INSERT INTO user VALUES + ('creator', 'Alice', '0042', 'private@example.com', NULL), + ('deleted', 'Old name', '0043', 'deleted@example.com', '2026-01-01'); + INSERT INTO community_server_member VALUES + ('a', 's1', 'admin', 'admin'), ('o', 's1', 'owner', 'owner'), + ('m', 's1', 'creator', 'member'), ('a2', 's2', 'other-admin', 'admin'); + INSERT INTO community_category VALUES + ('public', 's1', 'GENERAL', 2, 0), ('private', 's1', 'PRIVATE', 1, 1), + ('s2-group', 's2', 'OTHER SERVER', 0, 0); + INSERT INTO community_channel VALUES + ('uncat', 's1', NULL, 'uncategorized', 'text', 0, NULL, NULL, '2026-10-01T00:00:00.000Z', 'secret'), + ('public-text', 's1', 'public', 'general', 'text', 1, NULL, 'creator', '2026-10-01T01:00:00.000Z', 'secret'), + ('private-forum', 's1', 'private', 'hidden-forum', 'forum', 0, NULL, 'creator', '2026-10-01T02:00:00.000Z', 'secret'), + ('private-text', 's1', 'private', 'hidden-text', 'text', 1, NULL, 'creator', '2026-10-01T03:00:00.000Z', 'secret'), + ('deleted-creator', 's1', 'public', 'old-channel', 'text', 2, NULL, 'deleted', '2026-10-01T04:00:00.000Z', 'secret'), + ('missing-creator', 's1', 'public', 'missing-creator', 'text', 3, NULL, 'gone', '2026-10-01T05:00:00.000Z', 'secret'), + ('thread', 's1', 'private', 'child-thread', 'thread', 0, 'private-forum', 'creator', '2026-10-01', 'secret'), + ('child-text', 's1', 'private', 'invalid-child', 'text', 0, 'private-text', 'creator', '2026-10-01', 'secret'), + ('top-thread', 's1', NULL, 'invalid-top-thread', 'thread', 0, NULL, 'creator', '2026-10-01', 'secret'), + ('dm', 's1', NULL, 'dm', 'dm', 0, NULL, 'creator', '2026-10-01', 'secret'), + ('other-server', 's2', 's2-group', 'other', 'text', 0, NULL, 'creator', '2026-10-01', 'secret'); + `); + db = drizzle(sqlite) as unknown as Database; + }); + + afterEach(() => sqlite.close()); + + it.each(["admin", "owner"])("includes unjoined private top-level channels for %s, in group order", async (viewer) => { + const rows = await listServerChannelDirectoryForAdmin(db, "s1", viewer); + expect(rows.map((row) => row.id)).toEqual([ + "uncat", "private-forum", "private-text", "public-text", "deleted-creator", "missing-creator", + ]); + expect(rows[0]).toEqual({ + id: "uncat", name: "uncategorized", type: "text", category: null, creator: null, createdAt: "2026-10-01T00:00:00.000Z", + }); + expect(rows[1]).toEqual({ + id: "private-forum", name: "hidden-forum", type: "forum", category: { id: "private", name: "PRIVATE", private: true }, + creator: { name: "Alice", handle: "Alice#0042" }, createdAt: "2026-10-01T02:00:00.000Z", + }); + expect(rows[3]!.category?.private).toBe(false); + expect(rows[4]!.creator).toBeNull(); + expect(rows[5]!.creator).toBeNull(); + expect(JSON.stringify(rows)).not.toMatch(/secret|email|discriminator|creatorId|topic/); + }); + + it.each(["creator", "outsider", "other-admin"])("does not read metadata for unauthorized viewer %s", async (viewer) => { + await expect(listServerChannelDirectoryForAdmin(db, "s1", viewer)).resolves.toEqual([]); + }); + + it("scopes admin membership to the requested server", async () => { + await expect(listServerChannelDirectoryForAdmin(db, "s2", "admin")).resolves.toEqual([]); + expect((await listServerChannelDirectoryForAdmin(db, "s2", "other-admin")).map((row) => row.id)).toEqual(["other-server"]); + await expect(listServerChannelDirectoryForAdmin(db, "nonexistent", "admin")).resolves.toEqual([]); + }); + + it("stops returning metadata immediately after an admin is downgraded", async () => { + sqlite.prepare("UPDATE community_server_member SET role = 'member' WHERE user_id = ?").run("admin"); + await expect(listServerChannelDirectoryForAdmin(db, "s1", "admin")).resolves.toEqual([]); + }); +}); diff --git a/src/web/src/app/api/community/servers/[id]/channels/admin/route.test.ts b/src/web/src/app/api/community/servers/[id]/channels/admin/route.test.ts new file mode 100644 index 000000000..01d20006f --- /dev/null +++ b/src/web/src/app/api/community/servers/[id]/channels/admin/route.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import { NextRequest } from "next/server" + +const mocks = vi.hoisted(() => ({ + getMember: vi.fn(), + listDirectory: vi.fn(), + getPrimaryDb: vi.fn(() => ({})), + kind: "human" as "human" | "bot", +})) + +vi.mock("@/lib/db", () => ({ getPrimaryDb: mocks.getPrimaryDb })) +vi.mock("@alook/shared", async () => { + const actual = await vi.importActual("@alook/shared") + return { + ...actual, + queries: { + ...actual.queries, + communityMember: { getMember: mocks.getMember }, + communityChannel: { listServerChannelDirectoryForAdmin: mocks.listDirectory }, + }, + } +}) +vi.mock("@/lib/middleware/community-actor", async () => { + const actual = await vi.importActual("@/lib/middleware/community-actor") + return { + ...actual, + withCommunityActor: (handler: any) => (req: any, ctx: any) => handler(req, { + env: { DB: {} }, actor: { kind: mocks.kind, userId: "viewer" }, params: ctx?.params, + }), + } +}) + +import { GET } from "./route" + +const request = () => new NextRequest("http://localhost/api/community/servers/s1/channels/admin") +const context = (id = "s1") => ({ params: { id } }) as never + +describe("GET server admin channel directory", () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.kind = "human" + mocks.listDirectory.mockResolvedValue([]) + }) + + it.each(["admin", "owner"])("permits %s and returns only the directory response, without caching", async (role) => { + mocks.getMember.mockResolvedValue({ role }) + const channels = [{ + id: "private", name: "private", type: "forum", category: { id: "group", name: "PRIVATE", private: true }, + creator: { name: "Alice", handle: "Alice#0042" }, createdAt: "2026-10-01T00:00:00.000Z", + }] + mocks.listDirectory.mockResolvedValue(channels) + const response = await GET(request(), context()) + expect(response.status).toBe(200) + expect(response.headers.get("Cache-Control")).toBe("private, no-store") + expect(await response.json()).toEqual({ channels }) + expect(mocks.getPrimaryDb).toHaveBeenCalledWith({}) + expect(mocks.getMember).toHaveBeenCalledWith(expect.anything(), "s1", "viewer") + expect(mocks.listDirectory).toHaveBeenCalledWith(expect.anything(), "s1", "viewer") + }) + + it.each(["member", null, "unknown"])("rejects role %s before reading any channel metadata", async (role) => { + mocks.getMember.mockResolvedValue({ role }) + const response = await GET(request(), context()) + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: "admin permission required" }) + expect(mocks.listDirectory).not.toHaveBeenCalled() + }) + + it("rejects an outsider or an admin requesting a different server", async () => { + mocks.getMember.mockImplementation((_db, serverId) => Promise.resolve(serverId === "s1" ? { role: "admin" } : null)) + const response = await GET(request(), context("s2")) + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: "not a member of this server" }) + expect(mocks.listDirectory).not.toHaveBeenCalled() + }) + + it("rejects bots before looking up a server", async () => { + mocks.kind = "bot" + const response = await GET(request(), context()) + expect(response.status).toBe(403) + expect(mocks.getPrimaryDb).not.toHaveBeenCalled() + expect(mocks.getMember).not.toHaveBeenCalled() + expect(mocks.listDirectory).not.toHaveBeenCalled() + }) + + it("rejects a missing server id", async () => { + const response = await GET(request(), { params: {} } as never) + expect(response.status).toBe(400) + expect(mocks.getMember).not.toHaveBeenCalled() + expect(mocks.listDirectory).not.toHaveBeenCalled() + }) +}) diff --git a/src/web/src/app/api/community/servers/[id]/channels/admin/route.ts b/src/web/src/app/api/community/servers/[id]/channels/admin/route.ts new file mode 100644 index 000000000..7adcee51c --- /dev/null +++ b/src/web/src/app/api/community/servers/[id]/channels/admin/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server" +import { queries } from "@alook/shared" +import { getPrimaryDb } from "@/lib/db" +import { withCommunityActor, rejectBot } from "@/lib/middleware/community-actor" +import { writeError } from "@/lib/middleware/helpers" +import { requireServerAdmin } from "@/lib/community/permissions" + +const headers = { "Cache-Control": "private, no-store" } + +export const GET = withCommunityActor(async (_req, ctx) => { + const rejected = rejectBot(ctx.actor) + if (rejected) { + rejected.headers.set("Cache-Control", headers["Cache-Control"]) + return rejected + } + const serverId = ctx.params?.id + if (!serverId) return writeError("missing server id", 400, headers) + + const db = getPrimaryDb(ctx.env.DB) + const auth = await requireServerAdmin(db, serverId, ctx.actor.userId) + if (!auth.ok) return writeError(auth.error, auth.status, headers) + + const channels = await queries.communityChannel.listServerChannelDirectoryForAdmin(db, serverId, ctx.actor.userId) + return NextResponse.json({ channels }, { headers }) +}) diff --git a/src/web/src/app/c/channels/layout.tsx b/src/web/src/app/c/channels/layout.tsx index 1ea027c93..230c273b6 100644 --- a/src/web/src/app/c/channels/layout.tsx +++ b/src/web/src/app/c/channels/layout.tsx @@ -547,6 +547,7 @@ export default function ServerLayout({ children }: { children: ReactNode }) { { if (!o) closeSettings() }}> ({ default: { shell: "settings-shell" } })) +const apiFetchMock = vi.hoisted(() => vi.fn()) +vi.mock("@/lib/api/client", () => ({ apiFetch: apiFetchMock, toastApiError: vi.fn() })) +vi.mock("@/hooks/use-mobile", () => ({ useBreakpoint: () => "desktop" })) +vi.mock("@/hooks/community/use-server-panels", () => ({ useInvites: () => ({ invites: [], isLoading: false }) })) +vi.mock("@/hooks/community/use-bots", () => ({ useBots: () => ({ bots: [] }) })) +vi.mock("@/hooks/community/use-notification-settings", async (importOriginal) => ({ + ...await importOriginal(), + useBotNotificationSetting: () => ({ data: undefined, isError: false, isLoading: false }), + useSetBotNotificationSetting: () => ({ mutate: vi.fn(), isPending: false }), +})) + +import { ServerSettings } from "./server-settings" + +const props = { section: "overview" as const, setSection: vi.fn(), onClose: vi.fn(), serverId: "server-1", serverName: "Server", members: [] } + +function setup() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const wrapper = ({ children }: PropsWithChildren) => createElement(QueryClientProvider, { client }, children) + return { client, wrapper } +} + +describe("Channels tab access", () => { + it("lets an administrator select Channels", async () => { + const { client, wrapper } = setup() + const setSection = vi.fn() + const user = setupUser() + const rendered = render(createElement(ServerSettings, { ...props, isAdmin: true, setSection }), { wrapper }) + await user.click(screen.getByTestId(tid.settingsTab("channels"))) + expect(setSection).toHaveBeenCalledWith("channels") + rendered.unmount() + client.clear() + }) + + it("hides the entry and rejects a stale Channels selection after role loss", () => { + const { client, wrapper } = setup() + const rendered = render(createElement(ServerSettings, { ...props, isAdmin: false, section: "channels" }), { wrapper }) + expect(screen.queryByTestId(tid.settingsTab("channels"))).not.toBeInTheDocument() + expect(screen.getByRole("heading", { name: "overview" })).toBeVisible() + expect(screen.queryByTestId(tid.settingsChannels)).not.toBeInTheDocument() + rendered.unmount() + client.clear() + }) + + it("requests only when Channels opens, and removes the list when the role is lost", async () => { + apiFetchMock.mockReset().mockResolvedValue({ channels: [{ id: "private-1", name: "private-1", category: null, creator: null, createdAt: "2026-10-01T00:00:00.000Z" }] }) + const { client, wrapper } = setup() + const rendered = render(createElement(ServerSettings, { ...props, isAdmin: true }), { wrapper }) + expect(apiFetchMock).not.toHaveBeenCalled() + rendered.rerender(createElement(ServerSettings, { ...props, isAdmin: true, section: "members" })) + expect(apiFetchMock).not.toHaveBeenCalled() + rendered.rerender(createElement(ServerSettings, { ...props, isAdmin: true, section: "channels" })) + await waitFor(() => expect(screen.getByTestId(tid.settingsChannel("private-1"))).toBeVisible()) + expect(apiFetchMock).toHaveBeenCalledOnce() + rendered.rerender(createElement(ServerSettings, { ...props, isAdmin: false, section: "channels" })) + expect(screen.queryByTestId(tid.settingsChannels)).not.toBeInTheDocument() + expect(screen.queryByText("private-1")).not.toBeInTheDocument() + expect(apiFetchMock).toHaveBeenCalledOnce() + rendered.unmount() + client.clear() + }) +}) diff --git a/src/web/src/components/community/settings/server-settings-channels.dom.test.tsx b/src/web/src/components/community/settings/server-settings-channels.dom.test.tsx new file mode 100644 index 000000000..a569b82bd --- /dev/null +++ b/src/web/src/components/community/settings/server-settings-channels.dom.test.tsx @@ -0,0 +1,83 @@ +import { createElement } from "react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { render, screen, setupUser, within } from "@/test/react-dom-harness" +import { tid } from "@/lib/community/testids" +import { ServerSettingsChannels } from "./server-settings-channels" + +const query = vi.hoisted(() => ({ channels: undefined as unknown, isError: false, isFetching: false, forbidden: false, refetch: vi.fn() })) +vi.mock("@/hooks/community/use-server-admin-channels", () => ({ useServerAdminChannels: () => query })) + +beforeEach(() => { + Object.assign(query, { channels: undefined, isError: false, isFetching: false, forbidden: false }) + query.refetch.mockReset() +}) + +describe("read-only channel metadata", () => { + it("shows groups, channel names, creator handles and exact dates without navigation", () => { + query.channels = [ + { id: "public", name: "general", category: null, creator: null, createdAt: "2026-10-01T00:00:00.000Z" }, + { id: "private", name: "long-private-name", category: { id: "cat", name: "PRIVATE GROUP", private: true }, creator: { name: "Alice", handle: "alice#0001" }, createdAt: "2026-10-01T01:00:00.000Z" }, + ] + render(createElement(ServerSettingsChannels, { serverId: "server-1" })) + const group = screen.getByRole("region", { name: "Group: PRIVATE GROUP" }) + expect(within(group).getByText("long-private-name")).toBeVisible() + expect(within(group).getByText("@alice#0001")).toHaveAttribute("title", "@alice#0001") + expect(within(group).getByText(/2026/).closest("time")).toHaveAttribute("datetime", "2026-10-01T01:00:00.000Z") + expect(screen.getByRole("region", { name: "Group: Uncategorized" })).toBeVisible() + expect(screen.getByText("Deleted user")).toBeVisible() + expect(screen.queryByRole("link")).not.toBeInTheDocument() + expect(screen.getByLabelText("Private group")).toBeVisible() + expect(screen.queryByLabelText("Public group")).not.toBeInTheDocument() + expect(screen.getByRole("button", { name: "Group: PRIVATE GROUP" })).toHaveAttribute("aria-expanded", "true") + expect(screen.getAllByRole("listitem")).toHaveLength(2) + }) + + it("collapses and expands the settings group without navigating or fetching again", async () => { + query.channels = [{ id: "private", name: "private-forum", type: "forum", category: { id: "cat", name: "PRIVATE GROUP", private: true }, creator: null, createdAt: "2026-10-01T00:00:00.000Z" }] + const user = setupUser() + render(createElement(ServerSettingsChannels, { serverId: "server-1" })) + const toggle = screen.getByRole("button", { name: "Group: PRIVATE GROUP" }) + await user.click(toggle) + expect(toggle).toHaveAttribute("aria-expanded", "false") + expect(screen.queryByTestId(tid.settingsChannel("private"))).not.toBeInTheDocument() + await user.keyboard("{Enter}") + expect(toggle).toHaveAttribute("aria-expanded", "true") + expect(screen.getByTestId(tid.settingsChannel("private"))).toBeVisible() + await user.keyboard(" ") + expect(toggle).toHaveAttribute("aria-expanded", "false") + expect(screen.queryByTestId(tid.settingsChannel("private"))).not.toBeInTheDocument() + expect(query.refetch).not.toHaveBeenCalled() + }) + + it("distinguishes an unresolved request from an empty list", () => { + const rendered = render(createElement(ServerSettingsChannels, { serverId: "server-1" })) + expect(screen.getByRole("status")).toHaveTextContent("Loading channels…") + query.channels = [] + rendered.rerender(createElement(ServerSettingsChannels, { serverId: "server-1" })) + expect(screen.getByText("No channels yet.")).toBeVisible() + expect(screen.queryByRole("status")).not.toBeInTheDocument() + }) + + it("offers retry on failure, disables it during retry and retains loaded rows", async () => { + query.isError = true + const user = setupUser() + const rendered = render(createElement(ServerSettingsChannels, { serverId: "server-1" })) + expect(screen.getByRole("alert")).toHaveTextContent("Couldn’t load channels.") + await user.click(screen.getByRole("button", { name: "Retry" })) + expect(query.refetch).toHaveBeenCalledOnce() + query.isFetching = true + query.channels = [{ id: "public", name: "general", category: null, creator: null, createdAt: "2026-10-01T00:00:00.000Z" }] + rendered.rerender(createElement(ServerSettingsChannels, { serverId: "server-1" })) + expect(screen.getByTestId(tid.settingsChannel("public"))).toBeVisible() + expect(screen.getByRole("alert")).toHaveTextContent("Couldn’t refresh channels.") + expect(screen.getByRole("button", { name: "Retry" })).toBeDisabled() + }) + + it("renders no cached metadata after an authorization failure", () => { + query.forbidden = true + query.channels = [{ id: "private", name: "secret-name", category: null, creator: null, createdAt: "2026-10-01T00:00:00.000Z" }] + render(createElement(ServerSettingsChannels, { serverId: "server-1" })) + expect(screen.getByRole("alert")).toHaveTextContent("Only server administrators") + expect(screen.queryByText("secret-name")).not.toBeInTheDocument() + }) +}) diff --git a/src/web/src/components/community/settings/server-settings-channels.tsx b/src/web/src/components/community/settings/server-settings-channels.tsx new file mode 100644 index 000000000..ad76fc193 --- /dev/null +++ b/src/web/src/components/community/settings/server-settings-channels.tsx @@ -0,0 +1,77 @@ +"use client" + +import { useState } from "react" +import { ChevronDown, Lock } from "lucide-react" +import { Button } from "@/components/ui/button" +import { EntityIcon } from "../entity-icon" +import { MentionPill } from "../messages/inline-marks" +import { useServerAdminChannels, type AdminChannel } from "@/hooks/community/use-server-admin-channels" +import { tid } from "@/lib/community/testids" + +function groupChannels(channels: AdminChannel[]) { + const groups = new Map() + for (const channel of channels) { + const key = channel.category?.id ?? "__uncategorized__" + const group = groups.get(key) ?? { name: channel.category?.name ?? "Uncategorized", private: channel.category?.private ?? false, channels: [] } + group.channels.push(channel) + groups.set(key, group) + } + return [...groups.entries()] +} + +export function ServerSettingsChannels({ serverId }: { serverId: string }) { + const [collapsed, setCollapsed] = useState>(() => new Set()) + const { channels, isError, isFetching, refetch, forbidden } = useServerAdminChannels(serverId, true) + const retry = () => { void refetch() } + if (forbidden) return

Only server administrators can view channels.

+ + return ( +
+ {isError && ( +
+

{channels ? "Couldn’t refresh channels." : "Couldn’t load channels."}

+ +
+ )} + {!channels && !isError &&

Loading channels…

} + {channels?.length === 0 &&

No channels yet.

} + {channels && groupChannels(channels).map(([id, group]) => ( +
+ + {!collapsed.has(id) &&
    + {group.channels.map((channel) => ( +
  • +
    + + {channel.name} +
    + Creator: + + {channel.creator ? @{channel.creator.handle} : "Deleted user"} + + +
    +
    +
  • + ))} +
} +
+ ))} +
+ ) +} diff --git a/src/web/src/components/community/settings/server-settings.tsx b/src/web/src/components/community/settings/server-settings.tsx index 13e37d352..68aa88651 100644 --- a/src/web/src/components/community/settings/server-settings.tsx +++ b/src/web/src/components/community/settings/server-settings.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react" import { useVirtualizer } from "@tanstack/react-virtual" -import { Settings, Users, Link2, Bell, Trash2, X, Shield, Search, Camera } from "lucide-react" +import { Settings, Users, Link2, Bell, Trash2, X, Shield, Search, Camera, Hash } from "lucide-react" import { isServerOwner, NOTIF_LEVELS, @@ -36,6 +36,7 @@ import { useSetBotNotificationSetting, } from "@/hooks/community/use-notification-settings" import { toastApiError } from "@/lib/api/client" +import { ServerSettingsChannels } from "./server-settings-channels" const SETTABLE_ROLES: Role[] = ["admin", "member"] @@ -46,6 +47,7 @@ function capitalize(s: string): string { // Full-screen server settings view. Data via props. export function ServerSettings({ section, setSection, onClose, serverId, serverName, serverDescription, serverIcon, + isAdmin = false, members, membersLoading, membersLoadingMore, membersHasMore, membersTotal, onLoadMoreMembers, onSearchMembers, onOpenProfile, onKickMember, onSetRole, onRevokeInvite, onCopyInvite, onDeleteServer, onUploadIcon, onUpdateServer, notifLevel, onSetNotifLevel, @@ -57,6 +59,7 @@ export function ServerSettings({ serverName: string serverDescription?: string serverIcon?: string | null + isAdmin?: boolean members: Member[] membersLoading?: boolean membersLoadingMore?: boolean @@ -76,6 +79,7 @@ export function ServerSettings({ onSetNotifLevel?: (l: string) => void }) { const [confirmDelete, setConfirmDelete] = useState(false) + const visibleSection = section === "channels" && !isAdmin ? "overview" : section // Invites are low-frequency, admin-only panel data. Fetch them only when // their tab is open, never on settings mount or via WS. @@ -84,6 +88,7 @@ export function ServerSettings({ const nav: SettingsShellTab[] = [ { value: "overview", label: "Overview", icon: Settings }, { value: "members", label: "Members", icon: Users }, + ...(isAdmin ? [{ value: "channels" as const, label: "Channels", icon: Hash }] : []), { value: "invites", label: "Invites", icon: Link2 }, { value: "notifications", label: "Notifications", icon: Bell }, ] @@ -99,14 +104,15 @@ export function ServerSettings({ onConfirm={() => { setConfirmDelete(false); onDeleteServer?.() }} /> {section}} + title={{visibleSection}} tabs={nav} onClose={onClose} > setConfirmDelete(true)} /> + {isAdmin && section === "channels" && } diff --git a/src/web/src/components/community/settings/settings-types.ts b/src/web/src/components/community/settings/settings-types.ts index 7ff0876a8..b7e62feca 100644 --- a/src/web/src/components/community/settings/settings-types.ts +++ b/src/web/src/components/community/settings/settings-types.ts @@ -1,5 +1,6 @@ export type SettingsSection = | "overview" + | "channels" | "members" | "invites" | "notifications" diff --git a/src/web/src/hooks/community/use-server-admin-channels.dom.test.tsx b/src/web/src/hooks/community/use-server-admin-channels.dom.test.tsx new file mode 100644 index 000000000..f0c68f4ed --- /dev/null +++ b/src/web/src/hooks/community/use-server-admin-channels.dom.test.tsx @@ -0,0 +1,102 @@ +import { createElement, type PropsWithChildren } from "react" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { act, renderHook, waitFor } from "@/test/react-dom-harness" +import { communityKeys } from "@/lib/query-keys" +import { shouldPersistQueryKey } from "@/lib/query-persister" +import { ApiError } from "@/lib/errors" +import { useServerAdminChannels } from "./use-server-admin-channels" + +const apiFetchMock = vi.hoisted(() => vi.fn()) +vi.mock("@/lib/api/client", () => ({ apiFetch: apiFetchMock })) + +const channel = { + id: "private-channel", name: "private-channel", category: { id: "cat", name: "Private" }, + creator: { name: "Alice", handle: "alice#0001" }, createdAt: "2026-10-01T00:00:00.000Z", +} + +function setup() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const wrapper = ({ children }: PropsWithChildren) => createElement(QueryClientProvider, { client }, children) + return { client, wrapper } +} + +beforeEach(() => { apiFetchMock.mockReset() }) + +describe("admin channel list isolation", () => { + it("does not fetch until enabled and keeps metadata outside persisted/sidebar data", async () => { + apiFetchMock.mockResolvedValue({ channels: [channel] }) + const { client, wrapper } = setup() + const rendered = renderHook(({ serverId, isAdmin }) => useServerAdminChannels(serverId, isAdmin), { + wrapper, initialProps: { serverId: null as string | null, isAdmin: true }, + }) + expect(apiFetchMock).not.toHaveBeenCalled() + rendered.rerender({ serverId: "server-1", isAdmin: false }) + expect(apiFetchMock).not.toHaveBeenCalled() + expect(rendered.result.current.channels).toBeUndefined() + rendered.rerender({ serverId: "server-1", isAdmin: true }) + await waitFor(() => expect(rendered.result.current.channels).toEqual([channel])) + expect(apiFetchMock).toHaveBeenCalledWith("/api/community/servers/server-1/channels/admin", { signal: expect.any(AbortSignal) }) + expect(client.getQueryData(communityKeys.adminChannels("server-1"))).toEqual({ channels: [channel] }) + expect(shouldPersistQueryKey(communityKeys.adminChannels("server-1"))).toBe(false) + rendered.unmount() + await waitFor(() => expect(client.getQueryData(communityKeys.adminChannels("server-1"))).toBeUndefined()) + client.clear() + }) + + it("never displays old server metadata while the next server is loading", async () => { + apiFetchMock.mockResolvedValueOnce({ channels: [channel] }).mockImplementationOnce(() => new Promise(() => {})) + const { client, wrapper } = setup() + const rendered = renderHook(({ serverId }) => useServerAdminChannels(serverId, true), { + wrapper, initialProps: { serverId: "server-1" }, + }) + await waitFor(() => expect(rendered.result.current.channels).toEqual([channel])) + rendered.rerender({ serverId: "server-2" }) + expect(rendered.result.current.channels).toBeUndefined() + await waitFor(() => expect(apiFetchMock).toHaveBeenCalledWith("/api/community/servers/server-2/channels/admin", { signal: expect.any(AbortSignal) })) + rendered.unmount() + client.clear() + }) + + it("hides already loaded data immediately after role loss", async () => { + apiFetchMock.mockResolvedValue({ channels: [channel] }) + const { client, wrapper } = setup() + const rendered = renderHook(({ isAdmin }) => useServerAdminChannels("server-1", isAdmin), { + wrapper, initialProps: { isAdmin: true }, + }) + await waitFor(() => expect(rendered.result.current.channels).toEqual([channel])) + rendered.rerender({ isAdmin: false }) + expect(rendered.result.current.channels).toBeUndefined() + expect(apiFetchMock).toHaveBeenCalledOnce() + rendered.unmount() + client.clear() + }) + + it("retains metadata on a transient refresh failure, but hides it after a 403", async () => { + apiFetchMock.mockResolvedValueOnce({ channels: [channel] }) + .mockRejectedValueOnce(new ApiError("offline", 0)) + .mockRejectedValueOnce(new ApiError("forbidden", 403)) + const { client, wrapper } = setup() + const rendered = renderHook(() => useServerAdminChannels("server-1", true), { wrapper }) + await waitFor(() => expect(rendered.result.current.channels).toEqual([channel])) + await act(async () => { await rendered.result.current.refetch() }) + await waitFor(() => expect(rendered.result.current.isError).toBe(true)) + expect(rendered.result.current.channels).toEqual([channel]) + await act(async () => { await rendered.result.current.refetch() }) + await waitFor(() => expect(rendered.result.current.forbidden).toBe(true)) + expect(rendered.result.current.channels).toBeUndefined() + rendered.unmount() + client.clear() + }) + + it("passes an abort signal that cancels an in-flight request on close", async () => { + apiFetchMock.mockImplementation(() => new Promise(() => {})) + const { client, wrapper } = setup() + const rendered = renderHook(() => useServerAdminChannels("server-1", true), { wrapper }) + await waitFor(() => expect(apiFetchMock).toHaveBeenCalledOnce()) + const signal = apiFetchMock.mock.calls[0][1].signal as AbortSignal + rendered.unmount() + expect(signal.aborted).toBe(true) + client.clear() + }) +}) diff --git a/src/web/src/hooks/community/use-server-admin-channels.ts b/src/web/src/hooks/community/use-server-admin-channels.ts new file mode 100644 index 000000000..ff679a29d --- /dev/null +++ b/src/web/src/hooks/community/use-server-admin-channels.ts @@ -0,0 +1,36 @@ +"use client" + +import { useQuery } from "@tanstack/react-query" +import { apiFetch } from "@/lib/api/client" +import { ApiError } from "@/lib/errors" +import { communityKeys } from "@/lib/query-keys" + +export type AdminChannel = { + id: string + name: string + type: "text" | "forum" + category: { id: string; name: string; private: boolean } | null + creator: { name: string; handle: string } | null + createdAt: string +} + +export function useServerAdminChannels(serverId: string | null, isAdmin: boolean) { + const enabled = Boolean(serverId && isAdmin) + const query = useQuery({ + queryKey: communityKeys.adminChannels(enabled ? serverId! : "__none__"), + queryFn: ({ signal }) => apiFetch<{ channels: AdminChannel[] }>( + `/api/community/servers/${serverId}/channels/admin`, + { signal }, + ), + enabled, + staleTime: 60_000, + gcTime: 0, + retry: false, + }) + const forbidden = query.error instanceof ApiError && [401, 403].includes(query.error.status) + return { + ...query, + channels: enabled && !forbidden ? query.data?.channels : undefined, + forbidden, + } +} diff --git a/src/web/src/lib/community/testids.ts b/src/web/src/lib/community/testids.ts index 2994b55d4..a3e7d0575 100644 --- a/src/web/src/lib/community/testids.ts +++ b/src/web/src/lib/community/testids.ts @@ -56,6 +56,8 @@ export const tid = { userBarProfileSecondaryDock: "community-user-bar-profile-secondary-dock", serverSettingsOpen: "community-server-settings-open", serverSettingsName: "community-server-settings-name", + settingsChannels: "community-settings-channels", + settingsChannel: (id: string) => `community-settings-channel-${id}`, newDivider: "community-new-divider", typingIndicator: "community-typing-indicator", dmBlockedNotice: "community-dm-blocked-notice", diff --git a/src/web/src/lib/query-keys.ts b/src/web/src/lib/query-keys.ts index 282ec99ff..92b26b4eb 100644 --- a/src/web/src/lib/query-keys.ts +++ b/src/web/src/lib/query-keys.ts @@ -50,6 +50,8 @@ export const communityKeys = { [...communityKeys.server(serverId), "presence"] as const, invites: (serverId: string) => [...communityKeys.server(serverId), "invites"] as const, + adminChannels: (serverId: string) => + [...communityKeys.server(serverId), "admin-channels"] as const, invitableFriends: (serverId: string) => [...communityKeys.server(serverId), "invitable-friends"] as const, // Server metadata fetched for an inline invite card (token → serverName / diff --git a/src/web/src/test/e2e-ui/06-channel-member-admin.spec.ts b/src/web/src/test/e2e-ui/06-channel-member-admin.spec.ts index 077a6550a..2b6401f11 100644 --- a/src/web/src/test/e2e-ui/06-channel-member-admin.spec.ts +++ b/src/web/src/test/e2e-ui/06-channel-member-admin.spec.ts @@ -1,6 +1,6 @@ import { test, expect, userId } from "./_fixtures/community-fixture" import { tid } from "./_fixtures/testids" -import { seedServer, seedChannel, seedJoinServer } from "./_fixtures/seed" +import { seedServer, seedChannel, seedJoinServer, seedCategory, seedMessage, seedThread } from "./_fixtures/seed" // Journey 6 — channel / member administration + the eject branch (needs a // second identity). Focuses on member list presence and non-member ejection. @@ -30,4 +30,88 @@ test.describe.serial("channel & member admin", () => { await page.getByRole("button", { name: /member/i }).first().click() await expect(page.getByTestId(tid.memberRow(userId("bob")))).toBeVisible({ timeout: 15_000 }) }) + + test("Channels loads only on selection and lists private metadata without granting content access", async ({ asUser }) => { + const categoryId = await seedCategory("alice", serverId, `Directory QA ${Date.now()}`, { private: true }) + const privateName = "bob-private-channel-with-a-very-long-name-for-mobile-review" + const privateId = await seedChannel("bob", serverId, privateName, "forum", categoryId) + const root = await seedMessage("alice", channelId, "Thread root") + const childId = await seedThread("alice", root, "Child excluded") + const { page } = await asUser("alice") + const adminPath = `/api/community/servers/${serverId}/channels/admin` + const requests: string[] = [] + const responses: number[] = [] + let abortedRequests = 0 + page.on("request", (request) => { + if (new URL(request.url()).pathname === adminPath) requests.push(request.url()) + }) + page.on("response", (response) => { + if (new URL(response.url()).pathname === adminPath) responses.push(response.status()) + }) + page.on("requestfailed", (request) => { + if (new URL(request.url()).pathname === adminPath && request.failure()?.errorText === "net::ERR_ABORTED") abortedRequests += 1 + }) + await page.goto(`/c/channels/${serverId}/${channelId}`) + await expect(page.getByTestId(tid.composerInput)).toBeVisible() + expect(requests).toHaveLength(0) + await page.getByTestId(tid.serverIcon(serverId)).click({ button: "right" }) + await page.getByTestId(tid.serverSettingsOpen).click() + await expect(page.getByTestId(tid.settingsShell)).toBeVisible() + expect(requests).toHaveLength(0) + const response = page.waitForResponse((res) => new URL(res.url()).pathname === adminPath) + await page.getByTestId(tid.settingsTab("channels")).click() + const result = await response + expect(result.status()).toBe(200) + const data = await result.json() as { channels: { id: string; creator: { handle: string } | null; createdAt: string }[] } + expect(data.channels.map((row) => row.id)).toContain(privateId) + expect(data.channels.map((row) => row.id)).not.toContain(childId) + const row = page.getByTestId(tid.settingsChannel(privateId)) + await expect(row).toBeVisible() + await expect(row).toContainText(privateName) + await expect(row).toContainText(`@${data.channels.find((channel) => channel.id === privateId)!.creator!.handle}`) + await expect(row.locator("time")).toHaveAttribute("datetime", data.channels.find((channel) => channel.id === privateId)!.createdAt) + await expect(row.getByRole("link")).toHaveCount(0) + expect(responses).toEqual([200]) + await expect.poll(() => requests.length - abortedRequests).toBe(1) + await page.setViewportSize({ width: 390, height: 844 }) + const label = row.locator(`span[title="${privateName}"]`) + await expect.poll(async () => label.evaluate((element) => { + const style = getComputedStyle(element) + return element.getBoundingClientRect().height > Number.parseFloat(style.lineHeight) + && element.scrollWidth <= element.clientWidth + 1 + && element.scrollHeight <= element.clientHeight + 1 + })).toBe(true) + const metadata = row.locator("time").locator("..") + await expect.poll(async () => metadata.evaluate((element) => { + const pill = element.querySelector("span[title]")! + const time = element.querySelector("time")! + const a = pill.getBoundingClientRect(), b = time.getBoundingClientRect() + return Math.abs((a.top + a.bottom) / 2 - (b.top + b.bottom) / 2) < 2 + && element.scrollWidth <= element.clientWidth + 1 + })).toBe(true) + const category = page.getByRole("region", { name: /^Group: Directory QA/ }) + await expect(category.getByLabel("Private group")).toBeVisible() + const toggle = category.getByRole("button", { name: /^Group: Directory QA/ }) + await toggle.click() + await expect(row).toHaveCount(0) + await toggle.click() + await expect(row).toBeVisible() + expect(responses).toEqual([200]) + const messages = await page.request.get(`/api/community/channels/${privateId}/messages`) + expect(messages.status()).toBe(403) + await page.getByTestId(tid.settingsClose).click() + await expect(page.getByTestId(tid.channelRow(privateId))).toHaveCount(0) + }) + + test("ordinary members cannot open admin settings or access the admin list", async ({ asUser }) => { + const { page } = await asUser("bob") + await page.goto(`/c/channels/${serverId}/${channelId}`) + await expect(page.getByTestId(tid.composerInput)).toBeVisible() + await page.getByTestId(tid.serverIcon(serverId)).click({ button: "right" }) + await page.getByTestId(tid.serverSettingsOpen).click() + await expect(page.getByTestId(tid.settingsShell)).toHaveCount(0) + await expect(page.getByTestId(tid.settingsTab("channels"))).toHaveCount(0) + const result = await page.request.get(`/api/community/servers/${serverId}/channels/admin`) + expect(result.status()).toBe(403) + }) })