fix document 500 error and fix users tabel for bot

This commit is contained in:
lendry
2026-06-26 10:32:04 +03:00
parent ead3155ad8
commit d5e6b58955
17 changed files with 396 additions and 67 deletions

View File

@@ -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 пользователя' })

View File

@@ -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')

View File

@@ -13,6 +13,11 @@ interface RequesterProfile {
async function getRequesterProfile(jwt: JwtService, core: CoreGrpcService, authorization?: string): Promise<RequesterProfile> {
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 };
}

View File

@@ -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<ManagedBot[]>([]);
const [botAccounts, setBotAccounts] = useState<AdminUser[]>([]);
const [total, setTotal] = useState(0);
const [metrics, setMetrics] = useState<AdminBotMetrics | null>(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() {
</Button>
</div>
) : null}
<div className="mt-10">
<h3 className="mb-2 text-lg font-medium">Системные учётные записи ботов</h3>
<p className="mb-4 text-sm text-[#667085]">
Пользователи IdP, связанные с Telegram-ботами (BotFather и боты пользователей). Роль: «Бот».
</p>
<div className="overflow-hidden rounded-2xl border border-[#eceef4] bg-white">
<Table>
<TableHeader>
<TableRow>
<TableHead>Учётная запись</TableHead>
<TableHead>Telegram-бот</TableHead>
<TableHead>Роль</TableHead>
<TableHead>Статус</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={4} className="py-10 text-center text-[#667085]">
<Loader2 className="mx-auto h-5 w-5 animate-spin" />
</TableCell>
</TableRow>
) : botAccounts.length ? (
botAccounts.map((account) => (
<TableRow key={account.id}>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#eef4ff] text-[#3390ec]">
<Bot className="h-5 w-5" />
</div>
<div>
<p className="font-medium">{account.displayName}</p>
<p className="text-sm text-[#667085]">{account.username ? `@${account.username}` : account.id.slice(0, 8)}</p>
</div>
</div>
</TableCell>
<TableCell>
{account.linkedBotUsername ? `@${account.linkedBotUsername}` : '—'}
{account.isSystemBot ? <p className="text-xs text-[#3390ec]">Системный</p> : null}
</TableCell>
<TableCell>Бот</TableCell>
<TableCell>
<span className="inline-flex rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-700">
{account.status === 'ACTIVE' ? 'Активен' : account.status}
</span>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={4} className="py-10 text-center text-[#667085]">
Системные учётные записи не найдены
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</AdminShell>
);
}

View File

@@ -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<string, string> = {
const roleLabels: Record<string, string> = {
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() {
</Button>
</>
) : null}
{currentUser?.canViewUserDocuments ? (
{currentUser?.canViewUserDocuments && !user.isBot ? (
<Button
variant="secondary"
size="icon"
@@ -368,6 +392,17 @@ export default function AdminUsersPage() {
<FileText className="h-4 w-4" />
</Button>
) : null}
{currentUser?.isSuperAdmin && !user.isBot ? (
<Button
variant="secondary"
size="icon"
aria-label="Отключить 2FA"
disabled={actionLoading}
onClick={() => void handleAdminDisableTotp(user)}
>
<ShieldOff className="h-4 w-4" />
</Button>
) : null}
{currentUser?.canVerifyUsers ? (
<Button
variant={user.isVerified ? 'default' : 'secondary'}

View File

@@ -98,6 +98,9 @@ export interface AdminUser {
directPermissions?: string[];
isVerified?: boolean;
verificationIcon?: string | null;
isBot?: boolean;
linkedBotUsername?: string | null;
isSystemBot?: boolean;
}
export interface AdminRole {
@@ -1001,6 +1004,19 @@ export async function fetchAdminBotMetrics(token?: string | null) {
return apiFetch<AdminBotMetrics>('/admin/bots/metrics', {}, token);
}
export async function fetchAdminBotAccounts(search: string | undefined, token?: string | null) {
const suffix = search?.trim() ? `?search=${encodeURIComponent(search.trim())}` : '';
return apiFetch<{ users: AdminUser[] }>(`/admin/users/bot-accounts${suffix}`, {}, token);
}
export async function fetchUserTotpStatus(userId: string, token?: string | null) {
return apiFetch<TotpStatusResponse>(`/security/users/${userId}/totp/status`, {}, token);
}
export async function adminDisableUserTotp(userId: string, token?: string | null) {
return apiFetch<TotpStatusResponse>(`/admin/users/${userId}/totp/admin-disable`, { method: 'POST' }, token);
}
export async function setAdminBotActive(botId: string, isActive: boolean, token?: string | null) {
return apiFetch<ManagedBot>(`/admin/bots/${botId}/active`, {
method: 'PATCH',

View File

@@ -30,6 +30,14 @@ const ROLES = [
isSystem: true,
isDefault: true
},
{
slug: 'bot',
name: 'Бот',
description: 'Системная учётная запись Telegram-бота',
permissions: [] as string[],
isSystem: true,
isDefault: false
},
{
slug: 'admin',
name: 'Администратор',
@@ -120,9 +128,13 @@ export class AdminSeedService implements OnModuleInit {
}
}
const users = await this.prisma.user.findMany({ where: { deletedAt: null }, select: { id: true } });
const users = await this.prisma.user.findMany({ where: { deletedAt: null }, select: { id: true, isSystemAccount: true, linkedBotId: true } });
for (const user of users) {
await this.rbac.ensureDefaultUserRole(user.id);
if (user.linkedBotId || user.isSystemAccount) {
await this.rbac.ensureBotUserRole(user.id);
} else {
await this.rbac.ensureDefaultUserRole(user.id);
}
}
}
}

View File

@@ -4,6 +4,32 @@ import { UserStatus } from '../generated/prisma/client';
import { PrismaService } from '../infra/prisma.service';
import { AccessService } from './access.service';
import { DEFAULT_VERIFICATION_ICON, isAllowedVerificationIcon, VERIFICATION_ICONS } from './verification.constants';
import { isProtectedSystemAccount } from './system-account.util';
const HUMAN_USERS_WHERE = {
isSystemAccount: false,
linkedBotId: null
};
function buildBotAccountsWhere(search?: string) {
const botFilter = {
OR: [{ linkedBotId: { not: null } }, { isSystemAccount: true }]
};
if (!search) return botFilter;
return {
AND: [
botFilter,
{
OR: [
{ displayName: { contains: search, mode: 'insensitive' as const } },
{ username: { contains: search, mode: 'insensitive' as const } },
{ linkedBot: { username: { contains: search, mode: 'insensitive' as const } } },
{ linkedBot: { name: { contains: search, mode: 'insensitive' as const } } }
]
}
]
};
}
@Injectable()
export class AdminService {
@@ -14,16 +40,19 @@ export class AdminService {
async listUsers(search?: string) {
const users = await this.prisma.user.findMany({
where: search
? {
OR: [
{ email: { contains: search, mode: 'insensitive' } },
{ phone: { contains: search, mode: 'insensitive' } },
{ displayName: { contains: search, mode: 'insensitive' } },
{ username: { contains: search, mode: 'insensitive' } }
]
}
: undefined,
where: {
...HUMAN_USERS_WHERE,
...(search
? {
OR: [
{ email: { contains: search, mode: 'insensitive' } },
{ phone: { contains: search, mode: 'insensitive' } },
{ displayName: { contains: search, mode: 'insensitive' } },
{ username: { contains: search, mode: 'insensitive' } }
]
}
: {})
},
orderBy: { createdAt: 'desc' },
include: {
userRoles: { include: { role: true } },
@@ -34,14 +63,28 @@ export class AdminService {
return users.map((user) => this.toAdminUser(user));
}
async listBotAccounts(search?: string) {
const users = await this.prisma.user.findMany({
where: buildBotAccountsWhere(search),
orderBy: { createdAt: 'desc' },
include: {
userRoles: { include: { role: true } },
userPermissions: { include: { permission: true } },
linkedBot: { select: { username: true, isSystemBot: true } }
}
});
return users.map((user) => this.toAdminUser(user, true));
}
async updateUserProfile(userId: string, data: { displayName?: string; email?: string; phone?: string; backupEmail?: string; backupPhone?: string; status?: UserStatus }) {
await this.ensureUserExists(userId);
const user = await this.prisma.user.update({
where: { id: userId },
data,
include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } } }
include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } }, linkedBot: { select: { username: true, isSystemBot: true } } }
});
return this.toAdminUser(user);
return this.toAdminUser(user, isProtectedSystemAccount(user));
}
async resetPassword(userId: string, newPassword: string) {
@@ -53,9 +96,9 @@ export class AdminService {
const user = await this.prisma.user.update({
where: { id: userId },
data: { passwordHash: await bcrypt.hash(newPassword, 12) },
include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } } }
include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } }, linkedBot: { select: { username: true, isSystemBot: true } } }
});
return this.toAdminUser(user);
return this.toAdminUser(user, isProtectedSystemAccount(user));
}
async suspendUser(userId: string) {
@@ -64,9 +107,9 @@ export class AdminService {
const user = await this.prisma.user.update({
where: { id: userId },
data: { status: UserStatus.SUSPENDED },
include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } } }
include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } }, linkedBot: { select: { username: true, isSystemBot: true } } }
});
return this.toAdminUser(user);
return this.toAdminUser(user, isProtectedSystemAccount(user));
}
async setSuperAdmin(actorUserId: string, userId: string, isSuperAdmin: boolean) {
@@ -77,6 +120,9 @@ export class AdminService {
if (!target) {
throw new NotFoundException('Пользователь не найден');
}
if (isProtectedSystemAccount(target)) {
throw new ForbiddenException('Нельзя назначать системные учётные записи ботов супер-администраторами');
}
if (target.isSuperAdmin && !isSuperAdmin) {
const superAdmins = await this.prisma.user.count({ where: { isSuperAdmin: true, status: UserStatus.ACTIVE } });
@@ -88,9 +134,9 @@ export class AdminService {
const user = await this.prisma.user.update({
where: { id: userId },
data: { isSuperAdmin },
include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } } }
include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } }, linkedBot: { select: { username: true, isSystemBot: true } } }
});
return this.toAdminUser(user);
return this.toAdminUser(user, isProtectedSystemAccount(user));
}
async setUserVerification(actorUserId: string, userId: string, data: { isVerified: boolean; verificationIcon?: string }) {
@@ -109,9 +155,9 @@ export class AdminService {
verificationIcon: icon,
verifiedAt: new Date()
},
include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } } }
include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } }, linkedBot: { select: { username: true, isSystemBot: true } } }
});
return this.toAdminUser(user);
return this.toAdminUser(user, isProtectedSystemAccount(user));
}
const user = await this.prisma.user.update({
@@ -121,9 +167,9 @@ export class AdminService {
verificationIcon: null,
verifiedAt: null
},
include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } } }
include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } }, linkedBot: { select: { username: true, isSystemBot: true } } }
});
return this.toAdminUser(user);
return this.toAdminUser(user, isProtectedSystemAccount(user));
}
listVerificationIcons() {
@@ -135,27 +181,33 @@ export class AdminService {
const user = await this.prisma.user.update({
where: { id: userId },
data: { status: UserStatus.DELETED, deletedAt: new Date() },
include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } } }
include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } }, linkedBot: { select: { username: true, isSystemBot: true } } }
});
return this.toAdminUser(user);
return this.toAdminUser(user, isProtectedSystemAccount(user));
}
private toAdminUser(user: {
id: string;
email: string | null;
phone: string | null;
backupEmail: string | null;
backupPhone: string | null;
displayName: string;
username: string | null;
isSuperAdmin: boolean;
isVerified: boolean;
verificationIcon: string | null;
status: UserStatus;
createdAt: Date;
userRoles?: { role: { slug: string } }[];
userPermissions?: { permission: { slug: string } }[];
}) {
private toAdminUser(
user: {
id: string;
email: string | null;
phone: string | null;
backupEmail: string | null;
backupPhone: string | null;
displayName: string;
username: string | null;
isSuperAdmin: boolean;
isVerified: boolean;
verificationIcon: string | null;
status: UserStatus;
createdAt: Date;
isSystemAccount?: boolean;
linkedBotId?: string | null;
userRoles?: { role: { slug: string } }[];
userPermissions?: { permission: { slug: string } }[];
linkedBot?: { username: string; isSystemBot: boolean } | null;
},
isBot = isProtectedSystemAccount(user)
) {
return {
id: user.id,
email: user.email ?? undefined,
@@ -170,7 +222,10 @@ export class AdminService {
status: user.status,
createdAt: user.createdAt.toISOString(),
roles: user.userRoles?.map((link) => link.role.slug) ?? [],
directPermissions: user.userPermissions?.map((link) => link.permission.slug) ?? []
directPermissions: user.userPermissions?.map((link) => link.permission.slug) ?? [],
isBot,
linkedBotUsername: user.linkedBot?.username ?? undefined,
isSystemBot: user.linkedBot?.isSystemBot ?? false
};
}

View File

@@ -142,6 +142,12 @@ export class AuthGrpcController {
return { users };
}
@GrpcMethod('AdminService', 'ListBotAccounts')
async listBotAccounts(command: { search?: string }) {
const users = await this.admin.listBotAccounts(command.search);
return { users };
}
@GrpcMethod('AdminService', 'SuspendUser')
suspendUser(command: { userId: string }) {
return this.admin.suspendUser(command.userId);
@@ -461,6 +467,11 @@ export class AuthGrpcController {
return this.totp.disable(command.userId, command.code);
}
@GrpcMethod('SecurityService', 'AdminDisableTotp')
adminDisableTotp(command: { actorUserId: string; userId: string }) {
return this.totp.adminDisable(command.actorUserId, command.userId);
}
@GrpcMethod('SecurityService', 'SetupPin')
setupPin(command: { userId: string; pin: string }) {
return this.pin.setupPin(command.userId, command.pin);

View File

@@ -1,5 +1,6 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { PrismaService } from '../../infra/prisma.service';
import { RbacService } from '../rbac.service';
import {
BOTFATHER_BOT_USERNAME,
BOTFATHER_DISPLAY_NAME,
@@ -16,7 +17,10 @@ export class BotFatherSeedService implements OnModuleInit {
private cachedUserId: string | null = null;
private cachedBotId: string | null = null;
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly rbac: RbacService
) {}
async onModuleInit() {
await this.ensureBotFather();
@@ -101,6 +105,7 @@ export class BotFatherSeedService implements OnModuleInit {
await this.upsertSetting(BOTFATHER_SETTING_USER_ID, botFatherUser.id, 'UUID системного пользователя BotFather');
await this.upsertSetting(BOTFATHER_SETTING_BOT_ID, bot.id, 'UUID системного бота BotFather');
await this.rbac.ensureBotUserRole(botFatherUser.id);
this.cachedUserId = botFatherUser.id;
this.cachedBotId = bot.id;

View File

@@ -11,6 +11,7 @@ import { PrismaService } from '../../infra/prisma.service';
import { AccessService } from '../access.service';
import { NotificationsService } from '../notifications.service';
import { SettingsService } from '../settings.service';
import { RbacService } from '../rbac.service';
import { BotRateLimitService } from './bot-rate-limit.service';
import { assertValidBotUsername, generateTelegramStyleBotToken, hashBotToken, normalizeBotUsername } from './bot-token.util';
import { menuButtonToJson, parseMenuButtonInput, resolveComposerMenuButton } from './bot-menu-button.util';
@@ -45,7 +46,8 @@ export class BotFatherService implements OnModuleInit {
private readonly access: AccessService,
private readonly settings: SettingsService,
private readonly rateLimit: BotRateLimitService,
private readonly notifications: NotificationsService
private readonly notifications: NotificationsService,
private readonly rbac: RbacService
) {}
async onModuleInit() {
@@ -84,6 +86,8 @@ export class BotFatherService implements OnModuleInit {
select: { id: true }
});
await this.rbac.ensureBotUserRole(systemUser.id);
this.logger.log(`Создан системный пользователь для бота @${bot.username}`);
return { userId: systemUser.id, botId: bot.id };
}

View File

@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
import { Prisma } from '../generated/prisma/client';
import { EncryptionService } from '../infra/encryption.service';
import { PrismaService } from '../infra/prisma.service';
import { isProtectedSystemAccount } from './system-account.util';
export const DOCUMENT_TYPES = [
'PASSPORT_RF',
@@ -70,11 +71,29 @@ export class DocumentsService {
}
async list(userId: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { isSystemAccount: true, linkedBotId: true }
});
if (user && isProtectedSystemAccount(user)) {
return { documents: [] };
}
const documents = await this.prisma.userDocument.findMany({
where: { userId },
orderBy: { updatedAt: 'desc' }
});
return { documents: documents.map((document) => this.toResponse(document)) };
const results = [];
for (const document of documents) {
try {
results.push(this.toResponse(document));
} catch {
results.push(this.toFallbackResponse(document));
}
}
return { documents: results };
}
async get(documentId: string, userId: string) {
@@ -124,14 +143,33 @@ export class DocumentsService {
}
private toResponse(document: Awaited<ReturnType<PrismaService['userDocument']['create']>>) {
const response: Record<string, string> = {
id: document.id,
userId: document.userId,
type: document.type,
number: this.encryption.decryptIfNeeded(document.number ?? '—'),
createdAt: this.formatRequiredDate(document.createdAt),
updatedAt: this.formatRequiredDate(document.updatedAt)
};
const issuedAt = this.formatOptionalDate(document.issuedAt);
if (issuedAt) response.issuedAt = issuedAt;
const expiresAt = this.formatOptionalDate(document.expiresAt);
if (expiresAt) response.expiresAt = expiresAt;
const metadataJson = this.resolveMetadataJson(document.metadata);
if (metadataJson) response.metadataJson = metadataJson;
return response;
}
private toFallbackResponse(document: Awaited<ReturnType<PrismaService['userDocument']['create']>>) {
return {
id: document.id,
userId: document.userId,
type: document.type,
number: this.encryption.decryptIfNeeded(document.number),
issuedAt: this.formatOptionalDate(document.issuedAt),
expiresAt: this.formatOptionalDate(document.expiresAt),
metadataJson: this.resolveMetadataJson(document.metadata),
number: '—',
createdAt: this.formatRequiredDate(document.createdAt),
updatedAt: this.formatRequiredDate(document.updatedAt)
};

View File

@@ -1 +1,2 @@
export const DEFAULT_USER_ROLE_SLUG = 'user';
export const BOT_ROLE_SLUG = 'bot';

View File

@@ -4,7 +4,7 @@ import { OAuthClientType } from '../generated/prisma/client';
import { PrismaService } from '../infra/prisma.service';
import { AccessService, UserAccessContext } from './access.service';
import { NotificationsService } from './notifications.service';
import { DEFAULT_USER_ROLE_SLUG } from './rbac.constants';
import { DEFAULT_USER_ROLE_SLUG, BOT_ROLE_SLUG } from './rbac.constants';
export interface OAuthClientListItem {
id: string;
@@ -254,6 +254,15 @@ export class RbacService {
}
async ensureDefaultUserRole(userId: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { linkedBotId: true, isSystemAccount: true }
});
if (user?.linkedBotId || user?.isSystemAccount) {
await this.ensureBotUserRole(userId);
return;
}
const role = await this.prisma.role.findUnique({ where: { slug: DEFAULT_USER_ROLE_SLUG } });
if (!role) return;
await this.prisma.userRole.upsert({
@@ -263,6 +272,26 @@ export class RbacService {
});
}
async ensureBotUserRole(userId: string) {
const [botRole, userRole] = await Promise.all([
this.prisma.role.findUnique({ where: { slug: BOT_ROLE_SLUG } }),
this.prisma.role.findUnique({ where: { slug: DEFAULT_USER_ROLE_SLUG } })
]);
if (!botRole) return;
if (userRole) {
await this.prisma.userRole.deleteMany({
where: { userId, roleId: userRole.id }
});
}
await this.prisma.userRole.upsert({
where: { userId_roleId: { userId, roleId: botRole.id } },
create: { userId, roleId: botRole.id },
update: {}
});
}
async assignUserPermission(actorUserId: string, userId: string, permissionSlug: string) {
await this.access.assertRbacManage(actorUserId);
await this.ensureUserExists(userId);

View File

@@ -4,6 +4,7 @@ import { authenticator } from 'otplib';
import { EncryptionService } from '../infra/encryption.service';
import { PrismaService } from '../infra/prisma.service';
import { SettingsService } from './settings.service';
import { AccessService } from './access.service';
@Injectable()
export class TotpService {
@@ -12,7 +13,8 @@ export class TotpService {
constructor(
private readonly prisma: PrismaService,
private readonly encryption: EncryptionService,
private readonly settings: SettingsService
private readonly settings: SettingsService,
private readonly access: AccessService
) {}
async getStatus(userId: string) {
@@ -91,6 +93,19 @@ export class TotpService {
return { isEnabled: false };
}
async adminDisable(actorUserId: string, targetUserId: string) {
await this.access.assertSuperAdmin(actorUserId);
await this.ensureUserExists(targetUserId);
const record = await this.prisma.userTotpSecret.findUnique({ where: { userId: targetUserId } });
if (!record?.isEnabled) {
throw new BadRequestException('Двухфакторная аутентификация не включена');
}
await this.prisma.userTotpSecret.delete({ where: { userId: targetUserId } });
return { isEnabled: false };
}
async verifyEnabledCode(userId: string, code: string) {
const record = await this.prisma.userTotpSecret.findUnique({ where: { userId } });
if (!record?.isEnabled) {

View File

@@ -4,6 +4,7 @@ package admin;
service AdminService {
rpc ListUsers (ListUsersRequest) returns (ListUsersResponse);
rpc ListBotAccounts (ListUsersRequest) returns (ListUsersResponse);
rpc UpdateUserProfile (UpdateUserRequest) returns (AdminUser);
rpc ResetPassword (ResetPasswordRequest) returns (AdminUser);
rpc SuspendUser (UserIdRequest) returns (AdminUser);
@@ -74,6 +75,9 @@ message AdminUser {
repeated string directPermissions = 12;
bool isVerified = 13;
optional string verificationIcon = 14;
bool isBot = 15;
optional string linkedBotUsername = 16;
optional bool isSystemBot = 17;
}
message ListUsersResponse {

View File

@@ -22,6 +22,7 @@ service SecurityService {
rpc SetupTotp (UserSecurityRequest) returns (TotpSetupResponse);
rpc EnableTotp (TotpCodeRequest) returns (TotpStatusResponse);
rpc DisableTotp (TotpCodeRequest) returns (TotpStatusResponse);
rpc AdminDisableTotp (AdminDisableTotpRequest) returns (TotpStatusResponse);
}
service SettingsService {
@@ -248,3 +249,8 @@ message TotpCodeRequest {
string userId = 1;
string code = 2;
}
message AdminDisableTotpRequest {
string actorUserId = 1;
string userId = 2;
}