fix document 500 error and fix users tabel for bot
This commit is contained in:
@@ -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 пользователя' })
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
if (user.linkedBotId || user.isSystemAccount) {
|
||||
await this.rbac.ensureBotUserRole(user.id);
|
||||
} else {
|
||||
await this.rbac.ensureDefaultUserRole(user.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +40,9 @@ export class AdminService {
|
||||
|
||||
async listUsers(search?: string) {
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: search
|
||||
where: {
|
||||
...HUMAN_USERS_WHERE,
|
||||
...(search
|
||||
? {
|
||||
OR: [
|
||||
{ email: { contains: search, mode: 'insensitive' } },
|
||||
@@ -23,7 +51,8 @@ export class AdminService {
|
||||
{ username: { contains: search, mode: 'insensitive' } }
|
||||
]
|
||||
}
|
||||
: undefined,
|
||||
: {})
|
||||
},
|
||||
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,12 +181,13 @@ 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: {
|
||||
private toAdminUser(
|
||||
user: {
|
||||
id: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
@@ -153,9 +200,14 @@ export class AdminService {
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export const DEFAULT_USER_ROLE_SLUG = 'user';
|
||||
export const BOT_ROLE_SLUG = 'bot';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user