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

@@ -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>
)