first commit

This commit is contained in:
lendry
2026-06-24 14:37:15 +03:00
commit 995adeedd4
188 changed files with 28810 additions and 0 deletions

View File

@@ -0,0 +1,68 @@
import { Body, Controller, Delete, ForbiddenException, Get, Headers, Param, Post } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { firstValueFrom } from 'rxjs';
import { map } from 'rxjs/operators';
import { CoreGrpcService } from '../core-grpc.service';
import { getAuthorizedUserId } from '../document-access';
import { UpsertAddressDto } from '../dto/addresses.dto';
@ApiTags('Адреса')
@ApiBearerAuth()
@Controller('addresses/users/:userId')
export class AddressesController {
constructor(
private readonly core: CoreGrpcService,
private readonly jwt: JwtService
) {}
private async assertSelfAccess(authorization: string | undefined, userId: string) {
const requesterId = await getAuthorizedUserId(this.jwt, this.core, authorization);
if (requesterId !== userId) {
throw new ForbiddenException('Можно управлять только своими адресами');
}
return requesterId;
}
@Get()
@ApiOperation({ summary: 'Список адресов пользователя' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
async list(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) {
await this.assertSelfAccess(authorization, userId);
return firstValueFrom(
this.core.addresses.ListAddresses({ userId }).pipe(
map((response) => {
const payload = response as { addresses?: unknown[] };
return { addresses: payload.addresses ?? [] };
})
)
);
}
@Post()
@ApiOperation({ summary: 'Создать или обновить адрес' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiBody({ type: UpsertAddressDto })
async upsert(
@Headers('authorization') authorization: string | undefined,
@Param('userId') userId: string,
@Body() dto: UpsertAddressDto
) {
await this.assertSelfAccess(authorization, userId);
return firstValueFrom(this.core.addresses.UpsertAddress({ userId, ...dto }));
}
@Delete(':addressId')
@ApiOperation({ summary: 'Удалить адрес' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiParam({ name: 'addressId', description: 'ID адреса' })
@ApiResponse({ status: 200, description: 'Адрес удалён' })
async delete(
@Headers('authorization') authorization: string | undefined,
@Param('userId') userId: string,
@Param('addressId') addressId: string
) {
await this.assertSelfAccess(authorization, userId);
return firstValueFrom(this.core.addresses.DeleteAddress({ userId, addressId }));
}
}

View File

@@ -0,0 +1,69 @@
import { Body, Controller, ForbiddenException, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
import { map } from 'rxjs';
import { CoreGrpcService } from '../core-grpc.service';
import { CurrentAdmin } from '../decorators/current-admin.decorator';
import { ListUsersQueryDto, ResetPasswordDto, SetSuperAdminDto, UpdateUserDto } from '../dto/admin.dto';
import { AdminGuard, AdminRequestUser, assertAdminPermission, SuperAdminGuard } from '../guards/admin.guard';
@ApiTags('Администрирование')
@ApiBearerAuth()
@UseGuards(AdminGuard)
@Controller('admin/users')
export class AdminController {
constructor(private readonly core: CoreGrpcService) {}
@Get()
@ApiOperation({ summary: 'Список пользователей', description: 'Возвращает пользователей для административной таблицы.' })
listUsers(@Query() query: ListUsersQueryDto, @CurrentAdmin() admin: AdminRequestUser) {
if (!admin.canViewUsers && !admin.canManageUsers) {
throw new ForbiddenException('Недостаточно прав для просмотра пользователей');
}
return this.core.admin.ListUsers(query).pipe(
map((response) => {
const payload = response as { users?: Array<{ roles?: string[] }> };
return {
users: (payload.users ?? []).map((user) => ({
...user,
roles: user.roles ?? []
}))
};
})
);
}
@Patch(':userId')
@ApiOperation({ summary: 'Обновить профиль пользователя', description: 'Обновляет основные и резервные контакты пользователя.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiBody({ type: UpdateUserDto })
updateUser(@Param('userId') userId: string, @Body() dto: UpdateUserDto, @CurrentAdmin() admin: AdminRequestUser) {
assertAdminPermission(admin, 'canManageUsers');
return this.core.admin.UpdateUserProfile({ userId, ...dto });
}
@Post(':userId/reset-password')
@ApiOperation({ summary: 'Сбросить пароль', description: 'Назначает пользователю новый пароль.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiBody({ type: ResetPasswordDto })
resetPassword(@Param('userId') userId: string, @Body() dto: ResetPasswordDto, @CurrentAdmin() admin: AdminRequestUser) {
assertAdminPermission(admin, 'canManageUsers');
return this.core.admin.ResetPassword({ userId, newPassword: dto.newPassword });
}
@Post(':userId/suspend')
@ApiOperation({ summary: 'Заблокировать пользователя', description: 'Переводит пользователя в статус SUSPENDED и отзывает активные сессии.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
suspend(@Param('userId') userId: string, @CurrentAdmin() admin: AdminRequestUser) {
assertAdminPermission(admin, 'canManageUsers');
return this.core.admin.SuspendUser({ userId });
}
@Patch(':userId/super-admin')
@UseGuards(SuperAdminGuard)
@ApiOperation({ summary: 'Выдать или снять права супер-администратора', description: 'Доступно только супер-администратору.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiBody({ type: SetSuperAdminDto })
setSuperAdmin(@Param('userId') userId: string, @Body() dto: SetSuperAdminDto, @CurrentAdmin() admin: AdminRequestUser) {
return this.core.admin.SetSuperAdmin({ actorUserId: admin.id, userId, isSuperAdmin: dto.isSuperAdmin });
}
}

View File

@@ -0,0 +1,42 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CoreGrpcService } from '../core-grpc.service';
import { QrSessionDto, WebAuthnDto } from '../dto/identity.dto';
@ApiTags('Биометрия и QR-вход')
@Controller('auth/advanced')
export class AdvancedAuthController {
constructor(private readonly core: CoreGrpcService) {}
@Post('webauthn/register/challenge')
@ApiOperation({ summary: 'Challenge регистрации WebAuthn', description: 'Создает challenge для регистрации лица/отпечатка. Endpoint готов для подключения настоящего WebAuthn attestation.' })
@ApiBody({ type: WebAuthnDto })
@ApiResponse({ status: 201, description: 'Challenge создан' })
webAuthnRegister(@Body() dto: WebAuthnDto) {
return this.core.advancedAuth.CreateWebAuthnRegistrationChallenge(dto);
}
@Post('webauthn/login/challenge')
@ApiOperation({ summary: 'Challenge входа WebAuthn', description: 'Создает challenge для входа по лицу или отпечатку.' })
@ApiBody({ type: WebAuthnDto })
@ApiResponse({ status: 201, description: 'Challenge создан' })
webAuthnLogin(@Body() dto: WebAuthnDto) {
return this.core.advancedAuth.CreateWebAuthnLoginChallenge(dto);
}
@Post('qr/session')
@ApiOperation({ summary: 'Создать QR-сессию', description: 'Создает временную QR-сессию для входа с другого устройства.' })
@ApiBody({ type: QrSessionDto })
@ApiResponse({ status: 201, description: 'QR-сессия создана' })
createQr(@Body() dto: QrSessionDto) {
return this.core.advancedAuth.CreateQrSession(dto);
}
@Get('qr/session/:sessionId')
@ApiOperation({ summary: 'Проверить QR-сессию', description: 'Возвращает текущий статус QR-сессии: PENDING/CONFIRMED/EXPIRED.' })
@ApiParam({ name: 'sessionId', description: 'ID QR-сессии' })
@ApiResponse({ status: 200, description: 'Статус QR-сессии получен' })
pollQr(@Param('sessionId') sessionId: string) {
return this.core.advancedAuth.PollQrSession({ sessionId });
}
}

View File

@@ -0,0 +1,129 @@
import { Body, Controller, Get, Headers, Post } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger';
import { firstValueFrom } from 'rxjs';
import { CoreGrpcService } from '../core-grpc.service';
import { IdentifyDto, LdapLoginDto, LoginDto, PasswordlessOtpDto, PasswordlessVerifyDto, PasswordLoginDto, RefreshSessionDto, RegisterDto, VerifyPinDto } from '../dto/auth.dto';
import { resolveAuthorizedPayload, verifyAccessToken } from '../session-auth';
@ApiTags('Аутентификация')
@Controller('auth')
export class AuthController {
constructor(
private readonly core: CoreGrpcService,
private readonly jwt: JwtService
) {}
@Post('register')
@ApiOperation({ summary: 'Регистрация пользователя', description: 'Создает пользователя. Первый пользователь безопасно получает права super admin.' })
@ApiBody({ type: RegisterDto })
register(@Body() dto: RegisterDto) {
return this.core.auth.Register(dto);
}
@Post('login')
@ApiOperation({ summary: 'Вход по почте, телефону или логину', description: 'Возвращает JWT и refresh token. Если PIN включен, сессия создается в ограниченном режиме.' })
@ApiBody({ type: LoginDto })
login(@Body() dto: LoginDto) {
return this.core.auth.Login(dto);
}
@Post('identify')
@ApiOperation({ summary: 'Проверить способ входа', description: 'Identifier-first шаг: проверяет, существует ли пользователь, задан ли пароль и включен ли PIN.' })
@ApiBody({ type: IdentifyDto })
identify(@Body() dto: IdentifyDto) {
return this.core.auth.Identify(dto);
}
@Post('otp/send')
@ApiOperation({ summary: 'Отправить OTP для входа', description: 'Passwordless-first вход: пользователь вводит почту или телефон, сервер создает 6-значный код и пишет его в console.log.' })
@ApiBody({ type: PasswordlessOtpDto })
sendOtp(@Body() dto: PasswordlessOtpDto) {
return this.core.auth.SendOtp({ recipient: dto.recipient, channel: dto.channel });
}
@Post('otp/verify')
@ApiOperation({ summary: 'Проверить OTP для входа', description: 'Если пользователь не существует, создает его. Если пароль не задан, сразу возвращает JWT. Если пароль задан, возвращает requiresPassword=true и tempAuthToken.' })
@ApiBody({ type: PasswordlessVerifyDto })
verifyOtp(@Body() dto: PasswordlessVerifyDto) {
return this.core.auth.VerifyOtp(dto);
}
@Post('login/password')
@ApiOperation({ summary: 'Войти по паролю', description: 'Identifier-first парольный шаг. Принимает login+password, либо tempAuthToken+password для совместимости, затем выдает JWT.' })
@ApiBody({ type: PasswordLoginDto })
loginWithPassword(@Body() dto: PasswordLoginDto) {
return this.core.auth.LoginWithPassword(dto);
}
@Post('ldap/login')
@ApiOperation({ summary: 'Войти через LDAP/LDAPS', description: 'Аутентификация через корпоративный LDAP-сервер. Требует включённой настройки LDAP_ENABLED.' })
@ApiBody({ type: LdapLoginDto })
loginWithLdap(@Body() dto: LdapLoginDto) {
return this.core.auth.LoginWithLdap(dto);
}
@Post('pin/verify')
@ApiOperation({ summary: 'Подтвердить PIN-код', description: 'Разблокирует временную сессию и выдает JWT с pinVerified=true.' })
@ApiBody({ type: VerifyPinDto })
verifyPin(@Body() dto: VerifyPinDto) {
return this.core.auth.VerifyPin(dto);
}
@Post('refresh')
@ApiOperation({ summary: 'Обновить access token', description: 'Обновляет JWT по refresh token. Если сессия заблокирована PIN-кодом, возвращает requiresPin=true без выхода из аккаунта.' })
@ApiBody({ type: RefreshSessionDto })
refresh(@Body() dto: RefreshSessionDto) {
return this.core.auth.RefreshSession(dto);
}
@Get('session')
@ApiBearerAuth()
@ApiOperation({
summary: 'Состояние текущей сессии',
description: 'Возвращает профиль и флаг PIN-блокировки. Не выбрасывает пользователя из аккаунта при заблокированном PIN.'
})
async session(@Headers('authorization') authorization?: string) {
const payload = await verifyAccessToken(this.jwt, authorization);
if (!payload.sessionId) {
const user = await firstValueFrom(this.core.auth.GetMe({ userId: payload.sub }));
return { requiresPin: false, sessionId: null, pinVerified: true, user };
}
const validation = (await firstValueFrom(
this.core.auth.ValidateSession({
userId: payload.sub,
sessionId: payload.sessionId,
touchActivity: false
})
)) as { requiresPin: boolean; sessionId: string; pinVerified: boolean };
const user = await firstValueFrom(this.core.auth.GetMe({ userId: payload.sub }));
if (!validation.requiresPin) {
await firstValueFrom(
this.core.auth.ValidateSession({
userId: payload.sub,
sessionId: payload.sessionId,
touchActivity: true
})
);
}
return {
requiresPin: validation.requiresPin,
sessionId: validation.sessionId,
pinVerified: validation.pinVerified,
user
};
}
@Get('me')
@ApiBearerAuth()
@ApiOperation({ summary: 'Текущий пользователь', description: 'Возвращает профиль пользователя по access token.' })
async me(@Headers('authorization') authorization?: string) {
const payload = await resolveAuthorizedPayload(this.jwt, this.core, authorization);
return firstValueFrom(this.core.auth.GetMe({ userId: payload.sub }));
}
}

View File

@@ -0,0 +1,142 @@
import { Body, Controller, Get, Headers, Param, Patch, Post, Query } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { firstValueFrom } from 'rxjs';
import { map } from 'rxjs/operators';
import { CoreGrpcService } from '../core-grpc.service';
import { getAuthorizedUserId } from '../document-access';
import {
CreateChatRoomDto,
SendChatMessageDto,
SetRoomNotificationsMutedDto,
UpdateChatRoomDto,
VotePollDto
} from '../dto/chat.dto';
@ApiTags('Чат')
@ApiBearerAuth()
@Controller('chat')
export class ChatController {
constructor(
private readonly core: CoreGrpcService,
private readonly jwt: JwtService
) {}
private async auth(authorization?: string) {
return getAuthorizedUserId(this.jwt, this.core, authorization);
}
@Get('groups/:groupId/rooms')
@ApiOperation({ summary: 'Список чатов семьи' })
async listRooms(@Headers('authorization') authorization: string | undefined, @Param('groupId') groupId: string) {
const userId = await this.auth(authorization);
return firstValueFrom(
this.core.chat.ListRooms({ userId, groupId }).pipe(
map((response) => {
const payload = response as { rooms?: unknown[] };
return { rooms: payload.rooms ?? [] };
})
)
);
}
@Post('groups/:groupId/rooms')
@ApiOperation({ summary: 'Создать групповой чат' })
async createRoom(
@Headers('authorization') authorization: string | undefined,
@Param('groupId') groupId: string,
@Body() dto: CreateChatRoomDto
) {
const userId = await this.auth(authorization);
return firstValueFrom(
this.core.chat.CreateRoom({ userId, groupId, name: dto.name, memberUserIds: dto.memberUserIds ?? [] })
);
}
@Patch('rooms/:roomId')
@ApiOperation({ summary: 'Настройки чата' })
async updateRoom(
@Headers('authorization') authorization: string | undefined,
@Param('roomId') roomId: string,
@Body() dto: UpdateChatRoomDto
) {
const userId = await this.auth(authorization);
return firstValueFrom(
this.core.chat.UpdateRoomSettings({
userId,
roomId,
name: dto.name,
notificationsMuted: dto.notificationsMuted
})
);
}
@Get('rooms/:roomId/messages')
@ApiOperation({ summary: 'Сообщения чата' })
async listMessages(
@Headers('authorization') authorization: string | undefined,
@Param('roomId') roomId: string,
@Query('beforeMessageId') beforeMessageId?: string,
@Query('limit') limit?: string
) {
const userId = await this.auth(authorization);
return firstValueFrom(
this.core.chat.ListMessages({
userId,
roomId,
beforeMessageId,
limit: limit ? Number(limit) : 50
}).pipe(
map((response) => {
const payload = response as { messages?: unknown[] };
return { messages: payload.messages ?? [] };
})
)
);
}
@Post('rooms/:roomId/messages')
@ApiOperation({ summary: 'Отправить сообщение' })
async sendMessage(
@Headers('authorization') authorization: string | undefined,
@Param('roomId') roomId: string,
@Body() dto: SendChatMessageDto
) {
const userId = await this.auth(authorization);
return firstValueFrom(
this.core.chat.SendMessage({
userId,
roomId,
type: dto.type,
content: dto.content,
replyToId: dto.replyToId,
storageKey: dto.storageKey,
mimeType: dto.mimeType,
metadataJson: dto.metadataJson,
poll: dto.poll
})
);
}
@Post('messages/:messageId/vote')
@ApiOperation({ summary: 'Голос в опросе' })
async votePoll(
@Headers('authorization') authorization: string | undefined,
@Param('messageId') messageId: string,
@Body() dto: VotePollDto
) {
const userId = await this.auth(authorization);
return firstValueFrom(this.core.chat.VotePoll({ userId, messageId, optionIds: dto.optionIds }));
}
@Post('rooms/:roomId/mute')
@ApiOperation({ summary: 'Выключить/включить уведомления чата' })
async setMuted(
@Headers('authorization') authorization: string | undefined,
@Param('roomId') roomId: string,
@Body() dto: SetRoomNotificationsMutedDto
) {
const userId = await this.auth(authorization);
return firstValueFrom(this.core.chat.SetRoomNotificationsMuted({ userId, roomId, muted: dto.muted }));
}
}

View File

@@ -0,0 +1,97 @@
import { Body, Controller, Delete, Get, Headers, Param, Patch, Post } from '@nestjs/common';
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';
@ApiTags('Хранилище данных и документы')
@ApiBearerAuth()
@Controller('documents/users/:userId')
export class DocumentsController {
constructor(
private readonly core: CoreGrpcService,
private readonly jwt: JwtService
) {}
@Post()
@ApiOperation({ summary: 'Создать документ', description: 'Создает документ пользователя с данными формы. Поля шифруются в SSO Core.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiBody({ type: CreateDocumentDto })
async create(
@Headers('authorization') authorization: string | undefined,
@Param('userId') userId: string,
@Body() dto: CreateDocumentDto
) {
await assertDocumentsWriteAccess(this.jwt, this.core, authorization, userId);
return firstValueFrom(
this.core.documents.CreateDocument({
userId,
type: dto.type,
number: dto.number ?? '—',
issuedAt: dto.issuedAt,
expiresAt: dto.expiresAt,
metadataJson: dto.metadataJson
})
);
}
@Get()
@ApiOperation({ summary: 'Список документов', description: 'Возвращает все документы пользователя.' })
@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 ?? [] };
})
)
);
}
@Get(':documentId')
@ApiOperation({ summary: 'Получить документ', description: 'Возвращает один документ пользователя с расшифрованными полями.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiParam({ name: 'documentId', description: 'ID документа' })
async get(
@Headers('authorization') authorization: string | undefined,
@Param('userId') userId: string,
@Param('documentId') documentId: string
) {
await assertDocumentsReadAccess(this.jwt, this.core, authorization, userId);
return firstValueFrom(this.core.documents.GetDocument({ userId, documentId }));
}
@Patch(':documentId')
@ApiOperation({ summary: 'Обновить документ', description: 'Обновляет данные формы документа.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiParam({ name: 'documentId', description: 'ID документа' })
@ApiBody({ type: UpdateDocumentDto })
async update(
@Headers('authorization') authorization: string | undefined,
@Param('userId') userId: string,
@Param('documentId') documentId: string,
@Body() dto: UpdateDocumentDto
) {
await assertDocumentsWriteAccess(this.jwt, this.core, authorization, userId);
return firstValueFrom(this.core.documents.UpdateDocument({ userId, documentId, ...dto }));
}
@Delete(':documentId')
@ApiOperation({ summary: 'Удалить документ', description: 'Удаляет документ пользователя.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiParam({ name: 'documentId', description: 'ID документа' })
@ApiResponse({ status: 200, description: 'Документ удалён' })
async delete(
@Headers('authorization') authorization: string | undefined,
@Param('userId') userId: string,
@Param('documentId') documentId: string
) {
await assertDocumentsWriteAccess(this.jwt, this.core, authorization, userId);
return firstValueFrom(this.core.documents.DeleteDocument({ userId, documentId }));
}
}

View File

@@ -0,0 +1,130 @@
import { Body, Controller, Delete, ForbiddenException, Get, Headers, Param, Patch, Post } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
import { firstValueFrom } from 'rxjs';
import { map } from 'rxjs/operators';
import { CoreGrpcService } from '../core-grpc.service';
import { getAuthorizedUserId } from '../document-access';
import { AddFamilyMemberDto, CreateFamilyGroupDto, RespondFamilyInviteDto, SendFamilyInviteDto, UpdateFamilyGroupDto } from '../dto/identity.dto';
@ApiTags('Семья')
@ApiBearerAuth()
@Controller('family')
export class FamilyController {
constructor(
private readonly core: CoreGrpcService,
private readonly jwt: JwtService
) {}
private async auth(authorization?: string) {
return getAuthorizedUserId(this.jwt, this.core, authorization);
}
@Post('groups')
@ApiOperation({ summary: 'Создать семейную группу' })
@ApiBody({ type: CreateFamilyGroupDto })
async createGroup(@Headers('authorization') authorization: string | undefined, @Body() dto: CreateFamilyGroupDto) {
const userId = await this.auth(authorization);
if (dto.ownerId !== userId) {
throw new ForbiddenException('Можно создавать семью только от своего имени');
}
return firstValueFrom(this.core.family.CreateFamilyGroup(dto));
}
@Get('users/:userId/groups')
@ApiOperation({ summary: 'Список семей пользователя' })
async listGroups(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) {
const requesterId = await this.auth(authorization);
if (requesterId !== userId) {
throw new ForbiddenException('Можно просматривать только свои семьи');
}
return firstValueFrom(
this.core.family.ListFamilyGroups({ userId }).pipe(
map((response) => {
const payload = response as { groups?: unknown[] };
return { groups: payload.groups ?? [] };
})
)
);
}
@Get('groups/:groupId')
@ApiOperation({ summary: 'Получить семейную группу' })
async getGroup(@Headers('authorization') authorization: string | undefined, @Param('groupId') groupId: string) {
const requesterId = await this.auth(authorization);
return firstValueFrom(this.core.family.GetFamilyGroup({ requesterId, groupId }));
}
@Patch('groups/:groupId')
@ApiOperation({ summary: 'Обновить семейную группу' })
async updateGroup(
@Headers('authorization') authorization: string | undefined,
@Param('groupId') groupId: string,
@Body() dto: UpdateFamilyGroupDto
) {
const requesterId = await this.auth(authorization);

View File

@@ -0,0 +1,9 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check() {
return { status: 'ok', service: 'api-gateway' };
}
}

View File

@@ -0,0 +1,215 @@
import { Body, Controller, Get, Headers, Param, Post, Query, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { JwtService } from '@nestjs/jwt';
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { firstValueFrom } from 'rxjs';
import { CoreGrpcService } from '../core-grpc.service';
import { getAuthorizedUserId } from '../document-access';
import { AvatarUploadDto, ChatMediaUploadDto, ConfirmAvatarDto, DocumentPhotoUploadDto } from '../dto/media.dto';
import { buildContentDisposition } from '../media-content-disposition';
type StreamResponse = {
setHeader: (key: string, value: string) => void;
status: (code: number) => { json: (body: unknown) => void };
} & NodeJS.WritableStream;
@ApiTags('Медиа')
@Controller('media')
export class MediaController {
private s3Client: S3Client | null = null;
constructor(
private readonly core: CoreGrpcService,
private readonly jwt: JwtService
) {}
private getS3Client() {
if (!this.s3Client) {
const endpoint = process.env.MINIO_ENDPOINT ?? 'localhost:9000';
const useSsl = process.env.MINIO_USE_SSL === 'true';
this.s3Client = new S3Client({
endpoint: `${useSsl ? 'https' : 'http'}://${endpoint}`,
region: process.env.MINIO_REGION ?? 'us-east-1',
credentials: {
accessKeyId: process.env.MINIO_ACCESS_KEY ?? 'minioadmin',
secretAccessKey: process.env.MINIO_SECRET_KEY ?? 'minioadmin'
},
forcePathStyle: true
});
}
return this.s3Client;
}
private async authUserId(authorization?: string) {
return getAuthorizedUserId(this.jwt, this.core, authorization);
}
@Post('avatars/upload-url')
@ApiBearerAuth()
@ApiOperation({ summary: 'Получить URL для загрузки аватара', description: 'Возвращает presigned URL MinIO для загрузки изображения аватара.' })
@ApiBody({ type: AvatarUploadDto })
async createAvatarUploadUrl(@Headers('authorization') authorization: string | undefined, @Body() dto: AvatarUploadDto) {
const userId = await this.authUserId(authorization);
return firstValueFrom(this.core.media.CreateAvatarUploadUrl({ userId, contentType: dto.contentType }));
}
@Post('avatars/confirm')
@ApiBearerAuth()
@ApiOperation({ summary: 'Подтвердить загрузку аватара', description: 'Сохраняет ключ объекта MinIO как аватар пользователя.' })
@ApiBody({ type: ConfirmAvatarDto })
async confirmAvatar(@Headers('authorization') authorization: string | undefined, @Body() dto: ConfirmAvatarDto) {
const userId = await this.authUserId(authorization);
return firstValueFrom(this.core.media.ConfirmAvatar({ userId, storageKey: dto.storageKey }));
}
@Get('avatars/:userId/url')
@ApiBearerAuth()
@ApiOperation({ summary: 'Получить временную ссылку на аватар', description: 'Возвращает ссылку, действующую 15 минут. Без авторизации ссылка не выдаётся.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
async getAvatarUrl(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) {
const requesterId = await this.authUserId(authorization);
return firstValueFrom(this.core.media.GetAvatarAccessUrl({ requesterId, targetUserId: userId }));
}
@Post('documents/:documentId/photo/upload-url')
@ApiBearerAuth()
@ApiOperation({ summary: 'Получить URL для фото документа', description: 'Presigned URL для загрузки скана/фото документа в MinIO.' })
@ApiBody({ type: DocumentPhotoUploadDto })
async createDocumentPhotoUploadUrl(
@Headers('authorization') authorization: string | undefined,
@Param('documentId') documentId: string,
@Body() dto: DocumentPhotoUploadDto
) {
const userId = await this.authUserId(authorization);
return firstValueFrom(this.core.media.CreateDocumentPhotoUploadUrl({ userId, documentId, contentType: dto.contentType }));
}
@Get('users/:userId/documents/photo-url')
@ApiBearerAuth()
@ApiOperation({ summary: 'Получить ссылку на фото документа', description: 'Временная ссылка на просмотр фото документа (15 минут).' })
@ApiParam({ name: 'userId', description: 'ID владельца документа' })
@ApiQuery({ name: 'storageKey', description: 'Ключ объекта в MinIO' })
async getDocumentPhotoUrl(
@Headers('authorization') authorization: string | undefined,
@Param('userId') userId: string,
@Query('storageKey') storageKey: string
) {
const requesterId = await this.authUserId(authorization);
return firstValueFrom(
this.core.media.GetDocumentPhotoAccessUrl({ requesterId, targetUserId: userId, storageKey })
);
}
@Get('stream/:token')
@ApiOperation({ summary: 'Потоковая выдача медиа', description: 'Отдаёт файл по временному токену. Для файлов чата требуется Authorization. Токен действует 15 минут.' })
@ApiParam({ name: 'token', description: 'Временный JWT-токен доступа' })
@ApiResponse({ status: 403, description: 'Ссылка недействительна или истекла' })
async stream(
@Param('token') token: string,
@Headers('authorization') authorization: string | undefined,
@Query('download') download: string | undefined,
@Res({ passthrough: false }) response: StreamResponse
) {
let requesterId: string | undefined;
if (authorization) {
try {
requesterId = await getAuthorizedUserId(this.jwt, this.core, authorization);
} catch {
requesterId = undefined;
}
}
const resolved = await firstValueFrom(this.core.media.ResolveStreamToken({ token, requesterId }));
const payload = resolved as { storageKey: string; contentType: string; fileName?: string };
const bucket = process.env.MINIO_BUCKET ?? 'lendry-id';
const object = await this.getS3Client().send(
new GetObjectCommand({
Bucket: bucket,
Key: payload.storageKey
})
);
response.setHeader('Content-Type', payload.contentType);
response.setHeader('Cache-Control', 'private, no-store');
if (payload.fileName) {
const asAttachment = download === '1' || download === 'true';
response.setHeader(
'Content-Disposition',
buildContentDisposition(asAttachment ? 'attachment' : 'inline', payload.fileName)
);
}
const body = object.Body;
if (!body) {
response.status(404).json({ message: 'Файл не найден' });
return;
}
const stream = body as NodeJS.ReadableStream;
stream.pipe(response);
}
@Post('families/:groupId/avatar/upload-url')
@ApiBearerAuth()
async createFamilyAvatarUploadUrl(
@Headers('authorization') authorization: string | undefined,
@Param('groupId') groupId: string,
@Body() dto: AvatarUploadDto
) {
const requesterId = await this.authUserId(authorization);
return firstValueFrom(
this.core.media.CreateFamilyAvatarUploadUrl({ requesterId, groupId, contentType: dto.contentType })
);
}
@Post('families/:groupId/avatar/confirm')
@ApiBearerAuth()
async confirmFamilyAvatar(
@Headers('authorization') authorization: string | undefined,
@Param('groupId') groupId: string,
@Body() dto: ConfirmAvatarDto
) {
const requesterId = await this.authUserId(authorization);
return firstValueFrom(
this.core.media.ConfirmFamilyAvatar({ requesterId, groupId, storageKey: dto.storageKey })
);
}
@Get('families/:groupId/avatar/url')
@ApiBearerAuth()
async getFamilyAvatarUrl(@Headers('authorization') authorization: string | undefined, @Param('groupId') groupId: string) {
const requesterId = await this.authUserId(authorization);
return firstValueFrom(this.core.media.GetFamilyAvatarAccessUrl({ requesterId, groupId }));
}
@Post('chat/:roomId/media/upload-url')
@ApiBearerAuth()
async createChatMediaUploadUrl(
@Headers('authorization') authorization: string | undefined,
@Param('roomId') roomId: string,
@Body() dto: ChatMediaUploadDto
) {
const requesterId = await this.authUserId(authorization);
return firstValueFrom(
this.core.media.CreateChatMediaUploadUrl({
requesterId,
roomId,
contentType: dto.contentType,
fileName: dto.fileName
})
);
}
@Get('chat/:roomId/media/url')
@ApiBearerAuth()
async getChatMediaUrl(
@Headers('authorization') authorization: string | undefined,
@Param('roomId') roomId: string,
@Query('storageKey') storageKey: string,
@Query('fileName') fileName?: string
) {
const requesterId = await this.authUserId(authorization);
return firstValueFrom(
this.core.media.GetChatMediaAccessUrl({ requesterId, roomId, storageKey, fileName })
);
}
}

View File

@@ -0,0 +1,82 @@
import { Body, Controller, Delete, Get, Headers, Param, Patch, Post, Query } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { firstValueFrom } from 'rxjs';
import { map } from 'rxjs/operators';
import { CoreGrpcService } from '../core-grpc.service';
import { getAuthorizedUserId } from '../document-access';
import { MarkNotificationReadDto } from '../dto/notifications.dto';
@ApiTags('Уведомления')
@ApiBearerAuth()
@Controller('notifications')
export class NotificationsController {
constructor(
private readonly core: CoreGrpcService,
private readonly jwt: JwtService
) {}
@Get()
@ApiOperation({ summary: 'Список уведомлений' })
async list(
@Headers('authorization') authorization: string | undefined,
@Query('limit') limit?: string,
@Query('unreadOnly') unreadOnly?: string
) {
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization);
return firstValueFrom(
this.core.notifications.ListNotifications({
userId,
limit: limit ? Number(limit) : 30,
unreadOnly: unreadOnly === 'true'
}).pipe(
map((response) => {
const payload = response as { notifications?: unknown[] };
return { notifications: payload.notifications ?? [] };
})
)
);
}
@Get('unread-count')
@ApiOperation({ summary: 'Количество непрочитанных уведомлений' })
async unreadCount(@Headers('authorization') authorization: string | undefined) {
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization);
return firstValueFrom(this.core.notifications.GetUnreadCount({ userId }));
}
@Patch(':notificationId/read')
@ApiOperation({ summary: 'Удалить просмотренное уведомление' })
async markRead(
@Headers('authorization') authorization: string | undefined,
@Param('notificationId') notificationId: string,
@Body() _dto: MarkNotificationReadDto
) {
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization);
return firstValueFrom(this.core.notifications.DeleteNotification({ userId, notificationId }));
}
@Delete(':notificationId')
@ApiOperation({ summary: 'Удалить уведомление' })
async deleteNotification(
@Headers('authorization') authorization: string | undefined,
@Param('notificationId') notificationId: string
) {
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization);
return firstValueFrom(this.core.notifications.DeleteNotification({ userId, notificationId }));
}
@Post('read-all')
@ApiOperation({ summary: 'Удалить все уведомления' })
async markAllRead(@Headers('authorization') authorization: string | undefined) {
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization);
return firstValueFrom(this.core.notifications.DeleteAllNotifications({ userId }));
}
@Delete()
@ApiOperation({ summary: 'Удалить все уведомления' })
async deleteAll(@Headers('authorization') authorization: string | undefined) {
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization);
return firstValueFrom(this.core.notifications.DeleteAllNotifications({ userId }));
}
}

View File

@@ -0,0 +1,38 @@
import { Body, Controller, Get, Headers, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CoreGrpcService } from '../core-grpc.service';
import { OAuthAuthorizeQueryDto, OAuthTokenDto } from '../dto/identity.dto';
import { extractBearerToken } from '../auth-token';
@ApiTags('OAuth 2.0')
@Controller('oauth')
export class OAuthController {
constructor(private readonly core: CoreGrpcService) {}
@Get('authorize')
@ApiOperation({ summary: 'OAuth авторизация', description: 'Создает authorization_code и возвращает redirectUrl для OAuth клиента. Consent считается подтвержденным для переданного userId.' })
@ApiQuery({ name: 'userId', description: 'ID пользователя' })
@ApiQuery({ name: 'clientId', description: 'OAuth client_id' })
@ApiQuery({ name: 'redirectUri', description: 'redirect_uri' })
@ApiQuery({ name: 'scope', description: 'Scopes через пробел' })
@ApiResponse({ status: 200, description: 'Authorization code создан' })
authorize(@Query() query: OAuthAuthorizeQueryDto) {
return this.core.oauth.Authorize(query);
}
@Post('token')
@ApiOperation({ summary: 'Выдать OAuth токены', description: 'Поддерживает grant_type=authorization_code и grant_type=refresh_token.' })
@ApiBody({ type: OAuthTokenDto })
@ApiResponse({ status: 201, description: 'OAuth токены выданы' })
token(@Body() dto: OAuthTokenDto) {
return this.core.oauth.Token(dto);
}
@Get('userinfo')
@ApiBearerAuth()
@ApiOperation({ summary: 'OAuth userinfo', description: 'Возвращает профиль пользователя по OAuth access token.' })
@ApiResponse({ status: 200, description: 'Профиль пользователя получен' })
userInfo(@Headers('authorization') authorization?: string) {
return this.core.oauth.UserInfo({ accessToken: extractBearerToken(authorization) });
}
}

View File

@@ -0,0 +1,26 @@
import { Body, Controller, Post } from '@nestjs/common';
import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CoreGrpcService } from '../core-grpc.service';
import { SendOtpDto, VerifyOtpDto } from '../dto/identity.dto';
@ApiTags('OTP и 2FA')
@Controller('auth/otp')
export class OtpController {
constructor(private readonly core: CoreGrpcService) {}
@Post('send')
@ApiOperation({ summary: 'Отправить OTP код', description: 'Создает одноразовый код в AuthCode. Fallback-доставка пишет код в console.log.' })
@ApiBody({ type: SendOtpDto })
@ApiResponse({ status: 201, description: 'Код создан и отправлен' })
send(@Body() dto: SendOtpDto) {
return this.core.otp.SendOtp(dto);
}
@Post('verify')
@ApiOperation({ summary: 'Проверить OTP код', description: 'Проверяет одноразовый код, срок жизни и помечает его использованным.' })
@ApiBody({ type: VerifyOtpDto })
@ApiResponse({ status: 201, description: 'Код подтвержден' })
verify(@Body() dto: VerifyOtpDto) {
return this.core.otp.VerifyOtp(dto);
}
}

View File

@@ -0,0 +1,83 @@
import { Body, Controller, ForbiddenException, Get, Headers, Param, Patch, Post } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CoreGrpcService } from '../core-grpc.service';
import { getAuthorizedUserId } from '../document-access';
import { SetPasswordDto, UpdateAvatarDto, UpdateContactsDto, UpdateProfileDto } from '../dto/profile.dto';
@ApiTags('Профиль и биометрия')
@ApiBearerAuth()
@Controller('profile/users/:userId')
export class ProfileController {
constructor(
private readonly core: CoreGrpcService,
private readonly jwt: JwtService
) {}
private async assertSelfAccess(authorization: string | undefined, userId: string) {
const requesterId = await getAuthorizedUserId(this.jwt, this.core, authorization);
if (requesterId !== userId) {
throw new ForbiddenException('Можно изменять только свой профиль');
}
return requesterId;
}
@Get()
@ApiOperation({ summary: 'Получить профиль пользователя', description: 'Возвращает публичный профиль, аватар и основные/резервные контакты пользователя.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiResponse({ status: 200, description: 'Профиль пользователя успешно получен' })
@ApiResponse({ status: 404, description: 'Пользователь не найден' })
getProfile(@Param('userId') userId: string) {
return this.core.profile.GetProfile({ userId });
}
@Patch('avatar')
@ApiOperation({ summary: 'Обновить аватар', description: 'Сохраняет URL или ключ объекта MinIO как аватар пользователя.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiBody({ type: UpdateAvatarDto })
@ApiResponse({ status: 200, description: 'Аватар обновлен' })
updateAvatar(@Param('userId') userId: string, @Body() dto: UpdateAvatarDto) {
return this.core.profile.UpdateAvatar({ userId, avatarUrl: dto.avatarUrl, avatarStorageKey: dto.avatarStorageKey });
}

View File

@@ -0,0 +1,21 @@
import { Controller, Get } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { map } from 'rxjs';
import { CoreGrpcService } from '../core-grpc.service';
@ApiTags('Публичные настройки')
@Controller('settings')
export class PublicSettingsController {
constructor(private readonly core: CoreGrpcService) {}
@Get('public')
@ApiOperation({ summary: 'Публичные настройки', description: 'Возвращает несекретные настройки для интерфейса без авторизации.' })
listPublic() {
return this.core.settings.ListPublicSettings({}).pipe(
map((response) => {
const payload = response as { settings?: Array<{ key: string; value: string }> };
return { settings: payload.settings ?? [] };
})
);
}
}

View File

@@ -0,0 +1,103 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
import { map } from 'rxjs';
import { CoreGrpcService } from '../core-grpc.service';
import { CurrentAdmin } from '../decorators/current-admin.decorator';
import { AssignUserRoleDto, CreateOAuthClientDto, CreateRoleDto, UpdateOAuthClientDto } from '../dto/rbac.dto';
import { AdminGuard, AdminRequestUser, assertAdminPermission, SuperAdminGuard } from '../guards/admin.guard';
@ApiTags('RBAC и OAuth')
@ApiBearerAuth()
@UseGuards(AdminGuard)
@Controller('admin/rbac')
export class RbacController {
constructor(private readonly core: CoreGrpcService) {}
@Get('roles')
@ApiOperation({ summary: 'Список ролей', description: 'Возвращает роли вместе с назначенными правами.' })
listRoles() {
return this.core.rbac.ListRoles({}).pipe(
map((response) => {
const payload = response as { roles?: Array<{ permissions?: unknown[] }> };
return {
roles: (payload.roles ?? []).map((role) => ({
...role,
permissions: role.permissions ?? []
}))
};
})
);
}
@Get('permissions')
@UseGuards(SuperAdminGuard)
@ApiOperation({ summary: 'Список прав', description: 'Возвращает все доступные permissions.' })
listPermissions() {
return this.core.rbac.ListPermissions({});
}
@Get('oauth-scopes')
@ApiOperation({ summary: 'OAuth scopes', description: 'Возвращает доступные scopes для OAuth-приложений.' })
listOAuthScopes(@CurrentAdmin() admin: AdminRequestUser) {
assertAdminPermission(admin, 'canManageOAuth');
return this.core.rbac.ListOAuthScopes({});
}
@Get('oauth-clients')
@ApiOperation({ summary: 'OAuth приложения', description: 'Возвращает OAuth-клиенты и доступные scopes.' })
listOAuthClients(@CurrentAdmin() admin: AdminRequestUser) {
assertAdminPermission(admin, 'canManageOAuth');
return this.core.rbac.ListOAuthClients({});
}
@Post('roles')
@UseGuards(SuperAdminGuard)
@ApiOperation({ summary: 'Создать роль', description: 'Создаёт новую роль с набором прав. Только супер-администратор.' })
@ApiBody({ type: CreateRoleDto })
createRole(@Body() dto: CreateRoleDto, @CurrentAdmin() admin: AdminRequestUser) {
return this.core.rbac.CreateRole({ actorUserId: admin.id, ...dto });
}
@Post('users/:userId/roles')
@UseGuards(SuperAdminGuard)
@ApiOperation({ summary: 'Назначить роль пользователю', description: 'Только супер-администратор может назначать роли.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiBody({ type: AssignUserRoleDto })
assignRole(@Param('userId') userId: string, @Body() dto: AssignUserRoleDto, @CurrentAdmin() admin: AdminRequestUser) {
return this.core.rbac.AssignUserRole({ actorUserId: admin.id, userId, roleSlug: dto.roleSlug });
}
@Delete('users/:userId/roles/:roleSlug')
@UseGuards(SuperAdminGuard)
@ApiOperation({ summary: 'Снять роль с пользователя', description: 'Только супер-администратор может снимать роли.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiParam({ name: 'roleSlug', description: 'Slug роли' })
removeRole(@Param('userId') userId: string, @Param('roleSlug') roleSlug: string, @CurrentAdmin() admin: AdminRequestUser) {
return this.core.rbac.RemoveUserRole({ actorUserId: admin.id, userId, roleSlug });
}
@Post('oauth-clients')
@ApiOperation({ summary: 'Создать OAuth-приложение', description: 'Создаёт OAuth2-клиент и возвращает client secret один раз.' })
@ApiBody({ type: CreateOAuthClientDto })
createOAuthClient(@Body() dto: CreateOAuthClientDto, @CurrentAdmin() admin: AdminRequestUser) {
assertAdminPermission(admin, 'canManageOAuth');
return this.core.rbac.CreateOAuthClient({ actorUserId: admin.id, ...dto });
}
@Patch('oauth-clients/:clientId')
@ApiOperation({ summary: 'Обновить OAuth-приложение', description: 'Изменяет redirect URI, scopes и статус приложения.' })
@ApiParam({ name: 'clientId', description: 'Client ID приложения' })
@ApiBody({ type: UpdateOAuthClientDto })
updateOAuthClient(@Param('clientId') clientId: string, @Body() dto: UpdateOAuthClientDto, @CurrentAdmin() admin: AdminRequestUser) {
assertAdminPermission(admin, 'canManageOAuth');
return this.core.rbac.UpdateOAuthClient({ actorUserId: admin.id, clientId, ...dto });
}
@Post('oauth-clients/:clientId/rotate-secret')
@ApiOperation({ summary: 'Перевыпустить client secret', description: 'Генерирует новый secret для confidential-клиента.' })
@ApiParam({ name: 'clientId', description: 'Client ID приложения' })
rotateSecret(@Param('clientId') clientId: string, @CurrentAdmin() admin: AdminRequestUser) {
assertAdminPermission(admin, 'canManageOAuth');
return this.core.rbac.RotateOAuthSecret({ actorUserId: admin.id, clientId });
}
}

View File

@@ -0,0 +1,111 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CoreGrpcService } from '../core-grpc.service';
import { OptionalPinDto, PinDto, VerifySecurityPinDto } from '../dto/security.dto';
@ApiTags('Безопасность')
@ApiBearerAuth()
@Controller('security')
export class SecurityController {
constructor(private readonly core: CoreGrpcService) {}
@Get('users/:userId/devices')
@ApiOperation({ summary: 'Активные устройства', description: 'Показывает устройства пользователя и связанные активные сессии.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiResponse({ status: 200, description: 'Список устройств получен' })
listDevices(@Param('userId') userId: string) {
return this.core.security.ListActiveDevices({ userId });
}
@Get('users/:userId/sessions')
@ApiOperation({ summary: 'Активные сессии', description: 'Возвращает ACTIVE и LOCKED сессии пользователя для управления устройствами.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiResponse({ status: 200, description: 'Список активных сессий получен' })
listSessions(@Param('userId') userId: string) {
return this.core.security.ListActiveSessions({ userId });
}
@Get('users/:userId/sign-in-history')
@ApiOperation({ summary: 'История входов', description: 'Показывает последние попытки входа и причины отказов.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiResponse({ status: 200, description: 'История входов получена' })
listHistory(@Param('userId') userId: string) {
return this.core.security.ListSignInHistory({ userId });
}
@Post('users/:userId/pin/setup')
@ApiOperation({ summary: 'Настроить PIN-код', description: 'Создает или включает PIN-код пользователя. PIN хранится только в виде bcrypt hash.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiBody({ type: PinDto })
@ApiResponse({ status: 201, description: 'PIN-код настроен' })
setupPin(@Param('userId') userId: string, @Body() dto: PinDto) {
return this.core.security.SetupPin({ userId, pin: dto.pin });
}
@Post('users/:userId/pin/update')
@ApiOperation({ summary: 'Обновить PIN-код', description: 'Меняет PIN-код и переводит активные сессии пользователя в LOCKED до повторной проверки.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiBody({ type: PinDto })
@ApiResponse({ status: 201, description: 'PIN-код обновлен' })
updatePin(@Param('userId') userId: string, @Body() dto: PinDto) {
return this.core.security.UpdatePin({ userId, pin: dto.pin });
}
@Post('users/:userId/pin/delete-request')
@ApiOperation({ summary: 'Запросить удаление PIN-кода', description: 'Запускает период ожидания перед отключением PIN-кода согласно SystemSetting.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiBody({ type: OptionalPinDto })
requestPinDeletion(@Param('userId') userId: string, @Body() dto: OptionalPinDto) {
return this.core.security.RequestPinDeletion({ userId, pin: dto.pin ?? '' });
}
@Post('users/:userId/pin/delete-cancel')
@ApiOperation({ summary: 'Отменить удаление PIN-кода', description: 'Отменяет запланированное удаление PIN-кода.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
cancelPinDeletion(@Param('userId') userId: string) {
return this.core.security.CancelPinDeletion({ userId });
}
@Post('pin/verify')
@ApiOperation({ summary: 'Проверить PIN-код', description: 'Проверяет PIN-код и возвращает access token с pinVerified=true для указанной сессии.' })
@ApiBody({ type: VerifySecurityPinDto })
@ApiResponse({ status: 201, description: 'PIN-код проверен' })
verifyPin(@Body() dto: VerifySecurityPinDto) {
return this.core.security.VerifyPinCode(dto);
}
@Post('users/:userId/sessions/:sessionId/lock')
@ApiOperation({ summary: 'Заблокировать сессию', description: 'Переводит активную сессию в LOCKED и сбрасывает pinVerified=false.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiParam({ name: 'sessionId', description: 'ID сессии' })
@ApiResponse({ status: 201, description: 'Сессия заблокирована' })
lockSession(@Param('userId') userId: string, @Param('sessionId') sessionId: string) {
return this.core.security.LockSession({ userId, sessionId });
}
@Post('users/:userId/sessions/:sessionId/revoke')
@ApiOperation({ summary: 'Выйти с устройства', description: 'Отзывает конкретную сессию пользователя и завершает доступ с выбранного устройства.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiParam({ name: 'sessionId', description: 'ID сессии' })
@ApiResponse({ status: 201, description: 'Сессия отозвана' })
revokeSession(@Param('userId') userId: string, @Param('sessionId') sessionId: string) {
return this.core.security.RevokeSession({ userId, sessionId });
}
@Post('users/:userId/devices/:deviceId/revoke')
@ApiOperation({ summary: 'Завершить сессии устройства', description: 'Отзывает все активные сессии выбранного устройства пользователя.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiParam({ name: 'deviceId', description: 'ID устройства' })
@ApiResponse({ status: 201, description: 'Сессии устройства завершены' })
revokeDevice(@Param('userId') userId: string, @Param('deviceId') deviceId: string) {
return this.core.security.RevokeDevice({ userId, deviceId });
}
@Post('users/:userId/revoke-all-sessions')
@ApiOperation({ summary: 'Выйти везде', description: 'Отзывает все активные и PIN-заблокированные сессии пользователя.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiResponse({ status: 201, description: 'Все сессии отозваны' })
revokeAll(@Param('userId') userId: string) {
return this.core.security.RevokeAllSessions({ userId });
}
}

View File

@@ -0,0 +1,125 @@
import { Body, Controller, Delete, Get, Param, Put, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { map } from 'rxjs';
import { CoreGrpcService } from '../core-grpc.service';
import { CurrentAdmin } from '../decorators/current-admin.decorator';
import { ConnectLinkedAccountDto, UpsertSettingDto, UpsertSocialProviderDto } from '../dto/settings.dto';
import { AdminGuard, AdminRequestUser, assertAdminPermission } from '../guards/admin.guard';
const settingsWritePipe = new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: false
});
@ApiTags('Глобальные настройки')
@ApiBearerAuth()
@UseGuards(AdminGuard)
@Controller('admin/settings')
export class SettingsController {
constructor(private readonly core: CoreGrpcService) {}
@Get()
@ApiOperation({ summary: 'Список системных настроек', description: 'Возвращает динамические настройки, включая PIN timeout и OAuth providers.' })
@ApiResponse({ status: 200, description: 'Список настроек получен' })
list(@CurrentAdmin() admin: AdminRequestUser) {
assertAdminPermission(admin, 'canManageSettings');
return this.core.settings.ListSettings({}).pipe(
map((response) => {
const payload = response as { settings?: unknown[] };
return { settings: payload.settings ?? [] };
})
);
}
@Get('key/:key')
@ApiOperation({ summary: 'Получить настройку', description: 'Возвращает SystemSetting по ключу.' })
@ApiParam({ name: 'key', description: 'Ключ настройки', example: 'PIN_LOCK_TIMEOUT_MINUTES' })
@ApiResponse({ status: 200, description: 'Настройка получена' })
get(@Param('key') key: string, @CurrentAdmin() admin: AdminRequestUser) {
assertAdminPermission(admin, 'canManageSettings');
return this.core.settings.GetSetting({ key });
}
@Put()
@UsePipes(settingsWritePipe)
@ApiOperation({ summary: 'Создать или обновить настройку', description: 'Меняет значение SystemSetting без изменения кода frontend.' })
@ApiBody({ type: UpsertSettingDto })
@ApiResponse({ status: 200, description: 'Настройка создана или обновлена' })
upsert(@Body() dto: UpsertSettingDto, @CurrentAdmin() admin: AdminRequestUser) {
assertAdminPermission(admin, 'canManageSettings');
return this.core.settings.UpsertSetting({
key: dto.key,
value: dto.value,
description: dto.description,
isSecret: dto.isSecret
});
}
@Delete('key/:key')
@ApiOperation({ summary: 'Удалить настройку', description: 'Удаляет SystemSetting по ключу.' })
@ApiParam({ name: 'key', description: 'Ключ настройки' })
@ApiResponse({ status: 200, description: 'Настройка удалена' })
delete(@Param('key') key: string, @CurrentAdmin() admin: AdminRequestUser) {
assertAdminPermission(admin, 'canManageSettings');
return this.core.settings.DeleteSetting({ key });
}
@Get('oauth/providers')
@ApiOperation({ summary: 'Список OAuth провайдеров', description: 'Возвращает глобальные настройки Google/Yandex и других social login providers.' })
@ApiResponse({ status: 200, description: 'Список провайдеров получен' })
listProviders(@CurrentAdmin() admin: AdminRequestUser) {
assertAdminPermission(admin, 'canManageSettings');
return this.core.settings.ListSocialProviders({});
}
@Put('oauth/providers')
@UsePipes(settingsWritePipe)
@ApiOperation({ summary: 'Создать или обновить OAuth провайдера', description: 'Управляет clientId/clientSecret и глобальным включением Google/Yandex входа.' })
@ApiBody({ type: UpsertSocialProviderDto })
@ApiResponse({ status: 200, description: 'Провайдер создан или обновлен' })
upsertProvider(@Body() dto: UpsertSocialProviderDto, @CurrentAdmin() admin: AdminRequestUser) {
assertAdminPermission(admin, 'canManageSettings');
return this.core.settings.UpsertSocialProvider({
providerName: dto.providerName,
clientId: dto.clientId,
clientSecret: dto.clientSecret,
isEnabled: dto.isEnabled
});
}
@Delete('oauth/providers/:providerName')
@ApiOperation({ summary: 'Удалить OAuth провайдера', description: 'Удаляет глобальную настройку social login provider.' })
@ApiParam({ name: 'providerName', description: 'Название провайдера', example: 'google' })
@ApiResponse({ status: 200, description: 'Провайдер удален' })
deleteProvider(@Param('providerName') providerName: string, @CurrentAdmin() admin: AdminRequestUser) {
assertAdminPermission(admin, 'canManageSettings');
return this.core.settings.DeleteSocialProvider({ providerName });
}
@Get('linked-accounts/users/:userId')
@ApiOperation({ summary: 'Связанные внешние аккаунты', description: 'Возвращает LinkedAccount записи пользователя для Google/Yandex и других провайдеров.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiResponse({ status: 200, description: 'Список внешних аккаунтов получен' })
listLinkedAccounts(@Param('userId') userId: string) {
return this.core.security.ListLinkedAccounts({ userId });
}
@Put('linked-accounts/users/:userId')
@ApiOperation({ summary: 'Подключить внешний аккаунт', description: 'Создает или обновляет LinkedAccount для social OAuth логина пользователя.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiBody({ type: ConnectLinkedAccountDto })
@ApiResponse({ status: 200, description: 'Внешний аккаунт подключен' })
connectLinkedAccount(@Param('userId') userId: string, @Body() dto: ConnectLinkedAccountDto) {
return this.core.security.ConnectLinkedAccount({ userId, ...dto });
}
@Delete('linked-accounts/users/:userId/:providerName')
@ApiOperation({ summary: 'Отключить внешний аккаунт', description: 'Удаляет связь пользователя с Google/Yandex или другим social provider.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiParam({ name: 'providerName', description: 'Название провайдера', example: 'google' })
@ApiResponse({ status: 200, description: 'Внешний аккаунт отключен' })
disconnectLinkedAccount(@Param('userId') userId: string, @Param('providerName') providerName: string) {
return this.core.security.DisconnectLinkedAccount({ userId, providerName });
}
}