diff --git a/apps/api-gateway/src/controllers/admin.controller.ts b/apps/api-gateway/src/controllers/admin.controller.ts index fab6726..ecc6db2 100644 --- a/apps/api-gateway/src/controllers/admin.controller.ts +++ b/apps/api-gateway/src/controllers/admin.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, ForbiddenException, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger'; +import { firstValueFrom } from 'rxjs'; import { map } from 'rxjs'; import { CoreGrpcService } from '../core-grpc.service'; import { CurrentAdmin } from '../decorators/current-admin.decorator'; @@ -76,6 +77,38 @@ export class AdminController { return this.core.admin.SetSuperAdmin({ actorUserId: admin.id, userId, isSuperAdmin: dto.isSuperAdmin }); } + @Get('bot-accounts') + @ApiOperation({ summary: 'Системные учётные записи ботов', description: 'Возвращает пользователей, связанных с Telegram-ботами (BotFather и боты пользователей).' }) + listBotAccounts(@Query() query: ListUsersQueryDto, @CurrentAdmin() admin: AdminRequestUser) { + if (!admin.canViewUsers && !admin.canManageUsers && !admin.isSuperAdmin && !admin.permissions.includes('bots.manage.all')) { + throw new ForbiddenException('Недостаточно прав для просмотра ботов'); + } + return this.core.admin.ListBotAccounts(query).pipe( + map((response) => { + const payload = response as { users?: Array<{ roles?: string[] }> }; + return { + users: (payload.users ?? []).map((user) => ({ + ...user, + roles: user.roles ?? [] + })) + }; + }) + ); + } + + @Post(':userId/totp/admin-disable') + @UseGuards(SuperAdminGuard) + @ApiOperation({ summary: 'Отключить 2FA пользователя', description: 'Супер-администратор может принудительно отключить TOTP без кода пользователя.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + adminDisableTotp(@Param('userId') userId: string, @CurrentAdmin() admin: AdminRequestUser) { + return firstValueFrom( + this.core.security.AdminDisableTotp({ + actorUserId: admin.id, + userId + }) + ); + } + @Patch(':userId/verification') @ApiOperation({ summary: 'Верифицировать или снять верификацию', description: 'Требуется право users.verify.' }) @ApiParam({ name: 'userId', description: 'ID пользователя' }) diff --git a/apps/api-gateway/src/controllers/documents.controller.ts b/apps/api-gateway/src/controllers/documents.controller.ts index e6a6061..bf59f5c 100644 --- a/apps/api-gateway/src/controllers/documents.controller.ts +++ b/apps/api-gateway/src/controllers/documents.controller.ts @@ -2,7 +2,6 @@ import { Body, Controller, Delete, Get, Headers, Param, Patch, Post } from '@nes import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { JwtService } from '@nestjs/jwt'; import { firstValueFrom } from 'rxjs'; -import { map } from 'rxjs'; import { CoreGrpcService } from '../core-grpc.service'; import { assertDocumentsReadAccess, assertDocumentsWriteAccess } from '../document-access'; import { CreateDocumentDto, UpdateDocumentDto } from '../dto/documents.dto'; @@ -43,14 +42,10 @@ export class DocumentsController { @ApiParam({ name: 'userId', description: 'ID пользователя' }) async list(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) { await assertDocumentsReadAccess(this.jwt, this.core, authorization, userId); - return firstValueFrom( - this.core.documents.ListDocuments({ userId }).pipe( - map((response) => { - const payload = response as { documents?: unknown[] }; - return { documents: payload.documents ?? [] }; - }) - ) - ); + const result = (await firstValueFrom(this.core.documents.ListDocuments({ userId }))) as { + documents?: unknown[]; + }; + return { documents: result.documents ?? [] }; } @Get(':documentId') diff --git a/apps/api-gateway/src/document-access.ts b/apps/api-gateway/src/document-access.ts index 7b3be42..7e23d97 100644 --- a/apps/api-gateway/src/document-access.ts +++ b/apps/api-gateway/src/document-access.ts @@ -13,6 +13,11 @@ interface RequesterProfile { async function getRequesterProfile(jwt: JwtService, core: CoreGrpcService, authorization?: string): Promise { const payload = await verifyAccessToken(jwt, authorization); await assertSessionUnlocked(core, payload); + + if (payload.isSuperAdmin) { + return { id: payload.sub, isSuperAdmin: true, canViewUserDocuments: true }; + } + const profile = (await firstValueFrom(core.auth.GetMe({ userId: payload.sub }))) as RequesterProfile & { id: string }; return { id: profile.id, isSuperAdmin: profile.isSuperAdmin, canViewUserDocuments: profile.canViewUserDocuments }; } diff --git a/apps/frontend/app/admin/bots/page.tsx b/apps/frontend/app/admin/bots/page.tsx index 11f0183..e8c96ae 100644 --- a/apps/frontend/app/admin/bots/page.tsx +++ b/apps/frontend/app/admin/bots/page.tsx @@ -11,7 +11,9 @@ import { Input } from '@/components/ui/input'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { AdminBotMetrics, + AdminUser, ManagedBot, + fetchAdminBotAccounts, fetchAdminBotMetrics, fetchAdminBots, setAdminBotActive @@ -23,6 +25,7 @@ export default function AdminBotsPage() { const { token, user: currentUser } = useAuth(); const { showToast } = useToast(); const [bots, setBots] = useState([]); + const [botAccounts, setBotAccounts] = useState([]); const [total, setTotal] = useState(0); const [metrics, setMetrics] = useState(null); const [search, setSearch] = useState(''); @@ -34,13 +37,15 @@ export default function AdminBotsPage() { if (!token) return; setLoading(true); try { - const [botsResponse, metricsResponse] = await Promise.all([ + const [botsResponse, metricsResponse, accountsResponse] = await Promise.all([ fetchAdminBots({ search, page, limit: 20 }, token), - fetchAdminBotMetrics(token) + fetchAdminBotMetrics(token), + fetchAdminBotAccounts(search, token) ]); setBots(botsResponse.bots ?? []); setTotal(botsResponse.total ?? 0); setMetrics(metricsResponse); + setBotAccounts(accountsResponse.users ?? []); } catch (error) { showToast(error instanceof Error ? error.message : 'Не удалось загрузить ботов'); } finally { @@ -207,6 +212,66 @@ export default function AdminBotsPage() { ) : null} + +
+

