feat: implement QuestionBank CRUD with pagination and template query
- Add pagination support to findAll (page, limit query params) - Add findByTemplateId method to service - Add GET /by-template/:templateId endpoint to controller - Service already includes CRUD for QuestionBank and QuestionBankItem
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
IsNotEmpty,
|
||||
IsString,
|
||||
MinLength,
|
||||
IsOptional,
|
||||
IsEnum,
|
||||
} from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { UserRole } from '../user-role.enum';
|
||||
|
||||
export class CreateUserDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
username: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
displayName?: string;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { IsBoolean, IsOptional, IsString, IsEnum } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { UserRole } from '../user-role.enum';
|
||||
|
||||
export class UpdateUserDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
displayName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isAdmin?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
tenantId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
password?: string;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// server/src/user/dto/user-safe.dto.ts
|
||||
|
||||
import { UserRole } from '../user-role.enum';
|
||||
|
||||
export type SafeUser = {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName?: string;
|
||||
isAdmin: boolean;
|
||||
role: UserRole; // Computed property
|
||||
tenantId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum UserRole {
|
||||
SUPER_ADMIN = 'SUPER_ADMIN',
|
||||
TENANT_ADMIN = 'TENANT_ADMIN',
|
||||
USER = 'USER',
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from './user.entity';
|
||||
|
||||
@Entity('user_settings')
|
||||
export class UserSetting {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
userId: string;
|
||||
|
||||
@OneToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User;
|
||||
|
||||
@Column({ type: 'text', default: 'zh' })
|
||||
language: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { UserSetting } from './user-setting.entity';
|
||||
|
||||
@Injectable()
|
||||
export class UserSettingService {
|
||||
constructor(
|
||||
@InjectRepository(UserSetting)
|
||||
private userSettingRepository: Repository<UserSetting>,
|
||||
) {}
|
||||
|
||||
async getByUser(userId: string): Promise<UserSetting> {
|
||||
let setting = await this.userSettingRepository.findOne({
|
||||
where: { userId },
|
||||
});
|
||||
if (!setting) {
|
||||
setting = this.userSettingRepository.create({ userId, language: 'zh' });
|
||||
await this.userSettingRepository.save(setting);
|
||||
}
|
||||
return setting;
|
||||
}
|
||||
|
||||
async update(userId: string, language: string): Promise<UserSetting> {
|
||||
const setting = await this.getByUser(userId);
|
||||
setting.language = language;
|
||||
return this.userSettingRepository.save(setting);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Request,
|
||||
UseGuards,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { UserService } from './user.service';
|
||||
import { CombinedAuthGuard } from '../auth/combined-auth.guard';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { I18nService } from '../i18n/i18n.service';
|
||||
import { UserRole } from './user-role.enum';
|
||||
import { UserSettingService } from './user-setting.service';
|
||||
|
||||
@Controller('users')
|
||||
@UseGuards(CombinedAuthGuard)
|
||||
export class UserController {
|
||||
constructor(
|
||||
private readonly userService: UserService,
|
||||
private readonly i18nService: I18nService,
|
||||
private readonly userSettingService: UserSettingService,
|
||||
) {}
|
||||
|
||||
// --- API Key Management ---
|
||||
@Get('api-key')
|
||||
async getApiKey(@Request() req) {
|
||||
const apiKey = await this.userService.getOrCreateApiKey(req.user.id);
|
||||
return { apiKey };
|
||||
}
|
||||
|
||||
@Post('api-key/rotate')
|
||||
async rotateApiKey(@Request() req) {
|
||||
const apiKey = await this.userService.regenerateApiKey(req.user.id);
|
||||
return { apiKey };
|
||||
}
|
||||
|
||||
// --- Personal Settings ---
|
||||
@Get('settings')
|
||||
async getSettings(@Request() req) {
|
||||
return this.userSettingService.getByUser(req.user.id);
|
||||
}
|
||||
|
||||
@Put('settings/language')
|
||||
async updateLanguage(@Request() req, @Body() body: { language: string }) {
|
||||
if (!body.language) throw new BadRequestException('language is required');
|
||||
return this.userSettingService.update(req.user.id, body.language);
|
||||
}
|
||||
|
||||
// --- Profile ---
|
||||
@Get('profile')
|
||||
async getProfile(@Request() req: any) {
|
||||
return this.userService.findOneById(req.user.id);
|
||||
}
|
||||
|
||||
@Get('tenants')
|
||||
async getMyTenants(@Request() req: any) {
|
||||
return this.userService.getUserTenants(req.user.id);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
async getMe(@Request() req) {
|
||||
const user = await this.userService.findOneById(req.user.id);
|
||||
if (!user) throw new NotFoundException();
|
||||
|
||||
let isNotebookEnabled = true;
|
||||
if (user.tenantId) {
|
||||
const settings = await this.userService.getTenantSettings(user.tenantId);
|
||||
isNotebookEnabled = settings?.isNotebookEnabled ?? true;
|
||||
}
|
||||
|
||||
const tenantName = user.tenantMembers?.[0]?.tenant?.name || 'Default';
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
role: user.isAdmin ? UserRole.SUPER_ADMIN : UserRole.USER,
|
||||
tenantId: user.tenantId,
|
||||
tenantName,
|
||||
isAdmin: user.isAdmin,
|
||||
isNotebookEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
@Get()
|
||||
async findAll(
|
||||
@Request() req,
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
const callerRole = req.user.role;
|
||||
if (
|
||||
callerRole !== UserRole.SUPER_ADMIN &&
|
||||
callerRole !== UserRole.TENANT_ADMIN
|
||||
) {
|
||||
throw new ForbiddenException(
|
||||
this.i18nService.getErrorMessage('adminOnlyViewList'),
|
||||
);
|
||||
}
|
||||
|
||||
const p = page ? parseInt(page) : undefined;
|
||||
const l = limit ? parseInt(limit) : undefined;
|
||||
|
||||
if (callerRole === UserRole.SUPER_ADMIN) {
|
||||
return this.userService.findAll(p, l);
|
||||
} else {
|
||||
return this.userService.findByTenantId(req.user.tenantId, p, l);
|
||||
}
|
||||
}
|
||||
|
||||
@Put('password')
|
||||
async changePassword(
|
||||
@Request() req,
|
||||
@Body() body: { currentPassword: string; newPassword: string },
|
||||
) {
|
||||
const { currentPassword, newPassword } = body;
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
throw new BadRequestException(
|
||||
this.i18nService.getErrorMessage('passwordsRequired'),
|
||||
);
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
throw new BadRequestException(
|
||||
this.i18nService.getErrorMessage('newPasswordMinLength'),
|
||||
);
|
||||
}
|
||||
|
||||
return this.userService.changePassword(
|
||||
req.user.id,
|
||||
currentPassword,
|
||||
newPassword,
|
||||
);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async createUser(@Request() req, @Body() body: CreateUserDto) {
|
||||
const callerRole = req.user.role;
|
||||
if (
|
||||
callerRole !== UserRole.SUPER_ADMIN &&
|
||||
callerRole !== UserRole.TENANT_ADMIN
|
||||
) {
|
||||
throw new ForbiddenException(
|
||||
this.i18nService.getErrorMessage('adminOnlyCreateUser'),
|
||||
);
|
||||
}
|
||||
|
||||
const { username, password } = body;
|
||||
|
||||
if (!username || !password) {
|
||||
throw new BadRequestException(
|
||||
this.i18nService.getErrorMessage('usernamePasswordRequired'),
|
||||
);
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
throw new BadRequestException(
|
||||
this.i18nService.getErrorMessage('passwordMinLength'),
|
||||
);
|
||||
}
|
||||
|
||||
// All new global users default to non-admin.
|
||||
// Elevation to Super Admin status is handled separately.
|
||||
let isAdmin = false;
|
||||
|
||||
if (callerRole === UserRole.SUPER_ADMIN) {
|
||||
isAdmin = false;
|
||||
} else if (callerRole === UserRole.TENANT_ADMIN) {
|
||||
isAdmin = false;
|
||||
}
|
||||
|
||||
// Pass the calculated params to the service
|
||||
return this.userService.createUser(
|
||||
username,
|
||||
password,
|
||||
isAdmin,
|
||||
req.user.tenantId,
|
||||
body.displayName,
|
||||
);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async updateUser(
|
||||
@Request() req,
|
||||
@Body() body: UpdateUserDto,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
const callerRole = req.user.role;
|
||||
if (
|
||||
callerRole !== UserRole.SUPER_ADMIN &&
|
||||
callerRole !== UserRole.TENANT_ADMIN
|
||||
) {
|
||||
throw new ForbiddenException(
|
||||
this.i18nService.getErrorMessage('adminOnlyUpdateUser'),
|
||||
);
|
||||
}
|
||||
|
||||
// Get user info to update
|
||||
const userToUpdate = await this.userService.findOneById(id);
|
||||
if (!userToUpdate) {
|
||||
throw new NotFoundException(
|
||||
this.i18nService.getErrorMessage('userNotFound'),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
callerRole === 'TENANT_ADMIN' &&
|
||||
userToUpdate.tenantId !== req.user.tenantId
|
||||
) {
|
||||
throw new ForbiddenException('Cannot modify users outside your tenant');
|
||||
}
|
||||
|
||||
// Prevent modifying the builtin admin account
|
||||
if (userToUpdate.username === 'admin') {
|
||||
throw new ForbiddenException(
|
||||
this.i18nService.getErrorMessage('cannotModifyBuiltinAdmin'),
|
||||
);
|
||||
}
|
||||
|
||||
// Role modification is now obsolete on global level.
|
||||
// If Admin wants to elevate, they set isAdmin property directly.
|
||||
if (body.isAdmin !== undefined && userToUpdate.isAdmin !== body.isAdmin) {
|
||||
if (callerRole !== UserRole.SUPER_ADMIN) {
|
||||
throw new ForbiddenException(
|
||||
'Only Super Admins can change user admin status.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate password length if provided
|
||||
if (body.password && body.password.length < 6) {
|
||||
throw new BadRequestException(
|
||||
this.i18nService.getErrorMessage('passwordMinLength'),
|
||||
);
|
||||
}
|
||||
|
||||
return this.userService.updateUser(id, body);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async deleteUser(@Request() req, @Param('id') id: string) {
|
||||
const callerRole = req.user.role;
|
||||
if (
|
||||
callerRole !== UserRole.SUPER_ADMIN &&
|
||||
callerRole !== UserRole.TENANT_ADMIN
|
||||
) {
|
||||
throw new ForbiddenException(
|
||||
this.i18nService.getErrorMessage('adminOnlyDeleteUser'),
|
||||
);
|
||||
}
|
||||
|
||||
// Prevent admin from deleting themselves
|
||||
if (req.user.id === id) {
|
||||
throw new BadRequestException(
|
||||
this.i18nService.getErrorMessage('cannotDeleteSelf'),
|
||||
);
|
||||
}
|
||||
|
||||
// Get user info to delete
|
||||
const userToDelete = await this.userService.findOneById(id);
|
||||
if (!userToDelete) {
|
||||
throw new NotFoundException(
|
||||
this.i18nService.getErrorMessage('userNotFound'),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
callerRole === 'TENANT_ADMIN' &&
|
||||
userToDelete.tenantId !== req.user.tenantId
|
||||
) {
|
||||
throw new ForbiddenException('Cannot delete users outside your tenant');
|
||||
}
|
||||
|
||||
// Block deletion of built-in admin account
|
||||
if (userToDelete.username === 'admin') {
|
||||
throw new ForbiddenException(
|
||||
this.i18nService.getErrorMessage('cannotDeleteBuiltinAdmin'),
|
||||
);
|
||||
}
|
||||
|
||||
return this.userService.deleteUser(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
BeforeInsert,
|
||||
BeforeUpdate,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { ModelConfig } from '../model-config/model-config.entity';
|
||||
import { Tenant } from '../tenant/tenant.entity';
|
||||
import { TenantMember } from '../tenant/tenant-member.entity';
|
||||
import { ApiKey } from '../auth/entities/api-key.entity';
|
||||
|
||||
import { UserRole } from './user-role.enum';
|
||||
import { UserSetting } from './user-setting.entity';
|
||||
|
||||
@Entity('users')
|
||||
export class User {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'text', unique: true })
|
||||
username: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
password: string;
|
||||
|
||||
// Legacy field - kept for backward compatibility, use `role` for new logic
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isAdmin: boolean;
|
||||
|
||||
@Column({ type: 'text', nullable: false })
|
||||
displayName: string;
|
||||
|
||||
// Multi-tenancy: A user can belong to multiple tenants via TenantMember
|
||||
@OneToMany(() => TenantMember, (member) => member.user)
|
||||
tenantMembers: TenantMember[];
|
||||
|
||||
// Legacy field - kept for backward compatibility if needed, but primary tenant is now determined by context
|
||||
@Column({ type: 'text', nullable: true, name: 'tenant_id' })
|
||||
tenantId: string;
|
||||
|
||||
// API Keys for external API access
|
||||
@OneToMany(() => ApiKey, (apiKey) => apiKey.user)
|
||||
apiKeys: ApiKey[];
|
||||
|
||||
// Quota management field
|
||||
@Column({ type: 'decimal', precision: 10, scale: 2, default: 0 })
|
||||
monthlyCost: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 10, scale: 2, default: 100 })
|
||||
maxCost: number;
|
||||
|
||||
@Column({ type: 'datetime', nullable: true })
|
||||
lastQuotaReset: Date;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
|
||||
@OneToOne(() => UserSetting, (setting) => setting.user)
|
||||
userSetting: UserSetting;
|
||||
|
||||
@BeforeInsert()
|
||||
@BeforeUpdate()
|
||||
async hashPassword() {
|
||||
// Only hash if the password is not yet hashed (BCrypt hash length is 60)
|
||||
if (this.password && this.password.length < 60) {
|
||||
this.password = await bcrypt.hash(this.password, 10);
|
||||
}
|
||||
}
|
||||
|
||||
async validatePassword(password: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, this.password);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { User } from './user.entity';
|
||||
import { UserSetting } from './user-setting.entity';
|
||||
import { UserSettingService } from './user-setting.service';
|
||||
import { TenantMember } from '../tenant/tenant-member.entity';
|
||||
import { ApiKey } from '../auth/entities/api-key.entity';
|
||||
import { UserService } from './user.service';
|
||||
import { UserController } from './user.controller';
|
||||
import { TenantModule } from '../tenant/tenant.module';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([User, ApiKey, TenantMember, UserSetting]),
|
||||
TenantModule,
|
||||
],
|
||||
controllers: [UserController],
|
||||
providers: [UserService, UserSettingService],
|
||||
exports: [UserService, UserSettingService],
|
||||
})
|
||||
export class UserModule {}
|
||||
@@ -0,0 +1,412 @@
|
||||
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);
|
||||
console.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,
|
||||
});
|
||||
|
||||
console.log('\n=== Admin account created ===');
|
||||
console.log('Username: admin');
|
||||
console.log('Password:', randomPassword);
|
||||
console.log('========================================\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user