From 775236b4830b9b2a7bcba4c598cfbd9ecdf5d508 Mon Sep 17 00:00:00 2001 From: benya Date: Sun, 8 Mar 2026 21:26:10 +0300 Subject: [PATCH] feat(web): add banned users section in chat info moderation --- docs/api-reference.md | 11 ++++ docs/core-checklist-status.md | 2 +- web/src/api/chats.ts | 6 +++ web/src/chat/types.ts | 7 +++ web/src/components/ChatInfoPanel.tsx | 81 ++++++++++++++++++++++++++-- 5 files changed, 103 insertions(+), 4 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 78c0c7b..71eace6 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -285,6 +285,17 @@ Rules: } ``` +### ChatBanRead + +```json +{ + "chat_id": 42, + "user_id": 101, + "banned_by_user_id": 5, + "created_at": "2026-03-10T00:00:00Z" +} +``` + ## 3.4 Messages ### MessageRead diff --git a/docs/core-checklist-status.md b/docs/core-checklist-status.md index 68e5365..30334b8 100644 --- a/docs/core-checklist-status.md +++ b/docs/core-checklist-status.md @@ -31,7 +31,7 @@ Legend: 22. Text Formatting - `PARTIAL` (bold/italic/underline/spoiler/mono/links + strikethrough + quote/code block; toolbar still evolving) 23. Groups - `PARTIAL` (create/add/remove/invite link; join-by-invite and invite permissions covered by integration tests; members API now returns profile fields (`username/name/avatar_url`) for richer moderation UI; advanced moderation still partial) 24. Roles - `DONE` (owner/admin/member) -25. Admin Rights - `PARTIAL` (delete/pin/edit info + explicit ban APIs for groups/channels including ban list endpoint; integration tests cover channel member read-only, channel admin full-delete, channel message delete-for-all permissions, group profile edit permissions, owner-only role management rules, and admin-visible/member-forbidden ban-list access; remaining UX moderation tools limited) +25. Admin Rights - `PARTIAL` (delete/pin/edit info + explicit ban APIs for groups/channels including ban list endpoint; web Chat Info now shows `Banned users` with `Unban` action for owner/admin; integration tests cover channel member read-only, channel admin full-delete, channel message delete-for-all permissions, group profile edit permissions, owner-only role management rules, and admin-visible/member-forbidden ban-list access; remaining UX moderation tools limited) 26. Channels - `PARTIAL` (create/post/edit/delete/subscribe/unsubscribe; integration tests now also cover invite-link permissions (member forbidden, admin allowed); UX edge-cases still polishing) 27. Channel Types - `DONE` (public/private) 28. Notifications - `PARTIAL` (browser notifications + mute/settings; chat mute is propagated in chat list payload, honored by web realtime notifications with mention override, and mute toggle now syncs instantly in chat store; backend now emits `chat_updated` after notification mute/unmute for cross-tab consistency; no mobile push infra) diff --git a/web/src/api/chats.ts b/web/src/api/chats.ts index 0ec4767..a442ff9 100644 --- a/web/src/api/chats.ts +++ b/web/src/api/chats.ts @@ -2,6 +2,7 @@ import { http } from "./http"; import type { Chat, ChatAttachment, + ChatBan, ChatDetail, ChatInviteLink, ChatMember, @@ -299,6 +300,11 @@ export async function listChatMembers(chatId: number): Promise { return data; } +export async function listChatBans(chatId: number): Promise { + const { data } = await http.get(`/chats/${chatId}/bans`); + return data; +} + export async function updateChatTitle(chatId: number, title: string): Promise { const { data } = await http.patch(`/chats/${chatId}/title`, { title }); return data; diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index 415d6be..1b92275 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -48,6 +48,13 @@ export interface ChatMember { joined_at: string; } +export interface ChatBan { + chat_id: number; + user_id: number; + banned_by_user_id: number; + created_at: string; +} + export interface ChatDetail extends Chat { members: ChatMember[]; } diff --git a/web/src/components/ChatInfoPanel.tsx b/web/src/components/ChatInfoPanel.tsx index 2fbf363..e02c104 100644 --- a/web/src/components/ChatInfoPanel.tsx +++ b/web/src/components/ChatInfoPanel.tsx @@ -9,16 +9,18 @@ import { getChatNotificationSettings, getChatDetail, leaveChat, + listChatBans, listChatMembers, requestUploadUrl, removeChatMember, + unbanChatMember, updateChatProfile, updateChatNotificationSettings, updateChatMemberRole, uploadToPresignedUrl } from "../api/chats"; import { blockUser, getUserById, listBlockedUsers, searchUsers, unblockUser } from "../api/users"; -import type { AuthUser, ChatAttachment, ChatDetail, ChatMember, Message, UserSearchItem } from "../chat/types"; +import type { AuthUser, ChatAttachment, ChatBan, ChatDetail, ChatMember, Message, UserSearchItem } from "../chat/types"; import { useAuthStore } from "../store/authStore"; import { useChatStore } from "../store/chatStore"; import { useUiStore } from "../store/uiStore"; @@ -59,6 +61,8 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) { const [linkItems, setLinkItems] = useState>([]); const [attachmentCtx, setAttachmentCtx] = useState<{ x: number; y: number; url: string; messageId: number } | null>(null); const [memberCtx, setMemberCtx] = useState<{ x: number; y: number; member: ChatMember } | null>(null); + const [bans, setBans] = useState([]); + const [bannedUsers, setBannedUsers] = useState>({}); const [attachmentsTab, setAttachmentsTab] = useState<"all" | "photos" | "videos" | "audio" | "links" | "voice">("all"); const [mediaViewer, setMediaViewer] = useState<{ items: Array<{ url: string; type: "image" | "video"; messageId: number }>; index: number } | null>(null); const [avatarCropFile, setAvatarCropFile] = useState(null); @@ -96,7 +100,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) { ); const allAttachmentItems = useMemo(() => [...attachments].sort((a, b) => b.id - a.id), [attachments]); - async function refreshMembers(targetChatId: number) { + async function refreshMembers(targetChatId: number): Promise { const nextMembers = await listChatMembers(targetChatId); setMembers(nextMembers); const ids = [...new Set(nextMembers.map((m) => m.user_id))]; @@ -106,6 +110,27 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) { byId[profile.id] = profile; } setMemberUsers(byId); + return nextMembers; + } + + async function refreshBans(targetChatId: number, allowFailure = true) { + try { + const nextBans = await listChatBans(targetChatId); + setBans(nextBans); + const ids = [...new Set(nextBans.map((item) => item.user_id))]; + const profiles = await Promise.all(ids.map((id) => getUserById(id))); + const byId: Record = {}; + for (const profile of profiles) { + byId[profile.id] = profile; + } + setBannedUsers(byId); + } catch { + setBans([]); + setBannedUsers({}); + if (!allowFailure) { + throw new Error("failed_to_load_bans"); + } + } } async function refreshPanelData(targetChatId: number, withLoading = false) { @@ -134,7 +159,15 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) { setCounterpartProfile(null); setCounterpartBlocked(false); } - await refreshMembers(targetChatId); + const nextMembers = await refreshMembers(targetChatId); + const resolvedRole = String(detail.my_role ?? nextMembers.find((m) => m.user_id === me?.id)?.role ?? "").toLowerCase(); + const canLoadBans = (detail.type === "group" || detail.type === "channel") && (resolvedRole === "owner" || resolvedRole === "admin"); + if (canLoadBans) { + await refreshBans(targetChatId); + } else { + setBans([]); + setBannedUsers({}); + } const chatAttachments = await getChatAttachments(targetChatId, 120); const messages = await getRecentMessagesForLinks(targetChatId); setAttachments(chatAttachments); @@ -202,6 +235,8 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) { setInviteLink(null); setMembers([]); setMemberUsers({}); + setBans([]); + setBannedUsers({}); setSearchQuery(""); setSearchResults([]); }, [chatId, open]); @@ -512,6 +547,41 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) { ) : null} + {showMembersSection && canManageMembers ? ( +
+

Banned users ({bans.length})

+ {bans.length === 0 ?

No banned users

: null} +
+ {bans.map((ban) => { + const user = bannedUsers[ban.user_id]; + return ( +
+
+

{user?.name || `user #${ban.user_id}`}

+

@{user?.username || "unknown"}

+
+ +
+ ); + })} +
+
+ ) : null}

@@ -758,6 +828,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) { try { await updateChatMemberRole(chatId, memberCtx.member.user_id, "admin"); await refreshMembers(chatId); + await refreshBans(chatId); } catch { setError("Failed to update role"); } finally { @@ -776,6 +847,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) { try { await updateChatMemberRole(chatId, memberCtx.member.user_id, "member"); await refreshMembers(chatId); + await refreshBans(chatId); } catch { setError("Failed to update role"); } finally { @@ -794,6 +866,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) { try { await updateChatMemberRole(chatId, memberCtx.member.user_id, "owner"); await refreshMembers(chatId); + await refreshBans(chatId); } catch { setError("Failed to transfer ownership"); } finally { @@ -812,6 +885,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) { try { await banChatMember(chatId, memberCtx.member.user_id); await refreshMembers(chatId); + await refreshBans(chatId); } catch { setError("Failed to ban member"); } finally { @@ -830,6 +904,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) { try { await removeChatMember(chatId, memberCtx.member.user_id); await refreshMembers(chatId); + await refreshBans(chatId); } catch { setError("Failed to remove member"); } finally {