Files
IdP/apps/frontend/components/admin/user-inspector-dialog.tsx
2026-07-01 17:28:53 +03:00

581 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Activity,
CheckCircle2,
Loader2,
MessageSquare,
Search,
ShieldAlert,
Trash2,
XCircle
} from 'lucide-react';
import { VerificationBadge } from '@/components/id/verification-badge';
import { useToast } from '@/components/id/toast-provider';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
AdminChatMessage,
AdminChatRoomSummary,
AdminUser,
AdminUserActivityItem,
SignInEvent,
apiFetch
} from '@/lib/api';
import { cn } from '@/lib/utils';
function formatDateTime(value: string) {
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(new Date(value));
}
function useDebouncedValue<T>(value: T, delayMs = 350) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = window.setTimeout(() => setDebounced(value), delayMs);
return () => window.clearTimeout(timer);
}, [delayMs, value]);
return debounced;
}
const roomTypeLabels: Record<string, string> = {
GENERAL: 'Общий',
DIRECT: 'Личный',
GROUP: 'Группа'
};
export function UserInspectorDialog({
user,
token,
canModerateChats,
open,
onOpenChange
}: {
user: AdminUser | null;
token: string | null;
canModerateChats: boolean;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { showToast } = useToast();
const [tab, setTab] = useState('sign-in');
const [signInSearch, setSignInSearch] = useState('');
const [activitySearch, setActivitySearch] = useState('');
const [chatSearch, setChatSearch] = useState('');
const [messageSearch, setMessageSearch] = useState('');
const [globalMessageSearch, setGlobalMessageSearch] = useState('');
const debouncedSignInSearch = useDebouncedValue(signInSearch);
const debouncedActivitySearch = useDebouncedValue(activitySearch);
const debouncedChatSearch = useDebouncedValue(chatSearch);
const debouncedMessageSearch = useDebouncedValue(messageSearch);
const debouncedGlobalMessageSearch = useDebouncedValue(globalMessageSearch);
const [signInEvents, setSignInEvents] = useState<SignInEvent[]>([]);
const [signInTotal, setSignInTotal] = useState(0);
const [activityItems, setActivityItems] = useState<AdminUserActivityItem[]>([]);
const [activityTotal, setActivityTotal] = useState(0);
const [rooms, setRooms] = useState<AdminChatRoomSummary[]>([]);
const [roomsTotal, setRoomsTotal] = useState(0);
const [selectedRoomId, setSelectedRoomId] = useState<string | null>(null);
const [roomMessages, setRoomMessages] = useState<AdminChatMessage[]>([]);
const [roomContext, setRoomContext] = useState<{ name: string; groupName: string; type: string } | null>(null);
const [searchResults, setSearchResults] = useState<AdminChatMessage[]>([]);
const [searchTotal, setSearchTotal] = useState(0);
const [loadingSignIn, setLoadingSignIn] = useState(false);
const [loadingActivity, setLoadingActivity] = useState(false);
const [loadingRooms, setLoadingRooms] = useState(false);
const [loadingMessages, setLoadingMessages] = useState(false);
const [loadingSearch, setLoadingSearch] = useState(false);
const [deletingMessageId, setDeletingMessageId] = useState<string | null>(null);
const selectedRoom = useMemo(() => rooms.find((room) => room.id === selectedRoomId) ?? null, [rooms, selectedRoomId]);
const loadSignIn = useCallback(async () => {
if (!token || !user) return;
setLoadingSignIn(true);
try {
const params = new URLSearchParams();
if (debouncedSignInSearch.trim()) params.set('search', debouncedSignInSearch.trim());
params.set('limit', '50');
const response = await apiFetch<{ total: number; events: SignInEvent[] }>(
`/admin/users/${user.id}/sign-in-history?${params.toString()}`,
{},
token
);
setSignInEvents(response.events ?? []);
setSignInTotal(response.total ?? 0);
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось загрузить историю входов');
} finally {
setLoadingSignIn(false);
}
}, [debouncedSignInSearch, showToast, token, user]);
const loadActivity = useCallback(async () => {
if (!token || !user) return;
setLoadingActivity(true);
try {
const params = new URLSearchParams();
if (debouncedActivitySearch.trim()) params.set('search', debouncedActivitySearch.trim());
params.set('limit', '50');
const response = await apiFetch<{ total: number; items: AdminUserActivityItem[] }>(
`/admin/users/${user.id}/activity?${params.toString()}`,
{},
token
);
setActivityItems(response.items ?? []);
setActivityTotal(response.total ?? 0);
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось загрузить активность');
} finally {
setLoadingActivity(false);
}
}, [debouncedActivitySearch, showToast, token, user]);
const loadRooms = useCallback(async () => {
if (!token || !user || !canModerateChats) return;
setLoadingRooms(true);
try {
const params = new URLSearchParams();
if (debouncedChatSearch.trim()) params.set('search', debouncedChatSearch.trim());
params.set('limit', '50');
const response = await apiFetch<{ total: number; rooms: AdminChatRoomSummary[] }>(
`/admin/users/${user.id}/chats?${params.toString()}`,
{},
token
);
setRooms(response.rooms ?? []);
setRoomsTotal(response.total ?? 0);
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось загрузить чаты');
} finally {
setLoadingRooms(false);
}
}, [canModerateChats, debouncedChatSearch, showToast, token, user]);
const loadRoomMessages = useCallback(async () => {
if (!token || !user || !selectedRoomId) return;
setLoadingMessages(true);
try {
const params = new URLSearchParams();
if (debouncedMessageSearch.trim()) params.set('search', debouncedMessageSearch.trim());
params.set('limit', '80');
const response = await apiFetch<{
room?: { id: string; name: string; type: string; groupName: string };
messages: AdminChatMessage[];
}>(`/admin/users/${user.id}/chats/${selectedRoomId}/messages?${params.toString()}`, {}, token);
setRoomMessages(response.messages ?? []);
setRoomContext(
response.room
? { name: response.room.name, groupName: response.room.groupName, type: response.room.type }
: null
);
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось загрузить сообщения');
} finally {
setLoadingMessages(false);
}
}, [debouncedMessageSearch, selectedRoomId, showToast, token, user]);
const loadGlobalSearch = useCallback(async () => {
if (!token || !user || !canModerateChats || !debouncedGlobalMessageSearch.trim()) {
setSearchResults([]);
setSearchTotal(0);
return;
}
setLoadingSearch(true);
try {
const params = new URLSearchParams();
params.set('search', debouncedGlobalMessageSearch.trim());
params.set('limit', '50');
const response = await apiFetch<{ total: number; messages: AdminChatMessage[] }>(
`/admin/users/${user.id}/chats/search?${params.toString()}`,
{},
token
);
setSearchResults(response.messages ?? []);
setSearchTotal(response.total ?? 0);
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось выполнить поиск');
} finally {
setLoadingSearch(false);
}
}, [canModerateChats, debouncedGlobalMessageSearch, showToast, token, user]);
useEffect(() => {
if (!open || !user) return;
if (tab === 'sign-in') void loadSignIn();
}, [loadSignIn, open, tab, user]);
useEffect(() => {
if (!open || !user) return;
if (tab === 'activity') void loadActivity();
}, [loadActivity, open, tab, user]);
useEffect(() => {
if (!open || !user || !canModerateChats) return;
if (tab === 'chats') void loadRooms();
}, [canModerateChats, loadRooms, open, tab, user]);
useEffect(() => {
if (!open || !user || !selectedRoomId) return;
void loadRoomMessages();
}, [loadRoomMessages, open, selectedRoomId, user]);
useEffect(() => {
if (!open || !user || tab !== 'chats') return;
void loadGlobalSearch();
}, [loadGlobalSearch, open, tab, user]);
useEffect(() => {
if (!open) {
setTab('sign-in');
setSignInSearch('');
setActivitySearch('');
setChatSearch('');
setMessageSearch('');
setGlobalMessageSearch('');
setSelectedRoomId(null);
setRoomMessages([]);
setSearchResults([]);
}
}, [open]);
async function handleDeleteMessage(messageId: string) {
if (!token || !user || !canModerateChats) return;
const confirmed = window.confirm('Удалить это сообщение? Действие необратимо для пользователей чата.');
if (!confirmed) return;
setDeletingMessageId(messageId);
try {
await apiFetch(`/admin/users/${user.id}/chat-messages/${messageId}`, { method: 'DELETE' }, token);
setRoomMessages((prev) => prev.filter((item) => item.id !== messageId));
setSearchResults((prev) => prev.filter((item) => item.id !== messageId));
showToast('Сообщение удалено');
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось удалить сообщение');
} finally {
setDeletingMessageId(null);
}
}
if (!user) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[90vh] w-[min(960px,calc(100vw-2rem))] flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="border-b border-[#eceef4] px-6 py-5">
<DialogTitle className="flex items-center gap-3 text-left text-xl font-semibold">
<span>{user.displayName}</span>
{user.isVerified ? <VerificationBadge verificationIcon={user.verificationIcon} size="sm" /> : null}
</DialogTitle>
<p className="text-left text-sm text-[#667085]">
{[user.email, user.phone, user.username].filter(Boolean).join(' · ') || 'Контакты не указаны'}
</p>
</DialogHeader>
<Tabs value={tab} onValueChange={setTab} className="flex min-h-0 flex-1 flex-col">
<div className="border-b border-[#eceef4] px-6 pt-4">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="sign-in" className="gap-2">
<ShieldAlert className="h-4 w-4" />
Входы
</TabsTrigger>
<TabsTrigger value="activity" className="gap-2">
<Activity className="h-4 w-4" />
Активность
</TabsTrigger>
<TabsTrigger value="chats" className="gap-2" disabled={!canModerateChats}>
<MessageSquare className="h-4 w-4" />
Чаты
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="sign-in" className="mt-0 flex min-h-0 flex-1 flex-col gap-4 overflow-hidden px-6 py-4">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#667085]" />
<Input
value={signInSearch}
onChange={(event) => setSignInSearch(event.target.value)}
placeholder="Поиск по IP, устройству, user-agent, причине..."
className="pl-10"
/>
</div>
<p className="text-xs text-[#667085]">Найдено: {signInTotal}</p>
<div className="min-h-0 flex-1 overflow-y-auto rounded-2xl border border-[#eceef4]">
{loadingSignIn ? (
<div className="flex items-center justify-center gap-2 py-16 text-[#667085]">
<Loader2 className="h-5 w-5 animate-spin" />
Загрузка...
</div>
) : signInEvents.length ? (
<div className="divide-y divide-[#eceef4]">
{signInEvents.map((event) => (
<div key={event.id} className="flex gap-3 px-4 py-3">
{event.success ? (
<CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-emerald-600" />
) : (
<XCircle className="mt-0.5 h-5 w-5 shrink-0 text-red-500" />
)}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{event.success ? 'Успешный вход' : 'Неудачная попытка'}</span>
<span className="text-xs text-[#667085]">{formatDateTime(event.createdAt)}</span>
</div>
<p className="mt-1 text-sm text-[#667085]">
{[event.deviceName, event.ipAddress].filter(Boolean).join(' · ') || 'Устройство не определено'}
</p>
{event.reason ? <p className="mt-1 text-sm text-red-600">{event.reason}</p> : null}
{event.userAgent ? (
<p className="mt-1 truncate text-xs text-[#98a2b3]" title={event.userAgent}>
{event.userAgent}
</p>
) : null}
</div>
</div>
))}
</div>
) : (
<p className="py-16 text-center text-sm text-[#667085]">События входа не найдены</p>
)}
</div>
</TabsContent>
<TabsContent value="activity" className="mt-0 flex min-h-0 flex-1 flex-col gap-4 overflow-hidden px-6 py-4">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#667085]" />
<Input
value={activitySearch}
onChange={(event) => setActivitySearch(event.target.value)}
placeholder="Поиск по действиям, документам, чатам, OAuth..."
className="pl-10"
/>
</div>
<p className="text-xs text-[#667085]">Найдено: {activityTotal}</p>
<div className="min-h-0 flex-1 overflow-y-auto rounded-2xl border border-[#eceef4]">
{loadingActivity ? (
<div className="flex items-center justify-center gap-2 py-16 text-[#667085]">
<Loader2 className="h-5 w-5 animate-spin" />
Загрузка...
</div>
) : activityItems.length ? (
<div className="divide-y divide-[#eceef4]">
{activityItems.map((item) => (
<div key={item.id} className="px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{item.title}</span>
<span className="rounded-full bg-[#f4f5f8] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-[#667085]">
{item.action}
</span>
<span className="text-xs text-[#667085]">{formatDateTime(item.createdAt)}</span>
</div>
{item.detail ? <p className="mt-1 text-sm text-[#667085]">{item.detail}</p> : null}
</div>
))}
</div>
) : (
<p className="py-16 text-center text-sm text-[#667085]">Активность не найдена</p>
)}
</div>
</TabsContent>
<TabsContent value="chats" className="mt-0 flex min-h-0 flex-1 flex-col gap-4 overflow-hidden px-6 py-4">
{!canModerateChats ? (
<p className="py-8 text-center text-sm text-[#667085]">Недостаточно прав для модерации чатов</p>
) : (
<>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#667085]" />
<Input
value={globalMessageSearch}
onChange={(event) => setGlobalMessageSearch(event.target.value)}
placeholder="Глобальный поиск по сообщениям пользователя..."
className="pl-10"
/>
</div>
{debouncedGlobalMessageSearch.trim() ? (
<div className="rounded-2xl border border-[#eceef4] bg-[#fafbff] p-3">
<p className="mb-2 text-xs font-medium text-[#667085]">
Результаты поиска по сообщениям: {searchTotal}
</p>
{loadingSearch ? (
<div className="flex items-center gap-2 py-4 text-sm text-[#667085]">
<Loader2 className="h-4 w-4 animate-spin" />
Поиск...
</div>
) : searchResults.length ? (
<div className="max-h-40 space-y-2 overflow-y-auto">
{searchResults.map((message) => (
<button
key={message.id}
type="button"
className="flex w-full items-start gap-3 rounded-xl bg-white px-3 py-2 text-left hover:bg-[#f4f5f8]"
onClick={() => {
if (message.roomId) setSelectedRoomId(message.roomId);
}}
>
<div className="min-w-0 flex-1">
<p className="text-xs text-[#667085]">
{message.roomName} · {message.groupName} · {formatDateTime(message.createdAt)}
</p>
<p className="text-sm">
<span className="font-medium">{message.senderName}: </span>
{message.preview}
</p>
</div>
</button>
))}
</div>
) : (
<p className="py-2 text-sm text-[#667085]">Сообщения не найдены</p>
)}
</div>
) : null}
<div className="grid min-h-0 flex-1 gap-4 lg:grid-cols-[280px_minmax(0,1fr)]">
<div className="flex min-h-0 flex-col gap-3">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#667085]" />
<Input
value={chatSearch}
onChange={(event) => setChatSearch(event.target.value)}
placeholder="Поиск чатов..."
className="pl-10"
/>
</div>
<p className="text-xs text-[#667085]">Чатов: {roomsTotal}. E2E и боты скрыты.</p>
<div className="min-h-0 flex-1 overflow-y-auto rounded-2xl border border-[#eceef4]">
{loadingRooms ? (
<div className="flex items-center justify-center gap-2 py-10 text-[#667085]">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : rooms.length ? (
<div className="divide-y divide-[#eceef4]">
{rooms.map((room) => (
<button
key={room.id}
type="button"
onClick={() => setSelectedRoomId(room.id)}
className={cn(
'w-full px-3 py-3 text-left transition hover:bg-[#f8f9fb]',
selectedRoomId === room.id && 'bg-[#eef4ff]'
)}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate font-medium">{room.name}</span>
<span className="shrink-0 rounded-full bg-[#f4f5f8] px-2 py-0.5 text-[10px] font-semibold text-[#667085]">
{roomTypeLabels[room.type] ?? room.type}
</span>
</div>
<p className="mt-1 truncate text-xs text-[#667085]">{room.groupName}</p>
{room.lastMessage ? (
<p className="mt-1 truncate text-xs text-[#98a2b3]">{room.lastMessage.preview}</p>
) : null}
</button>
))}
</div>
) : (
<p className="px-3 py-10 text-center text-sm text-[#667085]">Чаты не найдены</p>
)}
</div>
</div>
<div className="flex min-h-0 flex-col gap-3">
<div className="rounded-2xl border border-[#eceef4] bg-[#fafbff] px-4 py-3">
<p className="font-medium">{selectedRoom?.name ?? 'Выберите чат'}</p>
<p className="text-xs text-[#667085]">
{roomContext
? `${roomContext.groupName} · ${roomTypeLabels[roomContext.type] ?? roomContext.type}`
: selectedRoom
? `${selectedRoom.groupName} · ${selectedRoom.memberCount} участников`
: 'Обычные чаты доступны для модерации'}
</p>
</div>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#667085]" />
<Input
value={messageSearch}
onChange={(event) => setMessageSearch(event.target.value)}
placeholder="Поиск в выбранном чате..."
className="pl-10"
disabled={!selectedRoomId}
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto rounded-2xl border border-[#eceef4] bg-white">
{!selectedRoomId ? (
<p className="py-16 text-center text-sm text-[#667085]">Выберите чат слева</p>
) : loadingMessages ? (
<div className="flex items-center justify-center gap-2 py-16 text-[#667085]">
<Loader2 className="h-5 w-5 animate-spin" />
Загрузка сообщений...
</div>
) : roomMessages.length ? (
<div className="space-y-2 p-3">
{roomMessages.map((message) => (
<div
key={message.id}
className={cn(
'rounded-2xl border px-3 py-2',
message.isDeleted ? 'border-red-100 bg-red-50' : 'border-[#eceef4] bg-[#fafbff]'
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{message.senderName}</span>
<span className="text-xs text-[#667085]">{formatDateTime(message.createdAt)}</span>
{message.isDeleted ? (
<span className="text-xs font-medium text-red-600">Удалено</span>
) : null}
</div>
<p className="mt-1 whitespace-pre-wrap break-words text-sm text-[#344054]">
{message.isEncrypted ? '[E2E недоступно для модерации]' : message.preview}
</p>
</div>
{!message.isDeleted && !message.isEncrypted ? (
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-red-600 hover:bg-red-50 hover:text-red-700"
disabled={deletingMessageId === message.id}
aria-label="Удалить сообщение"
onClick={() => void handleDeleteMessage(message.id)}
>
{deletingMessageId === message.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
) : null}
</div>
</div>
))}
</div>
) : (
<p className="py-16 text-center text-sm text-[#667085]">Сообщения не найдены</p>
)}
</div>
</div>
</div>
</>
)}
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}