feat: group chat owner protection — prevent removing creator, transfer ownership before leaving

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 11:47:18 +03:00
parent 9d12e157b1
commit 18a6359adb
4 changed files with 99 additions and 17 deletions

View File

@@ -0,0 +1,2 @@
-- Group room owner (creator)
ALTER TABLE chat_rooms ADD COLUMN IF NOT EXISTS created_by UUID REFERENCES users(id) ON DELETE SET NULL;

View File

@@ -80,7 +80,7 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
const userId = request.user.sub
const { rows } = await db.query(
`SELECT r.id, r.type, r.name, r.avatar_url,
`SELECT r.id, r.type, r.name, r.avatar_url, r.created_by,
(SELECT COUNT(*) FROM chat_messages m
WHERE m.room_id = r.id AND m.deleted_at IS NULL
AND m.created_at > COALESCE(
@@ -374,8 +374,8 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
const allMembers = [...new Set([userId, ...memberIds])]
const { rows: [room] } = await db.query(
`INSERT INTO chat_rooms (hotel_id, type, name) VALUES ($1, 'group', $2) RETURNING id`,
[hotelId, name.trim()],
`INSERT INTO chat_rooms (hotel_id, type, name, created_by) VALUES ($1, 'group', $2, $3) RETURNING id`,
[hotelId, name.trim(), userId],
)
const memberValues = allMembers.map((_, i) => `($1, $${i + 2})`).join(', ')
await db.query(
@@ -388,7 +388,7 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
// ── PATCH group room (rename / add/remove members) ────────────────────────
fastify.patch<RoomParam & { Body: { name?: string; avatarUrl?: string; avatar_url?: string; addMemberIds?: string[]; add_member_ids?: string[]; removeMemberIds?: string[]; remove_member_ids?: string[] } }>(
fastify.patch<RoomParam & { Body: { name?: string; avatarUrl?: string; avatar_url?: string; addMemberIds?: string[]; add_member_ids?: string[]; removeMemberIds?: string[]; remove_member_ids?: string[]; transferOwnerTo?: string; transfer_owner_to?: string } }>(
'/api/hotels/:slug/chat/rooms/:roomId/group',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
@@ -403,6 +403,25 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
: Array.isArray(b.addMemberIds) ? b.addMemberIds as string[] : []
const removeMemberIds: string[] = Array.isArray(b.remove_member_ids) ? b.remove_member_ids as string[]
: Array.isArray(b.removeMemberIds) ? b.removeMemberIds as string[] : []
const transferOwnerTo = (b.transfer_owner_to ?? b.transferOwnerTo) as string | undefined
// Fetch current room to get created_by
const roomRes = await db.query('SELECT created_by FROM chat_rooms WHERE id = $1', [roomId])
const createdBy: string | null = roomRes.rows[0]?.created_by ?? null
// Block removal of the creator
if (createdBy && removeMemberIds.includes(createdBy)) {
return reply.code(403).send({ error: 'Cannot remove the group creator' })
}
// Transfer ownership (only current owner or admin can do this)
if (transferOwnerTo) {
const userId = request.user.sub
if (createdBy !== userId && request.user.role !== 'admin') {
return reply.code(403).send({ error: 'Only the group creator can transfer ownership' })
}
await db.query('UPDATE chat_rooms SET created_by = $1 WHERE id = $2', [transferOwnerTo, roomId])
}
if (name) {
await db.query('UPDATE chat_rooms SET name = $1 WHERE id = $2', [name.trim(), roomId])

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import {
MessageSquare, X, ChevronLeft, Send, Users, Loader2,
Search, Bell, Settings, PenSquare, BellOff, Volume2, VolumeX, Paperclip, Pin, UserPlus,
Search, Bell, Settings, PenSquare, BellOff, Volume2, VolumeX, Paperclip, Pin, UserPlus, LogOut,
} from 'lucide-react'
import { api, type ChatRoom, type ChatMessage, type ChatSearchResult, type ChatReaction } from '../../lib/api'
import type { User } from '../../types'
@@ -544,11 +544,21 @@ export function ChatWidget() {
} catch { /**/ }
}
const handleSaveGroupInfo = async (patch: { name?: string; addMemberIds?: string[]; removeMemberIds?: string[] }) => {
const handleSaveGroupInfo = async (patch: { name?: string; addMemberIds?: string[]; removeMemberIds?: string[]; transferOwnerTo?: string; leaveAfter?: boolean }) => {
if (!activeRoom || savingGroup) return
setSavingGroup(true)
try {
await api.chat.updateGroup(slug, activeRoom.id, patch)
const { leaveAfter, ...apiPatch } = patch
await api.chat.updateGroup(slug, activeRoom.id, apiPatch)
if (leaveAfter) {
// After transferring ownership, leave the group
await api.chat.updateGroup(slug, activeRoom.id, { removeMemberIds: [user?.id ?? ''] })
const data = await api.chat.listRooms(slug)
setRooms(data)
setActiveRoom(null)
setView('rooms')
return
}
const data = await api.chat.listRooms(slug)
setRooms(data)
const updated = data.find(r => r.id === activeRoom.id)
@@ -1425,7 +1435,7 @@ function GroupInfoView({ room, allMembers, currentUserId, saving, editName, onEd
editName: string
onEditName: (v: string) => void
onAvatarClick: () => void
onSave: (patch: { name?: string; addMemberIds?: string[]; removeMemberIds?: string[] }) => void
onSave: (patch: { name?: string; addMemberIds?: string[]; removeMemberIds?: string[]; transferOwnerTo?: string; leaveAfter?: boolean }) => void
}) {
const roleLabels: Record<string, string> = {
hotel_admin: 'Администратор', manager: 'Менеджер', housekeeper: 'Горничная',
@@ -1433,8 +1443,11 @@ function GroupInfoView({ room, allMembers, currentUserId, saving, editName, onEd
}
const [addingMembers, setAddingMembers] = useState(false)
const [selectedAdd, setSelectedAdd] = useState<string[]>([])
const [transferMode, setTransferMode] = useState(false)
const [transferTarget, setTransferTarget] = useState<string>('')
const isCreator = room.createdBy === currentUserId
// Current member ids from allMembers that are in this room (from memberNames we don't have IDs, so use a different approach)
// We track removes locally until saved
const [removedIds, setRemovedIds] = useState<string[]>([])
@@ -1443,10 +1456,6 @@ function GroupInfoView({ room, allMembers, currentUserId, saving, editName, onEd
.map(u => u.id)
.filter(id => !removedIds.includes(id))
const removableMemberIds = allMembers.filter(u =>
(room.memberNames?.some(n => n === u.name) || u.id === currentUserId) && u.id !== currentUserId
).map(u => u.id)
const notMembers = allMembers.filter(u =>
u.id !== currentUserId &&
!currentMemberIds.includes(u.id) &&
@@ -1465,6 +1474,19 @@ function GroupInfoView({ room, allMembers, currentUserId, saving, editName, onEd
setAddingMembers(false)
}
const handleLeave = () => {
if (isCreator) {
setTransferMode(true)
} else {
onSave({ removeMemberIds: [currentUserId] })
}
}
const handleTransferAndLeave = () => {
if (!transferTarget) return
onSave({ transferOwnerTo: transferTarget, leaveAfter: true })
}
return (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Avatar + name */}
@@ -1538,12 +1560,14 @@ function GroupInfoView({ room, allMembers, currentUserId, saving, editName, onEd
<div key={u.id} className="flex items-center gap-3 px-4 py-2.5 border-b border-slate-100 dark:border-slate-700/50">
<Avatar name={u.name} size={32} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">
{u.name} {u.id === currentUserId && <span className="text-xs text-slate-400 font-normal">(вы)</span>}
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate flex items-center gap-1">
{u.name}
{room.createdBy === u.id && <span title="Создатель группы" className="text-amber-400 text-xs"></span>}
{u.id === currentUserId && <span className="text-xs text-slate-400 font-normal">(вы)</span>}
</p>
<p className="text-xs text-slate-400">{roleLabels[u.role] ?? u.role}</p>
</div>
{u.id !== currentUserId && (
{u.id !== currentUserId && room.createdBy !== u.id && (
<button onClick={() => handleRemove(u.id)} title="Удалить из группы"
className="shrink-0 p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors">
<X size={14} />
@@ -1551,6 +1575,42 @@ function GroupInfoView({ room, allMembers, currentUserId, saving, editName, onEd
)}
</div>
))}
{/* Transfer ownership UI (for creator leaving) */}
{transferMode && (
<div className="mx-3 my-3 rounded-xl border border-amber-200 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 p-3">
<p className="text-xs font-semibold text-amber-700 dark:text-amber-400 mb-2">
Передайте управление группой перед выходом
</p>
<select value={transferTarget} onChange={e => setTransferTarget(e.target.value)}
className="w-full text-sm rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-200 px-2 py-1.5 mb-2 outline-none focus:border-brand-500">
<option value="">Выберите нового владельца</option>
{allMembers.filter(u => currentMemberIds.includes(u.id) && u.id !== currentUserId).map(u => (
<option key={u.id} value={u.id}>{u.name}</option>
))}
</select>
<div className="flex gap-2">
<button onClick={() => { setTransferMode(false); setTransferTarget('') }}
className="flex-1 py-1.5 text-xs rounded-lg border border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">
Отмена
</button>
<button onClick={handleTransferAndLeave} disabled={!transferTarget || saving}
className="flex-1 py-1.5 text-xs rounded-lg bg-red-500 hover:bg-red-600 disabled:opacity-40 text-white font-medium transition-colors flex items-center justify-center gap-1">
{saving ? <Loader2 size={11} className="animate-spin" /> : null}
Передать и выйти
</button>
</div>
</div>
)}
{/* Leave group button */}
{!transferMode && (
<button onClick={handleLeave} disabled={saving}
className="w-full flex items-center gap-2 px-4 py-3 text-sm text-red-500 hover:bg-red-50 dark:hover:bg-red-900/10 transition-colors disabled:opacity-40">
<LogOut size={15} />
Выйти из группы
</button>
)}
</div>
</div>
)

View File

@@ -360,7 +360,7 @@ export const api = {
req<{ id: string; name: string; role: string }[]>('GET', `/api/hotels/${slug}/chat/members`),
createGroup: (slug: string, name: string, memberIds: string[]) =>
req<{ roomId: string }>('POST', `/api/hotels/${slug}/chat/group`, { name, memberIds }),
updateGroup: (slug: string, roomId: string, patch: { name?: string; avatarUrl?: string; addMemberIds?: string[]; removeMemberIds?: string[] }) =>
updateGroup: (slug: string, roomId: string, patch: { name?: string; avatarUrl?: string; addMemberIds?: string[]; removeMemberIds?: string[]; transferOwnerTo?: string }) =>
req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/chat/rooms/${roomId}/group`, patch),
editMessage: (slug: string, roomId: string, msgId: string, text: string) =>
req<ChatMessage>('PATCH', `/api/hotels/${slug}/chat/rooms/${roomId}/messages/${msgId}`, { text }),
@@ -1363,6 +1363,7 @@ export interface ChatRoom {
memberCount: number
memberNames: string[] | null
avatarUrl: string | null
createdBy?: string | null
}
export interface ChatReaction {