410 lines
11 KiB
TypeScript
410 lines
11 KiB
TypeScript
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<User>,
|
|
@InjectRepository(ApiKey)
|
|
private apiKeyRepository: Repository<ApiKey>,
|
|
@InjectRepository(TenantMember)
|
|
private tenantMemberRepository: Repository<TenantMember>,
|
|
private i18nService: I18nService,
|
|
private tenantService: TenantService,
|
|
) {}
|
|
|
|
async findOneByUsername(username: string): Promise<User | null> {
|
|
return this.usersRepository.findOne({ where: { username } });
|
|
}
|
|
|
|
async create(createUserDto: CreateUserDto): Promise<User> {
|
|
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<boolean> {
|
|
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<User | null> {
|
|
return this.usersRepository.findOne({
|
|
where: { id: userId },
|
|
relations: ['tenantMembers', 'tenantMembers.tenant'],
|
|
});
|
|
}
|
|
|
|
async findByApiKey(apiKeyValue: string): Promise<User | null> {
|
|
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<string> {
|
|
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<string> {
|
|
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 + ')');
|
|
}
|
|
}
|
|
}
|