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())
|
id String @id @default(cuid())
|
||||||
tenantId String?
|
tenantId String?
|
||||||
username String @unique
|
username String @unique
|
||||||
|
email String? @unique
|
||||||
|
phone String? @unique
|
||||||
displayName String
|
displayName String
|
||||||
passwordHash String
|
passwordHash String
|
||||||
status String @default("active")
|
status String @default("active")
|
||||||
|
failedLoginCount Int @default(0)
|
||||||
|
lockedUntil DateTime?
|
||||||
|
lastLoginAt DateTime?
|
||||||
|
deletedAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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 { ApiTags } from '@nestjs/swagger';
|
||||||
import { AuthService, LoginDto } from './auth.service';
|
import { AuthService, LoginDto } from './auth.service';
|
||||||
|
|
||||||
@ApiTags('auth')
|
@ApiTags('auth')
|
||||||
@Controller('client/auth')
|
@Controller()
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private readonly auth: AuthService) {}
|
constructor(private readonly auth: AuthService) {}
|
||||||
|
|
||||||
@Post('login')
|
@Get('admin/auth/captcha')
|
||||||
login(@Body() body: LoginDto) {
|
adminCaptcha() {
|
||||||
return this.auth.login(body);
|
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';
|
import { hashPassword, UsersService } from '../users/users.service';
|
||||||
|
|
||||||
export interface LoginDto {
|
export interface LoginDto {
|
||||||
username: string;
|
login: string;
|
||||||
password: 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()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
constructor(private readonly users: UsersService) {}
|
constructor(private readonly users: UsersService) {}
|
||||||
|
|
||||||
async login(data: LoginDto) {
|
createCaptcha() {
|
||||||
const user = await this.users.findByUsername(data.username);
|
const left = Math.floor(10 + Math.random() * 40);
|
||||||
if (!user || user.passwordHash !== hashPassword(data.password) || user.status !== 'active') {
|
const right = Math.floor(1 + Math.random() * 9);
|
||||||
throw new UnauthorizedException('Invalid username or password');
|
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 {
|
return {
|
||||||
accessToken: `dev-token-${user.id}`,
|
accessToken: `dev-token-${user.id}`,
|
||||||
tokenType: 'Bearer',
|
tokenType: 'Bearer',
|
||||||
|
portal,
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
tenantId: user.tenantId,
|
tenantId: user.tenantId,
|
||||||
|
tenantName: user.tenant?.name,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
phone: user.phone,
|
||||||
displayName: user.displayName,
|
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 { ApiTags } from '@nestjs/swagger';
|
||||||
import { TenantId } from '../common/tenant-id.decorator';
|
import { TenantId } from '../common/tenant-id.decorator';
|
||||||
import {
|
import {
|
||||||
AssignPermissionDto,
|
AssignPermissionDto,
|
||||||
AssignRoleDto,
|
AssignRoleDto,
|
||||||
|
ChangePasswordDto,
|
||||||
|
ChangeUserStatusDto,
|
||||||
CreatePermissionDto,
|
CreatePermissionDto,
|
||||||
CreateRoleDto,
|
CreateRoleDto,
|
||||||
CreateUserDto,
|
CreateUserDto,
|
||||||
|
UpdateUserDto,
|
||||||
UsersService,
|
UsersService,
|
||||||
} from './users.service';
|
} from './users.service';
|
||||||
|
|
||||||
@ApiTags('users')
|
@ApiTags('users')
|
||||||
@Controller('admin/users')
|
@Controller()
|
||||||
export class UsersController {
|
export class UsersController {
|
||||||
constructor(private readonly users: UsersService) {}
|
constructor(private readonly users: UsersService) {}
|
||||||
|
|
||||||
@Get()
|
@Get('admin/users')
|
||||||
list(@TenantId() tenantId?: string) {
|
list(@Query('tenantId') tenantId?: string, @Query('roleCode') roleCode?: string) {
|
||||||
return this.users.list(tenantId);
|
return this.users.list(tenantId, roleCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post('admin/users')
|
||||||
create(@Body() body: CreateUserDto) {
|
create(@Body() body: CreateUserDto) {
|
||||||
return this.users.create(body);
|
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() {
|
listRoles() {
|
||||||
return this.users.listRoles();
|
return this.users.listRoles();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('roles')
|
@Post('admin/users/roles')
|
||||||
createRole(@Body() body: CreateRoleDto) {
|
createRole(@Body() body: CreateRoleDto) {
|
||||||
return this.users.createRole(body);
|
return this.users.createRole(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('permissions')
|
@Get('admin/users/permissions')
|
||||||
listPermissions() {
|
listPermissions() {
|
||||||
return this.users.listPermissions();
|
return this.users.listPermissions();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('permissions')
|
@Post('admin/users/permissions')
|
||||||
createPermission(@Body() body: CreatePermissionDto) {
|
createPermission(@Body() body: CreatePermissionDto) {
|
||||||
return this.users.createPermission(body);
|
return this.users.createPermission(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('roles/assign')
|
@Post('admin/users/roles/assign')
|
||||||
assignRole(@Body() body: AssignRoleDto) {
|
assignRole(@Body() body: AssignRoleDto) {
|
||||||
return this.users.assignRole(body);
|
return this.users.assignRole(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('permissions/assign')
|
@Post('admin/users/permissions/assign')
|
||||||
assignPermission(@Body() body: AssignPermissionDto) {
|
assignPermission(@Body() body: AssignPermissionDto) {
|
||||||
return this.users.assignPermission(body);
|
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 { 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';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
export type UserRoleCode = 'platform_admin' | 'enterprise_admin';
|
||||||
|
|
||||||
export interface CreateUserDto {
|
export interface CreateUserDto {
|
||||||
tenantId?: string;
|
tenantId?: string;
|
||||||
username: string;
|
username?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
password: string;
|
password: string;
|
||||||
status?: 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 {
|
export interface CreateRoleDto {
|
||||||
@@ -33,31 +61,159 @@ export interface AssignPermissionDto {
|
|||||||
permissionId: string;
|
permissionId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const roleNames: Record<UserRoleCode, { name: string; scope: string }> = {
|
||||||
|
platform_admin: { name: '平台管理员', scope: 'platform' },
|
||||||
|
enterprise_admin: { name: '企业管理员', scope: 'tenant' },
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UsersService {
|
export class UsersService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
list(tenantId?: string) {
|
list(tenantId?: string, roleCode?: string) {
|
||||||
return this.prisma.user.findMany({
|
return this.prisma.user.findMany({
|
||||||
where: tenantId ? { tenantId } : undefined,
|
where: {
|
||||||
include: { roles: { include: { role: true } } },
|
deletedAt: null,
|
||||||
|
...(tenantId ? { tenantId } : {}),
|
||||||
|
...(roleCode ? { roles: { some: { role: { code: roleCode } } } } : {}),
|
||||||
|
},
|
||||||
|
include: { tenant: true, roles: { include: { role: true } } },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 100,
|
take: 200,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
findByUsername(username: string) {
|
listClientUsers(tenantId?: string) {
|
||||||
return this.prisma.user.findUnique({ where: { username } });
|
if (!tenantId) {
|
||||||
|
throw new BadRequestException('tenantId is required for client user management');
|
||||||
|
}
|
||||||
|
return this.list(tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
create(data: CreateUserDto) {
|
findByUsername(username: string) {
|
||||||
return this.prisma.user.create({
|
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: {
|
data: {
|
||||||
tenantId: data.tenantId,
|
tenantId,
|
||||||
username: data.username,
|
username: data.username ?? data.email ?? data.phone ?? '',
|
||||||
|
email: normalizeOptional(data.email),
|
||||||
|
phone: normalizeOptional(data.phone),
|
||||||
displayName: data.displayName,
|
displayName: data.displayName,
|
||||||
passwordHash: hashPassword(data.password),
|
passwordHash: hashPassword(data.password),
|
||||||
status: data.status ?? 'active',
|
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,
|
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) {
|
export function hashPassword(password: string) {
|
||||||
|
|||||||
@@ -835,7 +835,7 @@
|
|||||||
2. NestJS 后端实现 Prisma schema、migration、Service、Controller、DTO、单元测试。
|
2. NestJS 后端实现 Prisma schema、migration、Service、Controller、DTO、单元测试。
|
||||||
3. Go Gateway 实现配置、连接管理、CMPP submit/deliver/active test、回执事件发布、单元测试。
|
3. Go Gateway 实现配置、连接管理、CMPP submit/deliver/active test、回执事件发布、单元测试。
|
||||||
4. 前端将对应 mock 数据替换为 API 调用,保留现有视觉样式。
|
4. 前端将对应 mock 数据替换为 API 调用,保留现有视觉样式。
|
||||||
5. 原型阶段的 mock、localStorage 或静态数据只能作为开发临时兜底,不得作为真实开发完成标准;审核、通道、连接、日志、黑名单、敏感词、报备字段、用户、账务等闭环必须接入真实 API、数据库或 Gateway 回写。
|
5. 原型阶段的 mock、localStorage 或静态数据只能用于早期界面占位;进入系统功能验收后不得继续作为兜底通过路径。审核、通道、连接、日志、黑名单、敏感词、报备字段、用户、账务等闭环必须接入真实 API、数据库或 Gateway 回写;API 不可用时应展示错误态或空态,并将用例标记为阻塞或未通过。
|
||||||
6. 补充错误处理、权限校验、操作日志。
|
6. 补充错误处理、权限校验、操作日志。
|
||||||
7. 运行构建和相关测试,并更新测试进度文档。
|
7. 运行构建和相关测试,并更新测试进度文档。
|
||||||
```
|
```
|
||||||
@@ -1158,10 +1158,40 @@
|
|||||||
- 不从零手写整个 CMPP 协议栈,也不直接照搬完整开源网关;协议层可复用,服务层按本项目自研。
|
- 不从零手写整个 CMPP 协议栈,也不直接照搬完整开源网关;协议层可复用,服务层按本项目自研。
|
||||||
- NestJS 负责业务审核、风控、计费、报备、路由和发送编排。
|
- NestJS 负责业务审核、风控、计费、报备、路由和发送编排。
|
||||||
- Go Gateway 只负责 CMPP 连接、协议提交、submit resp、回执、上行事件回传。
|
- Go Gateway 只负责 CMPP 连接、协议提交、submit resp、回执、上行事件回传。
|
||||||
- 当前项目已进入真实开发阶段;前端 mock、localStorage 或静态数据只能临时兜底,不能作为功能完成标准。涉及审核、通道、连接、日志、安全控制、用户、账务等闭环时,必须接入真实 API、数据库或 Gateway 回写,并补测试。
|
- 当前项目已进入真实开发阶段;前端 mock、localStorage 或静态数据不得作为功能完成标准,也不得作为验收兜底通过路径。涉及审核、通道、连接、日志、安全控制、用户、账务等闭环时,必须接入真实 API、数据库或 Gateway 回写,并补真实后端 smoke 或 E2E 测试。
|
||||||
|
|
||||||
请从 docs/first-version-development-requirements.md 的“阶段 0:技术 Spike”开始执行。
|
请从 docs/first-version-development-requirements.md 的“阶段 0:技术 Spike”开始执行。
|
||||||
|
|
||||||
|
## 追加需求:登录与用户管理闭环
|
||||||
|
|
||||||
|
### 1. 登录入口
|
||||||
|
|
||||||
|
- 客户端和运营端必须使用独立登录页面:客户端 `/client/login`,运营端 `/admin/login`。
|
||||||
|
- 两端登录均输入邮箱或手机号、密码和图形验证码。
|
||||||
|
- 登录接口必须调用真实 NestJS API,不允许前端静态用户、localStorage mock 或纯前端验证码作为验收依据。
|
||||||
|
- 运营端登录仅允许平台管理员;客户端登录仅允许已关联企业的企业管理员。
|
||||||
|
|
||||||
|
### 2. 用户类型和企业关联
|
||||||
|
|
||||||
|
- 运营端用户管理支持创建两类用户:平台管理员、企业管理员。
|
||||||
|
- 平台管理员不得关联企业,只能登录运营端。
|
||||||
|
- 企业管理员必须关联一个企业,只能登录客户端。
|
||||||
|
- 企业管理员登录客户端后,所有客户端 API 必须继承当前企业租户范围,用户、Dashboard、账务、日志等数据不得越权访问其他企业。
|
||||||
|
|
||||||
|
### 3. 用户管理功能
|
||||||
|
|
||||||
|
- 运营端和客户端用户管理均需接入真实 API,支持添加、编辑、启用/禁用、删除、修改密码。
|
||||||
|
- 启用/禁用、删除必须弹窗确认。
|
||||||
|
- 删除采用软删除,历史系统日志、审核记录和业务记录仍可追溯;删除后用户不可登录且列表默认不展示。
|
||||||
|
- 客户端用户字段必须包含邮箱和手机号,并支持邮箱或手机号登录。
|
||||||
|
- 所有创建、编辑、启用、禁用、删除、修改密码动作必须写系统日志。
|
||||||
|
|
||||||
|
### 4. fail2ban
|
||||||
|
|
||||||
|
- 用户连续登录失败 5 次后,账号锁定 24 小时。
|
||||||
|
- 锁定状态必须写入后端持久化字段,后续登录直接拒绝。
|
||||||
|
- 登录成功后清空失败次数和锁定状态。
|
||||||
|
|
||||||
第一步请先不要大规模写业务代码,先输出并创建阶段 0 Spike 的最小工程计划,包括:
|
第一步请先不要大规模写业务代码,先输出并创建阶段 0 Spike 的最小工程计划,包括:
|
||||||
1. 目录结构建议。
|
1. 目录结构建议。
|
||||||
2. NestJS 与 Go Gateway 的队列消息格式。
|
2. NestJS 与 Go Gateway 的队列消息格式。
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 1. 用例说明
|
## 1. 用例说明
|
||||||
|
|
||||||
本文档补充第一版系统功能测试用例,面向验收测试、人工回归测试和后续 E2E 自动化改造。用例不依赖真实运营商 CMPP 网关;Gateway 场景使用项目模拟器、队列契约或测试替身。
|
本文档补充第一版系统功能测试用例,面向验收测试、人工回归测试和后续 E2E 自动化改造。系统功能用例必须依赖真实 NestJS API、Prisma/PostgreSQL、Redis/BullMQ、MinIO 或对应本地服务完成闭环;用例不依赖真实运营商 CMPP 网关,Gateway 场景使用 Go Gateway、本地 SMSC 模拟器、队列契约和连接状态回写 API 验证。
|
||||||
|
|
||||||
优先级定义:
|
优先级定义:
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
### TC-CLIENT-003 签名创建、材料上传与提交审核
|
### TC-CLIENT-003 签名创建、材料上传与提交审核
|
||||||
|
|
||||||
- 优先级:P0
|
- 优先级:P0
|
||||||
- 前置条件:MinIO 不可用时使用文件服务 mock 或跳过真实上传。
|
- 前置条件:MinIO 或等价对象存储测试服务可用;如不可用,本用例标记为阻塞或未执行,不得用文件服务 mock 作为验收通过依据。
|
||||||
- 步骤:
|
- 步骤:
|
||||||
1. 创建短信签名,填写名称、用途、引流信息。
|
1. 创建短信签名,填写名称、用途、引流信息。
|
||||||
2. 上传或登记签名证明材料。
|
2. 上传或登记签名证明材料。
|
||||||
@@ -2360,7 +2360,7 @@ npm run test:gateway
|
|||||||
npm run verify:phase8
|
npm run verify:phase8
|
||||||
```
|
```
|
||||||
|
|
||||||
若测试环境具备 PostgreSQL、Redis、MinIO,再补充执行 E2E smoke 和真实 API HTTP 测试。
|
测试环境必须具备 PostgreSQL、Redis、MinIO 或等价本地服务后,才能将 E2E smoke 和真实 API HTTP 测试记为系统功能通过;缺失时对应用例标记为阻塞或未执行。
|
||||||
|
|
||||||
## 17. 新增和更新用例细化执行清单
|
## 17. 新增和更新用例细化执行清单
|
||||||
|
|
||||||
@@ -2370,7 +2370,7 @@ npm run verify:phase8
|
|||||||
|
|
||||||
| 断言类型 | 检查点 |
|
| 断言类型 | 检查点 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 数据来源 | 页面列表、详情、统计卡片、弹窗和下拉选项均必须来自真实 API 响应;网络失败时可有兜底展示,但兜底不能计入通过。 |
|
| 数据来源 | 页面列表、详情、统计卡片、弹窗和下拉选项均必须来自真实 API 响应;网络失败时可以展示错误态或空态,但静态兜底数据不能计入通过。 |
|
||||||
| 租户隔离 | 客户端接口必须以当前租户为边界;通过 URL、查询参数或资源 id 访问其他租户数据时,应返回无权限、无数据或明确错误。 |
|
| 租户隔离 | 客户端接口必须以当前租户为边界;通过 URL、查询参数或资源 id 访问其他租户数据时,应返回无权限、无数据或明确错误。 |
|
||||||
| 状态联动 | 客户、应用、签名、模板、引流信息、通道、连接状态变化后,立即发送、定时到点、导入确认发送都必须重新校验。 |
|
| 状态联动 | 客户、应用、签名、模板、引流信息、通道、连接状态变化后,立即发送、定时到点、导入确认发送都必须重新校验。 |
|
||||||
| 日志证据 | 创建、编辑、删除、启停、复制、审核、导入、导出、充值、冲正、发送阻断、连接状态变化、失败动作都必须写系统日志。 |
|
| 日志证据 | 创建、编辑、删除、启停、复制、审核、导入、导出、充值、冲正、发送阻断、连接状态变化、失败动作都必须写系统日志。 |
|
||||||
@@ -2421,7 +2421,7 @@ npm run verify:phase8
|
|||||||
| TC-BILLING-006 | 运营端人工充值金额和短信条数,客户端查看 Dashboard 和账单流水。 | RechargeOrder 状态为 paid/manual_topup;TenantAccount 同步增加;AccountTransaction 类型 recharge;运营日志和客户端流水均可追溯。 |
|
| TC-BILLING-006 | 运营端人工充值金额和短信条数,客户端查看 Dashboard 和账单流水。 | RechargeOrder 状态为 paid/manual_topup;TenantAccount 同步增加;AccountTransaction 类型 recharge;运营日志和客户端流水均可追溯。 |
|
||||||
| TC-BILLING-007 | 分别只填金额、只填短信条数。 | 未填项按 0;金额和条数字段方向正确;不会产生 null、NaN 或负数脏数据。 |
|
| TC-BILLING-007 | 分别只填金额、只填短信条数。 | 未填项按 0;金额和条数字段方向正确;不会产生 null、NaN 或负数脏数据。 |
|
||||||
| TC-BILLING-008 | 对已充值记录执行撤销/冲正,分别覆盖未消费和已部分消费。 | 未消费可全额回退;已消费按规则拒绝或生成人工调整;原订单状态和反向流水清晰;日志记录原因。 |
|
| TC-BILLING-008 | 对已充值记录执行撤销/冲正,分别覆盖未消费和已部分消费。 | 未消费可全额回退;已消费按规则拒绝或生成人工调整;原订单状态和反向流水清晰;日志记录原因。 |
|
||||||
| TC-BILLING-009 | 无权限用户、审核员、管理员、大额审批分别执行充值。 | 权限不足被拒绝并写失败日志;大额充值 pending 时不更新余额;审批通过才入账,驳回不入账。 |
|
| TC-BILLING-009 | 无权限用户、审核员、管理员分别执行充值;大额人工充值不走审批。 | 权限不足被拒绝并写失败日志;有权限用户确认后立即入账;不产生 pending 审批态;充值订单、账户余额、流水和日志同步完成。 |
|
||||||
| TC-BILLING-010 | 余额不足发送失败,人工充值后重试发送并模拟 delivered。 | 充值前不扣费;充值后发送成功;冻结、扣费、短信计费记录完整;reconciliation diff 为 0。 |
|
| TC-BILLING-010 | 余额不足发送失败,人工充值后重试发送并模拟 delivered。 | 充值前不扣费;充值后发送成功;冻结、扣费、短信计费记录完整;reconciliation diff 为 0。 |
|
||||||
|
|
||||||
### 17.6 系统日志细化
|
### 17.6 系统日志细化
|
||||||
@@ -2482,3 +2482,20 @@ npm run verify:phase8
|
|||||||
| Go Gateway | 连接状态回写契约、登录成功/失败、心跳超时、断线重连、窗口占满、连接数调整。 |
|
| Go Gateway | 连接状态回写契约、登录成功/失败、心跳超时、断线重连、窗口占满、连接数调整。 |
|
||||||
| 前端 Smoke | 客户端头像菜单、系统日志分页、运营模板审核搜索、企业认证详情、通道复制/链接日志、安全控制 CRUD、Dashboard 指标跳转。 |
|
| 前端 Smoke | 客户端头像菜单、系统日志分页、运营模板审核搜索、企业认证详情、通道复制/链接日志、安全控制 CRUD、Dashboard 指标跳转。 |
|
||||||
| 性能 Smoke | BullMQ 500 TPS、CMPP 连接数变化后的提交能力、慢响应积压恢复。 |
|
| 性能 Smoke | BullMQ 500 TPS、CMPP 连接数变化后的提交能力、慢响应积压恢复。 |
|
||||||
|
|
||||||
|
### 17.9 登录和用户管理闭环
|
||||||
|
|
||||||
|
| 用例编号 | 操作 | 预期结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| TC-AUTH-001 | 打开 `/admin/login`,输入平台管理员邮箱或手机号、密码和正确图形验证码。 | 登录成功进入运营端;session 用户角色为 `platform_admin`;后续运营端 API 使用真实后端。 |
|
||||||
|
| TC-AUTH-002 | 使用企业管理员账号登录 `/admin/login`。 | 登录失败;返回“仅平台管理员可登录运营端”类错误;失败次数累计。 |
|
||||||
|
| TC-AUTH-003 | 打开 `/client/login`,输入已关联企业的企业管理员邮箱或手机号、密码和正确图形验证码。 | 登录成功进入客户端;客户端 API 自动携带当前企业 `tenantId`;Dashboard、用户、日志等仅展示当前企业数据。 |
|
||||||
|
| TC-AUTH-004 | 使用平台管理员或未关联企业的用户登录 `/client/login`。 | 登录失败;不进入客户端。 |
|
||||||
|
| TC-AUTH-005 | 同一用户连续输错密码 5 次。 | 第 5 次后用户锁定 24 小时;锁定期内正确密码也被拒绝;系统记录失败次数和锁定时间。 |
|
||||||
|
| TC-AUTH-006 | 输入错误或过期图形验证码登录。 | 返回 400 可读错误;必须刷新验证码后重试。 |
|
||||||
|
| TC-USER-ADMIN-001 | 运营端新增平台管理员,填写邮箱或手机号、初始密码。 | 创建成功;用户无 `tenantId`;可登录运营端;写 `user.created` 日志。 |
|
||||||
|
| TC-USER-ADMIN-002 | 运营端新增企业管理员但不选择企业。 | 返回 400;不创建用户。 |
|
||||||
|
| TC-USER-ADMIN-003 | 运营端新增企业管理员并选择企业。 | 创建成功;用户关联企业;可登录客户端;客户端数据按该企业隔离。 |
|
||||||
|
| TC-USER-ADMIN-004 | 运营端编辑用户、启用/禁用、删除、修改密码。 | 编辑和改密调用真实 API;启停/删除有确认弹窗;删除后列表不展示且不可登录;均写系统日志。 |
|
||||||
|
| TC-USER-CLIENT-001 | 企业管理员在客户端用户管理新增同企业用户。 | 创建成功;用户自动归属当前企业;不可创建平台管理员;写系统日志。 |
|
||||||
|
| TC-USER-CLIENT-002 | 客户端编辑、启用/禁用、删除、修改密码。 | 调用真实 `/api/client/users` API;仅影响当前企业用户;启停/删除有确认弹窗。 |
|
||||||
|
|||||||
@@ -325,3 +325,32 @@ GET /api/admin/system-logs?page=1&pageSize=2
|
|||||||
### 剩余说明
|
### 剩余说明
|
||||||
|
|
||||||
- 根 `npm run dev` 为稳定预览模式,不提供 Vite HMR;保留原因是 Vite 8/Rolldown dev transform 在当前 Windows + 中文路径工作区下会阻塞模块请求并造成白屏。开发时如需热更新,可另行评估降级 Vite 或迁移工作区路径后恢复原生 dev server。
|
- 根 `npm run dev` 为稳定预览模式,不提供 Vite HMR;保留原因是 Vite 8/Rolldown dev transform 在当前 Windows + 中文路径工作区下会阻塞模块请求并造成白屏。开发时如需热更新,可另行评估降级 Vite 或迁移工作区路径后恢复原生 dev server。
|
||||||
|
|
||||||
|
## 2026-07-02 登录与用户管理闭环补充
|
||||||
|
|
||||||
|
### 本轮修复范围
|
||||||
|
|
||||||
|
- 新增用户登录字段和 fail2ban 持久化字段:`email`、`phone`、`failedLoginCount`、`lockedUntil`、`lastLoginAt`、`deletedAt`。
|
||||||
|
- 登录入口拆分为 `/client/login` 和 `/admin/login`,两端均调用真实验证码和登录 API。
|
||||||
|
- 运营端登录仅允许 `platform_admin`;客户端登录仅允许已关联企业的 `enterprise_admin`。
|
||||||
|
- 运营端用户管理接入真实 `/api/admin/users`,支持平台管理员和企业管理员的新增、编辑、启用/禁用、删除、改密;企业管理员必须关联企业。
|
||||||
|
- 客户端用户管理接入真实 `/api/client/users`,所有操作继承当前登录企业 `tenantId`。
|
||||||
|
- 启用/禁用、删除均通过确认弹窗执行;用户删除采用软删除,不破坏历史日志和业务记录。
|
||||||
|
- 连续 5 次登录失败后锁定 24 小时;登录成功清空失败次数和锁定状态。
|
||||||
|
|
||||||
|
### 已执行测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm --prefix api test -- users.service.spec.ts auth.service.spec.ts
|
||||||
|
npm --prefix api run prisma:generate
|
||||||
|
npm --prefix api run build
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 当前结果
|
||||||
|
|
||||||
|
- `users.service.spec.ts`、`auth.service.spec.ts`:通过,覆盖用户类型约束、企业关联约束、操作日志、端登录隔离和失败次数累计。
|
||||||
|
- API build:通过。
|
||||||
|
- 前端 build:通过,仍有既有大 chunk warning。
|
||||||
|
- `tools/smoke/real-env-smoke.mjs` 已同步企业管理员邮箱/手机号、角色 seed、验证码登录和 CMPP 端口 `17890`。
|
||||||
|
- 真实数据库迁移、浏览器端登录 smoke 需要在生产验证环境执行 `prisma migrate deploy` 后补充记录。
|
||||||
|
|||||||
+73
-6
@@ -1,3 +1,5 @@
|
|||||||
|
import { getSessionTenantId, readSession, type LoginSession } from './session';
|
||||||
|
|
||||||
type RequestOptions = RequestInit & {
|
type RequestOptions = RequestInit & {
|
||||||
tenantId?: string;
|
tenantId?: string;
|
||||||
};
|
};
|
||||||
@@ -7,8 +9,13 @@ export const DEFAULT_CLIENT_TENANT_ID = 'tenant-a';
|
|||||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||||
const headers = new Headers(options.headers);
|
const headers = new Headers(options.headers);
|
||||||
headers.set('Content-Type', 'application/json');
|
headers.set('Content-Type', 'application/json');
|
||||||
if (options.tenantId) {
|
const session = readSession();
|
||||||
headers.set('x-tenant-id', options.tenantId);
|
if (session?.accessToken) {
|
||||||
|
headers.set('Authorization', `${session.tokenType} ${session.accessToken}`);
|
||||||
|
}
|
||||||
|
const tenantId = options.tenantId ?? (path.startsWith('/client') ? getSessionTenantId() : undefined);
|
||||||
|
if (tenantId) {
|
||||||
|
headers.set('x-tenant-id', tenantId);
|
||||||
}
|
}
|
||||||
const response = await fetch(`/api${path}`, { ...options, headers });
|
const response = await fetch(`/api${path}`, { ...options, headers });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -83,6 +90,40 @@ export type TenantOption = {
|
|||||||
status: string;
|
status: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CaptchaResponse = {
|
||||||
|
captchaId: string;
|
||||||
|
challenge: string;
|
||||||
|
expiresInSeconds: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ManagedUser = {
|
||||||
|
id: string;
|
||||||
|
tenantId?: string | null;
|
||||||
|
username: string;
|
||||||
|
email?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
displayName: string;
|
||||||
|
status: string;
|
||||||
|
failedLoginCount: number;
|
||||||
|
lockedUntil?: string | null;
|
||||||
|
lastLoginAt?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
tenant?: TenantOption | null;
|
||||||
|
roles: Array<{ role: { code: string; name: string; scope: string } }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserPayload = {
|
||||||
|
tenantId?: string | null;
|
||||||
|
username?: string;
|
||||||
|
email?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
displayName: string;
|
||||||
|
password?: string;
|
||||||
|
status?: string;
|
||||||
|
roleCode: 'platform_admin' | 'enterprise_admin';
|
||||||
|
operatorId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type DashboardResponse = {
|
export type DashboardResponse = {
|
||||||
taskCount: number;
|
taskCount: number;
|
||||||
messageStatus: Array<{ status: string; _count: { _all: number }; _sum: { amountCents?: number | null; billingUnits?: number | null } }>;
|
messageStatus: Array<{ status: string; _count: { _all: number }; _sum: { amountCents?: number | null; billingUnits?: number | null } }>;
|
||||||
@@ -223,7 +264,18 @@ function withQuery(path: string, query: Record<string, string | number | undefin
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const adminApi = {
|
export const adminApi = {
|
||||||
|
getCaptcha: () => request<CaptchaResponse>('/admin/auth/captcha'),
|
||||||
|
login: (body: { login: string; password: string; captchaId: string; captchaText: string }) =>
|
||||||
|
request<LoginSession>('/admin/auth/login', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
listTenants: () => request<TenantOption[]>('/admin/tenants'),
|
listTenants: () => request<TenantOption[]>('/admin/tenants'),
|
||||||
|
listUsers: (query: { tenantId?: string; roleCode?: string } = {}) => request<ManagedUser[]>(withQuery('/admin/users', query)),
|
||||||
|
createUser: (body: UserPayload) => request<ManagedUser>('/admin/users', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
updateUser: (id: string, body: Omit<UserPayload, 'password'>) => request<ManagedUser>(`/admin/users/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||||
|
changeUserStatus: (id: string, status: string, operatorId?: string) =>
|
||||||
|
request<ManagedUser>(`/admin/users/${id}/status`, { method: 'POST', body: JSON.stringify({ status, operatorId }) }),
|
||||||
|
deleteUser: (id: string, operatorId?: string) => request<ManagedUser>(`/admin/users/${id}`, { method: 'DELETE', body: JSON.stringify({ operatorId }) }),
|
||||||
|
changeUserPassword: (id: string, password: string, operatorId?: string) =>
|
||||||
|
request<ManagedUser>(`/admin/users/${id}/password`, { method: 'POST', body: JSON.stringify({ password, operatorId }) }),
|
||||||
getDashboard: (tenantId?: string) => request<DashboardResponse>(withQuery('/admin/operations/dashboard/statistics', { tenantId })),
|
getDashboard: (tenantId?: string) => request<DashboardResponse>(withQuery('/admin/operations/dashboard/statistics', { tenantId })),
|
||||||
listSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }) =>
|
listSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }) =>
|
||||||
request<OperationLogResponse>(withQuery('/admin/system-logs', query)),
|
request<OperationLogResponse>(withQuery('/admin/system-logs', query)),
|
||||||
@@ -293,12 +345,27 @@ export const adminApi = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const clientApi = {
|
export const clientApi = {
|
||||||
getDashboard: (tenantId = DEFAULT_CLIENT_TENANT_ID) =>
|
getCaptcha: () => request<CaptchaResponse>('/client/auth/captcha'),
|
||||||
|
login: (body: { login: string; password: string; captchaId: string; captchaText: string }) =>
|
||||||
|
request<LoginSession>('/client/auth/login', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
listUsers: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||||
|
request<ManagedUser[]>('/client/users', { tenantId }),
|
||||||
|
createUser: (body: UserPayload, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||||
|
request<ManagedUser>('/client/users', { method: 'POST', tenantId, body: JSON.stringify(body) }),
|
||||||
|
updateUser: (id: string, body: Omit<UserPayload, 'password'>, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||||
|
request<ManagedUser>(`/client/users/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }),
|
||||||
|
changeUserStatus: (id: string, status: string, operatorId?: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||||
|
request<ManagedUser>(`/client/users/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status, operatorId }) }),
|
||||||
|
deleteUser: (id: string, operatorId?: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||||
|
request<ManagedUser>(`/client/users/${id}`, { method: 'DELETE', tenantId, body: JSON.stringify({ operatorId }) }),
|
||||||
|
changeUserPassword: (id: string, password: string, operatorId?: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||||
|
request<ManagedUser>(`/client/users/${id}/password`, { method: 'POST', tenantId, body: JSON.stringify({ password, operatorId }) }),
|
||||||
|
getDashboard: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||||
request<DashboardResponse>('/client/operations/dashboard', { tenantId }),
|
request<DashboardResponse>('/client/operations/dashboard', { tenantId }),
|
||||||
listSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }, tenantId = DEFAULT_CLIENT_TENANT_ID) =>
|
listSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||||
request<OperationLogResponse>(withQuery('/client/operations/system-logs', query), { tenantId }),
|
request<OperationLogResponse>(withQuery('/client/operations/system-logs', query), { tenantId }),
|
||||||
listTransactions: (tenantId = DEFAULT_CLIENT_TENANT_ID) =>
|
listTransactions: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||||
request<AccountTransaction[]>('/client/billing/transactions', { tenantId }),
|
request<AccountTransaction[]>('/client/billing/transactions', { tenantId }),
|
||||||
listOrders: (tenantId = DEFAULT_CLIENT_TENANT_ID) =>
|
listOrders: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||||
request<RechargeOrder[]>('/client/billing/orders', { tenantId }),
|
request<RechargeOrder[]>('/client/billing/orders', { tenantId }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
export type Portal = 'admin' | 'client';
|
||||||
|
|
||||||
|
export type SessionUser = {
|
||||||
|
id: string;
|
||||||
|
tenantId?: string | null;
|
||||||
|
tenantName?: string | null;
|
||||||
|
username: string;
|
||||||
|
email?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
displayName: string;
|
||||||
|
roles: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoginSession = {
|
||||||
|
accessToken: string;
|
||||||
|
tokenType: string;
|
||||||
|
portal: Portal;
|
||||||
|
user: SessionUser;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sessionKey = 'cmpp-auth-session';
|
||||||
|
|
||||||
|
export function readSession(): LoginSession | null {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(sessionKey);
|
||||||
|
return raw ? JSON.parse(raw) as LoginSession : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeSession(session: LoginSession) {
|
||||||
|
window.localStorage.setItem(sessionKey, JSON.stringify(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSession() {
|
||||||
|
window.localStorage.removeItem(sessionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSessionTenantId() {
|
||||||
|
return readSession()?.user.tenantId ?? undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { ShieldCheck } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { adminApi, clientApi, type CaptchaResponse } from '@/api/adminApi';
|
||||||
|
import { writeSession, type Portal } from '@/api/session';
|
||||||
|
import { Button, Input } from '@/components/ui';
|
||||||
|
|
||||||
|
type LoginPageProps = {
|
||||||
|
portal: Portal;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LoginPage({ portal }: LoginPageProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [login, setLogin] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [captchaText, setCaptchaText] = useState('');
|
||||||
|
const [captcha, setCaptcha] = useState<CaptchaResponse | null>(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const isAdmin = portal === 'admin';
|
||||||
|
|
||||||
|
async function refreshCaptcha() {
|
||||||
|
setError('');
|
||||||
|
setCaptchaText('');
|
||||||
|
setCaptcha(await (isAdmin ? adminApi.getCaptcha() : clientApi.getCaptcha()));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshCaptcha();
|
||||||
|
}, [portal]);
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!captcha) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const session = await (isAdmin ? adminApi.login : clientApi.login)({
|
||||||
|
login,
|
||||||
|
password,
|
||||||
|
captchaId: captcha.captchaId,
|
||||||
|
captchaText,
|
||||||
|
});
|
||||||
|
writeSession(session);
|
||||||
|
navigate(isAdmin ? '/admin' : '/client', { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '登录失败');
|
||||||
|
await refreshCaptcha();
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="login-page">
|
||||||
|
<section className="login-panel">
|
||||||
|
<div className="login-brand">
|
||||||
|
<span><ShieldCheck size={28} /></span>
|
||||||
|
<div>
|
||||||
|
<h1>{isAdmin ? 'CMPP 运营端' : 'CMPP 客户端'}</h1>
|
||||||
|
<p>{isAdmin ? '平台管理员登录' : '企业管理员登录'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="login-form">
|
||||||
|
<Input label="邮箱或手机号" onChange={(event) => setLogin(event.target.value)} placeholder="请输入邮箱或手机号" value={login} />
|
||||||
|
<Input label="密码" onChange={(event) => setPassword(event.target.value)} placeholder="请输入密码" type="password" value={password} />
|
||||||
|
<div className="login-captcha-row">
|
||||||
|
<Input label="图形验证码" onChange={(event) => setCaptchaText(event.target.value)} placeholder="请输入计算结果" value={captchaText} />
|
||||||
|
<button className="login-captcha" onClick={() => void refreshCaptcha()} type="button">
|
||||||
|
{captcha?.challenge ?? '刷新'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error ? <p className="login-error">{error}</p> : null}
|
||||||
|
<Button disabled={loading} onClick={submit}>{loading ? '登录中...' : '登录'}</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
+183
-129
@@ -1,155 +1,166 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Plus, Search } from 'lucide-react';
|
import { KeyRound, Plus, Search } from 'lucide-react';
|
||||||
|
import { adminApi, type ManagedUser, type TenantOption, type UserPayload } from '@/api/adminApi';
|
||||||
|
import { readSession } from '@/api/session';
|
||||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||||
|
|
||||||
type UserStatus = 'enabled' | 'disabled';
|
type UserForm = {
|
||||||
|
tenantId: string;
|
||||||
type AdminUser = {
|
displayName: string;
|
||||||
id: string;
|
username: string;
|
||||||
name: string;
|
email: string;
|
||||||
account: string;
|
|
||||||
role: string;
|
|
||||||
phone: string;
|
phone: string;
|
||||||
status: UserStatus;
|
roleCode: 'platform_admin' | 'enterprise_admin';
|
||||||
createdAt: string;
|
status: string;
|
||||||
lastLogin: string;
|
password: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusLabelMap: Record<UserStatus, string> = {
|
type ConfirmAction = {
|
||||||
enabled: '启用',
|
type: 'status' | 'delete';
|
||||||
disabled: '停用',
|
user: ManagedUser;
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusToneMap: Record<UserStatus, 'success' | 'neutral'> = {
|
const emptyForm: UserForm = {
|
||||||
enabled: 'success',
|
tenantId: '',
|
||||||
disabled: 'neutral',
|
displayName: '',
|
||||||
};
|
username: '',
|
||||||
|
email: '',
|
||||||
const initialUsers: AdminUser[] = [
|
|
||||||
{ id: 'USR20260630001', name: '李明', account: 'liming', role: '平台管理员', phone: '13800001234', status: 'enabled', createdAt: '2026-06-01 09:20:10', lastLogin: '2026-06-30 09:15:22' },
|
|
||||||
{ id: 'USR20260630002', name: '张青', account: 'zhangqing', role: '审核专员', phone: '13900005678', status: 'enabled', createdAt: '2026-06-03 14:05:36', lastLogin: '2026-06-29 18:22:11' },
|
|
||||||
{ id: 'USR20260630003', name: '王珊', account: 'wangshan', role: '运营人员', phone: '13755558888', status: 'disabled', createdAt: '2026-06-08 11:12:48', lastLogin: '2026-06-21 10:08:09' },
|
|
||||||
{ id: 'USR20260630004', name: '赵一', account: 'zhaoyi', role: '财务人员', phone: '13677779999', status: 'enabled', createdAt: '2026-06-12 16:30:00', lastLogin: '2026-06-30 08:40:18' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function createUserId() {
|
|
||||||
return `USR${Date.now()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserFormModalProps = {
|
|
||||||
item?: AdminUser;
|
|
||||||
onClose: () => void;
|
|
||||||
onSubmit: (item: AdminUser) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function UserFormModal({ item, onClose, onSubmit }: UserFormModalProps) {
|
|
||||||
const [form, setForm] = useState<AdminUser>(() => item ?? {
|
|
||||||
id: createUserId(),
|
|
||||||
name: '',
|
|
||||||
account: '',
|
|
||||||
role: '运营人员',
|
|
||||||
phone: '',
|
phone: '',
|
||||||
status: 'enabled',
|
roleCode: 'platform_admin',
|
||||||
createdAt: '2026-06-30 10:00:00',
|
status: 'active',
|
||||||
lastLogin: '-',
|
password: '',
|
||||||
});
|
};
|
||||||
|
|
||||||
function updateField<Key extends keyof AdminUser>(key: Key, value: AdminUser[Key]) {
|
const roleLabel: Record<string, string> = {
|
||||||
setForm((current) => ({ ...current, [key]: value }));
|
platform_admin: '平台管理员',
|
||||||
}
|
enterprise_admin: '企业管理员',
|
||||||
|
};
|
||||||
|
|
||||||
function handleSubmit() {
|
function toForm(user?: ManagedUser): UserForm {
|
||||||
onSubmit(form);
|
const roleCode = user?.roles[0]?.role.code === 'enterprise_admin' ? 'enterprise_admin' : 'platform_admin';
|
||||||
}
|
return user ? {
|
||||||
|
tenantId: user.tenantId ?? '',
|
||||||
return (
|
displayName: user.displayName,
|
||||||
<Modal
|
username: user.username,
|
||||||
footer={(
|
email: user.email ?? '',
|
||||||
<>
|
phone: user.phone ?? '',
|
||||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
roleCode,
|
||||||
<Button onClick={handleSubmit}>保存</Button>
|
status: user.status,
|
||||||
</>
|
password: '',
|
||||||
)}
|
} : emptyForm;
|
||||||
onClose={onClose}
|
|
||||||
open
|
|
||||||
title={item ? '编辑用户' : '新增用户'}
|
|
||||||
>
|
|
||||||
<div className="admin-system-modal-form">
|
|
||||||
<Input label="用户姓名" onChange={(event) => updateField('name', event.target.value)} value={form.name} />
|
|
||||||
<Input label="登录账号" onChange={(event) => updateField('account', event.target.value)} value={form.account} />
|
|
||||||
<Select
|
|
||||||
label="角色"
|
|
||||||
onChange={(event) => updateField('role', event.target.value)}
|
|
||||||
options={[
|
|
||||||
{ label: '平台管理员', value: '平台管理员' },
|
|
||||||
{ label: '审核专员', value: '审核专员' },
|
|
||||||
{ label: '运营人员', value: '运营人员' },
|
|
||||||
{ label: '财务人员', value: '财务人员' },
|
|
||||||
]}
|
|
||||||
value={form.role}
|
|
||||||
/>
|
|
||||||
<Input label="手机号码" onChange={(event) => updateField('phone', event.target.value)} value={form.phone} />
|
|
||||||
<Select
|
|
||||||
label="状态"
|
|
||||||
onChange={(event) => updateField('status', event.target.value as UserStatus)}
|
|
||||||
options={[
|
|
||||||
{ label: '启用', value: 'enabled' },
|
|
||||||
{ label: '停用', value: 'disabled' },
|
|
||||||
]}
|
|
||||||
value={form.status}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdminUsersPage() {
|
export function AdminUsersPage() {
|
||||||
const [users, setUsers] = useState(initialUsers);
|
const session = readSession();
|
||||||
|
const [users, setUsers] = useState<ManagedUser[]>([]);
|
||||||
|
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
|
const [editingUser, setEditingUser] = useState<ManagedUser | null>(null);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [form, setForm] = useState<UserForm>(emptyForm);
|
||||||
|
const [passwordUser, setPasswordUser] = useState<ManagedUser | null>(null);
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const filteredUsers = useMemo(
|
async function load() {
|
||||||
() => users.filter((user) => [user.name, user.account, user.role, user.phone].some((value) => value.includes(keyword))),
|
const [nextUsers, nextTenants] = await Promise.all([adminApi.listUsers(), adminApi.listTenants()]);
|
||||||
[keyword, users],
|
setUsers(nextUsers);
|
||||||
);
|
setTenants(nextTenants);
|
||||||
|
|
||||||
function upsertUser(nextUser: AdminUser) {
|
|
||||||
setUsers((current) => {
|
|
||||||
const exists = current.some((item) => item.id === nextUser.id);
|
|
||||||
if (exists) {
|
|
||||||
return current.map((item) => (item.id === nextUser.id ? nextUser : item));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [nextUser, ...current];
|
useEffect(() => {
|
||||||
|
void load().catch((err) => setError(err instanceof Error ? err.message : '加载用户失败'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredUsers = useMemo(() => {
|
||||||
|
const value = keyword.trim().toLowerCase();
|
||||||
|
return users.filter((user) => {
|
||||||
|
const target = `${user.displayName} ${user.username} ${user.email ?? ''} ${user.phone ?? ''} ${user.tenant?.name ?? ''} ${roleLabel[user.roles[0]?.role.code] ?? ''}`.toLowerCase();
|
||||||
|
return !value || target.includes(value);
|
||||||
});
|
});
|
||||||
setEditingUser(null);
|
}, [keyword, users]);
|
||||||
setCreating(false);
|
|
||||||
|
function openCreate() {
|
||||||
|
setForm({ ...emptyForm, tenantId: tenants[0]?.id ?? '' });
|
||||||
|
setCreating(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = useMemo<Array<TableColumn<AdminUser>>>(() => [
|
function openEdit(user: ManagedUser) {
|
||||||
{ key: 'name', title: '用户姓名', width: '150px', render: (record) => <strong>{record.name}</strong> },
|
setForm(toForm(user));
|
||||||
{ key: 'account', title: '登录账号', width: '160px', render: (record) => record.account },
|
setEditingUser(user);
|
||||||
{ key: 'role', title: '角色', width: '150px', render: (record) => record.role },
|
}
|
||||||
{ key: 'phone', title: '手机号码', width: '150px', render: (record) => record.phone },
|
|
||||||
{ key: 'status', title: '状态', width: '120px', render: (record) => <Tag tone={statusToneMap[record.status]}>{statusLabelMap[record.status]}</Tag> },
|
function updateField<Key extends keyof UserForm>(key: Key, value: UserForm[Key]) {
|
||||||
{ key: 'createdAt', title: '创建时间', width: '190px', render: (record) => record.createdAt },
|
setForm((current) => {
|
||||||
{ key: 'lastLogin', title: '最近登录', width: '190px', render: (record) => record.lastLogin },
|
const next = { ...current, [key]: value };
|
||||||
|
if (key === 'roleCode' && value === 'platform_admin') {
|
||||||
|
next.tenantId = '';
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUser() {
|
||||||
|
setError('');
|
||||||
|
const body: UserPayload = {
|
||||||
|
tenantId: form.roleCode === 'enterprise_admin' ? form.tenantId : null,
|
||||||
|
username: form.username || form.email || form.phone,
|
||||||
|
email: form.email,
|
||||||
|
phone: form.phone,
|
||||||
|
displayName: form.displayName,
|
||||||
|
status: form.status,
|
||||||
|
roleCode: form.roleCode,
|
||||||
|
operatorId: session?.user.id,
|
||||||
|
};
|
||||||
|
if (creating) {
|
||||||
|
await adminApi.createUser({ ...body, password: form.password });
|
||||||
|
} else if (editingUser) {
|
||||||
|
await adminApi.updateUser(editingUser.id, body);
|
||||||
|
}
|
||||||
|
setCreating(false);
|
||||||
|
setEditingUser(null);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runConfirm() {
|
||||||
|
if (!confirmAction) return;
|
||||||
|
if (confirmAction.type === 'delete') {
|
||||||
|
await adminApi.deleteUser(confirmAction.user.id, session?.user.id);
|
||||||
|
} else {
|
||||||
|
await adminApi.changeUserStatus(confirmAction.user.id, confirmAction.user.status === 'active' ? 'disabled' : 'active', session?.user.id);
|
||||||
|
}
|
||||||
|
setConfirmAction(null);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePassword() {
|
||||||
|
if (!passwordUser) return;
|
||||||
|
await adminApi.changeUserPassword(passwordUser.id, newPassword, session?.user.id);
|
||||||
|
setPasswordUser(null);
|
||||||
|
setNewPassword('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = useMemo<Array<TableColumn<ManagedUser>>>(() => [
|
||||||
|
{ key: 'displayName', title: '用户姓名', width: '140px', render: (record) => <strong>{record.displayName}</strong> },
|
||||||
|
{ key: 'account', title: '邮箱/手机号', width: '230px', render: (record) => <span>{record.email ?? '-'}<br /><small className="muted">{record.phone ?? '-'}</small></span> },
|
||||||
|
{ key: 'role', title: '角色', width: '130px', render: (record) => roleLabel[record.roles[0]?.role.code] ?? record.roles[0]?.role.name ?? '-' },
|
||||||
|
{ key: 'tenant', title: '企业', width: '180px', render: (record) => record.tenant?.name ?? '-' },
|
||||||
|
{ key: 'status', title: '状态', width: '110px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status === 'active' ? '启用' : '禁用'}</Tag> },
|
||||||
|
{ key: 'lastLoginAt', title: '最近登录', width: '190px', render: (record) => record.lastLoginAt ? new Date(record.lastLoginAt).toLocaleString('zh-CN') : '-' },
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: '170px',
|
width: '280px',
|
||||||
align: 'right',
|
align: 'right',
|
||||||
render: (record) => (
|
render: (record) => (
|
||||||
<div className="admin-system-actions">
|
<div className="admin-system-actions">
|
||||||
<Button onClick={() => setEditingUser(record)} size="sm" variant="ghost">编辑</Button>
|
<Button onClick={() => openEdit(record)} size="sm" variant="ghost">编辑</Button>
|
||||||
<Button
|
<Button onClick={() => { setPasswordUser(record); setNewPassword(''); }} size="sm" variant="ghost">改密</Button>
|
||||||
onClick={() => setUsers((current) => current.filter((item) => item.id !== record.id))}
|
<Button onClick={() => setConfirmAction({ type: 'status', user: record })} size="sm" variant="ghost">
|
||||||
size="sm"
|
{record.status === 'active' ? '禁用' : '启用'}
|
||||||
variant="danger"
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button onClick={() => setConfirmAction({ type: 'delete', user: record })} size="sm" variant="danger">删除</Button>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -165,16 +176,59 @@ export function AdminUsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="surface admin-system-toolbar">
|
<div className="surface admin-system-toolbar">
|
||||||
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索姓名、账号、角色或手机号" prefix={<Search size={16} />} value={keyword} />
|
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索姓名、邮箱、手机号、角色或企业" prefix={<Search size={16} />} value={keyword} />
|
||||||
<Button icon={<Plus size={16} />} onClick={() => setCreating(true)}>新增用户</Button>
|
<Button icon={<Plus size={16} />} onClick={openCreate}>新增用户</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{error ? <div className="surface empty-state">{error}</div> : null}
|
||||||
<div className="surface admin-system-table-card">
|
<div className="surface admin-system-table-card">
|
||||||
<Table columns={columns} data={filteredUsers} emptyText="暂无用户" rowKey="id" />
|
<Table columns={columns} data={filteredUsers} emptyText="暂无用户" rowKey="id" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{creating ? <UserFormModal onClose={() => setCreating(false)} onSubmit={upsertUser} /> : null}
|
{(creating || editingUser) ? (
|
||||||
{editingUser ? <UserFormModal item={editingUser} onClose={() => setEditingUser(null)} onSubmit={upsertUser} /> : null}
|
<Modal
|
||||||
|
footer={<><Button onClick={() => { setCreating(false); setEditingUser(null); }} variant="ghost">取消</Button><Button onClick={() => void saveUser()}>保存</Button></>}
|
||||||
|
onClose={() => { setCreating(false); setEditingUser(null); }}
|
||||||
|
open
|
||||||
|
title={creating ? '新增用户' : '编辑用户'}
|
||||||
|
>
|
||||||
|
<div className="admin-system-modal-form">
|
||||||
|
<Input label="用户姓名" onChange={(event) => updateField('displayName', event.target.value)} value={form.displayName} />
|
||||||
|
<Input label="邮箱" onChange={(event) => updateField('email', event.target.value)} value={form.email} />
|
||||||
|
<Input label="手机号" onChange={(event) => updateField('phone', event.target.value)} value={form.phone} />
|
||||||
|
<Input label="登录账号" onChange={(event) => updateField('username', event.target.value)} value={form.username} />
|
||||||
|
<Select
|
||||||
|
label="用户类型"
|
||||||
|
onChange={(event) => updateField('roleCode', event.target.value as UserForm['roleCode'])}
|
||||||
|
options={[{ label: '平台管理员', value: 'platform_admin' }, { label: '企业管理员', value: 'enterprise_admin' }]}
|
||||||
|
value={form.roleCode}
|
||||||
|
/>
|
||||||
|
{form.roleCode === 'enterprise_admin' ? (
|
||||||
|
<Select
|
||||||
|
label="关联企业"
|
||||||
|
onChange={(event) => updateField('tenantId', event.target.value)}
|
||||||
|
options={tenants.map((tenant) => ({ label: tenant.name, value: tenant.id }))}
|
||||||
|
value={form.tenantId}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{creating ? <Input label="初始密码" onChange={(event) => updateField('password', event.target.value)} type="password" value={form.password} /> : null}
|
||||||
|
<Select label="状态" onChange={(event) => updateField('status', event.target.value)} options={[{ label: '启用', value: 'active' }, { label: '禁用', value: 'disabled' }]} value={form.status} />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{passwordUser ? (
|
||||||
|
<Modal footer={<><Button onClick={() => setPasswordUser(null)} variant="ghost">取消</Button><Button icon={<KeyRound size={16} />} onClick={() => void savePassword()}>保存</Button></>} onClose={() => setPasswordUser(null)} open title="修改密码">
|
||||||
|
<div className="admin-system-modal-form">
|
||||||
|
<Input label="新密码" onChange={(event) => setNewPassword(event.target.value)} type="password" value={newPassword} />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{confirmAction ? (
|
||||||
|
<Modal footer={<><Button onClick={() => setConfirmAction(null)} variant="ghost">取消</Button><Button onClick={() => void runConfirm()} variant={confirmAction.type === 'delete' ? 'danger' : 'primary'}>确认</Button></>} onClose={() => setConfirmAction(null)} open title={confirmAction.type === 'delete' ? '删除用户' : '变更用户状态'}>
|
||||||
|
<p>{confirmAction.type === 'delete' ? `确认删除用户 ${confirmAction.user.displayName}?` : `确认${confirmAction.user.status === 'active' ? '禁用' : '启用'}用户 ${confirmAction.user.displayName}?`}</p>
|
||||||
|
</Modal>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+140
-107
@@ -1,95 +1,138 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Edit3, Plus, Search, Trash2, Users } from 'lucide-react';
|
import { Edit3, KeyRound, Plus, Search, Trash2, Users } from 'lucide-react';
|
||||||
import {
|
import { clientApi, type ManagedUser, type UserPayload } from '@/api/adminApi';
|
||||||
Button,
|
import { readSession } from '@/api/session';
|
||||||
Input,
|
import { Button, Input, Modal, Pagination, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||||
Modal,
|
|
||||||
Pagination,
|
|
||||||
Select,
|
|
||||||
Table,
|
|
||||||
Tag,
|
|
||||||
type TableColumn,
|
|
||||||
} from '@/components/ui';
|
|
||||||
|
|
||||||
type UserRole = 'enterprise_admin' | 'user';
|
type UserForm = {
|
||||||
type UserStatus = 'active' | 'disabled';
|
displayName: string;
|
||||||
|
username: string;
|
||||||
type ClientUser = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
email: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
role: UserRole;
|
status: string;
|
||||||
status: UserStatus;
|
password: string;
|
||||||
lastLoginAt: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const roleLabelMap: Record<UserRole, string> = {
|
type ConfirmAction = {
|
||||||
enterprise_admin: '企业管理员',
|
type: 'status' | 'delete';
|
||||||
user: '普通用户',
|
user: ManagedUser;
|
||||||
};
|
};
|
||||||
|
|
||||||
const usersSeed: ClientUser[] = [
|
const emptyForm: UserForm = {
|
||||||
{ id: 'USER001', name: '张三', email: 'zhangsan@example.com', phone: '13800138000', role: 'enterprise_admin', status: 'active', lastLoginAt: '2026-03-17 09:15:00' },
|
displayName: '',
|
||||||
{ id: 'USER002', name: '李四', email: 'lisi@example.com', phone: '13800138001', role: 'user', status: 'active', lastLoginAt: '2026-03-16 16:45:00' },
|
username: '',
|
||||||
{ id: 'USER003', name: '王五', email: 'wangwu@example.com', phone: '13800138002', role: 'user', status: 'active', lastLoginAt: '2026-03-15 11:30:00' },
|
|
||||||
{ id: 'USER004', name: '赵六', email: 'zhaoliu@example.com', phone: '13800138003', role: 'user', status: 'disabled', lastLoginAt: '2026-02-20 14:00:00' },
|
|
||||||
{ id: 'USER005', name: '孙七', email: 'sunqi@example.com', phone: '13800138004', role: 'user', status: 'active', lastLoginAt: '2026-03-17 08:00:00' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const emptyUser: ClientUser = {
|
|
||||||
id: 'NEW',
|
|
||||||
name: '',
|
|
||||||
email: '',
|
email: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
role: 'user',
|
|
||||||
status: 'active',
|
status: 'active',
|
||||||
lastLoginAt: '-',
|
password: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function toForm(user?: ManagedUser): UserForm {
|
||||||
|
return user ? {
|
||||||
|
displayName: user.displayName,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email ?? '',
|
||||||
|
phone: user.phone ?? '',
|
||||||
|
status: user.status,
|
||||||
|
password: '',
|
||||||
|
} : emptyForm;
|
||||||
|
}
|
||||||
|
|
||||||
export function ClientUsersPage() {
|
export function ClientUsersPage() {
|
||||||
|
const session = readSession();
|
||||||
|
const tenantId = session?.user.tenantId ?? undefined;
|
||||||
|
const [users, setUsers] = useState<ManagedUser[]>([]);
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [editingUser, setEditingUser] = useState<ClientUser | null>(null);
|
const [editingUser, setEditingUser] = useState<ManagedUser | null>(null);
|
||||||
const [draft, setDraft] = useState<ClientUser>(emptyUser);
|
const [creating, setCreating] = useState(false);
|
||||||
const enterpriseAdmin = usersSeed.find((item) => item.role === 'enterprise_admin');
|
const [form, setForm] = useState<UserForm>(emptyForm);
|
||||||
const canSelectEnterpriseAdmin = !enterpriseAdmin || editingUser?.id === enterpriseAdmin.id;
|
const [passwordUser, setPasswordUser] = useState<ManagedUser | null>(null);
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const filteredUsers = usersSeed.filter((item) => {
|
async function load() {
|
||||||
const target = `${item.name} ${item.email} ${item.phone}`;
|
if (!tenantId) return;
|
||||||
return !keyword || target.toLowerCase().includes(keyword.toLowerCase());
|
setUsers(await clientApi.listUsers(tenantId));
|
||||||
});
|
|
||||||
|
|
||||||
function openEditor(user?: ClientUser) {
|
|
||||||
const nextUser = user ?? emptyUser;
|
|
||||||
setEditingUser(nextUser);
|
|
||||||
setDraft(nextUser);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = useMemo<Array<TableColumn<ClientUser>>>(() => [
|
useEffect(() => {
|
||||||
{ key: 'name', title: '用户名', width: '120px', render: (record) => <strong className="text-strong">{record.name}</strong> },
|
void load().catch((err) => setError(err instanceof Error ? err.message : '加载用户失败'));
|
||||||
{ key: 'email', title: '邮箱', render: (record) => <span className="muted">{record.email}</span> },
|
}, [tenantId]);
|
||||||
{ key: 'phone', title: '手机号', width: '180px', render: (record) => <span className="muted">{record.phone}</span> },
|
|
||||||
{
|
const filteredUsers = useMemo(() => {
|
||||||
key: 'role',
|
const value = keyword.trim().toLowerCase();
|
||||||
title: '角色',
|
return users.filter((item) => {
|
||||||
width: '150px',
|
const target = `${item.displayName} ${item.email ?? ''} ${item.phone ?? ''}`.toLowerCase();
|
||||||
render: (record) => <Tag tone={record.role === 'enterprise_admin' ? 'info' : 'success'}>{roleLabelMap[record.role]}</Tag>,
|
return !value || target.includes(value);
|
||||||
},
|
});
|
||||||
{
|
}, [keyword, users]);
|
||||||
key: 'status',
|
|
||||||
title: '状态',
|
function openEditor(user?: ManagedUser) {
|
||||||
width: '130px',
|
setForm(toForm(user));
|
||||||
render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status === 'active' ? '正常' : '禁用'}</Tag>,
|
setEditingUser(user ?? null);
|
||||||
},
|
setCreating(!user);
|
||||||
{ key: 'lastLoginAt', title: '最后登录时间', width: '210px', render: (record) => <span className="muted">{record.lastLoginAt}</span> },
|
}
|
||||||
|
|
||||||
|
function updateField<Key extends keyof UserForm>(key: Key, value: UserForm[Key]) {
|
||||||
|
setForm((current) => ({ ...current, [key]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUser() {
|
||||||
|
const body: UserPayload = {
|
||||||
|
displayName: form.displayName,
|
||||||
|
username: form.username || form.email || form.phone,
|
||||||
|
email: form.email,
|
||||||
|
phone: form.phone,
|
||||||
|
status: form.status,
|
||||||
|
roleCode: 'enterprise_admin',
|
||||||
|
operatorId: session?.user.id,
|
||||||
|
};
|
||||||
|
if (creating) {
|
||||||
|
await clientApi.createUser({ ...body, password: form.password }, tenantId);
|
||||||
|
} else if (editingUser) {
|
||||||
|
await clientApi.updateUser(editingUser.id, body, tenantId);
|
||||||
|
}
|
||||||
|
setCreating(false);
|
||||||
|
setEditingUser(null);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runConfirm() {
|
||||||
|
if (!confirmAction) return;
|
||||||
|
if (confirmAction.type === 'delete') {
|
||||||
|
await clientApi.deleteUser(confirmAction.user.id, session?.user.id, tenantId);
|
||||||
|
} else {
|
||||||
|
await clientApi.changeUserStatus(confirmAction.user.id, confirmAction.user.status === 'active' ? 'disabled' : 'active', session?.user.id, tenantId);
|
||||||
|
}
|
||||||
|
setConfirmAction(null);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePassword() {
|
||||||
|
if (!passwordUser) return;
|
||||||
|
await clientApi.changeUserPassword(passwordUser.id, newPassword, session?.user.id, tenantId);
|
||||||
|
setPasswordUser(null);
|
||||||
|
setNewPassword('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = useMemo<Array<TableColumn<ManagedUser>>>(() => [
|
||||||
|
{ key: 'name', title: '用户名', width: '140px', render: (record) => <strong className="text-strong">{record.displayName}</strong> },
|
||||||
|
{ key: 'email', title: '邮箱', render: (record) => <span className="muted">{record.email ?? '-'}</span> },
|
||||||
|
{ key: 'phone', title: '手机号', width: '160px', render: (record) => <span className="muted">{record.phone ?? '-'}</span> },
|
||||||
|
{ key: 'role', title: '角色', width: '130px', render: () => <Tag tone="info">企业管理员</Tag> },
|
||||||
|
{ key: 'status', title: '状态', width: '120px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status === 'active' ? '正常' : '禁用'}</Tag> },
|
||||||
|
{ key: 'lastLoginAt', title: '最后登录时间', width: '190px', render: (record) => <span className="muted">{record.lastLoginAt ? new Date(record.lastLoginAt).toLocaleString('zh-CN') : '-'}</span> },
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: '170px',
|
width: '290px',
|
||||||
render: (record) => (
|
render: (record) => (
|
||||||
<div className="inline-actions">
|
<div className="inline-actions">
|
||||||
<Button icon={<Edit3 size={15} />} onClick={() => openEditor(record)} size="sm" variant="ghost">编辑</Button>
|
<Button icon={<Edit3 size={15} />} onClick={() => openEditor(record)} size="sm" variant="ghost">编辑</Button>
|
||||||
<Button icon={<Trash2 size={15} />} size="sm" variant="danger">删除</Button>
|
<Button icon={<KeyRound size={15} />} onClick={() => { setPasswordUser(record); setNewPassword(''); }} size="sm" variant="ghost">改密</Button>
|
||||||
|
<Button onClick={() => setConfirmAction({ type: 'status', user: record })} size="sm" variant="ghost">{record.status === 'active' ? '禁用' : '启用'}</Button>
|
||||||
|
<Button icon={<Trash2 size={15} />} onClick={() => setConfirmAction({ type: 'delete', user: record })} size="sm" variant="danger">删除</Button>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -106,56 +149,46 @@ export function ClientUsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="system-filter-row">
|
<div className="system-filter-row">
|
||||||
<Input
|
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索用户名、邮箱或手机号" prefix={<Search size={16} />} value={keyword} />
|
||||||
onChange={(event) => setKeyword(event.target.value)}
|
|
||||||
placeholder="搜索用户名、邮箱或手机号"
|
|
||||||
prefix={<Search size={16} />}
|
|
||||||
value={keyword}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
{error ? <div className="surface empty-state">{error}</div> : null}
|
||||||
<div className="surface system-table-card">
|
<div className="surface system-table-card">
|
||||||
<Table columns={columns} data={filteredUsers} emptyText="暂无用户" rowKey="id" />
|
<Table columns={columns} data={filteredUsers} emptyText="暂无用户" rowKey="id" />
|
||||||
<Pagination total={filteredUsers.length} page={1} />
|
<Pagination total={filteredUsers.length} page={1} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(creating || editingUser) ? (
|
||||||
<Modal
|
<Modal
|
||||||
footer={(
|
footer={<><Button onClick={() => { setCreating(false); setEditingUser(null); }} variant="secondary">取消</Button><Button onClick={() => void saveUser()}>保存</Button></>}
|
||||||
<>
|
onClose={() => { setCreating(false); setEditingUser(null); }}
|
||||||
<Button onClick={() => setEditingUser(null)} variant="secondary">取消</Button>
|
open
|
||||||
<Button onClick={() => setEditingUser(null)}>保存</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
onClose={() => setEditingUser(null)}
|
|
||||||
open={Boolean(editingUser)}
|
|
||||||
size="xl"
|
size="xl"
|
||||||
title={editingUser?.id === 'NEW' ? '添加用户' : '编辑用户'}
|
title={creating ? '添加用户' : '编辑用户'}
|
||||||
>
|
>
|
||||||
<div className="system-user-form">
|
<div className="system-user-form">
|
||||||
<Input label="用户名 *" onChange={(event) => setDraft({ ...draft, name: event.target.value })} placeholder="请输入用户名" value={draft.name} />
|
<Input label="用户名 *" onChange={(event) => updateField('displayName', event.target.value)} placeholder="请输入用户名" value={form.displayName} />
|
||||||
<Input label="邮箱 *" onChange={(event) => setDraft({ ...draft, email: event.target.value })} placeholder="请输入邮箱" value={draft.email} />
|
<Input label="邮箱 *" onChange={(event) => updateField('email', event.target.value)} placeholder="请输入邮箱" value={form.email} />
|
||||||
<Input label="手机号 *" onChange={(event) => setDraft({ ...draft, phone: event.target.value })} placeholder="请输入手机号" value={draft.phone} />
|
<Input label="手机号 *" onChange={(event) => updateField('phone', event.target.value)} placeholder="请输入手机号" value={form.phone} />
|
||||||
<Select
|
<Input label="登录账号" onChange={(event) => updateField('username', event.target.value)} value={form.username} />
|
||||||
hint={canSelectEnterpriseAdmin ? '企业管理员拥有企业空间最高权限。' : `当前企业管理员为 ${enterpriseAdmin?.name},每个企业仅允许 1 位企业管理员。`}
|
{creating ? <Input label="初始密码 *" onChange={(event) => updateField('password', event.target.value)} type="password" value={form.password} /> : null}
|
||||||
label="角色 *"
|
<Select label="状态 *" onChange={(event) => updateField('status', event.target.value)} options={[{ label: '正常', value: 'active' }, { label: '禁用', value: 'disabled' }]} value={form.status} />
|
||||||
onChange={(event) => setDraft({ ...draft, role: event.target.value as UserRole })}
|
|
||||||
options={[
|
|
||||||
...(canSelectEnterpriseAdmin ? [{ label: '企业管理员', value: 'enterprise_admin' }] : []),
|
|
||||||
{ label: '普通用户', value: 'user' },
|
|
||||||
]}
|
|
||||||
value={draft.role}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
label="状态 *"
|
|
||||||
onChange={(event) => setDraft({ ...draft, status: event.target.value as UserStatus })}
|
|
||||||
options={[
|
|
||||||
{ label: '正常', value: 'active' },
|
|
||||||
{ label: '禁用', value: 'disabled' },
|
|
||||||
]}
|
|
||||||
value={draft.status}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{passwordUser ? (
|
||||||
|
<Modal footer={<><Button onClick={() => setPasswordUser(null)} variant="secondary">取消</Button><Button onClick={() => void savePassword()}>保存</Button></>} onClose={() => setPasswordUser(null)} open title="修改密码">
|
||||||
|
<div className="system-user-form">
|
||||||
|
<Input label="新密码" onChange={(event) => setNewPassword(event.target.value)} type="password" value={newPassword} />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{confirmAction ? (
|
||||||
|
<Modal footer={<><Button onClick={() => setConfirmAction(null)} variant="secondary">取消</Button><Button onClick={() => void runConfirm()} variant={confirmAction.type === 'delete' ? 'danger' : 'primary'}>确认</Button></>} onClose={() => setConfirmAction(null)} open title={confirmAction.type === 'delete' ? '删除用户' : '变更用户状态'}>
|
||||||
|
<p>{confirmAction.type === 'delete' ? `确认删除用户 ${confirmAction.user.displayName}?` : `确认${confirmAction.user.status === 'active' ? '禁用' : '启用'}用户 ${confirmAction.user.displayName}?`}</p>
|
||||||
|
</Modal>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,15 +24,23 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
UserX,
|
UserX,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import { readSession } from '@/api/session';
|
||||||
import { AppShell } from '@/layouts/AppShell';
|
import { AppShell } from '@/layouts/AppShell';
|
||||||
|
|
||||||
export function AdminLayout() {
|
export function AdminLayout() {
|
||||||
|
const session = readSession();
|
||||||
|
if (session?.portal !== 'admin') {
|
||||||
|
return <Navigate to="/admin/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
title="CMPP 运营端"
|
title="CMPP 运营端"
|
||||||
subtitle="平台运营管理中心"
|
subtitle="平台运营管理中心"
|
||||||
workspaceName="平台运营工作区"
|
workspaceName="平台运营工作区"
|
||||||
userName="运营"
|
loginPath="/admin/login"
|
||||||
|
userName={session.user.displayName}
|
||||||
userRole="平台管理员"
|
userRole="平台管理员"
|
||||||
auditNotifications={[
|
auditNotifications={[
|
||||||
{ label: '企业认证审核', count: 3, to: '/admin/enterprise-audit' },
|
{ label: '企业认证审核', count: 3, to: '/admin/enterprise-audit' },
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { NavLink, Outlet } from 'react-router-dom';
|
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||||
|
import { clearSession } from '@/api/session';
|
||||||
|
|
||||||
export type ShellNavItem = {
|
export type ShellNavItem = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -37,6 +38,7 @@ type AppShellProps = {
|
|||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
workspaceName: string;
|
workspaceName: string;
|
||||||
|
loginPath: string;
|
||||||
userName: string;
|
userName: string;
|
||||||
userRole: string;
|
userRole: string;
|
||||||
navSections: ShellNavSection[];
|
navSections: ShellNavSection[];
|
||||||
@@ -47,6 +49,7 @@ export function AppShell({
|
|||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
workspaceName,
|
workspaceName,
|
||||||
|
loginPath,
|
||||||
userName,
|
userName,
|
||||||
userRole,
|
userRole,
|
||||||
navSections,
|
navSections,
|
||||||
@@ -56,6 +59,7 @@ export function AppShell({
|
|||||||
const [closedSections, setClosedSections] = useState<Record<string, boolean>>({});
|
const [closedSections, setClosedSections] = useState<Record<string, boolean>>({});
|
||||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||||
const [noticeOpen, setNoticeOpen] = useState(false);
|
const [noticeOpen, setNoticeOpen] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
const ToggleIcon = collapsed ? PanelLeftOpen : PanelLeftClose;
|
const ToggleIcon = collapsed ? PanelLeftOpen : PanelLeftClose;
|
||||||
const auditTotal = useMemo(
|
const auditTotal = useMemo(
|
||||||
() => auditNotifications.reduce((sum, item) => sum + item.count, 0),
|
() => auditNotifications.reduce((sum, item) => sum + item.count, 0),
|
||||||
@@ -205,7 +209,11 @@ export function AppShell({
|
|||||||
<KeyRound size={16} />
|
<KeyRound size={16} />
|
||||||
修改密码
|
修改密码
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setUserMenuOpen(false)} role="menuitem" type="button">
|
<button onClick={() => {
|
||||||
|
clearSession();
|
||||||
|
setUserMenuOpen(false);
|
||||||
|
navigate(loginPath, { replace: true });
|
||||||
|
}} role="menuitem" type="button">
|
||||||
<LogOut size={16} />
|
<LogOut size={16} />
|
||||||
退出登录
|
退出登录
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -10,15 +10,23 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Users,
|
Users,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import { readSession } from '@/api/session';
|
||||||
import { AppShell } from '@/layouts/AppShell';
|
import { AppShell } from '@/layouts/AppShell';
|
||||||
|
|
||||||
export function ClientLayout() {
|
export function ClientLayout() {
|
||||||
|
const session = readSession();
|
||||||
|
if (session?.portal !== 'client') {
|
||||||
|
return <Navigate to="/client/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
title="CMPP 客户端"
|
title="CMPP 客户端"
|
||||||
subtitle="短信服务控制台"
|
subtitle="短信服务控制台"
|
||||||
workspaceName="华东一区客户空间"
|
workspaceName={session.user.tenantName ?? '企业客户空间'}
|
||||||
userName="赵先生"
|
loginPath="/client/login"
|
||||||
|
userName={session.user.displayName}
|
||||||
userRole="企业管理员"
|
userRole="企业管理员"
|
||||||
navSections={[
|
navSections={[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import { ClientSystemLogsPage } from '@/apps/client/ClientSystemLogsPage';
|
|||||||
import { ClientTemplatesPage } from '@/apps/client/ClientTemplatesPage';
|
import { ClientTemplatesPage } from '@/apps/client/ClientTemplatesPage';
|
||||||
import { ClientUplinkMessagesPage } from '@/apps/client/ClientUplinkMessagesPage';
|
import { ClientUplinkMessagesPage } from '@/apps/client/ClientUplinkMessagesPage';
|
||||||
import { ClientUsersPage } from '@/apps/client/ClientUsersPage';
|
import { ClientUsersPage } from '@/apps/client/ClientUsersPage';
|
||||||
|
import { LoginPage } from '@/apps/LoginPage';
|
||||||
import { PagePlaceholder } from '@/components/PagePlaceholder';
|
import { PagePlaceholder } from '@/components/PagePlaceholder';
|
||||||
import { AdminLayout } from '@/layouts/AdminLayout';
|
import { AdminLayout } from '@/layouts/AdminLayout';
|
||||||
import { ClientLayout } from '@/layouts/ClientLayout';
|
import { ClientLayout } from '@/layouts/ClientLayout';
|
||||||
@@ -64,6 +65,8 @@ export function AppRoutes() {
|
|||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/client" replace />} />
|
<Route path="/" element={<Navigate to="/client" replace />} />
|
||||||
|
<Route path="/client/login" element={<LoginPage portal="client" />} />
|
||||||
|
<Route path="/admin/login" element={<LoginPage portal="admin" />} />
|
||||||
<Route path="/client" element={<ClientLayout />}>
|
<Route path="/client" element={<ClientLayout />}>
|
||||||
<Route index element={<ClientHome />} />
|
<Route index element={<ClientHome />} />
|
||||||
<Route path="send" element={<ClientSendPage />} />
|
<Route path="send" element={<ClientSendPage />} />
|
||||||
|
|||||||
@@ -3856,6 +3856,82 @@ h3 {
|
|||||||
margin-top: var(--space-2);
|
margin-top: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 32px;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(37, 99, 235, 0.08), rgba(16, 185, 129, 0.08)),
|
||||||
|
var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-panel {
|
||||||
|
width: min(440px, 100%);
|
||||||
|
padding: 28px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand > span {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--color-primary);
|
||||||
|
background: var(--color-primary-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-captcha-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 136px;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-captcha {
|
||||||
|
height: 40px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-surface-muted);
|
||||||
|
color: var(--color-text-strong);
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--color-danger);
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
.system-page {
|
.system-page {
|
||||||
gap: 28px;
|
gap: 28px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ const prisma = new PrismaClient({
|
|||||||
const apiBaseUrl = process.env.API_BASE_URL ?? 'http://127.0.0.1:3101/api';
|
const apiBaseUrl = process.env.API_BASE_URL ?? 'http://127.0.0.1:3101/api';
|
||||||
const tenantCode = 'smoke-tenant';
|
const tenantCode = 'smoke-tenant';
|
||||||
const username = 'smoke_client';
|
const username = 'smoke_client';
|
||||||
|
const email = 'smoke-client@example.com';
|
||||||
|
const phone = '13800138099';
|
||||||
const password = 'SmokePass123!';
|
const password = 'SmokePass123!';
|
||||||
|
|
||||||
function hashPassword(value) {
|
function hashPassword(value) {
|
||||||
@@ -27,19 +29,37 @@ async function ensureSmokeData() {
|
|||||||
where: { username },
|
where: { username },
|
||||||
update: {
|
update: {
|
||||||
tenantId: tenant.id,
|
tenantId: tenant.id,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
displayName: 'Smoke Client Admin',
|
displayName: 'Smoke Client Admin',
|
||||||
passwordHash: hashPassword(password),
|
passwordHash: hashPassword(password),
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
failedLoginCount: 0,
|
||||||
|
lockedUntil: null,
|
||||||
|
deletedAt: null,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
tenantId: tenant.id,
|
tenantId: tenant.id,
|
||||||
username,
|
username,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
displayName: 'Smoke Client Admin',
|
displayName: 'Smoke Client Admin',
|
||||||
passwordHash: hashPassword(password),
|
passwordHash: hashPassword(password),
|
||||||
status: 'active',
|
status: 'active',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const enterpriseAdminRole = await prisma.role.upsert({
|
||||||
|
where: { code: 'enterprise_admin' },
|
||||||
|
update: { name: '企业管理员', scope: 'tenant' },
|
||||||
|
create: { code: 'enterprise_admin', name: '企业管理员', scope: 'tenant' },
|
||||||
|
});
|
||||||
|
await prisma.userRole.upsert({
|
||||||
|
where: { userId_roleId: { userId: user.id, roleId: enterpriseAdminRole.id } },
|
||||||
|
update: {},
|
||||||
|
create: { userId: user.id, roleId: enterpriseAdminRole.id },
|
||||||
|
});
|
||||||
|
|
||||||
await prisma.tenantAccount.upsert({
|
await prisma.tenantAccount.upsert({
|
||||||
where: { tenantId: tenant.id },
|
where: { tenantId: tenant.id },
|
||||||
update: { balanceCents: { increment: 0 }, smsUnits: { increment: 0 }, creditCents: 5000, status: 'active' },
|
update: { balanceCents: { increment: 0 }, smsUnits: { increment: 0 }, creditCents: 5000, status: 'active' },
|
||||||
@@ -51,7 +71,7 @@ async function ensureSmokeData() {
|
|||||||
update: {
|
update: {
|
||||||
name: 'Smoke CMPP Channel',
|
name: 'Smoke CMPP Channel',
|
||||||
gatewayHost: '127.0.0.1',
|
gatewayHost: '127.0.0.1',
|
||||||
gatewayPort: 7890,
|
gatewayPort: 17890,
|
||||||
account: 'smoke-account',
|
account: 'smoke-account',
|
||||||
passwordCipher: 'smoke-password',
|
passwordCipher: 'smoke-password',
|
||||||
srcId: '10690000',
|
srcId: '10690000',
|
||||||
@@ -65,7 +85,7 @@ async function ensureSmokeData() {
|
|||||||
carrier: 'all',
|
carrier: 'all',
|
||||||
protocol: 'CMPP',
|
protocol: 'CMPP',
|
||||||
gatewayHost: '127.0.0.1',
|
gatewayHost: '127.0.0.1',
|
||||||
gatewayPort: 7890,
|
gatewayPort: 17890,
|
||||||
account: 'smoke-account',
|
account: 'smoke-account',
|
||||||
passwordCipher: 'smoke-password',
|
passwordCipher: 'smoke-password',
|
||||||
srcId: '10690000',
|
srcId: '10690000',
|
||||||
@@ -180,9 +200,11 @@ async function run() {
|
|||||||
const health = await request('/health');
|
const health = await request('/health');
|
||||||
assert(health.status === 'ok', 'health should return ok');
|
assert(health.status === 'ok', 'health should return ok');
|
||||||
|
|
||||||
|
const captcha = await request('/client/auth/captcha');
|
||||||
|
const captchaText = String(captcha.challenge.split('=')[0].split('+').map((part) => Number(part.trim())).reduce((sum, value) => sum + value, 0));
|
||||||
const login = await request('/client/auth/login', {
|
const login = await request('/client/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ login: email, password, captchaId: captcha.captchaId, captchaText }),
|
||||||
});
|
});
|
||||||
assert(login.accessToken && login.user?.tenantId === data.tenant.id, 'login should return smoke tenant user');
|
assert(login.accessToken && login.user?.tenantId === data.tenant.id, 'login should return smoke tenant user');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user