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 { Body, Controller, 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 { 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';
|
||||||
@@ -76,6 +77,38 @@ export class AdminController {
|
|||||||
return this.core.admin.SetSuperAdmin({ actorUserId: admin.id, userId, isSuperAdmin: dto.isSuperAdmin });
|
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')
|
@Patch(':userId/verification')
|
||||||
@ApiOperation({ summary: 'Верифицировать или снять верификацию', description: 'Требуется право users.verify.' })
|
@ApiOperation({ summary: 'Верифицировать или снять верификацию', description: 'Требуется право users.verify.' })
|
||||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
@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 { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import { map } from 'rxjs';
|
|
||||||
import { CoreGrpcService } from '../core-grpc.service';
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
import { assertDocumentsReadAccess, assertDocumentsWriteAccess } from '../document-access';
|
import { assertDocumentsReadAccess, assertDocumentsWriteAccess } from '../document-access';
|
||||||
import { CreateDocumentDto, UpdateDocumentDto } from '../dto/documents.dto';
|
import { CreateDocumentDto, UpdateDocumentDto } from '../dto/documents.dto';
|
||||||
@@ -43,14 +42,10 @@ export class DocumentsController {
|
|||||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||||
async list(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) {
|
async list(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) {
|
||||||
await assertDocumentsReadAccess(this.jwt, this.core, authorization, userId);
|
await assertDocumentsReadAccess(this.jwt, this.core, authorization, userId);
|
||||||
return firstValueFrom(
|
const result = (await firstValueFrom(this.core.documents.ListDocuments({ userId }))) as {
|
||||||
this.core.documents.ListDocuments({ userId }).pipe(
|
documents?: unknown[];
|
||||||
map((response) => {
|
};
|
||||||
const payload = response as { documents?: unknown[] };
|
return { documents: result.documents ?? [] };
|
||||||
return { documents: payload.documents ?? [] };
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':documentId')
|
@Get(':documentId')
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ interface RequesterProfile {
|
|||||||
async function getRequesterProfile(jwt: JwtService, core: CoreGrpcService, authorization?: string): Promise<RequesterProfile> {
|
async function getRequesterProfile(jwt: JwtService, core: CoreGrpcService, authorization?: string): Promise<RequesterProfile> {
|
||||||
const payload = await verifyAccessToken(jwt, authorization);
|
const payload = await verifyAccessToken(jwt, authorization);
|
||||||
await assertSessionUnlocked(core, payload);
|
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 };
|
const profile = (await firstValueFrom(core.auth.GetMe({ userId: payload.sub }))) as RequesterProfile & { id: string };
|
||||||
return { id: profile.id, isSuperAdmin: profile.isSuperAdmin, canViewUserDocuments: profile.canViewUserDocuments };
|
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
import {
|
import {
|
||||||
AdminBotMetrics,
|
AdminBotMetrics,
|
||||||
|
AdminUser,
|
||||||
ManagedBot,
|
ManagedBot,
|
||||||
|
fetchAdminBotAccounts,
|
||||||
fetchAdminBotMetrics,
|
fetchAdminBotMetrics,
|
||||||
fetchAdminBots,
|
fetchAdminBots,
|
||||||
setAdminBotActive
|
setAdminBotActive
|
||||||
@@ -23,6 +25,7 @@ export default function AdminBotsPage() {
|
|||||||
const { token, user: currentUser } = useAuth();
|
const { token, user: currentUser } = useAuth();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [bots, setBots] = useState<ManagedBot[]>([]);
|
const [bots, setBots] = useState<ManagedBot[]>([]);
|
||||||
|
const [botAccounts, setBotAccounts] = useState<AdminUser[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [metrics, setMetrics] = useState<AdminBotMetrics | null>(null);
|
const [metrics, setMetrics] = useState<AdminBotMetrics | null>(null);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
@@ -34,13 +37,15 @@ export default function AdminBotsPage() {
|
|||||||
if (!token) return;
|
if (!token) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [botsResponse, metricsResponse] = await Promise.all([
|
const [botsResponse, metricsResponse, accountsResponse] = await Promise.all([
|
||||||
fetchAdminBots({ search, page, limit: 20 }, token),
|
fetchAdminBots({ search, page, limit: 20 }, token),
|
||||||
fetchAdminBotMetrics(token)
|
fetchAdminBotMetrics(token),
|
||||||
|
fetchAdminBotAccounts(search, token)
|
||||||
]);
|
]);
|
||||||
setBots(botsResponse.bots ?? []);
|
setBots(botsResponse.bots ?? []);
|
||||||
setTotal(botsResponse.total ?? 0);
|
setTotal(botsResponse.total ?? 0);
|
||||||
setMetrics(metricsResponse);
|
setMetrics(metricsResponse);
|
||||||
|
setBotAccounts(accountsResponse.users ?? []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error instanceof Error ? error.message : 'Не удалось загрузить ботов');
|
showToast(error instanceof Error ? error.message : 'Не удалось загрузить ботов');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -207,6 +212,66 @@ export default function AdminBotsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
</AdminShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
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, 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 { 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';
|
||||||
@@ -12,7 +12,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
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 { getAdminLandingPath } from '@/lib/admin-access';
|
||||||
import { DEFAULT_VERIFICATION_ICON, VERIFICATION_ICON_OPTIONS } from '@/lib/verification-icons';
|
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> = {
|
const roleLabels: Record<string, string> = {
|
||||||
user: 'Пользователь',
|
user: 'Пользователь',
|
||||||
|
bot: 'Бот',
|
||||||
admin: 'Администратор',
|
admin: 'Администратор',
|
||||||
moderator: 'Модератор',
|
moderator: 'Модератор',
|
||||||
manager: 'Менеджер',
|
manager: 'Менеджер',
|
||||||
@@ -274,9 +275,32 @@ export default function AdminUsersPage() {
|
|||||||
setDialog('verification');
|
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) {
|
function renderRoles(user: AdminUser) {
|
||||||
|
if (user.isBot) {
|
||||||
|
return roleLabels.bot;
|
||||||
|
}
|
||||||
const userRoles = user.roles ?? [];
|
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 direct = user.directPermissions ?? [];
|
||||||
const labels = user.isSuperAdmin
|
const labels = user.isSuperAdmin
|
||||||
? ['Супер-админ', ...extraRoles.map((role) => roleLabels[role] ?? role)]
|
? ['Супер-админ', ...extraRoles.map((role) => roleLabels[role] ?? role)]
|
||||||
@@ -357,7 +381,7 @@ export default function AdminUsersPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{currentUser?.canViewUserDocuments ? (
|
{currentUser?.canViewUserDocuments && !user.isBot ? (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -368,6 +392,17 @@ export default function AdminUsersPage() {
|
|||||||
<FileText className="h-4 w-4" />
|
<FileText className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : 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 ? (
|
{currentUser?.canVerifyUsers ? (
|
||||||
<Button
|
<Button
|
||||||
variant={user.isVerified ? 'default' : 'secondary'}
|
variant={user.isVerified ? 'default' : 'secondary'}
|
||||||
|
|||||||
@@ -98,6 +98,9 @@ export interface AdminUser {
|
|||||||
directPermissions?: string[];
|
directPermissions?: string[];
|
||||||
isVerified?: boolean;
|
isVerified?: boolean;
|
||||||
verificationIcon?: string | null;
|
verificationIcon?: string | null;
|
||||||
|
isBot?: boolean;
|
||||||
|
linkedBotUsername?: string | null;
|
||||||
|
isSystemBot?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminRole {
|
export interface AdminRole {
|
||||||
@@ -1001,6 +1004,19 @@ export async function fetchAdminBotMetrics(token?: string | null) {
|
|||||||
return apiFetch<AdminBotMetrics>('/admin/bots/metrics', {}, token);
|
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) {
|
export async function setAdminBotActive(botId: string, isActive: boolean, token?: string | null) {
|
||||||
return apiFetch<ManagedBot>(`/admin/bots/${botId}/active`, {
|
return apiFetch<ManagedBot>(`/admin/bots/${botId}/active`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ const ROLES = [
|
|||||||
isSystem: true,
|
isSystem: true,
|
||||||
isDefault: true
|
isDefault: true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
slug: 'bot',
|
||||||
|
name: 'Бот',
|
||||||
|
description: 'Системная учётная запись Telegram-бота',
|
||||||
|
permissions: [] as string[],
|
||||||
|
isSystem: true,
|
||||||
|
isDefault: false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
slug: 'admin',
|
slug: 'admin',
|
||||||
name: 'Администратор',
|
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) {
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,32 @@ import { UserStatus } from '../generated/prisma/client';
|
|||||||
import { PrismaService } from '../infra/prisma.service';
|
import { PrismaService } from '../infra/prisma.service';
|
||||||
import { AccessService } from './access.service';
|
import { AccessService } from './access.service';
|
||||||
import { DEFAULT_VERIFICATION_ICON, isAllowedVerificationIcon, VERIFICATION_ICONS } from './verification.constants';
|
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()
|
@Injectable()
|
||||||
export class AdminService {
|
export class AdminService {
|
||||||
@@ -14,16 +40,19 @@ export class AdminService {
|
|||||||
|
|
||||||
async listUsers(search?: string) {
|
async listUsers(search?: string) {
|
||||||
const users = await this.prisma.user.findMany({
|
const users = await this.prisma.user.findMany({
|
||||||
where: search
|
where: {
|
||||||
? {
|
...HUMAN_USERS_WHERE,
|
||||||
OR: [
|
...(search
|
||||||
{ email: { contains: search, mode: 'insensitive' } },
|
? {
|
||||||
{ phone: { contains: search, mode: 'insensitive' } },
|
OR: [
|
||||||
{ displayName: { contains: search, mode: 'insensitive' } },
|
{ email: { contains: search, mode: 'insensitive' } },
|
||||||
{ username: { contains: search, mode: 'insensitive' } }
|
{ phone: { contains: search, mode: 'insensitive' } },
|
||||||
]
|
{ displayName: { contains: search, mode: 'insensitive' } },
|
||||||
}
|
{ username: { contains: search, mode: 'insensitive' } }
|
||||||
: undefined,
|
]
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
include: {
|
include: {
|
||||||
userRoles: { include: { role: true } },
|
userRoles: { include: { role: true } },
|
||||||
@@ -34,14 +63,28 @@ export class AdminService {
|
|||||||
return users.map((user) => this.toAdminUser(user));
|
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 }) {
|
async updateUserProfile(userId: string, data: { displayName?: string; email?: string; phone?: string; backupEmail?: string; backupPhone?: string; status?: UserStatus }) {
|
||||||
await this.ensureUserExists(userId);
|
await this.ensureUserExists(userId);
|
||||||
const user = await this.prisma.user.update({
|
const user = await this.prisma.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data,
|
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) {
|
async resetPassword(userId: string, newPassword: string) {
|
||||||
@@ -53,9 +96,9 @@ export class AdminService {
|
|||||||
const user = await this.prisma.user.update({
|
const user = await this.prisma.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: { passwordHash: await bcrypt.hash(newPassword, 12) },
|
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) {
|
async suspendUser(userId: string) {
|
||||||
@@ -64,9 +107,9 @@ export class AdminService {
|
|||||||
const user = await this.prisma.user.update({
|
const user = await this.prisma.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: { status: UserStatus.SUSPENDED },
|
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) {
|
async setSuperAdmin(actorUserId: string, userId: string, isSuperAdmin: boolean) {
|
||||||
@@ -77,6 +120,9 @@ export class AdminService {
|
|||||||
if (!target) {
|
if (!target) {
|
||||||
throw new NotFoundException('Пользователь не найден');
|
throw new NotFoundException('Пользователь не найден');
|
||||||
}
|
}
|
||||||
|
if (isProtectedSystemAccount(target)) {
|
||||||
|
throw new ForbiddenException('Нельзя назначать системные учётные записи ботов супер-администраторами');
|
||||||
|
}
|
||||||
|
|
||||||
if (target.isSuperAdmin && !isSuperAdmin) {
|
if (target.isSuperAdmin && !isSuperAdmin) {
|
||||||
const superAdmins = await this.prisma.user.count({ where: { isSuperAdmin: true, status: UserStatus.ACTIVE } });
|
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({
|
const user = await this.prisma.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: { isSuperAdmin },
|
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 }) {
|
async setUserVerification(actorUserId: string, userId: string, data: { isVerified: boolean; verificationIcon?: string }) {
|
||||||
@@ -109,9 +155,9 @@ export class AdminService {
|
|||||||
verificationIcon: icon,
|
verificationIcon: icon,
|
||||||
verifiedAt: new Date()
|
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({
|
const user = await this.prisma.user.update({
|
||||||
@@ -121,9 +167,9 @@ export class AdminService {
|
|||||||
verificationIcon: null,
|
verificationIcon: null,
|
||||||
verifiedAt: 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() {
|
listVerificationIcons() {
|
||||||
@@ -135,27 +181,33 @@ export class AdminService {
|
|||||||
const user = await this.prisma.user.update({
|
const user = await this.prisma.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: { status: UserStatus.DELETED, deletedAt: new Date() },
|
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(
|
||||||
id: string;
|
user: {
|
||||||
email: string | null;
|
id: string;
|
||||||
phone: string | null;
|
email: string | null;
|
||||||
backupEmail: string | null;
|
phone: string | null;
|
||||||
backupPhone: string | null;
|
backupEmail: string | null;
|
||||||
displayName: string;
|
backupPhone: string | null;
|
||||||
username: string | null;
|
displayName: string;
|
||||||
isSuperAdmin: boolean;
|
username: string | null;
|
||||||
isVerified: boolean;
|
isSuperAdmin: boolean;
|
||||||
verificationIcon: string | null;
|
isVerified: boolean;
|
||||||
status: UserStatus;
|
verificationIcon: string | null;
|
||||||
createdAt: Date;
|
status: UserStatus;
|
||||||
userRoles?: { role: { slug: string } }[];
|
createdAt: Date;
|
||||||
userPermissions?: { permission: { slug: string } }[];
|
isSystemAccount?: boolean;
|
||||||
}) {
|
linkedBotId?: string | null;
|
||||||
|
userRoles?: { role: { slug: string } }[];
|
||||||
|
userPermissions?: { permission: { slug: string } }[];
|
||||||
|
linkedBot?: { username: string; isSystemBot: boolean } | null;
|
||||||
|
},
|
||||||
|
isBot = isProtectedSystemAccount(user)
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email ?? undefined,
|
email: user.email ?? undefined,
|
||||||
@@ -170,7 +222,10 @@ export class AdminService {
|
|||||||
status: user.status,
|
status: user.status,
|
||||||
createdAt: user.createdAt.toISOString(),
|
createdAt: user.createdAt.toISOString(),
|
||||||
roles: user.userRoles?.map((link) => link.role.slug) ?? [],
|
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 };
|
return { users };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GrpcMethod('AdminService', 'ListBotAccounts')
|
||||||
|
async listBotAccounts(command: { search?: string }) {
|
||||||
|
const users = await this.admin.listBotAccounts(command.search);
|
||||||
|
return { users };
|
||||||
|
}
|
||||||
|
|
||||||
@GrpcMethod('AdminService', 'SuspendUser')
|
@GrpcMethod('AdminService', 'SuspendUser')
|
||||||
suspendUser(command: { userId: string }) {
|
suspendUser(command: { userId: string }) {
|
||||||
return this.admin.suspendUser(command.userId);
|
return this.admin.suspendUser(command.userId);
|
||||||
@@ -461,6 +467,11 @@ export class AuthGrpcController {
|
|||||||
return this.totp.disable(command.userId, command.code);
|
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')
|
@GrpcMethod('SecurityService', 'SetupPin')
|
||||||
setupPin(command: { userId: string; pin: string }) {
|
setupPin(command: { userId: string; pin: string }) {
|
||||||
return this.pin.setupPin(command.userId, command.pin);
|
return this.pin.setupPin(command.userId, command.pin);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../infra/prisma.service';
|
import { PrismaService } from '../../infra/prisma.service';
|
||||||
|
import { RbacService } from '../rbac.service';
|
||||||
import {
|
import {
|
||||||
BOTFATHER_BOT_USERNAME,
|
BOTFATHER_BOT_USERNAME,
|
||||||
BOTFATHER_DISPLAY_NAME,
|
BOTFATHER_DISPLAY_NAME,
|
||||||
@@ -16,7 +17,10 @@ export class BotFatherSeedService implements OnModuleInit {
|
|||||||
private cachedUserId: string | null = null;
|
private cachedUserId: string | null = null;
|
||||||
private cachedBotId: string | null = null;
|
private cachedBotId: string | null = null;
|
||||||
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly rbac: RbacService
|
||||||
|
) {}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
await this.ensureBotFather();
|
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_USER_ID, botFatherUser.id, 'UUID системного пользователя BotFather');
|
||||||
await this.upsertSetting(BOTFATHER_SETTING_BOT_ID, bot.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.cachedUserId = botFatherUser.id;
|
||||||
this.cachedBotId = bot.id;
|
this.cachedBotId = bot.id;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { PrismaService } from '../../infra/prisma.service';
|
|||||||
import { AccessService } from '../access.service';
|
import { AccessService } from '../access.service';
|
||||||
import { NotificationsService } from '../notifications.service';
|
import { NotificationsService } from '../notifications.service';
|
||||||
import { SettingsService } from '../settings.service';
|
import { SettingsService } from '../settings.service';
|
||||||
|
import { RbacService } from '../rbac.service';
|
||||||
import { BotRateLimitService } from './bot-rate-limit.service';
|
import { BotRateLimitService } from './bot-rate-limit.service';
|
||||||
import { assertValidBotUsername, generateTelegramStyleBotToken, hashBotToken, normalizeBotUsername } from './bot-token.util';
|
import { assertValidBotUsername, generateTelegramStyleBotToken, hashBotToken, normalizeBotUsername } from './bot-token.util';
|
||||||
import { menuButtonToJson, parseMenuButtonInput, resolveComposerMenuButton } from './bot-menu-button.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 access: AccessService,
|
||||||
private readonly settings: SettingsService,
|
private readonly settings: SettingsService,
|
||||||
private readonly rateLimit: BotRateLimitService,
|
private readonly rateLimit: BotRateLimitService,
|
||||||
private readonly notifications: NotificationsService
|
private readonly notifications: NotificationsService,
|
||||||
|
private readonly rbac: RbacService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
@@ -84,6 +86,8 @@ export class BotFatherService implements OnModuleInit {
|
|||||||
select: { id: true }
|
select: { id: true }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.rbac.ensureBotUserRole(systemUser.id);
|
||||||
|
|
||||||
this.logger.log(`Создан системный пользователь для бота @${bot.username}`);
|
this.logger.log(`Создан системный пользователь для бота @${bot.username}`);
|
||||||
return { userId: systemUser.id, botId: bot.id };
|
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 { Prisma } from '../generated/prisma/client';
|
||||||
import { EncryptionService } from '../infra/encryption.service';
|
import { EncryptionService } from '../infra/encryption.service';
|
||||||
import { PrismaService } from '../infra/prisma.service';
|
import { PrismaService } from '../infra/prisma.service';
|
||||||
|
import { isProtectedSystemAccount } from './system-account.util';
|
||||||
|
|
||||||
export const DOCUMENT_TYPES = [
|
export const DOCUMENT_TYPES = [
|
||||||
'PASSPORT_RF',
|
'PASSPORT_RF',
|
||||||
@@ -70,11 +71,29 @@ export class DocumentsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async list(userId: string) {
|
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({
|
const documents = await this.prisma.userDocument.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
orderBy: { updatedAt: 'desc' }
|
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) {
|
async get(documentId: string, userId: string) {
|
||||||
@@ -124,14 +143,33 @@ export class DocumentsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private toResponse(document: Awaited<ReturnType<PrismaService['userDocument']['create']>>) {
|
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 {
|
return {
|
||||||
id: document.id,
|
id: document.id,
|
||||||
userId: document.userId,
|
userId: document.userId,
|
||||||
type: document.type,
|
type: document.type,
|
||||||
number: this.encryption.decryptIfNeeded(document.number),
|
number: '—',
|
||||||
issuedAt: this.formatOptionalDate(document.issuedAt),
|
|
||||||
expiresAt: this.formatOptionalDate(document.expiresAt),
|
|
||||||
metadataJson: this.resolveMetadataJson(document.metadata),
|
|
||||||
createdAt: this.formatRequiredDate(document.createdAt),
|
createdAt: this.formatRequiredDate(document.createdAt),
|
||||||
updatedAt: this.formatRequiredDate(document.updatedAt)
|
updatedAt: this.formatRequiredDate(document.updatedAt)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export const DEFAULT_USER_ROLE_SLUG = 'user';
|
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 { PrismaService } from '../infra/prisma.service';
|
||||||
import { AccessService, UserAccessContext } from './access.service';
|
import { AccessService, UserAccessContext } from './access.service';
|
||||||
import { NotificationsService } from './notifications.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 {
|
export interface OAuthClientListItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -254,6 +254,15 @@ export class RbacService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async ensureDefaultUserRole(userId: string) {
|
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 } });
|
const role = await this.prisma.role.findUnique({ where: { slug: DEFAULT_USER_ROLE_SLUG } });
|
||||||
if (!role) return;
|
if (!role) return;
|
||||||
await this.prisma.userRole.upsert({
|
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) {
|
async assignUserPermission(actorUserId: string, userId: string, permissionSlug: string) {
|
||||||
await this.access.assertRbacManage(actorUserId);
|
await this.access.assertRbacManage(actorUserId);
|
||||||
await this.ensureUserExists(userId);
|
await this.ensureUserExists(userId);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { authenticator } from 'otplib';
|
|||||||
import { EncryptionService } from '../infra/encryption.service';
|
import { EncryptionService } from '../infra/encryption.service';
|
||||||
import { PrismaService } from '../infra/prisma.service';
|
import { PrismaService } from '../infra/prisma.service';
|
||||||
import { SettingsService } from './settings.service';
|
import { SettingsService } from './settings.service';
|
||||||
|
import { AccessService } from './access.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TotpService {
|
export class TotpService {
|
||||||
@@ -12,7 +13,8 @@ export class TotpService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly encryption: EncryptionService,
|
private readonly encryption: EncryptionService,
|
||||||
private readonly settings: SettingsService
|
private readonly settings: SettingsService,
|
||||||
|
private readonly access: AccessService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getStatus(userId: string) {
|
async getStatus(userId: string) {
|
||||||
@@ -91,6 +93,19 @@ export class TotpService {
|
|||||||
return { isEnabled: false };
|
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) {
|
async verifyEnabledCode(userId: string, code: string) {
|
||||||
const record = await this.prisma.userTotpSecret.findUnique({ where: { userId } });
|
const record = await this.prisma.userTotpSecret.findUnique({ where: { userId } });
|
||||||
if (!record?.isEnabled) {
|
if (!record?.isEnabled) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package admin;
|
|||||||
|
|
||||||
service AdminService {
|
service AdminService {
|
||||||
rpc ListUsers (ListUsersRequest) returns (ListUsersResponse);
|
rpc ListUsers (ListUsersRequest) returns (ListUsersResponse);
|
||||||
|
rpc ListBotAccounts (ListUsersRequest) returns (ListUsersResponse);
|
||||||
rpc UpdateUserProfile (UpdateUserRequest) returns (AdminUser);
|
rpc UpdateUserProfile (UpdateUserRequest) returns (AdminUser);
|
||||||
rpc ResetPassword (ResetPasswordRequest) returns (AdminUser);
|
rpc ResetPassword (ResetPasswordRequest) returns (AdminUser);
|
||||||
rpc SuspendUser (UserIdRequest) returns (AdminUser);
|
rpc SuspendUser (UserIdRequest) returns (AdminUser);
|
||||||
@@ -74,6 +75,9 @@ message AdminUser {
|
|||||||
repeated string directPermissions = 12;
|
repeated string directPermissions = 12;
|
||||||
bool isVerified = 13;
|
bool isVerified = 13;
|
||||||
optional string verificationIcon = 14;
|
optional string verificationIcon = 14;
|
||||||
|
bool isBot = 15;
|
||||||
|
optional string linkedBotUsername = 16;
|
||||||
|
optional bool isSystemBot = 17;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ListUsersResponse {
|
message ListUsersResponse {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ service SecurityService {
|
|||||||
rpc SetupTotp (UserSecurityRequest) returns (TotpSetupResponse);
|
rpc SetupTotp (UserSecurityRequest) returns (TotpSetupResponse);
|
||||||
rpc EnableTotp (TotpCodeRequest) returns (TotpStatusResponse);
|
rpc EnableTotp (TotpCodeRequest) returns (TotpStatusResponse);
|
||||||
rpc DisableTotp (TotpCodeRequest) returns (TotpStatusResponse);
|
rpc DisableTotp (TotpCodeRequest) returns (TotpStatusResponse);
|
||||||
|
rpc AdminDisableTotp (AdminDisableTotpRequest) returns (TotpStatusResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
service SettingsService {
|
service SettingsService {
|
||||||
@@ -248,3 +249,8 @@ message TotpCodeRequest {
|
|||||||
string userId = 1;
|
string userId = 1;
|
||||||
string code = 2;
|
string code = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message AdminDisableTotpRequest {
|
||||||
|
string actorUserId = 1;
|
||||||
|
string userId = 2;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user