Системные учётные записи ботов

+

+ Пользователи IdP, связанные с Telegram-ботами (BotFather и боты пользователей). Роль: «Бот». +

+
+ + + + Учётная запись + Telegram-бот + Роль + Статус + + + + {loading ? ( + + + + + + ) : botAccounts.length ? ( + botAccounts.map((account) => ( + + +
+
+ +
+
+

{account.displayName}

+

{account.username ? `@${account.username}` : account.id.slice(0, 8)}

+
+
+
+ + {account.linkedBotUsername ? `@${account.linkedBotUsername}` : '—'} + {account.isSystemBot ?

Системный

: null} +
+ Бот + + + {account.status === 'ACTIVE' ? 'Активен' : account.status} + + +
+ )) + ) : ( + + + Системные учётные записи не найдены + + + )} +
+
+
+
); } diff --git a/apps/frontend/app/admin/users/page.tsx b/apps/frontend/app/admin/users/page.tsx index 6a18ef1..b12a875 100644 --- a/apps/frontend/app/admin/users/page.tsx +++ b/apps/frontend/app/admin/users/page.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { Ban, BadgeCheck, Crown, FileText, KeyRound, Loader2, Search, ShieldPlus, UserCheck, UserCog } from 'lucide-react'; +import { Ban, BadgeCheck, Crown, FileText, KeyRound, Loader2, Search, ShieldOff, ShieldPlus, UserCheck, UserCog } from 'lucide-react'; import { VerificationBadge } from '@/components/id/verification-badge'; import { UserDocumentsDialog } from '@/components/documents/user-documents-dialog'; import { AdminShell } from '@/components/id/admin-shell'; @@ -12,7 +12,7 @@ import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; -import { AdminPermission, AdminRole, AdminUser, apiFetch } from '@/lib/api'; +import { AdminPermission, AdminRole, AdminUser, adminDisableUserTotp, apiFetch, fetchUserTotpStatus } from '@/lib/api'; import { getAdminLandingPath } from '@/lib/admin-access'; import { DEFAULT_VERIFICATION_ICON, VERIFICATION_ICON_OPTIONS } from '@/lib/verification-icons'; @@ -24,6 +24,7 @@ const statusLabels: Record = { const roleLabels: Record = { user: 'Пользователь', + bot: 'Бот', admin: 'Администратор', moderator: 'Модератор', manager: 'Менеджер', @@ -274,9 +275,32 @@ export default function AdminUsersPage() { setDialog('verification'); } + async function handleAdminDisableTotp(user: AdminUser) { + if (!token || !currentUser?.isSuperAdmin) return; + setActionLoading(true); + try { + const status = await fetchUserTotpStatus(user.id, token); + if (!status.isEnabled) { + showToast('У пользователя не включена двухфакторная аутентификация'); + return; + } + const confirmed = window.confirm(`Отключить 2FA для ${user.displayName}? Пользователю не потребуется код из приложения-аутентификатора.`); + if (!confirmed) return; + await adminDisableUserTotp(user.id, token); + showToast('Двухфакторная аутентификация отключена'); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Не удалось отключить 2FA'); + } finally { + setActionLoading(false); + } + } + function renderRoles(user: AdminUser) { + if (user.isBot) { + return roleLabels.bot; + } const userRoles = user.roles ?? []; - const extraRoles = userRoles.filter((role) => role !== DEFAULT_USER_ROLE); + const extraRoles = userRoles.filter((role) => role !== DEFAULT_USER_ROLE && role !== 'bot'); const direct = user.directPermissions ?? []; const labels = user.isSuperAdmin ? ['Супер-админ', ...extraRoles.map((role) => roleLabels[role] ?? role)] @@ -357,7 +381,7 @@ export default function AdminUsersPage() { ) : null} - {currentUser?.canViewUserDocuments ? ( + {currentUser?.canViewUserDocuments && !user.isBot ? ( ) : null} + {currentUser?.isSuperAdmin && !user.isBot ? ( + + ) : null} {currentUser?.canVerifyUsers ? (