From 04a1e8c851f4bc2cb56d1eef6b523f84e20d7ece Mon Sep 17 00:00:00 2001 From: bigben-7 Date: Sun, 29 Mar 2026 07:01:04 +0100 Subject: [PATCH] feat: health check, 2FA, bull queue, and WebSocket gateway - Issue #526: Add GET /api/health using @nestjs/terminus with TypeOrmHealthIndicator (public, returns 503 when DB unreachable) - Issue #528: Implement TOTP-based 2FA (setup/enable/disable/verify endpoints, twoFactorSecret + twoFactorEnabled on User entity, login returns tempToken when 2FA is active) - Issue #531: Set up BullModule with Redis, mail queue, MailService (nodemailer), and MailProcessor with 3-retry exponential backoff - Issue #532: WebSocket gateway at /notifications namespace emitting asset:created, asset:updated, asset:status_changed, asset:transferred, and maintenance:scheduled events; JWT auth on connection --- backend/package.json | 2 + backend/src/app.module.ts | 8 ++ backend/src/assets/assets.module.ts | 2 + backend/src/assets/assets.service.ts | 7 ++ backend/src/auth/auth.controller.ts | 44 ++++++++ backend/src/auth/auth.service.ts | 101 +++++++++++++++++- backend/src/auth/dto/two-factor.dto.ts | 20 ++++ backend/src/health/health.controller.ts | 25 +++++ backend/src/health/health.module.ts | 9 ++ backend/src/mail/mail.module.ts | 8 ++ backend/src/mail/mail.service.ts | 36 +++++++ .../notifications/notifications.gateway.ts | 59 ++++++++++ .../src/notifications/notifications.module.ts | 11 ++ .../notifications/notifications.service.ts | 21 ++++ .../src/queue/processors/mail.processor.ts | 18 ++++ backend/src/queue/queue.module.ts | 34 ++++++ backend/src/users/user.entity.ts | 6 ++ backend/src/users/users.service.ts | 19 ++++ 18 files changed, 429 insertions(+), 1 deletion(-) create mode 100644 backend/src/auth/dto/two-factor.dto.ts create mode 100644 backend/src/health/health.controller.ts create mode 100644 backend/src/health/health.module.ts create mode 100644 backend/src/mail/mail.module.ts create mode 100644 backend/src/mail/mail.service.ts create mode 100644 backend/src/notifications/notifications.gateway.ts create mode 100644 backend/src/notifications/notifications.module.ts create mode 100644 backend/src/notifications/notifications.service.ts create mode 100644 backend/src/queue/processors/mail.processor.ts create mode 100644 backend/src/queue/queue.module.ts diff --git a/backend/package.json b/backend/package.json index cc9473e..28913da 100644 --- a/backend/package.json +++ b/backend/package.json @@ -22,6 +22,8 @@ "dependencies": { "@aws-sdk/client-s3": "^3.975.0", "@nestjs/bull": "^11.0.4", + "@nestjs/platform-socket.io": "^10.4.15", + "@nestjs/terminus": "^10.2.3", "@nestjs/cache-manager": "^3.1.0", "@nestjs/common": "^10.0.0", "@nestjs/config": "^4.0.2", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index dc0bcd4..de1e29a 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -8,6 +8,10 @@ import { RolesGuard } from './auth/guards/roles.guard'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { LocationsModule } from './locations/locations.module'; +import { HealthModule } from './health/health.module'; +import { AuthModule } from './auth/auth.module'; +import { QueueModule } from './queue/queue.module'; +import { NotificationsModule } from './notifications/notifications.module'; @Module({ imports: [ @@ -36,6 +40,10 @@ import { LocationsModule } from './locations/locations.module'; inject: [ConfigService], }), LocationsModule, + HealthModule, + AuthModule, + QueueModule, + NotificationsModule, ], controllers: [AppController], providers: [AppService, RolesGuard], diff --git a/backend/src/assets/assets.module.ts b/backend/src/assets/assets.module.ts index 901cdf7..3b7ce60 100644 --- a/backend/src/assets/assets.module.ts +++ b/backend/src/assets/assets.module.ts @@ -11,6 +11,7 @@ import { DepartmentsModule } from '../departments/departments.module'; import { CategoriesModule } from '../categories/categories.module'; import { UsersModule } from '../users/users.module'; import { StellarModule } from '../stellar/stellar.module'; +import { NotificationsModule } from '../notifications/notifications.module'; @Module({ imports: [ @@ -19,6 +20,7 @@ import { StellarModule } from '../stellar/stellar.module'; CategoriesModule, UsersModule, StellarModule, + NotificationsModule, ], controllers: [AssetsController], providers: [AssetsService], diff --git a/backend/src/assets/assets.service.ts b/backend/src/assets/assets.service.ts index c8014aa..fa396aa 100644 --- a/backend/src/assets/assets.service.ts +++ b/backend/src/assets/assets.service.ts @@ -21,6 +21,7 @@ import { DepartmentsService } from '../departments/departments.service'; import { CategoriesService } from '../categories/categories.service'; import { UsersService } from '../users/users.service'; import { StellarService } from '../stellar/stellar.service'; +import { NotificationsService } from '../notifications/notifications.service'; import { User } from '../users/user.entity'; @Injectable() @@ -43,6 +44,7 @@ export class AssetsService { private readonly usersService: UsersService, private readonly configService: ConfigService, private readonly stellarService: StellarService, + private readonly notificationsService: NotificationsService, ) {} async findAll(filters: AssetFiltersDto): Promise<{ data: Asset[]; total: number; page: number; limit: number }> { @@ -125,6 +127,7 @@ export class AssetsService { const saved = await this.assetsRepo.save(asset); await this.logHistory(saved, AssetHistoryAction.CREATED, 'Asset registered', null, null, currentUser); + this.notificationsService.emit('asset:created', { assetId: saved.id, assetCode: saved.assetId }); // Derive on-chain ID deterministically and mark PENDING (only if Stellar enabled) if (this.stellarService.isEnabled) { @@ -172,6 +175,7 @@ export class AssetsService { await this.assetsRepo.save(asset); await this.logHistory(asset, AssetHistoryAction.UPDATED, 'Asset updated', before as unknown as Record, dto as unknown as Record, currentUser); + this.notificationsService.emit('asset:updated', { assetId: id }); return this.findOne(id); } @@ -192,6 +196,7 @@ export class AssetsService { { status: dto.status }, currentUser, ); + this.notificationsService.emit('asset:status_changed', { assetId: id, from: prevStatus, to: dto.status }); return this.findOne(id); } @@ -216,6 +221,7 @@ export class AssetsService { { departmentId: asset.department.name }, currentUser, ); + this.notificationsService.emit('asset:transferred', { assetId: id, from: prevDept, to: asset.department.name }); return this.findOne(id); } @@ -299,6 +305,7 @@ export class AssetsService { { type: dto.type, scheduledDate: dto.scheduledDate }, currentUser, ); + this.notificationsService.emit('maintenance:scheduled', { assetId, maintenanceId: saved.id, type: dto.type }); return saved; } diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index b47b601..91e3dd0 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -20,6 +20,7 @@ import { ConfigService } from '@nestjs/config'; import { AuthService } from './auth.service'; import { RegisterDto } from './dto/register.dto'; import { LoginDto } from './dto/login.dto'; +import { TwoFactorCodeDto, TwoFactorVerifyDto } from './dto/two-factor.dto'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { CurrentUser } from './decorators/current-user.decorator'; import { User } from '../users/user.entity'; @@ -114,7 +115,50 @@ export class AuthController { firstName: user.firstName, lastName: user.lastName, role: user.role, + twoFactorEnabled: user.twoFactorEnabled, createdAt: user.createdAt, }; } + + // ── 2FA endpoints ──────────────────────────────────────────────── + + @Post('2fa/setup') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Generate TOTP secret and QR code for 2FA setup' }) + @ApiResponse({ status: 201, description: 'Returns otpauthUrl and qrCodeDataUrl' }) + twoFactorSetup(@CurrentUser() user: User) { + return this.authService.twoFactorSetup(user.id); + } + + @Post('2fa/enable') + @HttpCode(HttpStatus.OK) + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Enable 2FA after verifying TOTP code' }) + @ApiResponse({ status: 200, description: '2FA enabled' }) + @ApiResponse({ status: 401, description: 'Invalid TOTP code' }) + twoFactorEnable(@CurrentUser() user: User, @Body() dto: TwoFactorCodeDto) { + return this.authService.twoFactorEnable(user.id, dto.code); + } + + @Post('2fa/disable') + @HttpCode(HttpStatus.OK) + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Disable 2FA after verifying TOTP code' }) + @ApiResponse({ status: 200, description: '2FA disabled' }) + @ApiResponse({ status: 401, description: 'Invalid TOTP code' }) + twoFactorDisable(@CurrentUser() user: User, @Body() dto: TwoFactorCodeDto) { + return this.authService.twoFactorDisable(user.id, dto.code); + } + + @Post('2fa/verify') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Complete login by verifying TOTP code against temp token' }) + @ApiResponse({ status: 200, description: 'Returns full access and refresh tokens' }) + @ApiResponse({ status: 401, description: 'Invalid code or token' }) + twoFactorVerify(@Body() dto: TwoFactorVerifyDto) { + return this.authService.twoFactorVerify(dto.tempToken, dto.code); + } } diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index a6f28b7..0e1df00 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -2,10 +2,13 @@ import { Injectable, ConflictException, UnauthorizedException, + BadRequestException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import * as bcrypt from 'bcrypt'; +import * as speakeasy from 'speakeasy'; +import * as qrcode from 'qrcode'; import { UsersService } from '../users/users.service'; import { RegisterDto } from './dto/register.dto'; import { LoginDto } from './dto/login.dto'; @@ -44,7 +47,12 @@ export class AuthService { return { user, tokens }; } - async login(dto: LoginDto): Promise<{ user: User; tokens: AuthTokens }> { + async login( + dto: LoginDto, + ): Promise< + | { user: User; tokens: AuthTokens } + | { requiresTwoFactor: true; tempToken: string } + > { const user = await this.usersService.findByEmail(dto.email.toLowerCase()); if (!user) { throw new UnauthorizedException('Invalid email or password'); @@ -55,6 +63,17 @@ export class AuthService { throw new UnauthorizedException('Invalid email or password'); } + if (user.twoFactorEnabled) { + const tempToken = await this.jwtService.signAsync( + { sub: user.id, twoFactorPending: true }, + { + secret: this.configService.get('JWT_SECRET', 'change-me-in-env'), + expiresIn: '5m', + }, + ); + return { requiresTwoFactor: true, tempToken }; + } + const tokens = await this.signTokens(user); await this.storeRefreshToken(user.id, tokens.refreshToken); @@ -102,4 +121,84 @@ export class AuthService { const hashed = await bcrypt.hash(token, 10); await this.usersService.updateRefreshToken(userId, hashed); } + + // ── 2FA ────────────────────────────────────────────────────────── + + async twoFactorSetup(userId: string): Promise<{ otpauthUrl: string; qrCodeDataUrl: string }> { + const user = await this.usersService.findById(userId); + const secret = speakeasy.generateSecret({ + name: `ManageAssets (${user.email})`, + }); + + await this.usersService.updateTwoFactor(userId, secret.base32, false); + + const qrCodeDataUrl = await qrcode.toDataURL(secret.otpauth_url!); + return { otpauthUrl: secret.otpauth_url!, qrCodeDataUrl }; + } + + async twoFactorEnable(userId: string, code: string): Promise { + const user = await this.usersService.findByIdWithTwoFactor(userId); + if (!user?.twoFactorSecret) { + throw new BadRequestException('2FA setup not initiated. Call /auth/2fa/setup first.'); + } + + const valid = speakeasy.totp.verify({ + secret: user.twoFactorSecret, + encoding: 'base32', + token: code, + window: 1, + }); + if (!valid) throw new UnauthorizedException('Invalid TOTP code'); + + await this.usersService.updateTwoFactor(userId, user.twoFactorSecret, true); + } + + async twoFactorDisable(userId: string, code: string): Promise { + const user = await this.usersService.findByIdWithTwoFactor(userId); + if (!user?.twoFactorSecret) { + throw new BadRequestException('2FA is not set up for this account.'); + } + + const valid = speakeasy.totp.verify({ + secret: user.twoFactorSecret, + encoding: 'base32', + token: code, + window: 1, + }); + if (!valid) throw new UnauthorizedException('Invalid TOTP code'); + + await this.usersService.updateTwoFactor(userId, null, false); + } + + async twoFactorVerify(tempToken: string, code: string): Promise { + let payload: { sub: string; twoFactorPending?: boolean }; + try { + payload = await this.jwtService.verifyAsync(tempToken, { + secret: this.configService.get('JWT_SECRET', 'change-me-in-env'), + }); + } catch { + throw new UnauthorizedException('Invalid or expired temp token'); + } + + if (!payload.twoFactorPending) { + throw new UnauthorizedException('Token is not a 2FA pending token'); + } + + const user = await this.usersService.findByIdWithTwoFactor(payload.sub); + if (!user?.twoFactorSecret) { + throw new UnauthorizedException('2FA not configured'); + } + + const valid = speakeasy.totp.verify({ + secret: user.twoFactorSecret, + encoding: 'base32', + token: code, + window: 1, + }); + if (!valid) throw new UnauthorizedException('Invalid TOTP code'); + + const tokens = await this.signTokens(user); + await this.storeRefreshToken(user.id, tokens.refreshToken); + return tokens; + } } diff --git a/backend/src/auth/dto/two-factor.dto.ts b/backend/src/auth/dto/two-factor.dto.ts new file mode 100644 index 0000000..523096c --- /dev/null +++ b/backend/src/auth/dto/two-factor.dto.ts @@ -0,0 +1,20 @@ +import { IsString, Length } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class TwoFactorCodeDto { + @ApiProperty({ example: '123456', description: '6-digit TOTP code' }) + @IsString() + @Length(6, 6) + code: string; +} + +export class TwoFactorVerifyDto { + @ApiProperty({ description: 'Temporary token from login response' }) + @IsString() + tempToken: string; + + @ApiProperty({ example: '123456', description: '6-digit TOTP code' }) + @IsString() + @Length(6, 6) + code: string; +} diff --git a/backend/src/health/health.controller.ts b/backend/src/health/health.controller.ts new file mode 100644 index 0000000..7890063 --- /dev/null +++ b/backend/src/health/health.controller.ts @@ -0,0 +1,25 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { + HealthCheck, + HealthCheckService, + TypeOrmHealthIndicator, +} from '@nestjs/terminus'; + +@ApiTags('Health') +@Controller('health') +export class HealthController { + constructor( + private readonly health: HealthCheckService, + private readonly db: TypeOrmHealthIndicator, + ) {} + + @Get() + @HealthCheck() + @ApiOperation({ summary: 'Check service health and database connectivity' }) + @ApiResponse({ status: 200, description: 'Service is healthy' }) + @ApiResponse({ status: 503, description: 'Service unavailable' }) + check() { + return this.health.check([() => this.db.pingCheck('database')]); + } +} diff --git a/backend/src/health/health.module.ts b/backend/src/health/health.module.ts new file mode 100644 index 0000000..0208ef7 --- /dev/null +++ b/backend/src/health/health.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { TerminusModule } from '@nestjs/terminus'; +import { HealthController } from './health.controller'; + +@Module({ + imports: [TerminusModule], + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/backend/src/mail/mail.module.ts b/backend/src/mail/mail.module.ts new file mode 100644 index 0000000..1ea1d72 --- /dev/null +++ b/backend/src/mail/mail.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { MailService } from './mail.service'; + +@Module({ + providers: [MailService], + exports: [MailService], +}) +export class MailModule {} diff --git a/backend/src/mail/mail.service.ts b/backend/src/mail/mail.service.ts new file mode 100644 index 0000000..810d5ee --- /dev/null +++ b/backend/src/mail/mail.service.ts @@ -0,0 +1,36 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as nodemailer from 'nodemailer'; + +export interface MailJobData { + to: string; + subject: string; + html: string; +} + +@Injectable() +export class MailService { + private readonly logger = new Logger(MailService.name); + private readonly transporter: nodemailer.Transporter; + + constructor(private readonly configService: ConfigService) { + this.transporter = nodemailer.createTransport({ + host: this.configService.get('MAIL_HOST', 'smtp.mailtrap.io'), + port: this.configService.get('MAIL_PORT', 587), + auth: { + user: this.configService.get('MAIL_USER', ''), + pass: this.configService.get('MAIL_PASS', ''), + }, + }); + } + + async sendMail(data: MailJobData): Promise { + await this.transporter.sendMail({ + from: this.configService.get('MAIL_FROM', 'noreply@manageassets.com'), + to: data.to, + subject: data.subject, + html: data.html, + }); + this.logger.log(`Email sent to ${data.to}: ${data.subject}`); + } +} diff --git a/backend/src/notifications/notifications.gateway.ts b/backend/src/notifications/notifications.gateway.ts new file mode 100644 index 0000000..b613ae4 --- /dev/null +++ b/backend/src/notifications/notifications.gateway.ts @@ -0,0 +1,59 @@ +import { + WebSocketGateway, + OnGatewayInit, + OnGatewayConnection, + OnGatewayDisconnect, + WebSocketServer, +} from '@nestjs/websockets'; +import { Logger, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { Server, Socket } from 'socket.io'; +import { NotificationsService } from './notifications.service'; + +@WebSocketGateway({ cors: true, namespace: '/notifications' }) +export class NotificationsGateway + implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect +{ + @WebSocketServer() + server: Server; + + private readonly logger = new Logger(NotificationsGateway.name); + + constructor( + private readonly notificationsService: NotificationsService, + private readonly jwtService: JwtService, + private readonly configService: ConfigService, + ) {} + + afterInit(server: Server): void { + this.notificationsService.setServer(server); + this.logger.log('WebSocket gateway initialized'); + } + + async handleConnection(client: Socket): Promise { + const token = + (client.handshake.auth?.token as string | undefined) ?? + (client.handshake.headers?.authorization as string | undefined)?.replace('Bearer ', ''); + + if (!token) { + this.logger.warn(`Client ${client.id} disconnected: no token`); + client.disconnect(); + return; + } + + try { + await this.jwtService.verifyAsync(token, { + secret: this.configService.get('JWT_SECRET', 'change-me-in-env'), + }); + this.logger.log(`Client connected: ${client.id}`); + } catch { + this.logger.warn(`Client ${client.id} disconnected: invalid token`); + client.disconnect(); + } + } + + handleDisconnect(client: Socket): void { + this.logger.log(`Client disconnected: ${client.id}`); + } +} diff --git a/backend/src/notifications/notifications.module.ts b/backend/src/notifications/notifications.module.ts new file mode 100644 index 0000000..e5c9965 --- /dev/null +++ b/backend/src/notifications/notifications.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { NotificationsGateway } from './notifications.gateway'; +import { NotificationsService } from './notifications.service'; + +@Module({ + imports: [JwtModule.register({})], + providers: [NotificationsGateway, NotificationsService], + exports: [NotificationsService], +}) +export class NotificationsModule {} diff --git a/backend/src/notifications/notifications.service.ts b/backend/src/notifications/notifications.service.ts new file mode 100644 index 0000000..f3b194c --- /dev/null +++ b/backend/src/notifications/notifications.service.ts @@ -0,0 +1,21 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Server } from 'socket.io'; + +@Injectable() +export class NotificationsService { + private readonly logger = new Logger(NotificationsService.name); + private server: Server; + + setServer(server: Server): void { + this.server = server; + } + + emit(event: string, payload: unknown): void { + if (!this.server) { + this.logger.warn(`Cannot emit '${event}': WebSocket server not initialized`); + return; + } + this.server.emit(event, payload); + this.logger.debug(`Emitted event '${event}'`); + } +} diff --git a/backend/src/queue/processors/mail.processor.ts b/backend/src/queue/processors/mail.processor.ts new file mode 100644 index 0000000..32e0993 --- /dev/null +++ b/backend/src/queue/processors/mail.processor.ts @@ -0,0 +1,18 @@ +import { Process, Processor } from '@nestjs/bull'; +import { Logger } from '@nestjs/common'; +import { Job } from 'bull'; +import { MailService, MailJobData } from '../../mail/mail.service'; + +@Processor('mail') +export class MailProcessor { + private readonly logger = new Logger(MailProcessor.name); + + constructor(private readonly mailService: MailService) {} + + @Process('send-email') + async handleSendEmail(job: Job): Promise { + this.logger.debug(`Processing mail job ${job.id}: to=${job.data.to}`); + await this.mailService.sendMail(job.data); + this.logger.debug(`Mail job ${job.id} completed`); + } +} diff --git a/backend/src/queue/queue.module.ts b/backend/src/queue/queue.module.ts new file mode 100644 index 0000000..b250eb7 --- /dev/null +++ b/backend/src/queue/queue.module.ts @@ -0,0 +1,34 @@ +import { Module } from '@nestjs/common'; +import { BullModule } from '@nestjs/bull'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { MailProcessor } from './processors/mail.processor'; +import { MailModule } from '../mail/mail.module'; + +@Module({ + imports: [ + BullModule.forRootAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => ({ + redis: { + host: configService.get('REDIS_HOST', 'localhost'), + port: configService.get('REDIS_PORT', 6379), + }, + defaultJobOptions: { + attempts: 3, + backoff: { + type: 'exponential', + delay: 1000, + }, + removeOnComplete: true, + removeOnFail: false, + }, + }), + inject: [ConfigService], + }), + BullModule.registerQueue({ name: 'mail' }), + MailModule, + ], + providers: [MailProcessor], + exports: [BullModule], +}) +export class QueueModule {} diff --git a/backend/src/users/user.entity.ts b/backend/src/users/user.entity.ts index afe3b18..a86bc0c 100644 --- a/backend/src/users/user.entity.ts +++ b/backend/src/users/user.entity.ts @@ -35,6 +35,12 @@ export class User { @Column({ nullable: true, select: false }) refreshToken: string | null; + @Column({ nullable: true, select: false }) + twoFactorSecret: string | null; + + @Column({ default: false }) + twoFactorEnabled: boolean; + @CreateDateColumn() createdAt: Date; diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index 998fe18..5e3177a 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -72,4 +72,23 @@ export class UsersService { .where('user.id = :id', { id }) .getOne(); } + + async findByIdWithTwoFactor(id: string): Promise { + return this.usersRepo + .createQueryBuilder('user') + .addSelect('user.twoFactorSecret') + .where('user.id = :id', { id }) + .getOne(); + } + + async updateTwoFactor( + id: string, + secret: string | null, + enabled: boolean, + ): Promise { + await this.usersRepo.update(id, { + twoFactorSecret: secret, + twoFactorEnabled: enabled, + }); + } }