feat: complete login and user management flow
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
ALTER TABLE "User"
|
||||
ADD COLUMN "email" TEXT,
|
||||
ADD COLUMN "phone" TEXT,
|
||||
ADD COLUMN "failedLoginCount" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "lockedUntil" TIMESTAMP(3),
|
||||
ADD COLUMN "lastLoginAt" TIMESTAMP(3),
|
||||
ADD COLUMN "deletedAt" TIMESTAMP(3);
|
||||
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
CREATE UNIQUE INDEX "User_phone_key" ON "User"("phone");
|
||||
@@ -65,9 +65,15 @@ model User {
|
||||
id String @id @default(cuid())
|
||||
tenantId String?
|
||||
username String @unique
|
||||
email String? @unique
|
||||
phone String? @unique
|
||||
displayName String
|
||||
passwordHash String
|
||||
status String @default("active")
|
||||
failedLoginCount Int @default(0)
|
||||
lockedUntil DateTime?
|
||||
lastLoginAt DateTime?
|
||||
deletedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Post } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { AuthService, LoginDto } from './auth.service';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('client/auth')
|
||||
@Controller()
|
||||
export class AuthController {
|
||||
constructor(private readonly auth: AuthService) {}
|
||||
|
||||
@Post('login')
|
||||
login(@Body() body: LoginDto) {
|
||||
return this.auth.login(body);
|
||||
@Get('admin/auth/captcha')
|
||||
adminCaptcha() {
|
||||
return this.auth.createCaptcha();
|
||||
}
|
||||
|
||||
@Post('admin/auth/login')
|
||||
adminLogin(@Body() body: LoginDto) {
|
||||
return this.auth.login(body, 'admin');
|
||||
}
|
||||
|
||||
@Get('client/auth/captcha')
|
||||
clientCaptcha() {
|
||||
return this.auth.createCaptcha();
|
||||
}
|
||||
|
||||
@Post('client/auth/login')
|
||||
clientLogin(@Body() body: LoginDto) {
|
||||
return this.auth.login(body, 'client');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { hashPassword } from '../users/users.service';
|
||||
|
||||
function createUsersMock(roleCode: string, overrides: Record<string, unknown> = {}) {
|
||||
const user = {
|
||||
id: 'user-1',
|
||||
tenantId: roleCode === 'enterprise_admin' ? 'tenant-1' : null,
|
||||
username: 'user',
|
||||
email: 'user@example.com',
|
||||
phone: '13800000000',
|
||||
displayName: '用户',
|
||||
passwordHash: hashPassword('secret1'),
|
||||
status: 'active',
|
||||
deletedAt: null,
|
||||
lockedUntil: null,
|
||||
tenant: { id: 'tenant-1', name: '企业A' },
|
||||
roles: [{ role: { code: roleCode } }],
|
||||
...overrides,
|
||||
};
|
||||
return {
|
||||
findByLogin: jest.fn().mockResolvedValue(user),
|
||||
recordLoginSuccess: jest.fn(),
|
||||
recordLoginFailure: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
async function loginWithCaptcha(service: AuthService, portal: 'admin' | 'client', password = 'secret1') {
|
||||
const captcha = service.createCaptcha();
|
||||
const answer = captcha.challenge.split('=')[0].split('+').map((part) => Number(part.trim())).reduce((sum, value) => sum + value, 0);
|
||||
return service.login({
|
||||
login: 'user@example.com',
|
||||
password,
|
||||
captchaId: captcha.captchaId,
|
||||
captchaText: String(answer),
|
||||
}, portal);
|
||||
}
|
||||
|
||||
describe('AuthService', () => {
|
||||
it('allows platform admins to login admin portal', async () => {
|
||||
const users = createUsersMock('platform_admin');
|
||||
const service = new AuthService(users as never);
|
||||
await expect(loginWithCaptcha(service, 'admin')).resolves.toEqual(expect.objectContaining({ portal: 'admin' }));
|
||||
expect(users.recordLoginSuccess).toHaveBeenCalledWith('user-1');
|
||||
});
|
||||
|
||||
it('rejects enterprise admins on admin portal', async () => {
|
||||
const users = createUsersMock('enterprise_admin');
|
||||
const service = new AuthService(users as never);
|
||||
await expect(loginWithCaptcha(service, 'admin')).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(users.recordLoginFailure).toHaveBeenCalledWith('user-1');
|
||||
});
|
||||
|
||||
it('locks user after five failed password attempts', async () => {
|
||||
const users = createUsersMock('platform_admin');
|
||||
const service = new AuthService(users as never);
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await expect(loginWithCaptcha(service, 'admin', 'bad-password')).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
}
|
||||
expect(users.recordLoginFailure).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
});
|
||||
@@ -1,30 +1,122 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { hashPassword, UsersService } from '../users/users.service';
|
||||
|
||||
export interface LoginDto {
|
||||
username: string;
|
||||
login: string;
|
||||
password: string;
|
||||
captchaId: string;
|
||||
captchaText: string;
|
||||
}
|
||||
|
||||
type LoginPortal = 'admin' | 'client';
|
||||
|
||||
type CaptchaRecord = {
|
||||
answer: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
const captchaStore = new Map<string, CaptchaRecord>();
|
||||
const anonymousFailures = new Map<string, { count: number; lockedUntil?: number }>();
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(private readonly users: UsersService) {}
|
||||
|
||||
async login(data: LoginDto) {
|
||||
const user = await this.users.findByUsername(data.username);
|
||||
if (!user || user.passwordHash !== hashPassword(data.password) || user.status !== 'active') {
|
||||
throw new UnauthorizedException('Invalid username or password');
|
||||
createCaptcha() {
|
||||
const left = Math.floor(10 + Math.random() * 40);
|
||||
const right = Math.floor(1 + Math.random() * 9);
|
||||
const captchaId = randomUUID();
|
||||
captchaStore.set(captchaId, {
|
||||
answer: String(left + right),
|
||||
expiresAt: Date.now() + 5 * 60 * 1000,
|
||||
});
|
||||
return {
|
||||
captchaId,
|
||||
challenge: `${left} + ${right} = ?`,
|
||||
expiresInSeconds: 300,
|
||||
};
|
||||
}
|
||||
|
||||
async login(data: LoginDto, portal: LoginPortal) {
|
||||
const login = data.login?.trim();
|
||||
if (!login || !data.password) {
|
||||
throw new BadRequestException('login and password are required');
|
||||
}
|
||||
this.verifyCaptcha(data.captchaId, data.captchaText);
|
||||
this.assertAnonymousNotLocked(login);
|
||||
|
||||
const user = await this.users.findByLogin(login);
|
||||
if (!user) {
|
||||
this.recordAnonymousFailure(login);
|
||||
throw new UnauthorizedException('Invalid login or password');
|
||||
}
|
||||
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
|
||||
throw new UnauthorizedException('User is locked for 24 hours after repeated failures');
|
||||
}
|
||||
if (user.status !== 'active' || user.deletedAt) {
|
||||
await this.users.recordLoginFailure(user.id);
|
||||
throw new UnauthorizedException('User is disabled or deleted');
|
||||
}
|
||||
if (user.passwordHash !== hashPassword(data.password)) {
|
||||
await this.users.recordLoginFailure(user.id);
|
||||
throw new UnauthorizedException('Invalid login or password');
|
||||
}
|
||||
|
||||
const roleCodes = user.roles.map((item) => item.role.code);
|
||||
if (portal === 'admin' && !roleCodes.includes('platform_admin')) {
|
||||
await this.users.recordLoginFailure(user.id);
|
||||
throw new UnauthorizedException('Only platform admins can login to admin portal');
|
||||
}
|
||||
if (portal === 'client' && (!roleCodes.includes('enterprise_admin') || !user.tenantId)) {
|
||||
await this.users.recordLoginFailure(user.id);
|
||||
throw new UnauthorizedException('Only enterprise admins linked to a tenant can login to client portal');
|
||||
}
|
||||
|
||||
await this.users.recordLoginSuccess(user.id);
|
||||
anonymousFailures.delete(login);
|
||||
|
||||
return {
|
||||
accessToken: `dev-token-${user.id}`,
|
||||
tokenType: 'Bearer',
|
||||
portal,
|
||||
user: {
|
||||
id: user.id,
|
||||
tenantId: user.tenantId,
|
||||
tenantName: user.tenant?.name,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
displayName: user.displayName,
|
||||
roles: roleCodes,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private verifyCaptcha(captchaId?: string, captchaText?: string) {
|
||||
const record = captchaId ? captchaStore.get(captchaId) : undefined;
|
||||
captchaStore.delete(captchaId ?? '');
|
||||
if (!record || record.expiresAt < Date.now()) {
|
||||
throw new BadRequestException('Captcha expired, refresh and try again');
|
||||
}
|
||||
if (record.answer !== captchaText?.trim()) {
|
||||
throw new BadRequestException('Captcha is incorrect');
|
||||
}
|
||||
}
|
||||
|
||||
private assertAnonymousNotLocked(login: string) {
|
||||
const current = anonymousFailures.get(login);
|
||||
if (current?.lockedUntil && current.lockedUntil > Date.now()) {
|
||||
throw new UnauthorizedException('User is locked for 24 hours after repeated failures');
|
||||
}
|
||||
}
|
||||
|
||||
private recordAnonymousFailure(login: string) {
|
||||
const current = anonymousFailures.get(login) ?? { count: 0 };
|
||||
const count = current.count + 1;
|
||||
anonymousFailures.set(login, {
|
||||
count,
|
||||
lockedUntil: count >= 5 ? Date.now() + 24 * 60 * 60 * 1000 : current.lockedUntil,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +1,114 @@
|
||||
import { Body, Controller, Get, Post } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { TenantId } from '../common/tenant-id.decorator';
|
||||
import {
|
||||
AssignPermissionDto,
|
||||
AssignRoleDto,
|
||||
ChangePasswordDto,
|
||||
ChangeUserStatusDto,
|
||||
CreatePermissionDto,
|
||||
CreateRoleDto,
|
||||
CreateUserDto,
|
||||
UpdateUserDto,
|
||||
UsersService,
|
||||
} from './users.service';
|
||||
|
||||
@ApiTags('users')
|
||||
@Controller('admin/users')
|
||||
@Controller()
|
||||
export class UsersController {
|
||||
constructor(private readonly users: UsersService) {}
|
||||
|
||||
@Get()
|
||||
list(@TenantId() tenantId?: string) {
|
||||
return this.users.list(tenantId);
|
||||
@Get('admin/users')
|
||||
list(@Query('tenantId') tenantId?: string, @Query('roleCode') roleCode?: string) {
|
||||
return this.users.list(tenantId, roleCode);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Post('admin/users')
|
||||
create(@Body() body: CreateUserDto) {
|
||||
return this.users.create(body);
|
||||
}
|
||||
|
||||
@Get('roles')
|
||||
@Put('admin/users/:id')
|
||||
update(@Param('id') id: string, @Body() body: UpdateUserDto) {
|
||||
return this.users.update(id, body);
|
||||
}
|
||||
|
||||
@Patch('admin/users/:id')
|
||||
patch(@Param('id') id: string, @Body() body: UpdateUserDto) {
|
||||
return this.users.update(id, body);
|
||||
}
|
||||
|
||||
@Post('admin/users/:id/status')
|
||||
changeStatus(@Param('id') id: string, @Body() body: ChangeUserStatusDto) {
|
||||
return this.users.changeStatus(id, body);
|
||||
}
|
||||
|
||||
@Post('admin/users/:id/password')
|
||||
changePassword(@Param('id') id: string, @Body() body: ChangePasswordDto) {
|
||||
return this.users.changePassword(id, body);
|
||||
}
|
||||
|
||||
@Delete('admin/users/:id')
|
||||
remove(@Param('id') id: string, @Body('operatorId') operatorId?: string) {
|
||||
return this.users.remove(id, operatorId);
|
||||
}
|
||||
|
||||
@Get('client/users')
|
||||
listClient(@TenantId() tenantId?: string) {
|
||||
return this.users.listClientUsers(tenantId);
|
||||
}
|
||||
|
||||
@Post('client/users')
|
||||
createClient(@TenantId() tenantId: string | undefined, @Body() body: CreateUserDto) {
|
||||
return this.users.create({ ...body, roleCode: 'enterprise_admin' }, tenantId);
|
||||
}
|
||||
|
||||
@Put('client/users/:id')
|
||||
updateClient(@TenantId() tenantId: string | undefined, @Param('id') id: string, @Body() body: UpdateUserDto) {
|
||||
return this.users.update(id, { ...body, roleCode: 'enterprise_admin' }, tenantId);
|
||||
}
|
||||
|
||||
@Post('client/users/:id/status')
|
||||
changeClientStatus(@TenantId() tenantId: string | undefined, @Param('id') id: string, @Body() body: ChangeUserStatusDto) {
|
||||
return this.users.changeStatus(id, body, tenantId);
|
||||
}
|
||||
|
||||
@Post('client/users/:id/password')
|
||||
changeClientPassword(@TenantId() tenantId: string | undefined, @Param('id') id: string, @Body() body: ChangePasswordDto) {
|
||||
return this.users.changePassword(id, body, tenantId);
|
||||
}
|
||||
|
||||
@Delete('client/users/:id')
|
||||
removeClient(@TenantId() tenantId: string | undefined, @Param('id') id: string, @Body('operatorId') operatorId?: string) {
|
||||
return this.users.remove(id, operatorId, tenantId);
|
||||
}
|
||||
|
||||
@Get('admin/users/roles')
|
||||
listRoles() {
|
||||
return this.users.listRoles();
|
||||
}
|
||||
|
||||
@Post('roles')
|
||||
@Post('admin/users/roles')
|
||||
createRole(@Body() body: CreateRoleDto) {
|
||||
return this.users.createRole(body);
|
||||
}
|
||||
|
||||
@Get('permissions')
|
||||
@Get('admin/users/permissions')
|
||||
listPermissions() {
|
||||
return this.users.listPermissions();
|
||||
}
|
||||
|
||||
@Post('permissions')
|
||||
@Post('admin/users/permissions')
|
||||
createPermission(@Body() body: CreatePermissionDto) {
|
||||
return this.users.createPermission(body);
|
||||
}
|
||||
|
||||
@Post('roles/assign')
|
||||
@Post('admin/users/roles/assign')
|
||||
assignRole(@Body() body: AssignRoleDto) {
|
||||
return this.users.assignRole(body);
|
||||
}
|
||||
|
||||
@Post('permissions/assign')
|
||||
@Post('admin/users/permissions/assign')
|
||||
assignPermission(@Body() body: AssignPermissionDto) {
|
||||
return this.users.assignPermission(body);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
function createPrismaMock() {
|
||||
const roles = new Map<string, { id: string; code: string; name: string; scope: string }>();
|
||||
return {
|
||||
user: {
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'user-1', ...data, tenant: null, roles: [{ role: roles.get('platform_admin') ?? roles.get('enterprise_admin') }] })),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
findFirst: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
role: {
|
||||
upsert: jest.fn().mockImplementation(({ where, create, update }) => {
|
||||
const role = { id: `role-${where.code}`, ...create, ...update };
|
||||
roles.set(where.code, role);
|
||||
return Promise.resolve(role);
|
||||
}),
|
||||
findMany: jest.fn(),
|
||||
create: jest.fn(),
|
||||
},
|
||||
userRole: {
|
||||
upsert: jest.fn(),
|
||||
deleteMany: jest.fn(),
|
||||
create: jest.fn(),
|
||||
},
|
||||
permission: { findMany: jest.fn(), create: jest.fn() },
|
||||
rolePermission: { upsert: jest.fn() },
|
||||
operationLog: { create: jest.fn() },
|
||||
$transaction: jest.fn((callback) => callback({
|
||||
user: {
|
||||
update: jest.fn().mockResolvedValue({ id: 'user-1', username: 'u', roles: [] }),
|
||||
},
|
||||
userRole: {
|
||||
deleteMany: jest.fn(),
|
||||
create: jest.fn(),
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe('UsersService', () => {
|
||||
it('requires enterprise admins to be linked to a tenant', async () => {
|
||||
const service = new UsersService(createPrismaMock() as never);
|
||||
await expect(service.create({
|
||||
displayName: '企业管理员',
|
||||
email: 'tenant@example.com',
|
||||
password: 'secret1',
|
||||
roleCode: 'enterprise_admin',
|
||||
})).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects platform admins linked to tenants', async () => {
|
||||
const service = new UsersService(createPrismaMock() as never);
|
||||
await expect(service.create({
|
||||
tenantId: 'tenant-1',
|
||||
displayName: '平台管理员',
|
||||
email: 'admin@example.com',
|
||||
password: 'secret1',
|
||||
roleCode: 'platform_admin',
|
||||
})).rejects.toThrow('platform_admin must not be linked to a tenant');
|
||||
});
|
||||
|
||||
it('creates platform admins and writes operation logs', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new UsersService(prisma as never);
|
||||
await service.create({
|
||||
displayName: '平台管理员',
|
||||
email: 'admin@example.com',
|
||||
phone: '13800000000',
|
||||
password: 'secret1',
|
||||
roleCode: 'platform_admin',
|
||||
operatorId: 'operator-1',
|
||||
});
|
||||
expect(prisma.user.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
email: 'admin@example.com',
|
||||
phone: '13800000000',
|
||||
status: 'active',
|
||||
}),
|
||||
}));
|
||||
expect(prisma.operationLog.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ action: 'user.created', resource: 'user' }),
|
||||
}));
|
||||
});
|
||||
});
|
||||
+227
-12
@@ -1,13 +1,41 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export type UserRoleCode = 'platform_admin' | 'enterprise_admin';
|
||||
|
||||
export interface CreateUserDto {
|
||||
tenantId?: string;
|
||||
username: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
displayName: string;
|
||||
password: string;
|
||||
status?: string;
|
||||
roleCode: UserRoleCode;
|
||||
operatorId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateUserDto {
|
||||
tenantId?: string | null;
|
||||
username?: string;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
displayName?: string;
|
||||
status?: string;
|
||||
roleCode?: UserRoleCode;
|
||||
operatorId?: string;
|
||||
}
|
||||
|
||||
export interface ChangeUserStatusDto {
|
||||
status: 'active' | 'disabled';
|
||||
operatorId?: string;
|
||||
}
|
||||
|
||||
export interface ChangePasswordDto {
|
||||
password: string;
|
||||
operatorId?: string;
|
||||
}
|
||||
|
||||
export interface CreateRoleDto {
|
||||
@@ -33,31 +61,159 @@ export interface AssignPermissionDto {
|
||||
permissionId: string;
|
||||
}
|
||||
|
||||
const roleNames: Record<UserRoleCode, { name: string; scope: string }> = {
|
||||
platform_admin: { name: '平台管理员', scope: 'platform' },
|
||||
enterprise_admin: { name: '企业管理员', scope: 'tenant' },
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
list(tenantId?: string) {
|
||||
list(tenantId?: string, roleCode?: string) {
|
||||
return this.prisma.user.findMany({
|
||||
where: tenantId ? { tenantId } : undefined,
|
||||
include: { roles: { include: { role: true } } },
|
||||
where: {
|
||||
deletedAt: null,
|
||||
...(tenantId ? { tenantId } : {}),
|
||||
...(roleCode ? { roles: { some: { role: { code: roleCode } } } } : {}),
|
||||
},
|
||||
include: { tenant: true, roles: { include: { role: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
take: 200,
|
||||
});
|
||||
}
|
||||
|
||||
findByUsername(username: string) {
|
||||
return this.prisma.user.findUnique({ where: { username } });
|
||||
listClientUsers(tenantId?: string) {
|
||||
if (!tenantId) {
|
||||
throw new BadRequestException('tenantId is required for client user management');
|
||||
}
|
||||
return this.list(tenantId);
|
||||
}
|
||||
|
||||
create(data: CreateUserDto) {
|
||||
return this.prisma.user.create({
|
||||
findByUsername(username: string) {
|
||||
return this.prisma.user.findUnique({ where: { username }, include: { tenant: true, roles: { include: { role: true } } } });
|
||||
}
|
||||
|
||||
findByLogin(login: string) {
|
||||
return this.prisma.user.findFirst({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
OR: [{ username: login }, { email: login }, { phone: login }],
|
||||
},
|
||||
include: { tenant: true, roles: { include: { role: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: CreateUserDto, scopeTenantId?: string) {
|
||||
const roleCode = data.roleCode;
|
||||
this.assertUserInput({ ...data, roleCode }, scopeTenantId, true);
|
||||
const tenantId = scopeTenantId ?? data.tenantId;
|
||||
const role = await this.ensureRole(roleCode);
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
username: data.username,
|
||||
tenantId,
|
||||
username: data.username ?? data.email ?? data.phone ?? '',
|
||||
email: normalizeOptional(data.email),
|
||||
phone: normalizeOptional(data.phone),
|
||||
displayName: data.displayName,
|
||||
passwordHash: hashPassword(data.password),
|
||||
status: data.status ?? 'active',
|
||||
roles: { create: [{ roleId: role.id }] },
|
||||
},
|
||||
include: { tenant: true, roles: { include: { role: true } } },
|
||||
});
|
||||
await this.writeLog(tenantId, data.operatorId, 'user.created', user.id, { roleCode, username: user.username });
|
||||
return user;
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdateUserDto, scopeTenantId?: string) {
|
||||
const current = await this.getExisting(id, scopeTenantId);
|
||||
const roleCode = data.roleCode ?? current.roles[0]?.role.code as UserRoleCode | undefined;
|
||||
this.assertUserInput({
|
||||
tenantId: data.tenantId ?? current.tenantId ?? undefined,
|
||||
username: data.username ?? current.username,
|
||||
email: data.email === undefined ? current.email ?? undefined : data.email ?? undefined,
|
||||
phone: data.phone === undefined ? current.phone ?? undefined : data.phone ?? undefined,
|
||||
displayName: data.displayName ?? current.displayName,
|
||||
password: 'unchanged-password',
|
||||
roleCode: roleCode ?? 'enterprise_admin',
|
||||
}, scopeTenantId, false);
|
||||
const tenantId = scopeTenantId ?? data.tenantId ?? current.tenantId;
|
||||
const role = roleCode ? await this.ensureRole(roleCode) : null;
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
if (role) {
|
||||
await tx.userRole.deleteMany({ where: { userId: id } });
|
||||
await tx.userRole.create({ data: { userId: id, roleId: role.id } });
|
||||
}
|
||||
return tx.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
tenantId,
|
||||
username: data.username ?? current.username,
|
||||
email: data.email === undefined ? undefined : normalizeOptional(data.email),
|
||||
phone: data.phone === undefined ? undefined : normalizeOptional(data.phone),
|
||||
displayName: data.displayName ?? current.displayName,
|
||||
status: data.status ?? current.status,
|
||||
},
|
||||
include: { tenant: true, roles: { include: { role: true } } },
|
||||
});
|
||||
});
|
||||
await this.writeLog(tenantId, data.operatorId, 'user.updated', id, { roleCode, username: updated.username });
|
||||
return updated;
|
||||
}
|
||||
|
||||
async changeStatus(id: string, data: ChangeUserStatusDto, scopeTenantId?: string) {
|
||||
const current = await this.getExisting(id, scopeTenantId);
|
||||
const updated = await this.prisma.user.update({
|
||||
where: { id },
|
||||
data: { status: data.status },
|
||||
include: { tenant: true, roles: { include: { role: true } } },
|
||||
});
|
||||
await this.writeLog(current.tenantId, data.operatorId, `user.${data.status}`, id, { username: current.username });
|
||||
return updated;
|
||||
}
|
||||
|
||||
async changePassword(id: string, data: ChangePasswordDto, scopeTenantId?: string) {
|
||||
if (!data.password || data.password.length < 6) {
|
||||
throw new BadRequestException('password must be at least 6 characters');
|
||||
}
|
||||
const current = await this.getExisting(id, scopeTenantId);
|
||||
const updated = await this.prisma.user.update({
|
||||
where: { id },
|
||||
data: { passwordHash: hashPassword(data.password), failedLoginCount: 0, lockedUntil: null },
|
||||
include: { tenant: true, roles: { include: { role: true } } },
|
||||
});
|
||||
await this.writeLog(current.tenantId, data.operatorId, 'user.password_changed', id, { username: current.username });
|
||||
return updated;
|
||||
}
|
||||
|
||||
async remove(id: string, operatorId?: string, scopeTenantId?: string) {
|
||||
const current = await this.getExisting(id, scopeTenantId);
|
||||
const updated = await this.prisma.user.update({
|
||||
where: { id },
|
||||
data: { status: 'deleted', deletedAt: new Date() },
|
||||
include: { tenant: true, roles: { include: { role: true } } },
|
||||
});
|
||||
await this.writeLog(current.tenantId, operatorId, 'user.deleted', id, { username: current.username });
|
||||
return updated;
|
||||
}
|
||||
|
||||
async recordLoginSuccess(id: string) {
|
||||
return this.prisma.user.update({
|
||||
where: { id },
|
||||
data: { failedLoginCount: 0, lockedUntil: null, lastLoginAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async recordLoginFailure(id: string) {
|
||||
const current = await this.prisma.user.findUnique({ where: { id } });
|
||||
if (!current) return null;
|
||||
const failedLoginCount = current.failedLoginCount + 1;
|
||||
return this.prisma.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
failedLoginCount,
|
||||
lockedUntil: failedLoginCount >= 5 ? new Date(Date.now() + 24 * 60 * 60 * 1000) : current.lockedUntil,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -104,6 +260,65 @@ export class UsersService {
|
||||
create: data,
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureRole(code: UserRoleCode) {
|
||||
const info = roleNames[code];
|
||||
return this.prisma.role.upsert({
|
||||
where: { code },
|
||||
update: { name: info.name, scope: info.scope },
|
||||
create: { code, name: info.name, scope: info.scope },
|
||||
});
|
||||
}
|
||||
|
||||
private async getExisting(id: string, scopeTenantId?: string) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { id, deletedAt: null, ...(scopeTenantId ? { tenantId: scopeTenantId } : {}) },
|
||||
include: { tenant: true, roles: { include: { role: true } } },
|
||||
});
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
private assertUserInput(data: CreateUserDto, scopeTenantId: string | undefined, requirePassword: boolean) {
|
||||
if (!data.displayName?.trim()) {
|
||||
throw new BadRequestException('displayName is required');
|
||||
}
|
||||
if (requirePassword && (!data.password || data.password.length < 6)) {
|
||||
throw new BadRequestException('password must be at least 6 characters');
|
||||
}
|
||||
if (!data.email && !data.phone) {
|
||||
throw new BadRequestException('email or phone is required');
|
||||
}
|
||||
if (data.roleCode === 'platform_admin' && (scopeTenantId || data.tenantId)) {
|
||||
throw new BadRequestException('platform_admin must not be linked to a tenant');
|
||||
}
|
||||
if (data.roleCode === 'enterprise_admin' && !(scopeTenantId || data.tenantId)) {
|
||||
throw new BadRequestException('enterprise_admin must be linked to a tenant');
|
||||
}
|
||||
if (scopeTenantId && data.roleCode === 'platform_admin') {
|
||||
throw new BadRequestException('client user management cannot create platform admins');
|
||||
}
|
||||
}
|
||||
|
||||
private writeLog(tenantId: string | null | undefined, userId: string | undefined, action: string, resourceId: string, detail: Record<string, unknown>) {
|
||||
return this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: tenantId ?? undefined,
|
||||
userId,
|
||||
action,
|
||||
resource: 'user',
|
||||
resourceId,
|
||||
detail: detail as Prisma.InputJsonObject,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOptional(value?: string | null) {
|
||||
const next = value?.trim();
|
||||
return next ? next : null;
|
||||
}
|
||||
|
||||
export function hashPassword(password: string) {
|
||||
|
||||
Reference in New Issue
Block a user