import { Injectable, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import type { StringValue } from 'ms'; import * as bcrypt from 'bcrypt'; import { PrismaService } from '../prisma/prisma.service'; import { LoginDto } from './dto/login.dto'; import { RefreshTokenDto } from './dto/refresh-token.dto'; @Injectable() export class AuthService { constructor( private readonly prismaService: PrismaService, private readonly jwtService: JwtService, private readonly configService: ConfigService, ) {} private async issueTokens(user: { id: string; username: string; role: string; displayName: string | null; profileImageUrl: string | null; workspaceId: string | null; createdAt: Date; }) { const membership = await this.prismaService.projectMembership.findUnique({ where: { userId: user.id }, select: { projectId: true }, }); const currentProjectId = membership?.projectId ?? null; const accessToken = await this.jwtService.signAsync({ sub: user.id, username: user.username, role: user.role, workspaceId: user.workspaceId, projectId: currentProjectId, }); const refreshToken = await this.jwtService.signAsync( { sub: user.id, type: 'refresh' }, { secret: this.configService.get('REFRESH_TOKEN_SECRET') ?? 'change_me_refresh_secret', expiresIn: (this.configService.get( 'REFRESH_TOKEN_EXPIRES_IN', ) as StringValue) ?? '30d', }, ); const refreshTokenHash = await bcrypt.hash(refreshToken, 12); await this.prismaService.user.update({ where: { id: user.id }, data: { refreshTokenHash }, }); return { accessToken, refreshToken, user: { id: user.id, username: user.username, displayName: user.displayName, profileImageUrl: user.profileImageUrl, workspaceId: user.workspaceId, currentProjectId, role: user.role, createdAt: user.createdAt, }, }; } async login(loginDto: LoginDto) { const normalizedUsername = loginDto.username.trim(); const user = await this.prismaService.user.findUnique({ where: { username: normalizedUsername }, }); const caseInsensitiveUser = user ?? (await this.prismaService.user.findFirst({ where: { username: { equals: normalizedUsername, mode: 'insensitive', }, }, })); if (!caseInsensitiveUser) { throw new UnauthorizedException('Invalid credentials'); } const passwordMatches = await bcrypt.compare( loginDto.password, caseInsensitiveUser.passwordHash, ); if (!passwordMatches) { throw new UnauthorizedException('Invalid credentials'); } return this.issueTokens(caseInsensitiveUser); } async refresh(refreshTokenDto: RefreshTokenDto) { const refreshSecret = this.configService.get('REFRESH_TOKEN_SECRET') ?? 'change_me_refresh_secret'; try { const payload = await this.jwtService.verifyAsync(refreshTokenDto.refreshToken, { secret: refreshSecret, }); if (payload.type !== 'refresh') { throw new UnauthorizedException('Invalid refresh token'); } const user = await this.prismaService.user.findUnique({ where: { id: payload.sub as string }, }); if (!user || !user.refreshTokenHash) { throw new UnauthorizedException('Invalid refresh token'); } const isRefreshTokenValid = await bcrypt.compare( refreshTokenDto.refreshToken, user.refreshTokenHash, ); if (!isRefreshTokenValid) { throw new UnauthorizedException('Invalid refresh token'); } return this.issueTokens(user); } catch { throw new UnauthorizedException('Invalid or expired refresh token'); } } async logout(userId: string) { await this.prismaService.user.updateMany({ where: { id: userId }, data: { refreshTokenHash: null }, }); return { loggedOut: true }; } async me(userId: string) { const user = await this.prismaService.user.findUnique({ where: { id: userId } }); if (!user) { throw new UnauthorizedException('User not found'); } return { id: user.id, username: user.username, displayName: user.displayName, profileImageUrl: user.profileImageUrl, workspaceId: user.workspaceId, currentProjectId: ( await this.prismaService.projectMembership.findUnique({ where: { userId: user.id }, select: { projectId: true }, }) )?.projectId ?? null, role: user.role, createdAt: user.createdAt, }; } }