fix(channel): enforce read-only for members and polish chat info
Some checks failed
CI / test (push) Failing after 26s
Some checks failed
CI / test (push) Failing after 26s
- block send/forward in channels for member role on backend - expose my_role in chat payload for client-side permissions - hide message composer for channel members and show read-only notice - hide members management sections for private/saved chats in info panel - return enriched chat detail via serialize_chat_for_user
This commit is contained in:
@@ -96,13 +96,12 @@ async def get_chat(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
) -> ChatDetailRead:
|
) -> ChatDetailRead:
|
||||||
chat, members = await get_chat_for_user(db, chat_id=chat_id, user_id=current_user.id)
|
chat, members = await get_chat_for_user(db, chat_id=chat_id, user_id=current_user.id)
|
||||||
return ChatDetailRead(
|
base = await serialize_chat_for_user(db, user_id=current_user.id, chat=chat)
|
||||||
id=chat.id,
|
return ChatDetailRead.model_validate(
|
||||||
type=chat.type,
|
{
|
||||||
title=chat.title,
|
**base.model_dump(),
|
||||||
pinned_message_id=chat.pinned_message_id,
|
"members": members,
|
||||||
created_at=chat.created_at,
|
}
|
||||||
members=members,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class ChatRead(BaseModel):
|
|||||||
counterpart_username: str | None = None
|
counterpart_username: str | None = None
|
||||||
counterpart_is_online: bool | None = None
|
counterpart_is_online: bool | None = None
|
||||||
counterpart_last_seen_at: datetime | None = None
|
counterpart_last_seen_at: datetime | None = None
|
||||||
|
my_role: ChatMemberRole | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ from app.users.repository import get_user_by_id
|
|||||||
|
|
||||||
async def serialize_chat_for_user(db: AsyncSession, *, user_id: int, chat: Chat) -> ChatRead:
|
async def serialize_chat_for_user(db: AsyncSession, *, user_id: int, chat: Chat) -> ChatRead:
|
||||||
display_title = chat.title
|
display_title = chat.title
|
||||||
|
my_role = None
|
||||||
|
membership = await repository.get_chat_member(db, chat_id=chat.id, user_id=user_id)
|
||||||
|
if membership:
|
||||||
|
my_role = membership.role
|
||||||
members_count: int | None = None
|
members_count: int | None = None
|
||||||
online_count: int | None = None
|
online_count: int | None = None
|
||||||
subscribers_count: int | None = None
|
subscribers_count: int | None = None
|
||||||
@@ -70,6 +74,7 @@ async def serialize_chat_for_user(db: AsyncSession, *, user_id: int, chat: Chat)
|
|||||||
"counterpart_username": counterpart_username,
|
"counterpart_username": counterpart_username,
|
||||||
"counterpart_is_online": counterpart_is_online,
|
"counterpart_is_online": counterpart_is_online,
|
||||||
"counterpart_last_seen_at": counterpart_last_seen_at,
|
"counterpart_last_seen_at": counterpart_last_seen_at,
|
||||||
|
"my_role": my_role,
|
||||||
"created_at": chat.created_at,
|
"created_at": chat.created_at,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ from app.notifications.service import dispatch_message_notifications
|
|||||||
|
|
||||||
async def create_chat_message(db: AsyncSession, *, sender_id: int, payload: MessageCreateRequest) -> Message:
|
async def create_chat_message(db: AsyncSession, *, sender_id: int, payload: MessageCreateRequest) -> Message:
|
||||||
await ensure_chat_membership(db, chat_id=payload.chat_id, user_id=sender_id)
|
await ensure_chat_membership(db, chat_id=payload.chat_id, user_id=sender_id)
|
||||||
|
chat = await chats_repository.get_chat_by_id(db, payload.chat_id)
|
||||||
|
if not chat:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found")
|
||||||
|
membership = await chats_repository.get_chat_member(db, chat_id=payload.chat_id, user_id=sender_id)
|
||||||
|
if not membership:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You are not a member of this chat")
|
||||||
|
if chat.type == ChatType.CHANNEL and membership.role == ChatMemberRole.MEMBER:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only admins can post in channels")
|
||||||
if payload.reply_to_message_id is not None:
|
if payload.reply_to_message_id is not None:
|
||||||
reply_to = await repository.get_message_by_id(db, payload.reply_to_message_id)
|
reply_to = await repository.get_message_by_id(db, payload.reply_to_message_id)
|
||||||
if not reply_to or reply_to.chat_id != payload.chat_id:
|
if not reply_to or reply_to.chat_id != payload.chat_id:
|
||||||
@@ -229,6 +237,14 @@ async def forward_message(
|
|||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Source message not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Source message not found")
|
||||||
await ensure_chat_membership(db, chat_id=source.chat_id, user_id=sender_id)
|
await ensure_chat_membership(db, chat_id=source.chat_id, user_id=sender_id)
|
||||||
await ensure_chat_membership(db, chat_id=payload.target_chat_id, user_id=sender_id)
|
await ensure_chat_membership(db, chat_id=payload.target_chat_id, user_id=sender_id)
|
||||||
|
target_chat = await chats_repository.get_chat_by_id(db, payload.target_chat_id)
|
||||||
|
if not target_chat:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found")
|
||||||
|
target_membership = await chats_repository.get_chat_member(db, chat_id=payload.target_chat_id, user_id=sender_id)
|
||||||
|
if not target_membership:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You are not a member of this chat")
|
||||||
|
if target_chat.type == ChatType.CHANNEL and target_membership.role == ChatMemberRole.MEMBER:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only admins can post in channels")
|
||||||
forwarded = await repository.create_message(
|
forwarded = await repository.create_message(
|
||||||
db,
|
db,
|
||||||
chat_id=payload.target_chat_id,
|
chat_id=payload.target_chat_id,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface Chat {
|
|||||||
counterpart_username?: string | null;
|
counterpart_username?: string | null;
|
||||||
counterpart_is_online?: boolean | null;
|
counterpart_is_online?: boolean | null;
|
||||||
counterpart_last_seen_at?: string | null;
|
counterpart_last_seen_at?: string | null;
|
||||||
|
my_role?: ChatMemberRole | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
|||||||
|
|
||||||
const myRole = useMemo(() => members.find((m) => m.user_id === me?.id)?.role, [members, me?.id]);
|
const myRole = useMemo(() => members.find((m) => m.user_id === me?.id)?.role, [members, me?.id]);
|
||||||
const isGroupLike = chat?.type === "group" || chat?.type === "channel";
|
const isGroupLike = chat?.type === "group" || chat?.type === "channel";
|
||||||
|
const showMembersSection = Boolean(chat && isGroupLike && !chat.is_saved);
|
||||||
const canManageMembers = Boolean(isGroupLike && (myRole === "owner" || myRole === "admin"));
|
const canManageMembers = Boolean(isGroupLike && (myRole === "owner" || myRole === "admin"));
|
||||||
const canChangeRoles = Boolean(isGroupLike && myRole === "owner");
|
const canChangeRoles = Boolean(isGroupLike && myRole === "owner");
|
||||||
|
|
||||||
@@ -139,56 +140,74 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
|||||||
{chat.description ? <p className="mt-1 text-xs text-slate-400">{chat.description}</p> : null}
|
{chat.description ? <p className="mt-1 text-xs text-slate-400">{chat.description}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-3 rounded-lg border border-slate-700/70 bg-slate-800/60 p-3">
|
{showMembersSection ? (
|
||||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-300">Members ({members.length})</p>
|
<div className="mb-3 rounded-lg border border-slate-700/70 bg-slate-800/60 p-3">
|
||||||
<div className="tg-scrollbar max-h-56 space-y-2 overflow-auto">
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-300">Members ({members.length})</p>
|
||||||
{members.map((member) => {
|
<div className="tg-scrollbar max-h-56 space-y-2 overflow-auto">
|
||||||
const user = memberUsers[member.user_id];
|
{members.map((member) => {
|
||||||
return (
|
const user = memberUsers[member.user_id];
|
||||||
<div className="rounded border border-slate-700/60 bg-slate-900/60 p-2" key={member.id}>
|
return (
|
||||||
<p className="truncate text-sm font-semibold">{user?.name || `user #${member.user_id}`}</p>
|
<div className="rounded border border-slate-700/60 bg-slate-900/60 p-2" key={member.id}>
|
||||||
<p className="truncate text-xs text-slate-400">@{user?.username || "unknown"}</p>
|
<p className="truncate text-sm font-semibold">{user?.name || `user #${member.user_id}`}</p>
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<p className="truncate text-xs text-slate-400">@{user?.username || "unknown"}</p>
|
||||||
<select
|
<div className="mt-2 flex items-center gap-2">
|
||||||
className="flex-1 rounded bg-slate-800 px-2 py-1 text-xs"
|
<select
|
||||||
disabled={!canChangeRoles || member.user_id === me?.id}
|
className="flex-1 rounded bg-slate-800 px-2 py-1 text-xs"
|
||||||
value={member.role}
|
disabled={!canChangeRoles || member.user_id === me?.id}
|
||||||
onChange={async (e) => {
|
value={member.role}
|
||||||
try {
|
onChange={async (e) => {
|
||||||
await updateChatMemberRole(chatId, member.user_id, e.target.value as ChatMember["role"]);
|
|
||||||
await refreshMembers(chatId);
|
|
||||||
} catch {
|
|
||||||
setError("Failed to update role");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="member">member</option>
|
|
||||||
<option value="admin">admin</option>
|
|
||||||
<option value="owner">owner</option>
|
|
||||||
</select>
|
|
||||||
{canManageMembers && member.user_id !== me?.id ? (
|
|
||||||
<button
|
|
||||||
className="rounded bg-red-500 px-2 py-1 text-xs font-semibold text-white"
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
try {
|
||||||
await removeChatMember(chatId, member.user_id);
|
await updateChatMemberRole(chatId, member.user_id, e.target.value as ChatMember["role"]);
|
||||||
await refreshMembers(chatId);
|
await refreshMembers(chatId);
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to remove member");
|
setError("Failed to update role");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Remove
|
<option value="member">member</option>
|
||||||
</button>
|
<option value="admin">admin</option>
|
||||||
) : null}
|
<option value="owner">owner</option>
|
||||||
|
</select>
|
||||||
|
{canManageMembers && member.user_id !== me?.id ? (
|
||||||
|
<button
|
||||||
|
className="rounded bg-red-500 px-2 py-1 text-xs font-semibold text-white"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await removeChatMember(chatId, member.user_id);
|
||||||
|
await refreshMembers(chatId);
|
||||||
|
} catch {
|
||||||
|
setError("Failed to remove member");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="mb-3 rounded-lg border border-slate-700/70 bg-slate-800/60 p-3">
|
||||||
|
{chat.is_saved ? (
|
||||||
|
<p className="text-sm text-slate-300">Saved Messages is your personal cloud chat.</p>
|
||||||
|
) : chat.type === "private" ? (
|
||||||
|
<p className="text-sm text-slate-300">
|
||||||
|
{chat.counterpart_is_online
|
||||||
|
? "User is online"
|
||||||
|
: chat.counterpart_last_seen_at
|
||||||
|
? `Last seen ${formatLastSeen(chat.counterpart_last_seen_at)}`
|
||||||
|
: "User is offline"}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-slate-300">No extra information.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{canManageMembers ? (
|
{showMembersSection && canManageMembers ? (
|
||||||
<div className="mb-3 rounded-lg border border-slate-700/70 bg-slate-800/60 p-3">
|
<div className="mb-3 rounded-lg border border-slate-700/70 bg-slate-800/60 p-3">
|
||||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-300">Add member</p>
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-300">Add member</p>
|
||||||
<input
|
<input
|
||||||
@@ -234,7 +253,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{chat.type === "group" || chat.type === "channel" ? (
|
{showMembersSection && (chat.type === "group" || chat.type === "channel") ? (
|
||||||
<button
|
<button
|
||||||
className="w-full rounded bg-slate-700 px-3 py-2 text-sm"
|
className="w-full rounded bg-slate-700 px-3 py-2 text-sm"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -258,3 +277,16 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
|||||||
document.body
|
document.body
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatLastSeen(value: string): string {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return "recently";
|
||||||
|
}
|
||||||
|
return date.toLocaleString(undefined, {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export function ChatsPage() {
|
|||||||
const setActiveChatId = useChatStore((s) => s.setActiveChatId);
|
const setActiveChatId = useChatStore((s) => s.setActiveChatId);
|
||||||
const loadMessages = useChatStore((s) => s.loadMessages);
|
const loadMessages = useChatStore((s) => s.loadMessages);
|
||||||
const activeChat = chats.find((chat) => chat.id === activeChatId);
|
const activeChat = chats.find((chat) => chat.id === activeChatId);
|
||||||
|
const isReadOnlyChannel = Boolean(activeChat && activeChat.type === "channel" && activeChat.my_role === "member");
|
||||||
const [infoOpen, setInfoOpen] = useState(false);
|
const [infoOpen, setInfoOpen] = useState(false);
|
||||||
|
|
||||||
useRealtime();
|
useRealtime();
|
||||||
@@ -61,8 +62,12 @@ export function ChatsPage() {
|
|||||||
<div className="min-h-0 flex-1">
|
<div className="min-h-0 flex-1">
|
||||||
<MessageList />
|
<MessageList />
|
||||||
</div>
|
</div>
|
||||||
{activeChatId ? (
|
{activeChatId && !isReadOnlyChannel ? (
|
||||||
<MessageComposer />
|
<MessageComposer />
|
||||||
|
) : activeChatId && isReadOnlyChannel ? (
|
||||||
|
<div className="border-t border-slate-700/50 bg-slate-900/40 p-4 text-center text-sm text-slate-300/80">
|
||||||
|
Только администраторы могут публиковать сообщения в этом канале
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="border-t border-slate-700/50 bg-slate-900/40 p-4 text-center text-sm text-slate-300/80">
|
<div className="border-t border-slate-700/50 bg-slate-900/40 p-4 text-center text-sm text-slate-300/80">
|
||||||
Выберите чат, чтобы начать переписку
|
Выберите чат, чтобы начать переписку
|
||||||
|
|||||||
Reference in New Issue
Block a user