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); console.log(`[UserService] Creating user: ${username}, isAdmin: ${isAdmin}`); const user = await this.usersRepository.save({ username, password: hashedPassword, displayName, 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, isAdmin: true, role: UserRole.SUPER_ADMIN, }); console.log('\n=== Admin account created ==='); console.log('Username: admin'); console.log('Password:', randomPassword); console.log('========================================\n'); } } }