fix and update
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
import { Body, Controller, ForbiddenException, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
import { BadRequestException, Body, Controller, Delete, ForbiddenException, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import { map } from 'rxjs';
|
import { map } from 'rxjs';
|
||||||
import { CoreGrpcService } from '../core-grpc.service';
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
import { CurrentAdmin } from '../decorators/current-admin.decorator';
|
import { CurrentAdmin } from '../decorators/current-admin.decorator';
|
||||||
import { ListUsersQueryDto, ResetPasswordDto, SetSuperAdminDto, SetUserVerificationDto, UpdateUserDto } from '../dto/admin.dto';
|
import { ListUsersQueryDto, ResetPasswordDto, SetSuperAdminDto, SetUserVerificationDto, UpdateUserDto, UserInsightsQueryDto } from '../dto/admin.dto';
|
||||||
import { AdminGuard, AdminRequestUser, assertAdminPermission, SuperAdminGuard } from '../guards/admin.guard';
|
import { AdminGuard, AdminRequestUser, assertAdminPermission, SuperAdminGuard } from '../guards/admin.guard';
|
||||||
|
|
||||||
@ApiTags('Администрирование')
|
@ApiTags('Администрирование')
|
||||||
@@ -124,4 +124,112 @@ export class AdminController {
|
|||||||
verificationIcon: dto.verificationIcon
|
verificationIcon: dto.verificationIcon
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':userId/sign-in-history')
|
||||||
|
@ApiOperation({ summary: 'История входов пользователя', description: 'Журнал SignInEvent с поиском по IP, устройству и причине.' })
|
||||||
|
getUserSignInHistory(@Param('userId') userId: string, @Query() query: UserInsightsQueryDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||||
|
if (!admin.canViewUsers && !admin.canManageUsers) {
|
||||||
|
throw new ForbiddenException('Недостаточно прав для просмотра журнала пользователя');
|
||||||
|
}
|
||||||
|
return firstValueFrom(
|
||||||
|
this.core.admin.GetUserSignInHistory({
|
||||||
|
userId,
|
||||||
|
search: query.search,
|
||||||
|
limit: query.limit,
|
||||||
|
offset: query.offset
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':userId/activity')
|
||||||
|
@ApiOperation({ summary: 'Активность пользователя', description: 'Созданные документы, чаты, семьи, OAuth-согласия и журнал действий.' })
|
||||||
|
getUserActivity(@Param('userId') userId: string, @Query() query: UserInsightsQueryDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||||
|
if (!admin.canViewUsers && !admin.canManageUsers) {
|
||||||
|
throw new ForbiddenException('Недостаточно прав для просмотра активности пользователя');
|
||||||
|
}
|
||||||
|
return firstValueFrom(
|
||||||
|
this.core.admin.GetUserActivity({
|
||||||
|
userId,
|
||||||
|
search: query.search,
|
||||||
|
limit: query.limit,
|
||||||
|
offset: query.offset
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':userId/chats')
|
||||||
|
@ApiOperation({ summary: 'Чаты пользователя для модерации', description: 'Обычные чаты без E2E и ботов.' })
|
||||||
|
listUserChats(@Param('userId') userId: string, @Query() query: UserInsightsQueryDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||||
|
if (!admin.canModerateChats && !admin.isSuperAdmin) {
|
||||||
|
throw new ForbiddenException('Недостаточно прав для модерации чатов');
|
||||||
|
}
|
||||||
|
return firstValueFrom(
|
||||||
|
this.core.admin.ListUserChatRooms({
|
||||||
|
userId,
|
||||||
|
search: query.search,
|
||||||
|
limit: query.limit,
|
||||||
|
offset: query.offset
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':userId/chats/search')
|
||||||
|
@ApiOperation({ summary: 'Поиск по сообщениям пользователя', description: 'Поиск по обычным (не E2E) перепискам пользователя.' })
|
||||||
|
searchUserChats(@Param('userId') userId: string, @Query() query: UserInsightsQueryDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||||
|
if (!admin.canModerateChats && !admin.isSuperAdmin) {
|
||||||
|
throw new ForbiddenException('Недостаточно прав для модерации чатов');
|
||||||
|
}
|
||||||
|
if (!query.search?.trim()) {
|
||||||
|
throw new BadRequestException('Укажите параметр search');
|
||||||
|
}
|
||||||
|
return firstValueFrom(
|
||||||
|
this.core.admin.SearchUserChatMessages({
|
||||||
|
userId,
|
||||||
|
search: query.search.trim(),
|
||||||
|
limit: query.limit,
|
||||||
|
offset: query.offset
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':userId/chats/:roomId/messages')
|
||||||
|
@ApiOperation({ summary: 'Сообщения чата пользователя', description: 'Просмотр переписки в обычном чате для модерации.' })
|
||||||
|
listUserChatMessages(
|
||||||
|
@Param('userId') userId: string,
|
||||||
|
@Param('roomId') roomId: string,
|
||||||
|
@Query() query: UserInsightsQueryDto,
|
||||||
|
@CurrentAdmin() admin: AdminRequestUser
|
||||||
|
) {
|
||||||
|
if (!admin.canModerateChats && !admin.isSuperAdmin) {
|
||||||
|
throw new ForbiddenException('Недостаточно прав для модерации чатов');
|
||||||
|
}
|
||||||
|
return firstValueFrom(
|
||||||
|
this.core.admin.ListUserChatMessages({
|
||||||
|
userId,
|
||||||
|
roomId,
|
||||||
|
search: query.search,
|
||||||
|
limit: query.limit,
|
||||||
|
beforeMessageId: query.beforeMessageId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':userId/chat-messages/:messageId')
|
||||||
|
@ApiOperation({ summary: 'Удалить сообщение (модерация)', description: 'Мягкое удаление сообщения в обычном чате.' })
|
||||||
|
deleteUserChatMessage(
|
||||||
|
@Param('userId') userId: string,
|
||||||
|
@Param('messageId') messageId: string,
|
||||||
|
@CurrentAdmin() admin: AdminRequestUser
|
||||||
|
) {
|
||||||
|
if (!admin.canModerateChats && !admin.isSuperAdmin) {
|
||||||
|
throw new ForbiddenException('Недостаточно прав для модерации чатов');
|
||||||
|
}
|
||||||
|
void userId;
|
||||||
|
return firstValueFrom(
|
||||||
|
this.core.admin.AdminDeleteChatMessage({
|
||||||
|
actorUserId: admin.id,
|
||||||
|
messageId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,3 +69,23 @@ export class SetUserVerificationDto {
|
|||||||
@IsString({ message: 'Значок должен быть строкой' })
|
@IsString({ message: 'Значок должен быть строкой' })
|
||||||
verificationIcon?: string;
|
verificationIcon?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class UserInsightsQueryDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Поиск по событиям, IP, user-agent, тексту активности или чатам' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'Поисковая строка должна быть текстом' })
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Лимит записей', default: 50 })
|
||||||
|
@IsOptional()
|
||||||
|
limit?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Смещение для пагинации', default: 0 })
|
||||||
|
@IsOptional()
|
||||||
|
offset?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'ID сообщения для пагинации истории чата' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'beforeMessageId должно быть строкой' })
|
||||||
|
beforeMessageId?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface AdminRequestUser {
|
|||||||
canViewUsers: boolean;
|
canViewUsers: boolean;
|
||||||
canManageSettings: boolean;
|
canManageSettings: boolean;
|
||||||
canVerifyUsers: boolean;
|
canVerifyUsers: boolean;
|
||||||
|
canModerateChats: boolean;
|
||||||
roles: string[];
|
roles: string[];
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
}
|
}
|
||||||
@@ -60,6 +61,7 @@ export class AdminGuard implements CanActivate {
|
|||||||
canManageSettings: Boolean(profile.canManageSettings),
|
canManageSettings: Boolean(profile.canManageSettings),
|
||||||
canViewUsers: Boolean(profile.canViewUsers),
|
canViewUsers: Boolean(profile.canViewUsers),
|
||||||
canVerifyUsers: Boolean(profile.canVerifyUsers),
|
canVerifyUsers: Boolean(profile.canVerifyUsers),
|
||||||
|
canModerateChats: Boolean(profile.canModerateChats),
|
||||||
roles: profile.roles ?? [],
|
roles: profile.roles ?? [],
|
||||||
permissions: profile.permissions ?? []
|
permissions: profile.permissions ?? []
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
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 { VerificationBadge } from '@/components/id/verification-badge';
|
||||||
import { UserDocumentsDialog } from '@/components/documents/user-documents-dialog';
|
import { UserDocumentsDialog } from '@/components/documents/user-documents-dialog';
|
||||||
import { AdminShell } from '@/components/id/admin-shell';
|
import { AdminShell } from '@/components/id/admin-shell';
|
||||||
@@ -47,6 +48,7 @@ export default function AdminUsersPage() {
|
|||||||
const [actionLoading, setActionLoading] = useState(false);
|
const [actionLoading, setActionLoading] = useState(false);
|
||||||
const [dialog, setDialog] = useState<'password' | 'roles' | 'verification' | null>(null);
|
const [dialog, setDialog] = useState<'password' | 'roles' | 'verification' | null>(null);
|
||||||
const [documentsUser, setDocumentsUser] = useState<AdminUser | null>(null);
|
const [documentsUser, setDocumentsUser] = useState<AdminUser | null>(null);
|
||||||
|
const [inspectorUser, setInspectorUser] = useState<AdminUser | null>(null);
|
||||||
const [verificationIcon, setVerificationIcon] = useState(DEFAULT_VERIFICATION_ICON);
|
const [verificationIcon, setVerificationIcon] = useState(DEFAULT_VERIFICATION_ICON);
|
||||||
|
|
||||||
const loadUsers = useCallback(async () => {
|
const loadUsers = useCallback(async () => {
|
||||||
@@ -344,7 +346,14 @@ export default function AdminUsersPage() {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{users.map((user) => (
|
{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>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium">{user.displayName}</span>
|
<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>
|
<span className="rounded-full bg-[#f4f5f8] px-3 py-1 text-xs font-semibold">{statusLabels[user.status] ?? user.status}</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<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 ? (
|
{currentUser?.canManageUsers ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
@@ -586,6 +605,16 @@ export default function AdminUsersPage() {
|
|||||||
token={token}
|
token={token}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<UserInspectorDialog
|
||||||
|
user={inspectorUser}
|
||||||
|
token={token}
|
||||||
|
canModerateChats={Boolean(currentUser?.canModerateChats || currentUser?.isSuperAdmin)}
|
||||||
|
open={Boolean(inspectorUser)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setInspectorUser(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</AdminShell>
|
</AdminShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
580
apps/frontend/components/admin/user-inspector-dialog.tsx
Normal file
580
apps/frontend/components/admin/user-inspector-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -107,6 +107,7 @@ export interface PublicUser {
|
|||||||
verificationIcon?: string | null;
|
verificationIcon?: string | null;
|
||||||
canVerifyUsers?: boolean;
|
canVerifyUsers?: boolean;
|
||||||
canManageBots?: boolean;
|
canManageBots?: boolean;
|
||||||
|
canModerateChats?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminUser {
|
export interface AdminUser {
|
||||||
@@ -658,6 +659,50 @@ export interface SignInEvent {
|
|||||||
createdAt: string;
|
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 {
|
export interface ActiveSession {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ model User {
|
|||||||
sessions Session[]
|
sessions Session[]
|
||||||
devices Device[]
|
devices Device[]
|
||||||
signInEvents SignInEvent[]
|
signInEvents SignInEvent[]
|
||||||
|
activityLogs UserActivityLog[]
|
||||||
authCodes AuthCode[]
|
authCodes AuthCode[]
|
||||||
linkedAccounts LinkedAccount[]
|
linkedAccounts LinkedAccount[]
|
||||||
userRoles UserRole[]
|
userRoles UserRole[]
|
||||||
@@ -171,6 +172,24 @@ model SignInEvent {
|
|||||||
@@index([userId, createdAt])
|
@@index([userId, createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model UserActivityLog {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
action String
|
||||||
|
title String
|
||||||
|
detail String?
|
||||||
|
entityType String?
|
||||||
|
entityId String?
|
||||||
|
ipAddress String?
|
||||||
|
userAgent String?
|
||||||
|
actorId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId, createdAt])
|
||||||
|
@@index([userId, action])
|
||||||
|
}
|
||||||
|
|
||||||
model LinkedAccount {
|
model LinkedAccount {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
userId String
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { JwtModule } from '@nestjs/jwt';
|
|||||||
import { AccessService } from './domain/access.service';
|
import { AccessService } from './domain/access.service';
|
||||||
import { AdminSeedService } from './domain/admin-seed.service';
|
import { AdminSeedService } from './domain/admin-seed.service';
|
||||||
import { AdminService } from './domain/admin.service';
|
import { AdminService } from './domain/admin.service';
|
||||||
|
import { AdminUserInsightsService } from './domain/admin-user-insights.service';
|
||||||
|
import { ActivityLogService } from './domain/activity-log.service';
|
||||||
import { AuthGrpcController } from './domain/auth-grpc.controller';
|
import { AuthGrpcController } from './domain/auth-grpc.controller';
|
||||||
import { AuthService } from './domain/auth.service';
|
import { AuthService } from './domain/auth.service';
|
||||||
import { PinService } from './domain/pin.service';
|
import { PinService } from './domain/pin.service';
|
||||||
@@ -70,6 +72,8 @@ import { BotFatherAssistantService } from './domain/bot/bot-father-assistant.ser
|
|||||||
AccessService,
|
AccessService,
|
||||||
AdminSeedService,
|
AdminSeedService,
|
||||||
AdminService,
|
AdminService,
|
||||||
|
AdminUserInsightsService,
|
||||||
|
ActivityLogService,
|
||||||
RbacService,
|
RbacService,
|
||||||
OAuthSslSansService,
|
OAuthSslSansService,
|
||||||
SecurityService,
|
SecurityService,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface UserAccessContext {
|
|||||||
canViewUserDocuments: boolean;
|
canViewUserDocuments: boolean;
|
||||||
canVerifyUsers: boolean;
|
canVerifyUsers: boolean;
|
||||||
canManageBots: boolean;
|
canManageBots: boolean;
|
||||||
|
canModerateChats: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasAnyPermission(permissions: string[], ...slugs: string[]) {
|
function hasAnyPermission(permissions: string[], ...slugs: string[]) {
|
||||||
@@ -44,7 +45,8 @@ export class AccessService {
|
|||||||
'rbac.manage',
|
'rbac.manage',
|
||||||
'documents.view_others',
|
'documents.view_others',
|
||||||
'users.verify',
|
'users.verify',
|
||||||
'bots.manage.all'
|
'bots.manage.all',
|
||||||
|
'chat.moderate'
|
||||||
],
|
],
|
||||||
canAccessAdmin: true,
|
canAccessAdmin: true,
|
||||||
canManageRoles: true,
|
canManageRoles: true,
|
||||||
@@ -57,7 +59,8 @@ export class AccessService {
|
|||||||
canManageSettings: true,
|
canManageSettings: true,
|
||||||
canViewUserDocuments: true,
|
canViewUserDocuments: true,
|
||||||
canVerifyUsers: true,
|
canVerifyUsers: true,
|
||||||
canManageBots: true
|
canManageBots: true,
|
||||||
|
canModerateChats: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +109,8 @@ export class AccessService {
|
|||||||
canManageSettings: permissions.includes('settings.manage'),
|
canManageSettings: permissions.includes('settings.manage'),
|
||||||
canViewUserDocuments: permissions.includes('documents.view_others'),
|
canViewUserDocuments: permissions.includes('documents.view_others'),
|
||||||
canVerifyUsers: permissions.includes('users.verify'),
|
canVerifyUsers: permissions.includes('users.verify'),
|
||||||
canManageBots: permissions.includes('bots.manage.all')
|
canManageBots: permissions.includes('bots.manage.all'),
|
||||||
|
canModerateChats: permissions.includes('chat.moderate')
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
39
apps/sso-core/src/domain/activity-log.service.ts
Normal file
39
apps/sso-core/src/domain/activity-log.service.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../infra/prisma.service';
|
||||||
|
|
||||||
|
export interface RecordActivityInput {
|
||||||
|
userId: string;
|
||||||
|
action: string;
|
||||||
|
title: string;
|
||||||
|
detail?: string;
|
||||||
|
entityType?: string;
|
||||||
|
entityId?: string;
|
||||||
|
ipAddress?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
actorId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ActivityLogService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async record(input: RecordActivityInput) {
|
||||||
|
return this.prisma.userActivityLog.create({
|
||||||
|
data: {
|
||||||
|
userId: input.userId,
|
||||||
|
action: input.action,
|
||||||
|
title: input.title,
|
||||||
|
detail: input.detail,
|
||||||
|
entityType: input.entityType,
|
||||||
|
entityId: input.entityId,
|
||||||
|
ipAddress: input.ipAddress,
|
||||||
|
userAgent: input.userAgent,
|
||||||
|
actorId: input.actorId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
recordSafe(input: RecordActivityInput) {
|
||||||
|
void this.record(input).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ const PERMISSIONS = [
|
|||||||
{ slug: 'settings.manage', name: 'Управление настройками', description: 'Изменение глобальных SystemSetting' },
|
{ slug: 'settings.manage', name: 'Управление настройками', description: 'Изменение глобальных SystemSetting' },
|
||||||
{ slug: 'rbac.manage', name: 'Управление ролями и правами', description: 'Назначение ролей, прав пользователям и редактирование ролей' },
|
{ slug: 'rbac.manage', name: 'Управление ролями и правами', description: 'Назначение ролей, прав пользователям и редактирование ролей' },
|
||||||
{ slug: 'documents.view_others', name: 'Просмотр документов пользователей', description: 'Просмотр документов и фото других пользователей' },
|
{ slug: 'documents.view_others', name: 'Просмотр документов пользователей', description: 'Просмотр документов и фото других пользователей' },
|
||||||
|
{ slug: 'chat.moderate', name: 'Модерация чатов', description: 'Просмотр обычных переписок пользователей и удаление сообщений' },
|
||||||
{ slug: 'bots.manage.all', name: 'Управление всеми ботами', description: 'Просмотр, блокировка и администрирование всех Telegram-ботов' }
|
{ slug: 'bots.manage.all', name: 'Управление всеми ботами', description: 'Просмотр, блокировка и администрирование всех Telegram-ботов' }
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@ const ROLES = [
|
|||||||
slug: 'admin',
|
slug: 'admin',
|
||||||
name: 'Администратор',
|
name: 'Администратор',
|
||||||
description: 'Полный доступ к админ-панели',
|
description: 'Полный доступ к админ-панели',
|
||||||
permissions: ['admin.access', 'oauth.manage.all', 'oauth.view.all', 'users.manage.all', 'users.view.all', 'users.verify', 'settings.manage', 'rbac.manage', 'bots.manage.all'],
|
permissions: ['admin.access', 'oauth.manage.all', 'oauth.view.all', 'users.manage.all', 'users.view.all', 'users.verify', 'settings.manage', 'rbac.manage', 'bots.manage.all', 'chat.moderate'],
|
||||||
isSystem: false,
|
isSystem: false,
|
||||||
isDefault: false
|
isDefault: false
|
||||||
},
|
},
|
||||||
@@ -50,7 +51,7 @@ const ROLES = [
|
|||||||
slug: 'moderator',
|
slug: 'moderator',
|
||||||
name: 'Модератор',
|
name: 'Модератор',
|
||||||
description: 'Просмотр пользователей и базовый доступ к админке',
|
description: 'Просмотр пользователей и базовый доступ к админке',
|
||||||
permissions: ['admin.access', 'users.view', 'users.view.all'],
|
permissions: ['admin.access', 'users.view', 'users.view.all', 'chat.moderate'],
|
||||||
isSystem: false,
|
isSystem: false,
|
||||||
isDefault: false
|
isDefault: false
|
||||||
},
|
},
|
||||||
|
|||||||
474
apps/sso-core/src/domain/admin-user-insights.service.ts
Normal file
474
apps/sso-core/src/domain/admin-user-insights.service.ts
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../infra/prisma.service';
|
||||||
|
|
||||||
|
const MODERATABLE_ROOM_FILTER = {
|
||||||
|
isE2E: false,
|
||||||
|
type: { notIn: ['E2E', 'BOT'] as string[] }
|
||||||
|
};
|
||||||
|
|
||||||
|
const DOCUMENT_TYPE_LABELS: Record<string, string> = {
|
||||||
|
PASSPORT: 'Паспорт',
|
||||||
|
DRIVER_LICENSE: 'Водительское удостоверение',
|
||||||
|
SNILS: 'СНИЛС',
|
||||||
|
INN: 'ИНН',
|
||||||
|
BIRTH_CERTIFICATE: 'Свидетельство о рождении',
|
||||||
|
OTHER: 'Документ'
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROOM_TYPE_LABELS: Record<string, string> = {
|
||||||
|
GENERAL: 'Общий чат семьи',
|
||||||
|
DIRECT: 'Личный чат',
|
||||||
|
GROUP: 'Групповой чат'
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminUserInsightsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async ensureHumanUser(userId: string) {
|
||||||
|
const user = await this.prisma.user.findFirst({
|
||||||
|
where: { id: userId, deletedAt: null, isSystemAccount: false, linkedBotId: null }
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('Пользователь не найден');
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSignInHistory(userId: string, search?: string, limit = 50, offset = 0) {
|
||||||
|
await this.ensureHumanUser(userId);
|
||||||
|
const take = Math.min(Math.max(limit, 1), 100);
|
||||||
|
const skip = Math.max(offset, 0);
|
||||||
|
const searchTerm = search?.trim();
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
userId,
|
||||||
|
...(searchTerm
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ ipAddress: { contains: searchTerm, mode: 'insensitive' as const } },
|
||||||
|
{ userAgent: { contains: searchTerm, mode: 'insensitive' as const } },
|
||||||
|
{ reason: { contains: searchTerm, mode: 'insensitive' as const } },
|
||||||
|
{ device: { name: { contains: searchTerm, mode: 'insensitive' as const } } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
};
|
||||||
|
|
||||||
|
const [events, total] = await Promise.all([
|
||||||
|
this.prisma.signInEvent.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take,
|
||||||
|
skip,
|
||||||
|
include: { device: true }
|
||||||
|
}),
|
||||||
|
this.prisma.signInEvent.count({ where })
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
events: events.map((event) => ({
|
||||||
|
id: event.id,
|
||||||
|
success: event.success,
|
||||||
|
reason: event.reason ?? undefined,
|
||||||
|
ipAddress: event.ipAddress ?? undefined,
|
||||||
|
userAgent: event.userAgent ?? undefined,
|
||||||
|
deviceName: event.device?.name ?? undefined,
|
||||||
|
createdAt: event.createdAt.toISOString()
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActivityTimeline(userId: string, search?: string, limit = 50, offset = 0) {
|
||||||
|
await this.ensureHumanUser(userId);
|
||||||
|
const searchTerm = search?.trim().toLowerCase();
|
||||||
|
const take = Math.min(Math.max(limit, 1), 100);
|
||||||
|
|
||||||
|
const [logs, documents, messages, rooms, families, consents] = await Promise.all([
|
||||||
|
this.prisma.userActivityLog.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 200
|
||||||
|
}),
|
||||||
|
this.prisma.userDocument.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 100
|
||||||
|
}),
|
||||||
|
this.prisma.chatMessage.findMany({
|
||||||
|
where: {
|
||||||
|
senderId: userId,
|
||||||
|
deletedAt: null,
|
||||||
|
room: MODERATABLE_ROOM_FILTER
|
||||||
|
},
|
||||||
|
include: { room: { include: { group: true } } },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 100
|
||||||
|
}),
|
||||||
|
this.prisma.chatRoom.findMany({
|
||||||
|
where: { createdById: userId, ...MODERATABLE_ROOM_FILTER },
|
||||||
|
include: { group: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 50
|
||||||
|
}),
|
||||||
|
this.prisma.familyGroup.findMany({
|
||||||
|
where: { ownerId: userId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 50
|
||||||
|
}),
|
||||||
|
this.prisma.oAuthConsent.findMany({
|
||||||
|
where: { userId },
|
||||||
|
include: { client: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 50
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
type TimelineItem = {
|
||||||
|
id: string;
|
||||||
|
action: string;
|
||||||
|
title: string;
|
||||||
|
detail?: string;
|
||||||
|
entityType?: string;
|
||||||
|
entityId?: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const items: TimelineItem[] = [
|
||||||
|
...logs.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
action: item.action,
|
||||||
|
title: item.title,
|
||||||
|
detail: item.detail ?? undefined,
|
||||||
|
entityType: item.entityType ?? undefined,
|
||||||
|
entityId: item.entityId ?? undefined,
|
||||||
|
createdAt: item.createdAt.toISOString()
|
||||||
|
})),
|
||||||
|
...documents.map((doc) => ({
|
||||||
|
id: `doc-${doc.id}`,
|
||||||
|
action: 'DOCUMENT_CREATED',
|
||||||
|
title: 'Добавлен документ',
|
||||||
|
detail: `${DOCUMENT_TYPE_LABELS[doc.type] ?? doc.type} · ${doc.number}`,
|
||||||
|
entityType: 'document',
|
||||||
|
entityId: doc.id,
|
||||||
|
createdAt: doc.createdAt.toISOString()
|
||||||
|
})),
|
||||||
|
...messages.map((message) => ({
|
||||||
|
id: `msg-${message.id}`,
|
||||||
|
action: 'CHAT_MESSAGE_SENT',
|
||||||
|
title: 'Сообщение в чате',
|
||||||
|
detail: this.formatMessagePreview(message.type, message.content, message.isEncrypted),
|
||||||
|
entityType: 'chat_message',
|
||||||
|
entityId: message.id,
|
||||||
|
createdAt: message.createdAt.toISOString()
|
||||||
|
})),
|
||||||
|
...rooms.map((room) => ({
|
||||||
|
id: `room-${room.id}`,
|
||||||
|
action: 'CHAT_ROOM_CREATED',
|
||||||
|
title: 'Создан чат',
|
||||||
|
detail: `${ROOM_TYPE_LABELS[room.type] ?? room.type}: ${room.name} (${room.group.name})`,
|
||||||
|
entityType: 'chat_room',
|
||||||
|
entityId: room.id,
|
||||||
|
createdAt: room.createdAt.toISOString()
|
||||||
|
})),
|
||||||
|
...families.map((group) => ({
|
||||||
|
id: `family-${group.id}`,
|
||||||
|
action: 'FAMILY_CREATED',
|
||||||
|
title: 'Создана семья',
|
||||||
|
detail: group.name,
|
||||||
|
entityType: 'family',
|
||||||
|
entityId: group.id,
|
||||||
|
createdAt: group.createdAt.toISOString()
|
||||||
|
})),
|
||||||
|
...consents.map((consent) => ({
|
||||||
|
id: `oauth-${consent.id}`,
|
||||||
|
action: 'OAUTH_CONSENT',
|
||||||
|
title: 'Разрешён доступ приложению',
|
||||||
|
detail: consent.client.name,
|
||||||
|
entityType: 'oauth_consent',
|
||||||
|
entityId: consent.id,
|
||||||
|
createdAt: consent.createdAt.toISOString()
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
|
||||||
|
const deduped = [...new Map(items.map((item) => [item.id, item])).values()]
|
||||||
|
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||||
|
.filter((item) => {
|
||||||
|
if (!searchTerm) return true;
|
||||||
|
const haystack = `${item.title} ${item.detail ?? ''} ${item.action}`.toLowerCase();
|
||||||
|
return haystack.includes(searchTerm);
|
||||||
|
});
|
||||||
|
|
||||||
|
const skip = Math.max(offset, 0);
|
||||||
|
return {
|
||||||
|
total: deduped.length,
|
||||||
|
items: deduped.slice(skip, skip + take)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listUserChatRooms(userId: string, search?: string, limit = 50, offset = 0) {
|
||||||
|
await this.ensureHumanUser(userId);
|
||||||
|
const take = Math.min(Math.max(limit, 1), 100);
|
||||||
|
const skip = Math.max(offset, 0);
|
||||||
|
const searchTerm = search?.trim();
|
||||||
|
|
||||||
|
const memberships = await this.prisma.chatRoomMember.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
room: {
|
||||||
|
...MODERATABLE_ROOM_FILTER,
|
||||||
|
...(searchTerm
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: searchTerm, mode: 'insensitive' } },
|
||||||
|
{ group: { name: { contains: searchTerm, mode: 'insensitive' } } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
room: {
|
||||||
|
include: {
|
||||||
|
group: true,
|
||||||
|
members: {
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, displayName: true } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
where: { deletedAt: null },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 1,
|
||||||
|
include: { sender: { select: { displayName: true } } }
|
||||||
|
},
|
||||||
|
_count: { select: { messages: true } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: { joinedAt: 'desc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const rooms = memberships.map((membership) => {
|
||||||
|
const room = membership.room;
|
||||||
|
const lastMessage = room.messages[0];
|
||||||
|
return {
|
||||||
|
id: room.id,
|
||||||
|
type: room.type,
|
||||||
|
name: room.name,
|
||||||
|
groupId: room.groupId,
|
||||||
|
groupName: room.group.name,
|
||||||
|
memberCount: room.members.length,
|
||||||
|
messageCount: room._count.messages,
|
||||||
|
members: room.members.map((member) => ({
|
||||||
|
id: member.user.id,
|
||||||
|
displayName: member.user.displayName
|
||||||
|
})),
|
||||||
|
lastMessage: lastMessage
|
||||||
|
? {
|
||||||
|
id: lastMessage.id,
|
||||||
|
senderName: lastMessage.sender.displayName,
|
||||||
|
preview: this.formatMessagePreview(lastMessage.type, lastMessage.content, lastMessage.isEncrypted),
|
||||||
|
createdAt: lastMessage.createdAt.toISOString()
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
joinedAt: membership.joinedAt.toISOString()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: rooms.length,
|
||||||
|
rooms: rooms.slice(skip, skip + take)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRoomMessages(userId: string, roomId: string, search?: string, limit = 50, beforeMessageId?: string) {
|
||||||
|
await this.ensureHumanUser(userId);
|
||||||
|
const room = await this.getModeratableRoom(roomId);
|
||||||
|
const member = await this.prisma.chatRoomMember.findUnique({
|
||||||
|
where: { roomId_userId: { roomId, userId } }
|
||||||
|
});
|
||||||
|
if (!member) {
|
||||||
|
throw new ForbiddenException('Пользователь не состоит в этом чате');
|
||||||
|
}
|
||||||
|
|
||||||
|
const take = Math.min(Math.max(limit, 1), 100);
|
||||||
|
const searchTerm = search?.trim();
|
||||||
|
|
||||||
|
let cursorDate: Date | undefined;
|
||||||
|
if (beforeMessageId) {
|
||||||
|
const cursor = await this.prisma.chatMessage.findFirst({ where: { id: beforeMessageId, roomId } });
|
||||||
|
if (cursor) cursorDate = cursor.createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = await this.prisma.chatMessage.findMany({
|
||||||
|
where: {
|
||||||
|
roomId,
|
||||||
|
...(cursorDate ? { createdAt: { lt: cursorDate } } : {}),
|
||||||
|
...(searchTerm
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ content: { contains: searchTerm, mode: 'insensitive' } },
|
||||||
|
{ sender: { displayName: { contains: searchTerm, mode: 'insensitive' } } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
},
|
||||||
|
include: { sender: { select: { id: true, displayName: true } } },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
room: {
|
||||||
|
id: room.id,
|
||||||
|
name: room.name,
|
||||||
|
type: room.type,
|
||||||
|
groupName: room.group.name
|
||||||
|
},
|
||||||
|
messages: messages.reverse().map((message) => this.toAdminMessage(message))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchUserChatMessages(userId: string, search?: string, limit = 50, offset = 0) {
|
||||||
|
await this.ensureHumanUser(userId);
|
||||||
|
const searchTerm = search?.trim();
|
||||||
|
if (!searchTerm) {
|
||||||
|
throw new BadRequestException('Укажите текст для поиска по сообщениям');
|
||||||
|
}
|
||||||
|
|
||||||
|
const take = Math.min(Math.max(limit, 1), 100);
|
||||||
|
const skip = Math.max(offset, 0);
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
room: {
|
||||||
|
members: { some: { userId } },
|
||||||
|
...MODERATABLE_ROOM_FILTER
|
||||||
|
},
|
||||||
|
deletedAt: null,
|
||||||
|
OR: [
|
||||||
|
{ content: { contains: searchTerm, mode: 'insensitive' as const } },
|
||||||
|
{ sender: { displayName: { contains: searchTerm, mode: 'insensitive' as const } } }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const [messages, total] = await Promise.all([
|
||||||
|
this.prisma.chatMessage.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
sender: { select: { id: true, displayName: true } },
|
||||||
|
room: { include: { group: true } }
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take,
|
||||||
|
skip
|
||||||
|
}),
|
||||||
|
this.prisma.chatMessage.count({ where })
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
messages: messages.map((message) => ({
|
||||||
|
...this.toAdminMessage(message),
|
||||||
|
roomId: message.roomId,
|
||||||
|
roomName: message.room.name,
|
||||||
|
groupName: message.room.group.name
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async adminDeleteMessage(actorId: string, messageId: string) {
|
||||||
|
const message = await this.prisma.chatMessage.findUnique({
|
||||||
|
where: { id: messageId },
|
||||||
|
include: { room: { include: { group: true } }, sender: true }
|
||||||
|
});
|
||||||
|
if (!message) {
|
||||||
|
throw new NotFoundException('Сообщение не найдено');
|
||||||
|
}
|
||||||
|
if (message.room.isE2E || message.room.type === 'E2E') {
|
||||||
|
throw new ForbiddenException('E2E-сообщения недоступны для модерации');
|
||||||
|
}
|
||||||
|
if (message.room.type === 'BOT') {
|
||||||
|
throw new ForbiddenException('Сообщения бота модерируются отдельно');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.chatMessage.update({
|
||||||
|
where: { id: messageId },
|
||||||
|
data: {
|
||||||
|
deletedAt: new Date(),
|
||||||
|
content: null,
|
||||||
|
storageKey: null,
|
||||||
|
mimeType: null,
|
||||||
|
metadata: { moderated: true, moderatedBy: actorId }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.prisma.userActivityLog.create({
|
||||||
|
data: {
|
||||||
|
userId: message.senderId,
|
||||||
|
action: 'ADMIN_MESSAGE_DELETED',
|
||||||
|
title: 'Сообщение удалено модератором',
|
||||||
|
detail: `Чат «${message.room.name}» (${message.room.group.name})`,
|
||||||
|
entityType: 'chat_message',
|
||||||
|
entityId: message.id,
|
||||||
|
actorId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getModeratableRoom(roomId: string) {
|
||||||
|
const room = await this.prisma.chatRoom.findUnique({
|
||||||
|
where: { id: roomId },
|
||||||
|
include: { group: true }
|
||||||
|
});
|
||||||
|
if (!room) {
|
||||||
|
throw new NotFoundException('Чат не найден');
|
||||||
|
}
|
||||||
|
if (room.isE2E || room.type === 'E2E' || room.type === 'BOT') {
|
||||||
|
throw new ForbiddenException('Этот чат недоступен для модерации');
|
||||||
|
}
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatMessagePreview(type: string, content: string | null, isEncrypted: boolean) {
|
||||||
|
if (isEncrypted) {
|
||||||
|
return '[Зашифрованное сообщение]';
|
||||||
|
}
|
||||||
|
if (type === 'IMAGE') return '[Изображение]';
|
||||||
|
if (type === 'AUDIO' || type === 'VOICE') return '[Аудио]';
|
||||||
|
if (type === 'FILE') return '[Файл]';
|
||||||
|
if (type === 'POLL') return '[Опрос]';
|
||||||
|
if (type === 'SYSTEM') return '[Системное сообщение]';
|
||||||
|
const text = content?.trim();
|
||||||
|
if (!text) return '[Пустое сообщение]';
|
||||||
|
return text.length > 160 ? `${text.slice(0, 157)}…` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toAdminMessage(message: {
|
||||||
|
id: string;
|
||||||
|
senderId: string;
|
||||||
|
type: string;
|
||||||
|
content: string | null;
|
||||||
|
isEncrypted: boolean;
|
||||||
|
deletedAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
editedAt: Date | null;
|
||||||
|
sender: { id: string; displayName: string };
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
id: message.id,
|
||||||
|
senderId: message.senderId,
|
||||||
|
senderName: message.sender.displayName,
|
||||||
|
type: message.type,
|
||||||
|
content: message.isEncrypted ? null : message.content,
|
||||||
|
preview: this.formatMessagePreview(message.type, message.content, message.isEncrypted),
|
||||||
|
isEncrypted: message.isEncrypted,
|
||||||
|
isDeleted: Boolean(message.deletedAt),
|
||||||
|
createdAt: message.createdAt.toISOString(),
|
||||||
|
editedAt: message.editedAt?.toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { BadRequestException, Controller, UseFilters } from '@nestjs/common';
|
|||||||
import { GrpcMethod } from '@nestjs/microservices';
|
import { GrpcMethod } from '@nestjs/microservices';
|
||||||
import { GrpcExceptionFilter } from '../infra/grpc-exception.filter';
|
import { GrpcExceptionFilter } from '../infra/grpc-exception.filter';
|
||||||
import { AdminService } from './admin.service';
|
import { AdminService } from './admin.service';
|
||||||
|
import { AdminUserInsightsService } from './admin-user-insights.service';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { LoginCommand, RegisterCommand, VerifyPinCommand } from './dto';
|
import { LoginCommand, RegisterCommand, VerifyPinCommand } from './dto';
|
||||||
import { PinService } from './pin.service';
|
import { PinService } from './pin.service';
|
||||||
@@ -36,6 +37,7 @@ export class AuthGrpcController {
|
|||||||
private readonly pin: PinService,
|
private readonly pin: PinService,
|
||||||
private readonly session: SessionService,
|
private readonly session: SessionService,
|
||||||
private readonly admin: AdminService,
|
private readonly admin: AdminService,
|
||||||
|
private readonly adminInsights: AdminUserInsightsService,
|
||||||
private readonly rbac: RbacService,
|
private readonly rbac: RbacService,
|
||||||
private readonly security: SecurityService,
|
private readonly security: SecurityService,
|
||||||
private readonly settings: SettingsService,
|
private readonly settings: SettingsService,
|
||||||
@@ -184,6 +186,42 @@ export class AuthGrpcController {
|
|||||||
return this.admin.listVerificationIcons();
|
return this.admin.listVerificationIcons();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GrpcMethod('AdminService', 'GetUserSignInHistory')
|
||||||
|
getUserSignInHistory(command: { userId: string; search?: string; limit?: number; offset?: number }) {
|
||||||
|
return this.adminInsights.getSignInHistory(command.userId, command.search, command.limit, command.offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GrpcMethod('AdminService', 'GetUserActivity')
|
||||||
|
getUserActivity(command: { userId: string; search?: string; limit?: number; offset?: number }) {
|
||||||
|
return this.adminInsights.getActivityTimeline(command.userId, command.search, command.limit, command.offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GrpcMethod('AdminService', 'ListUserChatRooms')
|
||||||
|
listUserChatRooms(command: { userId: string; search?: string; limit?: number; offset?: number }) {
|
||||||
|
return this.adminInsights.listUserChatRooms(command.userId, command.search, command.limit, command.offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GrpcMethod('AdminService', 'ListUserChatMessages')
|
||||||
|
listUserChatMessages(command: { userId: string; roomId: string; search?: string; limit?: number; beforeMessageId?: string }) {
|
||||||
|
return this.adminInsights.listRoomMessages(
|
||||||
|
command.userId,
|
||||||
|
command.roomId,
|
||||||
|
command.search,
|
||||||
|
command.limit,
|
||||||
|
command.beforeMessageId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GrpcMethod('AdminService', 'SearchUserChatMessages')
|
||||||
|
searchUserChatMessages(command: { userId: string; search: string; limit?: number; offset?: number }) {
|
||||||
|
return this.adminInsights.searchUserChatMessages(command.userId, command.search, command.limit, command.offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GrpcMethod('AdminService', 'AdminDeleteChatMessage')
|
||||||
|
adminDeleteChatMessage(command: { actorUserId: string; messageId: string }) {
|
||||||
|
return this.adminInsights.adminDeleteMessage(command.actorUserId, command.messageId);
|
||||||
|
}
|
||||||
|
|
||||||
@GrpcMethod('RbacService', 'ListRoles')
|
@GrpcMethod('RbacService', 'ListRoles')
|
||||||
async listRoles() {
|
async listRoles() {
|
||||||
const roles = await this.rbac.listRoles();
|
const roles = await this.rbac.listRoles();
|
||||||
|
|||||||
@@ -932,7 +932,8 @@ export class AuthService implements OnModuleInit {
|
|||||||
canViewUsers: access.canViewUsers,
|
canViewUsers: access.canViewUsers,
|
||||||
canViewUserDocuments: access.canViewUserDocuments,
|
canViewUserDocuments: access.canViewUserDocuments,
|
||||||
canVerifyUsers: access.canVerifyUsers,
|
canVerifyUsers: access.canVerifyUsers,
|
||||||
canManageBots: access.canManageBots
|
canManageBots: access.canManageBots,
|
||||||
|
canModerateChats: access.canModerateChats
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export interface PublicUser {
|
|||||||
verificationIcon?: string;
|
verificationIcon?: string;
|
||||||
canVerifyUsers?: boolean;
|
canVerifyUsers?: boolean;
|
||||||
canManageBots?: boolean;
|
canManageBots?: boolean;
|
||||||
|
canModerateChats?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VerifyPinCommand {
|
export interface VerifyPinCommand {
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ service AdminService {
|
|||||||
rpc SetSuperAdmin (SetSuperAdminRequest) returns (AdminUser);
|
rpc SetSuperAdmin (SetSuperAdminRequest) returns (AdminUser);
|
||||||
rpc SetUserVerification (SetUserVerificationRequest) returns (AdminUser);
|
rpc SetUserVerification (SetUserVerificationRequest) returns (AdminUser);
|
||||||
rpc ListVerificationIcons (Empty) returns (ListVerificationIconsResponse);
|
rpc ListVerificationIcons (Empty) returns (ListVerificationIconsResponse);
|
||||||
|
rpc GetUserSignInHistory (GetUserInsightsRequest) returns (UserSignInHistoryResponse);
|
||||||
|
rpc GetUserActivity (GetUserInsightsRequest) returns (UserActivityResponse);
|
||||||
|
rpc ListUserChatRooms (GetUserInsightsRequest) returns (UserChatRoomsResponse);
|
||||||
|
rpc ListUserChatMessages (ListUserChatMessagesRequest) returns (UserChatMessagesResponse);
|
||||||
|
rpc SearchUserChatMessages (SearchUserChatMessagesRequest) returns (UserChatMessageSearchResponse);
|
||||||
|
rpc AdminDeleteChatMessage (AdminDeleteChatMessageRequest) returns (Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
message Empty {}
|
message Empty {}
|
||||||
@@ -83,3 +89,123 @@ message AdminUser {
|
|||||||
message ListUsersResponse {
|
message ListUsersResponse {
|
||||||
repeated AdminUser users = 1;
|
repeated AdminUser users = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message GetUserInsightsRequest {
|
||||||
|
string userId = 1;
|
||||||
|
optional string search = 2;
|
||||||
|
optional int32 limit = 3;
|
||||||
|
optional int32 offset = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AdminSignInEvent {
|
||||||
|
string id = 1;
|
||||||
|
bool success = 2;
|
||||||
|
optional string reason = 3;
|
||||||
|
optional string ipAddress = 4;
|
||||||
|
optional string userAgent = 5;
|
||||||
|
optional string deviceName = 6;
|
||||||
|
string createdAt = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UserSignInHistoryResponse {
|
||||||
|
int32 total = 1;
|
||||||
|
repeated AdminSignInEvent events = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UserActivityItem {
|
||||||
|
string id = 1;
|
||||||
|
string action = 2;
|
||||||
|
string title = 3;
|
||||||
|
optional string detail = 4;
|
||||||
|
optional string entityType = 5;
|
||||||
|
optional string entityId = 6;
|
||||||
|
string createdAt = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UserActivityResponse {
|
||||||
|
int32 total = 1;
|
||||||
|
repeated UserActivityItem items = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AdminChatRoomMember {
|
||||||
|
string id = 1;
|
||||||
|
string displayName = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AdminChatLastMessage {
|
||||||
|
string id = 1;
|
||||||
|
string senderName = 2;
|
||||||
|
string preview = 3;
|
||||||
|
string createdAt = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AdminChatRoomSummary {
|
||||||
|
string id = 1;
|
||||||
|
string type = 2;
|
||||||
|
string name = 3;
|
||||||
|
string groupId = 4;
|
||||||
|
string groupName = 5;
|
||||||
|
int32 memberCount = 6;
|
||||||
|
int32 messageCount = 7;
|
||||||
|
repeated AdminChatRoomMember members = 8;
|
||||||
|
optional AdminChatLastMessage lastMessage = 9;
|
||||||
|
string joinedAt = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UserChatRoomsResponse {
|
||||||
|
int32 total = 1;
|
||||||
|
repeated AdminChatRoomSummary rooms = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListUserChatMessagesRequest {
|
||||||
|
string userId = 1;
|
||||||
|
string roomId = 2;
|
||||||
|
optional string search = 3;
|
||||||
|
optional int32 limit = 4;
|
||||||
|
optional string beforeMessageId = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SearchUserChatMessagesRequest {
|
||||||
|
string userId = 1;
|
||||||
|
string search = 2;
|
||||||
|
optional int32 limit = 3;
|
||||||
|
optional int32 offset = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AdminChatMessage {
|
||||||
|
string id = 1;
|
||||||
|
string senderId = 2;
|
||||||
|
string senderName = 3;
|
||||||
|
string type = 4;
|
||||||
|
optional string content = 5;
|
||||||
|
string preview = 6;
|
||||||
|
bool isEncrypted = 7;
|
||||||
|
bool isDeleted = 8;
|
||||||
|
string createdAt = 9;
|
||||||
|
optional string editedAt = 10;
|
||||||
|
optional string roomId = 11;
|
||||||
|
optional string roomName = 12;
|
||||||
|
optional string groupName = 13;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AdminChatRoomContext {
|
||||||
|
string id = 1;
|
||||||
|
string name = 2;
|
||||||
|
string type = 3;
|
||||||
|
string groupName = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UserChatMessagesResponse {
|
||||||
|
optional AdminChatRoomContext room = 1;
|
||||||
|
repeated AdminChatMessage messages = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UserChatMessageSearchResponse {
|
||||||
|
int32 total = 1;
|
||||||
|
repeated AdminChatMessage messages = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AdminDeleteChatMessageRequest {
|
||||||
|
string actorUserId = 1;
|
||||||
|
string messageId = 2;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user