import { BadRequestException, ConflictException, Injectable, NotFoundException, OnModuleInit, ForbiddenException, Logger, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { User } from './user.entity'; import { UserRole } from './user-role.enum'; import { TenantMember } from '../tenant/tenant-member.entity'; import { ApiKey } from '../auth/entities/api-key.entity'; import * as bcrypt from 'bcrypt'; import { CreateUserDto } from './dto/create-user.dto'; import * as crypto from 'crypto'; import { I18nService } from '../i18n/i18n.service'; import { TenantService } from '../tenant/tenant.service'; @Injectable() export class UserService implements OnModuleInit { private readonly logger = new Logger(UserService.name); constructor( @InjectRepository(User) private usersRepository: Repository, @InjectRepository(ApiKey) private apiKeyRepository: Repository, @InjectRepository(TenantMember) private tenantMemberRepository: Repository, private i18nService: I18nService, private tenantService: TenantService, ) {} async findOneByUsername(username: string): Promise { return this.usersRepository.findOne({ where: { username } }); } async create(createUserDto: CreateUserDto): Promise { const user = this.usersRepository.create(createUserDto as any); return this.usersRepository.save(user as any); } async onModuleInit() { await this.createAdminIfNotExists(); } async findAll( page?: number, limit?: number, ): Promise<{ data: User[]; total: number }> { const queryBuilder = this.usersRepository .createQueryBuilder('user') .leftJoinAndSelect('user.tenantMembers', 'tenantMember') .leftJoinAndSelect('tenantMember.tenant', 'tenant') .select([ 'user.id', 'user.username', 'user.displayName', 'user.isAdmin', 'user.createdAt', 'user.tenantId', 'tenantMember', 'tenant', ]) .orderBy('user.createdAt', 'DESC'); if (page && limit) { const [data, total] = await queryBuilder .skip((page - 1) * limit) .take(limit) .getManyAndCount(); return { data, total }; } const [data, total] = await queryBuilder.getManyAndCount(); return { data, total }; } async findByTenantId( tenantId: string, page?: number, limit?: number, ): Promise<{ data: User[]; total: number }> { const queryBuilder = this.usersRepository .createQueryBuilder('user') .innerJoin( 'user.tenantMembers', 'member', 'member.tenantId = :tenantId', { tenantId }, ) .select([ 'user.id', 'user.username', 'user.displayName', 'user.isAdmin', 'user.createdAt', 'user.tenantId', ]) .orderBy('user.createdAt', 'DESC'); if (page && limit) { const [data, total] = await queryBuilder .skip((page - 1) * limit) .take(limit) .getManyAndCount(); return { data, total }; } const [data, total] = await queryBuilder.getManyAndCount(); return { data, total }; } async isAdmin(userId: string): Promise { const user = await this.usersRepository.findOne({ where: { id: userId }, select: ['isAdmin'], }); return user?.isAdmin || false; } async changePassword( userId: string, currentPassword: string, newPassword: string, ): Promise<{ message: string }> { const user = await this.usersRepository.findOne({ where: { id: userId } }); if (!user) { throw new NotFoundException(this.i18nService.getMessage('userNotFound')); } const isCurrentPasswordValid = await bcrypt.compare( currentPassword, user.password, ); if (!isCurrentPasswordValid) { throw new BadRequestException( this.i18nService.getMessage('incorrectCurrentPassword'), ); } const hashedNewPassword = await bcrypt.hash(newPassword, 10); await this.usersRepository.update(userId, { password: hashedNewPassword }); return { message: this.i18nService.getMessage('passwordChanged') }; } async createUser( username: string, password: string, isAdmin: boolean = false, tenantId?: string, displayName?: string, ): Promise<{ message: string; user: { id: string; username: string; displayName: string; isAdmin: boolean; }; }> { const existingUser = await this.findOneByUsername(username); if (existingUser) { throw new ConflictException( this.i18nService.getMessage('usernameExists'), ); } const hashedPassword = await bcrypt.hash(password, 10); this.logger.log( `[UserService] Creating user: ${username}, isAdmin: ${isAdmin}`, ); const user = await this.usersRepository.save({ username, password: hashedPassword, displayName: displayName || username, isAdmin, tenantId: tenantId ?? undefined, } as any); return { message: this.i18nService.getMessage('userCreated'), user: { id: user.id, username: user.username, displayName: user.displayName, isAdmin: user.isAdmin, }, }; } async findOneById(userId: string): Promise { return this.usersRepository.findOne({ where: { id: userId }, relations: ['tenantMembers', 'tenantMembers.tenant'], }); } async findByApiKey(apiKeyValue: string): Promise { const apiKey = await this.apiKeyRepository.findOne({ where: { key: apiKeyValue }, relations: ['user'], }); return apiKey ? apiKey.user : null; } async getUserTenants( userId: string, ): Promise<(TenantMember & { features?: { isNotebookEnabled: boolean } })[]> { const user = await this.usersRepository.findOne({ where: { id: userId }, select: ['isAdmin'], }); if (user?.isAdmin) { const tenantsData = await this.tenantService.findAll(); const allTenants = Array.isArray(tenantsData) ? tenantsData : tenantsData.data; const results = await Promise.all( allTenants.map(async (t) => { const settings = await this.tenantService.getSettings(t.id); return { tenantId: t.id, tenant: t, role: UserRole.SUPER_ADMIN, userId: userId, features: { isNotebookEnabled: settings?.isNotebookEnabled ?? true, }, } as TenantMember & { features: { isNotebookEnabled: boolean } }; }), ); return results; } const members = await this.tenantMemberRepository.find({ where: { userId }, relations: ['tenant'], }); // Filter out the "Default" tenant for non-super admins const filtered = members.filter( (m) => m.tenant?.name !== TenantService.DEFAULT_TENANT_NAME, ); // Attach per-tenant feature flags return Promise.all( filtered.map(async (m) => { const settings = await this.tenantService.getSettings(m.tenantId); return { ...m, features: { isNotebookEnabled: settings?.isNotebookEnabled ?? true, }, }; }), ); } /** * Generates a new API key for the user, or returns the existing one (first one). */ async getOrCreateApiKey(userId: string): Promise { const user = await this.usersRepository.findOne({ where: { id: userId }, relations: ['apiKeys'], }); if (!user) throw new NotFoundException(this.i18nService.getMessage('userNotFound')); if (user.apiKeys && user.apiKeys.length > 0) { return user.apiKeys[0].key; } const keyString = 'kb_' + crypto.randomBytes(32).toString('hex'); const newApiKey = this.apiKeyRepository.create({ userId: user.id, key: keyString, }); await this.apiKeyRepository.save(newApiKey); return keyString; } /** * Regenerates (rotates) the API key for the user. * This clears existing keys and creates a new one. */ async regenerateApiKey(userId: string): Promise { const user = await this.usersRepository.findOne({ where: { id: userId } }); if (!user) throw new NotFoundException(this.i18nService.getMessage('userNotFound')); // Delete existing keys await this.apiKeyRepository.delete({ userId: user.id }); // Create new key const keyString = 'kb_' + crypto.randomBytes(32).toString('hex'); const newApiKey = this.apiKeyRepository.create({ userId: user.id, key: keyString, }); await this.apiKeyRepository.save(newApiKey); return keyString; } async updateUser( userId: string, updateData: { username?: string; isAdmin?: boolean; password?: string; tenantId?: string; displayName?: string; }, ): Promise<{ message: string; user: { id: string; username: string; displayName: string; isAdmin: boolean; }; }> { const user = await this.usersRepository.findOne({ where: { id: userId } }); if (!user) { throw new NotFoundException(this.i18nService.getMessage('userNotFound')); } // Hash password first if update needed if (updateData.password) { const hashedPassword = await bcrypt.hash(updateData.password, 10); updateData.password = hashedPassword; } // Block any changes to user "admin" if (user.username === 'admin') { throw new ForbiddenException( this.i18nService.getMessage('cannotModifyBuiltinAdmin'), ); } await this.usersRepository.update(userId, updateData as any); const updatedUser = await this.usersRepository.findOne({ where: { id: userId }, select: ['id', 'username', 'displayName', 'isAdmin'], }); return { message: this.i18nService.getMessage('userInfoUpdated'), user: { id: updatedUser!.id, username: updatedUser!.username, displayName: updatedUser!.displayName, isAdmin: updatedUser!.isAdmin, }, }; } async deleteUser(userId: string): Promise<{ message: string }> { const user = await this.usersRepository.findOne({ where: { id: userId } }); if (!user) { throw new NotFoundException(this.i18nService.getMessage('userNotFound')); } // Block deletion of user "admin" if (user.username === 'admin') { throw new ForbiddenException( this.i18nService.getMessage('cannotDeleteBuiltinAdmin'), ); } await this.usersRepository.delete(userId); return { message: this.i18nService.getMessage('userDeleted'), }; } async getTenantSettings(tenantId: string) { return this.tenantService.getSettings(tenantId); } private async createAdminIfNotExists() { const adminUser = await this.findOneByUsername('admin'); if (!adminUser) { const randomPassword = Math.random().toString(36).slice(-8); const hashedPassword = await bcrypt.hash(randomPassword, 10); await this.usersRepository.save({ username: 'admin', password: hashedPassword, displayName: 'Admin', isAdmin: true, role: UserRole.SUPER_ADMIN, }); this.logger.log('Admin account created (username: admin, password: ' + randomPassword + ')'); } } }