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

644 lines
29 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 {
CheckCircle2,
Loader2,
LogIn,
MessagesSquare,
ScrollText,
Search,
XCircle
} from 'lucide-react';
import { AdminInspectorMessageFeed } from '@/components/admin/admin-inspector-message-body';
import {
AdminInsightsDateFilter,
defaultInsightsPeriodLocal,
localDatetimeToIso
} from '@/components/admin/admin-insights-date-filter';
import {
adminMessageToChatMessage,
useAdminChatMedia
} from '@/components/admin/use-admin-chat-media';
import { ChatImageLightbox } from '@/components/chat/chat-image-lightbox';
import { VerificationBadge } from '@/components/id/verification-badge';
import { useToast } from '@/components/id/toast-provider';
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,
ChatMessage,
SignInEvent,
apiFetch
} from '@/lib/api';
import { collectChatImageMessages } from '@/lib/chat-media-album';
import { cn } from '@/lib/utils';
const inspectorTabTriggerClass =
'flex items-center justify-center gap-2 rounded-lg px-2 py-2.5 text-sm font-medium text-[#667085] shadow-none transition data-[state=active]:bg-white data-[state=active]:text-[#1f2430] data-[state=active]:shadow-sm';
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 defaultPeriod = useMemo(() => defaultInsightsPeriodLocal(7), []);
const [periodFrom, setPeriodFrom] = useState(defaultPeriod.from);
const [periodTo, setPeriodTo] = useState(defaultPeriod.to);
const [retentionDays, setRetentionDays] = useState(7);
const resetPeriod = useCallback(() => {
const next = defaultInsightsPeriodLocal(retentionDays);
setPeriodFrom(next.from);
setPeriodTo(next.to);
}, [retentionDays]);
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 debouncedPeriodFrom = useDebouncedValue(periodFrom, 500);
const debouncedPeriodTo = useDebouncedValue(periodTo, 500);
const appendDateParams = useCallback(
(params: URLSearchParams) => {
const fromIso = localDatetimeToIso(debouncedPeriodFrom);
const toIso = localDatetimeToIso(debouncedPeriodTo);
if (fromIso) params.set('dateFrom', fromIso);
if (toIso) params.set('dateTo', toIso);
},
[debouncedPeriodFrom, debouncedPeriodTo]
);
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 [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const selectedRoom = useMemo(() => rooms.find((room) => room.id === selectedRoomId) ?? null, [rooms, selectedRoomId]);
const { mediaUrls } = useAdminChatMedia(token, selectedRoomId, roomMessages);
const chatImageMessages = useMemo(
() =>
selectedRoomId
? collectChatImageMessages(roomMessages.map((message) => adminMessageToChatMessage(message, selectedRoomId)))
: [],
[roomMessages, 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');
appendDateParams(params);
const response = await apiFetch<{
total: number;
events: SignInEvent[];
retentionDays?: number;
periodFrom?: string;
periodTo?: string;
}>(
`/admin/users/${user.id}/sign-in-history?${params.toString()}`,
{},
token
);
setSignInEvents(response.events ?? []);
setSignInTotal(response.total ?? 0);
if (response.retentionDays) setRetentionDays(response.retentionDays);
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось загрузить историю входов');
} finally {
setLoadingSignIn(false);
}
}, [appendDateParams, 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');
appendDateParams(params);
const response = await apiFetch<{
total: number;
items: AdminUserActivityItem[];
retentionDays?: number;
}>(
`/admin/users/${user.id}/activity?${params.toString()}`,
{},
token
);
setActivityItems(response.items ?? []);
setActivityTotal(response.total ?? 0);
if (response.retentionDays) setRetentionDays(response.retentionDays);
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось загрузить активность');
} finally {
setLoadingActivity(false);
}
}, [appendDateParams, 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');
appendDateParams(params);
const response = await apiFetch<{ total: number; rooms: AdminChatRoomSummary[]; retentionDays?: number }>(
`/admin/users/${user.id}/chats?${params.toString()}`,
{},
token
);
setRooms(response.rooms ?? []);
setRoomsTotal(response.total ?? 0);
if (response.retentionDays) setRetentionDays(response.retentionDays);
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось загрузить чаты');
} finally {
setLoadingRooms(false);
}
}, [appendDateParams, 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');
appendDateParams(params);
const response = await apiFetch<{
room?: { id: string; name: string; type: string; groupName: string };
messages: AdminChatMessage[];
retentionDays?: number;
}>(`/admin/users/${user.id}/chats/${selectedRoomId}/messages?${params.toString()}`, {}, token);
setRoomMessages(response.messages ?? []);
if (response.retentionDays) setRetentionDays(response.retentionDays);
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);
}
}, [appendDateParams, 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');
appendDateParams(params);
const response = await apiFetch<{ total: number; messages: AdminChatMessage[]; retentionDays?: number }>(
`/admin/users/${user.id}/chats/search?${params.toString()}`,
{},
token
);
setSearchResults(response.messages ?? []);
setSearchTotal(response.total ?? 0);
if (response.retentionDays) setRetentionDays(response.retentionDays);
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось выполнить поиск');
} finally {
setLoadingSearch(false);
}
}, [appendDateParams, 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([]);
setLightboxIndex(null);
const next = defaultInsightsPeriodLocal(7);
setPeriodFrom(next.from);
setPeriodTo(next.to);
setRetentionDays(7);
}
}, [open]);
function openImageLightbox(message: ChatMessage) {
const index = chatImageMessages.findIndex((item) => item.id === message.id);
if (index >= 0) setLightboxIndex(index);
}
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>
<div className="border-b border-[#eceef4] px-6 py-4">
<AdminInsightsDateFilter
from={periodFrom}
to={periodTo}
retentionDays={retentionDays}
onFromChange={setPeriodFrom}
onToChange={setPeriodTo}
onReset={resetPeriod}
/>
</div>
<Tabs value={tab} onValueChange={setTab} className="flex min-h-0 flex-1 flex-col">
<div className="border-b border-[#eceef4] px-6 pt-4 pb-4">
<TabsList className="grid h-auto w-full grid-cols-3 gap-1 rounded-xl bg-[#f4f5f8] p-1">
<TabsTrigger value="sign-in" className={inspectorTabTriggerClass}>
<LogIn className="h-4 w-4 shrink-0" />
Входы
</TabsTrigger>
<TabsTrigger value="activity" className={inspectorTabTriggerClass}>
<ScrollText className="h-4 w-4 shrink-0" />
Активность
</TabsTrigger>
<TabsTrigger value="chats" className={inspectorTabTriggerClass} disabled={!canModerateChats}>
<MessagesSquare className="h-4 w-4 shrink-0" />
Чаты
</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="p-3">
<AdminInspectorMessageFeed
messages={roomMessages}
roomId={selectedRoomId}
mediaUrls={mediaUrls}
deletingMessageId={deletingMessageId}
onDelete={(messageId) => void handleDeleteMessage(messageId)}
onOpenImage={openImageLightbox}
/>
</div>
) : (
<p className="py-16 text-center text-sm text-[#667085]">Сообщения не найдены</p>
)}
</div>
</div>
</div>
</>
)}
</TabsContent>
</Tabs>
<ChatImageLightbox
open={lightboxIndex !== null}
images={chatImageMessages}
index={lightboxIndex ?? 0}
mediaUrls={mediaUrls}
onClose={() => setLightboxIndex(null)}
onIndexChange={setLightboxIndex}
/>
</DialogContent>
</Dialog>
);
}