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:
2
backend/migrations/079_chat_room_owner.sql
Normal file
2
backend/migrations/079_chat_room_owner.sql
Normal 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;
|
||||
@@ -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])
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user