import { Injectable, UnauthorizedException, ConflictException, ForbiddenException, NotFoundException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { DataSource } from 'typeorm'; import * as bcrypt from 'bcryptjs'; import { UsersService } from '../users/users.service'; import { RegisterDto } from './dto/register.dto'; import { User } from '../users/entities/user.entity'; @Injectable() export class AuthService { constructor( private usersService: UsersService, private jwtService: JwtService, private dataSource: DataSource, ) {} async register(dto: RegisterDto) { const existing = await this.usersService.findByEmail(dto.email); if (existing) { throw new ConflictException('Email already registered'); } const passwordHash = await bcrypt.hash(dto.password, 12); const user = await this.usersService.create({ email: dto.email, passwordHash, firstName: dto.firstName, lastName: dto.lastName, }); return this.generateTokenResponse(user); } async validateUser(email: string, password: string): Promise { const user = await this.usersService.findByEmail(email); if (!user || !user.passwordHash) { throw new UnauthorizedException('Invalid credentials'); } const isValid = await bcrypt.compare(password, user.passwordHash); if (!isValid) { throw new UnauthorizedException('Invalid credentials'); } return user; } async login(user: User, ipAddress?: string, userAgent?: string) { await this.usersService.updateLastLogin(user.id); const fullUser = await this.usersService.findByIdWithOrgs(user.id); const u = fullUser || user; // Check if user's organizations are all suspended/archived const orgs = u.userOrganizations || []; if (orgs.length > 0 && !u.isSuperadmin) { const activeOrgs = orgs.filter( (uo) => uo.organization && !['suspended', 'archived'].includes(uo.organization.status), ); if (activeOrgs.length === 0) { throw new UnauthorizedException( 'Your organization has been suspended. Please contact your administrator.', ); } } // Record login in history (org_id is null at initial login) this.recordLoginHistory(user.id, null, ipAddress, userAgent).catch(() => {}); return this.generateTokenResponse(u); } async getProfile(userId: string) { const user = await this.usersService.findByIdWithOrgs(userId); if (!user) { throw new UnauthorizedException('User not found'); } return { id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName, organizations: user.userOrganizations?.map((uo) => ({ id: uo.organization.id, name: uo.organization.name, role: uo.role, })) || [], }; } async switchOrganization(userId: string, organizationId: string, ipAddress?: string, userAgent?: string) { const user = await this.usersService.findByIdWithOrgs(userId); if (!user) { throw new UnauthorizedException('User not found'); } const membership = user.userOrganizations?.find( (uo) => uo.organizationId === organizationId && uo.isActive, ); if (!membership) { throw new UnauthorizedException('Not a member of this organization'); } // Block access to suspended/archived organizations const orgStatus = membership.organization?.status; if (orgStatus && ['suspended', 'archived'].includes(orgStatus)) { throw new ForbiddenException( `This organization has been ${orgStatus}. Please contact your administrator.`, ); } const payload = { sub: user.id, email: user.email, orgId: membership.organizationId, role: membership.role, }; // Record org switch in login history this.recordLoginHistory(userId, organizationId, ipAddress, userAgent).catch(() => {}); return { accessToken: this.jwtService.sign(payload), organization: { id: membership.organization.id, name: membership.organization.name, role: membership.role, settings: membership.organization.settings || {}, }, }; } async markIntroSeen(userId: string): Promise { await this.usersService.markIntroSeen(userId); } private async recordLoginHistory( userId: string, organizationId: string | null, ipAddress?: string, userAgent?: string, ) { try { await this.dataSource.query( `INSERT INTO shared.login_history (user_id, organization_id, ip_address, user_agent) VALUES ($1, $2, $3, $4)`, [userId, organizationId, ipAddress || null, userAgent || null], ); } catch (err) { // Non-critical — don't let login history failure block auth } } private generateTokenResponse(user: User, impersonatedBy?: string) { const allOrgs = user.userOrganizations || []; // Filter out suspended/archived organizations const orgs = allOrgs.filter( (uo) => !uo.organization?.status || !['suspended', 'archived'].includes(uo.organization.status), ); const defaultOrg = orgs[0]; const payload: Record = { sub: user.id, email: user.email, isSuperadmin: user.isSuperadmin || false, }; if (impersonatedBy) { payload.impersonatedBy = impersonatedBy; } if (defaultOrg) { payload.orgId = defaultOrg.organizationId; payload.role = defaultOrg.role; } return { accessToken: this.jwtService.sign(payload), user: { id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName, isSuperadmin: user.isSuperadmin || false, isPlatformOwner: user.isPlatformOwner || false, hasSeenIntro: user.hasSeenIntro || false, }, organizations: orgs.map((uo) => ({ id: uo.organizationId, name: uo.organization?.name, status: uo.organization?.status, role: uo.role, })), }; } async impersonateUser(adminUserId: string, targetUserId: string) { const targetUser = await this.usersService.findByIdWithOrgs(targetUserId); if (!targetUser) { throw new NotFoundException('User not found'); } if (targetUser.isSuperadmin) { throw new ForbiddenException('Cannot impersonate another superadmin'); } return this.generateTokenResponse(targetUser, adminUserId); } }