From e26d8324c39233e51a72985fd4f09849cd99f804 Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Thu, 2 Jul 2026 16:28:48 +0800 Subject: [PATCH] feat: complete login and user management flow --- .../migration.sql | 10 + api/prisma/schema.prisma | 6 + api/src/auth/auth.controller.ts | 25 +- api/src/auth/auth.service.spec.ts | 62 ++++ api/src/auth/auth.service.ts | 104 +++++- api/src/users/users.controller.ts | 82 ++++- api/src/users/users.service.spec.ts | 87 +++++ api/src/users/users.service.ts | 239 ++++++++++++- .../first-version-development-requirements.md | 34 +- docs/system-functional-test-cases.md | 27 +- docs/testing-progress.md | 29 ++ src/api/adminApi.ts | 79 ++++- src/api/session.ts | 42 +++ src/apps/LoginPage.tsx | 78 +++++ src/apps/admin/AdminUsersPage.tsx | 316 ++++++++++-------- src/apps/client/ClientUsersPage.tsx | 259 +++++++------- src/layouts/AdminLayout.tsx | 10 +- src/layouts/AppShell.tsx | 12 +- src/layouts/ClientLayout.tsx | 12 +- src/routes/AppRoutes.tsx | 3 + src/styles/global.css | 76 +++++ tools/smoke/real-env-smoke.mjs | 28 +- 22 files changed, 1320 insertions(+), 300 deletions(-) create mode 100644 api/prisma/migrations/20260702183000_add_user_login_fields/migration.sql create mode 100644 api/src/auth/auth.service.spec.ts create mode 100644 api/src/users/users.service.spec.ts create mode 100644 src/api/session.ts create mode 100644 src/apps/LoginPage.tsx diff --git a/api/prisma/migrations/20260702183000_add_user_login_fields/migration.sql b/api/prisma/migrations/20260702183000_add_user_login_fields/migration.sql new file mode 100644 index 0000000..c83a21a --- /dev/null +++ b/api/prisma/migrations/20260702183000_add_user_login_fields/migration.sql @@ -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"); diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index b83cc2c..c5cc6ef 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -65,9 +65,15 @@ model User { id String @id @default(cuid()) tenantId String? username String @unique + email String? @unique + phone String? @unique displayName String passwordHash String status String @default("active") + failedLoginCount Int @default(0) + lockedUntil DateTime? + lastLoginAt DateTime? + deletedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/api/src/auth/auth.controller.ts b/api/src/auth/auth.controller.ts index f5cd488..74e26e1 100644 --- a/api/src/auth/auth.controller.ts +++ b/api/src/auth/auth.controller.ts @@ -1,14 +1,29 @@ -import { Body, Controller, Post } from '@nestjs/common'; +import { Body, Controller, Get, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AuthService, LoginDto } from './auth.service'; @ApiTags('auth') -@Controller('client/auth') +@Controller() export class AuthController { constructor(private readonly auth: AuthService) {} - @Post('login') - login(@Body() body: LoginDto) { - return this.auth.login(body); + @Get('admin/auth/captcha') + adminCaptcha() { + return this.auth.createCaptcha(); + } + + @Post('admin/auth/login') + adminLogin(@Body() body: LoginDto) { + return this.auth.login(body, 'admin'); + } + + @Get('client/auth/captcha') + clientCaptcha() { + return this.auth.createCaptcha(); + } + + @Post('client/auth/login') + clientLogin(@Body() body: LoginDto) { + return this.auth.login(body, 'client'); } } diff --git a/api/src/auth/auth.service.spec.ts b/api/src/auth/auth.service.spec.ts new file mode 100644 index 0000000..4512620 --- /dev/null +++ b/api/src/auth/auth.service.spec.ts @@ -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 = {}) { + 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); + }); +}); diff --git a/api/src/auth/auth.service.ts b/api/src/auth/auth.service.ts index 5f24db3..e42e53f 100644 --- a/api/src/auth/auth.service.ts +++ b/api/src/auth/auth.service.ts @@ -1,30 +1,122 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common'; import { hashPassword, UsersService } from '../users/users.service'; export interface LoginDto { - username: string; + login: string; password: string; + captchaId: string; + captchaText: string; } +type LoginPortal = 'admin' | 'client'; + +type CaptchaRecord = { + answer: string; + expiresAt: number; +}; + +const captchaStore = new Map(); +const anonymousFailures = new Map(); + @Injectable() export class AuthService { constructor(private readonly users: UsersService) {} - async login(data: LoginDto) { - const user = await this.users.findByUsername(data.username); - if (!user || user.passwordHash !== hashPassword(data.password) || user.status !== 'active') { - throw new UnauthorizedException('Invalid username or password'); + createCaptcha() { + const left = Math.floor(10 + Math.random() * 40); + const right = Math.floor(1 + Math.random() * 9); + const captchaId = randomUUID(); + captchaStore.set(captchaId, { + answer: String(left + right), + expiresAt: Date.now() + 5 * 60 * 1000, + }); + return { + captchaId, + challenge: `${left} + ${right} = ?`, + expiresInSeconds: 300, + }; + } + + async login(data: LoginDto, portal: LoginPortal) { + const login = data.login?.trim(); + if (!login || !data.password) { + throw new BadRequestException('login and password are required'); } + this.verifyCaptcha(data.captchaId, data.captchaText); + this.assertAnonymousNotLocked(login); + + const user = await this.users.findByLogin(login); + if (!user) { + this.recordAnonymousFailure(login); + throw new UnauthorizedException('Invalid login or password'); + } + if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) { + throw new UnauthorizedException('User is locked for 24 hours after repeated failures'); + } + if (user.status !== 'active' || user.deletedAt) { + await this.users.recordLoginFailure(user.id); + throw new UnauthorizedException('User is disabled or deleted'); + } + if (user.passwordHash !== hashPassword(data.password)) { + await this.users.recordLoginFailure(user.id); + throw new UnauthorizedException('Invalid login or password'); + } + + const roleCodes = user.roles.map((item) => item.role.code); + if (portal === 'admin' && !roleCodes.includes('platform_admin')) { + await this.users.recordLoginFailure(user.id); + throw new UnauthorizedException('Only platform admins can login to admin portal'); + } + if (portal === 'client' && (!roleCodes.includes('enterprise_admin') || !user.tenantId)) { + await this.users.recordLoginFailure(user.id); + throw new UnauthorizedException('Only enterprise admins linked to a tenant can login to client portal'); + } + + await this.users.recordLoginSuccess(user.id); + anonymousFailures.delete(login); return { accessToken: `dev-token-${user.id}`, tokenType: 'Bearer', + portal, user: { id: user.id, tenantId: user.tenantId, + tenantName: user.tenant?.name, username: user.username, + email: user.email, + phone: user.phone, displayName: user.displayName, + roles: roleCodes, }, }; } + + private verifyCaptcha(captchaId?: string, captchaText?: string) { + const record = captchaId ? captchaStore.get(captchaId) : undefined; + captchaStore.delete(captchaId ?? ''); + if (!record || record.expiresAt < Date.now()) { + throw new BadRequestException('Captcha expired, refresh and try again'); + } + if (record.answer !== captchaText?.trim()) { + throw new BadRequestException('Captcha is incorrect'); + } + } + + private assertAnonymousNotLocked(login: string) { + const current = anonymousFailures.get(login); + if (current?.lockedUntil && current.lockedUntil > Date.now()) { + throw new UnauthorizedException('User is locked for 24 hours after repeated failures'); + } + } + + private recordAnonymousFailure(login: string) { + const current = anonymousFailures.get(login) ?? { count: 0 }; + const count = current.count + 1; + anonymousFailures.set(login, { + count, + lockedUntil: count >= 5 ? Date.now() + 24 * 60 * 60 * 1000 : current.lockedUntil, + }); + } } diff --git a/api/src/users/users.controller.ts b/api/src/users/users.controller.ts index 8af0da2..e499021 100644 --- a/api/src/users/users.controller.ts +++ b/api/src/users/users.controller.ts @@ -1,56 +1,114 @@ -import { Body, Controller, Get, Post } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { TenantId } from '../common/tenant-id.decorator'; import { AssignPermissionDto, AssignRoleDto, + ChangePasswordDto, + ChangeUserStatusDto, CreatePermissionDto, CreateRoleDto, CreateUserDto, + UpdateUserDto, UsersService, } from './users.service'; @ApiTags('users') -@Controller('admin/users') +@Controller() export class UsersController { constructor(private readonly users: UsersService) {} - @Get() - list(@TenantId() tenantId?: string) { - return this.users.list(tenantId); + @Get('admin/users') + list(@Query('tenantId') tenantId?: string, @Query('roleCode') roleCode?: string) { + return this.users.list(tenantId, roleCode); } - @Post() + @Post('admin/users') create(@Body() body: CreateUserDto) { return this.users.create(body); } - @Get('roles') + @Put('admin/users/:id') + update(@Param('id') id: string, @Body() body: UpdateUserDto) { + return this.users.update(id, body); + } + + @Patch('admin/users/:id') + patch(@Param('id') id: string, @Body() body: UpdateUserDto) { + return this.users.update(id, body); + } + + @Post('admin/users/:id/status') + changeStatus(@Param('id') id: string, @Body() body: ChangeUserStatusDto) { + return this.users.changeStatus(id, body); + } + + @Post('admin/users/:id/password') + changePassword(@Param('id') id: string, @Body() body: ChangePasswordDto) { + return this.users.changePassword(id, body); + } + + @Delete('admin/users/:id') + remove(@Param('id') id: string, @Body('operatorId') operatorId?: string) { + return this.users.remove(id, operatorId); + } + + @Get('client/users') + listClient(@TenantId() tenantId?: string) { + return this.users.listClientUsers(tenantId); + } + + @Post('client/users') + createClient(@TenantId() tenantId: string | undefined, @Body() body: CreateUserDto) { + return this.users.create({ ...body, roleCode: 'enterprise_admin' }, tenantId); + } + + @Put('client/users/:id') + updateClient(@TenantId() tenantId: string | undefined, @Param('id') id: string, @Body() body: UpdateUserDto) { + return this.users.update(id, { ...body, roleCode: 'enterprise_admin' }, tenantId); + } + + @Post('client/users/:id/status') + changeClientStatus(@TenantId() tenantId: string | undefined, @Param('id') id: string, @Body() body: ChangeUserStatusDto) { + return this.users.changeStatus(id, body, tenantId); + } + + @Post('client/users/:id/password') + changeClientPassword(@TenantId() tenantId: string | undefined, @Param('id') id: string, @Body() body: ChangePasswordDto) { + return this.users.changePassword(id, body, tenantId); + } + + @Delete('client/users/:id') + removeClient(@TenantId() tenantId: string | undefined, @Param('id') id: string, @Body('operatorId') operatorId?: string) { + return this.users.remove(id, operatorId, tenantId); + } + + @Get('admin/users/roles') listRoles() { return this.users.listRoles(); } - @Post('roles') + @Post('admin/users/roles') createRole(@Body() body: CreateRoleDto) { return this.users.createRole(body); } - @Get('permissions') + @Get('admin/users/permissions') listPermissions() { return this.users.listPermissions(); } - @Post('permissions') + @Post('admin/users/permissions') createPermission(@Body() body: CreatePermissionDto) { return this.users.createPermission(body); } - @Post('roles/assign') + @Post('admin/users/roles/assign') assignRole(@Body() body: AssignRoleDto) { return this.users.assignRole(body); } - @Post('permissions/assign') + @Post('admin/users/permissions/assign') assignPermission(@Body() body: AssignPermissionDto) { return this.users.assignPermission(body); } diff --git a/api/src/users/users.service.spec.ts b/api/src/users/users.service.spec.ts new file mode 100644 index 0000000..96284aa --- /dev/null +++ b/api/src/users/users.service.spec.ts @@ -0,0 +1,87 @@ +import { BadRequestException } from '@nestjs/common'; +import { UsersService } from './users.service'; + +function createPrismaMock() { + const roles = new Map(); + 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' }), + })); + }); +}); diff --git a/api/src/users/users.service.ts b/api/src/users/users.service.ts index 235ae95..11af866 100644 --- a/api/src/users/users.service.ts +++ b/api/src/users/users.service.ts @@ -1,13 +1,41 @@ import { createHash } from 'node:crypto'; -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; +export type UserRoleCode = 'platform_admin' | 'enterprise_admin'; + export interface CreateUserDto { tenantId?: string; - username: string; + username?: string; + email?: string; + phone?: string; displayName: string; password: string; status?: string; + roleCode: UserRoleCode; + operatorId?: string; +} + +export interface UpdateUserDto { + tenantId?: string | null; + username?: string; + email?: string | null; + phone?: string | null; + displayName?: string; + status?: string; + roleCode?: UserRoleCode; + operatorId?: string; +} + +export interface ChangeUserStatusDto { + status: 'active' | 'disabled'; + operatorId?: string; +} + +export interface ChangePasswordDto { + password: string; + operatorId?: string; } export interface CreateRoleDto { @@ -33,31 +61,159 @@ export interface AssignPermissionDto { permissionId: string; } +const roleNames: Record = { + platform_admin: { name: '平台管理员', scope: 'platform' }, + enterprise_admin: { name: '企业管理员', scope: 'tenant' }, +}; + @Injectable() export class UsersService { constructor(private readonly prisma: PrismaService) {} - list(tenantId?: string) { + list(tenantId?: string, roleCode?: string) { return this.prisma.user.findMany({ - where: tenantId ? { tenantId } : undefined, - include: { roles: { include: { role: true } } }, + where: { + deletedAt: null, + ...(tenantId ? { tenantId } : {}), + ...(roleCode ? { roles: { some: { role: { code: roleCode } } } } : {}), + }, + include: { tenant: true, roles: { include: { role: true } } }, orderBy: { createdAt: 'desc' }, - take: 100, + take: 200, }); } - findByUsername(username: string) { - return this.prisma.user.findUnique({ where: { username } }); + listClientUsers(tenantId?: string) { + if (!tenantId) { + throw new BadRequestException('tenantId is required for client user management'); + } + return this.list(tenantId); } - create(data: CreateUserDto) { - return this.prisma.user.create({ + findByUsername(username: string) { + return this.prisma.user.findUnique({ where: { username }, include: { tenant: true, roles: { include: { role: true } } } }); + } + + findByLogin(login: string) { + return this.prisma.user.findFirst({ + where: { + deletedAt: null, + OR: [{ username: login }, { email: login }, { phone: login }], + }, + include: { tenant: true, roles: { include: { role: true } } }, + }); + } + + async create(data: CreateUserDto, scopeTenantId?: string) { + const roleCode = data.roleCode; + this.assertUserInput({ ...data, roleCode }, scopeTenantId, true); + const tenantId = scopeTenantId ?? data.tenantId; + const role = await this.ensureRole(roleCode); + const user = await this.prisma.user.create({ data: { - tenantId: data.tenantId, - username: data.username, + tenantId, + username: data.username ?? data.email ?? data.phone ?? '', + email: normalizeOptional(data.email), + phone: normalizeOptional(data.phone), displayName: data.displayName, passwordHash: hashPassword(data.password), status: data.status ?? 'active', + roles: { create: [{ roleId: role.id }] }, + }, + include: { tenant: true, roles: { include: { role: true } } }, + }); + await this.writeLog(tenantId, data.operatorId, 'user.created', user.id, { roleCode, username: user.username }); + return user; + } + + async update(id: string, data: UpdateUserDto, scopeTenantId?: string) { + const current = await this.getExisting(id, scopeTenantId); + const roleCode = data.roleCode ?? current.roles[0]?.role.code as UserRoleCode | undefined; + this.assertUserInput({ + tenantId: data.tenantId ?? current.tenantId ?? undefined, + username: data.username ?? current.username, + email: data.email === undefined ? current.email ?? undefined : data.email ?? undefined, + phone: data.phone === undefined ? current.phone ?? undefined : data.phone ?? undefined, + displayName: data.displayName ?? current.displayName, + password: 'unchanged-password', + roleCode: roleCode ?? 'enterprise_admin', + }, scopeTenantId, false); + const tenantId = scopeTenantId ?? data.tenantId ?? current.tenantId; + const role = roleCode ? await this.ensureRole(roleCode) : null; + const updated = await this.prisma.$transaction(async (tx) => { + if (role) { + await tx.userRole.deleteMany({ where: { userId: id } }); + await tx.userRole.create({ data: { userId: id, roleId: role.id } }); + } + return tx.user.update({ + where: { id }, + data: { + tenantId, + username: data.username ?? current.username, + email: data.email === undefined ? undefined : normalizeOptional(data.email), + phone: data.phone === undefined ? undefined : normalizeOptional(data.phone), + displayName: data.displayName ?? current.displayName, + status: data.status ?? current.status, + }, + include: { tenant: true, roles: { include: { role: true } } }, + }); + }); + await this.writeLog(tenantId, data.operatorId, 'user.updated', id, { roleCode, username: updated.username }); + return updated; + } + + async changeStatus(id: string, data: ChangeUserStatusDto, scopeTenantId?: string) { + const current = await this.getExisting(id, scopeTenantId); + const updated = await this.prisma.user.update({ + where: { id }, + data: { status: data.status }, + include: { tenant: true, roles: { include: { role: true } } }, + }); + await this.writeLog(current.tenantId, data.operatorId, `user.${data.status}`, id, { username: current.username }); + return updated; + } + + async changePassword(id: string, data: ChangePasswordDto, scopeTenantId?: string) { + if (!data.password || data.password.length < 6) { + throw new BadRequestException('password must be at least 6 characters'); + } + const current = await this.getExisting(id, scopeTenantId); + const updated = await this.prisma.user.update({ + where: { id }, + data: { passwordHash: hashPassword(data.password), failedLoginCount: 0, lockedUntil: null }, + include: { tenant: true, roles: { include: { role: true } } }, + }); + await this.writeLog(current.tenantId, data.operatorId, 'user.password_changed', id, { username: current.username }); + return updated; + } + + async remove(id: string, operatorId?: string, scopeTenantId?: string) { + const current = await this.getExisting(id, scopeTenantId); + const updated = await this.prisma.user.update({ + where: { id }, + data: { status: 'deleted', deletedAt: new Date() }, + include: { tenant: true, roles: { include: { role: true } } }, + }); + await this.writeLog(current.tenantId, operatorId, 'user.deleted', id, { username: current.username }); + return updated; + } + + async recordLoginSuccess(id: string) { + return this.prisma.user.update({ + where: { id }, + data: { failedLoginCount: 0, lockedUntil: null, lastLoginAt: new Date() }, + }); + } + + async recordLoginFailure(id: string) { + const current = await this.prisma.user.findUnique({ where: { id } }); + if (!current) return null; + const failedLoginCount = current.failedLoginCount + 1; + return this.prisma.user.update({ + where: { id }, + data: { + failedLoginCount, + lockedUntil: failedLoginCount >= 5 ? new Date(Date.now() + 24 * 60 * 60 * 1000) : current.lockedUntil, }, }); } @@ -104,6 +260,65 @@ export class UsersService { create: data, }); } + + private async ensureRole(code: UserRoleCode) { + const info = roleNames[code]; + return this.prisma.role.upsert({ + where: { code }, + update: { name: info.name, scope: info.scope }, + create: { code, name: info.name, scope: info.scope }, + }); + } + + private async getExisting(id: string, scopeTenantId?: string) { + const user = await this.prisma.user.findFirst({ + where: { id, deletedAt: null, ...(scopeTenantId ? { tenantId: scopeTenantId } : {}) }, + include: { tenant: true, roles: { include: { role: true } } }, + }); + if (!user) { + throw new NotFoundException('User not found'); + } + return user; + } + + private assertUserInput(data: CreateUserDto, scopeTenantId: string | undefined, requirePassword: boolean) { + if (!data.displayName?.trim()) { + throw new BadRequestException('displayName is required'); + } + if (requirePassword && (!data.password || data.password.length < 6)) { + throw new BadRequestException('password must be at least 6 characters'); + } + if (!data.email && !data.phone) { + throw new BadRequestException('email or phone is required'); + } + if (data.roleCode === 'platform_admin' && (scopeTenantId || data.tenantId)) { + throw new BadRequestException('platform_admin must not be linked to a tenant'); + } + if (data.roleCode === 'enterprise_admin' && !(scopeTenantId || data.tenantId)) { + throw new BadRequestException('enterprise_admin must be linked to a tenant'); + } + if (scopeTenantId && data.roleCode === 'platform_admin') { + throw new BadRequestException('client user management cannot create platform admins'); + } + } + + private writeLog(tenantId: string | null | undefined, userId: string | undefined, action: string, resourceId: string, detail: Record) { + 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) { diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index 0fbab96..2b8da58 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -835,7 +835,7 @@ 2. NestJS 后端实现 Prisma schema、migration、Service、Controller、DTO、单元测试。 3. Go Gateway 实现配置、连接管理、CMPP submit/deliver/active test、回执事件发布、单元测试。 4. 前端将对应 mock 数据替换为 API 调用,保留现有视觉样式。 -5. 原型阶段的 mock、localStorage 或静态数据只能作为开发临时兜底,不得作为真实开发完成标准;审核、通道、连接、日志、黑名单、敏感词、报备字段、用户、账务等闭环必须接入真实 API、数据库或 Gateway 回写。 +5. 原型阶段的 mock、localStorage 或静态数据只能用于早期界面占位;进入系统功能验收后不得继续作为兜底通过路径。审核、通道、连接、日志、黑名单、敏感词、报备字段、用户、账务等闭环必须接入真实 API、数据库或 Gateway 回写;API 不可用时应展示错误态或空态,并将用例标记为阻塞或未通过。 6. 补充错误处理、权限校验、操作日志。 7. 运行构建和相关测试,并更新测试进度文档。 ``` @@ -1158,10 +1158,40 @@ - 不从零手写整个 CMPP 协议栈,也不直接照搬完整开源网关;协议层可复用,服务层按本项目自研。 - NestJS 负责业务审核、风控、计费、报备、路由和发送编排。 - Go Gateway 只负责 CMPP 连接、协议提交、submit resp、回执、上行事件回传。 -- 当前项目已进入真实开发阶段;前端 mock、localStorage 或静态数据只能临时兜底,不能作为功能完成标准。涉及审核、通道、连接、日志、安全控制、用户、账务等闭环时,必须接入真实 API、数据库或 Gateway 回写,并补测试。 +- 当前项目已进入真实开发阶段;前端 mock、localStorage 或静态数据不得作为功能完成标准,也不得作为验收兜底通过路径。涉及审核、通道、连接、日志、安全控制、用户、账务等闭环时,必须接入真实 API、数据库或 Gateway 回写,并补真实后端 smoke 或 E2E 测试。 请从 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 的最小工程计划,包括: 1. 目录结构建议。 2. NestJS 与 Go Gateway 的队列消息格式。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index b78b55d..bd3cb4f 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -2,7 +2,7 @@ ## 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 签名创建、材料上传与提交审核 - 优先级:P0 -- 前置条件:MinIO 不可用时使用文件服务 mock 或跳过真实上传。 +- 前置条件:MinIO 或等价对象存储测试服务可用;如不可用,本用例标记为阻塞或未执行,不得用文件服务 mock 作为验收通过依据。 - 步骤: 1. 创建短信签名,填写名称、用途、引流信息。 2. 上传或登记签名证明材料。 @@ -2360,7 +2360,7 @@ npm run test:gateway npm run verify:phase8 ``` -若测试环境具备 PostgreSQL、Redis、MinIO,再补充执行 E2E smoke 和真实 API HTTP 测试。 +测试环境必须具备 PostgreSQL、Redis、MinIO 或等价本地服务后,才能将 E2E smoke 和真实 API HTTP 测试记为系统功能通过;缺失时对应用例标记为阻塞或未执行。 ## 17. 新增和更新用例细化执行清单 @@ -2370,7 +2370,7 @@ npm run verify:phase8 | 断言类型 | 检查点 | | --- | --- | -| 数据来源 | 页面列表、详情、统计卡片、弹窗和下拉选项均必须来自真实 API 响应;网络失败时可有兜底展示,但兜底不能计入通过。 | +| 数据来源 | 页面列表、详情、统计卡片、弹窗和下拉选项均必须来自真实 API 响应;网络失败时可以展示错误态或空态,但静态兜底数据不能计入通过。 | | 租户隔离 | 客户端接口必须以当前租户为边界;通过 URL、查询参数或资源 id 访问其他租户数据时,应返回无权限、无数据或明确错误。 | | 状态联动 | 客户、应用、签名、模板、引流信息、通道、连接状态变化后,立即发送、定时到点、导入确认发送都必须重新校验。 | | 日志证据 | 创建、编辑、删除、启停、复制、审核、导入、导出、充值、冲正、发送阻断、连接状态变化、失败动作都必须写系统日志。 | @@ -2421,7 +2421,7 @@ npm run verify:phase8 | TC-BILLING-006 | 运营端人工充值金额和短信条数,客户端查看 Dashboard 和账单流水。 | RechargeOrder 状态为 paid/manual_topup;TenantAccount 同步增加;AccountTransaction 类型 recharge;运营日志和客户端流水均可追溯。 | | TC-BILLING-007 | 分别只填金额、只填短信条数。 | 未填项按 0;金额和条数字段方向正确;不会产生 null、NaN 或负数脏数据。 | | TC-BILLING-008 | 对已充值记录执行撤销/冲正,分别覆盖未消费和已部分消费。 | 未消费可全额回退;已消费按规则拒绝或生成人工调整;原订单状态和反向流水清晰;日志记录原因。 | -| TC-BILLING-009 | 无权限用户、审核员、管理员、大额审批分别执行充值。 | 权限不足被拒绝并写失败日志;大额充值 pending 时不更新余额;审批通过才入账,驳回不入账。 | +| TC-BILLING-009 | 无权限用户、审核员、管理员分别执行充值;大额人工充值不走审批。 | 权限不足被拒绝并写失败日志;有权限用户确认后立即入账;不产生 pending 审批态;充值订单、账户余额、流水和日志同步完成。 | | TC-BILLING-010 | 余额不足发送失败,人工充值后重试发送并模拟 delivered。 | 充值前不扣费;充值后发送成功;冻结、扣费、短信计费记录完整;reconciliation diff 为 0。 | ### 17.6 系统日志细化 @@ -2482,3 +2482,20 @@ npm run verify:phase8 | Go Gateway | 连接状态回写契约、登录成功/失败、心跳超时、断线重连、窗口占满、连接数调整。 | | 前端 Smoke | 客户端头像菜单、系统日志分页、运营模板审核搜索、企业认证详情、通道复制/链接日志、安全控制 CRUD、Dashboard 指标跳转。 | | 性能 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;仅影响当前企业用户;启停/删除有确认弹窗。 | diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 8b9b070..4ca5437 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -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。 + +## 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` 后补充记录。 diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index fe4c526..b569fbb 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -1,3 +1,5 @@ +import { getSessionTenantId, readSession, type LoginSession } from './session'; + type RequestOptions = RequestInit & { tenantId?: string; }; @@ -7,8 +9,13 @@ export const DEFAULT_CLIENT_TENANT_ID = 'tenant-a'; async function request(path: string, options: RequestOptions = {}): Promise { const headers = new Headers(options.headers); headers.set('Content-Type', 'application/json'); - if (options.tenantId) { - headers.set('x-tenant-id', options.tenantId); + const session = readSession(); + 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 }); if (!response.ok) { @@ -83,6 +90,40 @@ export type TenantOption = { 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 = { taskCount: number; messageStatus: Array<{ status: string; _count: { _all: number }; _sum: { amountCents?: number | null; billingUnits?: number | null } }>; @@ -223,7 +264,18 @@ function withQuery(path: string, query: Record request('/admin/auth/captcha'), + login: (body: { login: string; password: string; captchaId: string; captchaText: string }) => + request('/admin/auth/login', { method: 'POST', body: JSON.stringify(body) }), listTenants: () => request('/admin/tenants'), + listUsers: (query: { tenantId?: string; roleCode?: string } = {}) => request(withQuery('/admin/users', query)), + createUser: (body: UserPayload) => request('/admin/users', { method: 'POST', body: JSON.stringify(body) }), + updateUser: (id: string, body: Omit) => request(`/admin/users/${id}`, { method: 'PUT', body: JSON.stringify(body) }), + changeUserStatus: (id: string, status: string, operatorId?: string) => + request(`/admin/users/${id}/status`, { method: 'POST', body: JSON.stringify({ status, operatorId }) }), + deleteUser: (id: string, operatorId?: string) => request(`/admin/users/${id}`, { method: 'DELETE', body: JSON.stringify({ operatorId }) }), + changeUserPassword: (id: string, password: string, operatorId?: string) => + request(`/admin/users/${id}/password`, { method: 'POST', body: JSON.stringify({ password, operatorId }) }), getDashboard: (tenantId?: string) => request(withQuery('/admin/operations/dashboard/statistics', { tenantId })), listSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }) => request(withQuery('/admin/system-logs', query)), @@ -293,12 +345,27 @@ export const adminApi = { }; export const clientApi = { - getDashboard: (tenantId = DEFAULT_CLIENT_TENANT_ID) => + getCaptcha: () => request('/client/auth/captcha'), + login: (body: { login: string; password: string; captchaId: string; captchaText: string }) => + request('/client/auth/login', { method: 'POST', body: JSON.stringify(body) }), + listUsers: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request('/client/users', { tenantId }), + createUser: (body: UserPayload, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request('/client/users', { method: 'POST', tenantId, body: JSON.stringify(body) }), + updateUser: (id: string, body: Omit, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(`/client/users/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }), + changeUserStatus: (id: string, status: string, operatorId?: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(`/client/users/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status, operatorId }) }), + deleteUser: (id: string, operatorId?: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(`/client/users/${id}`, { method: 'DELETE', tenantId, body: JSON.stringify({ operatorId }) }), + changeUserPassword: (id: string, password: string, operatorId?: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(`/client/users/${id}/password`, { method: 'POST', tenantId, body: JSON.stringify({ password, operatorId }) }), + getDashboard: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/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(withQuery('/client/operations/system-logs', query), { tenantId }), - listTransactions: (tenantId = DEFAULT_CLIENT_TENANT_ID) => + listTransactions: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/billing/transactions', { tenantId }), - listOrders: (tenantId = DEFAULT_CLIENT_TENANT_ID) => + listOrders: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/billing/orders', { tenantId }), }; diff --git a/src/api/session.ts b/src/api/session.ts new file mode 100644 index 0000000..7f9147d --- /dev/null +++ b/src/api/session.ts @@ -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; +} diff --git a/src/apps/LoginPage.tsx b/src/apps/LoginPage.tsx new file mode 100644 index 0000000..320f23c --- /dev/null +++ b/src/apps/LoginPage.tsx @@ -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(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 ( +
+
+
+ +
+

