From 933bf6c1109b1e24cace8f27fdc694862f2d1f4e Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 2 Oct 2026 00:02:12 +0800 Subject: [PATCH 1/9] feat(web): show server channels in admin settings --- src/web/src/app/c/channels/layout.tsx | 1 + .../channels/channel-picker-label.tsx | 15 +++ .../messages/composer-suggestion-popups.tsx | 15 +-- ...rver-settings-channels-access.dom.test.tsx | 68 ++++++++++++ .../server-settings-channels.dom.test.tsx | 64 +++++++++++ .../settings/server-settings-channels.tsx | 59 ++++++++++ .../community/settings/server-settings.tsx | 12 ++- .../community/settings/settings-types.ts | 1 + .../use-server-admin-channels.dom.test.tsx | 102 ++++++++++++++++++ .../community/use-server-admin-channels.ts | 35 ++++++ src/web/src/lib/community/testids.ts | 2 + src/web/src/lib/query-keys.ts | 2 + .../e2e-ui/06-channel-member-admin.spec.ts | 51 ++++++++- 13 files changed, 410 insertions(+), 17 deletions(-) create mode 100644 src/web/src/components/community/channels/channel-picker-label.tsx create mode 100644 src/web/src/components/community/settings/server-settings-channels-access.dom.test.tsx create mode 100644 src/web/src/components/community/settings/server-settings-channels.dom.test.tsx create mode 100644 src/web/src/components/community/settings/server-settings-channels.tsx create mode 100644 src/web/src/hooks/community/use-server-admin-channels.dom.test.tsx create mode 100644 src/web/src/hooks/community/use-server-admin-channels.ts 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() }}> + + + + + {serverName && {serverName} / } + {name} + + + ) +} diff --git a/src/web/src/components/community/messages/composer-suggestion-popups.tsx b/src/web/src/components/community/messages/composer-suggestion-popups.tsx index 2eaa35a36..972a2ece9 100644 --- a/src/web/src/components/community/messages/composer-suggestion-popups.tsx +++ b/src/web/src/components/community/messages/composer-suggestion-popups.tsx @@ -6,7 +6,7 @@ import { useAnchoredPopover, } from "@/hooks/use-anchored-popover" import { Avatar } from "../avatar" -import { ChannelIcon } from "../channels/channel-icon" +import { ChannelPickerLabel } from "../channels/channel-picker-label" import { nextListScrollTop } from "@/lib/community/popup-scroll" import { tid } from "@/lib/community/testids" import { @@ -222,18 +222,7 @@ function ChannelRefRow({ onSelect() }} > - - - - - {showServerPrefix && ( - {item.serverName} / - )} - {item.name} - + ) } diff --git a/src/web/src/components/community/settings/server-settings-channels-access.dom.test.tsx b/src/web/src/components/community/settings/server-settings-channels-access.dom.test.tsx new file mode 100644 index 000000000..c4eee7f73 --- /dev/null +++ b/src/web/src/components/community/settings/server-settings-channels-access.dom.test.tsx @@ -0,0 +1,68 @@ +import { createElement, type PropsWithChildren } from "react" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { describe, expect, it, vi } from "vitest" +import { render, screen, setupUser, waitFor } from "@/test/react-dom-harness" +import { tid } from "@/lib/community/testids" + +vi.mock("./settings-shell.module.css", () => ({ 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..309d79e9f --- /dev/null +++ b/src/web/src/components/community/settings/server-settings-channels.dom.test.tsx @@ -0,0 +1,64 @@ +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" }, 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") + 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.queryByRole("button")).not.toBeInTheDocument() + expect(screen.getAllByRole("listitem")).toHaveLength(2) + }) + + 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..4ac9eb281 --- /dev/null +++ b/src/web/src/components/community/settings/server-settings-channels.tsx @@ -0,0 +1,59 @@ +"use client" + +import { Button } from "@/components/ui/button" +import { ChannelPickerLabel } from "../channels/channel-picker-label" +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", channels: [] } + group.channels.push(channel) + groups.set(key, group) + } + return [...groups.entries()] +} + +export function ServerSettingsChannels({ serverId }: { serverId: string }) { + 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]) => ( +
+

{group.name}

