moderation: add chat bans for groups/channels with web actions
All checks were successful
CI / test (push) Successful in 26s
All checks were successful
CI / test (push) Successful in 26s
This commit is contained in:
46
alembic/versions/0024_chat_bans.py
Normal file
46
alembic/versions/0024_chat_bans.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""add chat bans for moderation
|
||||||
|
|
||||||
|
Revision ID: 0024_chat_bans
|
||||||
|
Revises: 0023_privacy_pm_level
|
||||||
|
Create Date: 2026-03-09 01:10:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "0024_chat_bans"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "0023_privacy_pm_level"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"chat_bans",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("chat_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("banned_by_user_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["chat_id"], ["chats.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["banned_by_user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("chat_id", "user_id", name="uq_chat_bans_chat_user"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_chat_bans_id", "chat_bans", ["id"], unique=False)
|
||||||
|
op.create_index("ix_chat_bans_chat_id", "chat_bans", ["chat_id"], unique=False)
|
||||||
|
op.create_index("ix_chat_bans_user_id", "chat_bans", ["user_id"], unique=False)
|
||||||
|
op.create_index("ix_chat_bans_banned_by_user_id", "chat_bans", ["banned_by_user_id"], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_chat_bans_banned_by_user_id", table_name="chat_bans")
|
||||||
|
op.drop_index("ix_chat_bans_user_id", table_name="chat_bans")
|
||||||
|
op.drop_index("ix_chat_bans_chat_id", table_name="chat_bans")
|
||||||
|
op.drop_index("ix_chat_bans_id", table_name="chat_bans")
|
||||||
|
op.drop_table("chat_bans")
|
||||||
|
|
||||||
@@ -108,3 +108,14 @@ class ChatInviteLink(Base):
|
|||||||
token: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
token: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class ChatBan(Base):
|
||||||
|
__tablename__ = "chat_bans"
|
||||||
|
__table_args__ = (UniqueConstraint("chat_id", "user_id", name="uq_chat_bans_chat_user"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||||
|
chat_id: Mapped[int] = mapped_column(ForeignKey("chats.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
banned_by_user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from sqlalchemy import Select, String, func, or_, select
|
|||||||
from sqlalchemy.orm import aliased
|
from sqlalchemy.orm import aliased
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.chats.models import Chat, ChatInviteLink, ChatMember, ChatMemberRole, ChatNotificationSetting, ChatType, ChatUserSetting
|
from app.chats.models import Chat, ChatBan, ChatInviteLink, ChatMember, ChatMemberRole, ChatNotificationSetting, ChatType, ChatUserSetting
|
||||||
from app.messages.models import Message, MessageHidden, MessageReceipt
|
from app.messages.models import Message, MessageHidden, MessageReceipt
|
||||||
|
|
||||||
|
|
||||||
@@ -401,3 +401,31 @@ async def get_active_chat_invite_by_token(db: AsyncSession, *, token: str) -> Ch
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_chat_ban(db: AsyncSession, *, chat_id: int, user_id: int) -> ChatBan | None:
|
||||||
|
result = await db.execute(select(ChatBan).where(ChatBan.chat_id == chat_id, ChatBan.user_id == user_id))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def is_user_banned_in_chat(db: AsyncSession, *, chat_id: int, user_id: int) -> bool:
|
||||||
|
result = await db.execute(select(ChatBan.id).where(ChatBan.chat_id == chat_id, ChatBan.user_id == user_id).limit(1))
|
||||||
|
return result.scalar_one_or_none() is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_chat_ban(db: AsyncSession, *, chat_id: int, user_id: int, banned_by_user_id: int) -> ChatBan:
|
||||||
|
existing = await get_chat_ban(db, chat_id=chat_id, user_id=user_id)
|
||||||
|
if existing:
|
||||||
|
existing.banned_by_user_id = banned_by_user_id
|
||||||
|
await db.flush()
|
||||||
|
return existing
|
||||||
|
ban = ChatBan(chat_id=chat_id, user_id=user_id, banned_by_user_id=banned_by_user_id)
|
||||||
|
db.add(ban)
|
||||||
|
await db.flush()
|
||||||
|
return ban
|
||||||
|
|
||||||
|
|
||||||
|
async def remove_chat_ban(db: AsyncSession, *, chat_id: int, user_id: int) -> None:
|
||||||
|
ban = await get_chat_ban(db, chat_id=chat_id, user_id=user_id)
|
||||||
|
if ban:
|
||||||
|
await db.delete(ban)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from app.chats.schemas import (
|
|||||||
)
|
)
|
||||||
from app.chats.service import (
|
from app.chats.service import (
|
||||||
add_chat_member_for_user,
|
add_chat_member_for_user,
|
||||||
|
ban_chat_member_for_user,
|
||||||
create_chat_for_user,
|
create_chat_for_user,
|
||||||
create_chat_invite_link_for_user,
|
create_chat_invite_link_for_user,
|
||||||
clear_chat_for_user,
|
clear_chat_for_user,
|
||||||
@@ -39,6 +40,7 @@ from app.chats.service import (
|
|||||||
serialize_chats_for_user,
|
serialize_chats_for_user,
|
||||||
set_chat_archived_for_user,
|
set_chat_archived_for_user,
|
||||||
set_chat_pinned_for_user,
|
set_chat_pinned_for_user,
|
||||||
|
unban_chat_member_for_user,
|
||||||
update_chat_member_role_for_user,
|
update_chat_member_role_for_user,
|
||||||
update_chat_notification_settings_for_user,
|
update_chat_notification_settings_for_user,
|
||||||
update_chat_profile_for_user,
|
update_chat_profile_for_user,
|
||||||
@@ -207,6 +209,29 @@ async def remove_chat_member(
|
|||||||
await realtime_gateway.publish_chat_updated(chat_id=chat_id)
|
await realtime_gateway.publish_chat_updated(chat_id=chat_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{chat_id}/bans/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def ban_chat_member(
|
||||||
|
chat_id: int,
|
||||||
|
user_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> None:
|
||||||
|
await ban_chat_member_for_user(db, chat_id=chat_id, actor_user_id=current_user.id, target_user_id=user_id)
|
||||||
|
realtime_gateway.remove_chat_subscription(chat_id=chat_id, user_id=user_id)
|
||||||
|
await realtime_gateway.publish_chat_updated(chat_id=chat_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{chat_id}/bans/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def unban_chat_member(
|
||||||
|
chat_id: int,
|
||||||
|
user_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> None:
|
||||||
|
await unban_chat_member_for_user(db, chat_id=chat_id, actor_user_id=current_user.id, target_user_id=user_id)
|
||||||
|
await realtime_gateway.publish_chat_updated(chat_id=chat_id)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{chat_id}/leave", status_code=status.HTTP_204_NO_CONTENT)
|
@router.post("/{chat_id}/leave", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def leave_chat(
|
async def leave_chat(
|
||||||
chat_id: int,
|
chat_id: int,
|
||||||
|
|||||||
@@ -350,6 +350,8 @@ async def add_chat_member_for_user(
|
|||||||
chat, membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=actor_user_id)
|
chat, membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=actor_user_id)
|
||||||
_ensure_group_or_channel(chat.type)
|
_ensure_group_or_channel(chat.type)
|
||||||
_ensure_manage_permission(membership.role)
|
_ensure_manage_permission(membership.role)
|
||||||
|
if await repository.is_user_banned_in_chat(db, chat_id=chat_id, user_id=target_user_id):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is banned from this chat")
|
||||||
if target_user_id == actor_user_id:
|
if target_user_id == actor_user_id:
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="User is already in chat")
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="User is already in chat")
|
||||||
target_user = await get_user_by_id(db, target_user_id)
|
target_user = await get_user_by_id(db, target_user_id)
|
||||||
@@ -427,6 +429,45 @@ async def remove_chat_member_for_user(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def ban_chat_member_for_user(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
chat_id: int,
|
||||||
|
actor_user_id: int,
|
||||||
|
target_user_id: int,
|
||||||
|
) -> None:
|
||||||
|
chat, actor_membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=actor_user_id)
|
||||||
|
_ensure_group_or_channel(chat.type)
|
||||||
|
if actor_membership.role not in {ChatMemberRole.OWNER, ChatMemberRole.ADMIN}:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
|
||||||
|
if target_user_id == actor_user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Cannot ban yourself")
|
||||||
|
target_membership = await repository.get_chat_member(db, chat_id=chat_id, user_id=target_user_id)
|
||||||
|
if target_membership:
|
||||||
|
if target_membership.role == ChatMemberRole.OWNER:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Owner cannot be banned")
|
||||||
|
if actor_membership.role == ChatMemberRole.ADMIN and target_membership.role != ChatMemberRole.MEMBER:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin can ban only members")
|
||||||
|
await repository.delete_chat_member(db, target_membership)
|
||||||
|
await repository.upsert_chat_ban(db, chat_id=chat_id, user_id=target_user_id, banned_by_user_id=actor_user_id)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def unban_chat_member_for_user(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
chat_id: int,
|
||||||
|
actor_user_id: int,
|
||||||
|
target_user_id: int,
|
||||||
|
) -> None:
|
||||||
|
chat, actor_membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=actor_user_id)
|
||||||
|
_ensure_group_or_channel(chat.type)
|
||||||
|
if actor_membership.role not in {ChatMemberRole.OWNER, ChatMemberRole.ADMIN}:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
|
||||||
|
await repository.remove_chat_ban(db, chat_id=chat_id, user_id=target_user_id)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
async def leave_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: int) -> None:
|
async def leave_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: int) -> None:
|
||||||
chat, membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=user_id)
|
chat, membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=user_id)
|
||||||
_ensure_group_or_channel(chat.type)
|
_ensure_group_or_channel(chat.type)
|
||||||
@@ -514,6 +555,8 @@ async def join_public_chat_for_user(db: AsyncSession, *, chat_id: int, user_id:
|
|||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found")
|
||||||
if not chat.is_public or chat.type not in {ChatType.GROUP, ChatType.CHANNEL}:
|
if not chat.is_public or chat.type not in {ChatType.GROUP, ChatType.CHANNEL}:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Chat is not joinable")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Chat is not joinable")
|
||||||
|
if await repository.is_user_banned_in_chat(db, chat_id=chat_id, user_id=user_id):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You are banned from this chat")
|
||||||
membership = await repository.get_chat_member(db, chat_id=chat_id, user_id=user_id)
|
membership = await repository.get_chat_member(db, chat_id=chat_id, user_id=user_id)
|
||||||
if membership:
|
if membership:
|
||||||
return chat
|
return chat
|
||||||
@@ -650,6 +693,8 @@ async def join_chat_by_invite_for_user(
|
|||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found")
|
||||||
if chat.type not in {ChatType.GROUP, ChatType.CHANNEL}:
|
if chat.type not in {ChatType.GROUP, ChatType.CHANNEL}:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite link is not valid for this chat")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite link is not valid for this chat")
|
||||||
|
if await repository.is_user_banned_in_chat(db, chat_id=chat.id, user_id=user_id):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You are banned from this chat")
|
||||||
membership = await repository.get_chat_member(db, chat_id=chat.id, user_id=user_id)
|
membership = await repository.get_chat_member(db, chat_id=chat.id, user_id=user_id)
|
||||||
if membership:
|
if membership:
|
||||||
return chat
|
return chat
|
||||||
|
|||||||
@@ -747,6 +747,17 @@ Response: `200` + `ChatMemberRead`
|
|||||||
Auth required.
|
Auth required.
|
||||||
Response: `204`
|
Response: `204`
|
||||||
|
|
||||||
|
### POST `/api/v1/chats/{chat_id}/bans/{user_id}`
|
||||||
|
|
||||||
|
Auth required (`owner/admin` in group/channel).
|
||||||
|
Response: `204`
|
||||||
|
Behavior: bans user from chat and removes membership if present.
|
||||||
|
|
||||||
|
### DELETE `/api/v1/chats/{chat_id}/bans/{user_id}`
|
||||||
|
|
||||||
|
Auth required (`owner/admin` in group/channel).
|
||||||
|
Response: `204`
|
||||||
|
|
||||||
### POST `/api/v1/chats/{chat_id}/leave`
|
### POST `/api/v1/chats/{chat_id}/leave`
|
||||||
|
|
||||||
Auth required.
|
Auth required.
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ Legend:
|
|||||||
22. Text Formatting - `PARTIAL` (bold/italic/underline/spoiler/mono/links + strikethrough + quote/code block; toolbar still evolving)
|
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; advanced moderation partial)
|
23. Groups - `PARTIAL` (create/add/remove/invite link; advanced moderation partial)
|
||||||
24. Roles - `DONE` (owner/admin/member)
|
24. Roles - `DONE` (owner/admin/member)
|
||||||
25. Admin Rights - `PARTIAL` (delete/ban-like remove/pin/edit info; full ban system limited)
|
25. Admin Rights - `PARTIAL` (delete/pin/edit info + explicit ban API for groups/channels; remaining UX moderation tools limited)
|
||||||
26. Channels - `PARTIAL` (create/post/edit/delete/subscribe/unsubscribe; UX edge-cases still polishing)
|
26. Channels - `PARTIAL` (create/post/edit/delete/subscribe/unsubscribe; UX edge-cases still polishing)
|
||||||
27. Channel Types - `DONE` (public/private)
|
27. Channel Types - `DONE` (public/private)
|
||||||
28. Notifications - `PARTIAL` (browser notifications + mute/settings; no mobile push infra)
|
28. Notifications - `PARTIAL` (browser notifications + mute/settings; no mobile push infra)
|
||||||
|
|||||||
@@ -96,3 +96,31 @@ async def test_private_chat_respects_contacts_only_policy(client, db_session):
|
|||||||
json={"type": ChatType.PRIVATE.value, "title": None, "member_ids": [u2_id]},
|
json={"type": ChatType.PRIVATE.value, "title": None, "member_ids": [u2_id]},
|
||||||
)
|
)
|
||||||
assert create_chat_allowed.status_code == 200
|
assert create_chat_allowed.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
async def test_group_ban_blocks_rejoin(client, db_session):
|
||||||
|
owner = await _create_verified_user(client, db_session, "ban_owner@example.com", "ban_owner", "strongpass123")
|
||||||
|
member = await _create_verified_user(client, db_session, "ban_member@example.com", "ban_member", "strongpass123")
|
||||||
|
|
||||||
|
me_member = await client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {member['access_token']}"})
|
||||||
|
member_id = me_member.json()["id"]
|
||||||
|
|
||||||
|
create_group = await client.post(
|
||||||
|
"/api/v1/chats",
|
||||||
|
headers={"Authorization": f"Bearer {owner['access_token']}"},
|
||||||
|
json={"type": ChatType.GROUP.value, "title": "Test group", "member_ids": [member_id], "is_public": True, "handle": "ban_test_group"},
|
||||||
|
)
|
||||||
|
assert create_group.status_code == 200
|
||||||
|
chat_id = create_group.json()["id"]
|
||||||
|
|
||||||
|
ban_response = await client.post(
|
||||||
|
f"/api/v1/chats/{chat_id}/bans/{member_id}",
|
||||||
|
headers={"Authorization": f"Bearer {owner['access_token']}"},
|
||||||
|
)
|
||||||
|
assert ban_response.status_code == 204
|
||||||
|
|
||||||
|
rejoin_response = await client.post(
|
||||||
|
f"/api/v1/chats/{chat_id}/join",
|
||||||
|
headers={"Authorization": f"Bearer {member['access_token']}"},
|
||||||
|
)
|
||||||
|
assert rejoin_response.status_code == 403
|
||||||
|
|||||||
@@ -329,6 +329,14 @@ export async function removeChatMember(chatId: number, userId: number): Promise<
|
|||||||
await http.delete(`/chats/${chatId}/members/${userId}`);
|
await http.delete(`/chats/${chatId}/members/${userId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function banChatMember(chatId: number, userId: number): Promise<void> {
|
||||||
|
await http.post(`/chats/${chatId}/bans/${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unbanChatMember(chatId: number, userId: number): Promise<void> {
|
||||||
|
await http.delete(`/chats/${chatId}/bans/${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function leaveChat(chatId: number): Promise<void> {
|
export async function leaveChat(chatId: number): Promise<void> {
|
||||||
await http.post(`/chats/${chatId}/leave`);
|
await http.post(`/chats/${chatId}/leave`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import {
|
import {
|
||||||
addChatMember,
|
addChatMember,
|
||||||
|
banChatMember,
|
||||||
createInviteLink,
|
createInviteLink,
|
||||||
getChatAttachments,
|
getChatAttachments,
|
||||||
getMessages,
|
getMessages,
|
||||||
@@ -785,6 +786,24 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
|||||||
Transfer ownership
|
Transfer ownership
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{(myRoleNormalized === "owner" || (myRoleNormalized === "admin" && memberCtx.member.role === "member")) ? (
|
||||||
|
<button
|
||||||
|
className="block w-full rounded px-2 py-1.5 text-left text-sm text-red-300 hover:bg-slate-800"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await banChatMember(chatId, memberCtx.member.user_id);
|
||||||
|
await refreshMembers(chatId);
|
||||||
|
} catch {
|
||||||
|
setError("Failed to ban member");
|
||||||
|
} finally {
|
||||||
|
setMemberCtx(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Ban from chat
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
{(myRoleNormalized === "owner" || (myRoleNormalized === "admin" && memberCtx.member.role === "member")) ? (
|
{(myRoleNormalized === "owner" || (myRoleNormalized === "admin" && memberCtx.member.role === "member")) ? (
|
||||||
<button
|
<button
|
||||||
className="block w-full rounded px-2 py-1.5 text-left text-sm text-red-300 hover:bg-slate-800"
|
className="block w-full rounded px-2 py-1.5 text-left text-sm text-red-300 hover:bg-slate-800"
|
||||||
@@ -800,7 +819,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
|||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Remove from chat
|
Remove from chat (without ban)
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user