{isAdmin ? 'CMPP 运营端' : 'CMPP 客户端'}

+

{isAdmin ? '平台管理员登录' : '企业管理员登录'}

+
+
+
+ setLogin(event.target.value)} placeholder="请输入邮箱或手机号" value={login} /> + setPassword(event.target.value)} placeholder="请输入密码" type="password" value={password} /> +
+ setCaptchaText(event.target.value)} placeholder="请输入计算结果" value={captchaText} /> + +
+ {error ?

{error}

: null} + +
+
+
+ ); +} diff --git a/src/apps/admin/AdminUsersPage.tsx b/src/apps/admin/AdminUsersPage.tsx index 4a8cbd3..cef0fa2 100644 --- a/src/apps/admin/AdminUsersPage.tsx +++ b/src/apps/admin/AdminUsersPage.tsx @@ -1,155 +1,166 @@ -import { useMemo, useState } from 'react'; -import { Plus, Search } from 'lucide-react'; +import { useEffect, useMemo, useState } from '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'; -type UserStatus = 'enabled' | 'disabled'; - -type AdminUser = { - id: string; - name: string; - account: string; - role: string; +type UserForm = { + tenantId: string; + displayName: string; + username: string; + email: string; phone: string; - status: UserStatus; - createdAt: string; - lastLogin: string; + roleCode: 'platform_admin' | 'enterprise_admin'; + status: string; + password: string; }; -const statusLabelMap: Record = { - enabled: '启用', - disabled: '停用', +type ConfirmAction = { + type: 'status' | 'delete'; + user: ManagedUser; }; -const statusToneMap: Record = { - enabled: 'success', - disabled: 'neutral', +const emptyForm: UserForm = { + tenantId: '', + displayName: '', + username: '', + email: '', + phone: '', + roleCode: 'platform_admin', + status: 'active', + password: '', }; -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; +const roleLabel: Record = { + platform_admin: '平台管理员', + enterprise_admin: '企业管理员', }; -function UserFormModal({ item, onClose, onSubmit }: UserFormModalProps) { - const [form, setForm] = useState(() => item ?? { - id: createUserId(), - name: '', - account: '', - role: '运营人员', - phone: '', - status: 'enabled', - createdAt: '2026-06-30 10:00:00', - lastLogin: '-', - }); - - function updateField(key: Key, value: AdminUser[Key]) { - setForm((current) => ({ ...current, [key]: value })); - } - - function handleSubmit() { - onSubmit(form); - } - - return ( - - - - - )} - onClose={onClose} - open - title={item ? '编辑用户' : '新增用户'} - > -
- updateField('name', event.target.value)} value={form.name} /> - updateField('account', event.target.value)} value={form.account} /> - updateField('phone', event.target.value)} value={form.phone} /> - setKeyword(event.target.value)} placeholder="搜索姓名、账号、角色或手机号" prefix={} value={keyword} /> - + setKeyword(event.target.value)} placeholder="搜索姓名、邮箱、手机号、角色或企业" prefix={} value={keyword} /> +
- + {error ?
{error}
: null}
- {creating ? setCreating(false)} onSubmit={upsertUser} /> : null} - {editingUser ? setEditingUser(null)} onSubmit={upsertUser} /> : null} + {(creating || editingUser) ? ( + } + onClose={() => { setCreating(false); setEditingUser(null); }} + open + title={creating ? '新增用户' : '编辑用户'} + > +
+ updateField('displayName', event.target.value)} value={form.displayName} /> + updateField('email', event.target.value)} value={form.email} /> + updateField('phone', event.target.value)} value={form.phone} /> + updateField('username', event.target.value)} value={form.username} /> + updateField('tenantId', event.target.value)} + options={tenants.map((tenant) => ({ label: tenant.name, value: tenant.id }))} + value={form.tenantId} + /> + ) : null} + {creating ? updateField('password', event.target.value)} type="password" value={form.password} /> : null} + setNewPassword(event.target.value)} type="password" value={newPassword} /> +
+
+ ) : null} + + {confirmAction ? ( + } onClose={() => setConfirmAction(null)} open title={confirmAction.type === 'delete' ? '删除用户' : '变更用户状态'}> +

{confirmAction.type === 'delete' ? `确认删除用户 ${confirmAction.user.displayName}?` : `确认${confirmAction.user.status === 'active' ? '禁用' : '启用'}用户 ${confirmAction.user.displayName}?`}

+
+ ) : null} ); } diff --git a/src/apps/client/ClientUsersPage.tsx b/src/apps/client/ClientUsersPage.tsx index 09ef1ef..ffebead 100644 --- a/src/apps/client/ClientUsersPage.tsx +++ b/src/apps/client/ClientUsersPage.tsx @@ -1,95 +1,138 @@ -import { useMemo, useState } from 'react'; -import { Edit3, Plus, Search, Trash2, Users } from 'lucide-react'; -import { - Button, - Input, - Modal, - Pagination, - Select, - Table, - Tag, - type TableColumn, -} from '@/components/ui'; +import { useEffect, useMemo, useState } from 'react'; +import { Edit3, KeyRound, Plus, Search, Trash2, Users } from 'lucide-react'; +import { clientApi, type ManagedUser, type UserPayload } from '@/api/adminApi'; +import { readSession } from '@/api/session'; +import { Button, Input, Modal, Pagination, Select, Table, Tag, type TableColumn } from '@/components/ui'; -type UserRole = 'enterprise_admin' | 'user'; -type UserStatus = 'active' | 'disabled'; - -type ClientUser = { - id: string; - name: string; +type UserForm = { + displayName: string; + username: string; email: string; phone: string; - role: UserRole; - status: UserStatus; - lastLoginAt: string; + status: string; + password: string; }; -const roleLabelMap: Record = { - enterprise_admin: '企业管理员', - user: '普通用户', +type ConfirmAction = { + type: 'status' | 'delete'; + user: ManagedUser; }; -const usersSeed: ClientUser[] = [ - { id: 'USER001', name: '张三', email: 'zhangsan@example.com', phone: '13800138000', role: 'enterprise_admin', status: 'active', lastLoginAt: '2026-03-17 09:15:00' }, - { id: 'USER002', name: '李四', email: 'lisi@example.com', phone: '13800138001', role: 'user', status: 'active', lastLoginAt: '2026-03-16 16:45:00' }, - { 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: '', +const emptyForm: UserForm = { + displayName: '', + username: '', email: '', phone: '', - role: 'user', 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() { + const session = readSession(); + const tenantId = session?.user.tenantId ?? undefined; + const [users, setUsers] = useState([]); const [keyword, setKeyword] = useState(''); - const [editingUser, setEditingUser] = useState(null); - const [draft, setDraft] = useState(emptyUser); - const enterpriseAdmin = usersSeed.find((item) => item.role === 'enterprise_admin'); - const canSelectEnterpriseAdmin = !enterpriseAdmin || editingUser?.id === enterpriseAdmin.id; + const [editingUser, setEditingUser] = useState(null); + const [creating, setCreating] = useState(false); + const [form, setForm] = useState(emptyForm); + const [passwordUser, setPasswordUser] = useState(null); + const [newPassword, setNewPassword] = useState(''); + const [confirmAction, setConfirmAction] = useState(null); + const [error, setError] = useState(''); - const filteredUsers = usersSeed.filter((item) => { - const target = `${item.name} ${item.email} ${item.phone}`; - return !keyword || target.toLowerCase().includes(keyword.toLowerCase()); - }); - - function openEditor(user?: ClientUser) { - const nextUser = user ?? emptyUser; - setEditingUser(nextUser); - setDraft(nextUser); + async function load() { + if (!tenantId) return; + setUsers(await clientApi.listUsers(tenantId)); } - const columns = useMemo>>(() => [ - { key: 'name', title: '用户名', width: '120px', render: (record) => {record.name} }, - { key: 'email', title: '邮箱', render: (record) => {record.email} }, - { key: 'phone', title: '手机号', width: '180px', render: (record) => {record.phone} }, - { - key: 'role', - title: '角色', - width: '150px', - render: (record) => {roleLabelMap[record.role]}, - }, - { - key: 'status', - title: '状态', - width: '130px', - render: (record) => {record.status === 'active' ? '正常' : '禁用'}, - }, - { key: 'lastLoginAt', title: '最后登录时间', width: '210px', render: (record) => {record.lastLoginAt} }, + useEffect(() => { + void load().catch((err) => setError(err instanceof Error ? err.message : '加载用户失败')); + }, [tenantId]); + + const filteredUsers = useMemo(() => { + const value = keyword.trim().toLowerCase(); + return users.filter((item) => { + const target = `${item.displayName} ${item.email ?? ''} ${item.phone ?? ''}`.toLowerCase(); + return !value || target.includes(value); + }); + }, [keyword, users]); + + function openEditor(user?: ManagedUser) { + setForm(toForm(user)); + setEditingUser(user ?? null); + setCreating(!user); + } + + function updateField(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>>(() => [ + { key: 'name', title: '用户名', width: '140px', render: (record) => {record.displayName} }, + { key: 'email', title: '邮箱', render: (record) => {record.email ?? '-'} }, + { key: 'phone', title: '手机号', width: '160px', render: (record) => {record.phone ?? '-'} }, + { key: 'role', title: '角色', width: '130px', render: () => 企业管理员 }, + { key: 'status', title: '状态', width: '120px', render: (record) => {record.status === 'active' ? '正常' : '禁用'} }, + { key: 'lastLoginAt', title: '最后登录时间', width: '190px', render: (record) => {record.lastLoginAt ? new Date(record.lastLoginAt).toLocaleString('zh-CN') : '-'} }, { key: 'actions', title: '操作', - width: '170px', + width: '290px', render: (record) => (
- + + +
), }, @@ -106,56 +149,46 @@ export function ClientUsersPage() {
- setKeyword(event.target.value)} - placeholder="搜索用户名、邮箱或手机号" - prefix={} - value={keyword} - /> + setKeyword(event.target.value)} placeholder="搜索用户名、邮箱或手机号" prefix={} value={keyword} />
- + {error ?
{error}
: null}
- - - - - )} - onClose={() => setEditingUser(null)} - open={Boolean(editingUser)} - size="xl" - title={editingUser?.id === 'NEW' ? '添加用户' : '编辑用户'} - > -
- setDraft({ ...draft, name: event.target.value })} placeholder="请输入用户名" value={draft.name} /> - setDraft({ ...draft, email: event.target.value })} placeholder="请输入邮箱" value={draft.email} /> - setDraft({ ...draft, phone: event.target.value })} placeholder="请输入手机号" value={draft.phone} /> - setDraft({ ...draft, status: event.target.value as UserStatus })} - options={[ - { label: '正常', value: 'active' }, - { label: '禁用', value: 'disabled' }, - ]} - value={draft.status} - /> -
-
+ {(creating || editingUser) ? ( + } + onClose={() => { setCreating(false); setEditingUser(null); }} + open + size="xl" + title={creating ? '添加用户' : '编辑用户'} + > +
+ updateField('displayName', event.target.value)} placeholder="请输入用户名" value={form.displayName} /> + updateField('email', event.target.value)} placeholder="请输入邮箱" value={form.email} /> + updateField('phone', event.target.value)} placeholder="请输入手机号" value={form.phone} /> + updateField('username', event.target.value)} value={form.username} /> + {creating ? updateField('password', event.target.value)} type="password" value={form.password} /> : null} + setNewPassword(event.target.value)} type="password" value={newPassword} /> +
+
+ ) : null} + + {confirmAction ? ( + } onClose={() => setConfirmAction(null)} open title={confirmAction.type === 'delete' ? '删除用户' : '变更用户状态'}> +

{confirmAction.type === 'delete' ? `确认删除用户 ${confirmAction.user.displayName}?` : `确认${confirmAction.user.status === 'active' ? '禁用' : '启用'}用户 ${confirmAction.user.displayName}?`}

+
+ ) : null} ); } diff --git a/src/layouts/AdminLayout.tsx b/src/layouts/AdminLayout.tsx index 32ee3cb..0283cf0 100644 --- a/src/layouts/AdminLayout.tsx +++ b/src/layouts/AdminLayout.tsx @@ -24,15 +24,23 @@ import { Users, UserX, } from 'lucide-react'; +import { Navigate } from 'react-router-dom'; +import { readSession } from '@/api/session'; import { AppShell } from '@/layouts/AppShell'; export function AdminLayout() { + const session = readSession(); + if (session?.portal !== 'admin') { + return ; + } + return ( >({}); const [userMenuOpen, setUserMenuOpen] = useState(false); const [noticeOpen, setNoticeOpen] = useState(false); + const navigate = useNavigate(); const ToggleIcon = collapsed ? PanelLeftOpen : PanelLeftClose; const auditTotal = useMemo( () => auditNotifications.reduce((sum, item) => sum + item.count, 0), @@ -205,7 +209,11 @@ export function AppShell({ 修改密码 - diff --git a/src/layouts/ClientLayout.tsx b/src/layouts/ClientLayout.tsx index 57c9580..72e4899 100644 --- a/src/layouts/ClientLayout.tsx +++ b/src/layouts/ClientLayout.tsx @@ -10,15 +10,23 @@ import { ShieldCheck, Users, } from 'lucide-react'; +import { Navigate } from 'react-router-dom'; +import { readSession } from '@/api/session'; import { AppShell } from '@/layouts/AppShell'; export function ClientLayout() { + const session = readSession(); + if (session?.portal !== 'client') { + return ; + } + return ( } /> + } /> + } /> }> } /> } /> diff --git a/src/styles/global.css b/src/styles/global.css index 8e5994d..f94789c 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -3856,6 +3856,82 @@ h3 { 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 { gap: 28px; } diff --git a/tools/smoke/real-env-smoke.mjs b/tools/smoke/real-env-smoke.mjs index cd35273..270f4c4 100644 --- a/tools/smoke/real-env-smoke.mjs +++ b/tools/smoke/real-env-smoke.mjs @@ -10,6 +10,8 @@ const prisma = new PrismaClient({ const apiBaseUrl = process.env.API_BASE_URL ?? 'http://127.0.0.1:3101/api'; const tenantCode = 'smoke-tenant'; const username = 'smoke_client'; +const email = 'smoke-client@example.com'; +const phone = '13800138099'; const password = 'SmokePass123!'; function hashPassword(value) { @@ -27,19 +29,37 @@ async function ensureSmokeData() { where: { username }, update: { tenantId: tenant.id, + email, + phone, displayName: 'Smoke Client Admin', passwordHash: hashPassword(password), status: 'active', + failedLoginCount: 0, + lockedUntil: null, + deletedAt: null, }, create: { tenantId: tenant.id, username, + email, + phone, displayName: 'Smoke Client Admin', passwordHash: hashPassword(password), 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({ where: { tenantId: tenant.id }, update: { balanceCents: { increment: 0 }, smsUnits: { increment: 0 }, creditCents: 5000, status: 'active' }, @@ -51,7 +71,7 @@ async function ensureSmokeData() { update: { name: 'Smoke CMPP Channel', gatewayHost: '127.0.0.1', - gatewayPort: 7890, + gatewayPort: 17890, account: 'smoke-account', passwordCipher: 'smoke-password', srcId: '10690000', @@ -65,7 +85,7 @@ async function ensureSmokeData() { carrier: 'all', protocol: 'CMPP', gatewayHost: '127.0.0.1', - gatewayPort: 7890, + gatewayPort: 17890, account: 'smoke-account', passwordCipher: 'smoke-password', srcId: '10690000', @@ -180,9 +200,11 @@ async function run() { const health = await request('/health'); 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', { 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');