fix and update

This commit is contained in:
lendry
2026-07-01 17:28:53 +03:00
parent 0c3c6d6d82
commit 06f1481787
16 changed files with 1502 additions and 11 deletions

View File

@@ -2,7 +2,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Ban, BadgeCheck, Crown, FileText, KeyRound, Loader2, Search, ShieldOff, ShieldPlus, UserCheck, UserCog } from 'lucide-react';
import { Ban, BadgeCheck, Crown, FileText, KeyRound, Loader2, ScrollText, Search, ShieldOff, ShieldPlus, UserCheck, UserCog } from 'lucide-react';
import { UserInspectorDialog } from '@/components/admin/user-inspector-dialog';
import { VerificationBadge } from '@/components/id/verification-badge';
import { UserDocumentsDialog } from '@/components/documents/user-documents-dialog';
import { AdminShell } from '@/components/id/admin-shell';
@@ -47,6 +48,7 @@ export default function AdminUsersPage() {
const [actionLoading, setActionLoading] = useState(false);
const [dialog, setDialog] = useState<'password' | 'roles' | 'verification' | null>(null);
const [documentsUser, setDocumentsUser] = useState<AdminUser | null>(null);
const [inspectorUser, setInspectorUser] = useState<AdminUser | null>(null);
const [verificationIcon, setVerificationIcon] = useState(DEFAULT_VERIFICATION_ICON);
const loadUsers = useCallback(async () => {
@@ -344,7 +346,14 @@ export default function AdminUsersPage() {
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableRow
key={user.id}
className={user.isBot ? undefined : 'cursor-pointer hover:bg-[#fafbff]'}
onClick={() => {
if (user.isBot) return;
setInspectorUser(user);
}}
>
<TableCell>
<div className="flex items-center gap-2">
<span className="font-medium">{user.displayName}</span>
@@ -358,7 +367,17 @@ export default function AdminUsersPage() {
<span className="rounded-full bg-[#f4f5f8] px-3 py-1 text-xs font-semibold">{statusLabels[user.status] ?? user.status}</span>
</TableCell>
<TableCell>
<div className="flex justify-end gap-2">
<div className="flex justify-end gap-2" onClick={(event) => event.stopPropagation()}>
{(currentUser?.canViewUsers || currentUser?.canManageUsers) && !user.isBot ? (
<Button
variant="secondary"
size="icon"
aria-label="Журнал и чаты"
onClick={() => setInspectorUser(user)}
>
<ScrollText className="h-4 w-4" />
</Button>
) : null}
{currentUser?.canManageUsers ? (
<>
<Button
@@ -586,6 +605,16 @@ export default function AdminUsersPage() {
token={token}
/>
) : null}
<UserInspectorDialog
user={inspectorUser}
token={token}
canModerateChats={Boolean(currentUser?.canModerateChats || currentUser?.isSuperAdmin)}
open={Boolean(inspectorUser)}
onOpenChange={(open) => {
if (!open) setInspectorUser(null);
}}
/>
</AdminShell>
);
}

View File

@@ -0,0 +1,580 @@
'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>
);
}

View File

@@ -107,6 +107,7 @@ export interface PublicUser {
verificationIcon?: string | null;
canVerifyUsers?: boolean;
canManageBots?: boolean;
canModerateChats?: boolean;
}
export interface AdminUser {
@@ -658,6 +659,50 @@ export interface SignInEvent {
createdAt: string;
}
export interface AdminUserActivityItem {
id: string;
action: string;
title: string;
detail?: string;
entityType?: string;
entityId?: string;
createdAt: string;
}
export interface AdminChatRoomSummary {
id: string;
type: string;
name: string;
groupId: string;
groupName: string;
memberCount: number;
messageCount: number;
members: Array<{ id: string; displayName: string }>;
lastMessage?: {
id: string;
senderName: string;
preview: string;
createdAt: string;
};
joinedAt: string;
}
export interface AdminChatMessage {
id: string;
senderId: string;
senderName: string;
type: string;
content?: string | null;
preview: string;
isEncrypted: boolean;
isDeleted: boolean;
createdAt: string;
editedAt?: string;
roomId?: string;
roomName?: string;
groupName?: string;
}
export interface ActiveSession {
id: string;
userId: string;