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