+ +
    + {group.channels.map((channel) => ( +
  • +
    +
    + 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..e12ff9259 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. @@ -83,6 +87,7 @@ export function ServerSettings({ const nav: SettingsShellTab[] = [ { value: "overview", label: "Overview", icon: Settings }, + ...(isAdmin ? [{ value: "channels" as const, label: "Channels", icon: Hash }] : []), { value: "members", label: "Members", icon: Users }, { 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..c202880d1 --- /dev/null +++ b/src/web/src/hooks/community/use-server-admin-channels.ts @@ -0,0 +1,35 @@ +"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 + category: { id: string; name: string } | 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..361065111 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,53 @@ 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, "Private", { private: true }) + const privateId = await seedChannel("bob", serverId, "bob-private", "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[] = [] + page.on("request", (request) => { + if (new URL(request.url()).pathname === adminPath) requests.push(request.url()) + }) + 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("bob-private") + 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(requests).toHaveLength(1) + 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 have no settings entry or admin list access", 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 expect(page.getByTestId(tid.serverSettingsOpen)).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) + }) }) From 8298b13f2eafa200e08470c49b4667ee2e25ea22 Mon Sep 17 00:00:00 2001 From: Gus Date: Thu, 1 Oct 2026 23:57:33 +0800 Subject: [PATCH 2/9] feat(web): add admin-only server channel directory --- .../ci/fixtures/d1-dynamic-bind-sites.json | 8 ++ .../src/db/queries/community/channel.ts | 35 ++++++- .../community-channel-admin-directory.test.ts | 79 ++++++++++++++++ .../servers/[id]/channels/admin/route.test.ts | 92 +++++++++++++++++++ .../servers/[id]/channels/admin/route.ts | 25 +++++ 5 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 src/shared/test/queries/community-channel-admin-directory.test.ts create mode 100644 src/web/src/app/api/community/servers/[id]/channels/admin/route.test.ts create mode 100644 src/web/src/app/api/community/servers/[id]/channels/admin/route.ts 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..1775889bc 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,38 @@ 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, + category: { id: communityCategory.id, name: communityCategory.name }, + 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, ...row }) => ({ + ...row, + 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..2bbf43aef --- /dev/null +++ b/src/shared/test/queries/community-channel-admin-directory.test.ts @@ -0,0 +1,79 @@ +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", category: null, creator: null, createdAt: "2026-10-01T00:00:00.000Z", + }); + expect(rows[1]).toEqual({ + id: "private-forum", name: "hidden-forum", category: { id: "private", name: "PRIVATE" }, + creator: { name: "Alice", handle: "Alice#0042" }, createdAt: "2026-10-01T02:00:00.000Z", + }); + 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..37e60915a --- /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", category: { id: "group", name: "PRIVATE" }, + 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 }) +}) From 8a393dd86ce571f4339bac775720baab73672fd6 Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 2 Oct 2026 00:10:57 +0800 Subject: [PATCH 3/9] test(web): avoid default category collision in channel directory QA --- src/web/src/test/e2e-ui/06-channel-member-admin.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 361065111..0678e802c 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 @@ -32,7 +32,7 @@ test.describe.serial("channel & member admin", () => { }) test("Channels loads only on selection and lists private metadata without granting content access", async ({ asUser }) => { - const categoryId = await seedCategory("alice", serverId, "Private", { private: true }) + const categoryId = await seedCategory("alice", serverId, `Directory QA ${Date.now()}`, { private: true }) const privateId = await seedChannel("bob", serverId, "bob-private", "forum", categoryId) const root = await seedMessage("alice", channelId, "Thread root") const childId = await seedThread("alice", root, "Child excluded") From c20076d50cb1fecda840de273d23e685c2926e19 Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 2 Oct 2026 00:13:11 +0800 Subject: [PATCH 4/9] test(web): count delivered admin list requests in dev QA --- .../src/test/e2e-ui/06-channel-member-admin.spec.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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 0678e802c..727ed6f08 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 @@ -39,9 +39,17 @@ test.describe.serial("channel & member admin", () => { 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) @@ -62,7 +70,8 @@ test.describe.serial("channel & member admin", () => { 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(requests).toHaveLength(1) + expect(responses).toEqual([200]) + await expect.poll(() => requests.length - abortedRequests).toBe(1) const messages = await page.request.get(`/api/community/channels/${privateId}/messages`) expect(messages.status()).toBe(403) await page.getByTestId(tid.settingsClose).click() From 22c81851f280d54733db3e9b0966ca0bec06cb35 Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 2 Oct 2026 00:14:55 +0800 Subject: [PATCH 5/9] test(web): verify member settings permission at the open handler --- src/web/src/test/e2e-ui/06-channel-member-admin.spec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 727ed6f08..04dc41334 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 @@ -78,12 +78,13 @@ test.describe.serial("channel & member admin", () => { await expect(page.getByTestId(tid.channelRow(privateId))).toHaveCount(0) }) - test("ordinary members have no settings entry or admin list access", async ({ asUser }) => { + 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 expect(page.getByTestId(tid.serverSettingsOpen)).toHaveCount(0) + 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) From a73ec5bbf7ebae30fc891f9a6f62309f3bea64fa Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 2 Oct 2026 00:45:17 +0800 Subject: [PATCH 6/9] fix(web): reuse sidebar category panels in channel settings --- .../src/db/queries/community/channel.ts | 6 +- .../community-channel-admin-directory.test.ts | 5 +- .../servers/[id]/channels/admin/route.test.ts | 2 +- .../channels/channel-category.dom.test.tsx | 34 ++++++++ .../community/channels/channel-category.tsx | 52 ++++++++++++ .../channels/channel-picker-label.tsx | 15 ---- .../community/channels/channel-row.tsx | 17 ++++ .../community/channels/sortable-category.tsx | 80 +++++-------------- .../community/channels/sortable-channel.tsx | 31 +++---- .../messages/composer-suggestion-popups.tsx | 15 +++- .../server-settings-channels.dom.test.tsx | 22 ++++- .../settings/server-settings-channels.tsx | 61 ++++++++------ .../community/use-server-admin-channels.ts | 3 +- .../e2e-ui/06-channel-member-admin.spec.ts | 29 ++++++- 14 files changed, 239 insertions(+), 133 deletions(-) create mode 100644 src/web/src/components/community/channels/channel-category.dom.test.tsx create mode 100644 src/web/src/components/community/channels/channel-category.tsx delete mode 100644 src/web/src/components/community/channels/channel-picker-label.tsx create mode 100644 src/web/src/components/community/channels/channel-row.tsx diff --git a/src/shared/src/db/queries/community/channel.ts b/src/shared/src/db/queries/community/channel.ts index 1775889bc..214993779 100644 --- a/src/shared/src/db/queries/community/channel.ts +++ b/src/shared/src/db/queries/community/channel.ts @@ -327,7 +327,8 @@ export async function listServerChannelDirectoryForAdmin(db: Database, serverId: .select({ id: communityChannel.id, name: communityChannel.name, - category: { id: communityCategory.id, name: communityCategory.name }, + type: communityChannel.type, + category: { id: communityCategory.id, name: communityCategory.name, private: communityCategory.private }, creator: { name: user.name, discriminator: user.discriminator }, createdAt: communityChannel.createdAt, }) @@ -348,8 +349,9 @@ export async function listServerChannelDirectoryForAdmin(db: Database, serverId: inArray(communityChannel.type, ["text", "forum"]), )) .orderBy(asc(communityCategory.position), asc(communityCategory.id), asc(communityChannel.position), asc(communityChannel.id)); - return rows.map(({ creator, ...row }) => ({ + 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, })); } diff --git a/src/shared/test/queries/community-channel-admin-directory.test.ts b/src/shared/test/queries/community-channel-admin-directory.test.ts index 2bbf43aef..7e0801e9a 100644 --- a/src/shared/test/queries/community-channel-admin-directory.test.ts +++ b/src/shared/test/queries/community-channel-admin-directory.test.ts @@ -51,12 +51,13 @@ describe("listServerChannelDirectoryForAdmin", () => { "uncat", "private-forum", "private-text", "public-text", "deleted-creator", "missing-creator", ]); expect(rows[0]).toEqual({ - id: "uncat", name: "uncategorized", category: null, creator: null, createdAt: "2026-10-01T00:00:00.000Z", + 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", category: { id: "private", name: "PRIVATE" }, + 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/); 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 index 37e60915a..01d20006f 100644 --- 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 @@ -45,7 +45,7 @@ describe("GET server admin channel directory", () => { 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", category: { id: "group", name: "PRIVATE" }, + 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) diff --git a/src/web/src/components/community/channels/channel-category.dom.test.tsx b/src/web/src/components/community/channels/channel-category.dom.test.tsx new file mode 100644 index 000000000..76645ea85 --- /dev/null +++ b/src/web/src/components/community/channels/channel-category.dom.test.tsx @@ -0,0 +1,34 @@ +import { createElement, useState } from "react" +import { describe, expect, it, vi } from "vitest" +import { render, screen, setupUser } from "@/test/react-dom-harness" +import { SortableCategory } from "./sortable-category" + +function SidebarGroup({ onSettings }: { onSettings?: () => void }) { + const [open, setOpen] = useState(true) + return setOpen((value) => !value)} onSettings={onSettings}>sidebar channel +} + +describe("shared category in the sidebar", () => { + it("keeps the header, icon and collapse when rendered through its context menu", async () => { + const onSettings = vi.fn() + const user = setupUser() + render(createElement(SidebarGroup, { onSettings })) + const header = screen.getByRole("button", { name: "Group: PRIVATE" }) + expect(screen.getByLabelText("Private group")).toBeVisible() + await user.click(screen.getByRole("button", { name: "Category settings for PRIVATE" })) + expect(onSettings).toHaveBeenCalledOnce() + expect(header).toHaveAttribute("aria-expanded", "true") + await user.click(header) + expect(screen.queryByText("sidebar channel")).not.toBeInTheDocument() + await user.click(header) + expect(screen.getByText("sidebar channel")).toBeVisible() + }) + + it("allows keyboard collapse without a management menu", async () => { + const user = setupUser() + render(createElement(SidebarGroup)) + await user.tab() + await user.keyboard(" ") + expect(screen.getByRole("button", { name: "Group: PRIVATE" })).toHaveAttribute("aria-expanded", "false") + }) +}) diff --git a/src/web/src/components/community/channels/channel-category.tsx b/src/web/src/components/community/channels/channel-category.tsx new file mode 100644 index 000000000..783597fb8 --- /dev/null +++ b/src/web/src/components/community/channels/channel-category.tsx @@ -0,0 +1,52 @@ +"use client" + +import type React from "react" +import { ChevronDown, Globe, Lock, Plus, Settings } from "lucide-react" + +export function ChannelCategory({ name, open, onToggle, isPrivate, onSettings, onAddChannel, headerProps, renderHeader, highlighted, children }: { + name: string + open: boolean + onToggle: () => void + isPrivate?: boolean + onSettings?: () => void + onAddChannel?: () => void + headerProps?: React.ComponentPropsWithRef<"div"> + renderHeader?: (header: React.ReactElement) => React.ReactNode + highlighted?: boolean + children: React.ReactNode +}) { + const header = ( +
{ + if (event.target === event.currentTarget && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); onToggle() } + }} + {...headerProps} + onClick={onToggle} + className={`group flex w-full touch-manipulation items-center gap-1 rounded px-1 py-1 text-xs font-semibold text-muted-foreground/80 select-none hover:text-foreground ${headerProps?.className ?? "cursor-pointer"}`} + > + {isPrivate ? : } + {name} + {onSettings && ( + + )} + {onAddChannel && ( + + )} + +
+ ) + return ( + <> + {renderHeader ? renderHeader(header) : header} + {open &&
{children}
} + + ) +} diff --git a/src/web/src/components/community/channels/channel-picker-label.tsx b/src/web/src/components/community/channels/channel-picker-label.tsx deleted file mode 100644 index bd6a0303a..000000000 --- a/src/web/src/components/community/channels/channel-picker-label.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { ChannelIcon } from "./channel-icon" - -export function ChannelPickerLabel({ name, serverName }: { name: string; serverName?: string }) { - return ( - <> - - - - - {serverName && {serverName} / } - {name} - - - ) -} diff --git a/src/web/src/components/community/channels/channel-row.tsx b/src/web/src/components/community/channels/channel-row.tsx new file mode 100644 index 000000000..c91de8f50 --- /dev/null +++ b/src/web/src/components/community/channels/channel-row.tsx @@ -0,0 +1,17 @@ +import type React from "react" +import type { EntityKind } from "@/lib/community/models/navigation" +import { EntityIcon } from "../entity-icon" + +export function ChannelRow({ name, kind, wrapName, className, children, ...props }: React.ComponentPropsWithRef<"div"> & { + name: string + kind?: EntityKind + wrapName?: boolean +}) { + return ( +
+ + {name} + {children} +
+ ) +} diff --git a/src/web/src/components/community/channels/sortable-category.tsx b/src/web/src/components/community/channels/sortable-category.tsx index 5b10d9a68..68e910950 100644 --- a/src/web/src/components/community/channels/sortable-category.tsx +++ b/src/web/src/components/community/channels/sortable-category.tsx @@ -2,12 +2,13 @@ import { useState } from "react" import type React from "react" -import { ChevronDown, Plus, Settings, Lock, Trash2 } from "lucide-react" +import { Plus, Settings, Trash2 } from "lucide-react" import { useSortable } from "@dnd-kit/sortable" import { CSS } from "@dnd-kit/utilities" import { ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu" import { ConfirmDialog } from "@/components/ui/confirm-dialog" import { DropLine } from "../drop-line" +import { ChannelCategory } from "./channel-category" // True when the category header has at least one right-click action. With none, we skip // the ContextMenu wrapper so a non-admin doesn't get an empty popover strip. @@ -47,69 +48,28 @@ export function SortableCategory({ id: catDndId, name, open, onToggle, onAddChan onTouchStartCapture: listeners?.onTouchStart ? (event: React.TouchEvent) => listeners.onTouchStart?.(event) : undefined, - onClick: onToggle, - className: `group flex w-full touch-manipulation items-center gap-1 rounded px-1 py-1 text-xs font-semibold text-muted-foreground/80 select-none hover:text-foreground ${canReorder ? "cursor-grab active:cursor-grabbing" : "cursor-pointer"}`, + className: canReorder ? "cursor-grab active:cursor-grabbing" : "cursor-pointer", } - const headerInner = ( - <> - {/* Always reserve the lock's slot so public + private category names share - the same left edge (private shows the Lock, public a same-size spacer). */} - {isPrivate ? :