背景: - 开发计划 A1 批次要求登录/刷新/登出、用户生命周期、路由与按钮权限、 权限三态用例;视觉已选定方案 B(主从分栏 + 分屏登录)。 改动: - packages/shared:新增 identity 契约(User/Me/Login/Refresh/Department/Role) 与 user:view 权限点;写接口 strict 校验。 - apps/api:新增 identity 模块与 Prisma expand 迁移(departments/users/roles/ user_roles/role_permissions/refresh_tokens);Argon2id、JWT access+refresh 旋转与 jti 吊销、登录失败锁定、首登强改、守卫链、/me、@Audit 上下文、幂等种子。 - apps/web:按方案 B 落地 /users、/users/new、/users/:id、分屏登录与 403; usePermission/<Can>、路由守卫、401 单飞刷新、停用会话兜底、Semi + token。 - e2e:新增 A1-QA-01 六类端到端用例(A1_E2E 门控)。 - openapi 与前端生成类型同步;.env.example 登记认证变量;实施报告入库。 影响面: - 新增运行时依赖 @nestjs/jwt、argon2(计划 A1-BE-02 已批准,版本精确锁定)。 - 迁移为 expand,不改既有表;本地需 prisma migrate 后重新 seed。 - 平台导航「用户与组织」由 /platform/users 调整为 /users。 回滚方式: - 代码整体回滚本提交;迁移为 expand,回滚代码不需删表; - 本地如需彻底回退按实施报告 §6 处理。 验证证据: - pnpm format:check / lint / typecheck 通过; - pnpm test:shared 95 / api 60 / web 55; - pnpm test:int:7 个(真实 PG/Redis); - A1_E2E=1 playwright test e2e/a1-identity.spec.ts:6/6; - 报告:docs/开发计划/灵犀_A1身份认证与用户管理_实施报告_V0.1.md
This commit is contained in:
1 parent
f0d218bd99
commit
4d9eb980af
120 files changed
+13112
-314
No files matched your search
+9
-3
@@ -22,9 +22,15 @@ S3_BUCKET_TEMP=lingxi-dev
|
||||
S3_ACCESS_KEY=<rustfs-access-key>
|
||||
S3_SECRET_KEY=<rustfs-secret-key>
|
||||
|
||||
# ---------- 认证(本地 dev 值,生产由密钥管理注入) ----------
|
||||
JWT_SECRET=dev-only-jwt-secret-change-me-32chars
|
||||
JWT_REFRESH_SECRET=dev-only-jwt-refresh-secret-change-me-32chars
|
||||
# ---------- 认证(本地 dev 值,生产由密钥管理注入;密钥至少 32 位) ----------
|
||||
JWT_ACCESS_SECRET=dev-only-jwt-access-secret-change-me-32
|
||||
JWT_REFRESH_SECRET=dev-only-jwt-refresh-secret-change-me-32
|
||||
# access / refresh 有效期(秒):15 分钟 / 7 天
|
||||
JWT_ACCESS_TTL_SECONDS=900
|
||||
JWT_REFRESH_TTL_SECONDS=604800
|
||||
# 登录失败阈值与锁定时长(分钟内累计失败达阈值即锁定)
|
||||
AUTH_MAX_LOGIN_FAILURES=5
|
||||
AUTH_LOCK_MINUTES=15
|
||||
|
||||
# ---------- API ----------
|
||||
PORT=3000
|
||||
|
||||
@@ -17,9 +17,11 @@
|
||||
"@lingxi/shared": "workspace:*",
|
||||
"@nestjs/common": "11.2.8",
|
||||
"@nestjs/core": "11.2.8",
|
||||
"@nestjs/jwt": "12.0.3",
|
||||
"@nestjs/platform-fastify": "11.2.8",
|
||||
"@nestjs/swagger": "11.4.7",
|
||||
"@prisma/client": "6.19.3",
|
||||
"argon2": "0.45.1",
|
||||
"dotenv": "18.0.9",
|
||||
"fastify": "5.12.7",
|
||||
"ioredis": "5.11.1",
|
||||
|
||||
@@ -2,10 +2,12 @@ import { REQUEST_ID_HEADER, ulid } from '@lingxi/shared';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
|
||||
import { SecurityModule } from './common/security/security.module';
|
||||
import { APP_CONFIG, AppConfigModule } from './config/config.module';
|
||||
import type { AppConfig } from './config/env';
|
||||
import { PrismaModule } from './infra/prisma/prisma.module';
|
||||
import { RedisModule } from './infra/redis/redis.module';
|
||||
import { IdentityModule } from './modules/identity/identity.module';
|
||||
import { PlatformModule } from './modules/platform/platform.module';
|
||||
|
||||
@Module({
|
||||
@@ -45,6 +47,8 @@ import { PlatformModule } from './modules/platform/platform.module';
|
||||
}),
|
||||
PrismaModule,
|
||||
RedisModule,
|
||||
SecurityModule,
|
||||
IdentityModule,
|
||||
PlatformModule,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { createApp, type CreatedApiApp } from './bootstrap';
|
||||
import { loadDotenv } from './config/load-dotenv';
|
||||
|
||||
/** 装配冒烟:不监听端口,验证 env / 模块 / 守卫链 / Swagger 装配可完成 */
|
||||
describe('bootstrap.createApp', () => {
|
||||
let created: CreatedApiApp;
|
||||
|
||||
beforeAll(async () => {
|
||||
loadDotenv();
|
||||
created = await createApp();
|
||||
await created.app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await created.app.close();
|
||||
});
|
||||
|
||||
it('应用装配成功,健康检查公开(不因守卫 401)', async () => {
|
||||
expect(created.config.port).toBeGreaterThan(0);
|
||||
expect(created.config.jwtAccessSecret.length).toBeGreaterThanOrEqual(32);
|
||||
const response = await created.app.inject({ method: 'GET', url: '/health' });
|
||||
expect([200, 503]).toContain(response.statusCode);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
type CallHandler,
|
||||
type ExecutionContext,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
type NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { defer, type Observable } from 'rxjs';
|
||||
import { tap } from 'rxjs';
|
||||
|
||||
import { AUDIT_ACTION_KEY } from './decorators';
|
||||
import {
|
||||
buildRequestContext,
|
||||
type RequestContext,
|
||||
RequestContextService,
|
||||
type RequestWithTrace,
|
||||
} from './request-context';
|
||||
|
||||
interface AuditParams {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
interface AuditPayload {
|
||||
action: string;
|
||||
result: 'success' | 'failure';
|
||||
actorId: string | null;
|
||||
account: string | null;
|
||||
targetId: string | null;
|
||||
traceId: string;
|
||||
durationMs: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function actionOf(context: ExecutionContext): string | null {
|
||||
const action = Reflect.getMetadata(AUDIT_ACTION_KEY, context.getHandler()) as unknown;
|
||||
return typeof action === 'string' ? action : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局请求上下文 + @Audit 结构化日志。
|
||||
* A1 先写日志(Pino);A5 审计表落地后在此追加落库,装饰器签名不变。
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuditInterceptor implements NestInterceptor {
|
||||
private readonly logger = new Logger('Audit');
|
||||
|
||||
constructor(
|
||||
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
|
||||
) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const request = context
|
||||
.switchToHttp()
|
||||
.getRequest<RequestWithTrace & { params?: AuditParams }>();
|
||||
const ctx = buildRequestContext(request);
|
||||
const action = actionOf(context);
|
||||
const targetId = request.params?.id ?? null;
|
||||
|
||||
return defer(() =>
|
||||
this.requestContext.run(ctx, () => {
|
||||
if (action === null) {
|
||||
return next.handle();
|
||||
}
|
||||
return next.handle().pipe(
|
||||
tap({
|
||||
next: () => {
|
||||
this.emit(ctx, action, 'success', targetId);
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
this.emit(ctx, action, 'failure', targetId, error);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private emit(
|
||||
ctx: RequestContext,
|
||||
action: string,
|
||||
result: AuditPayload['result'],
|
||||
targetId: string | null,
|
||||
error?: unknown,
|
||||
): void {
|
||||
const payload: AuditPayload = {
|
||||
action,
|
||||
result,
|
||||
actorId: ctx.userId,
|
||||
account: ctx.account,
|
||||
targetId,
|
||||
traceId: ctx.traceId,
|
||||
durationMs: Date.now() - ctx.startedAt,
|
||||
...(error === undefined
|
||||
? {}
|
||||
: { error: error instanceof Error ? error.message : '未知错误' }),
|
||||
};
|
||||
this.logger.log(JSON.stringify(payload));
|
||||
}
|
||||
}
|
||||
|
||||
/** 供 @Audit 断言测试使用:读取处理器上的审计动作名 */
|
||||
export function auditActionOf(handler: unknown): string | null {
|
||||
const action = Reflect.getMetadata(AUDIT_ACTION_KEY, handler as object) as unknown;
|
||||
return typeof action === 'string' ? action : null;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { DataScope, RoleSummary, UserStatus } from '@lingxi/shared';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
/** access token 载荷:只放用户 id,权限每请求按 DB 解析(角色变更即时生效) */
|
||||
export interface JwtAccessPayload {
|
||||
sub: string;
|
||||
typ: 'access';
|
||||
}
|
||||
|
||||
/** refresh token 载荷:jti 对应 refresh_tokens 主键,fam 为旋转家族(重放时整族吊销) */
|
||||
export interface JwtRefreshPayload {
|
||||
sub: string;
|
||||
typ: 'refresh';
|
||||
jti: string;
|
||||
fam: string;
|
||||
}
|
||||
|
||||
/** 守卫解析出的鉴权上下文(挂在 request.auth,并写入 AsyncLocalStorage) */
|
||||
export interface AuthContext {
|
||||
userId: string;
|
||||
account: string;
|
||||
status: UserStatus;
|
||||
roles: RoleSummary[];
|
||||
permissions: string[];
|
||||
dataScope: DataScope;
|
||||
mustChangePassword: boolean;
|
||||
}
|
||||
|
||||
export interface AuthenticatedRequest extends FastifyRequest {
|
||||
auth?: AuthContext;
|
||||
}
|
||||
|
||||
/** 登录 / 刷新时随令牌一起返回的调用上下文(用于日志与 @Audit) */
|
||||
export interface AuthRequestMeta {
|
||||
ip: string | null;
|
||||
userAgent: string | null;
|
||||
traceId: string;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createParamDecorator, type ExecutionContext, SetMetadata } from '@nestjs/common';
|
||||
|
||||
import type { AuthContext, AuthenticatedRequest } from './auth.types';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'auth:public';
|
||||
/** 标记公开接口(登录、刷新、健康检查);其余接口默认必须登录 */
|
||||
export const Public = (): MethodDecorator & ClassDecorator => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
|
||||
export const PERMISSIONS_KEY = 'auth:permissions';
|
||||
/** 声明接口所需权限点(任一满足即通过,多个权限点之间为 OR) */
|
||||
export const RequirePermissions = (...permissions: string[]): MethodDecorator & ClassDecorator =>
|
||||
SetMetadata(PERMISSIONS_KEY, permissions);
|
||||
|
||||
export const AUDIT_ACTION_KEY = 'audit:action';
|
||||
/** 关键动作审计:先写结构化日志(A5 落库与检索接审计表后再扩展) */
|
||||
export const Audit = (action: string): MethodDecorator & ClassDecorator =>
|
||||
SetMetadata(AUDIT_ACTION_KEY, action);
|
||||
|
||||
/** 取当前登录上下文;未登录(@Public 接口)返回 null */
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_data: unknown, context: ExecutionContext): AuthContext | null => {
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
return request.auth ?? null;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,174 @@
|
||||
import { ErrorCode, SPECIAL_PERMISSIONS } from '@lingxi/shared';
|
||||
import { type CanActivate, type ExecutionContext, Inject, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
|
||||
import { AppException } from '../errors/app-exception';
|
||||
import type { AuthContext, AuthenticatedRequest } from './auth.types';
|
||||
import { IS_PUBLIC_KEY, PERMISSIONS_KEY } from './decorators';
|
||||
import { PermissionService } from './permission.service';
|
||||
import { RequestContextService } from './request-context';
|
||||
import { TokenService } from './token.service';
|
||||
|
||||
const MUST_CHANGE_ALLOWED_PATHS: readonly string[] = [
|
||||
'/api/v1/me',
|
||||
'/api/v1/auth/change-password',
|
||||
'/api/v1/auth/logout',
|
||||
'/api/v1/auth/refresh',
|
||||
];
|
||||
|
||||
const SPECIAL_PERMISSION_SET: ReadonlySet<string> = new Set<string>(SPECIAL_PERMISSIONS);
|
||||
|
||||
function extractBearer(header: string | undefined): string | null {
|
||||
if (!header?.startsWith('Bearer ')) {
|
||||
return null;
|
||||
}
|
||||
const token = header.slice('Bearer '.length).trim();
|
||||
return token.length > 0 ? token : null;
|
||||
}
|
||||
|
||||
function isMustChangeAllowed(url: string): boolean {
|
||||
const path = url.split('?')[0] ?? url;
|
||||
return MUST_CHANGE_ALLOWED_PATHS.includes(path);
|
||||
}
|
||||
|
||||
function isPublic(reflector: Reflector, context: ExecutionContext): boolean {
|
||||
const value: unknown = reflector.getAllAndOverride(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
return value === true;
|
||||
}
|
||||
|
||||
function requiredPermissions(reflector: Reflector, context: ExecutionContext): string[] {
|
||||
const value: unknown = reflector.getAllAndOverride(PERMISSIONS_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.filter((code): code is string => typeof code === 'string');
|
||||
}
|
||||
|
||||
function authOf(context: ExecutionContext): AuthContext | null {
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
return request.auth ?? null;
|
||||
}
|
||||
|
||||
function requireAuth(context: ExecutionContext): AuthContext {
|
||||
const auth = authOf(context);
|
||||
if (auth === null) {
|
||||
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
|
||||
}
|
||||
return auth;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1) JWT 校验 → 每请求解析权限 → 强制改密拦截。
|
||||
* 守卫顺序由 SecurityModule 的 APP_GUARD 注册顺序保证:
|
||||
* JwtAuthGuard → PermissionGuard → SpecialPermissionGuard → ScopeGuard。
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(
|
||||
@Inject(Reflector) private readonly reflector: Reflector,
|
||||
@Inject(TokenService) private readonly tokens: TokenService,
|
||||
@Inject(PermissionService) private readonly permissions: PermissionService,
|
||||
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
if (isPublic(this.reflector, context)) {
|
||||
return true;
|
||||
}
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
const token = extractBearer(request.headers.authorization);
|
||||
if (token === null) {
|
||||
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
|
||||
}
|
||||
const payload = this.tokens.verifyAccess(token);
|
||||
const auth = await this.permissions.resolveAuth(payload.sub);
|
||||
if (auth === null) {
|
||||
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
|
||||
}
|
||||
if (auth.status === 'disabled') {
|
||||
throw new AppException({ code: ErrorCode.PERMISSION_DENIED, message: '账号已停用' });
|
||||
}
|
||||
if (auth.mustChangePassword && !isMustChangeAllowed(request.url)) {
|
||||
throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '请先修改初始密码' });
|
||||
}
|
||||
request.auth = auth;
|
||||
this.requestContext.patch({
|
||||
userId: auth.userId,
|
||||
account: auth.account,
|
||||
permissions: auth.permissions,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** 2) 普通权限点校验;特殊权限留给 SpecialPermissionGuard(40301 与 40303 分开) */
|
||||
@Injectable()
|
||||
export class PermissionGuard implements CanActivate {
|
||||
constructor(@Inject(Reflector) private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
if (isPublic(this.reflector, context)) {
|
||||
return true;
|
||||
}
|
||||
const normal = requiredPermissions(this.reflector, context).filter(
|
||||
(code) => !SPECIAL_PERMISSION_SET.has(code),
|
||||
);
|
||||
if (normal.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const auth = requireAuth(context);
|
||||
const missing = normal.filter((code) => !auth.permissions.includes(code));
|
||||
if (missing.length > 0) {
|
||||
throw new AppException({ code: ErrorCode.PERMISSION_DENIED, details: { missing } });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** 3) 特殊权限(导出 / AI 配置 / 审计导出 / 行业发布 / 客户字段审批)单独校验 */
|
||||
@Injectable()
|
||||
export class SpecialPermissionGuard implements CanActivate {
|
||||
constructor(@Inject(Reflector) private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
if (isPublic(this.reflector, context)) {
|
||||
return true;
|
||||
}
|
||||
const special = requiredPermissions(this.reflector, context).filter((code) =>
|
||||
SPECIAL_PERMISSION_SET.has(code),
|
||||
);
|
||||
if (special.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const auth = requireAuth(context);
|
||||
const missing = special.filter((code) => !auth.permissions.includes(code));
|
||||
if (missing.length > 0) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.SPECIAL_PERMISSION_REQUIRED,
|
||||
details: { missing },
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 4) 数据范围守卫骨架:A2 按 dataScope 注入 ScopeContext(部门 / 客户集合)。
|
||||
* A1 identity 接口不返回客户数据,非全局范围暂直接放行。
|
||||
*/
|
||||
@Injectable()
|
||||
export class ScopeGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const auth = authOf(context);
|
||||
if (auth !== null && auth.dataScope.type !== 'all') {
|
||||
// TODO(A2-BE-04):按 dataScope 注入 ScopeContext,并在 Repository 强制带 customer_id 过滤
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { DataScope, RoleSummary, UserStatus } from '@lingxi/shared';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||
import { PRISMA } from '../../infra/tokens';
|
||||
import type { AuthContext } from './auth.types';
|
||||
|
||||
/** 鉴权解析需要的用户关联:部门(含父级)、角色(含权限点) */
|
||||
export type UserAuthRecord = Prisma.UserGetPayload<{
|
||||
include: {
|
||||
department: { include: { parent: true } };
|
||||
roles: { include: { role: { include: { permissions: true } } } };
|
||||
};
|
||||
}>;
|
||||
|
||||
const ALL_DATA_SCOPE_ROLES: readonly string[] = ['super_admin', 'platform_admin'];
|
||||
|
||||
function toRoleSummary(role: UserAuthRecord['roles'][number]['role']): RoleSummary {
|
||||
return {
|
||||
id: role.id,
|
||||
code: role.code,
|
||||
name: role.name,
|
||||
isSystem: role.isSystem,
|
||||
...(role.description.length > 0 ? { description: role.description } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 数据范围骨架:A1 只分「全部 / 本部门及下级」,A2 再落六类范围与客户集合解析 */
|
||||
export function resolveDataScope(roleCodes: readonly string[]): DataScope {
|
||||
const isGlobal = roleCodes.some((code) => ALL_DATA_SCOPE_ROLES.includes(code));
|
||||
return isGlobal ? { type: 'all' } : { type: 'department_and_children' };
|
||||
}
|
||||
|
||||
export function activeRolesOf(user: UserAuthRecord): UserAuthRecord['roles'] {
|
||||
return user.roles.filter((item) => item.role.status === 'ACTIVE');
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限解析:**每个请求实时读 DB**,不缓存角色/权限点,
|
||||
* 保证角色变更在下一次请求立即生效(A1-BE-04 验收项)。
|
||||
*/
|
||||
@Injectable()
|
||||
export class PermissionService {
|
||||
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
|
||||
|
||||
async resolveAuth(userId: string): Promise<AuthContext | null> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
department: { include: { parent: true } },
|
||||
roles: { include: { role: { include: { permissions: true } } } },
|
||||
},
|
||||
});
|
||||
if (user === null) {
|
||||
return null;
|
||||
}
|
||||
return this.toAuthContext(user);
|
||||
}
|
||||
|
||||
async findAuthRecord(userId: string): Promise<UserAuthRecord | null> {
|
||||
return this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
department: { include: { parent: true } },
|
||||
roles: { include: { role: { include: { permissions: true } } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
toAuthContext(user: UserAuthRecord): AuthContext {
|
||||
const roles = activeRolesOf(user);
|
||||
const permissions = [
|
||||
...new Set(roles.flatMap((item) => item.role.permissions.map((p) => p.permissionCode))),
|
||||
].sort();
|
||||
const status: UserStatus = user.status === 'ACTIVE' ? 'active' : 'disabled';
|
||||
return {
|
||||
userId: user.id,
|
||||
account: user.account,
|
||||
status,
|
||||
roles: roles.map((item) => toRoleSummary(item.role)),
|
||||
permissions,
|
||||
dataScope: resolveDataScope(roles.map((item) => item.role.code)),
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
|
||||
import { ulid } from '@lingxi/shared';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type { AuthenticatedRequest } from './auth.types';
|
||||
|
||||
/** 单次请求上下文:trace_id / 当前用户 / 权限快照(@Audit 与业务日志复用) */
|
||||
export interface RequestContext {
|
||||
traceId: string;
|
||||
userId: string | null;
|
||||
account: string | null;
|
||||
permissions: string[] | null;
|
||||
ip: string | null;
|
||||
userAgent: string | null;
|
||||
method: string;
|
||||
url: string;
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
export type RequestWithTrace = AuthenticatedRequest & { id?: string };
|
||||
|
||||
function traceIdOf(request: RequestWithTrace): string {
|
||||
return typeof request.id === 'string' && request.id.length > 0 ? request.id : ulid();
|
||||
}
|
||||
|
||||
/** 由请求构造上下文(与 Pino requestId 同源,x-request-id 透传) */
|
||||
export function buildRequestContext(request: RequestWithTrace): RequestContext {
|
||||
const auth = request.auth;
|
||||
return {
|
||||
traceId: traceIdOf(request),
|
||||
userId: auth?.userId ?? null,
|
||||
account: auth?.account ?? null,
|
||||
permissions: auth?.permissions ?? null,
|
||||
ip: request.ip.length > 0 ? request.ip : null,
|
||||
userAgent: request.headers['user-agent'] ?? null,
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* AsyncLocalStorage 请求上下文:拦截器在进入 handler 前建立,
|
||||
* 业务 Service 可 `get()` 读取 trace_id / 当前用户,无需逐层透传。
|
||||
*/
|
||||
@Injectable()
|
||||
export class RequestContextService {
|
||||
private readonly storage = new AsyncLocalStorage<RequestContext>();
|
||||
|
||||
run<T>(context: RequestContext, callback: () => T): T {
|
||||
return this.storage.run(context, callback);
|
||||
}
|
||||
|
||||
get(): RequestContext | null {
|
||||
return this.storage.getStore() ?? null;
|
||||
}
|
||||
|
||||
patch(partial: Partial<RequestContext>): void {
|
||||
const store = this.storage.getStore();
|
||||
if (store === undefined) {
|
||||
return;
|
||||
}
|
||||
Object.assign(store, partial);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
import { AuditInterceptor } from './audit.interceptor';
|
||||
import { JwtAuthGuard, PermissionGuard, ScopeGuard, SpecialPermissionGuard } from './guards';
|
||||
import { PermissionService } from './permission.service';
|
||||
import { RequestContextService } from './request-context';
|
||||
import { TokenService } from './token.service';
|
||||
|
||||
/**
|
||||
* 全局安全能力:请求上下文(AsyncLocalStorage)、权限解析、JWT 与守卫链。
|
||||
* 守卫顺序:JwtAuthGuard → PermissionGuard → SpecialPermissionGuard → ScopeGuard。
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [JwtModule.register({})],
|
||||
providers: [
|
||||
RequestContextService,
|
||||
PermissionService,
|
||||
TokenService,
|
||||
AuditInterceptor,
|
||||
JwtAuthGuard,
|
||||
PermissionGuard,
|
||||
SpecialPermissionGuard,
|
||||
ScopeGuard,
|
||||
{ provide: APP_GUARD, useExisting: JwtAuthGuard },
|
||||
{ provide: APP_GUARD, useExisting: PermissionGuard },
|
||||
{ provide: APP_GUARD, useExisting: SpecialPermissionGuard },
|
||||
{ provide: APP_GUARD, useExisting: ScopeGuard },
|
||||
{ provide: APP_INTERCEPTOR, useExisting: AuditInterceptor },
|
||||
],
|
||||
exports: [RequestContextService, PermissionService, TokenService],
|
||||
})
|
||||
export class SecurityModule {}
|
||||
@@ -0,0 +1,314 @@
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { ErrorCode } from '@lingxi/shared';
|
||||
import type { ExecutionContext } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { testConfig } from '../../testing/test-app';
|
||||
import { AppException } from '../errors/app-exception';
|
||||
import type { AuthContext, AuthenticatedRequest } from './auth.types';
|
||||
import { AUDIT_ACTION_KEY, IS_PUBLIC_KEY, PERMISSIONS_KEY } from './decorators';
|
||||
import { JwtAuthGuard, PermissionGuard, ScopeGuard, SpecialPermissionGuard } from './guards';
|
||||
import type { UserAuthRecord } from './permission.service';
|
||||
import { PermissionService, resolveDataScope } from './permission.service';
|
||||
import type { RequestContextService } from './request-context';
|
||||
import { TokenService } from './token.service';
|
||||
|
||||
const jwt = new JwtService({});
|
||||
const tokens = new TokenService(jwt, testConfig);
|
||||
|
||||
function contextOf(request: Partial<AuthenticatedRequest>): ExecutionContext {
|
||||
const handler = { name: 'handler' };
|
||||
const cls = { name: 'controller' };
|
||||
return {
|
||||
getHandler: () => handler,
|
||||
getClass: () => cls,
|
||||
switchToHttp: () => ({ getRequest: () => request }),
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
function reflectorFor(metadata: Record<string, unknown>): {
|
||||
getAllAndOverride: (key: string) => unknown;
|
||||
} {
|
||||
return { getAllAndOverride: (key: string) => metadata[key] };
|
||||
}
|
||||
|
||||
function authFixture(overrides: Partial<AuthContext> = {}): AuthContext {
|
||||
return {
|
||||
userId: 'u1',
|
||||
account: 'chen.mo',
|
||||
status: 'active',
|
||||
roles: [],
|
||||
permissions: ['user:view'],
|
||||
dataScope: { type: 'department_and_children' },
|
||||
mustChangePassword: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function expectCode(fn: () => unknown, code: ErrorCode): void {
|
||||
try {
|
||||
fn();
|
||||
expect.unreachable('应抛出异常');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(AppException);
|
||||
expect((error as AppException).code).toBe(code);
|
||||
}
|
||||
}
|
||||
|
||||
function expectRejected(promise: Promise<unknown>, code: ErrorCode): Promise<void> {
|
||||
return promise.then(
|
||||
() => expect.unreachable('应抛出异常'),
|
||||
(error: unknown) => {
|
||||
expect(error).toBeInstanceOf(AppException);
|
||||
expect((error as AppException).code).toBe(code);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function authRecord(
|
||||
status: 'ACTIVE' | 'DISABLED' = 'ACTIVE',
|
||||
roleCodes: string[] = ['platform_admin'],
|
||||
): UserAuthRecord {
|
||||
return {
|
||||
id: 'u1',
|
||||
account: 'chen.mo',
|
||||
name: '陈默',
|
||||
phone: '13866202046',
|
||||
email: 'chen.mo@lingxi.cn',
|
||||
passwordHash: 'hash',
|
||||
departmentId: 'd1',
|
||||
jobTitle: null,
|
||||
status,
|
||||
mustChangePassword: false,
|
||||
lastLoginAt: new Date('2026-11-02T01:41:00.000Z'),
|
||||
lastLoginIp: null,
|
||||
disabledAt: null,
|
||||
remark: null,
|
||||
createdBy: null,
|
||||
createdAt: new Date('2026-09-30T10:40:00.000Z'),
|
||||
updatedAt: new Date('2026-11-01T08:22:00.000Z'),
|
||||
department: {
|
||||
id: 'd1',
|
||||
code: 'content-g1',
|
||||
name: '素材一组',
|
||||
parentId: 'd0',
|
||||
leaderUserId: null,
|
||||
sortOrder: 1,
|
||||
status: 'ACTIVE',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
parent: {
|
||||
id: 'd0',
|
||||
code: 'content',
|
||||
name: '内容生产中心',
|
||||
parentId: null,
|
||||
leaderUserId: null,
|
||||
sortOrder: 1,
|
||||
status: 'ACTIVE',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
},
|
||||
roles: roleCodes.map((code, index) => ({
|
||||
userId: 'u1',
|
||||
roleId: `r${String(index)}`,
|
||||
createdAt: new Date(),
|
||||
role: {
|
||||
id: `r${String(index)}`,
|
||||
code,
|
||||
name: code,
|
||||
description: '',
|
||||
isSystem: code === 'super_admin',
|
||||
status: 'ACTIVE',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
permissions: [
|
||||
{ roleId: `r${String(index)}`, permissionCode: 'user:view', createdAt: new Date() },
|
||||
{ roleId: `r${String(index)}`, permissionCode: 'user:manage', createdAt: new Date() },
|
||||
],
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe('TokenService', () => {
|
||||
it('access token 可签发并校验,载荷只含 userId', () => {
|
||||
const token = tokens.issueAccess('u1');
|
||||
const payload = tokens.verifyAccess(token);
|
||||
expect(payload.sub).toBe('u1');
|
||||
expect(payload.typ).toBe('access');
|
||||
});
|
||||
|
||||
it('refresh token 带 jti 与家族,类型不可混用', () => {
|
||||
const refresh = tokens.issueRefresh('u1', 'jti1', 'fam1');
|
||||
const payload = tokens.verifyRefresh(refresh);
|
||||
expect(payload.jti).toBe('jti1');
|
||||
expect(payload.fam).toBe('fam1');
|
||||
expectCode(() => tokens.verifyAccess(refresh), ErrorCode.SIGNATURE_INVALID);
|
||||
});
|
||||
|
||||
it('过期 token 返回 40102,篡改 token 返回 40103', () => {
|
||||
const expired = jwt.sign(
|
||||
{ sub: 'u1', typ: 'access' },
|
||||
{ secret: testConfig.jwtAccessSecret, expiresIn: -10 },
|
||||
);
|
||||
expectCode(() => tokens.verifyAccess(expired), ErrorCode.TOKEN_EXPIRED);
|
||||
expectCode(
|
||||
() => tokens.verifyAccess(`${tokens.issueAccess('u1')}x`),
|
||||
ErrorCode.SIGNATURE_INVALID,
|
||||
);
|
||||
expectCode(() => tokens.verifyAccess('not-a-jwt'), ErrorCode.SIGNATURE_INVALID);
|
||||
});
|
||||
|
||||
it('refresh 哈希稳定且 access TTL 来自配置', () => {
|
||||
expect(tokens.hashToken('abc')).toBe(tokens.hashToken('abc'));
|
||||
expect(tokens.hashToken('abc')).not.toBe(tokens.hashToken('abd'));
|
||||
expect(tokens.accessTtlSeconds).toBe(900);
|
||||
expect(tokens.refreshTtlSeconds).toBe(604800);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PermissionService / resolveDataScope', () => {
|
||||
it('数据范围:平台 / 超管为 all,其余为本部门及下级', () => {
|
||||
expect(resolveDataScope(['super_admin']).type).toBe('all');
|
||||
expect(resolveDataScope(['designer']).type).toBe('department_and_children');
|
||||
});
|
||||
|
||||
it('鉴权上下文:过滤停用角色、权限去重排序、状态转小写', () => {
|
||||
const service = new PermissionService({} as never);
|
||||
const context = service.toAuthContext(authRecord());
|
||||
expect(context.permissions).toEqual(['user:manage', 'user:view']);
|
||||
expect(context.status).toBe('active');
|
||||
expect(context.roles).toHaveLength(1);
|
||||
expect(context.dataScope.type).toBe('all');
|
||||
});
|
||||
|
||||
it('resolveAuth 用户不存在返回 null', async () => {
|
||||
const prisma = { user: { findUnique: vi.fn().mockResolvedValue(null) } };
|
||||
const service = new PermissionService(prisma as never);
|
||||
await expect(service.resolveAuth('missing')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('resolveAuth 读取关联角色并返回上下文', async () => {
|
||||
const prisma = {
|
||||
user: { findUnique: vi.fn().mockResolvedValue(authRecord('ACTIVE', ['designer'])) },
|
||||
};
|
||||
const service = new PermissionService(prisma as never);
|
||||
const context = await service.resolveAuth('u1');
|
||||
expect(context?.userId).toBe('u1');
|
||||
expect(context?.dataScope.type).toBe('department_and_children');
|
||||
expect(context?.roles[0]?.code).toBe('designer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('JwtAuthGuard', () => {
|
||||
const permissions = { resolveAuth: vi.fn() };
|
||||
const patch = vi.fn();
|
||||
const requestContext = { patch } as unknown as RequestContextService;
|
||||
|
||||
function guardOf(metadata: Record<string, unknown>): JwtAuthGuard {
|
||||
return new JwtAuthGuard(
|
||||
reflectorFor(metadata) as never,
|
||||
tokens,
|
||||
permissions as unknown as PermissionService,
|
||||
requestContext,
|
||||
);
|
||||
}
|
||||
|
||||
it('@Public 直接放行', async () => {
|
||||
const guard = guardOf({ [IS_PUBLIC_KEY]: true });
|
||||
await expect(guard.canActivate(contextOf({}))).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('缺少 Bearer token 返回 40101', async () => {
|
||||
const guard = guardOf({});
|
||||
await expectRejected(guard.canActivate(contextOf({ headers: {} })), ErrorCode.UNAUTHENTICATED);
|
||||
});
|
||||
|
||||
it('账号停用返回 40301,未改初始密码返回 40903', async () => {
|
||||
const disabled = guardOf({});
|
||||
permissions.resolveAuth.mockResolvedValueOnce(authFixture({ status: 'disabled' }));
|
||||
await expectRejected(
|
||||
disabled.canActivate(
|
||||
contextOf({ headers: { authorization: `Bearer ${tokens.issueAccess('u1')}` } }),
|
||||
),
|
||||
ErrorCode.PERMISSION_DENIED,
|
||||
);
|
||||
|
||||
const mustChange = guardOf({});
|
||||
permissions.resolveAuth.mockResolvedValueOnce(authFixture({ mustChangePassword: true }));
|
||||
await expectRejected(
|
||||
mustChange.canActivate(
|
||||
contextOf({
|
||||
headers: { authorization: `Bearer ${tokens.issueAccess('u1')}` },
|
||||
url: '/api/v1/users',
|
||||
}),
|
||||
),
|
||||
ErrorCode.STATE_NOT_ALLOWED,
|
||||
);
|
||||
});
|
||||
|
||||
it('成功时挂载 request.auth 并写入上下文', async () => {
|
||||
const guard = guardOf({});
|
||||
permissions.resolveAuth.mockResolvedValueOnce(authFixture());
|
||||
const request = {
|
||||
headers: { authorization: `Bearer ${tokens.issueAccess('u1')}` },
|
||||
url: '/api/v1/users',
|
||||
} as unknown as AuthenticatedRequest;
|
||||
await guard.canActivate(contextOf(request));
|
||||
expect(request.auth?.userId).toBe('u1');
|
||||
expect(patch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PermissionGuard / SpecialPermissionGuard / ScopeGuard', () => {
|
||||
const auth = authFixture();
|
||||
const request = { auth } as AuthenticatedRequest;
|
||||
|
||||
it('缺普通权限点返回 40301,特殊权限留给下一守卫', () => {
|
||||
const guard = new PermissionGuard(
|
||||
reflectorFor({ [PERMISSIONS_KEY]: ['user:manage'] }) as never,
|
||||
);
|
||||
expect(() => guard.canActivate(contextOf(request))).toThrow(AppException);
|
||||
try {
|
||||
guard.canActivate(contextOf(request));
|
||||
} catch (error) {
|
||||
expect((error as AppException).code).toBe(ErrorCode.PERMISSION_DENIED);
|
||||
}
|
||||
|
||||
const specialOnly = new PermissionGuard(
|
||||
reflectorFor({ [PERMISSIONS_KEY]: ['audit:export'] }) as never,
|
||||
);
|
||||
expect(specialOnly.canActivate(contextOf(request))).toBe(true);
|
||||
});
|
||||
|
||||
it('特殊权限缺失返回 40303,已授予则通过', () => {
|
||||
const missing = new SpecialPermissionGuard(
|
||||
reflectorFor({ [PERMISSIONS_KEY]: ['audit:export'] }) as never,
|
||||
);
|
||||
expectCode(
|
||||
() => missing.canActivate(contextOf(request)),
|
||||
ErrorCode.SPECIAL_PERMISSION_REQUIRED,
|
||||
);
|
||||
|
||||
const granted = new SpecialPermissionGuard(
|
||||
reflectorFor({ [PERMISSIONS_KEY]: ['user:view'] }) as never,
|
||||
);
|
||||
expect(granted.canActivate(contextOf(request))).toBe(true);
|
||||
});
|
||||
|
||||
it('未登录时普通守卫返回 40101;ScopeGuard 骨架放行', () => {
|
||||
const guard = new PermissionGuard(reflectorFor({ [PERMISSIONS_KEY]: ['user:view'] }) as never);
|
||||
expectCode(() => guard.canActivate(contextOf({})), ErrorCode.UNAUTHENTICATED);
|
||||
expect(new ScopeGuard().canActivate(contextOf(request))).toBe(true);
|
||||
expect(new ScopeGuard().canActivate(contextOf({}))).toBe(true);
|
||||
});
|
||||
|
||||
it('@Audit 元数据可读取', () => {
|
||||
const handler = (): void => undefined;
|
||||
Reflect.defineMetadata(AUDIT_ACTION_KEY, 'identity.user.create', handler);
|
||||
expect(Reflect.getMetadata(AUDIT_ACTION_KEY, handler)).toBe('identity.user.create');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { ErrorCode } from '@lingxi/shared';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
|
||||
import { APP_CONFIG } from '../../config/config.module';
|
||||
import type { AppConfig } from '../../config/env';
|
||||
import { AppException } from '../errors/app-exception';
|
||||
import type { JwtAccessPayload, JwtRefreshPayload } from './auth.types';
|
||||
|
||||
type TokenType = 'access' | 'refresh';
|
||||
|
||||
function mapTokenError(error: unknown): AppException {
|
||||
if (error instanceof AppException) {
|
||||
return error;
|
||||
}
|
||||
if (error instanceof Error && error.name === 'TokenExpiredError') {
|
||||
return new AppException({ code: ErrorCode.TOKEN_EXPIRED });
|
||||
}
|
||||
return new AppException({ code: ErrorCode.SIGNATURE_INVALID });
|
||||
}
|
||||
|
||||
function assertTokenType<T extends { typ: TokenType }>(payload: T, expected: TokenType): T {
|
||||
if (payload.typ !== expected) {
|
||||
throw new AppException({ code: ErrorCode.SIGNATURE_INVALID, message: '令牌类型不正确' });
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 令牌签发与校验。
|
||||
* - access / refresh 使用不同密钥,避免 refresh 当 access 使用;
|
||||
* - refresh 载荷带 jti + fam,服务端 refresh_tokens 表保存哈希与吊销状态。
|
||||
*/
|
||||
@Injectable()
|
||||
export class TokenService {
|
||||
constructor(
|
||||
@Inject(JwtService) private readonly jwt: JwtService,
|
||||
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||
) {}
|
||||
|
||||
issueAccess(userId: string): string {
|
||||
const payload: JwtAccessPayload = { sub: userId, typ: 'access' };
|
||||
return this.jwt.sign(payload, {
|
||||
secret: this.config.jwtAccessSecret,
|
||||
expiresIn: this.config.jwtAccessTtlSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
issueRefresh(userId: string, jti: string, familyId: string): string {
|
||||
const payload: JwtRefreshPayload = { sub: userId, typ: 'refresh', jti, fam: familyId };
|
||||
return this.jwt.sign(payload, {
|
||||
secret: this.config.jwtRefreshSecret,
|
||||
expiresIn: this.config.jwtRefreshTtlSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
verifyAccess(token: string): JwtAccessPayload {
|
||||
return assertTokenType(this.decodeAccess(token), 'access');
|
||||
}
|
||||
|
||||
verifyRefresh(token: string): JwtRefreshPayload {
|
||||
return assertTokenType(this.decodeRefresh(token), 'refresh');
|
||||
}
|
||||
|
||||
hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
get accessTtlSeconds(): number {
|
||||
return this.config.jwtAccessTtlSeconds;
|
||||
}
|
||||
|
||||
get refreshTtlSeconds(): number {
|
||||
return this.config.jwtRefreshTtlSeconds;
|
||||
}
|
||||
|
||||
private decodeAccess(token: string): JwtAccessPayload {
|
||||
try {
|
||||
return this.jwt.verify<JwtAccessPayload>(token, { secret: this.config.jwtAccessSecret });
|
||||
} catch (error) {
|
||||
throw mapTokenError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private decodeRefresh(token: string): JwtRefreshPayload {
|
||||
try {
|
||||
return this.jwt.verify<JwtRefreshPayload>(token, { secret: this.config.jwtRefreshSecret });
|
||||
} catch (error) {
|
||||
throw mapTokenError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ const validEnv: NodeJS.ProcessEnv = {
|
||||
DATABASE_URL: 'postgresql://lingxi:lingxi@127.0.0.1:15432/lingxi_test',
|
||||
REDIS_URL: 'redis://127.0.0.1:16379',
|
||||
WEB_ORIGIN: 'http://127.0.0.1:5173,http://localhost:5173',
|
||||
JWT_ACCESS_SECRET: 'test-access-secret-at-least-32-characters',
|
||||
JWT_REFRESH_SECRET: 'test-refresh-secret-at-least-32-characters',
|
||||
};
|
||||
|
||||
describe('loadEnv', () => {
|
||||
@@ -20,6 +22,23 @@ describe('loadEnv', () => {
|
||||
expect(config.maxUploadMb).toBe(500);
|
||||
expect(config.featureVideo).toBe(false);
|
||||
expect(config.webOrigins).toEqual(['http://127.0.0.1:5173', 'http://localhost:5173']);
|
||||
expect(config.jwtAccessTtlSeconds).toBe(900);
|
||||
expect(config.jwtRefreshTtlSeconds).toBe(604800);
|
||||
expect(config.authMaxLoginFailures).toBe(5);
|
||||
expect(config.authLockMinutes).toBe(15);
|
||||
});
|
||||
|
||||
it('缺少 JWT 密钥时抛错并带字段名', () => {
|
||||
expect(() => loadEnv({ ...validEnv, JWT_ACCESS_SECRET: undefined })).toThrow(
|
||||
/JWT_ACCESS_SECRET/,
|
||||
);
|
||||
expect(() => loadEnv({ ...validEnv, JWT_REFRESH_SECRET: undefined })).toThrow(
|
||||
/JWT_REFRESH_SECRET/,
|
||||
);
|
||||
});
|
||||
|
||||
it('JWT 密钥过短会被拒绝', () => {
|
||||
expect(() => loadEnv({ ...validEnv, JWT_ACCESS_SECRET: 'short' })).toThrow(/JWT_ACCESS_SECRET/);
|
||||
});
|
||||
|
||||
it('字符串数字会被转成 number', () => {
|
||||
|
||||
@@ -25,6 +25,13 @@ const envSchema = z.object({
|
||||
.enum(['true', 'false'])
|
||||
.default('false')
|
||||
.transform((value) => value === 'true'),
|
||||
// ---------- 认证(A1 identity) ----------
|
||||
JWT_ACCESS_SECRET: z.string().min(32, 'JWT_ACCESS_SECRET 至少 32 位'),
|
||||
JWT_REFRESH_SECRET: z.string().min(32, 'JWT_REFRESH_SECRET 至少 32 位'),
|
||||
JWT_ACCESS_TTL_SECONDS: z.coerce.number().int().min(60).max(86400).default(900),
|
||||
JWT_REFRESH_TTL_SECONDS: z.coerce.number().int().min(3600).max(2592000).default(604800),
|
||||
AUTH_MAX_LOGIN_FAILURES: z.coerce.number().int().min(1).max(20).default(5),
|
||||
AUTH_LOCK_MINUTES: z.coerce.number().int().min(1).max(1440).default(15),
|
||||
});
|
||||
|
||||
export type RawEnv = z.infer<typeof envSchema>;
|
||||
@@ -40,6 +47,12 @@ export interface AppConfig {
|
||||
webOrigins: string[];
|
||||
maxUploadMb: number;
|
||||
featureVideo: boolean;
|
||||
jwtAccessSecret: string;
|
||||
jwtRefreshSecret: string;
|
||||
jwtAccessTtlSeconds: number;
|
||||
jwtRefreshTtlSeconds: number;
|
||||
authMaxLoginFailures: number;
|
||||
authLockMinutes: number;
|
||||
}
|
||||
|
||||
export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||
@@ -64,5 +77,11 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||
.filter((origin) => origin.length > 0),
|
||||
maxUploadMb: env.MAX_UPLOAD_MB,
|
||||
featureVideo: env.FEATURE_VIDEO,
|
||||
jwtAccessSecret: env.JWT_ACCESS_SECRET,
|
||||
jwtRefreshSecret: env.JWT_REFRESH_SECRET,
|
||||
jwtAccessTtlSeconds: env.JWT_ACCESS_TTL_SECONDS,
|
||||
jwtRefreshTtlSeconds: env.JWT_REFRESH_TTL_SECONDS,
|
||||
authMaxLoginFailures: env.AUTH_MAX_LOGIN_FAILURES,
|
||||
authLockMinutes: env.AUTH_LOCK_MINUTES,
|
||||
};
|
||||
}
|
||||
@@ -5,13 +5,18 @@ import { config as loadDotenvFile } from 'dotenv';
|
||||
|
||||
/**
|
||||
* 按工程规范 09 的顺序加载本地环境变量:.env.local 优先,.env 兜底。
|
||||
* 同时向上查找两级目录,兼容「从 apps/api 或包内跑 vitest」的工作目录差异。
|
||||
* 只做加载,不做校验(校验见 config/env.ts)。
|
||||
*/
|
||||
export function loadDotenv(): void {
|
||||
const starts = [process.cwd(), resolve(process.cwd(), '..'), resolve(process.cwd(), '..', '..')];
|
||||
const directories = [...new Set(starts.map((item) => resolve(item)))];
|
||||
for (const file of ['.env.local', '.env']) {
|
||||
const path = resolve(process.cwd(), file);
|
||||
if (existsSync(path)) {
|
||||
loadDotenvFile({ path });
|
||||
for (const directory of directories) {
|
||||
const path = resolve(directory, file);
|
||||
if (existsSync(path)) {
|
||||
loadDotenvFile({ path });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
changePasswordInputSchema,
|
||||
ErrorCode,
|
||||
type LoginInput,
|
||||
loginInputSchema,
|
||||
type LoginResponse,
|
||||
type LogoutInput,
|
||||
logoutInputSchema,
|
||||
type RefreshInput,
|
||||
refreshInputSchema,
|
||||
type TokenPair,
|
||||
} from '@lingxi/shared';
|
||||
import { Body, Controller, HttpCode, Inject, Post, Req } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||
import type { AuthContext, AuthRequestMeta } from '../../common/security/auth.types';
|
||||
import { Audit, CurrentUser, Public } from '../../common/security/decorators';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
function metaOf(request: FastifyRequest & { id?: string }): AuthRequestMeta {
|
||||
return {
|
||||
ip: request.ip.length > 0 ? request.ip : null,
|
||||
userAgent: request.headers['user-agent'] ?? null,
|
||||
traceId: typeof request.id === 'string' ? request.id : '',
|
||||
};
|
||||
}
|
||||
|
||||
/** 认证与会话:登录(返回 tokenPair + me)、刷新旋转、登出、强制改密 */
|
||||
@ApiTags('identity-auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(@Inject(AuthService) private readonly auth: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@Post('login')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({
|
||||
summary: '账号密码登录',
|
||||
description: '返回 accessToken / refreshToken / expiresIn / me',
|
||||
})
|
||||
async login(
|
||||
@Body(new ZodValidationPipe(loginInputSchema)) input: LoginInput,
|
||||
@Req() request: FastifyRequest,
|
||||
): Promise<LoginResponse> {
|
||||
return this.auth.login(input, metaOf(request));
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('refresh')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: '刷新令牌(旋转,旧 refresh 立即失效)' })
|
||||
async refresh(
|
||||
@Body(new ZodValidationPipe(refreshInputSchema)) input: RefreshInput,
|
||||
): Promise<TokenPair> {
|
||||
return this.auth.refresh(input);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('logout')
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: '登出', description: '吊销 refresh 令牌所在家族' })
|
||||
async logout(@Body(new ZodValidationPipe(logoutInputSchema)) input: LogoutInput): Promise<void> {
|
||||
await this.auth.logout(input);
|
||||
}
|
||||
|
||||
@Post('change-password')
|
||||
@HttpCode(204)
|
||||
@Audit('identity.auth.change-password')
|
||||
@ApiOperation({ summary: '修改密码(首登强改)' })
|
||||
async changePassword(
|
||||
@Body(new ZodValidationPipe(changePasswordInputSchema))
|
||||
input: {
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
},
|
||||
@CurrentUser() auth: AuthContext | null,
|
||||
): Promise<void> {
|
||||
if (auth === null) {
|
||||
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
|
||||
}
|
||||
await this.auth.changePassword(auth.userId, input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { ErrorCode } from '@lingxi/shared';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import type { User } from '@prisma/client';
|
||||
import { describe, expect, it, type Mock, vi } from 'vitest';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import type { PermissionService } from '../../common/security/permission.service';
|
||||
import { TokenService } from '../../common/security/token.service';
|
||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||
import { testConfig } from '../../testing/test-app';
|
||||
import { AuthService } from './auth.service';
|
||||
import type { LoginFailureService } from './login-failure.service';
|
||||
import type { MeService } from './me.service';
|
||||
|
||||
const tokens = new TokenService(new JwtService({}), testConfig);
|
||||
|
||||
function userFixture(overrides: Partial<User> = {}): User {
|
||||
const base: User = {
|
||||
id: 'u1',
|
||||
account: 'chen.mo',
|
||||
email: 'chen.mo@lingxi.cn',
|
||||
name: '陈默',
|
||||
phone: '13866202046',
|
||||
passwordHash: 'hash',
|
||||
departmentId: 'g1',
|
||||
jobTitle: null,
|
||||
status: 'ACTIVE',
|
||||
mustChangePassword: false,
|
||||
lastLoginAt: null,
|
||||
lastLoginIp: null,
|
||||
disabledAt: null,
|
||||
remark: null,
|
||||
createdBy: null,
|
||||
createdAt: new Date('2026-09-30T10:40:00.000Z'),
|
||||
updatedAt: new Date('2026-11-01T08:22:00.000Z'),
|
||||
};
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
|
||||
function expectRejected(promise: Promise<unknown>, code: ErrorCode): Promise<void> {
|
||||
return promise.then(
|
||||
() => expect.unreachable('应抛出异常'),
|
||||
(error: unknown) => {
|
||||
expect(error).toBeInstanceOf(AppException);
|
||||
expect((error as AppException).code).toBe(code);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
interface PrismaMock {
|
||||
user: { findFirst: Mock; findUnique: Mock; update: Mock };
|
||||
refreshToken: { create: Mock; findUnique: Mock; updateMany: Mock };
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
service: AuthService;
|
||||
prisma: PrismaMock;
|
||||
failures: ReturnType<typeof failureMock>;
|
||||
passwords: ReturnType<typeof passwordMock>;
|
||||
permissions: { resolveAuth: Mock };
|
||||
me: { getMe: Mock };
|
||||
}
|
||||
|
||||
function failureMock(): {
|
||||
status: ReturnType<typeof vi.fn>;
|
||||
record: ReturnType<typeof vi.fn>;
|
||||
clear: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
return {
|
||||
status: vi.fn().mockResolvedValue({ locked: false, remaining: 5 }),
|
||||
record: vi.fn().mockResolvedValue(1),
|
||||
clear: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function passwordMock(): {
|
||||
verify: ReturnType<typeof vi.fn>;
|
||||
hash: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
return { verify: vi.fn().mockResolvedValue(true), hash: vi.fn().mockResolvedValue('new-hash') };
|
||||
}
|
||||
|
||||
function harness(): Harness {
|
||||
const prisma: PrismaMock = {
|
||||
user: {
|
||||
findFirst: vi.fn().mockResolvedValue(userFixture()),
|
||||
findUnique: vi.fn().mockResolvedValue(userFixture()),
|
||||
update: vi.fn().mockResolvedValue(userFixture()),
|
||||
},
|
||||
refreshToken: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
};
|
||||
const failures = failureMock();
|
||||
const passwords = passwordMock();
|
||||
const permissions = {
|
||||
resolveAuth: vi.fn().mockResolvedValue({
|
||||
userId: 'u1',
|
||||
account: 'chen.mo',
|
||||
status: 'active',
|
||||
roles: [],
|
||||
permissions: ['user:manage'],
|
||||
dataScope: { type: 'all' },
|
||||
mustChangePassword: false,
|
||||
}),
|
||||
};
|
||||
const me = {
|
||||
getMe: vi.fn().mockResolvedValue({
|
||||
user: { id: 'u1' },
|
||||
roles: [],
|
||||
permissions: [],
|
||||
dataScope: { type: 'all' },
|
||||
mustChangePassword: false,
|
||||
}),
|
||||
};
|
||||
const service = new AuthService(
|
||||
prisma as unknown as PrismaService,
|
||||
testConfig,
|
||||
tokens,
|
||||
passwords,
|
||||
failures as unknown as LoginFailureService,
|
||||
permissions as unknown as PermissionService,
|
||||
me as unknown as MeService,
|
||||
);
|
||||
return { service, prisma, failures, passwords, permissions, me };
|
||||
}
|
||||
|
||||
const meta = { ip: '127.0.0.1', userAgent: 'vitest', traceId: 'trace-1' };
|
||||
|
||||
describe('AuthService.login', () => {
|
||||
it('成功登录返回 tokenPair + me,并清空失败计数、记录登录时间', async () => {
|
||||
const { service, prisma, failures } = harness();
|
||||
const result = await service.login({ account: 'chen.mo', password: 'lingxi2026' }, meta);
|
||||
expect(result.accessToken.length).toBeGreaterThan(0);
|
||||
expect(result.refreshToken.length).toBeGreaterThan(0);
|
||||
expect(result.me).toBeDefined();
|
||||
expect(failures.clear).toHaveBeenCalledWith('chen.mo');
|
||||
expect(prisma.user.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('锁定期间拒绝登录(42901);账号不存在与密码错误统一返回 40101', async () => {
|
||||
const locked = harness();
|
||||
locked.failures.status.mockResolvedValueOnce({ locked: true, remaining: 0 });
|
||||
await expectRejected(
|
||||
locked.service.login({ account: 'chen.mo', password: 'x' }, meta),
|
||||
ErrorCode.RATE_LIMITED,
|
||||
);
|
||||
|
||||
const missing = harness();
|
||||
missing.prisma.user.findFirst.mockResolvedValueOnce(null);
|
||||
await expectRejected(
|
||||
missing.service.login({ account: 'nobody', password: 'x' }, meta),
|
||||
ErrorCode.UNAUTHENTICATED,
|
||||
);
|
||||
expect(missing.failures.record).toHaveBeenCalled();
|
||||
|
||||
const wrong = harness();
|
||||
wrong.passwords.verify.mockResolvedValueOnce(false);
|
||||
await expectRejected(
|
||||
wrong.service.login({ account: 'chen.mo', password: 'bad-pass1' }, meta),
|
||||
ErrorCode.UNAUTHENTICATED,
|
||||
);
|
||||
});
|
||||
|
||||
it('停用账号拒绝登录(40301)', async () => {
|
||||
const { service, prisma } = harness();
|
||||
prisma.user.findFirst.mockResolvedValueOnce(userFixture({ status: 'DISABLED' }));
|
||||
await expectRejected(
|
||||
service.login({ account: 'chen.mo', password: 'lingxi2026' }, meta),
|
||||
ErrorCode.PERMISSION_DENIED,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthService.refresh / logout', () => {
|
||||
it('刷新旋转成功并吊销旧 jti', async () => {
|
||||
const { service, prisma } = harness();
|
||||
const refreshToken = tokens.issueRefresh('u1', 'jti1', 'fam1');
|
||||
prisma.refreshToken.findUnique.mockResolvedValueOnce({
|
||||
id: 'jti1',
|
||||
userId: 'u1',
|
||||
familyId: 'fam1',
|
||||
tokenHash: tokens.hashToken(refreshToken),
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
revokedAt: null,
|
||||
replacedById: null,
|
||||
ip: null,
|
||||
userAgent: null,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
const pair = await service.refresh({ refreshToken });
|
||||
expect(pair.refreshToken).not.toBe(refreshToken);
|
||||
expect(prisma.refreshToken.updateMany).toHaveBeenCalled();
|
||||
expect(prisma.refreshToken.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('已吊销 refresh 重放:整族吊销并返回 40101', async () => {
|
||||
const { service, prisma } = harness();
|
||||
const refreshToken = tokens.issueRefresh('u1', 'jti1', 'fam1');
|
||||
prisma.refreshToken.findUnique.mockResolvedValueOnce({
|
||||
id: 'jti1',
|
||||
userId: 'u1',
|
||||
familyId: 'fam1',
|
||||
tokenHash: tokens.hashToken(refreshToken),
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
revokedAt: new Date(),
|
||||
replacedById: null,
|
||||
ip: null,
|
||||
userAgent: null,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
await expectRejected(service.refresh({ refreshToken }), ErrorCode.UNAUTHENTICATED);
|
||||
expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { familyId: 'fam1', revokedAt: null } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('登出按 refresh 家族吊销;无 token 时按用户吊销', async () => {
|
||||
const { service, prisma } = harness();
|
||||
const refreshToken = tokens.issueRefresh('u1', 'jti1', 'fam1');
|
||||
prisma.refreshToken.findUnique.mockResolvedValueOnce({
|
||||
id: 'jti1',
|
||||
userId: 'u1',
|
||||
familyId: 'fam1',
|
||||
tokenHash: tokens.hashToken(refreshToken),
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
revokedAt: null,
|
||||
replacedById: null,
|
||||
ip: null,
|
||||
userAgent: null,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
await service.logout({ refreshToken });
|
||||
expect(prisma.refreshToken.updateMany).toHaveBeenCalled();
|
||||
await service.logout({}, 'u1');
|
||||
expect(prisma.refreshToken.updateMany).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthService.changePassword', () => {
|
||||
it('当前密码错误返回 40101;成功则改密并吊销全部会话', async () => {
|
||||
const wrong = harness();
|
||||
wrong.passwords.verify.mockResolvedValueOnce(false);
|
||||
await expectRejected(
|
||||
wrong.service.changePassword('u1', { currentPassword: 'wrong', newPassword: 'abc12345' }),
|
||||
ErrorCode.UNAUTHENTICATED,
|
||||
);
|
||||
|
||||
const { service, prisma } = harness();
|
||||
await service.changePassword('u1', { currentPassword: 'lingxi2026', newPassword: 'abc12345' });
|
||||
expect(prisma.user.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ mustChangePassword: false }) }),
|
||||
);
|
||||
expect(prisma.refreshToken.updateMany).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('新旧密码相同返回 42201', async () => {
|
||||
const { service } = harness();
|
||||
await expectRejected(
|
||||
service.changePassword('u1', { currentPassword: 'lingxi2026', newPassword: 'lingxi2026' }),
|
||||
ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import {
|
||||
type ChangePasswordInput,
|
||||
ErrorCode,
|
||||
type LoginInput,
|
||||
type LoginResponse,
|
||||
type LogoutInput,
|
||||
type RefreshInput,
|
||||
type TokenPair,
|
||||
ulid,
|
||||
} from '@lingxi/shared';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { RefreshToken, User } from '@prisma/client';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import type { AuthRequestMeta } from '../../common/security/auth.types';
|
||||
import { PermissionService } from '../../common/security/permission.service';
|
||||
import { TokenService } from '../../common/security/token.service';
|
||||
import { APP_CONFIG } from '../../config/config.module';
|
||||
import type { AppConfig } from '../../config/env';
|
||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||
import { PRISMA } from '../../infra/tokens';
|
||||
import { LoginFailureService } from './login-failure.service';
|
||||
import { MeService } from './me.service';
|
||||
import { PasswordService } from './password.service';
|
||||
|
||||
/** 认证与会话:登录、刷新旋转、登出、强制改密 */
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
@Inject(PRISMA) private readonly prisma: PrismaService,
|
||||
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||
@Inject(TokenService) private readonly tokens: TokenService,
|
||||
@Inject(PasswordService) private readonly passwords: PasswordService,
|
||||
@Inject(LoginFailureService) private readonly failures: LoginFailureService,
|
||||
@Inject(PermissionService) private readonly permissions: PermissionService,
|
||||
@Inject(MeService) private readonly me: MeService,
|
||||
) {}
|
||||
|
||||
async login(input: LoginInput, meta: AuthRequestMeta): Promise<LoginResponse> {
|
||||
const account = input.account.trim().toLowerCase();
|
||||
const status = await this.failures.status(account);
|
||||
if (status.locked) {
|
||||
throw this.lockedError();
|
||||
}
|
||||
const user = await this.findByAccount(account);
|
||||
if (user === null) {
|
||||
await this.failures.record(account);
|
||||
throw this.badCredentials();
|
||||
}
|
||||
const matched = await this.passwords.verify(user.passwordHash, input.password);
|
||||
if (!matched) {
|
||||
await this.recordFailure(account);
|
||||
throw this.badCredentials();
|
||||
}
|
||||
if (user.status === 'DISABLED') {
|
||||
throw new AppException({ code: ErrorCode.PERMISSION_DENIED, message: '账号已停用' });
|
||||
}
|
||||
await this.failures.clear(account);
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date(), lastLoginIp: meta.ip },
|
||||
});
|
||||
const auth = await this.permissions.resolveAuth(user.id);
|
||||
const { pair } = await this.createTokenPair(user.id, null, meta);
|
||||
const me = await this.me.getMe(user.id, auth?.permissions ?? []);
|
||||
return { ...pair, me };
|
||||
}
|
||||
|
||||
async refresh(input: RefreshInput): Promise<TokenPair> {
|
||||
const payload = this.tokens.verifyRefresh(input.refreshToken);
|
||||
const record = await this.prisma.refreshToken.findUnique({ where: { id: payload.jti } });
|
||||
if (record === null) {
|
||||
throw new AppException({ code: ErrorCode.SIGNATURE_INVALID, message: '刷新令牌无效' });
|
||||
}
|
||||
if (record.tokenHash !== this.tokens.hashToken(input.refreshToken)) {
|
||||
throw new AppException({ code: ErrorCode.SIGNATURE_INVALID, message: '刷新令牌无效' });
|
||||
}
|
||||
if (record.revokedAt !== null) {
|
||||
await this.revokeFamily(record.familyId);
|
||||
throw new AppException({
|
||||
code: ErrorCode.UNAUTHENTICATED,
|
||||
message: '刷新令牌已失效,请重新登录',
|
||||
});
|
||||
}
|
||||
if (record.expiresAt.getTime() <= Date.now()) {
|
||||
throw new AppException({ code: ErrorCode.TOKEN_EXPIRED });
|
||||
}
|
||||
const user = await this.prisma.user.findUnique({ where: { id: record.userId } });
|
||||
if (user === null || user.status === 'DISABLED') {
|
||||
throw new AppException({ code: ErrorCode.PERMISSION_DENIED, message: '账号已停用' });
|
||||
}
|
||||
return this.rotate(record);
|
||||
}
|
||||
|
||||
async logout(input: LogoutInput, userId?: string): Promise<void> {
|
||||
if (input.refreshToken !== undefined) {
|
||||
await this.revokeByToken(input.refreshToken);
|
||||
return;
|
||||
}
|
||||
if (userId !== undefined) {
|
||||
await this.revokeAllForUser(userId);
|
||||
}
|
||||
}
|
||||
|
||||
async changePassword(userId: string, input: ChangePasswordInput): Promise<void> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (user === null) {
|
||||
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
|
||||
}
|
||||
const matched = await this.passwords.verify(user.passwordHash, input.currentPassword);
|
||||
if (!matched) {
|
||||
throw new AppException({ code: ErrorCode.UNAUTHENTICATED, message: '当前密码不正确' });
|
||||
}
|
||||
if (input.currentPassword === input.newPassword) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
message: '新密码不能与当前密码相同',
|
||||
});
|
||||
}
|
||||
const passwordHash = await this.passwords.hash(input.newPassword);
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { passwordHash, mustChangePassword: false },
|
||||
});
|
||||
await this.revokeAllForUser(userId);
|
||||
}
|
||||
|
||||
private async findByAccount(account: string): Promise<User | null> {
|
||||
return this.prisma.user.findFirst({
|
||||
where: { OR: [{ account }, { email: account }] },
|
||||
});
|
||||
}
|
||||
|
||||
private async recordFailure(account: string): Promise<void> {
|
||||
const failures = await this.failures.record(account);
|
||||
if (failures >= this.config.authMaxLoginFailures) {
|
||||
throw this.lockedError();
|
||||
}
|
||||
}
|
||||
|
||||
private badCredentials(): AppException {
|
||||
return new AppException({ code: ErrorCode.UNAUTHENTICATED, message: '账号或密码不正确' });
|
||||
}
|
||||
|
||||
private lockedError(): AppException {
|
||||
return new AppException({
|
||||
code: ErrorCode.RATE_LIMITED,
|
||||
message: `登录失败次数过多,请 ${String(this.config.authLockMinutes)} 分钟后重试`,
|
||||
});
|
||||
}
|
||||
|
||||
private async createTokenPair(
|
||||
userId: string,
|
||||
familyId: string | null,
|
||||
meta: AuthRequestMeta,
|
||||
): Promise<{ pair: TokenPair; jti: string }> {
|
||||
const jti = ulid();
|
||||
const family = familyId ?? ulid();
|
||||
const accessToken = this.tokens.issueAccess(userId);
|
||||
const refreshToken = this.tokens.issueRefresh(userId, jti, family);
|
||||
await this.prisma.refreshToken.create({
|
||||
data: {
|
||||
id: jti,
|
||||
userId,
|
||||
familyId: family,
|
||||
tokenHash: this.tokens.hashToken(refreshToken),
|
||||
expiresAt: new Date(Date.now() + this.tokens.refreshTtlSeconds * 1000),
|
||||
ip: meta.ip,
|
||||
userAgent: meta.userAgent,
|
||||
},
|
||||
});
|
||||
return { pair: { accessToken, refreshToken, expiresIn: this.tokens.accessTtlSeconds }, jti };
|
||||
}
|
||||
|
||||
/** 刷新旋转:旧 token 原子吊销(revokedAt 为空才成功),新 token 同族;重放则整族吊销 */
|
||||
private async rotate(record: RefreshToken): Promise<TokenPair> {
|
||||
const { pair, jti } = await this.createTokenPair(record.userId, record.familyId, {
|
||||
ip: record.ip,
|
||||
userAgent: record.userAgent,
|
||||
traceId: '',
|
||||
});
|
||||
const updated = await this.prisma.refreshToken.updateMany({
|
||||
where: { id: record.id, revokedAt: null },
|
||||
data: { revokedAt: new Date(), replacedById: jti },
|
||||
});
|
||||
if (updated.count === 0) {
|
||||
await this.revokeFamily(record.familyId);
|
||||
throw new AppException({
|
||||
code: ErrorCode.UNAUTHENTICATED,
|
||||
message: '刷新令牌已失效,请重新登录',
|
||||
});
|
||||
}
|
||||
return pair;
|
||||
}
|
||||
|
||||
private async revokeByToken(token: string): Promise<void> {
|
||||
let payload;
|
||||
try {
|
||||
payload = this.tokens.verifyRefresh(token);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const record = await this.prisma.refreshToken.findUnique({ where: { id: payload.jti } });
|
||||
if (record === null) {
|
||||
return;
|
||||
}
|
||||
await this.revokeFamily(record.familyId);
|
||||
}
|
||||
|
||||
private async revokeFamily(familyId: string): Promise<void> {
|
||||
await this.prisma.refreshToken.updateMany({
|
||||
where: { familyId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
private async revokeAllForUser(userId: string): Promise<void> {
|
||||
await this.prisma.refreshToken.updateMany({
|
||||
where: { userId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Permission, ulidSchema } from '@lingxi/shared';
|
||||
import { Body, Controller, Get, Inject, Param, Patch, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||
import { Audit, RequirePermissions } from '../../common/security/decorators';
|
||||
import { DepartmentsService, type DepartmentTreeNode } from './departments.service';
|
||||
|
||||
const createDepartmentSchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1, '请输入部门名称').max(20, '部门名称最多 20 个字符'),
|
||||
parentId: ulidSchema.nullable().default(null),
|
||||
})
|
||||
.strict();
|
||||
|
||||
type CreateDepartmentBody = z.infer<typeof createDepartmentSchema>;
|
||||
|
||||
const updateDepartmentSchema = z
|
||||
.object({ name: z.string().trim().min(1, '请输入部门名称').max(20, '部门名称最多 20 个字符') })
|
||||
.strict();
|
||||
|
||||
type UpdateDepartmentBody = z.infer<typeof updateDepartmentSchema>;
|
||||
|
||||
/** 组织架构:两级部门树(A1 前端只读;写接口先备好,供后续批次使用) */
|
||||
@ApiTags('identity-departments')
|
||||
@ApiBearerAuth()
|
||||
@Controller('departments')
|
||||
export class DepartmentsController {
|
||||
constructor(@Inject(DepartmentsService) private readonly departments: DepartmentsService) {}
|
||||
|
||||
@Get('tree')
|
||||
@RequirePermissions(Permission.USER_VIEW)
|
||||
@ApiOperation({ summary: '部门树(两级,含成员数与负责人)' })
|
||||
async tree(): Promise<{ items: DepartmentTreeNode[] }> {
|
||||
return { items: await this.departments.tree() };
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions(Permission.USER_MANAGE)
|
||||
@Audit('identity.department.create')
|
||||
@ApiOperation({ summary: '新建部门 / 小组', description: '最多两级,在小组下新建返回 42201' })
|
||||
async create(
|
||||
@Body(new ZodValidationPipe(createDepartmentSchema)) input: CreateDepartmentBody,
|
||||
): Promise<{ id: string }> {
|
||||
return this.departments.create(input);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermissions(Permission.USER_MANAGE)
|
||||
@Audit('identity.department.rename')
|
||||
@ApiOperation({ summary: '部门改名' })
|
||||
async rename(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(updateDepartmentSchema)) input: UpdateDepartmentBody,
|
||||
): Promise<{ id: string }> {
|
||||
await this.departments.rename(id, input.name);
|
||||
return { id };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { type DepartmentNode, ErrorCode, ulid } from '@lingxi/shared';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||
import { PRISMA } from '../../infra/tokens';
|
||||
|
||||
/** 部门树节点:契约节点 + 子节点(最多两级) */
|
||||
export interface DepartmentTreeNode extends DepartmentNode {
|
||||
children: DepartmentTreeNode[];
|
||||
}
|
||||
|
||||
export interface CreateDepartmentPayload {
|
||||
name: string;
|
||||
parentId: string | null;
|
||||
}
|
||||
|
||||
function codeOf(name: string): string {
|
||||
const normalized = name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
return `${normalized.length > 0 ? normalized.slice(0, 40) : 'dept'}-${ulid().slice(-6).toLowerCase()}`;
|
||||
}
|
||||
|
||||
function mapDepartmentError(error: unknown): AppException {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||
return new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '同级部门名称已存在' });
|
||||
}
|
||||
return new AppException({ code: ErrorCode.INTERNAL_ERROR, message: '部门保存失败' });
|
||||
}
|
||||
|
||||
/** 组织架构:两级部门树(含成员数与负责人)、新建、改名 */
|
||||
@Injectable()
|
||||
export class DepartmentsService {
|
||||
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
|
||||
|
||||
async tree(): Promise<DepartmentTreeNode[]> {
|
||||
const [departments, counts] = await Promise.all([
|
||||
this.prisma.department.findMany({
|
||||
orderBy: [{ parentId: 'asc' }, { sortOrder: 'asc' }, { name: 'asc' }],
|
||||
include: { leader: { select: { id: true, name: true } } },
|
||||
}),
|
||||
this.prisma.user.groupBy({
|
||||
by: ['departmentId'],
|
||||
where: { status: 'ACTIVE' },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
const byDept = new Map(counts.map((row) => [row.departmentId, row._count._all]));
|
||||
const nodes = new Map<string, DepartmentTreeNode>();
|
||||
for (const department of departments) {
|
||||
nodes.set(department.id, {
|
||||
id: department.id,
|
||||
name: department.name,
|
||||
parentId: department.parentId,
|
||||
leader: department.leader,
|
||||
memberCount: byDept.get(department.id) ?? 0,
|
||||
createdAt: department.createdAt.toISOString(),
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
const roots: DepartmentTreeNode[] = [];
|
||||
for (const node of nodes.values()) {
|
||||
if (node.parentId === null) {
|
||||
roots.push(node);
|
||||
continue;
|
||||
}
|
||||
const parent = nodes.get(node.parentId);
|
||||
if (parent !== undefined) {
|
||||
parent.children.push(node);
|
||||
}
|
||||
}
|
||||
for (const root of roots) {
|
||||
root.memberCount += root.children.reduce((sum, child) => sum + child.memberCount, 0);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
async create(input: CreateDepartmentPayload): Promise<{ id: string }> {
|
||||
await this.assertParentUsable(input.parentId);
|
||||
try {
|
||||
const created = await this.prisma.department.create({
|
||||
data: {
|
||||
id: ulid(),
|
||||
name: input.name,
|
||||
parentId: input.parentId,
|
||||
code: codeOf(input.name),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return created;
|
||||
} catch (error) {
|
||||
throw mapDepartmentError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async rename(id: string, name: string): Promise<void> {
|
||||
const existing = await this.prisma.department.findUnique({ where: { id } });
|
||||
if (existing === null) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '部门不存在' });
|
||||
}
|
||||
try {
|
||||
await this.prisma.department.update({ where: { id }, data: { name } });
|
||||
} catch (error) {
|
||||
throw mapDepartmentError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertParentUsable(parentId: string | null): Promise<void> {
|
||||
if (parentId === null) {
|
||||
return;
|
||||
}
|
||||
const parent = await this.prisma.department.findUnique({ where: { id: parentId } });
|
||||
if (parent === null) {
|
||||
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '上级部门不存在' });
|
||||
}
|
||||
if (parent.parentId !== null) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
message: '部门最多两级,不能在小组下再建部门',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { API_PREFIX, ErrorCode, ulid } from '@lingxi/shared';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { createApp, type CreatedApiApp } from '../../bootstrap';
|
||||
import { loadDotenv } from '../../config/load-dotenv';
|
||||
|
||||
/** 与 prisma/seed.ts 一致的示例账号初始密码 lingxi2026(Argon2id) */
|
||||
const PASSWORD_HASH =
|
||||
'$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4';
|
||||
const PASSWORD = 'lingxi2026';
|
||||
const ACCOUNT_PREFIX = 'it.';
|
||||
const API = API_PREFIX;
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface FixtureUser {
|
||||
id: string;
|
||||
account: string;
|
||||
}
|
||||
|
||||
interface TokenBody {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
me: { permissions: string[]; user: { id: string; phone?: string }; mustChangePassword: boolean };
|
||||
}
|
||||
|
||||
interface ErrorBody {
|
||||
code: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
async function createFixtureUser(suffix: string, roleCode: string): Promise<FixtureUser> {
|
||||
const [department, role] = await Promise.all([
|
||||
prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } }),
|
||||
prisma.role.findUniqueOrThrow({ where: { code: roleCode } }),
|
||||
]);
|
||||
const account = `${ACCOUNT_PREFIX}${suffix}`;
|
||||
const id = ulid();
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id,
|
||||
name: `集成测试${suffix}`,
|
||||
account,
|
||||
email: `${account}@lingxi.cn`,
|
||||
phone: `137${String(Date.now()).slice(-8)}`,
|
||||
passwordHash: PASSWORD_HASH,
|
||||
departmentId: department.id,
|
||||
mustChangePassword: false,
|
||||
roles: { create: [{ roleId: role.id }] },
|
||||
},
|
||||
});
|
||||
return { id, account };
|
||||
}
|
||||
|
||||
async function login(
|
||||
app: CreatedApiApp['app'],
|
||||
account: string,
|
||||
): Promise<{ status: number; body: TokenBody }> {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: `${API}/auth/login`,
|
||||
payload: { account, password: PASSWORD },
|
||||
});
|
||||
return { status: response.statusCode, body: response.json<TokenBody>() };
|
||||
}
|
||||
|
||||
async function authed(
|
||||
app: CreatedApiApp['app'],
|
||||
method: 'GET' | 'POST' | 'PATCH',
|
||||
url: string,
|
||||
token: string,
|
||||
payload?: unknown,
|
||||
): Promise<number> {
|
||||
const response = await app.inject({
|
||||
method,
|
||||
url: `${API}${url}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
...(payload === undefined ? {} : { payload: payload as Record<string, unknown> }),
|
||||
});
|
||||
return response.statusCode;
|
||||
}
|
||||
|
||||
async function setRole(userId: string, roleCode: string): Promise<void> {
|
||||
const role = await prisma.role.findUniqueOrThrow({ where: { code: roleCode } });
|
||||
await prisma.userRole.deleteMany({ where: { userId } });
|
||||
await prisma.userRole.create({ data: { userId, roleId: role.id } });
|
||||
}
|
||||
|
||||
describe('identity 集成(真实 PG / Redis)', () => {
|
||||
let created: CreatedApiApp;
|
||||
let manager: FixtureUser;
|
||||
let viewer: FixtureUser;
|
||||
|
||||
beforeAll(async () => {
|
||||
loadDotenv();
|
||||
created = await createApp();
|
||||
await created.app.init();
|
||||
await prisma.user.deleteMany({ where: { account: { startsWith: ACCOUNT_PREFIX } } });
|
||||
manager = await createFixtureUser('manager', 'platform_admin');
|
||||
viewer = await createFixtureUser('viewer', 'designer');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.user.deleteMany({ where: { account: { startsWith: ACCOUNT_PREFIX } } });
|
||||
await prisma.$disconnect();
|
||||
await created.app.close();
|
||||
});
|
||||
|
||||
it('登录返回 tokenPair + me,且权限点来自角色', async () => {
|
||||
const { status, body } = await login(created.app, manager.account);
|
||||
expect(status).toBe(200);
|
||||
expect(body.refreshToken.length).toBeGreaterThan(0);
|
||||
expect(body.expiresIn).toBeGreaterThan(0);
|
||||
expect(body.me.permissions).toContain('user:manage');
|
||||
expect(body.me.mustChangePassword).toBe(false);
|
||||
expect(body.me.user.phone).toBeDefined();
|
||||
});
|
||||
|
||||
it('/me 与登录上下文一致;列表只回 phoneMasked,详情对 user:manage 回全号', async () => {
|
||||
const { body } = await login(created.app, manager.account);
|
||||
const meResponse = await created.app.inject({
|
||||
method: 'GET',
|
||||
url: `${API}/me`,
|
||||
headers: { authorization: `Bearer ${body.accessToken}` },
|
||||
});
|
||||
expect(meResponse.statusCode).toBe(200);
|
||||
const me = meResponse.json<{ user: { account: string } }>();
|
||||
expect(me.user.account).toBe(manager.account);
|
||||
|
||||
const listResponse = await created.app.inject({
|
||||
method: 'GET',
|
||||
url: `${API}/users?limit=5`,
|
||||
headers: { authorization: `Bearer ${body.accessToken}` },
|
||||
});
|
||||
expect(listResponse.statusCode).toBe(200);
|
||||
const list = listResponse.json<{
|
||||
items: Record<string, unknown>[];
|
||||
next_cursor: string | null;
|
||||
}>();
|
||||
expect(list.items.length).toBeGreaterThan(0);
|
||||
expect(list.items[0]?.phoneMasked).toMatch(/^\d{3}\*{4}\d{4}$/);
|
||||
expect(list.items[0]?.phone).toBeUndefined();
|
||||
expect(list.next_cursor === null || typeof list.next_cursor === 'string').toBe(true);
|
||||
|
||||
const detailResponse = await created.app.inject({
|
||||
method: 'GET',
|
||||
url: `${API}/users/${manager.id}`,
|
||||
headers: { authorization: `Bearer ${body.accessToken}` },
|
||||
});
|
||||
expect(detailResponse.statusCode).toBe(200);
|
||||
const detail = detailResponse.json<{ phone?: string }>();
|
||||
expect(detail.phone).toBeDefined();
|
||||
});
|
||||
|
||||
it('无 user:view 权限返回 40301;授予角色后下一次请求立即生效', async () => {
|
||||
const { body } = await login(created.app, viewer.account);
|
||||
expect(await authed(created.app, 'GET', '/users', body.accessToken)).toBe(403);
|
||||
await setRole(viewer.id, 'platform_admin');
|
||||
expect(await authed(created.app, 'GET', '/users', body.accessToken)).toBe(200);
|
||||
});
|
||||
|
||||
it('刷新旋转:旧 refresh 立即失效,重放触发整族吊销', async () => {
|
||||
const { body } = await login(created.app, manager.account);
|
||||
const refreshed = await created.app.inject({
|
||||
method: 'POST',
|
||||
url: `${API}/auth/refresh`,
|
||||
payload: { refreshToken: body.refreshToken },
|
||||
});
|
||||
expect(refreshed.statusCode).toBe(200);
|
||||
const next = refreshed.json<{ refreshToken: string }>();
|
||||
expect(next.refreshToken).not.toBe(body.refreshToken);
|
||||
const replay = await created.app.inject({
|
||||
method: 'POST',
|
||||
url: `${API}/auth/refresh`,
|
||||
payload: { refreshToken: body.refreshToken },
|
||||
});
|
||||
expect(replay.statusCode).toBe(401);
|
||||
const revoked = replay.json<ErrorBody>();
|
||||
expect(revoked.code).toBe(ErrorCode.UNAUTHENTICATED);
|
||||
});
|
||||
|
||||
it('停用后旧 access token 立即失效,且无法再登录', async () => {
|
||||
const { body } = await login(created.app, viewer.account);
|
||||
const managerLogin = await login(created.app, manager.account);
|
||||
const managerDisabled = await created.app.inject({
|
||||
method: 'POST',
|
||||
url: `${API}/users/${viewer.id}/disable`,
|
||||
headers: { authorization: `Bearer ${managerLogin.body.accessToken}` },
|
||||
payload: { reason: '集成测试停用' },
|
||||
});
|
||||
expect(managerDisabled.statusCode).toBe(200);
|
||||
const meAfter = await created.app.inject({
|
||||
method: 'GET',
|
||||
url: `${API}/me`,
|
||||
headers: { authorization: `Bearer ${body.accessToken}` },
|
||||
});
|
||||
expect(meAfter.statusCode).toBe(403);
|
||||
expect(meAfter.json<ErrorBody>().code).toBe(ErrorCode.PERMISSION_DENIED);
|
||||
const relogin = await login(created.app, viewer.account);
|
||||
expect(relogin.status).toBe(403);
|
||||
});
|
||||
|
||||
it('登出后 refresh 不可用;部门树与角色列表可读', async () => {
|
||||
const { body } = await login(created.app, manager.account);
|
||||
const logout = await created.app.inject({
|
||||
method: 'POST',
|
||||
url: `${API}/auth/logout`,
|
||||
payload: { refreshToken: body.refreshToken },
|
||||
});
|
||||
expect(logout.statusCode).toBe(204);
|
||||
const refreshAfter = await created.app.inject({
|
||||
method: 'POST',
|
||||
url: `${API}/auth/refresh`,
|
||||
payload: { refreshToken: body.refreshToken },
|
||||
});
|
||||
expect(refreshAfter.statusCode).toBe(401);
|
||||
|
||||
const tree = await created.app.inject({
|
||||
method: 'GET',
|
||||
url: `${API}/departments/tree`,
|
||||
headers: { authorization: `Bearer ${body.accessToken}` },
|
||||
});
|
||||
expect(tree.statusCode).toBe(200);
|
||||
const treeBody = tree.json<{ items: { name: string; children: unknown[] }[] }>();
|
||||
expect(treeBody.items.length).toBeGreaterThanOrEqual(3);
|
||||
expect(treeBody.items[0]?.children.length).toBeGreaterThan(0);
|
||||
|
||||
const roles = await created.app.inject({
|
||||
method: 'GET',
|
||||
url: `${API}/roles`,
|
||||
headers: { authorization: `Bearer ${body.accessToken}` },
|
||||
});
|
||||
expect(roles.statusCode).toBe(200);
|
||||
expect(roles.json<{ items: unknown[] }>().items.length).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { DepartmentsController } from './departments.controller';
|
||||
import { DepartmentsService } from './departments.service';
|
||||
import { LoginFailureService } from './login-failure.service';
|
||||
import { MeController } from './me.controller';
|
||||
import { MeService } from './me.service';
|
||||
import { PasswordService } from './password.service';
|
||||
import { RolesController } from './roles.controller';
|
||||
import { RolesService } from './roles.service';
|
||||
import { UsersController } from './users.controller';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
/**
|
||||
* identity 模块(技术架构 §3):组织与账号、认证与会话、角色绑定、/me 权限上下文。
|
||||
* 守卫链与请求上下文由 @Global SecurityModule 提供,本模块不重复注册。
|
||||
*/
|
||||
@Module({
|
||||
controllers: [
|
||||
AuthController,
|
||||
MeController,
|
||||
UsersController,
|
||||
DepartmentsController,
|
||||
RolesController,
|
||||
],
|
||||
providers: [
|
||||
AuthService,
|
||||
MeService,
|
||||
PasswordService,
|
||||
LoginFailureService,
|
||||
UsersService,
|
||||
DepartmentsService,
|
||||
RolesService,
|
||||
],
|
||||
})
|
||||
export class IdentityModule {}
|
||||
@@ -0,0 +1,399 @@
|
||||
import { ErrorCode } from '@lingxi/shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||
import { DepartmentsService } from './departments.service';
|
||||
import { LoginFailureService } from './login-failure.service';
|
||||
import { MeService } from './me.service';
|
||||
import { PasswordService } from './password.service';
|
||||
import { RolesService } from './roles.service';
|
||||
import { maskPhone, toUserDetail, toUserSummary, type UserWithRelations } from './user.mapper';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
function userFixture(overrides: Partial<UserWithRelations> = {}): UserWithRelations {
|
||||
const base: UserWithRelations = {
|
||||
id: 'u1',
|
||||
account: 'chen.mo',
|
||||
email: 'chen.mo@lingxi.cn',
|
||||
name: '陈默',
|
||||
phone: '13866202046',
|
||||
passwordHash: 'hash',
|
||||
departmentId: 'g1',
|
||||
jobTitle: '主管',
|
||||
status: 'ACTIVE',
|
||||
mustChangePassword: false,
|
||||
lastLoginAt: new Date('2026-11-02T01:41:00.000Z'),
|
||||
lastLoginIp: '192.168.12.46',
|
||||
disabledAt: null,
|
||||
remark: null,
|
||||
createdBy: null,
|
||||
createdAt: new Date('2026-09-30T10:40:00.000Z'),
|
||||
updatedAt: new Date('2026-11-01T08:22:00.000Z'),
|
||||
department: {
|
||||
id: 'g1',
|
||||
code: 'content-g1',
|
||||
name: '素材一组',
|
||||
parentId: 'd1',
|
||||
leaderUserId: null,
|
||||
sortOrder: 1,
|
||||
status: 'ACTIVE',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
parent: {
|
||||
id: 'd1',
|
||||
code: 'content',
|
||||
name: '内容生产中心',
|
||||
parentId: null,
|
||||
leaderUserId: null,
|
||||
sortOrder: 1,
|
||||
status: 'ACTIVE',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
},
|
||||
roles: [
|
||||
{
|
||||
userId: 'u1',
|
||||
roleId: 'r1',
|
||||
createdAt: new Date(),
|
||||
role: {
|
||||
id: 'r1',
|
||||
code: 'material_operator',
|
||||
name: '素材运营',
|
||||
description: '素材生产',
|
||||
isSystem: false,
|
||||
status: 'ACTIVE',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
},
|
||||
{
|
||||
userId: 'u1',
|
||||
roleId: 'r2',
|
||||
createdAt: new Date(),
|
||||
role: {
|
||||
id: 'r2',
|
||||
code: 'account_owner',
|
||||
name: '客户负责人',
|
||||
description: '',
|
||||
isSystem: false,
|
||||
status: 'DISABLED',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
|
||||
function expectRejected(promise: Promise<unknown>, code: ErrorCode): Promise<void> {
|
||||
return promise.then(
|
||||
() => expect.unreachable('应抛出异常'),
|
||||
(error: unknown) => {
|
||||
expect(error).toBeInstanceOf(AppException);
|
||||
expect((error as AppException).code).toBe(code);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe('user.mapper', () => {
|
||||
it('手机号脱敏与部门 / 小组拆分', () => {
|
||||
expect(maskPhone('13866202046')).toBe('138****2046');
|
||||
const summary = toUserSummary(userFixture());
|
||||
expect(summary.departmentId).toBe('d1');
|
||||
expect(summary.departmentName).toBe('内容生产中心');
|
||||
expect(summary.groupId).toBe('g1');
|
||||
expect(summary.groupName).toBe('素材一组');
|
||||
expect(summary.status).toBe('active');
|
||||
expect(summary.roles).toHaveLength(1);
|
||||
expect(summary.roles[0]?.code).toBe('material_operator');
|
||||
expect(summary.lastLoginAt).toBe('2026-11-02T01:41:00.000Z');
|
||||
});
|
||||
|
||||
it('一级部门直属:groupId 为空;详情按权限决定是否给全号', () => {
|
||||
const direct = toUserSummary(
|
||||
userFixture({
|
||||
department: {
|
||||
...userFixture().department,
|
||||
id: 'd1',
|
||||
name: '平台技术部',
|
||||
parentId: null,
|
||||
parent: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(direct.groupId).toBeNull();
|
||||
expect(direct.departmentName).toBe('平台技术部');
|
||||
|
||||
const withPhone = toUserDetail(userFixture(), true);
|
||||
const withoutPhone = toUserDetail(userFixture(), false);
|
||||
expect(withPhone.phone).toBe('13866202046');
|
||||
expect(withoutPhone.phone).toBeUndefined();
|
||||
expect(withPhone.mustChangePassword).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PasswordService', () => {
|
||||
it('哈希后可校验,错误口令与非法哈希返回 false', async () => {
|
||||
const service = new PasswordService();
|
||||
const hash = await service.hash('lingxi2026');
|
||||
await expect(service.verify(hash, 'lingxi2026')).resolves.toBe(true);
|
||||
await expect(service.verify(hash, 'wrong-pass1')).resolves.toBe(false);
|
||||
await expect(service.verify('not-a-hash', 'x')).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LoginFailureService', () => {
|
||||
function fakeRedis(): {
|
||||
service: LoginFailureService;
|
||||
store: Map<string, number>;
|
||||
del: () => void;
|
||||
} {
|
||||
const store = new Map<string, number>();
|
||||
const client = {
|
||||
status: 'ready',
|
||||
connect: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn((key: string) => Promise.resolve(store.has(key) ? String(store.get(key)) : null)),
|
||||
del: vi.fn((key: string) => {
|
||||
store.delete(key);
|
||||
return Promise.resolve(1);
|
||||
}),
|
||||
multi: vi.fn(() => {
|
||||
let key = '';
|
||||
const api = {
|
||||
incr: (target: string) => {
|
||||
key = target;
|
||||
store.set(key, (store.get(key) ?? 0) + 1);
|
||||
return api;
|
||||
},
|
||||
expire: () => api,
|
||||
exec: () => Promise.resolve([[null, store.get(key) ?? 0]]),
|
||||
};
|
||||
return api;
|
||||
}),
|
||||
};
|
||||
const service = new LoginFailureService(
|
||||
{ getClient: () => client } as never,
|
||||
{ authMaxLoginFailures: 3, authLockMinutes: 15 } as never,
|
||||
);
|
||||
return {
|
||||
service,
|
||||
store,
|
||||
del: () => {
|
||||
store.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('累计失败达阈值后锁定,成功登录清零', async () => {
|
||||
const { service, store, del } = fakeRedis();
|
||||
await expect(service.status('Chen.Mo')).resolves.toEqual({ locked: false, remaining: 3 });
|
||||
await service.record('chen.mo');
|
||||
await service.record('chen.mo');
|
||||
await expect(service.count('chen.mo')).resolves.toBe(2);
|
||||
await expect(service.status('chen.mo')).resolves.toEqual({ locked: false, remaining: 1 });
|
||||
await service.record('chen.mo');
|
||||
await expect(service.status('chen.mo')).resolves.toEqual({ locked: true, remaining: 0 });
|
||||
await service.clear('chen.mo');
|
||||
await expect(service.count('chen.mo')).resolves.toBe(0);
|
||||
del();
|
||||
expect(store.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UsersService', () => {
|
||||
const passwords = { hash: vi.fn().mockResolvedValue('hashed') };
|
||||
|
||||
function serviceOf(prisma: Record<string, unknown>): UsersService {
|
||||
return new UsersService(
|
||||
prisma as unknown as PrismaService,
|
||||
passwords as unknown as PasswordService,
|
||||
);
|
||||
}
|
||||
|
||||
it('详情:user:manage 才带手机号全号', async () => {
|
||||
const prisma = { user: { findUnique: vi.fn().mockResolvedValue(userFixture()) } };
|
||||
const service = serviceOf(prisma);
|
||||
const withPhone = await service.detail('u1', ['user:manage']);
|
||||
const withoutPhone = await service.detail('u1', ['user:view']);
|
||||
expect(withPhone.phone).toBe('13866202046');
|
||||
expect(withoutPhone.phone).toBeUndefined();
|
||||
});
|
||||
|
||||
it('列表:游标分页、状态默认 active、排序白名单', async () => {
|
||||
const rows = [userFixture({ id: 'u1' }), userFixture({ id: 'u2' }), userFixture({ id: 'u3' })];
|
||||
const findMany = vi.fn().mockResolvedValue(rows);
|
||||
const service = serviceOf({ user: { findMany } });
|
||||
const result = await service.list({
|
||||
limit: 2,
|
||||
status: 'active',
|
||||
sort: 'last_login',
|
||||
order: 'desc',
|
||||
});
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.next_cursor).toBe('u2');
|
||||
const args = findMany.mock.calls[0]?.[0] as {
|
||||
where: { status: string };
|
||||
orderBy: unknown[];
|
||||
take: number;
|
||||
};
|
||||
expect(args.where.status).toBe('ACTIVE');
|
||||
expect(args.take).toBe(3);
|
||||
expect(args.orderBy).toEqual([{ lastLoginAt: { sort: 'desc', nulls: 'last' } }, { id: 'asc' }]);
|
||||
});
|
||||
|
||||
it('停用:重复停用 40903,停用自己 42201,客户转交占位 42201', async () => {
|
||||
const prisma = { user: { findUnique: vi.fn().mockResolvedValue(userFixture()) } };
|
||||
const service = serviceOf(prisma);
|
||||
await expectRejected(service.disable('u1', {}, 'u1', []), ErrorCode.BUSINESS_RULE_VIOLATION);
|
||||
|
||||
const disabled = userFixture({ status: 'DISABLED' });
|
||||
const service2 = serviceOf({ user: { findUnique: vi.fn().mockResolvedValue(disabled) } });
|
||||
await expectRejected(service2.disable('u1', {}, 'u2', []), ErrorCode.STATE_NOT_ALLOWED);
|
||||
|
||||
expect(() => service.transferCustomers()).toThrow(AppException);
|
||||
});
|
||||
|
||||
it('停用成功会同步吊销 refresh token;启用恢复状态', async () => {
|
||||
const transaction = vi.fn().mockResolvedValue([]);
|
||||
const findUnique = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(userFixture())
|
||||
.mockResolvedValueOnce(userFixture({ status: 'DISABLED' }));
|
||||
const service = serviceOf({
|
||||
user: { findUnique, update: vi.fn().mockResolvedValue(userFixture()) },
|
||||
refreshToken: { updateMany: vi.fn() },
|
||||
$transaction: transaction,
|
||||
});
|
||||
const detail = await service.disable('u1', { reason: '离职' }, 'u9', ['user:manage']);
|
||||
expect(detail.status).toBe('disabled');
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
|
||||
const enableService = serviceOf({
|
||||
user: {
|
||||
findUnique: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(userFixture({ status: 'DISABLED' }))
|
||||
.mockResolvedValueOnce(userFixture()),
|
||||
update: vi.fn().mockResolvedValue(userFixture()),
|
||||
},
|
||||
});
|
||||
const enabled = await enableService.enable('u1', ['user:manage']);
|
||||
expect(enabled.status).toBe('active');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DepartmentsService', () => {
|
||||
it('部门树:两级嵌套且一级成员数含下级', async () => {
|
||||
const departments = [
|
||||
{
|
||||
id: 'd1',
|
||||
code: 'content',
|
||||
name: '内容生产中心',
|
||||
parentId: null,
|
||||
leaderUserId: null,
|
||||
sortOrder: 1,
|
||||
status: 'ACTIVE',
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date(),
|
||||
leader: { id: 'u1', name: '陈默' },
|
||||
},
|
||||
{
|
||||
id: 'g1',
|
||||
code: 'content-g1',
|
||||
name: '素材一组',
|
||||
parentId: 'd1',
|
||||
leaderUserId: null,
|
||||
sortOrder: 1,
|
||||
status: 'ACTIVE',
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date(),
|
||||
leader: null,
|
||||
},
|
||||
];
|
||||
const service = new DepartmentsService({
|
||||
department: { findMany: vi.fn().mockResolvedValue(departments) },
|
||||
user: { groupBy: vi.fn().mockResolvedValue([{ departmentId: 'g1', _count: { _all: 5 } }]) },
|
||||
} as unknown as PrismaService);
|
||||
const tree = await service.tree();
|
||||
expect(tree).toHaveLength(1);
|
||||
expect(tree[0]?.memberCount).toBe(5);
|
||||
expect(tree[0]?.children[0]?.name).toBe('素材一组');
|
||||
});
|
||||
|
||||
it('新建:父级不存在 40001,超过两级 42201', async () => {
|
||||
const service = new DepartmentsService({
|
||||
department: {
|
||||
findUnique: vi.fn().mockResolvedValueOnce(null),
|
||||
},
|
||||
} as unknown as PrismaService);
|
||||
await expectRejected(
|
||||
service.create({ name: '新组', parentId: 'missing' }),
|
||||
ErrorCode.PARAM_INVALID,
|
||||
);
|
||||
|
||||
const service2 = new DepartmentsService({
|
||||
department: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: 'g1',
|
||||
parentId: 'd1',
|
||||
}),
|
||||
},
|
||||
} as unknown as PrismaService);
|
||||
await expectRejected(
|
||||
service2.create({ name: '三级', parentId: 'g1' }),
|
||||
ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
);
|
||||
});
|
||||
|
||||
it('重命名不存在的部门返回 40401', async () => {
|
||||
const service = new DepartmentsService({
|
||||
department: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
} as unknown as PrismaService);
|
||||
await expectRejected(service.rename('missing', '新名字'), ErrorCode.NOT_FOUND);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MeService / RolesService', () => {
|
||||
it('/me:手机号按 user:manage 返回,角色与数据范围来自 DB', async () => {
|
||||
const prisma = { user: { findUnique: vi.fn().mockResolvedValue(userFixture()) } };
|
||||
const service = new MeService(prisma as unknown as PrismaService);
|
||||
const me = await service.getMe('u1', ['user:manage']);
|
||||
expect(me.user.phone).toBe('13866202046');
|
||||
expect(me.permissions).toEqual(['user:manage']);
|
||||
expect(me.roles).toHaveLength(1);
|
||||
const meWithout = await service.getMe('u1', []);
|
||||
expect(meWithout.user.phone).toBeUndefined();
|
||||
await expectRejected(
|
||||
new MeService({
|
||||
user: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
} as unknown as PrismaService).getMe('u1', []),
|
||||
ErrorCode.UNAUTHENTICATED,
|
||||
);
|
||||
});
|
||||
|
||||
it('角色列表只返回启用角色', async () => {
|
||||
const prisma = {
|
||||
role: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'r1',
|
||||
code: 'super_admin',
|
||||
name: '超级管理员',
|
||||
description: '内置',
|
||||
isSystem: true,
|
||||
status: 'ACTIVE',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
const roles = await new RolesService(prisma as unknown as PrismaService).list();
|
||||
expect(roles).toEqual([
|
||||
{ id: 'r1', code: 'super_admin', name: '超级管理员', isSystem: true, description: '内置' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type Redis from 'ioredis';
|
||||
|
||||
import { APP_CONFIG } from '../../config/config.module';
|
||||
import type { AppConfig } from '../../config/env';
|
||||
import type { RedisService } from '../../infra/redis/redis.service';
|
||||
import { REDIS } from '../../infra/tokens';
|
||||
|
||||
/**
|
||||
* 登录失败计数(Redis):窗口内累计失败达阈值即锁定,成功登录清零。
|
||||
* key 按账号归一化,避免大小写绕过;TTL 即锁定时长(默认 15 分钟)。
|
||||
*/
|
||||
@Injectable()
|
||||
export class LoginFailureService {
|
||||
constructor(
|
||||
@Inject(REDIS) private readonly redis: RedisService,
|
||||
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||
) {}
|
||||
|
||||
private key(account: string): string {
|
||||
return `auth:login-fail:${account.trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
private get client(): Redis {
|
||||
return this.redis.getClient();
|
||||
}
|
||||
|
||||
/** ioredis 为 lazyConnect:首次命令前显式连接,避免 enableOfflineQueue=false 直接报错 */
|
||||
private async connected(): Promise<Redis> {
|
||||
const client = this.client;
|
||||
if (client.status === 'wait') {
|
||||
await client.connect();
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
async count(account: string): Promise<number> {
|
||||
const client = await this.connected();
|
||||
const value = await client.get(this.key(account));
|
||||
if (value === null) {
|
||||
return 0;
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
/** 是否已达锁定阈值;未达阈值时返回剩余可尝试次数 */
|
||||
async status(account: string): Promise<{ locked: boolean; remaining: number }> {
|
||||
const failures = await this.count(account);
|
||||
const remaining = Math.max(0, this.config.authMaxLoginFailures - failures);
|
||||
return { locked: failures >= this.config.authMaxLoginFailures, remaining };
|
||||
}
|
||||
|
||||
async record(account: string): Promise<number> {
|
||||
const key = this.key(account);
|
||||
const client = await this.connected();
|
||||
const multi = client.multi();
|
||||
multi.incr(key);
|
||||
multi.expire(key, this.config.authLockMinutes * 60);
|
||||
const result = await multi.exec();
|
||||
const incr = result?.[0]?.[1];
|
||||
return typeof incr === 'number' ? incr : 0;
|
||||
}
|
||||
|
||||
async clear(account: string): Promise<void> {
|
||||
const client = await this.connected();
|
||||
await client.del(this.key(account));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ErrorCode, type MeResponse } from '@lingxi/shared';
|
||||
import { Controller, Get, Inject } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import type { AuthContext } from '../../common/security/auth.types';
|
||||
import { CurrentUser } from '../../common/security/decorators';
|
||||
import { MeService } from './me.service';
|
||||
|
||||
/** 当前用户上下文:登录后前端据此做路由 / 按钮级权限控制 */
|
||||
@ApiTags('identity-me')
|
||||
@ApiBearerAuth()
|
||||
@Controller('me')
|
||||
export class MeController {
|
||||
constructor(@Inject(MeService) private readonly me: MeService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '当前用户、角色、权限点、数据范围' })
|
||||
async current(@CurrentUser() auth: AuthContext | null): Promise<MeResponse> {
|
||||
if (auth === null) {
|
||||
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
|
||||
}
|
||||
return this.me.getMe(auth.userId, auth.permissions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ErrorCode, type MeResponse, Permission } from '@lingxi/shared';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import type { UserAuthRecord } from '../../common/security/permission.service';
|
||||
import { resolveDataScope } from '../../common/security/permission.service';
|
||||
import { activeRolesOf } from '../../common/security/permission.service';
|
||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||
import { PRISMA } from '../../infra/tokens';
|
||||
import { toRoleSummary, toUserSummary } from './user.mapper';
|
||||
|
||||
/** /me 与登录响应里的上下文组装:用户摘要 + 角色 + 权限点 + 数据范围 */
|
||||
@Injectable()
|
||||
export class MeService {
|
||||
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
|
||||
|
||||
async getMe(userId: string, permissions: string[]): Promise<MeResponse> {
|
||||
const user = await this.findUser(userId);
|
||||
if (user === null) {
|
||||
throw new AppException({ code: ErrorCode.UNAUTHENTICATED, message: '用户不存在或已停用' });
|
||||
}
|
||||
const roles = activeRolesOf(user);
|
||||
const canSeePhone = permissions.includes(Permission.USER_MANAGE);
|
||||
return {
|
||||
user: {
|
||||
...toUserSummary(user),
|
||||
...(canSeePhone ? { phone: user.phone } : {}),
|
||||
},
|
||||
roles: roles.map((item) => toRoleSummary(item.role)),
|
||||
permissions,
|
||||
dataScope: resolveDataScope(roles.map((item) => item.role.code)),
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
};
|
||||
}
|
||||
|
||||
/** /me 每次都读 DB(不缓存旧权限),角色变更下一次请求即生效 */
|
||||
private async findUser(userId: string): Promise<UserAuthRecord | null> {
|
||||
return this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
department: { include: { parent: true } },
|
||||
roles: { include: { role: { include: { permissions: true } } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import argon2 from 'argon2';
|
||||
|
||||
/** Argon2id 口令哈希与校验;参数取库默认(内存 64MB / 3 轮),后续可按环境调优 */
|
||||
@Injectable()
|
||||
export class PasswordService {
|
||||
async hash(plain: string): Promise<string> {
|
||||
return argon2.hash(plain, { type: argon2.argon2id });
|
||||
}
|
||||
|
||||
async verify(hash: string, plain: string): Promise<boolean> {
|
||||
try {
|
||||
return await argon2.verify(hash, plain);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { type ListResponse, Permission, type RoleSummary } from '@lingxi/shared';
|
||||
import { Controller, Get, Inject } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { RequirePermissions } from '../../common/security/decorators';
|
||||
import { RolesService } from './roles.service';
|
||||
|
||||
/** 角色列表:供用户管理页做角色多选绑定 */
|
||||
@ApiTags('identity-roles')
|
||||
@ApiBearerAuth()
|
||||
@Controller('roles')
|
||||
export class RolesController {
|
||||
constructor(@Inject(RolesService) private readonly roles: RolesService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions(Permission.USER_VIEW)
|
||||
@ApiOperation({ summary: '角色列表(绑定用)' })
|
||||
async list(): Promise<ListResponse<RoleSummary>> {
|
||||
return { items: await this.roles.list(), next_cursor: null };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { type RoleSummary } from '@lingxi/shared';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||
import { PRISMA } from '../../infra/tokens';
|
||||
import { toRoleSummary } from './user.mapper';
|
||||
|
||||
/** 角色列表:A1 只做绑定多选,完整角色管理属 A2 */
|
||||
@Injectable()
|
||||
export class RolesService {
|
||||
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(): Promise<RoleSummary[]> {
|
||||
const roles = await this.prisma.role.findMany({
|
||||
where: { status: 'ACTIVE' },
|
||||
orderBy: [{ isSystem: 'desc' }, { name: 'asc' }],
|
||||
});
|
||||
return roles.map((role) => toRoleSummary(role));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { RoleSummary, UserDetail, UserSummary } from '@lingxi/shared';
|
||||
import type { Prisma, Role, UserStatus as PrismaUserStatus } from '@prisma/client';
|
||||
|
||||
/** 用户列表 / 详情需要的关联数据(部门含父级、角色) */
|
||||
export type UserWithRelations = Prisma.UserGetPayload<{
|
||||
include: {
|
||||
department: { include: { parent: true } };
|
||||
roles: { include: { role: true } };
|
||||
};
|
||||
}>;
|
||||
|
||||
export function iso(value: Date): string {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
export function isoOrNull(value: Date | null): string | null {
|
||||
return value === null ? null : value.toISOString();
|
||||
}
|
||||
|
||||
/** 手机号脱敏:138****2046;全号只在具备 user:manage 时由详情接口附带 */
|
||||
export function maskPhone(phone: string): string {
|
||||
return `${phone.slice(0, 3)}****${phone.slice(7)}`;
|
||||
}
|
||||
|
||||
export function toRoleSummary(role: Role): RoleSummary {
|
||||
return {
|
||||
id: role.id,
|
||||
code: role.code,
|
||||
name: role.name,
|
||||
isSystem: role.isSystem,
|
||||
...(role.description.length > 0 ? { description: role.description } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function toApiStatus(status: PrismaUserStatus): UserSummary['status'] {
|
||||
return status === 'ACTIVE' ? 'active' : 'disabled';
|
||||
}
|
||||
|
||||
function departmentFields(
|
||||
user: UserWithRelations,
|
||||
): Pick<UserSummary, 'departmentId' | 'departmentName' | 'groupId' | 'groupName'> {
|
||||
const leaf = user.department;
|
||||
const parent = leaf.parent;
|
||||
if (leaf.parentId !== null && parent !== null) {
|
||||
return {
|
||||
departmentId: parent.id,
|
||||
departmentName: parent.name,
|
||||
groupId: leaf.id,
|
||||
groupName: leaf.name,
|
||||
};
|
||||
}
|
||||
return {
|
||||
departmentId: leaf.id,
|
||||
departmentName: leaf.name,
|
||||
groupId: null,
|
||||
groupName: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function toUserSummary(user: UserWithRelations): UserSummary {
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
phoneMasked: maskPhone(user.phone),
|
||||
account: user.account,
|
||||
...departmentFields(user),
|
||||
roles: user.roles
|
||||
.filter((item) => item.role.status === 'ACTIVE')
|
||||
.map((item) => toRoleSummary(item.role)),
|
||||
status: toApiStatus(user.status),
|
||||
lastLoginAt: isoOrNull(user.lastLoginAt),
|
||||
createdAt: iso(user.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
/** 详情:phone 全号仅调用方具备 user:manage 时返回(契约铁律) */
|
||||
export function toUserDetail(user: UserWithRelations, canSeePhone: boolean): UserDetail {
|
||||
return {
|
||||
...toUserSummary(user),
|
||||
...(canSeePhone ? { phone: user.phone } : {}),
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
updatedAt: iso(user.updatedAt),
|
||||
lastLoginIp: user.lastLoginIp,
|
||||
disabledAt: isoOrNull(user.disabledAt),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
type CreateUserInput,
|
||||
createUserInputSchema,
|
||||
type DisableUserInput,
|
||||
disableUserInputSchema,
|
||||
ErrorCode,
|
||||
type ListResponse,
|
||||
Permission,
|
||||
transferCustomersInputSchema,
|
||||
type UpdateUserInput,
|
||||
updateUserInputSchema,
|
||||
type UserDetail,
|
||||
type UserListQuery,
|
||||
userListQuerySchema,
|
||||
type UserSummary,
|
||||
} from '@lingxi/shared';
|
||||
import { Body, Controller, Get, HttpCode, Inject, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||
import type { AuthContext } from '../../common/security/auth.types';
|
||||
import { Audit, CurrentUser, RequirePermissions } from '../../common/security/decorators';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
function requireAuth(auth: AuthContext | null): AuthContext {
|
||||
if (auth === null) {
|
||||
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
|
||||
}
|
||||
return auth;
|
||||
}
|
||||
|
||||
/** 用户管理:/users 前缀(A1 前端路由同为 /users;全局前缀 /api/v1) */
|
||||
@ApiTags('identity-users')
|
||||
@ApiBearerAuth()
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
constructor(@Inject(UsersService) private readonly users: UsersService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions(Permission.USER_VIEW)
|
||||
@ApiOperation({
|
||||
summary: '用户列表(游标分页)',
|
||||
description: 'status 默认 active(停用默认隐藏);sort/order 走白名单;手机号仅返回掩码',
|
||||
})
|
||||
async list(
|
||||
@Query(new ZodValidationPipe(userListQuerySchema)) query: UserListQuery,
|
||||
@CurrentUser() auth: AuthContext | null,
|
||||
): Promise<ListResponse<UserSummary>> {
|
||||
requireAuth(auth);
|
||||
return this.users.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions(Permission.USER_VIEW)
|
||||
@ApiOperation({ summary: '用户详情', description: '仅 user:manage 返回 phone 全号' })
|
||||
async detail(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() auth: AuthContext | null,
|
||||
): Promise<UserDetail> {
|
||||
return this.users.detail(id, requireAuth(auth).permissions);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions(Permission.USER_MANAGE)
|
||||
@Audit('identity.user.create')
|
||||
@ApiOperation({ summary: '新建用户', description: '初始密码由管理员设定,首登强制修改' })
|
||||
async create(
|
||||
@Body(new ZodValidationPipe(createUserInputSchema)) input: CreateUserInput,
|
||||
@CurrentUser() auth: AuthContext | null,
|
||||
): Promise<UserDetail> {
|
||||
const current = requireAuth(auth);
|
||||
return this.users.create(input, current.userId, current.permissions);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermissions(Permission.USER_MANAGE)
|
||||
@Audit('identity.user.update')
|
||||
@ApiOperation({ summary: '更新用户(不含账号 / 状态 / 密码)' })
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(updateUserInputSchema)) input: UpdateUserInput,
|
||||
@CurrentUser() auth: AuthContext | null,
|
||||
): Promise<UserDetail> {
|
||||
return this.users.update(id, input, requireAuth(auth).permissions);
|
||||
}
|
||||
|
||||
@Post(':id/disable')
|
||||
@HttpCode(200)
|
||||
@RequirePermissions(Permission.USER_MANAGE)
|
||||
@Audit('identity.user.disable')
|
||||
@ApiOperation({ summary: '停用用户', description: '停用后旧令牌立即失效;可指定客户转交人' })
|
||||
async disable(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(disableUserInputSchema)) input: DisableUserInput,
|
||||
@CurrentUser() auth: AuthContext | null,
|
||||
): Promise<UserDetail> {
|
||||
const current = requireAuth(auth);
|
||||
return this.users.disable(id, input, current.userId, current.permissions);
|
||||
}
|
||||
|
||||
@Post(':id/enable')
|
||||
@HttpCode(200)
|
||||
@RequirePermissions(Permission.USER_MANAGE)
|
||||
@Audit('identity.user.enable')
|
||||
@ApiOperation({ summary: '启用用户' })
|
||||
async enable(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() auth: AuthContext | null,
|
||||
): Promise<UserDetail> {
|
||||
return this.users.enable(id, requireAuth(auth).permissions);
|
||||
}
|
||||
|
||||
@Post(':id/transfer-customers')
|
||||
@HttpCode(200)
|
||||
@RequirePermissions(Permission.USER_MANAGE)
|
||||
@Audit('identity.user.transfer_customers')
|
||||
@ApiOperation({ summary: '客户转交(占位)', description: 'A3 客户模块落地前返回 42201' })
|
||||
transferCustomers(
|
||||
@Body(new ZodValidationPipe(transferCustomersInputSchema)) _input: unknown,
|
||||
): never {
|
||||
return this.users.transferCustomers();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import {
|
||||
type CreateUserInput,
|
||||
type DisableUserInput,
|
||||
ErrorCode,
|
||||
type ListResponse,
|
||||
Permission,
|
||||
ulid,
|
||||
type UpdateUserInput,
|
||||
type UserDetail,
|
||||
type UserListQuery,
|
||||
type UserSummary,
|
||||
} from '@lingxi/shared';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||
import { PRISMA } from '../../infra/tokens';
|
||||
import { PasswordService } from './password.service';
|
||||
import { toUserDetail, toUserSummary, type UserWithRelations } from './user.mapper';
|
||||
|
||||
const USER_INCLUDE = {
|
||||
department: { include: { parent: true } },
|
||||
roles: { include: { role: true } },
|
||||
} satisfies Prisma.UserInclude;
|
||||
|
||||
function hasPermission(permissions: string[], code: string): boolean {
|
||||
return permissions.includes(code);
|
||||
}
|
||||
|
||||
/** 用户管理:列表(游标 + 筛选 + 排序)、详情、新建、更新、停用 / 启用 */
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(
|
||||
@Inject(PRISMA) private readonly prisma: PrismaService,
|
||||
@Inject(PasswordService) private readonly passwords: PasswordService,
|
||||
) {}
|
||||
|
||||
async list(query: UserListQuery): Promise<ListResponse<UserSummary>> {
|
||||
const where = this.buildWhere(query);
|
||||
const orderBy = this.buildOrderBy(query);
|
||||
const cursor = query.cursor;
|
||||
const rows = await this.prisma.user.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
take: query.limit + 1,
|
||||
include: USER_INCLUDE,
|
||||
...(cursor === undefined ? {} : { cursor: { id: cursor }, skip: 1 }),
|
||||
});
|
||||
const page = rows.slice(0, query.limit);
|
||||
const next = rows.length > query.limit ? (page[page.length - 1]?.id ?? null) : null;
|
||||
return { items: page.map((row) => toUserSummary(row)), next_cursor: next };
|
||||
}
|
||||
|
||||
async detail(id: string, permissions: string[]): Promise<UserDetail> {
|
||||
const user = await this.findOrThrow(id);
|
||||
return toUserDetail(user, hasPermission(permissions, Permission.USER_MANAGE));
|
||||
}
|
||||
|
||||
async create(
|
||||
input: CreateUserInput,
|
||||
actorId: string,
|
||||
permissions: string[],
|
||||
): Promise<UserDetail> {
|
||||
await this.ensureDepartment(input.departmentId);
|
||||
await this.ensureRoles(input.roleIds);
|
||||
const account = input.account.trim().toLowerCase();
|
||||
const email = `${account}@lingxi.cn`;
|
||||
await this.ensureUnique({ account, phone: input.phone });
|
||||
const passwordHash = await this.passwords.hash(input.password);
|
||||
const id = ulid();
|
||||
try {
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.user.create({
|
||||
data: {
|
||||
id,
|
||||
name: input.name,
|
||||
account,
|
||||
email,
|
||||
phone: input.phone,
|
||||
passwordHash,
|
||||
departmentId: input.departmentId,
|
||||
mustChangePassword: true,
|
||||
createdBy: actorId,
|
||||
...(input.remark === undefined ? {} : { remark: input.remark }),
|
||||
roles: { create: input.roleIds.map((roleId) => ({ roleId })) },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
} catch (error) {
|
||||
throw this.mapUniqueError(error);
|
||||
}
|
||||
return this.detail(id, permissions);
|
||||
}
|
||||
|
||||
async update(id: string, input: UpdateUserInput, permissions: string[]): Promise<UserDetail> {
|
||||
const existing = await this.findOrThrow(id);
|
||||
if (input.departmentId !== undefined) {
|
||||
await this.ensureDepartment(input.departmentId);
|
||||
}
|
||||
if (input.roleIds !== undefined) {
|
||||
await this.ensureRoles(input.roleIds);
|
||||
}
|
||||
if (input.phone !== undefined && input.phone !== existing.phone) {
|
||||
await this.ensureUnique({ phone: input.phone });
|
||||
}
|
||||
const data: Prisma.UserUpdateInput = {
|
||||
...(input.name === undefined ? {} : { name: input.name }),
|
||||
...(input.phone === undefined ? {} : { phone: input.phone }),
|
||||
...(input.departmentId === undefined
|
||||
? {}
|
||||
: { department: { connect: { id: input.departmentId } } }),
|
||||
...(input.remark === undefined ? {} : { remark: input.remark }),
|
||||
};
|
||||
try {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.user.update({ where: { id }, data });
|
||||
if (input.roleIds !== undefined) {
|
||||
await tx.userRole.deleteMany({ where: { userId: id } });
|
||||
await tx.userRole.createMany({
|
||||
data: input.roleIds.map((roleId) => ({ userId: id, roleId })),
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
throw this.mapUniqueError(error);
|
||||
}
|
||||
return this.detail(id, permissions);
|
||||
}
|
||||
|
||||
async disable(
|
||||
id: string,
|
||||
input: DisableUserInput,
|
||||
actorId: string,
|
||||
permissions: string[],
|
||||
): Promise<UserDetail> {
|
||||
const existing = await this.findOrThrow(id);
|
||||
if (existing.status === 'DISABLED') {
|
||||
throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '账号已是停用状态' });
|
||||
}
|
||||
if (existing.id === actorId) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
message: '不能停用当前登录账号',
|
||||
});
|
||||
}
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'DISABLED',
|
||||
disabledAt: new Date(),
|
||||
...(input.reason === undefined ? {} : { remark: input.reason }),
|
||||
},
|
||||
}),
|
||||
this.prisma.refreshToken.updateMany({
|
||||
where: { userId: id, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
}),
|
||||
]);
|
||||
return this.detail(id, permissions);
|
||||
}
|
||||
|
||||
async enable(id: string, permissions: string[]): Promise<UserDetail> {
|
||||
const existing = await this.findOrThrow(id);
|
||||
if (existing.status === 'ACTIVE') {
|
||||
throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '账号已是启用状态' });
|
||||
}
|
||||
await this.prisma.user.update({
|
||||
where: { id },
|
||||
data: { status: 'ACTIVE', disabledAt: null },
|
||||
});
|
||||
return this.detail(id, permissions);
|
||||
}
|
||||
|
||||
/** 客户转交占位:A3 客户模块落地前按契约返回 42201(不自造 5xx 错误码) */
|
||||
transferCustomers(): never {
|
||||
throw new AppException({
|
||||
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
message: '客户转交将在客户模块(A3)提供',
|
||||
});
|
||||
}
|
||||
|
||||
private async findOrThrow(id: string): Promise<UserWithRelations> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id }, include: USER_INCLUDE });
|
||||
if (user === null) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '用户不存在' });
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
private async ensureDepartment(departmentId: string): Promise<void> {
|
||||
const department = await this.prisma.department.findUnique({ where: { id: departmentId } });
|
||||
if (department === null) {
|
||||
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '所属部门不存在' });
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureRoles(roleIds: string[]): Promise<void> {
|
||||
const count = await this.prisma.role.count({
|
||||
where: { id: { in: roleIds }, status: 'ACTIVE' },
|
||||
});
|
||||
if (count !== new Set(roleIds).size) {
|
||||
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '角色不存在或已停用' });
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureUnique(input: { account?: string; phone?: string }): Promise<void> {
|
||||
const existing = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
...(input.account === undefined ? [] : [{ account: input.account }]),
|
||||
...(input.phone === undefined ? [] : [{ phone: input.phone }]),
|
||||
],
|
||||
},
|
||||
select: { account: true, phone: true },
|
||||
});
|
||||
if (existing !== null) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.STATE_NOT_ALLOWED,
|
||||
message: existing.account === input.account ? '登录账号已存在' : '手机号已存在',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private mapUniqueError(error: unknown): AppException {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||
return new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '账号或手机号已存在' });
|
||||
}
|
||||
return error instanceof AppException
|
||||
? error
|
||||
: new AppException({ code: ErrorCode.INTERNAL_ERROR, message: '用户保存失败' });
|
||||
}
|
||||
|
||||
private buildWhere(query: UserListQuery): Prisma.UserWhereInput {
|
||||
const where: Prisma.UserWhereInput = {};
|
||||
if (query.status !== 'all') {
|
||||
where.status = query.status === 'active' ? 'ACTIVE' : 'DISABLED';
|
||||
}
|
||||
if (query.keyword !== undefined && query.keyword.length > 0) {
|
||||
where.OR = [
|
||||
{ name: { contains: query.keyword, mode: 'insensitive' } },
|
||||
{ account: { contains: query.keyword, mode: 'insensitive' } },
|
||||
{ phone: { contains: query.keyword } },
|
||||
];
|
||||
}
|
||||
if (query.department_id !== undefined) {
|
||||
where.department = {
|
||||
OR: [{ id: query.department_id }, { parentId: query.department_id }],
|
||||
};
|
||||
}
|
||||
if (query.role_id !== undefined) {
|
||||
where.roles = { some: { roleId: query.role_id } };
|
||||
}
|
||||
return where;
|
||||
}
|
||||
|
||||
private buildOrderBy(query: UserListQuery): Prisma.UserOrderByWithRelationInput[] {
|
||||
const direction: Prisma.SortOrder = query.order === 'asc' ? 'asc' : 'desc';
|
||||
if (query.sort === 'created_at') {
|
||||
return [{ createdAt: direction }, { id: 'asc' }];
|
||||
}
|
||||
return [{ lastLoginAt: { sort: direction, nulls: 'last' } }, { id: 'asc' }];
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Controller, Get, Inject } from '@nestjs/common';
|
||||
import { ApiOkResponse, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { Public } from '../../../common/security/decorators';
|
||||
import { HealthService } from './health.service';
|
||||
import { type HealthReport, HealthResponseDto } from './health.types';
|
||||
|
||||
@ApiTags('platform')
|
||||
@Public()
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(@Inject(HealthService) private readonly healthService: HealthService) {}
|
||||
|
||||
@@ -23,6 +23,12 @@ export const testConfig: AppConfig = {
|
||||
webOrigins: ['http://127.0.0.1:5173'],
|
||||
maxUploadMb: 500,
|
||||
featureVideo: false,
|
||||
jwtAccessSecret: 'test-only-jwt-access-secret-32-characters',
|
||||
jwtRefreshSecret: 'test-only-jwt-refresh-secret-32-characters',
|
||||
jwtAccessTtlSeconds: 900,
|
||||
jwtRefreshTtlSeconds: 604800,
|
||||
authMaxLoginFailures: 5,
|
||||
authLockMinutes: 15,
|
||||
};
|
||||
|
||||
@Controller('__test')
|
||||
|
||||
@@ -9,7 +9,17 @@ export default defineConfig({
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'lcov', 'json-summary'],
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: ['src/**/*.spec.ts', 'src/**/*.int.spec.ts', 'src/main.ts', 'src/scripts/**'],
|
||||
exclude: [
|
||||
'src/**/*.spec.ts',
|
||||
'src/**/*.int.spec.ts',
|
||||
'src/main.ts',
|
||||
'src/scripts/**',
|
||||
// 控制器 / 模块为薄装配层:行为由集成测试(test:int)覆盖,不计入单测覆盖率
|
||||
'src/**/*.controller.ts',
|
||||
'src/**/*.module.ts',
|
||||
'src/**/*.types.ts',
|
||||
'src/testing/**',
|
||||
],
|
||||
thresholds: { lines: 60, functions: 60, statements: 60, branches: 60 },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"build": "tsc -p tsconfig.json --noEmit && vite build",
|
||||
"preview": "vite preview --port 4173 --strictPort",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"lint": "eslint src --max-warnings=0",
|
||||
"test": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -16,7 +16,7 @@ function signIn(accessToken = 'token-1', refreshToken = 'refresh-1'): void {
|
||||
useAuthStore.getState().setSession({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: { id: '01J0TEST000000000000000000', name: '测试用户', email: 'test@lingxi.local' },
|
||||
user: { id: '01J0TEST000000000000000000', name: '测试用户', account: 'test@lingxi.local' },
|
||||
permissions: [],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -108,6 +108,13 @@ async function toApiError(response: Response): Promise<ApiError> {
|
||||
});
|
||||
}
|
||||
|
||||
/** 刷新失败:清本地会话并通知外壳跳登录(会话过期统一入口) */
|
||||
function failRefresh(): false {
|
||||
useAuthStore.getState().clearSession();
|
||||
unauthorizedHandler?.();
|
||||
return false;
|
||||
}
|
||||
|
||||
let refreshPromise: Promise<boolean> | null = null;
|
||||
|
||||
/** 单飞刷新:并发 401 只触发一次 /auth/refresh */
|
||||
@@ -119,9 +126,9 @@ export function refreshSession(): Promise<boolean> {
|
||||
}
|
||||
|
||||
async function doRefresh(): Promise<boolean> {
|
||||
const { refreshToken, clearSession } = useAuthStore.getState();
|
||||
const { refreshToken } = useAuthStore.getState();
|
||||
if (refreshToken === null) {
|
||||
return false;
|
||||
return failRefresh();
|
||||
}
|
||||
let response: Response;
|
||||
try {
|
||||
@@ -131,12 +138,10 @@ async function doRefresh(): Promise<boolean> {
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
return failRefresh();
|
||||
}
|
||||
if (!response.ok) {
|
||||
clearSession();
|
||||
unauthorizedHandler?.();
|
||||
return false;
|
||||
return failRefresh();
|
||||
}
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
const accessToken =
|
||||
@@ -148,9 +153,7 @@ async function doRefresh(): Promise<boolean> {
|
||||
? payload.refreshToken
|
||||
: null;
|
||||
if (typeof accessToken !== 'string' || accessToken.length === 0) {
|
||||
clearSession();
|
||||
unauthorizedHandler?.();
|
||||
return false;
|
||||
return failRefresh();
|
||||
}
|
||||
useAuthStore.getState().setTokens({
|
||||
accessToken,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* identity 模块接口路径(唯一出处)。
|
||||
* 依据:技术架构 §6.3.1、开发计划附录 D。
|
||||
* 后端若调整路径,只改这里;联调时以 `apps/web/src/api/generated/openapi.d.ts` 为准。
|
||||
*/
|
||||
const USERS = '/users';
|
||||
|
||||
export const ENDPOINTS = {
|
||||
login: '/auth/login',
|
||||
refresh: '/auth/refresh',
|
||||
logout: '/auth/logout',
|
||||
changePassword: '/auth/change-password',
|
||||
me: '/me',
|
||||
users: USERS,
|
||||
user: (userId: string): string => `${USERS}/${encodeURIComponent(userId)}`,
|
||||
userDisable: (userId: string): string => `${USERS}/${encodeURIComponent(userId)}/disable`,
|
||||
userEnable: (userId: string): string => `${USERS}/${encodeURIComponent(userId)}/enable`,
|
||||
userCustomers: (userId: string): string => `${USERS}/${encodeURIComponent(userId)}/customers`,
|
||||
userTransferCustomers: (userId: string): string =>
|
||||
`${USERS}/${encodeURIComponent(userId)}/transfer-customers`,
|
||||
departments: '/departments/tree',
|
||||
roles: '/roles',
|
||||
} as const;
|
||||
+536
@@ -4,6 +4,270 @@
|
||||
*/
|
||||
|
||||
export interface paths {
|
||||
'/api/v1/auth/login': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* 账号密码登录
|
||||
* @description 返回 accessToken / refreshToken / expiresIn / me
|
||||
*/
|
||||
post: operations['AuthController_login'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/auth/refresh': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** 刷新令牌(旋转,旧 refresh 立即失效) */
|
||||
post: operations['AuthController_refresh'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/auth/logout': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* 登出
|
||||
* @description 吊销 refresh 令牌所在家族
|
||||
*/
|
||||
post: operations['AuthController_logout'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/auth/change-password': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** 修改密码(首登强改) */
|
||||
post: operations['AuthController_changePassword'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/me': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 当前用户、角色、权限点、数据范围 */
|
||||
get: operations['MeController_current'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/users': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* 用户列表(游标分页)
|
||||
* @description status 默认 active(停用默认隐藏);sort/order 走白名单;手机号仅返回掩码
|
||||
*/
|
||||
get: operations['UsersController_list'];
|
||||
put?: never;
|
||||
/**
|
||||
* 新建用户
|
||||
* @description 初始密码由管理员设定,首登强制修改
|
||||
*/
|
||||
post: operations['UsersController_create'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/users/{id}': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* 用户详情
|
||||
* @description 仅 user:manage 返回 phone 全号
|
||||
*/
|
||||
get: operations['UsersController_detail'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
/** 更新用户(不含账号 / 状态 / 密码) */
|
||||
patch: operations['UsersController_update'];
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/users/{id}/disable': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* 停用用户
|
||||
* @description 停用后旧令牌立即失效;可指定客户转交人
|
||||
*/
|
||||
post: operations['UsersController_disable'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/users/{id}/enable': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** 启用用户 */
|
||||
post: operations['UsersController_enable'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/users/{id}/transfer-customers': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* 客户转交(占位)
|
||||
* @description A3 客户模块落地前返回 42201
|
||||
*/
|
||||
post: operations['UsersController_transferCustomers'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/departments/tree': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 部门树(两级,含成员数与负责人) */
|
||||
get: operations['DepartmentsController_tree'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/departments': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* 新建部门 / 小组
|
||||
* @description 最多两级,在小组下新建返回 42201
|
||||
*/
|
||||
post: operations['DepartmentsController_create'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/departments/{id}': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
/** 部门改名 */
|
||||
patch: operations['DepartmentsController_rename'];
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/roles': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 角色列表(绑定用) */
|
||||
get: operations['RolesController_list'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/health': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -135,6 +399,278 @@ export interface components {
|
||||
}
|
||||
export type $defs = Record<string, never>;
|
||||
export interface operations {
|
||||
AuthController_login: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
AuthController_refresh: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
AuthController_logout: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
AuthController_changePassword: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
MeController_current: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
UsersController_list: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
UsersController_create: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
UsersController_detail: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
UsersController_update: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
UsersController_disable: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
UsersController_enable: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
UsersController_transferCustomers: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
DepartmentsController_tree: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
DepartmentsController_create: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
DepartmentsController_rename: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
RolesController_list: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
HealthController_check: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
import { ApiError, request, setUnauthorizedHandler } from './client';
|
||||
import {
|
||||
changePassword,
|
||||
createUser,
|
||||
disableUser,
|
||||
enableUser,
|
||||
fetchDepartments,
|
||||
fetchMe,
|
||||
fetchRoles,
|
||||
fetchUserCustomers,
|
||||
login,
|
||||
logout,
|
||||
transferCustomers,
|
||||
updateUser,
|
||||
} from './identity';
|
||||
import { fetchUsers } from './users';
|
||||
|
||||
const USER_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
|
||||
const ROLE_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ2';
|
||||
const GROUP_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ3';
|
||||
|
||||
const userSummary = {
|
||||
id: USER_ID,
|
||||
name: '陈默',
|
||||
phoneMasked: '138****2046',
|
||||
account: 'chen.mo',
|
||||
departmentId: GROUP_ID,
|
||||
departmentName: '内容生产中心',
|
||||
groupId: GROUP_ID,
|
||||
groupName: '素材一组',
|
||||
roles: [{ id: ROLE_ID, code: 'ops', name: '素材运营', isSystem: false }],
|
||||
status: 'active',
|
||||
lastLoginAt: '2026-11-02T09:41:00.000Z',
|
||||
createdAt: '2026-09-30T18:40:00.000Z',
|
||||
};
|
||||
|
||||
const me = {
|
||||
user: userSummary,
|
||||
roles: userSummary.roles,
|
||||
permissions: ['user:view'],
|
||||
dataScope: { type: 'all' },
|
||||
mustChangePassword: false,
|
||||
};
|
||||
|
||||
const jsonResponse = (body: unknown, status = 200): Response =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
function toUrl(input: string | URL | Request | undefined): string {
|
||||
if (typeof input === 'string') {
|
||||
return input;
|
||||
}
|
||||
if (input instanceof URL) {
|
||||
return input.href;
|
||||
}
|
||||
return input?.url ?? '';
|
||||
}
|
||||
|
||||
function calls(): { url: string; init: RequestInit | undefined }[] {
|
||||
const fetchMock = vi.mocked(globalThis.fetch);
|
||||
return fetchMock.mock.calls.map((call) => ({
|
||||
url: toUrl(call[0]),
|
||||
init: call[1],
|
||||
}));
|
||||
}
|
||||
|
||||
function lastBody(): unknown {
|
||||
const last = calls().at(-1);
|
||||
const body = last?.init?.body;
|
||||
return typeof body === 'string' ? (JSON.parse(body) as unknown) : undefined;
|
||||
}
|
||||
|
||||
describe('identity 接口封装', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
window.localStorage.clear();
|
||||
useAuthStore.getState().clearSession();
|
||||
setUnauthorizedHandler(null);
|
||||
useAuthStore.getState().setSession({
|
||||
accessToken: 'token',
|
||||
user: { id: USER_ID, name: '陈默', account: 'chen.mo' },
|
||||
permissions: ['user:view'],
|
||||
});
|
||||
});
|
||||
|
||||
it('登录:POST /auth/login,body 为 account/password 且不带 token', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
jsonResponse({
|
||||
accessToken: 'a',
|
||||
refreshToken: 'r',
|
||||
expiresIn: 3600,
|
||||
me,
|
||||
}),
|
||||
);
|
||||
const response = await login({ account: 'chen.mo', password: 'Lx123456' });
|
||||
expect(response.me.permissions).toEqual(['user:view']);
|
||||
expect(calls()[0]?.init?.method).toBe('POST');
|
||||
expect(lastBody()).toEqual({ account: 'chen.mo', password: 'Lx123456' });
|
||||
});
|
||||
|
||||
it('/me、部门与角色列表按共享 schema 解析', async () => {
|
||||
const department = {
|
||||
id: GROUP_ID,
|
||||
name: '内容生产中心',
|
||||
parentId: null,
|
||||
leader: null,
|
||||
memberCount: 19,
|
||||
createdAt: '2026-09-01T00:00:00.000Z',
|
||||
children: [
|
||||
{
|
||||
id: USER_ID,
|
||||
name: '素材一组',
|
||||
parentId: GROUP_ID,
|
||||
leader: null,
|
||||
memberCount: 9,
|
||||
createdAt: '2026-09-01T00:00:00.000Z',
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(jsonResponse(me))
|
||||
.mockResolvedValueOnce(jsonResponse({ items: [department] }))
|
||||
.mockResolvedValueOnce(jsonResponse({ items: userSummary.roles }));
|
||||
expect((await fetchMe()).user.name).toBe('陈默');
|
||||
const departments = await fetchDepartments();
|
||||
expect(departments.map((node) => node.name)).toEqual(['内容生产中心', '素材一组']);
|
||||
expect((await fetchRoles())[0]?.name).toBe('素材运营');
|
||||
});
|
||||
|
||||
it('用户列表保留 next_cursor,写接口使用约定方法与路径', async () => {
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(jsonResponse({ items: [userSummary], next_cursor: 'cursor-2' }))
|
||||
.mockResolvedValueOnce(jsonResponse({ id: USER_ID }))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 204 }))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 204 }))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 204 }))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 204 }))
|
||||
.mockResolvedValueOnce(jsonResponse([{ id: USER_ID, name: '花漾家居' }]))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 204 }))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
||||
|
||||
const list = await fetchUsers({ limit: 10, status: 'active' });
|
||||
expect(list.next_cursor).toBe('cursor-2');
|
||||
expect(
|
||||
await createUser({
|
||||
name: '测试成员',
|
||||
phone: '13900001111',
|
||||
account: 'test.member',
|
||||
password: 'Lx123456',
|
||||
departmentId: GROUP_ID,
|
||||
roleIds: [ROLE_ID],
|
||||
}),
|
||||
).toBe(USER_ID);
|
||||
await updateUser(USER_ID, { name: '陈默' });
|
||||
await disableUser(USER_ID, { reason: '离职' });
|
||||
await enableUser(USER_ID);
|
||||
await transferCustomers(USER_ID, { customerIds: [USER_ID], toUserId: ROLE_ID });
|
||||
expect((await fetchUserCustomers(USER_ID))[0]?.name).toBe('花漾家居');
|
||||
await changePassword({ currentPassword: 'Lx123456', newPassword: 'Lx654321' });
|
||||
await logout('refresh-token');
|
||||
|
||||
const paths = calls().map((call) => ({
|
||||
url: call.url.replace('http://127.0.0.1:3000/api/v1', ''),
|
||||
method: call.init?.method ?? 'GET',
|
||||
}));
|
||||
expect(paths).toContainEqual({ url: '/users', method: 'POST' });
|
||||
expect(paths).toContainEqual({ url: `/users/${USER_ID}`, method: 'PATCH' });
|
||||
expect(paths).toContainEqual({ url: `/users/${USER_ID}/disable`, method: 'POST' });
|
||||
expect(paths).toContainEqual({ url: `/users/${USER_ID}/enable`, method: 'POST' });
|
||||
expect(paths).toContainEqual({
|
||||
url: `/users/${USER_ID}/transfer-customers`,
|
||||
method: 'POST',
|
||||
});
|
||||
expect(paths).toContainEqual({ url: '/auth/change-password', method: 'POST' });
|
||||
expect(paths).toContainEqual({ url: '/auth/logout', method: 'POST' });
|
||||
});
|
||||
|
||||
it('刷新失败时清理会话并触发跳登录', async () => {
|
||||
const handler = vi.fn();
|
||||
setUnauthorizedHandler(handler);
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ code: 40101, message: '登录已过期', trace_id: 't-401' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
useAuthStore.getState().setTokens({ accessToken: 'old', refreshToken: 'refresh-1' });
|
||||
await expect(request('/me')).rejects.toBeInstanceOf(ApiError);
|
||||
expect(handler).toHaveBeenCalled();
|
||||
expect(useAuthStore.getState().accessToken).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
type ChangePasswordInput,
|
||||
changePasswordInputSchema,
|
||||
type CreateUserInput,
|
||||
createUserInputSchema,
|
||||
type DepartmentNode,
|
||||
departmentNodeSchema,
|
||||
type DisableUserInput,
|
||||
disableUserInputSchema,
|
||||
type LoginInput,
|
||||
loginInputSchema,
|
||||
type LoginResponse,
|
||||
loginResponseSchema,
|
||||
type MeResponse,
|
||||
meResponseSchema,
|
||||
type RoleSummary,
|
||||
roleSummarySchema,
|
||||
type TransferCustomersInput,
|
||||
transferCustomersInputSchema,
|
||||
type UpdateUserInput,
|
||||
updateUserInputSchema,
|
||||
} from '@lingxi/shared';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { request } from './client';
|
||||
import { ENDPOINTS } from './endpoints';
|
||||
|
||||
/** 客户摘要:A3 客户模块落地前的转交占位契约(仅 id + name) */
|
||||
export interface CustomerSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const customerSummarySchema = z.object({ id: z.string().min(1), name: z.string().min(1) });
|
||||
const createdUserSchema = z.object({ id: z.string().min(1) }).passthrough();
|
||||
|
||||
/** 兼容 `T[]` 与 `{ items: T[] }` 两种列表返回,逐项用共享 schema 校验 */
|
||||
function parseList<T>(payload: unknown, itemSchema: z.ZodType<T>): T[] {
|
||||
const candidate: unknown[] = Array.isArray(payload)
|
||||
? payload
|
||||
: isRecord(payload) && Array.isArray(payload.items)
|
||||
? payload.items
|
||||
: [];
|
||||
return candidate.map((item) => itemSchema.parse(item));
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
export async function login(input: LoginInput): Promise<LoginResponse> {
|
||||
const body = loginInputSchema.parse(input);
|
||||
const payload: unknown = await request(ENDPOINTS.login, { method: 'POST', auth: false, body });
|
||||
return loginResponseSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function logout(refreshToken: string | null): Promise<void> {
|
||||
const body: { refreshToken?: string } = refreshToken === null ? {} : { refreshToken };
|
||||
await request<unknown>(ENDPOINTS.logout, { method: 'POST', body });
|
||||
}
|
||||
|
||||
export async function changePassword(input: ChangePasswordInput): Promise<void> {
|
||||
const body = changePasswordInputSchema.parse(input);
|
||||
await request<unknown>(ENDPOINTS.changePassword, { method: 'POST', body });
|
||||
}
|
||||
|
||||
export async function fetchMe(): Promise<MeResponse> {
|
||||
const payload: unknown = await request(ENDPOINTS.me);
|
||||
return meResponseSchema.parse(payload);
|
||||
}
|
||||
|
||||
/** 部门树(后端返回两级嵌套 children),前端拍平成列表供下拉与筛选使用 */
|
||||
export async function fetchDepartments(): Promise<DepartmentNode[]> {
|
||||
const payload: unknown = await request(ENDPOINTS.departments);
|
||||
const raw: unknown = isRecord(payload) && Array.isArray(payload.items) ? payload.items : payload;
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
const flat: DepartmentNode[] = [];
|
||||
const walk = (nodes: unknown[]): void => {
|
||||
for (const node of nodes) {
|
||||
if (!isRecord(node)) {
|
||||
continue;
|
||||
}
|
||||
flat.push(departmentNodeSchema.parse(node));
|
||||
const children: unknown = node.children;
|
||||
if (Array.isArray(children)) {
|
||||
walk(children);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(raw);
|
||||
return flat;
|
||||
}
|
||||
|
||||
export async function fetchRoles(): Promise<RoleSummary[]> {
|
||||
const payload: unknown = await request(ENDPOINTS.roles);
|
||||
return parseList(payload, roleSummarySchema);
|
||||
}
|
||||
|
||||
/** 新建用户:返回新用户 id;后端若未返回 id,则由调用方回落到列表页 */
|
||||
export async function createUser(input: CreateUserInput): Promise<string | null> {
|
||||
const body = createUserInputSchema.parse(input);
|
||||
const payload: unknown = await request(ENDPOINTS.users, { method: 'POST', body });
|
||||
const parsed = createdUserSchema.safeParse(payload);
|
||||
return parsed.success ? parsed.data.id : null;
|
||||
}
|
||||
|
||||
export async function updateUser(userId: string, input: UpdateUserInput): Promise<void> {
|
||||
const body = updateUserInputSchema.parse(input);
|
||||
await request<unknown>(ENDPOINTS.user(userId), { method: 'PATCH', body });
|
||||
}
|
||||
|
||||
export async function disableUser(userId: string, input: DisableUserInput): Promise<void> {
|
||||
const body = disableUserInputSchema.parse(input);
|
||||
await request<unknown>(ENDPOINTS.userDisable(userId), { method: 'POST', body });
|
||||
}
|
||||
|
||||
export async function enableUser(userId: string): Promise<void> {
|
||||
await request<unknown>(ENDPOINTS.userEnable(userId), { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function transferCustomers(
|
||||
userId: string,
|
||||
input: TransferCustomersInput,
|
||||
): Promise<void> {
|
||||
const body = transferCustomersInputSchema.parse(input);
|
||||
await request<unknown>(ENDPOINTS.userTransferCustomers(userId), { method: 'POST', body });
|
||||
}
|
||||
|
||||
/** 负责客户列表(A3 接口落地前调用会 404,由 hook 降级为空态) */
|
||||
export async function fetchUserCustomers(userId: string): Promise<CustomerSummary[]> {
|
||||
const payload: unknown = await request(ENDPOINTS.userCustomers(userId));
|
||||
return parseList(payload, customerSummarySchema);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { UserListQueryInput } from './users';
|
||||
|
||||
/** TanStack Query 缓存键(唯一出处,避免散落字符串) */
|
||||
export const queryKeys = {
|
||||
me: ['me'] as const,
|
||||
users: ['users'] as const,
|
||||
userList: (query: UserListQueryInput) => ['users', 'list', query] as const,
|
||||
user: (userId: string) => ['users', 'detail', userId] as const,
|
||||
userCustomers: (userId: string) => ['users', 'customers', userId] as const,
|
||||
departments: ['departments'] as const,
|
||||
roles: ['roles'] as const,
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
import { fetchUserDetail, fetchUsers, toUserListSearchParams } from './users';
|
||||
|
||||
const USER_ID = '01HZZZZZZZZZZZZZZZZZZZZZZZ';
|
||||
const DEPT_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
|
||||
const ROLE_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ2';
|
||||
|
||||
const summary = {
|
||||
id: USER_ID,
|
||||
name: '陈默',
|
||||
phoneMasked: '138****2046',
|
||||
account: 'chen.mo',
|
||||
departmentId: DEPT_ID,
|
||||
departmentName: '内容生产中心',
|
||||
groupId: null,
|
||||
groupName: '素材一组',
|
||||
roles: [{ id: ROLE_ID, code: 'ops', name: '素材运营', isSystem: false }],
|
||||
status: 'active',
|
||||
lastLoginAt: '2026-11-02T09:41:00.000Z',
|
||||
createdAt: '2026-09-30T18:40:00.000Z',
|
||||
};
|
||||
|
||||
describe('用户列表请求参数', () => {
|
||||
it('按后端 query 约定拼装 snake_case 参数,空值不传', () => {
|
||||
const params = toUserListSearchParams({
|
||||
limit: 10,
|
||||
status: 'active',
|
||||
sort: 'last_login',
|
||||
order: 'desc',
|
||||
keyword: ' 陈 ',
|
||||
});
|
||||
expect(params.get('limit')).toBe('10');
|
||||
expect(params.get('status')).toBe('active');
|
||||
expect(params.get('sort')).toBe('last_login');
|
||||
expect(params.get('order')).toBe('desc');
|
||||
expect(params.get('keyword')).toBe('陈');
|
||||
expect(params.has('department_id')).toBe(false);
|
||||
expect(params.has('cursor')).toBe(false);
|
||||
});
|
||||
|
||||
it('带游标与部门/角色筛选时完整输出', () => {
|
||||
const params = toUserListSearchParams({
|
||||
limit: 10,
|
||||
cursor: 'cursor-2',
|
||||
status: 'all',
|
||||
sort: 'created_at',
|
||||
order: 'asc',
|
||||
department_id: DEPT_ID,
|
||||
role_id: ROLE_ID,
|
||||
});
|
||||
expect(params.toString()).toBe(
|
||||
`limit=10&cursor=cursor-2&department_id=${DEPT_ID}&role_id=${ROLE_ID}&status=all&sort=created_at&order=asc`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function toUrl(input: string | URL | Request | undefined): string {
|
||||
if (typeof input === 'string') {
|
||||
return input;
|
||||
}
|
||||
if (input instanceof URL) {
|
||||
return input.href;
|
||||
}
|
||||
return input?.url ?? '';
|
||||
}
|
||||
|
||||
describe('用户列表请求', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
useAuthStore.getState().setSession({
|
||||
accessToken: 'token',
|
||||
user: { id: USER_ID, name: '陈默', account: 'chen.mo' },
|
||||
permissions: ['user:view'],
|
||||
});
|
||||
});
|
||||
|
||||
it('解析列表信封与详情', async () => {
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ items: [summary], next_cursor: 'cursor-2' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
...summary,
|
||||
phone: '13800002046',
|
||||
mustChangePassword: true,
|
||||
updatedAt: '2026-11-02T09:41:00.000Z',
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
);
|
||||
|
||||
const list = await fetchUsers({ limit: 10, status: 'active' });
|
||||
expect(list.items).toHaveLength(1);
|
||||
expect(list.next_cursor).toBe('cursor-2');
|
||||
expect(toUrl(fetchMock.mock.calls[0]?.[0])).toContain('/users?limit=10&status=active');
|
||||
|
||||
const detail = await fetchUserDetail(USER_ID);
|
||||
expect(detail.phone).toBe('13800002046');
|
||||
expect(detail.mustChangePassword).toBe(true);
|
||||
expect(toUrl(fetchMock.mock.calls[1]?.[0])).toContain(`/users/${USER_ID}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
type ListResponse,
|
||||
listResponseSchema,
|
||||
type UserDetail,
|
||||
userDetailSchema,
|
||||
type UserListQuery,
|
||||
type UserSummary,
|
||||
userSummarySchema,
|
||||
} from '@lingxi/shared';
|
||||
|
||||
import { request } from './client';
|
||||
import { ENDPOINTS } from './endpoints';
|
||||
|
||||
const userListResponseSchema = listResponseSchema(userSummarySchema);
|
||||
|
||||
/** 用户列表筛选与游标分页查询(query 参数 snake_case) */
|
||||
export interface UserListQueryInput extends Partial<UserListQuery> {
|
||||
limit: number;
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
/** 组装 GET /users 的 query 参数;空值不落 query,避免后端严格校验报错 */
|
||||
export function toUserListSearchParams(query: UserListQueryInput): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', String(query.limit));
|
||||
if (query.cursor !== undefined && query.cursor !== '') {
|
||||
params.set('cursor', query.cursor);
|
||||
}
|
||||
if (query.keyword !== undefined && query.keyword.trim() !== '') {
|
||||
params.set('keyword', query.keyword.trim());
|
||||
}
|
||||
if (query.department_id !== undefined) {
|
||||
params.set('department_id', query.department_id);
|
||||
}
|
||||
if (query.role_id !== undefined) {
|
||||
params.set('role_id', query.role_id);
|
||||
}
|
||||
if (query.status !== undefined) {
|
||||
params.set('status', query.status);
|
||||
}
|
||||
if (query.sort !== undefined) {
|
||||
params.set('sort', query.sort);
|
||||
}
|
||||
if (query.order !== undefined) {
|
||||
params.set('order', query.order);
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
export async function fetchUsers(query: UserListQueryInput): Promise<ListResponse<UserSummary>> {
|
||||
const search = toUserListSearchParams(query).toString();
|
||||
const path = search === '' ? ENDPOINTS.users : `${ENDPOINTS.users}?${search}`;
|
||||
const payload: unknown = await request(path);
|
||||
return userListResponseSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function fetchUserDetail(userId: string): Promise<UserDetail> {
|
||||
const payload: unknown = await request(ENDPOINTS.user(userId));
|
||||
return userDetailSchema.parse(payload);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { PermissionCode } from '@lingxi/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { usePermission } from '@/hooks/use-permission';
|
||||
|
||||
export interface CanProps {
|
||||
/** 需要的权限点(数组时按 mode 组合判断) */
|
||||
permission: PermissionCode | readonly PermissionCode[];
|
||||
mode?: 'all' | 'any';
|
||||
/** 无权限时的渲染内容;默认不渲染(按钮级控制要求「不渲染」而非隐藏) */
|
||||
fallback?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** 按钮级权限控制:无权限时不渲染子树 */
|
||||
export function Can({ permission, mode = 'all', fallback = null, children }: CanProps): ReactNode {
|
||||
const allowed = usePermission(permission, mode);
|
||||
return allowed ? children : fallback;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/* eslint-disable max-lines-per-function -- 弹窗按字段顺序展开,拆分后状态传递反而更繁琐 */
|
||||
import { Button, Input, Modal } from '@douyinfe/semi-ui-19';
|
||||
import { IconLock } from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { useChangePasswordForm } from './use-change-password-form';
|
||||
|
||||
export interface MustChangePasswordModalProps {
|
||||
/** 改密成功:调用方负责清会话并停留在登录页 */
|
||||
onCompleted: () => void;
|
||||
/** 取消 / 关闭:调用方负责清会话回登录表单 */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
error,
|
||||
disabled,
|
||||
placeholder,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
error: string | null;
|
||||
disabled: boolean;
|
||||
placeholder: string;
|
||||
onChange: (value: string) => void;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<label className="lx-field">
|
||||
<span className="lx-field__label">{label}</span>
|
||||
<Input
|
||||
type="password"
|
||||
size="large"
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
onChange={onChange}
|
||||
/>
|
||||
{error === null ? null : <span className="lx-field__error">{error}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 首登强制改密:只在登录流程内渲染(不进入 AppLayout),
|
||||
* 改密成功后由登录页清会话并要求用新密码重新登录。
|
||||
*/
|
||||
export function MustChangePasswordModal({
|
||||
onCompleted,
|
||||
onCancel,
|
||||
}: MustChangePasswordModalProps): ReactNode {
|
||||
const form = useChangePasswordForm();
|
||||
|
||||
const handleSubmit = (): void => {
|
||||
void form.submit().then((ok) => {
|
||||
if (ok) {
|
||||
onCompleted();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
closable={!form.submitting}
|
||||
maskClosable={false}
|
||||
title="首次登录,请修改初始密码"
|
||||
width={440}
|
||||
onCancel={onCancel}
|
||||
footer={
|
||||
<div className="lx-must-change__footer">
|
||||
<Button onClick={onCancel} disabled={form.submitting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
theme="solid"
|
||||
type="primary"
|
||||
loading={form.submitting}
|
||||
onClick={handleSubmit}
|
||||
data-testid="change-password-submit"
|
||||
>
|
||||
更新密码
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="lx-must-change">
|
||||
<p className="lx-must-change__hint">
|
||||
<IconLock size={16} />
|
||||
为保障账号安全,初始密码仅用于首次登录;新密码需 8–64 位且同时包含字母与数字。
|
||||
</p>
|
||||
<Field
|
||||
label="当前密码"
|
||||
value={form.currentPassword}
|
||||
error={form.currentError}
|
||||
disabled={form.submitting}
|
||||
placeholder="请输入初始密码"
|
||||
onChange={form.setCurrentPassword}
|
||||
/>
|
||||
<Field
|
||||
label="新密码"
|
||||
value={form.newPassword}
|
||||
error={form.newError}
|
||||
disabled={form.submitting}
|
||||
placeholder="请输入新密码"
|
||||
onChange={form.setNewPassword}
|
||||
/>
|
||||
{form.submitError === null ? null : (
|
||||
<p className="lx-must-change__error">{form.submitError}</p>
|
||||
)}
|
||||
<p className="lx-must-change__hint is-muted">
|
||||
修改成功后当前会话将退出,请使用新密码重新登录。
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ApiError } from '@/api/client';
|
||||
import { fetchMe } from '@/api/identity';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
// @lingxi/ui 的骨架/错误态依赖 Semi(lottie),jsdom 无 canvas;守卫单测只关心跳转逻辑
|
||||
vi.mock('@lingxi/ui', () => ({
|
||||
PageSkeleton: () => <div data-testid="skeleton" />,
|
||||
ErrorState: () => <div data-testid="error-state" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/api/identity', () => ({
|
||||
fetchMe: vi.fn(),
|
||||
}));
|
||||
|
||||
import { RequireAuth } from './RequireAuth';
|
||||
|
||||
const mockedFetchMe = vi.mocked(fetchMe);
|
||||
|
||||
function renderGuard(entry: string): void {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<div>受保护页面</div>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route path="/login" element={<div data-testid="login-route">登录页</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('登录守卫', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockedFetchMe.mockReset();
|
||||
useAuthStore.getState().clearSession();
|
||||
});
|
||||
|
||||
it('未登录时跳登录页并携带 redirect', async () => {
|
||||
renderGuard('/users');
|
||||
expect(await screen.findByTestId('login-route')).toBeTruthy();
|
||||
expect(screen.queryByText('受保护页面')).toBeNull();
|
||||
});
|
||||
|
||||
it('会话处于「未改初始密码」时清会话并回登录页(后端 40903)', async () => {
|
||||
mockedFetchMe.mockRejectedValue(
|
||||
new ApiError({ code: 40903, status: 409, message: '请先修改初始密码' }),
|
||||
);
|
||||
useAuthStore.getState().setSession({
|
||||
accessToken: 'real-token',
|
||||
refreshToken: 'refresh',
|
||||
user: { id: '01HZZZZZZZZZZZZZZZZZZZZZZZ', name: '管理员', account: 'admin' },
|
||||
permissions: ['user:view'],
|
||||
mustChangePassword: true,
|
||||
});
|
||||
renderGuard('/users');
|
||||
expect(await screen.findByTestId('login-route')).toBeTruthy();
|
||||
expect(useAuthStore.getState().accessToken).toBeNull();
|
||||
});
|
||||
|
||||
it('已登录(演示会话)时直接渲染受保护内容', () => {
|
||||
useAuthStore.getState().signInDemo();
|
||||
expect(useAuthStore.getState().accessToken).not.toBeNull();
|
||||
renderGuard('/users');
|
||||
expect(screen.getByText('受保护页面')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/* eslint-disable max-lines-per-function -- 守卫串联 /me 同步、401 与 409 兜底,集中便于追踪 */
|
||||
import { ErrorState, PageSkeleton } from '@lingxi/ui';
|
||||
import { type ReactNode, useEffect } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router';
|
||||
|
||||
import {
|
||||
isAccountDisabledError,
|
||||
isMustChangePasswordError,
|
||||
isUnauthorizedError,
|
||||
useMeQuery,
|
||||
} from '@/hooks/queries/use-me';
|
||||
import { loginPath } from '@/routes';
|
||||
import { isDemoSession, useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
export interface RequireAuthProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** 登录守卫:未登录跳登录页(带 redirect),已登录则拉 /me 后再渲染外壳 */
|
||||
export function RequireAuth({ children }: RequireAuthProps): ReactNode {
|
||||
const location = useLocation();
|
||||
const accessToken = useAuthStore((state) => state.accessToken);
|
||||
const syncFromMe = useAuthStore((state) => state.syncFromMe);
|
||||
const clearSession = useAuthStore((state) => state.clearSession);
|
||||
const meQuery = useMeQuery();
|
||||
const from = `${location.pathname}${location.search}`;
|
||||
const mustChangeBlocked = isMustChangePasswordError(meQuery.error);
|
||||
|
||||
useEffect(() => {
|
||||
if (meQuery.data !== undefined) {
|
||||
syncFromMe(meQuery.data);
|
||||
}
|
||||
}, [meQuery.data, syncFromMe]);
|
||||
|
||||
// 刷新页面时若会话仍处于「未改初始密码」状态:清会话回登录页完成改密,避免卡在 409
|
||||
useEffect(() => {
|
||||
if (mustChangeBlocked) {
|
||||
clearSession();
|
||||
}
|
||||
}, [mustChangeBlocked, clearSession]);
|
||||
|
||||
if (accessToken === null) {
|
||||
return <Navigate to={loginPath(from)} replace />;
|
||||
}
|
||||
if (isDemoSession(accessToken)) {
|
||||
return children;
|
||||
}
|
||||
if (meQuery.isPending) {
|
||||
return (
|
||||
<div className="lx-auth-loading" data-testid="auth-loading">
|
||||
<PageSkeleton rows={10} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (mustChangeBlocked) {
|
||||
return <Navigate to={loginPath(from)} replace />;
|
||||
}
|
||||
if (meQuery.isError) {
|
||||
if (isAccountDisabledError(meQuery.error)) {
|
||||
// 会话在登录页按 reason 清理,避免先 clearSession 触发「未登录」分支把 reason 丢掉
|
||||
const base = loginPath(from);
|
||||
return (
|
||||
<Navigate to={`${base}${base.includes('?') ? '&' : '?'}reason=account-disabled`} replace />
|
||||
);
|
||||
}
|
||||
if (isUnauthorizedError(meQuery.error)) {
|
||||
clearSession();
|
||||
return <Navigate to={loginPath(from)} replace />;
|
||||
}
|
||||
return (
|
||||
<div className="lx-auth-loading">
|
||||
<ErrorState
|
||||
title="权限信息加载失败"
|
||||
message={meQuery.error.message}
|
||||
onRetry={() => {
|
||||
void meQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Permission } from '@lingxi/shared';
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
import { RequirePermission } from './RequirePermission';
|
||||
|
||||
const USER_ID = '01HZZZZZZZZZZZZZZZZZZZZZZZ';
|
||||
|
||||
function renderGuard(permissions: string[]): void {
|
||||
useAuthStore.getState().setSession({
|
||||
accessToken: 'token',
|
||||
user: { id: USER_ID, name: '测试用户', account: 'test.user' },
|
||||
permissions,
|
||||
});
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/users']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
<RequirePermission permission={Permission.USER_VIEW}>
|
||||
<div>用户页面</div>
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route path="/403" element={<div>无权限页面</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('路由权限守卫', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
useAuthStore.getState().clearSession();
|
||||
});
|
||||
|
||||
it('有 user:view 时渲染子路由', () => {
|
||||
renderGuard([Permission.USER_VIEW]);
|
||||
expect(screen.getByText('用户页面')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('无权限时跳转 403,不渲染子路由', () => {
|
||||
renderGuard([Permission.SCRIPT_READ]);
|
||||
expect(screen.queryByText('用户页面')).toBeNull();
|
||||
expect(screen.getByText('无权限页面')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { PermissionCode } from '@lingxi/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Navigate } from 'react-router';
|
||||
|
||||
import { usePermission } from '@/hooks/use-permission';
|
||||
import { ROUTES } from '@/routes';
|
||||
|
||||
export interface RequirePermissionProps {
|
||||
permission: PermissionCode | readonly PermissionCode[];
|
||||
mode?: 'all' | 'any';
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** 路由级权限守卫:缺少权限点时跳 403(权限来自 /me 或登录响应) */
|
||||
export function RequirePermission({
|
||||
permission,
|
||||
mode = 'all',
|
||||
children,
|
||||
}: RequirePermissionProps): ReactNode {
|
||||
const allowed = usePermission(permission, mode);
|
||||
return allowed ? children : <Navigate to={ROUTES.forbidden} replace />;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ApiError } from '@/api/client';
|
||||
import { changePassword } from '@/api/identity';
|
||||
|
||||
import { useChangePasswordForm } from './use-change-password-form';
|
||||
|
||||
vi.mock('@/api/identity', () => ({
|
||||
changePassword: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedChangePassword = vi.mocked(changePassword);
|
||||
|
||||
describe('首登强改密码表单', () => {
|
||||
beforeEach(() => {
|
||||
mockedChangePassword.mockReset();
|
||||
});
|
||||
|
||||
it('当前密码错误(后端 401)显示在字段下且不返回成功', async () => {
|
||||
mockedChangePassword.mockRejectedValue(
|
||||
new ApiError({ code: 40101, status: 401, message: '当前密码不正确' }),
|
||||
);
|
||||
const { result } = renderHook(() => useChangePasswordForm());
|
||||
act(() => {
|
||||
result.current.setCurrentPassword('wrong-pass1');
|
||||
result.current.setNewPassword('Lingxi2026a');
|
||||
});
|
||||
let ok = true;
|
||||
await act(async () => {
|
||||
ok = await result.current.submit();
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(result.current.currentError).toBe('当前密码不正确,请重新输入');
|
||||
expect(result.current.submitError).toBeNull();
|
||||
});
|
||||
|
||||
it('校验不过时不发请求', async () => {
|
||||
const { result } = renderHook(() => useChangePasswordForm());
|
||||
act(() => {
|
||||
result.current.setCurrentPassword('lingxi2026');
|
||||
result.current.setNewPassword('short');
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.submit();
|
||||
});
|
||||
expect(mockedChangePassword).not.toHaveBeenCalled();
|
||||
expect(result.current.newError).toBeTruthy();
|
||||
});
|
||||
|
||||
it('改密成功返回 true,由调用方负责清会话', async () => {
|
||||
mockedChangePassword.mockResolvedValue();
|
||||
const { result } = renderHook(() => useChangePasswordForm());
|
||||
act(() => {
|
||||
result.current.setCurrentPassword('lingxi2026');
|
||||
result.current.setNewPassword('Lingxi2026a');
|
||||
});
|
||||
let ok = false;
|
||||
await act(async () => {
|
||||
ok = await result.current.submit();
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
expect(mockedChangePassword).toHaveBeenCalledWith({
|
||||
currentPassword: 'lingxi2026',
|
||||
newPassword: 'Lingxi2026a',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/* eslint-disable max-lines-per-function -- 表单校验与提交流程集中在一个 Hook 便于追踪 */
|
||||
import { changePasswordInputSchema, passwordSchema } from '@lingxi/shared';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { ApiError } from '@/api/client';
|
||||
import { changePassword } from '@/api/identity';
|
||||
|
||||
export interface ChangePasswordFormState {
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
currentError: string | null;
|
||||
newError: string | null;
|
||||
submitting: boolean;
|
||||
submitError: string | null;
|
||||
setCurrentPassword: (value: string) => void;
|
||||
setNewPassword: (value: string) => void;
|
||||
/** 提交成功返回 true(由调用方决定清会话/跳转),失败返回 false */
|
||||
submit: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
function fieldError(message: string | undefined): string | null {
|
||||
return message ?? null;
|
||||
}
|
||||
|
||||
function isWrongCurrentPassword(error: unknown): boolean {
|
||||
return error instanceof ApiError && (error.status === 401 || error.code === 40101);
|
||||
}
|
||||
|
||||
/** 首次登录强制改密:密码规则与后端共用 shared 的 zod schema;不改会话,副作用交给调用方 */
|
||||
export function useChangePasswordForm(): ChangePasswordFormState {
|
||||
const [currentPassword, setCurrent] = useState('');
|
||||
const [newPassword, setNew] = useState('');
|
||||
const [currentError, setCurrentError] = useState<string | null>(null);
|
||||
const [newError, setNewError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const submit = async (): Promise<boolean> => {
|
||||
const parsed = changePasswordInputSchema.safeParse({ currentPassword, newPassword });
|
||||
if (!parsed.success) {
|
||||
const issues = parsed.error.issues;
|
||||
setCurrentError(
|
||||
fieldError(issues.find((issue) => issue.path[0] === 'currentPassword')?.message),
|
||||
);
|
||||
setNewError(fieldError(issues.find((issue) => issue.path[0] === 'newPassword')?.message));
|
||||
return false;
|
||||
}
|
||||
const passwordCheck = passwordSchema.safeParse(newPassword);
|
||||
if (!passwordCheck.success) {
|
||||
setNewError(fieldError(passwordCheck.error.issues[0]?.message));
|
||||
return false;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
await changePassword(parsed.data);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isWrongCurrentPassword(error)) {
|
||||
// 后端对「当前密码不正确」返回 401;按字段错误展示,不触发全局登出
|
||||
setCurrentError('当前密码不正确,请重新输入');
|
||||
} else {
|
||||
setSubmitError(error instanceof Error ? error.message : '修改密码失败,请稍后重试');
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
currentPassword,
|
||||
newPassword,
|
||||
currentError,
|
||||
newError,
|
||||
submitting,
|
||||
submitError,
|
||||
setCurrentPassword: (value) => {
|
||||
setCurrent(value);
|
||||
setCurrentError(null);
|
||||
},
|
||||
setNewPassword: (value) => {
|
||||
setNew(value);
|
||||
setNewError(null);
|
||||
},
|
||||
submit,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { type DepartmentNode, type RoleSummary } from '@lingxi/shared';
|
||||
import { useQuery, type UseQueryResult } from '@tanstack/react-query';
|
||||
|
||||
import { fetchDepartments, fetchRoles } from '@/api/identity';
|
||||
import { queryKeys } from '@/api/query-keys';
|
||||
|
||||
export function useDepartmentsQuery(): UseQueryResult<DepartmentNode[]> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.departments,
|
||||
queryFn: fetchDepartments,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRolesQuery(): UseQueryResult<RoleSummary[]> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.roles,
|
||||
queryFn: fetchRoles,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { MeResponse } from '@lingxi/shared';
|
||||
import { useQuery, type UseQueryResult } from '@tanstack/react-query';
|
||||
|
||||
import { ApiError } from '@/api/client';
|
||||
import { fetchMe } from '@/api/identity';
|
||||
import { queryKeys } from '@/api/query-keys';
|
||||
import { isDemoSession, useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
/** /me 全局缓存:守卫与权限 Hook 共用;演示会话不请求后端 */
|
||||
export function useMeQuery(): UseQueryResult<MeResponse> {
|
||||
const accessToken = useAuthStore((state) => state.accessToken);
|
||||
const enabled = accessToken !== null && !isDemoSession(accessToken);
|
||||
return useQuery({
|
||||
queryKey: queryKeys.me,
|
||||
queryFn: fetchMe,
|
||||
enabled,
|
||||
retry: false,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function isUnauthorizedError(error: unknown): boolean {
|
||||
return error instanceof ApiError && error.status === 401;
|
||||
}
|
||||
|
||||
/** 账号被停用:/me 由登录守卫返回 40301,此时旧会话应视为失效并回登录页 */
|
||||
export function isAccountDisabledError(error: unknown): boolean {
|
||||
return error instanceof ApiError && (error.status === 403 || error.code === 40301);
|
||||
}
|
||||
|
||||
/** 后端守卫:初始密码未修改时,除改密外的接口统一返回 40903「请先修改初始密码」 */
|
||||
export function isMustChangePasswordError(error: unknown): boolean {
|
||||
return error instanceof ApiError && (error.status === 409 || error.code === 40903);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
type CreateUserInput,
|
||||
type DisableUserInput,
|
||||
type ListResponse,
|
||||
type TransferCustomersInput,
|
||||
type UpdateUserInput,
|
||||
type UserDetail,
|
||||
type UserSummary,
|
||||
} from '@lingxi/shared';
|
||||
import {
|
||||
keepPreviousData,
|
||||
useMutation,
|
||||
type UseMutationResult,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type UseQueryResult,
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import { ApiError } from '@/api/client';
|
||||
import {
|
||||
createUser,
|
||||
type CustomerSummary,
|
||||
disableUser,
|
||||
enableUser,
|
||||
fetchUserCustomers,
|
||||
transferCustomers,
|
||||
updateUser,
|
||||
} from '@/api/identity';
|
||||
import { queryKeys } from '@/api/query-keys';
|
||||
import { fetchUserDetail, fetchUsers, type UserListQueryInput } from '@/api/users';
|
||||
|
||||
/** 转交/停用弹窗的接收人候选:一次拉取在职成员(上限 100),长时间复用 */
|
||||
export function useUserCandidatesQuery(
|
||||
enabled: boolean,
|
||||
): UseQueryResult<ListResponse<UserSummary>> {
|
||||
return useQuery({
|
||||
queryKey: [...queryKeys.users, 'candidates'] as const,
|
||||
queryFn: () => fetchUsers({ limit: 100, status: 'active', sort: 'last_login', order: 'desc' }),
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUserListQuery(
|
||||
query: UserListQueryInput,
|
||||
): UseQueryResult<ListResponse<UserSummary>> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.userList(query),
|
||||
queryFn: () => fetchUsers(query),
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUserDetailQuery(userId: string | null): UseQueryResult<UserDetail> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.user(userId ?? ''),
|
||||
queryFn: () => fetchUserDetail(userId ?? ''),
|
||||
enabled: userId !== null && userId !== '',
|
||||
});
|
||||
}
|
||||
|
||||
/** 负责客户:A3 客户模块接口未落地时降级为空列表,不阻塞详情其它 Tab */
|
||||
export function useUserCustomersQuery(userId: string | null): UseQueryResult<CustomerSummary[]> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.userCustomers(userId ?? ''),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await fetchUserCustomers(userId ?? '');
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && (error.status === 404 || error.status === 422)) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
enabled: userId !== null && userId !== '',
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
function useInvalidateUsers(): () => Promise<void> {
|
||||
const queryClient = useQueryClient();
|
||||
return async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.users });
|
||||
};
|
||||
}
|
||||
|
||||
export function useCreateUserMutation(): UseMutationResult<string | null, Error, CreateUserInput> {
|
||||
const invalidate = useInvalidateUsers();
|
||||
return useMutation({
|
||||
mutationFn: (input: CreateUserInput) => createUser(input),
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateUserMutation(): UseMutationResult<
|
||||
void,
|
||||
Error,
|
||||
{ userId: string; input: UpdateUserInput }
|
||||
> {
|
||||
const invalidate = useInvalidateUsers();
|
||||
return useMutation({
|
||||
mutationFn: ({ userId, input }: { userId: string; input: UpdateUserInput }) =>
|
||||
updateUser(userId, input),
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDisableUserMutation(): UseMutationResult<
|
||||
void,
|
||||
Error,
|
||||
{ userId: string; input: DisableUserInput }
|
||||
> {
|
||||
const invalidate = useInvalidateUsers();
|
||||
return useMutation({
|
||||
mutationFn: ({ userId, input }: { userId: string; input: DisableUserInput }) =>
|
||||
disableUser(userId, input),
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useEnableUserMutation(): UseMutationResult<void, Error, string> {
|
||||
const invalidate = useInvalidateUsers();
|
||||
return useMutation({
|
||||
mutationFn: (userId: string) => enableUser(userId),
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTransferCustomersMutation(): UseMutationResult<
|
||||
void,
|
||||
Error,
|
||||
{ userId: string; input: TransferCustomersInput }
|
||||
> {
|
||||
const invalidate = useInvalidateUsers();
|
||||
return useMutation({
|
||||
mutationFn: ({ userId, input }: { userId: string; input: TransferCustomersInput }) =>
|
||||
transferCustomers(userId, input),
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/** 输入防抖:UI 即时反馈,请求参数延后生效 */
|
||||
export function useDebouncedValue<T>(value: T, delay = 300): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
setDebounced(value);
|
||||
}, delay);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debounced;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { logout } from '@/api/identity';
|
||||
import { ROUTES } from '@/routes';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
/** 登出:先吊销服务端会话(失败不阻塞),再清本地会话与查询缓存 */
|
||||
export function useLogout(): () => void {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const refreshToken = useAuthStore((state) => state.refreshToken);
|
||||
const clearSession = useAuthStore((state) => state.clearSession);
|
||||
|
||||
return useCallback(() => {
|
||||
const finish = (): void => {
|
||||
clearSession();
|
||||
queryClient.clear();
|
||||
void navigate(ROUTES.login, { replace: true });
|
||||
};
|
||||
void logout(refreshToken)
|
||||
.catch(() => undefined)
|
||||
.finally(finish);
|
||||
}, [clearSession, navigate, queryClient, refreshToken]);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Permission } from '@lingxi/shared';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
import { can, usePermission, usePermissions } from './use-permission';
|
||||
|
||||
const USER_ID = '01HZZZZZZZZZZZZZZZZZZZZZZZ';
|
||||
|
||||
function signIn(permissions: string[]): void {
|
||||
act(() => {
|
||||
useAuthStore.getState().setSession({
|
||||
accessToken: 'token',
|
||||
user: { id: USER_ID, name: '测试用户', account: 'test.user' },
|
||||
permissions,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('权限判断', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
act(() => {
|
||||
useAuthStore.getState().clearSession();
|
||||
});
|
||||
});
|
||||
|
||||
it('can 支持单权限、全部命中与任一命中', () => {
|
||||
expect(can([Permission.USER_VIEW], Permission.USER_VIEW)).toBe(true);
|
||||
expect(can([], Permission.USER_VIEW)).toBe(false);
|
||||
expect(can([Permission.USER_VIEW], [Permission.USER_VIEW, Permission.USER_MANAGE])).toBe(false);
|
||||
expect(can([Permission.USER_VIEW], [Permission.USER_VIEW, Permission.USER_MANAGE], 'any')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(can([], [])).toBe(true);
|
||||
});
|
||||
|
||||
it('usePermission 随 auth store 权限变化更新', () => {
|
||||
const view = renderHook(() => usePermission(Permission.USER_VIEW));
|
||||
expect(view.result.current).toBe(false);
|
||||
|
||||
signIn([Permission.USER_VIEW]);
|
||||
expect(view.result.current).toBe(true);
|
||||
|
||||
signIn([]);
|
||||
expect(view.result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('usePermissions 返回当前权限集合', () => {
|
||||
signIn([Permission.USER_VIEW, Permission.USER_MANAGE]);
|
||||
const perms = renderHook(() => usePermissions());
|
||||
expect(perms.result.current).toEqual([Permission.USER_VIEW, Permission.USER_MANAGE]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { PermissionCode } from '@lingxi/shared';
|
||||
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
/** 纯函数:权限点判断(便于单测与在非组件上下文复用) */
|
||||
export function can(
|
||||
permissions: readonly string[],
|
||||
permission: PermissionCode | readonly PermissionCode[],
|
||||
mode: 'all' | 'any' = 'all',
|
||||
): boolean {
|
||||
const required = typeof permission === 'string' ? [permission] : permission;
|
||||
if (required.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return mode === 'all'
|
||||
? required.every((code) => permissions.includes(code))
|
||||
: required.some((code) => permissions.includes(code));
|
||||
}
|
||||
|
||||
/** 当前登录用户的权限点集合(/me 或登录响应写入 auth store) */
|
||||
export function usePermissions(): readonly string[] {
|
||||
return useAuthStore((state) => state.permissions);
|
||||
}
|
||||
|
||||
/** 单个(或一组)权限点判断;`mode='all'` 要求全部命中 */
|
||||
export function usePermission(
|
||||
permission: PermissionCode | readonly PermissionCode[],
|
||||
mode: 'all' | 'any' = 'all',
|
||||
): boolean {
|
||||
const permissions = usePermissions();
|
||||
return can(permissions, permission, mode);
|
||||
}
|
||||
@@ -11,6 +11,11 @@ import { PageBreadcrumbs } from './PageBreadcrumbs';
|
||||
import { SideNav } from './SideNav';
|
||||
import { TopBar } from './TopBar';
|
||||
|
||||
/** 用户与组织为「满高主从布局」,由页面自己控制内部滚动 */
|
||||
function isFlushRoute(pathname: string): boolean {
|
||||
return pathname === ROUTES.users || pathname.startsWith(`${ROUTES.users}/`);
|
||||
}
|
||||
|
||||
export function AppLayout(): ReactNode {
|
||||
const location = useLocation();
|
||||
const permissions = useAuthStore((state) => state.permissions);
|
||||
@@ -46,7 +51,7 @@ export function AppLayout(): ReactNode {
|
||||
/>
|
||||
<main className="lx-workspace">
|
||||
<PageBreadcrumbs pathname={location.pathname} />
|
||||
<div className="lx-page">
|
||||
<div className={isFlushRoute(location.pathname) ? 'lx-page lx-page--flush' : 'lx-page'}>
|
||||
<Suspense fallback={<PageSkeleton rows={8} />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { getUserSectionTrail } from '@/navigation/breadcrumbs';
|
||||
import { getBreadcrumbs, NAV_MODULES } from '@/navigation/nav-config';
|
||||
|
||||
export interface PageBreadcrumbsProps {
|
||||
@@ -8,7 +9,7 @@ export interface PageBreadcrumbsProps {
|
||||
}
|
||||
|
||||
export function PageBreadcrumbs({ pathname }: PageBreadcrumbsProps): ReactNode {
|
||||
const entries = getBreadcrumbs(NAV_MODULES, pathname);
|
||||
const entries = getUserSectionTrail(pathname) ?? getBreadcrumbs(NAV_MODULES, pathname);
|
||||
return (
|
||||
<nav className="lx-breadcrumbs" aria-label="面包屑">
|
||||
{entries.map((entry, index) => {
|
||||
|
||||
@@ -2,8 +2,8 @@ import { IconChevronDown, IconLogout, IconNotification, IconSearch } from '@ling
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
|
||||
import { useLogout } from '@/hooks/use-logout';
|
||||
import type { NavModule } from '@/navigation/nav-config';
|
||||
import { ROUTES } from '@/routes';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
export interface TopBarProps {
|
||||
@@ -42,10 +42,9 @@ function ModuleTabs({
|
||||
}
|
||||
|
||||
function UserMenu(): ReactNode {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const clearSession = useAuthStore((state) => state.clearSession);
|
||||
const logout = useLogout();
|
||||
const [openPath, setOpenPath] = useState<string | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const open = openPath === location.pathname;
|
||||
@@ -80,25 +79,18 @@ function UserMenu(): ReactNode {
|
||||
</span>
|
||||
<IconChevronDown size={14} className="lx-user__chevron" />
|
||||
</button>
|
||||
<UserMenuDropdown
|
||||
open={open}
|
||||
email={user?.email ?? '—'}
|
||||
onLogout={() => {
|
||||
clearSession();
|
||||
void navigate(ROUTES.login, { replace: true });
|
||||
}}
|
||||
/>
|
||||
<UserMenuDropdown open={open} account={user?.account ?? '—'} onLogout={logout} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserMenuDropdown({
|
||||
open,
|
||||
email,
|
||||
account,
|
||||
onLogout,
|
||||
}: {
|
||||
open: boolean;
|
||||
email: string;
|
||||
account: string;
|
||||
onLogout: () => void;
|
||||
}): ReactNode {
|
||||
if (!open) {
|
||||
@@ -106,7 +98,7 @@ function UserMenuDropdown({
|
||||
}
|
||||
return (
|
||||
<div className="lx-user__menu" role="menu">
|
||||
<div className="lx-user__menu-header">{email}</div>
|
||||
<div className="lx-user__menu-header">{account}</div>
|
||||
<button type="button" role="menuitem" className="lx-user__menu-item" onClick={onLogout}>
|
||||
<IconLogout size={16} />
|
||||
退出登录
|
||||
|
||||
@@ -5,6 +5,7 @@ import '@lingxi/ui/styles.css';
|
||||
import '@/styles/global.css';
|
||||
import '@/styles/layout.css';
|
||||
import '@/styles/pages.css';
|
||||
import '@/styles/users.css';
|
||||
|
||||
import { LocaleProvider } from '@douyinfe/semi-ui-19';
|
||||
import { localeZhCN } from '@lingxi/ui';
|
||||
@@ -16,10 +17,15 @@ import { RouterProvider } from 'react-router';
|
||||
import { setUnauthorizedHandler } from '@/api/client';
|
||||
import { queryClient } from '@/api/query-client';
|
||||
import { router } from '@/router';
|
||||
import { ROUTES } from '@/routes';
|
||||
import { loginPath, ROUTES } from '@/routes';
|
||||
|
||||
setUnauthorizedHandler(() => {
|
||||
void router.navigate(ROUTES.login, { replace: true });
|
||||
const { pathname, search } = router.state.location;
|
||||
queryClient.clear();
|
||||
if (pathname === ROUTES.login) {
|
||||
return;
|
||||
}
|
||||
void router.navigate(loginPath(`${pathname}${search}`), { replace: true });
|
||||
});
|
||||
|
||||
const container = document.getElementById('root');
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { BreadcrumbEntry } from './nav-config';
|
||||
|
||||
/**
|
||||
* /users 下的子路由(新建 / 详情)不在 nav-config 的菜单项里,
|
||||
* 这里补齐面包屑:工作台 / 平台 / 用户与组织 / 当前页。
|
||||
*/
|
||||
export function getUserSectionTrail(pathname: string): BreadcrumbEntry[] | null {
|
||||
if (pathname === '/users/new') {
|
||||
return [
|
||||
{ key: 'home', label: '工作台', path: '/' },
|
||||
{ key: 'platform', label: '平台' },
|
||||
{ key: 'users', label: '用户与组织', path: '/users' },
|
||||
{ key: 'user-new', label: '新建用户' },
|
||||
];
|
||||
}
|
||||
if (/^\/users\/[^/]+$/.test(pathname)) {
|
||||
return [
|
||||
{ key: 'home', label: '工作台', path: '/' },
|
||||
{ key: 'platform', label: '平台' },
|
||||
{ key: 'users', label: '用户与组织', path: '/users' },
|
||||
{ key: 'user-detail', label: '成员详情' },
|
||||
];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -26,6 +26,13 @@ describe('导航配置', () => {
|
||||
expect(modules.map((module) => module.key)).toEqual(['dashboard']);
|
||||
});
|
||||
|
||||
it('子路由(/users/new、/users/:id)也归属用户与组织', () => {
|
||||
expect(findActiveNav(NAV_MODULES, '/users/new')?.item?.label).toBe('用户与组织');
|
||||
expect(findActiveNav(NAV_MODULES, '/users/01HZZZZZZZZZZZZZZZZZZZZZZZ')?.module.key).toBe(
|
||||
'platform',
|
||||
);
|
||||
});
|
||||
|
||||
it('按路径定位当前模块与菜单', () => {
|
||||
const active = findActiveNav(NAV_MODULES, '/material/quick-review');
|
||||
expect(active?.module.key).toBe('material');
|
||||
@@ -35,7 +42,7 @@ describe('导航配置', () => {
|
||||
|
||||
it('面包屑从导航配置派生', () => {
|
||||
expect(getBreadcrumbs(NAV_MODULES, '/')).toEqual([{ key: 'home', label: '工作台', path: '/' }]);
|
||||
const breadcrumbs = getBreadcrumbs(NAV_MODULES, '/platform/users');
|
||||
const breadcrumbs = getBreadcrumbs(NAV_MODULES, '/users');
|
||||
expect(breadcrumbs.map((entry) => entry.label)).toEqual(['工作台', '平台', '用户与组织']);
|
||||
});
|
||||
});
|
||||
@@ -242,16 +242,16 @@ export const NAV_MODULES: readonly NavModule[] = [
|
||||
{
|
||||
key: 'platform',
|
||||
label: '平台',
|
||||
path: '/platform/users',
|
||||
path: '/users',
|
||||
icon: IconSettings,
|
||||
permission: Permission.USER_MANAGE,
|
||||
permission: Permission.USER_VIEW,
|
||||
items: [
|
||||
{
|
||||
key: 'users',
|
||||
label: '用户与组织',
|
||||
path: '/platform/users',
|
||||
path: '/users',
|
||||
icon: IconUser,
|
||||
permission: Permission.USER_MANAGE,
|
||||
permission: Permission.USER_VIEW,
|
||||
description: '账号、组织与登录状态',
|
||||
},
|
||||
{
|
||||
@@ -378,7 +378,11 @@ export interface ActiveNav {
|
||||
export function findActiveNav(modules: readonly NavModule[], pathname: string): ActiveNav | null {
|
||||
const normalized = pathname !== '/' ? pathname.replace(/\/+$/, '') : pathname;
|
||||
for (const module of modules) {
|
||||
const item = module.items.find((candidate) => candidate.path === normalized);
|
||||
const item = module.items.find(
|
||||
(candidate) =>
|
||||
candidate.path === normalized ||
|
||||
(candidate.path !== '/' && normalized.startsWith(`${candidate.path}/`)),
|
||||
);
|
||||
if (item) {
|
||||
return { module, item };
|
||||
}
|
||||
|
||||
@@ -1,14 +1,83 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export function LoginBrandPanel(): ReactNode {
|
||||
import {
|
||||
LOGIN_CAPABILITIES,
|
||||
LOGIN_MATERIAL_SAMPLES,
|
||||
type LoginCapability,
|
||||
type LoginMaterialSample,
|
||||
} from './login-content';
|
||||
|
||||
function LoginBrand(): ReactNode {
|
||||
return (
|
||||
<header className="lx-login__topbar">
|
||||
<div className="lx-login__brand">
|
||||
<span className="lx-login__mark">灵</span>
|
||||
<div className="lx-login__brand">
|
||||
<span className="lx-login__mark">灵</span>
|
||||
<span className="lx-login__brand-text">
|
||||
<span className="lx-login__wordmark">灵犀</span>
|
||||
<span className="lx-login__brand-sub">素材生产平台 · LINGXI</span>
|
||||
<span className="lx-login__brand-sub">素材生产平台</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CapabilityItem({ capability }: { capability: LoginCapability }): ReactNode {
|
||||
const Icon = capability.icon;
|
||||
return (
|
||||
<li className="lx-login__capability">
|
||||
<span className="lx-login__capability-icon" aria-hidden="true">
|
||||
<Icon size={17} />
|
||||
</span>
|
||||
<span className="lx-login__capability-copy">
|
||||
<span className="lx-login__capability-title">{capability.title}</span>
|
||||
<span className="lx-login__capability-desc">{capability.description}</span>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function MaterialSample({ sample }: { sample: LoginMaterialSample }): ReactNode {
|
||||
return (
|
||||
<figure className="lx-login__sample">
|
||||
<span
|
||||
className={`lx-login__sample-thumb lx-login__sample-thumb--${String(sample.tone)}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="lx-login__sample-ratio">{sample.ratio}</span>
|
||||
</span>
|
||||
<figcaption className="lx-login__sample-label">{sample.label}</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
/** 左栏:品牌 + 一句定位 + 三条能力 + CSS 素材墙(与方案 B 原型一致,不引图片) */
|
||||
export function LoginBrandPanel(): ReactNode {
|
||||
return (
|
||||
<aside className="lx-login__aside" aria-label="灵犀产品介绍">
|
||||
<LoginBrand />
|
||||
|
||||
<div className="lx-login__pitch">
|
||||
<h1 className="lx-login__headline">
|
||||
一条产线,
|
||||
<br />
|
||||
把素材从脚本做到成片
|
||||
</h1>
|
||||
<p className="lx-login__desc">
|
||||
行业包、要素卡、脚本、图片合成与视频逐镜生成收在同一条生产线上;快筛通过的素材直接导出归档,交付团队拿到的就是可投放的成品。
|
||||
</p>
|
||||
|
||||
<ul className="lx-login__capabilities">
|
||||
{LOGIN_CAPABILITIES.map((capability) => (
|
||||
<CapabilityItem key={capability.key} capability={capability} />
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="lx-login__samples" aria-label="可交付素材规格示例">
|
||||
{LOGIN_MATERIAL_SAMPLES.map((sample) => (
|
||||
<MaterialSample key={sample.key} sample={sample} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<span className="lx-login__badge">内部系统 · 仅限授权成员</span>
|
||||
</header>
|
||||
|
||||
<p className="lx-login__aside-foot">灵犀 · 素材生产平台 · v2.4.0</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,114 +1,154 @@
|
||||
import { Button, Checkbox, Input } from '@douyinfe/semi-ui-19';
|
||||
import { IconLock, IconMail } from '@lingxi/ui/icons';
|
||||
import type { ReactNode, SyntheticEvent } from 'react';
|
||||
import { notifyWarning } from '@lingxi/ui';
|
||||
import { IconAlert, IconCheck, IconLock, IconMail, IconView } from '@lingxi/ui/icons';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
|
||||
import type { LoginFormState } from './use-login-form';
|
||||
|
||||
export interface LoginFormCardProps {
|
||||
account: string;
|
||||
password: string;
|
||||
submitting: boolean;
|
||||
onAccountChange: (value: string) => void;
|
||||
onPasswordChange: (value: string) => void;
|
||||
onSubmit: (event: SyntheticEvent<HTMLFormElement>) => void;
|
||||
form: LoginFormState;
|
||||
/** 改密成功等成功提示(登录页内嵌提示位) */
|
||||
successMessage?: string | null;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
function LoginFormHeader(): ReactNode {
|
||||
function LoginSuccess({ message }: { message: string }): ReactNode {
|
||||
return (
|
||||
<header className="lx-login__form-head">
|
||||
<h2>登录灵犀</h2>
|
||||
<p>使用企业账号登录素材生产平台</p>
|
||||
</header>
|
||||
<div className="lx-login__alert lx-login__alert--success" role="status">
|
||||
<IconCheck size={16} />
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginFormFooter(): ReactNode {
|
||||
return (
|
||||
<footer className="lx-login__form-foot">
|
||||
<span className="lx-login__form-foot-dot" aria-hidden="true" />
|
||||
账号由平台管理员开通,如需协助请联系 IT 服务台
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginFields({
|
||||
account,
|
||||
password,
|
||||
onAccountChange,
|
||||
onPasswordChange,
|
||||
}: Pick<
|
||||
LoginFormCardProps,
|
||||
'account' | 'password' | 'onAccountChange' | 'onPasswordChange'
|
||||
>): ReactNode {
|
||||
return (
|
||||
<>
|
||||
<label className="lx-field">
|
||||
<span className="lx-field__label">账号</span>
|
||||
<Input
|
||||
size="large"
|
||||
value={account}
|
||||
onChange={onAccountChange}
|
||||
placeholder="请输入账号或邮箱"
|
||||
prefix={<IconMail size={16} />}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="lx-field">
|
||||
<span className="lx-field__label">密码</span>
|
||||
<Input
|
||||
size="large"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={onPasswordChange}
|
||||
placeholder="请输入密码"
|
||||
prefix={<IconLock size={16} />}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="lx-login__form-meta">
|
||||
<Checkbox defaultChecked>记住我(7 天)</Checkbox>
|
||||
<button type="button" className="lx-link-button">
|
||||
忘记密码?
|
||||
</button>
|
||||
function LoginAlert({ form }: { form: LoginFormState }): ReactNode {
|
||||
if (form.lockedMessage !== null) {
|
||||
return (
|
||||
<div className="lx-login__alert lx-login__alert--warn" role="alert">
|
||||
<IconAlert size={16} />
|
||||
<span>{form.lockedMessage}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (form.formError !== null) {
|
||||
return (
|
||||
<div className="lx-login__alert" role="alert">
|
||||
<IconAlert size={16} />
|
||||
<span>{form.formError}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function AccountField({ form }: { form: LoginFormState }): ReactNode {
|
||||
return (
|
||||
<label className="lx-field">
|
||||
<span className="lx-field__label">账号</span>
|
||||
<Input
|
||||
size="large"
|
||||
value={form.account}
|
||||
prefix={<IconMail size={16} />}
|
||||
placeholder="请输入账号或手机号"
|
||||
autoComplete="username"
|
||||
disabled={form.submitting || form.lockedMessage !== null || form.mustChangePassword}
|
||||
onChange={form.setAccount}
|
||||
/>
|
||||
{form.accountError === null ? null : (
|
||||
<span className="lx-field__error">{form.accountError}</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoginFormCard({
|
||||
account,
|
||||
password,
|
||||
submitting,
|
||||
onAccountChange,
|
||||
onPasswordChange,
|
||||
onSubmit,
|
||||
}: LoginFormCardProps): ReactNode {
|
||||
function PasswordField({ form }: { form: LoginFormState }): ReactNode {
|
||||
const [visible, setVisible] = useState(false);
|
||||
return (
|
||||
<div className="lx-login__form">
|
||||
<LoginFormHeader />
|
||||
<label className="lx-field">
|
||||
<span className="lx-field__label">密码</span>
|
||||
<Input
|
||||
size="large"
|
||||
type={visible ? 'text' : 'password'}
|
||||
value={form.password}
|
||||
prefix={<IconLock size={16} />}
|
||||
suffix={
|
||||
<button
|
||||
type="button"
|
||||
className="lx-login__eye"
|
||||
aria-label={visible ? '隐藏密码' : '显示密码'}
|
||||
onClick={() => {
|
||||
setVisible((current) => !current);
|
||||
}}
|
||||
>
|
||||
<IconView size={16} />
|
||||
</button>
|
||||
}
|
||||
placeholder="请输入密码"
|
||||
autoComplete="current-password"
|
||||
disabled={form.submitting || form.lockedMessage !== null || form.mustChangePassword}
|
||||
onChange={form.setPassword}
|
||||
/>
|
||||
{form.passwordError === null ? null : (
|
||||
<span className="lx-field__error">{form.passwordError}</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
<form onSubmit={onSubmit} data-testid="login-form">
|
||||
<LoginFields
|
||||
account={account}
|
||||
password={password}
|
||||
onAccountChange={onAccountChange}
|
||||
onPasswordChange={onPasswordChange}
|
||||
/>
|
||||
function LoginMeta(): ReactNode {
|
||||
return (
|
||||
<div className="lx-login__meta">
|
||||
<Checkbox defaultChecked>记住我(7 天)</Checkbox>
|
||||
<button
|
||||
type="button"
|
||||
className="lx-link-button"
|
||||
onClick={() => {
|
||||
notifyWarning('请联系平台管理员重置密码,或通过 IT 服务台提交申请');
|
||||
}}
|
||||
>
|
||||
忘记密码?
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 右栏登录卡片:错误 / 锁定 / 提交中三态由 form 状态驱动 */
|
||||
export function LoginFormCard({ form, successMessage, onSubmit }: LoginFormCardProps): ReactNode {
|
||||
const disabled = form.submitting || form.lockedMessage !== null || form.mustChangePassword;
|
||||
return (
|
||||
<div className="lx-login__card">
|
||||
<header className="lx-login__card-head">
|
||||
<h2>登录灵犀</h2>
|
||||
<p>使用企业账号登录素材生产平台</p>
|
||||
</header>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onSubmit();
|
||||
}}
|
||||
data-testid="login-form"
|
||||
>
|
||||
{successMessage === undefined || successMessage === null ? null : (
|
||||
<LoginSuccess message={successMessage} />
|
||||
)}
|
||||
<LoginAlert form={form} />
|
||||
<AccountField form={form} />
|
||||
<PasswordField form={form} />
|
||||
<LoginMeta />
|
||||
<Button
|
||||
htmlType="submit"
|
||||
theme="solid"
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
loading={submitting}
|
||||
loading={form.submitting}
|
||||
disabled={disabled}
|
||||
data-testid="login-submit"
|
||||
>
|
||||
登录
|
||||
{form.lockedMessage === null ? '登录' : '账号已锁定'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<LoginFormFooter />
|
||||
<p className="lx-login__help">账号由平台管理员开通,如需协助请联系 IT 服务台</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +1,73 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { IconAlert } from '@lingxi/ui/icons';
|
||||
import { type ReactNode, useEffect } from 'react';
|
||||
import { Navigate, useSearchParams } from 'react-router';
|
||||
|
||||
import { MustChangePasswordModal } from '@/components/auth/MustChangePasswordModal';
|
||||
import { ROUTES, sanitizeRedirect } from '@/routes';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
import { LoginBrandPanel } from './LoginBrandPanel';
|
||||
import { LoginFormCard } from './LoginFormCard';
|
||||
import { useLoginForm } from './use-login-form';
|
||||
|
||||
function AccountDisabledNotice(): ReactNode {
|
||||
return (
|
||||
<p className="lx-login__notice" role="alert">
|
||||
<IconAlert size={15} />
|
||||
你的账号已被停用,请联系平台管理员。停用账号无法登录,历史数据仍会保留。
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage(): ReactNode {
|
||||
const { account, password, submitting, setAccount, setPassword, handleSubmit } = useLoginForm();
|
||||
const accessToken = useAuthStore((state) => state.accessToken);
|
||||
const [searchParams] = useSearchParams();
|
||||
const form = useLoginForm();
|
||||
const clearSession = useAuthStore((state) => state.clearSession);
|
||||
const target = sanitizeRedirect(searchParams.get('redirect')) ?? ROUTES.users;
|
||||
const accountDisabled = searchParams.get('reason') === 'account-disabled';
|
||||
|
||||
useEffect(() => {
|
||||
if (accountDisabled) {
|
||||
clearSession();
|
||||
}
|
||||
}, [accountDisabled, clearSession]);
|
||||
|
||||
// 首登强改期间已写入 token,但不能进入 AppLayout(后端会拒绝其它接口)
|
||||
if (accessToken !== null && !form.mustChangePassword && !accountDisabled) {
|
||||
return <Navigate to={target} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="lx-login">
|
||||
<LoginBrandPanel />
|
||||
<section className="lx-login__panel">
|
||||
<section className="lx-login__main">
|
||||
<span className="lx-login__env">
|
||||
<span className="lx-login__env-dot" aria-hidden="true" />
|
||||
内部系统 · 仅限授权成员
|
||||
</span>
|
||||
{accountDisabled ? <AccountDisabledNotice /> : null}
|
||||
<LoginFormCard
|
||||
account={account}
|
||||
password={password}
|
||||
submitting={submitting}
|
||||
onAccountChange={setAccount}
|
||||
onPasswordChange={setPassword}
|
||||
onSubmit={(event) => {
|
||||
void handleSubmit(event);
|
||||
form={form}
|
||||
successMessage={form.successMessage}
|
||||
onSubmit={() => {
|
||||
void form.submit();
|
||||
}}
|
||||
/>
|
||||
{form.mustChangePassword ? (
|
||||
<MustChangePasswordModal
|
||||
onCompleted={form.completePasswordChange}
|
||||
onCancel={form.cancelPasswordChange}
|
||||
/>
|
||||
) : null}
|
||||
<footer className="lx-login__footer">
|
||||
<span>© 2026 灵犀 · 内部系统</span>
|
||||
<span className="lx-login__footer-links">
|
||||
<span>隐私政策</span>
|
||||
<span>服务条款</span>
|
||||
</span>
|
||||
</footer>
|
||||
</section>
|
||||
<footer className="lx-login__footer">
|
||||
<span>© 2026 灵犀 · 内部系统</span>
|
||||
<span className="lx-login__footer-links">
|
||||
<span>隐私政策</span>
|
||||
<span>服务条款</span>
|
||||
<span>华东一区</span>
|
||||
</span>
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { IconCheck, IconMaterial, IconScript } from '@lingxi/ui/icons';
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
export type LoginCapabilityIcon = ComponentType<{ size?: number | string; className?: string }>;
|
||||
|
||||
/** 登录页能力说明(左栏) */
|
||||
export interface LoginCapability {
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: LoginCapabilityIcon;
|
||||
}
|
||||
|
||||
export const LOGIN_CAPABILITIES: readonly LoginCapability[] = [
|
||||
{
|
||||
key: 'script',
|
||||
title: '从要素卡到脚本',
|
||||
description: '行业包给定字段与话术边界,脚本按镜头拆分,逐镜可改。',
|
||||
icon: IconScript,
|
||||
},
|
||||
{
|
||||
key: 'material',
|
||||
title: '图片确定性与视频逐镜生成',
|
||||
description: 'PSD 套版合成稳定出品,视频按分镜逐条生成、逐条替换。',
|
||||
icon: IconMaterial,
|
||||
},
|
||||
{
|
||||
key: 'delivery',
|
||||
title: '快筛之后即归档',
|
||||
description: '多版同屏对比快筛,导出即归档,每次导出都留痕可追溯。',
|
||||
icon: IconCheck,
|
||||
},
|
||||
];
|
||||
|
||||
/** 素材墙:用 CSS 渐变模拟同一支片子的四种规格,不引图片资源 */
|
||||
export interface LoginMaterialSample {
|
||||
key: string;
|
||||
ratio: string;
|
||||
label: string;
|
||||
tone: number;
|
||||
}
|
||||
|
||||
export const LOGIN_MATERIAL_SAMPLES: readonly LoginMaterialSample[] = [
|
||||
{ key: 'vertical', ratio: '9:16', label: '竖版主图', tone: 1 },
|
||||
{ key: 'square', ratio: '1:1', label: '方形主图', tone: 2 },
|
||||
{ key: 'wide', ratio: '16:9', label: '横版封面', tone: 3 },
|
||||
{ key: 'classic', ratio: '4:3', label: '经典比例', tone: 4 },
|
||||
];
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { MeResponse } from '@lingxi/shared';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { login } from '@/api/identity';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
import { useLoginForm } from './use-login-form';
|
||||
|
||||
vi.mock('@/api/identity', () => ({
|
||||
login: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@lingxi/ui', () => ({
|
||||
notifySuccess: vi.fn(),
|
||||
notifyWarning: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedLogin = vi.mocked(login);
|
||||
|
||||
const USER_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
|
||||
const ROLE_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ2';
|
||||
const GROUP_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ3';
|
||||
|
||||
function meWith(mustChangePassword: boolean): MeResponse {
|
||||
return {
|
||||
user: {
|
||||
id: USER_ID,
|
||||
name: '管理员',
|
||||
phoneMasked: '133****0071',
|
||||
account: 'admin',
|
||||
departmentId: GROUP_ID,
|
||||
departmentName: '平台技术部',
|
||||
groupId: null,
|
||||
groupName: null,
|
||||
roles: [{ id: ROLE_ID, code: 'super_admin', name: '超级管理员', isSystem: true }],
|
||||
status: 'active',
|
||||
lastLoginAt: null,
|
||||
createdAt: '2026-09-01T00:00:00.000Z',
|
||||
},
|
||||
roles: [{ id: ROLE_ID, code: 'super_admin', name: '超级管理员', isSystem: true }],
|
||||
permissions: ['user:view', 'user:manage'],
|
||||
dataScope: { type: 'all' },
|
||||
mustChangePassword,
|
||||
};
|
||||
}
|
||||
|
||||
function createWrapper(): ({ children }: { children: ReactNode }) => ReactNode {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return function Wrapper({ children }: { children: ReactNode }): ReactNode {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{children}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
async function submitLogin(
|
||||
result: { current: ReturnType<typeof useLoginForm> },
|
||||
account: string,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
act(() => {
|
||||
result.current.setAccount(account);
|
||||
result.current.setPassword(password);
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.submit();
|
||||
});
|
||||
}
|
||||
|
||||
describe('登录流程 · 首登强改', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
useAuthStore.getState().clearSession();
|
||||
mockedLogin.mockReset();
|
||||
});
|
||||
|
||||
it('mustChangePassword=true 时写入会话但停留在登录页等待改密', async () => {
|
||||
mockedLogin.mockResolvedValue({
|
||||
accessToken: 'access-1',
|
||||
refreshToken: 'refresh-1',
|
||||
expiresIn: 3600,
|
||||
me: meWith(true),
|
||||
});
|
||||
const { result } = renderHook(() => useLoginForm(), { wrapper: createWrapper() });
|
||||
await submitLogin(result, 'admin', 'lingxi2026');
|
||||
expect(result.current.mustChangePassword).toBe(true);
|
||||
expect(useAuthStore.getState().accessToken).toBe('access-1');
|
||||
expect(useAuthStore.getState().mustChangePassword).toBe(true);
|
||||
});
|
||||
|
||||
it('改密成功:清会话并提示用新密码重新登录', async () => {
|
||||
mockedLogin.mockResolvedValue({
|
||||
accessToken: 'access-1',
|
||||
refreshToken: 'refresh-1',
|
||||
expiresIn: 3600,
|
||||
me: meWith(true),
|
||||
});
|
||||
const { result } = renderHook(() => useLoginForm(), { wrapper: createWrapper() });
|
||||
await submitLogin(result, 'admin', 'lingxi2026');
|
||||
act(() => {
|
||||
result.current.completePasswordChange();
|
||||
});
|
||||
expect(result.current.mustChangePassword).toBe(false);
|
||||
expect(result.current.successMessage).toContain('请使用新密码重新登录');
|
||||
expect(useAuthStore.getState().accessToken).toBeNull();
|
||||
});
|
||||
|
||||
it('取消改密:清会话回到登录表单', async () => {
|
||||
mockedLogin.mockResolvedValue({
|
||||
accessToken: 'access-1',
|
||||
refreshToken: 'refresh-1',
|
||||
expiresIn: 3600,
|
||||
me: meWith(true),
|
||||
});
|
||||
const { result } = renderHook(() => useLoginForm(), { wrapper: createWrapper() });
|
||||
await submitLogin(result, 'admin', 'lingxi2026');
|
||||
act(() => {
|
||||
result.current.cancelPasswordChange();
|
||||
});
|
||||
expect(result.current.mustChangePassword).toBe(false);
|
||||
expect(result.current.successMessage).toBeNull();
|
||||
expect(useAuthStore.getState().accessToken).toBeNull();
|
||||
});
|
||||
|
||||
it('无需改密时直接写会话并跳转(不回退到改密态)', async () => {
|
||||
mockedLogin.mockResolvedValue({
|
||||
accessToken: 'access-2',
|
||||
refreshToken: 'refresh-2',
|
||||
expiresIn: 3600,
|
||||
me: meWith(false),
|
||||
});
|
||||
const { result } = renderHook(() => useLoginForm(), { wrapper: createWrapper() });
|
||||
await submitLogin(result, 'admin', 'Lingxi2026a');
|
||||
expect(result.current.mustChangePassword).toBe(false);
|
||||
expect(useAuthStore.getState().accessToken).toBe('access-2');
|
||||
});
|
||||
});
|
||||
@@ -1,70 +1,153 @@
|
||||
import { notifyError, notifyWarning } from '@lingxi/ui';
|
||||
import { type SyntheticEvent, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
/* eslint-disable max-lines-per-function, complexity -- 登录状态机集中在一个 Hook 便于追踪 */
|
||||
import { loginInputSchema } from '@lingxi/shared';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
|
||||
import { ApiError, request } from '@/api/client';
|
||||
import { ROUTES } from '@/routes';
|
||||
import type { AuthUser } from '@/stores/auth-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { ApiError } from '@/api/client';
|
||||
import { login } from '@/api/identity';
|
||||
import { queryKeys } from '@/api/query-keys';
|
||||
import { ROUTES, sanitizeRedirect } from '@/routes';
|
||||
import { authUserFromMe, useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
interface LoginResponse {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
user: AuthUser;
|
||||
permissions?: string[];
|
||||
}
|
||||
const demoEnabled = import.meta.env.VITE_PUBLIC_DEMO_SESSION === 'true';
|
||||
|
||||
const demoEnabled = import.meta.env.VITE_PUBLIC_DEMO_SESSION !== 'false';
|
||||
const LOCKED_FALLBACK = '账号已临时锁定,请 15 分钟后重试,或联系平台管理员解锁。';
|
||||
|
||||
export function useLoginForm(): {
|
||||
const PASSWORD_CHANGED_NOTICE = '初始密码已更新,请使用新密码重新登录';
|
||||
|
||||
export interface LoginFormState {
|
||||
account: string;
|
||||
password: string;
|
||||
submitting: boolean;
|
||||
accountError: string | null;
|
||||
passwordError: string | null;
|
||||
formError: string | null;
|
||||
lockedMessage: string | null;
|
||||
/** 登录返回 me.mustChangePassword=true:停留在登录页完成首登改密 */
|
||||
mustChangePassword: boolean;
|
||||
/** 改密成功后的成功提示位 */
|
||||
successMessage: string | null;
|
||||
setAccount: (value: string) => void;
|
||||
setPassword: (value: string) => void;
|
||||
handleSubmit: (event: SyntheticEvent<HTMLFormElement>) => Promise<void>;
|
||||
} {
|
||||
submit: () => Promise<void>;
|
||||
/** 改密成功:清会话与缓存,留在登录页提示用新密码登录 */
|
||||
completePasswordChange: () => void;
|
||||
/** 取消改密:清会话回登录表单 */
|
||||
cancelPasswordChange: () => void;
|
||||
}
|
||||
|
||||
function isLockedError(error: ApiError): boolean {
|
||||
return error.status === 429 || error.code === 42902;
|
||||
}
|
||||
|
||||
/** 登录表单状态机:校验 → 提交 → 写会话/查询缓存 → (首登)改密 → 回跳 redirect */
|
||||
export function useLoginForm(): LoginFormState {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams] = useSearchParams();
|
||||
const signInDemo = useAuthStore((state) => state.signInDemo);
|
||||
const setSession = useAuthStore((state) => state.setSession);
|
||||
const [account, setAccount] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const clearSession = useAuthStore((state) => state.clearSession);
|
||||
const [account, setAccountValue] = useState('');
|
||||
const [password, setPasswordValue] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [accountError, setAccountError] = useState<string | null>(null);
|
||||
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [lockedMessage, setLockedMessage] = useState<string | null>(null);
|
||||
const [mustChangePassword, setMustChangePassword] = useState(false);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (event: SyntheticEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault();
|
||||
if (account.trim() === '' || password === '') {
|
||||
notifyWarning('请输入账号和密码');
|
||||
const redirectTarget = (): string =>
|
||||
sanitizeRedirect(searchParams.get('redirect')) ?? ROUTES.users;
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
if (submitting || lockedMessage !== null || mustChangePassword) {
|
||||
return;
|
||||
}
|
||||
setFormError(null);
|
||||
setSuccessMessage(null);
|
||||
const parsed = loginInputSchema.safeParse({ account: account.trim(), password });
|
||||
if (!parsed.success) {
|
||||
const issues = parsed.error.issues;
|
||||
setAccountError(issues.find((issue) => issue.path[0] === 'account')?.message ?? null);
|
||||
setPasswordError(issues.find((issue) => issue.path[0] === 'password')?.message ?? null);
|
||||
return;
|
||||
}
|
||||
setAccountError(null);
|
||||
setPasswordError(null);
|
||||
if (demoEnabled) {
|
||||
signInDemo();
|
||||
queryClient.removeQueries({ queryKey: queryKeys.me });
|
||||
void navigate(redirectTarget(), { replace: true });
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (demoEnabled) {
|
||||
signInDemo();
|
||||
await navigate(ROUTES.home, { replace: true });
|
||||
const response = await login(parsed.data);
|
||||
setSession({
|
||||
accessToken: response.accessToken,
|
||||
refreshToken: response.refreshToken,
|
||||
user: authUserFromMe(response.me),
|
||||
permissions: response.me.permissions,
|
||||
mustChangePassword: response.me.mustChangePassword,
|
||||
});
|
||||
// 用登录响应写 /me 缓存,进入 AppLayout 后不再重复请求
|
||||
queryClient.setQueryData(queryKeys.me, response.me);
|
||||
if (response.me.mustChangePassword) {
|
||||
setMustChangePassword(true);
|
||||
return;
|
||||
}
|
||||
const session = await request<LoginResponse>('/auth/login', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { email: account.trim(), password },
|
||||
});
|
||||
setSession({
|
||||
accessToken: session.accessToken,
|
||||
refreshToken: session.refreshToken ?? null,
|
||||
user: session.user,
|
||||
permissions: session.permissions,
|
||||
});
|
||||
await navigate(ROUTES.home, { replace: true });
|
||||
void navigate(redirectTarget(), { replace: true });
|
||||
} catch (error) {
|
||||
notifyError(
|
||||
error instanceof Error ? error.message : '登录失败,请稍后重试',
|
||||
error instanceof ApiError ? error.traceId : undefined,
|
||||
);
|
||||
if (error instanceof ApiError && isLockedError(error)) {
|
||||
setLockedMessage(error.message === '' ? LOCKED_FALLBACK : error.message);
|
||||
} else {
|
||||
setFormError(error instanceof Error ? error.message : '登录失败,请稍后重试');
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { account, password, submitting, setAccount, setPassword, handleSubmit };
|
||||
const resetAfterPasswordChange = (notice: string | null): void => {
|
||||
clearSession();
|
||||
queryClient.clear();
|
||||
setMustChangePassword(false);
|
||||
setPasswordValue('');
|
||||
setPasswordError(null);
|
||||
setFormError(null);
|
||||
setSuccessMessage(notice);
|
||||
};
|
||||
|
||||
return {
|
||||
account,
|
||||
password,
|
||||
submitting,
|
||||
accountError,
|
||||
passwordError,
|
||||
formError,
|
||||
lockedMessage,
|
||||
mustChangePassword,
|
||||
successMessage,
|
||||
setAccount: (value) => {
|
||||
setAccountValue(value);
|
||||
setAccountError(null);
|
||||
setFormError(null);
|
||||
setSuccessMessage(null);
|
||||
},
|
||||
setPassword: (value) => {
|
||||
setPasswordValue(value);
|
||||
setPasswordError(null);
|
||||
setFormError(null);
|
||||
},
|
||||
submit,
|
||||
completePasswordChange: () => {
|
||||
resetAfterPasswordChange(PASSWORD_CHANGED_NOTICE);
|
||||
},
|
||||
cancelPasswordChange: () => {
|
||||
resetAfterPasswordChange(null);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { RoleSummary } from '@lingxi/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { isSuperAdminRole } from './role-utils';
|
||||
|
||||
export interface RoleChipsSelectProps {
|
||||
roles: readonly RoleSummary[];
|
||||
value: readonly string[];
|
||||
disabled?: boolean;
|
||||
onChange: (roleIds: string[]) => void;
|
||||
}
|
||||
|
||||
export function RoleChipsSelect({
|
||||
roles,
|
||||
value,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: RoleChipsSelectProps): ReactNode {
|
||||
const selected = new Set(value);
|
||||
return (
|
||||
<div className="lx-role-picker" role="group" aria-label="角色">
|
||||
{roles.map((role) => {
|
||||
const active = selected.has(role.id);
|
||||
return (
|
||||
<button
|
||||
key={role.id}
|
||||
type="button"
|
||||
className={active ? 'lx-role-picker__item is-active' : 'lx-role-picker__item'}
|
||||
aria-pressed={active}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
const next = new Set(selected);
|
||||
if (next.has(role.id)) {
|
||||
next.delete(role.id);
|
||||
} else {
|
||||
next.add(role.id);
|
||||
}
|
||||
onChange([...next]);
|
||||
}}
|
||||
>
|
||||
{role.name}
|
||||
{isSuperAdminRole(role) ? (
|
||||
<span className="lx-role-picker__badge">内部预置,不可停用</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
/* eslint-disable max-lines-per-function -- 详情面板按头部/标签页分区组织,拆分降低可读性 */
|
||||
import { Button, Tabs } from '@douyinfe/semi-ui-19';
|
||||
import type { UserDetail } from '@lingxi/shared';
|
||||
import { Permission } from '@lingxi/shared';
|
||||
import { ErrorState, PageEmpty, PageSkeleton } from '@lingxi/ui';
|
||||
import { IconEdit, IconLock, IconRefresh } from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Can } from '@/components/Can';
|
||||
|
||||
import { displayPhone, formatDateTime, userInitial } from './user-display';
|
||||
import {
|
||||
UserActivityTab,
|
||||
UserCustomersTab,
|
||||
UserProfileTab,
|
||||
UserRolesTab,
|
||||
UserSecurityTab,
|
||||
} from './UserDetailTabs';
|
||||
import { UserStatusTag } from './UserStatusTag';
|
||||
|
||||
const TABS = [
|
||||
{ key: 'profile', label: '基本资料' },
|
||||
{ key: 'roles', label: '角色权限' },
|
||||
{ key: 'customers', label: '负责客户' },
|
||||
{ key: 'security', label: '登录与安全' },
|
||||
{ key: 'activity', label: '操作记录' },
|
||||
] as const;
|
||||
|
||||
export interface UserDetailPanelProps {
|
||||
userId: string | null;
|
||||
detail: UserDetail | undefined;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
canManage: boolean;
|
||||
onRetry: () => void;
|
||||
onEdit: (user: UserDetail) => void;
|
||||
onDisable: (user: UserDetail) => void;
|
||||
onEnable: (user: UserDetail) => void;
|
||||
onAdjustRoles: (user: UserDetail) => void;
|
||||
onTransfer: (user: UserDetail) => void;
|
||||
}
|
||||
|
||||
function DetailHeader({
|
||||
user,
|
||||
canManage,
|
||||
onEdit,
|
||||
onDisable,
|
||||
onEnable,
|
||||
}: Pick<UserDetailPanelProps, 'canManage' | 'onEdit' | 'onDisable' | 'onEnable'> & {
|
||||
user: UserDetail;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<header className="lx-user-detail__head">
|
||||
<span
|
||||
className={
|
||||
user.status === 'disabled'
|
||||
? 'lx-user-detail__avatar is-disabled'
|
||||
: 'lx-user-detail__avatar'
|
||||
}
|
||||
>
|
||||
{userInitial(user.name)}
|
||||
</span>
|
||||
<div className="lx-user-detail__ident">
|
||||
<div className="lx-user-detail__name">
|
||||
<h2>{user.name}</h2>
|
||||
<UserStatusTag status={user.status} />
|
||||
</div>
|
||||
<p className="lx-user-detail__meta">
|
||||
{user.groupName === null
|
||||
? user.departmentName
|
||||
: `${user.departmentName} / ${user.groupName}`}
|
||||
<span className="lx-user-detail__dot">·</span>
|
||||
{displayPhone(user, canManage)}
|
||||
<span className="lx-user-detail__dot">·</span>
|
||||
最近登录 {formatDateTime(user.lastLoginAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="lx-user-detail__actions">
|
||||
<Can permission={Permission.USER_MANAGE}>
|
||||
<Button
|
||||
icon={<IconEdit size={14} />}
|
||||
onClick={() => {
|
||||
onEdit(user);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
{user.status === 'active' ? (
|
||||
<Button
|
||||
type="danger"
|
||||
icon={<IconLock size={14} />}
|
||||
onClick={() => {
|
||||
onDisable(user);
|
||||
}}
|
||||
>
|
||||
停用账号
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
icon={<IconRefresh size={14} />}
|
||||
onClick={() => {
|
||||
onEnable(user);
|
||||
}}
|
||||
>
|
||||
启用账号
|
||||
</Button>
|
||||
)}
|
||||
</Can>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function TabContent({
|
||||
tabKey,
|
||||
user,
|
||||
canManage,
|
||||
onAdjustRoles,
|
||||
onTransfer,
|
||||
}: {
|
||||
tabKey: string;
|
||||
user: UserDetail;
|
||||
canManage: boolean;
|
||||
onAdjustRoles: () => void;
|
||||
onTransfer: () => void;
|
||||
}): ReactNode {
|
||||
if (tabKey === 'roles') {
|
||||
return <UserRolesTab user={user} canManage={canManage} onAdjustRoles={onAdjustRoles} />;
|
||||
}
|
||||
if (tabKey === 'customers') {
|
||||
return <UserCustomersTab user={user} onTransfer={onTransfer} />;
|
||||
}
|
||||
if (tabKey === 'security') {
|
||||
return <UserSecurityTab user={user} canManage={canManage} />;
|
||||
}
|
||||
if (tabKey === 'activity') {
|
||||
return <UserActivityTab user={user} canManage={canManage} />;
|
||||
}
|
||||
return <UserProfileTab user={user} canManage={canManage} />;
|
||||
}
|
||||
|
||||
function DetailBody({
|
||||
user,
|
||||
canManage,
|
||||
onAdjustRoles,
|
||||
onTransfer,
|
||||
}: Pick<UserDetailPanelProps, 'canManage' | 'onAdjustRoles' | 'onTransfer'> & {
|
||||
user: UserDetail;
|
||||
}): ReactNode {
|
||||
const [activeKey, setActiveKey] = useState<string>('profile');
|
||||
return (
|
||||
<Tabs
|
||||
className="lx-user-detail__tabs"
|
||||
type="line"
|
||||
activeKey={activeKey}
|
||||
onChange={(key) => {
|
||||
setActiveKey(key);
|
||||
}}
|
||||
>
|
||||
{TABS.map((tab) => (
|
||||
<Tabs.TabPane key={tab.key} itemKey={tab.key} tab={tab.label}>
|
||||
{tab.key !== activeKey ? null : (
|
||||
<TabContent
|
||||
tabKey={tab.key}
|
||||
user={user}
|
||||
canManage={canManage}
|
||||
onAdjustRoles={() => {
|
||||
onAdjustRoles(user);
|
||||
}}
|
||||
onTransfer={() => {
|
||||
onTransfer(user);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Tabs.TabPane>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailState({
|
||||
userId,
|
||||
loading,
|
||||
error,
|
||||
onRetry,
|
||||
}: Pick<UserDetailPanelProps, 'userId' | 'loading' | 'error' | 'onRetry'>): ReactNode {
|
||||
if (userId === null) {
|
||||
return (
|
||||
<PageEmpty
|
||||
title="选择左侧成员查看详情"
|
||||
description="支持按姓名、手机号与登录账号搜索成员。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="lx-user-detail__state">
|
||||
<PageSkeleton rows={8} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error !== null) {
|
||||
return (
|
||||
<ErrorState
|
||||
className="lx-user-detail__state"
|
||||
title="成员资料加载失败"
|
||||
message={error.message}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 右侧详情面板:头部操作 + 五个 Tab;数据由 /users/{id} 提供 */
|
||||
export function UserDetailPanel(props: UserDetailPanelProps): ReactNode {
|
||||
const ready = props.userId !== null && props.detail !== undefined && props.error === null;
|
||||
return (
|
||||
<section className="lx-user-detail" aria-label="成员详情">
|
||||
{ready && props.detail !== undefined ? (
|
||||
<>
|
||||
<DetailHeader
|
||||
user={props.detail}
|
||||
canManage={props.canManage}
|
||||
onEdit={props.onEdit}
|
||||
onDisable={props.onDisable}
|
||||
onEnable={props.onEnable}
|
||||
/>
|
||||
<DetailBody
|
||||
user={props.detail}
|
||||
canManage={props.canManage}
|
||||
onAdjustRoles={props.onAdjustRoles}
|
||||
onTransfer={props.onTransfer}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="lx-user-detail__empty">
|
||||
<DetailState
|
||||
userId={props.userId}
|
||||
loading={props.loading}
|
||||
error={props.error}
|
||||
onRetry={props.onRetry}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { Button } from '@douyinfe/semi-ui-19';
|
||||
import { Permission } from '@lingxi/shared';
|
||||
import { type UserDetail } from '@lingxi/shared';
|
||||
import { PageEmpty } from '@lingxi/ui';
|
||||
import { IconEdit, IconShare, IconUser } from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { Can } from '@/components/Can';
|
||||
import { useUserCustomersQuery } from '@/hooks/queries/use-users';
|
||||
|
||||
import { displayPhone, formatDateTime } from './user-display';
|
||||
import { UserStatusTag } from './UserStatusTag';
|
||||
|
||||
interface TabProps {
|
||||
user: UserDetail;
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
function Kvi({ label, value }: { label: string; value: ReactNode }): ReactNode {
|
||||
return (
|
||||
<div className="lx-kvi">
|
||||
<span className="lx-kvi__label">{label}</span>
|
||||
<span className="lx-kvi__value">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: ReactNode }): ReactNode {
|
||||
return (
|
||||
<section className="lx-detail-section">
|
||||
<h4 className="lx-detail-section__title">{title}</h4>
|
||||
<div className="lx-kv-grid">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHead({
|
||||
title,
|
||||
hint,
|
||||
action,
|
||||
}: {
|
||||
title: string;
|
||||
hint: string;
|
||||
action?: ReactNode;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<div className="lx-detail-tab__head">
|
||||
<div>
|
||||
<h4 className="lx-detail-section__title">{title}</h4>
|
||||
<p className="lx-detail-tab__hint">{hint}</p>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserProfileTab({ user, canManage }: TabProps): ReactNode {
|
||||
const department =
|
||||
user.groupName === null ? user.departmentName : `${user.departmentName} / ${user.groupName}`;
|
||||
return (
|
||||
<div className="lx-detail-tab">
|
||||
<Section title="身份">
|
||||
<Kvi label="姓名" value={user.name} />
|
||||
<Kvi label="登录账号" value={user.account} />
|
||||
<Kvi label="手机号" value={displayPhone(user, canManage)} />
|
||||
<Kvi label="所属部门" value={department} />
|
||||
</Section>
|
||||
<Section title="权限">
|
||||
<Kvi
|
||||
label="角色"
|
||||
value={user.roles.length === 0 ? '—' : user.roles.map((role) => role.name).join('、')}
|
||||
/>
|
||||
<Kvi label="账号状态" value={<UserStatusTag status={user.status} />} />
|
||||
<Kvi label="最近登录" value={formatDateTime(user.lastLoginAt)} />
|
||||
<Kvi label="登录地址" value={user.lastLoginIp ?? '—'} />
|
||||
</Section>
|
||||
<Section title="状态">
|
||||
<Kvi label="创建时间" value={formatDateTime(user.createdAt)} />
|
||||
<Kvi label="最近更新" value={formatDateTime(user.updatedAt)} />
|
||||
<Kvi label="停用时间" value={formatDateTime(user.disabledAt)} />
|
||||
<Kvi label="密码状态" value={user.mustChangePassword ? '初始密码待修改' : '已修改'} />
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserRolesTab({
|
||||
user,
|
||||
canManage,
|
||||
onAdjustRoles,
|
||||
}: TabProps & { onAdjustRoles: () => void }): ReactNode {
|
||||
return (
|
||||
<div className="lx-detail-tab">
|
||||
<SectionHead
|
||||
title="已绑定角色"
|
||||
hint="角色决定成员可访问的模块与操作;权限点由「角色权限」统一维护。"
|
||||
action={
|
||||
canManage ? (
|
||||
<Button
|
||||
icon={<IconEdit size={14} />}
|
||||
onClick={onAdjustRoles}
|
||||
data-testid="adjust-roles"
|
||||
>
|
||||
调整角色
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<div className="lx-role-chips">
|
||||
{user.roles.map((role) => (
|
||||
<span key={role.id} className="lx-role-chip">
|
||||
{role.name}
|
||||
{role.isSystem ? <span className="lx-role-chip__badge">内置</span> : null}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="lx-detail-tab__hint">
|
||||
内置角色由系统预置,不可停用;角色变更即时生效并写入审计。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserCustomersTab({
|
||||
user,
|
||||
onTransfer,
|
||||
}: {
|
||||
user: UserDetail;
|
||||
onTransfer: () => void;
|
||||
}): ReactNode {
|
||||
const query = useUserCustomersQuery(user.id);
|
||||
const customers = query.data ?? [];
|
||||
return (
|
||||
<div className="lx-detail-tab">
|
||||
<SectionHead
|
||||
title="负责客户"
|
||||
hint="客户资料与交付记录仅对负责人及平台管理员可见。"
|
||||
action={
|
||||
<Can permission={Permission.USER_MANAGE}>
|
||||
<Button
|
||||
icon={<IconShare size={14} />}
|
||||
disabled={customers.length === 0}
|
||||
onClick={onTransfer}
|
||||
>
|
||||
转交客户
|
||||
</Button>
|
||||
</Can>
|
||||
}
|
||||
/>
|
||||
{query.isLoading ? <p className="lx-detail-tab__hint">加载中…</p> : null}
|
||||
{!query.isLoading && customers.length === 0 ? (
|
||||
<PageEmpty title="暂未负责客户" description="该成员当前不是任何客户的服务负责人。" />
|
||||
) : (
|
||||
<ul className="lx-customer-list">
|
||||
{customers.map((customer) => (
|
||||
<li key={customer.id} className="lx-customer-item">
|
||||
<span className="lx-customer-item__icon">
|
||||
<IconUser size={16} />
|
||||
</span>
|
||||
<span className="lx-customer-item__name">{customer.name}</span>
|
||||
<span className="lx-role-tag">合作中</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserSecurityTab({ user }: TabProps): ReactNode {
|
||||
return (
|
||||
<div className="lx-detail-tab">
|
||||
<Section title="登录与安全">
|
||||
<Kvi label="最近登录" value={formatDateTime(user.lastLoginAt)} />
|
||||
<Kvi label="登录地址" value={user.lastLoginIp ?? '—'} />
|
||||
<Kvi label="账号状态" value={<UserStatusTag status={user.status} />} />
|
||||
<Kvi label="密码状态" value={user.mustChangePassword ? '初始密码待修改' : '已修改'} />
|
||||
<Kvi label="停用时间" value={formatDateTime(user.disabledAt)} />
|
||||
</Section>
|
||||
<p className="lx-detail-tab__hint">
|
||||
登录失败次数与临时锁定由平台统一管理;密码到期或重置后需在下次登录时修改。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ActivityEntry {
|
||||
key: string;
|
||||
title: string;
|
||||
at: string | null | undefined;
|
||||
extra?: string;
|
||||
}
|
||||
|
||||
function buildActivity(user: UserDetail): ActivityEntry[] {
|
||||
const entries: ActivityEntry[] = [
|
||||
{ key: 'created', title: '创建用户', at: user.createdAt },
|
||||
{ key: 'updated', title: '最近更新资料', at: user.updatedAt },
|
||||
{
|
||||
key: 'login',
|
||||
title: '最近登录成功',
|
||||
at: user.lastLoginAt,
|
||||
extra: user.lastLoginIp ?? undefined,
|
||||
},
|
||||
];
|
||||
if (user.disabledAt !== null && user.disabledAt !== undefined) {
|
||||
entries.push({ key: 'disabled', title: '停用账号', at: user.disabledAt });
|
||||
}
|
||||
return entries
|
||||
.filter((entry) => entry.at !== null && entry.at !== undefined)
|
||||
.sort((left, right) => (right.at ?? '').localeCompare(left.at ?? ''));
|
||||
}
|
||||
|
||||
export function UserActivityTab({ user }: TabProps): ReactNode {
|
||||
const entries = buildActivity(user);
|
||||
return (
|
||||
<div className="lx-detail-tab">
|
||||
<h4 className="lx-detail-section__title">操作记录</h4>
|
||||
<p className="lx-detail-tab__hint">完整审计日志将在审计模块上线后提供检索与导出。</p>
|
||||
<ol className="lx-timeline">
|
||||
{entries.map((entry, index) => (
|
||||
<li key={entry.key} className="lx-timeline__item">
|
||||
<span className={index === 0 ? 'lx-timeline__dot is-latest' : 'lx-timeline__dot'} />
|
||||
<div className="lx-timeline__main">
|
||||
<span className="lx-timeline__title">{entry.title}</span>
|
||||
<span className="lx-timeline__meta">
|
||||
{formatDateTime(entry.at)}
|
||||
{entry.extra === undefined ? '' : ` · ${entry.extra}`}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
/* eslint-disable max-lines-per-function -- 列表卡片按筛选/行/状态分区组织,拆分降低可读性 */
|
||||
import { Button, Input, Select, Skeleton } from '@douyinfe/semi-ui-19';
|
||||
import type { DepartmentNode, RoleSummary, UserListStatus, UserSummary } from '@lingxi/shared';
|
||||
import { ErrorState, PageEmpty } from '@lingxi/ui';
|
||||
import { IconBack, IconChevronRight, IconRefresh, IconSearch } from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { userInitial, userOrgLabel } from './user-display';
|
||||
import {
|
||||
USER_SORT_OPTIONS,
|
||||
USER_STATUS_OPTIONS,
|
||||
type UserListFilters,
|
||||
type UserSortValue,
|
||||
} from './user-filters';
|
||||
import { UserStatusTag } from './UserStatusTag';
|
||||
|
||||
export interface UserFilterBarProps {
|
||||
filters: UserListFilters;
|
||||
departments: readonly DepartmentNode[];
|
||||
roles: readonly RoleSummary[];
|
||||
onFiltersChange: (next: Partial<UserListFilters>) => void;
|
||||
onResetFilters: () => void;
|
||||
}
|
||||
|
||||
export interface UserListPanelProps {
|
||||
filters: UserListFilters;
|
||||
users: readonly UserSummary[];
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
selectedUserId: string | null;
|
||||
hasNext: boolean;
|
||||
hasPrev: boolean;
|
||||
onSortChange: (sort: UserSortValue) => void;
|
||||
onSelect: (userId: string) => void;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
onRetry: () => void;
|
||||
onCreate: () => void;
|
||||
onResetFilters: () => void;
|
||||
}
|
||||
|
||||
function firstString(value: unknown): string {
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
function StatusSegmented({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: UserListStatus;
|
||||
onChange: (value: UserListStatus) => void;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<div className="lx-seg" role="group" aria-label="账号状态">
|
||||
{USER_STATUS_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={option.value === value ? 'lx-seg__item is-active' : 'lx-seg__item'}
|
||||
aria-pressed={option.value === value}
|
||||
onClick={() => {
|
||||
onChange(option.value);
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 顶部筛选条:关键词 / 部门 / 角色 / 状态;变更即重置游标并重新查询 */
|
||||
export function UserFilterBar({
|
||||
filters,
|
||||
departments,
|
||||
roles,
|
||||
onFiltersChange,
|
||||
onResetFilters,
|
||||
}: UserFilterBarProps): ReactNode {
|
||||
return (
|
||||
<section className="lx-user-filters" aria-label="筛选成员">
|
||||
<Input
|
||||
className="lx-user-filters__keyword"
|
||||
value={filters.keyword}
|
||||
prefix={<IconSearch size={16} />}
|
||||
placeholder="搜索姓名、手机号或登录账号"
|
||||
aria-label="搜索成员"
|
||||
showClear
|
||||
onChange={(value) => {
|
||||
onFiltersChange({ keyword: value });
|
||||
}}
|
||||
/>
|
||||
<SelectField
|
||||
className="lx-user-filters__select"
|
||||
value={filters.departmentId}
|
||||
placeholder="全部部门"
|
||||
options={departments.map((node) => ({
|
||||
label: `${node.name}(${String(node.memberCount)} 人)`,
|
||||
value: node.id,
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
onFiltersChange({ departmentId: value });
|
||||
}}
|
||||
/>
|
||||
<SelectField
|
||||
className="lx-user-filters__select"
|
||||
value={filters.roleId}
|
||||
placeholder="全部角色"
|
||||
options={roles.map((role) => ({
|
||||
label: role.isSystem ? `${role.name}(内置)` : role.name,
|
||||
value: role.id,
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
onFiltersChange({ roleId: value });
|
||||
}}
|
||||
/>
|
||||
<StatusSegmented
|
||||
value={filters.status}
|
||||
onChange={(status) => {
|
||||
onFiltersChange({ status });
|
||||
}}
|
||||
/>
|
||||
<Button icon={<IconRefresh size={14} />} onClick={onResetFilters}>
|
||||
重置
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface Option {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** 受控 Select:值统一降为 string,空字符串表示未选择 */
|
||||
function SelectField({
|
||||
className,
|
||||
value,
|
||||
placeholder,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
className: string;
|
||||
value: string;
|
||||
placeholder: string;
|
||||
options: Option[];
|
||||
onChange: (value: string) => void;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<div className={className}>
|
||||
<Select
|
||||
className="lx-user-filters__control"
|
||||
value={value === '' ? undefined : value}
|
||||
placeholder={placeholder}
|
||||
optionList={options}
|
||||
showClear
|
||||
onChange={(selected) => {
|
||||
onChange(firstString(selected));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListRow({
|
||||
user,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
user: UserSummary;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className={selected ? 'lx-user-row is-selected' : 'lx-user-row'}
|
||||
aria-current={selected ? 'true' : undefined}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
user.status === 'disabled' ? 'lx-user-row__avatar is-disabled' : 'lx-user-row__avatar'
|
||||
}
|
||||
>
|
||||
{userInitial(user.name)}
|
||||
</span>
|
||||
<span className="lx-user-row__main">
|
||||
<span className="lx-user-row__name">
|
||||
{user.name}
|
||||
<UserStatusTag status={user.status} dotOnly />
|
||||
</span>
|
||||
<span className="lx-user-row__phone">{user.phoneMasked}</span>
|
||||
</span>
|
||||
<span className="lx-user-row__side">
|
||||
<span className="lx-user-row__org">{userOrgLabel(user)}</span>
|
||||
<span className="lx-user-row__roles">
|
||||
{user.roles.slice(0, 1).map((role) => (
|
||||
<span key={role.id} className="lx-role-tag">
|
||||
{role.name}
|
||||
</span>
|
||||
))}
|
||||
{user.roles.length > 1 ? (
|
||||
<span className="lx-role-tag lx-role-tag--more">+{user.roles.length - 1}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function isEmptyFilter(filters: UserListFilters): boolean {
|
||||
return (
|
||||
filters.keyword.trim() !== '' ||
|
||||
filters.departmentId !== '' ||
|
||||
filters.roleId !== '' ||
|
||||
filters.status !== 'active'
|
||||
);
|
||||
}
|
||||
|
||||
function ListState({
|
||||
props,
|
||||
}: {
|
||||
props: Pick<
|
||||
UserListPanelProps,
|
||||
| 'users'
|
||||
| 'loading'
|
||||
| 'error'
|
||||
| 'selectedUserId'
|
||||
| 'onSelect'
|
||||
| 'onRetry'
|
||||
| 'onCreate'
|
||||
| 'onResetFilters'
|
||||
| 'filters'
|
||||
>;
|
||||
}): ReactNode {
|
||||
if (props.loading) {
|
||||
return (
|
||||
<div className="lx-user-list__state">
|
||||
<Skeleton placeholder={<Skeleton.Paragraph rows={6} />} loading />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (props.error !== null) {
|
||||
return (
|
||||
<ErrorState
|
||||
className="lx-user-list__state"
|
||||
title="成员加载失败"
|
||||
message={props.error.message}
|
||||
onRetry={props.onRetry}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (props.users.length === 0) {
|
||||
return isEmptyFilter(props.filters) ? (
|
||||
<PageEmpty
|
||||
title="没有符合条件的成员"
|
||||
description="调整关键词或筛选条件后再试。"
|
||||
action={<Button onClick={props.onResetFilters}>重置筛选</Button>}
|
||||
/>
|
||||
) : (
|
||||
<PageEmpty
|
||||
title="团队还没有用户"
|
||||
description="创建第一位成员,为 TA 分配角色与负责部门。"
|
||||
action={
|
||||
<Button theme="solid" type="primary" onClick={props.onCreate}>
|
||||
新建用户
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ul className="lx-user-list__items">
|
||||
{props.users.map((user) => (
|
||||
<ListRow
|
||||
key={user.id}
|
||||
user={user}
|
||||
selected={user.id === props.selectedUserId}
|
||||
onSelect={() => {
|
||||
props.onSelect(user.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function ListFooter({
|
||||
count,
|
||||
hasPrev,
|
||||
hasNext,
|
||||
onPrev,
|
||||
onNext,
|
||||
}: {
|
||||
count: number;
|
||||
hasPrev: boolean;
|
||||
hasNext: boolean;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<footer className="lx-user-list__footer">
|
||||
<span className="lx-user-list__range">本页 {count} 人</span>
|
||||
<div className="lx-user-list__pager">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<IconBack size={14} />}
|
||||
disabled={!hasPrev}
|
||||
aria-label="上一页"
|
||||
onClick={onPrev}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<IconChevronRight size={14} />}
|
||||
disabled={!hasNext}
|
||||
aria-label="下一页"
|
||||
onClick={onNext}
|
||||
/>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
/** 左侧成员列表卡:卡头排序 + 列表 + 游标分页页脚 */
|
||||
export function UserListPanel(props: UserListPanelProps): ReactNode {
|
||||
return (
|
||||
<section className="lx-user-list" aria-label="成员列表">
|
||||
<header className="lx-user-list__head">
|
||||
<span className="lx-user-list__title">成员</span>
|
||||
<div className="lx-user-list__sort">
|
||||
<Select
|
||||
size="small"
|
||||
value={props.filters.sort}
|
||||
optionList={USER_SORT_OPTIONS.map((option) => ({ ...option }))}
|
||||
onChange={(value) => {
|
||||
const sort = firstString(value);
|
||||
if (sort !== '') {
|
||||
props.onSortChange(sort as UserSortValue);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<div className="lx-user-list__body">
|
||||
<ListState props={props} />
|
||||
</div>
|
||||
<ListFooter
|
||||
count={props.users.length}
|
||||
hasPrev={props.hasPrev}
|
||||
hasNext={props.hasNext}
|
||||
onPrev={props.onPrev}
|
||||
onNext={props.onNext}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
/* eslint-disable max-lines-per-function -- 表单页按三段顺序展开,与设计稿一一对应 */
|
||||
import { Button, Form, Input, Select } from '@douyinfe/semi-ui-19';
|
||||
import { type CreateUserInput, type DepartmentNode, type RoleSummary } from '@lingxi/shared';
|
||||
import { notifySuccess } from '@lingxi/ui';
|
||||
import { IconAdd, IconBack, IconRefresh } from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { useDepartmentsQuery, useRolesQuery } from '@/hooks/queries/use-directory';
|
||||
import { useCreateUserMutation } from '@/hooks/queries/use-users';
|
||||
import { ROUTES, userDetailPath } from '@/routes';
|
||||
|
||||
import { SuperAdminGrantDialog } from './dialogs/SuperAdminGrantDialog';
|
||||
import { isSuperAdminRole } from './role-utils';
|
||||
import { RoleChipsSelect } from './RoleChipsSelect';
|
||||
import { type NewUserFormState, useNewUserForm } from './use-user-new-form';
|
||||
import { childDepartments, topLevelDepartments } from './user-filters';
|
||||
|
||||
function firstString(value: unknown): string {
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
error,
|
||||
required = false,
|
||||
optional = false,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
error: string | undefined;
|
||||
required?: boolean;
|
||||
optional?: boolean;
|
||||
children: ReactNode;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<label className="lx-field">
|
||||
<span className="lx-field__label">
|
||||
{label}
|
||||
{required ? <span className="lx-field__required">*</span> : null}
|
||||
{optional ? <span className="lx-field__optional">选填</span> : null}
|
||||
</span>
|
||||
{children}
|
||||
{error === undefined ? null : <span className="lx-field__error">{error}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
index,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
index: number;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<section className="lx-new-user__section">
|
||||
<h3 className="lx-new-user__section-title">
|
||||
<span className="lx-new-user__section-index">{index}</span>
|
||||
{title}
|
||||
</h3>
|
||||
<div className="lx-new-user__grid">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountFields({ form }: { form: NewUserFormState }): ReactNode {
|
||||
return (
|
||||
<Section index={1} title="账号信息">
|
||||
<Field label="姓名" error={form.errors.name} required>
|
||||
<Input
|
||||
value={form.values.name}
|
||||
placeholder="请输入成员姓名"
|
||||
onChange={(value) => {
|
||||
form.setField('name', value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="手机号" error={form.errors.phone} required>
|
||||
<Input
|
||||
value={form.values.phone}
|
||||
placeholder="11 位手机号,用于登录与通知"
|
||||
onChange={(value) => {
|
||||
form.setField('phone', value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="登录账号" error={form.errors.account} required>
|
||||
<Input
|
||||
value={form.values.account}
|
||||
placeholder="字母或数字开头,可含 . _ -"
|
||||
onChange={(value) => {
|
||||
form.setField('account', value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="初始密码" error={form.errors.password} required>
|
||||
<div className="lx-new-user__password">
|
||||
<Input
|
||||
value={form.values.password}
|
||||
onChange={(value) => {
|
||||
form.setField('password', value);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
icon={<IconRefresh size={14} />}
|
||||
onClick={() => {
|
||||
form.regeneratePassword();
|
||||
}}
|
||||
>
|
||||
重新生成
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function OrganizationFields({
|
||||
form,
|
||||
departments,
|
||||
roles,
|
||||
}: {
|
||||
form: NewUserFormState;
|
||||
departments: readonly DepartmentNode[];
|
||||
roles: readonly RoleSummary[];
|
||||
}): ReactNode {
|
||||
const groups = childDepartments(departments, form.values.departmentId);
|
||||
return (
|
||||
<Section index={2} title="组织与角色">
|
||||
<Field label="所属部门" error={form.errors.departmentId} required>
|
||||
<Select
|
||||
value={form.values.departmentId === '' ? undefined : form.values.departmentId}
|
||||
placeholder="请选择一级部门"
|
||||
optionList={topLevelDepartments(departments).map((node) => ({
|
||||
label: node.name,
|
||||
value: node.id,
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
form.setField('departmentId', firstString(value));
|
||||
form.setField('groupId', '');
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="所属小组" error={undefined}>
|
||||
<Select
|
||||
value={form.values.groupId === '' ? undefined : form.values.groupId}
|
||||
placeholder={groups.length === 0 ? '该部门无二级小组' : '请选择小组'}
|
||||
disabled={groups.length === 0}
|
||||
optionList={groups.map((node) => ({ label: node.name, value: node.id }))}
|
||||
onChange={(value) => {
|
||||
form.setField('groupId', firstString(value));
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<div className="lx-field lx-new-user__roles">
|
||||
<span className="lx-field__label">
|
||||
角色
|
||||
<span className="lx-field__required">*</span>
|
||||
<span className="lx-field__optional">至少选择一个</span>
|
||||
</span>
|
||||
<RoleChipsSelect
|
||||
roles={roles}
|
||||
value={form.values.roleIds}
|
||||
onChange={(roleIds) => {
|
||||
form.setField('roleIds', roleIds);
|
||||
}}
|
||||
/>
|
||||
{form.errors.roleIds === undefined ? null : (
|
||||
<span className="lx-field__error">{form.errors.roleIds}</span>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsFields({ form }: { form: NewUserFormState }): ReactNode {
|
||||
return (
|
||||
<Section index={3} title="初始设置">
|
||||
<div className="lx-field lx-new-user__full">
|
||||
<span className="lx-field__label">
|
||||
备注
|
||||
<span className="lx-field__optional">选填</span>
|
||||
</span>
|
||||
<Input
|
||||
value={form.values.remark}
|
||||
placeholder="例如:入职批次、岗位交接说明"
|
||||
onChange={(value) => {
|
||||
form.setField('remark', value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="lx-new-user__note lx-new-user__full">
|
||||
创建后账号立即生效,成员首次登录需修改初始密码;初始密码仅通过安全渠道线下交付。
|
||||
</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
/** 新建用户:单栏三段式(账号信息 / 组织与角色 / 初始设置) */
|
||||
export function UserNewPage(): ReactNode {
|
||||
const navigate = useNavigate();
|
||||
const form = useNewUserForm();
|
||||
const departments = useDepartmentsQuery();
|
||||
const roles = useRolesQuery();
|
||||
const createUser = useCreateUserMutation();
|
||||
const [pendingGrant, setPendingGrant] = useState<CreateUserInput | null>(null);
|
||||
|
||||
const runCreate = (input: CreateUserInput): void => {
|
||||
createUser.mutate(input, {
|
||||
onSuccess: (userId) => {
|
||||
notifySuccess('成员已创建,初始密码已生成');
|
||||
void navigate(userId === null ? ROUTES.users : userDetailPath(userId), { replace: true });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const submit = (): void => {
|
||||
const input = form.validate();
|
||||
if (input === null) {
|
||||
return;
|
||||
}
|
||||
const superAdminIds = (roles.data ?? []).filter(isSuperAdminRole).map((role) => role.id);
|
||||
if (input.roleIds.some((id) => superAdminIds.includes(id))) {
|
||||
setPendingGrant(input);
|
||||
return;
|
||||
}
|
||||
runCreate(input);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="lx-new-user">
|
||||
<header className="lx-users__header">
|
||||
<div>
|
||||
<h1>新建用户</h1>
|
||||
<p>填写账号信息、组织与角色后创建;初始密码由管理员设定</p>
|
||||
</div>
|
||||
<Button
|
||||
icon={<IconBack size={14} />}
|
||||
onClick={() => {
|
||||
void navigate(ROUTES.users);
|
||||
}}
|
||||
>
|
||||
返回列表
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<Form
|
||||
className="lx-new-user__form"
|
||||
onSubmit={() => {
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<AccountFields form={form} />
|
||||
<OrganizationFields
|
||||
form={form}
|
||||
departments={departments.data ?? []}
|
||||
roles={roles.data ?? []}
|
||||
/>
|
||||
<SettingsFields form={form} />
|
||||
<footer className="lx-new-user__footer">
|
||||
<Button
|
||||
onClick={() => {
|
||||
void navigate(ROUTES.users);
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
htmlType="submit"
|
||||
theme="solid"
|
||||
type="primary"
|
||||
icon={<IconAdd size={16} />}
|
||||
loading={createUser.isPending}
|
||||
data-testid="create-user"
|
||||
>
|
||||
创建用户
|
||||
</Button>
|
||||
</footer>
|
||||
</Form>
|
||||
|
||||
<SuperAdminGrantDialog
|
||||
key={`grant-${pendingGrant === null ? 'closed' : 'open'}`}
|
||||
visible={pendingGrant !== null}
|
||||
targetTitle={form.values.name === '' ? '待填写成员' : form.values.name}
|
||||
targetMeta={form.values.account === '' ? form.values.phone : form.values.account}
|
||||
loading={createUser.isPending}
|
||||
onCancel={() => {
|
||||
setPendingGrant(null);
|
||||
}}
|
||||
onConfirm={() => {
|
||||
if (pendingGrant !== null) {
|
||||
runCreate(pendingGrant);
|
||||
}
|
||||
setPendingGrant(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { UserStatus } from '@lingxi/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { USER_STATUS_LABEL } from './user-display';
|
||||
|
||||
export interface UserStatusTagProps {
|
||||
status: UserStatus;
|
||||
/** 列表行内只显示状态点,详情展示完整标签 */
|
||||
dotOnly?: boolean;
|
||||
}
|
||||
|
||||
export function UserStatusTag({ status, dotOnly = false }: UserStatusTagProps): ReactNode {
|
||||
const active = status === 'active';
|
||||
return (
|
||||
<span
|
||||
className={
|
||||
active
|
||||
? dotOnly
|
||||
? 'lx-status-dot'
|
||||
: 'lx-status-tag is-active'
|
||||
: dotOnly
|
||||
? 'lx-status-dot is-disabled'
|
||||
: 'lx-status-tag is-disabled'
|
||||
}
|
||||
title={USER_STATUS_LABEL[status]}
|
||||
>
|
||||
<span className="lx-status-tag__dot" aria-hidden="true" />
|
||||
{dotOnly ? null : USER_STATUS_LABEL[status]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/* eslint-disable max-lines-per-function, complexity -- 页面负责主从联动与弹窗编排,集中在一处便于追踪数据流 */
|
||||
import { Button } from '@douyinfe/semi-ui-19';
|
||||
import { Permission } from '@lingxi/shared';
|
||||
import { IconAdd } from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
|
||||
import { Can } from '@/components/Can';
|
||||
import { useDepartmentsQuery, useRolesQuery } from '@/hooks/queries/use-directory';
|
||||
import {
|
||||
useUserCandidatesQuery,
|
||||
useUserCustomersQuery,
|
||||
useUserDetailQuery,
|
||||
} from '@/hooks/queries/use-users';
|
||||
import { usePermission } from '@/hooks/use-permission';
|
||||
import { ROUTES, userDetailPath } from '@/routes';
|
||||
|
||||
import { AdjustRolesDialog } from './dialogs/AdjustRolesDialog';
|
||||
import { DisableUserDialog } from './dialogs/DisableUserDialog';
|
||||
import { EditUserDialog } from './dialogs/EditUserDialog';
|
||||
import { EnableUserDialog } from './dialogs/EnableUserDialog';
|
||||
import { SuperAdminGrantDialog } from './dialogs/SuperAdminGrantDialog';
|
||||
import { TransferCustomersDialog } from './dialogs/TransferCustomersDialog';
|
||||
import { useUserDialogs } from './use-user-dialogs';
|
||||
import { useUserListController } from './use-user-list-controller';
|
||||
import { UserDetailPanel } from './UserDetailPanel';
|
||||
import { UserFilterBar, UserListPanel } from './UserListPanel';
|
||||
|
||||
function UsersHeader(): ReactNode {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<header className="lx-users__header">
|
||||
<div>
|
||||
<h1>用户与组织</h1>
|
||||
<p>管理成员账号、角色与客户归属</p>
|
||||
</div>
|
||||
<Can permission={Permission.USER_MANAGE}>
|
||||
<Button
|
||||
theme="solid"
|
||||
type="primary"
|
||||
icon={<IconAdd size={16} />}
|
||||
onClick={() => {
|
||||
void navigate(ROUTES.userNew);
|
||||
}}
|
||||
>
|
||||
新建用户
|
||||
</Button>
|
||||
</Can>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
/** 用户管理主页面:左列表 + 右详情主从联动(/users 与 /users/:id 共用) */
|
||||
export function UsersPage(): ReactNode {
|
||||
const params = useParams<{ id?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const list = useUserListController();
|
||||
const canManage = usePermission(Permission.USER_MANAGE);
|
||||
const departments = useDepartmentsQuery();
|
||||
const roles = useRolesQuery();
|
||||
const dialogs = useUserDialogs(roles.data ?? []);
|
||||
const selectedId = params.id ?? list.users[0]?.id ?? null;
|
||||
const detail = useUserDetailQuery(selectedId);
|
||||
const customers = useUserCustomersQuery(dialogs.dialog?.user.id ?? null);
|
||||
const candidates = useUserCandidatesQuery(dialogs.dialog !== null);
|
||||
const dialogUser = dialogs.dialog?.user ?? null;
|
||||
|
||||
return (
|
||||
<div className="lx-users">
|
||||
<UsersHeader />
|
||||
<UserFilterBar
|
||||
filters={list.filters}
|
||||
departments={departments.data ?? []}
|
||||
roles={roles.data ?? []}
|
||||
onFiltersChange={list.setFilters}
|
||||
onResetFilters={list.resetFilters}
|
||||
/>
|
||||
<div className="lx-users__split">
|
||||
<UserListPanel
|
||||
filters={list.filters}
|
||||
users={list.users}
|
||||
loading={list.loading}
|
||||
error={list.error}
|
||||
selectedUserId={selectedId}
|
||||
hasNext={list.hasNext}
|
||||
hasPrev={list.hasPrev}
|
||||
onSortChange={(sort) => {
|
||||
list.setFilters({ sort });
|
||||
}}
|
||||
onSelect={(userId) => {
|
||||
void navigate(userDetailPath(userId), { replace: true });
|
||||
}}
|
||||
onPrev={list.onPrev}
|
||||
onNext={list.onNext}
|
||||
onRetry={list.onRetry}
|
||||
onCreate={() => {
|
||||
void navigate(ROUTES.userNew);
|
||||
}}
|
||||
onResetFilters={list.resetFilters}
|
||||
/>
|
||||
<UserDetailPanel
|
||||
userId={selectedId}
|
||||
detail={detail.data}
|
||||
loading={detail.isPending}
|
||||
error={detail.error}
|
||||
canManage={canManage}
|
||||
onRetry={() => {
|
||||
void detail.refetch();
|
||||
}}
|
||||
onEdit={(user) => {
|
||||
dialogs.open('edit', user);
|
||||
}}
|
||||
onDisable={(user) => {
|
||||
dialogs.open('disable', user);
|
||||
}}
|
||||
onEnable={(user) => {
|
||||
dialogs.open('enable', user);
|
||||
}}
|
||||
onAdjustRoles={(user) => {
|
||||
dialogs.open('roles', user);
|
||||
}}
|
||||
onTransfer={(user) => {
|
||||
dialogs.open('transfer', user);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<EditUserDialog
|
||||
key={`edit-${dialogUser?.id ?? ''}`}
|
||||
visible={dialogs.dialog?.type === 'edit'}
|
||||
user={dialogUser}
|
||||
departments={departments.data ?? []}
|
||||
roles={roles.data ?? []}
|
||||
loading={dialogs.updatePending}
|
||||
onCancel={dialogs.close}
|
||||
onConfirm={dialogs.submitEdit}
|
||||
/>
|
||||
<DisableUserDialog
|
||||
key={`disable-${dialogUser?.id ?? ''}`}
|
||||
visible={dialogs.dialog?.type === 'disable'}
|
||||
user={dialogUser}
|
||||
candidates={candidates.data?.items ?? []}
|
||||
loading={dialogs.updatePending}
|
||||
onCancel={dialogs.close}
|
||||
onConfirm={dialogs.submitDisable}
|
||||
/>
|
||||
<EnableUserDialog
|
||||
key={`enable-${dialogUser?.id ?? ''}`}
|
||||
visible={dialogs.dialog?.type === 'enable'}
|
||||
user={dialogUser}
|
||||
loading={dialogs.updatePending}
|
||||
onCancel={dialogs.close}
|
||||
onConfirm={dialogs.submitEnable}
|
||||
/>
|
||||
<AdjustRolesDialog
|
||||
key={`roles-${dialogUser?.id ?? ''}`}
|
||||
visible={dialogs.dialog?.type === 'roles' && !dialogs.grantSuper}
|
||||
user={dialogUser}
|
||||
roles={roles.data ?? []}
|
||||
loading={dialogs.updatePending}
|
||||
onCancel={dialogs.close}
|
||||
onConfirm={dialogs.submitRoles}
|
||||
/>
|
||||
<TransferCustomersDialog
|
||||
key={`transfer-${dialogUser?.id ?? ''}`}
|
||||
visible={dialogs.dialog?.type === 'transfer'}
|
||||
user={dialogUser}
|
||||
customers={customers.data ?? []}
|
||||
candidates={candidates.data?.items ?? []}
|
||||
loading={dialogs.updatePending}
|
||||
onCancel={dialogs.close}
|
||||
onConfirm={dialogs.submitTransfer}
|
||||
/>
|
||||
<SuperAdminGrantDialog
|
||||
key={`grant-${dialogs.grantSuper ? 'open' : 'closed'}`}
|
||||
visible={dialogs.grantSuper}
|
||||
targetTitle={dialogUser?.name ?? ''}
|
||||
targetMeta={
|
||||
dialogUser === null ? '' : `${dialogUser.account} · ${dialogUser.departmentName}`
|
||||
}
|
||||
loading={dialogs.updatePending}
|
||||
onCancel={dialogs.closeGrant}
|
||||
onConfirm={dialogs.confirmGrant}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { RoleSummary, UserDetail } from '@lingxi/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { RoleChipsSelect } from '../RoleChipsSelect';
|
||||
import { ConfirmActionDialog } from './ConfirmActionDialog';
|
||||
|
||||
export interface AdjustRolesDialogProps {
|
||||
visible: boolean;
|
||||
user: UserDetail | null;
|
||||
roles: readonly RoleSummary[];
|
||||
loading: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: (roleIds: string[]) => void;
|
||||
}
|
||||
|
||||
/** 调整角色:变更前后角色对比 + 影响与审计说明,勾选后保存 */
|
||||
export function AdjustRolesDialog({
|
||||
visible,
|
||||
user,
|
||||
roles,
|
||||
loading,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: AdjustRolesDialogProps): ReactNode {
|
||||
const initial = user?.roles.map((role) => role.id) ?? [];
|
||||
const [roleIds, setRoleIds] = useState<string[]>(initial);
|
||||
const unchanged =
|
||||
roleIds.length === initial.length && roleIds.every((id) => initial.includes(id));
|
||||
|
||||
return (
|
||||
<ConfirmActionDialog
|
||||
visible={visible}
|
||||
title="调整角色"
|
||||
objectTitle={user?.name ?? ''}
|
||||
objectMeta={user === null ? '' : `${user.account} · ${user.departmentName}`}
|
||||
consequences={[
|
||||
'角色变更即时生效,成员下一次请求即使用新权限',
|
||||
'涉及权限扩大时请确认职责范围,变更将写入审计',
|
||||
]}
|
||||
acknowledgeLabel="我已确认本次角色变更"
|
||||
confirmText="保存角色"
|
||||
loading={loading}
|
||||
confirmDisabled={unchanged || roleIds.length === 0}
|
||||
onCancel={onCancel}
|
||||
onConfirm={() => {
|
||||
onConfirm(roleIds);
|
||||
}}
|
||||
>
|
||||
<div className="lx-field">
|
||||
<span className="lx-field__label">角色(至少选择一个)</span>
|
||||
<RoleChipsSelect roles={roles} value={roleIds} onChange={setRoleIds} />
|
||||
</div>
|
||||
</ConfirmActionDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/* eslint-disable max-lines-per-function -- 确认弹窗模板固定,保持单文件便于复用 */
|
||||
import { Button, Checkbox, Modal } from '@douyinfe/semi-ui-19';
|
||||
import { IconAlert } from '@lingxi/ui/icons';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
|
||||
export interface ConfirmActionDialogProps {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
objectTitle: string;
|
||||
objectMeta: string;
|
||||
objectTags?: ReactNode;
|
||||
consequences: readonly string[];
|
||||
acknowledgeLabel: string;
|
||||
confirmText: string;
|
||||
danger?: boolean;
|
||||
loading?: boolean;
|
||||
/** 额外条件(如转交接收人未选)导致不可提交 */
|
||||
confirmDisabled?: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
function ModalFooter({
|
||||
loading,
|
||||
danger,
|
||||
disabled,
|
||||
confirmText,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
loading: boolean;
|
||||
danger: boolean;
|
||||
disabled: boolean;
|
||||
confirmText: string;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<>
|
||||
<Button onClick={onCancel} disabled={loading}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
theme="solid"
|
||||
type={danger ? 'danger' : 'primary'}
|
||||
loading={loading}
|
||||
disabled={disabled}
|
||||
onClick={onConfirm}
|
||||
data-testid="confirm-action"
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 统一二次确认弹窗:对象卡 → 后果说明 → 勾选确认后才可提交 */
|
||||
export function ConfirmActionDialog({
|
||||
visible,
|
||||
title,
|
||||
objectTitle,
|
||||
objectMeta,
|
||||
objectTags,
|
||||
consequences,
|
||||
acknowledgeLabel,
|
||||
confirmText,
|
||||
danger = false,
|
||||
loading = false,
|
||||
confirmDisabled = false,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
children,
|
||||
}: ConfirmActionDialogProps): ReactNode {
|
||||
const [acknowledged, setAcknowledged] = useState(false);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={title}
|
||||
width={520}
|
||||
maskClosable={false}
|
||||
closeOnEsc={!loading}
|
||||
onCancel={onCancel}
|
||||
footer={
|
||||
<ModalFooter
|
||||
loading={loading}
|
||||
danger={danger}
|
||||
disabled={!acknowledged || confirmDisabled}
|
||||
confirmText={confirmText}
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="lx-confirm-dialog">
|
||||
<div className="lx-confirm-dialog__object">
|
||||
<span className="lx-confirm-dialog__object-icon" aria-hidden="true">
|
||||
{objectTitle.slice(0, 1)}
|
||||
</span>
|
||||
<span className="lx-confirm-dialog__object-copy">
|
||||
<span className="lx-confirm-dialog__object-title">{objectTitle}</span>
|
||||
<span className="lx-confirm-dialog__object-meta">{objectMeta}</span>
|
||||
</span>
|
||||
{objectTags}
|
||||
</div>
|
||||
|
||||
<div className={danger ? 'lx-confirm-dialog__warn' : 'lx-confirm-dialog__warn is-info'}>
|
||||
<IconAlert size={16} />
|
||||
<ul>
|
||||
{consequences.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
|
||||
<Checkbox
|
||||
className="lx-confirm-dialog__ack"
|
||||
checked={acknowledged}
|
||||
onChange={(event) => {
|
||||
setAcknowledged(event.target.checked === true);
|
||||
}}
|
||||
>
|
||||
{acknowledgeLabel}
|
||||
</Checkbox>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/* eslint-disable max-lines-per-function -- 确认弹窗模板固定,保持单文件便于复用 */
|
||||
import { Input, Select } from '@douyinfe/semi-ui-19';
|
||||
import type { DisableUserInput, UserDetail, UserSummary } from '@lingxi/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { ConfirmActionDialog } from './ConfirmActionDialog';
|
||||
|
||||
export interface DisableUserDialogProps {
|
||||
visible: boolean;
|
||||
user: UserDetail | null;
|
||||
candidates: readonly UserSummary[];
|
||||
loading: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: (input: DisableUserInput) => void;
|
||||
}
|
||||
|
||||
function firstString(value: unknown): string {
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
/** 停用账号:对象卡 → 后果 → 客户转交接收人 + 勾选确认 */
|
||||
export function DisableUserDialog({
|
||||
visible,
|
||||
user,
|
||||
candidates,
|
||||
loading,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: DisableUserDialogProps): ReactNode {
|
||||
const [reason, setReason] = useState('');
|
||||
const [transferTo, setTransferTo] = useState('');
|
||||
|
||||
const submit = (): void => {
|
||||
const input: DisableUserInput = {};
|
||||
const trimmed = reason.trim();
|
||||
if (trimmed !== '') {
|
||||
input.reason = trimmed;
|
||||
}
|
||||
if (transferTo !== '') {
|
||||
input.transferToUserId = transferTo;
|
||||
}
|
||||
onConfirm(input);
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmActionDialog
|
||||
visible={visible}
|
||||
title="停用账号"
|
||||
objectTitle={user?.name ?? ''}
|
||||
objectMeta={user === null ? '' : `${user.account} · ${user.departmentName}`}
|
||||
consequences={[
|
||||
'账号停用后立即失效,在线会话同步退出',
|
||||
'名下客户需在停用前完成转交,避免交付中断',
|
||||
'操作记录与产出物保留,可随时重新启用',
|
||||
]}
|
||||
acknowledgeLabel="我已了解上述影响,并确认停用该账号"
|
||||
confirmText="停用账号"
|
||||
danger
|
||||
loading={loading}
|
||||
onCancel={onCancel}
|
||||
onConfirm={submit}
|
||||
>
|
||||
<label className="lx-field">
|
||||
<span className="lx-field__label">停用原因(选填)</span>
|
||||
<Input value={reason} maxLength={200} placeholder="例如:离职、转岗" onChange={setReason} />
|
||||
</label>
|
||||
<label className="lx-field">
|
||||
<span className="lx-field__label">客户转交给(选填)</span>
|
||||
<Select
|
||||
value={transferTo === '' ? undefined : transferTo}
|
||||
placeholder="暂不转交"
|
||||
optionList={candidates.map((candidate) => ({
|
||||
label: `${candidate.name} · ${candidate.departmentName}`,
|
||||
value: candidate.id,
|
||||
}))}
|
||||
showClear
|
||||
onChange={(value) => {
|
||||
setTransferTo(firstString(value));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</ConfirmActionDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/* eslint-disable max-lines-per-function, complexity -- 编辑表单字段与校验集中在一个弹窗组件 */
|
||||
import { Button, Input, Modal, Select } from '@douyinfe/semi-ui-19';
|
||||
import type { DepartmentNode, RoleSummary, UpdateUserInput, UserDetail } from '@lingxi/shared';
|
||||
import { updateUserInputSchema } from '@lingxi/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { RoleChipsSelect } from '../RoleChipsSelect';
|
||||
|
||||
export interface EditUserDialogProps {
|
||||
visible: boolean;
|
||||
user: UserDetail | null;
|
||||
departments: readonly DepartmentNode[];
|
||||
roles: readonly RoleSummary[];
|
||||
loading: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: (input: UpdateUserInput) => void;
|
||||
}
|
||||
|
||||
interface FieldErrors {
|
||||
name?: string;
|
||||
phone?: string;
|
||||
departmentId?: string;
|
||||
roleIds?: string;
|
||||
}
|
||||
|
||||
function departmentOptions(nodes: readonly DepartmentNode[]): { label: string; value: string }[] {
|
||||
const parents = nodes.filter((node) => node.parentId === null);
|
||||
return parents.flatMap((parent) => {
|
||||
const children = nodes.filter((node) => node.parentId === parent.id);
|
||||
if (children.length === 0) {
|
||||
return [{ label: parent.name, value: parent.id }];
|
||||
}
|
||||
return [
|
||||
{ label: parent.name, value: parent.id },
|
||||
...children.map((child) => ({ label: `— ${child.name}`, value: child.id })),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function firstString(value: unknown): string {
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
/** 编辑资料:姓名 / 手机号 / 部门 / 角色;account 与状态不在本弹窗修改 */
|
||||
export function EditUserDialog({
|
||||
visible,
|
||||
user,
|
||||
departments,
|
||||
roles,
|
||||
loading,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: EditUserDialogProps): ReactNode {
|
||||
const [name, setName] = useState(user?.name ?? '');
|
||||
const [phone, setPhone] = useState(user?.phone ?? '');
|
||||
const [departmentId, setDepartmentId] = useState(user?.departmentId ?? '');
|
||||
const [roleIds, setRoleIds] = useState<string[]>(user?.roles.map((role) => role.id) ?? []);
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
|
||||
const submit = (): void => {
|
||||
const parsed = updateUserInputSchema.safeParse({ name, phone, departmentId, roleIds });
|
||||
if (!parsed.success) {
|
||||
const next: FieldErrors = {};
|
||||
for (const issue of parsed.error.issues) {
|
||||
const key = issue.path[0];
|
||||
if (key === 'name' || key === 'phone' || key === 'departmentId' || key === 'roleIds') {
|
||||
next[key] = issue.message;
|
||||
}
|
||||
}
|
||||
setErrors(next);
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
onConfirm(parsed.data);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title="编辑资料"
|
||||
width={520}
|
||||
maskClosable={false}
|
||||
onCancel={onCancel}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onCancel} disabled={loading}>
|
||||
取消
|
||||
</Button>
|
||||
<Button theme="solid" type="primary" loading={loading} onClick={submit}>
|
||||
保存
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="lx-edit-dialog">
|
||||
<p className="lx-detail-tab__hint">
|
||||
登录账号 {user?.account ?? '—'}{' '}
|
||||
由身份源维护,不可修改;手机号仅具备用户管理权限的成员可见。
|
||||
</p>
|
||||
<label className="lx-field">
|
||||
<span className="lx-field__label">姓名</span>
|
||||
<Input value={name} onChange={setName} />
|
||||
{errors.name === undefined ? null : (
|
||||
<span className="lx-field__error">{errors.name}</span>
|
||||
)}
|
||||
</label>
|
||||
<label className="lx-field">
|
||||
<span className="lx-field__label">手机号</span>
|
||||
<Input value={phone} placeholder="11 位手机号" onChange={setPhone} />
|
||||
{errors.phone === undefined ? null : (
|
||||
<span className="lx-field__error">{errors.phone}</span>
|
||||
)}
|
||||
</label>
|
||||
<label className="lx-field">
|
||||
<span className="lx-field__label">所属部门</span>
|
||||
<Select
|
||||
value={departmentId === '' ? undefined : departmentId}
|
||||
optionList={departmentOptions(departments)}
|
||||
onChange={(value) => {
|
||||
setDepartmentId(firstString(value));
|
||||
}}
|
||||
/>
|
||||
{errors.departmentId === undefined ? null : (
|
||||
<span className="lx-field__error">{errors.departmentId}</span>
|
||||
)}
|
||||
</label>
|
||||
<div className="lx-field">
|
||||
<span className="lx-field__label">角色</span>
|
||||
<RoleChipsSelect roles={roles} value={roleIds} onChange={setRoleIds} />
|
||||
{errors.roleIds === undefined ? null : (
|
||||
<span className="lx-field__error">{errors.roleIds}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { UserDetail } from '@lingxi/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { ConfirmActionDialog } from './ConfirmActionDialog';
|
||||
|
||||
export interface EnableUserDialogProps {
|
||||
visible: boolean;
|
||||
user: UserDetail | null;
|
||||
loading: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
/** 启用账号:恢复登录能力,角色与数据范围按停用前保留 */
|
||||
export function EnableUserDialog({
|
||||
visible,
|
||||
user,
|
||||
loading,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: EnableUserDialogProps): ReactNode {
|
||||
return (
|
||||
<ConfirmActionDialog
|
||||
visible={visible}
|
||||
title="启用账号"
|
||||
objectTitle={user?.name ?? ''}
|
||||
objectMeta={user === null ? '' : `${user.account} · ${user.departmentName}`}
|
||||
consequences={[
|
||||
'启用后成员可立即使用原密码登录',
|
||||
'角色与数据范围沿用停用前的配置',
|
||||
'本次启用将写入审计日志',
|
||||
]}
|
||||
acknowledgeLabel="我已了解上述影响,并确认启用该账号"
|
||||
confirmText="启用账号"
|
||||
loading={loading}
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { ConfirmActionDialog } from './ConfirmActionDialog';
|
||||
|
||||
export interface SuperAdminGrantDialogProps {
|
||||
visible: boolean;
|
||||
targetTitle: string;
|
||||
targetMeta: string;
|
||||
loading: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
/** 授予超级管理员:权限后果说明 + 强制勾选;新建与调整角色共用 */
|
||||
export function SuperAdminGrantDialog({
|
||||
visible,
|
||||
targetTitle,
|
||||
targetMeta,
|
||||
loading,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: SuperAdminGrantDialogProps): ReactNode {
|
||||
return (
|
||||
<ConfirmActionDialog
|
||||
visible={visible}
|
||||
title="授予超级管理员"
|
||||
objectTitle={targetTitle}
|
||||
objectMeta={targetMeta}
|
||||
consequences={[
|
||||
'超级管理员拥有全部模块权限与全部客户数据范围',
|
||||
'授予即时生效并写入审计;建议全平台超级管理员不超过 2 人',
|
||||
'如需收回,请在「角色权限」中移除该角色',
|
||||
]}
|
||||
acknowledgeLabel="我已确认授权范围,并同意授予超级管理员"
|
||||
confirmText="确认授予"
|
||||
danger
|
||||
loading={loading}
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/* eslint-disable max-lines-per-function -- 转交弹窗字段与校验集中在一个弹窗组件 */
|
||||
import { Select } from '@douyinfe/semi-ui-19';
|
||||
import type { TransferCustomersInput, UserDetail, UserSummary } from '@lingxi/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { CustomerSummary } from '@/api/identity';
|
||||
|
||||
import { ConfirmActionDialog } from './ConfirmActionDialog';
|
||||
|
||||
export interface TransferCustomersDialogProps {
|
||||
visible: boolean;
|
||||
user: UserDetail | null;
|
||||
customers: readonly CustomerSummary[];
|
||||
candidates: readonly UserSummary[];
|
||||
loading: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: (input: TransferCustomersInput) => void;
|
||||
}
|
||||
|
||||
function firstString(value: unknown): string {
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
/** 客户转交:选择客户 + 新负责人,确认后一次提交;A3 客户模块落地前后端返回占位错误 */
|
||||
export function TransferCustomersDialog({
|
||||
visible,
|
||||
user,
|
||||
customers,
|
||||
candidates,
|
||||
loading,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: TransferCustomersDialogProps): ReactNode {
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [ownerId, setOwnerId] = useState('');
|
||||
|
||||
const toggle = (id: string): void => {
|
||||
setSelected((current) =>
|
||||
current.includes(id) ? current.filter((item) => item !== id) : [...current, id],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmActionDialog
|
||||
visible={visible}
|
||||
title="转交客户"
|
||||
objectTitle={user?.name ?? ''}
|
||||
objectMeta={
|
||||
user === null ? '' : `${user.departmentName} · 名下客户 ${String(customers.length)} 个`
|
||||
}
|
||||
consequences={[
|
||||
'客户资料、交付记录与历史素材将一并移交',
|
||||
'转交后原负责人不再拥有该客户的数据权限',
|
||||
'本次转交将写入审计日志',
|
||||
]}
|
||||
acknowledgeLabel="我已了解上述影响,并确认转交所选客户"
|
||||
confirmText="确认转交"
|
||||
loading={loading}
|
||||
confirmDisabled={selected.length === 0 || ownerId === ''}
|
||||
onCancel={onCancel}
|
||||
onConfirm={() => {
|
||||
onConfirm({ customerIds: selected, toUserId: ownerId });
|
||||
}}
|
||||
>
|
||||
<div className="lx-field">
|
||||
<span className="lx-field__label">选择客户</span>
|
||||
<div className="lx-check-list">
|
||||
{customers.map((customer) => (
|
||||
<label key={customer.id} className="lx-check-list__item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(customer.id)}
|
||||
onChange={() => {
|
||||
toggle(customer.id);
|
||||
}}
|
||||
/>
|
||||
<span>{customer.name}</span>
|
||||
</label>
|
||||
))}
|
||||
{customers.length === 0 ? (
|
||||
<p className="lx-detail-tab__hint">该成员暂无可转交客户。</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<label className="lx-field">
|
||||
<span className="lx-field__label">新负责人</span>
|
||||
<Select
|
||||
value={ownerId === '' ? undefined : ownerId}
|
||||
placeholder="请选择接收人"
|
||||
optionList={candidates.map((candidate) => ({
|
||||
label: `${candidate.name} · ${candidate.departmentName}`,
|
||||
value: candidate.id,
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
setOwnerId(firstString(value));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</ConfirmActionDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { RoleSummary } from '@lingxi/shared';
|
||||
|
||||
/** 超级管理员(内置角色):授予/收回需二次确认并审计 */
|
||||
export function isSuperAdminRole(role: RoleSummary): boolean {
|
||||
return role.code === 'super_admin' || role.name === '超级管理员';
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/* eslint-disable max-lines-per-function -- 页面动作编排集中在此 Hook,便于统一维护二次确认与缓存失效 */
|
||||
import {
|
||||
type DisableUserInput,
|
||||
type RoleSummary,
|
||||
type TransferCustomersInput,
|
||||
type UpdateUserInput,
|
||||
type UserDetail,
|
||||
} from '@lingxi/shared';
|
||||
import { notifySuccess } from '@lingxi/ui';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
useDisableUserMutation,
|
||||
useEnableUserMutation,
|
||||
useTransferCustomersMutation,
|
||||
useUpdateUserMutation,
|
||||
} from '@/hooks/queries/use-users';
|
||||
|
||||
import { isSuperAdminRole } from './role-utils';
|
||||
|
||||
export type UserDialogType = 'disable' | 'enable' | 'edit' | 'roles' | 'transfer';
|
||||
|
||||
export interface UserDialogState {
|
||||
type: UserDialogType;
|
||||
user: UserDetail;
|
||||
}
|
||||
|
||||
export interface UserDialogsController {
|
||||
dialog: UserDialogState | null;
|
||||
grantSuper: boolean;
|
||||
updatePending: boolean;
|
||||
open: (type: UserDialogType, user: UserDetail) => void;
|
||||
close: () => void;
|
||||
closeGrant: () => void;
|
||||
submitEdit: (input: UpdateUserInput) => void;
|
||||
submitDisable: (input: DisableUserInput) => void;
|
||||
submitEnable: () => void;
|
||||
submitRoles: (roleIds: string[]) => void;
|
||||
confirmGrant: () => void;
|
||||
submitTransfer: (input: TransferCustomersInput) => void;
|
||||
}
|
||||
|
||||
/** 用户写操作:调整角色若新增「超级管理员」则先走授予二次确认 */
|
||||
export function useUserDialogs(roles: readonly RoleSummary[]): UserDialogsController {
|
||||
const [dialog, setDialog] = useState<UserDialogState | null>(null);
|
||||
const [pendingRoleIds, setPendingRoleIds] = useState<string[]>([]);
|
||||
const [grantSuper, setGrantSuper] = useState(false);
|
||||
const updateMutation = useUpdateUserMutation();
|
||||
const disableMutation = useDisableUserMutation();
|
||||
const enableMutation = useEnableUserMutation();
|
||||
const transferMutation = useTransferCustomersMutation();
|
||||
|
||||
const close = (): void => {
|
||||
setDialog(null);
|
||||
setGrantSuper(false);
|
||||
setPendingRoleIds([]);
|
||||
};
|
||||
|
||||
const open = (type: UserDialogType, user: UserDetail): void => {
|
||||
setDialog({ type, user });
|
||||
};
|
||||
|
||||
const saveRoles = (roleIds: string[]): void => {
|
||||
if (dialog === null) {
|
||||
return;
|
||||
}
|
||||
updateMutation.mutate(
|
||||
{ userId: dialog.user.id, input: { roleIds } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifySuccess('角色已更新');
|
||||
close();
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
dialog,
|
||||
grantSuper,
|
||||
updatePending:
|
||||
updateMutation.isPending ||
|
||||
disableMutation.isPending ||
|
||||
enableMutation.isPending ||
|
||||
transferMutation.isPending,
|
||||
open,
|
||||
close,
|
||||
closeGrant: () => {
|
||||
setGrantSuper(false);
|
||||
},
|
||||
submitEdit: (input) => {
|
||||
if (dialog === null) {
|
||||
return;
|
||||
}
|
||||
updateMutation.mutate(
|
||||
{ userId: dialog.user.id, input },
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifySuccess('成员资料已更新');
|
||||
close();
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
submitDisable: (input) => {
|
||||
if (dialog === null) {
|
||||
return;
|
||||
}
|
||||
disableMutation.mutate(
|
||||
{ userId: dialog.user.id, input },
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifySuccess('账号已停用,在线会话已退出');
|
||||
close();
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
submitEnable: () => {
|
||||
if (dialog === null) {
|
||||
return;
|
||||
}
|
||||
enableMutation.mutate(dialog.user.id, {
|
||||
onSuccess: () => {
|
||||
notifySuccess('账号已启用');
|
||||
close();
|
||||
},
|
||||
});
|
||||
},
|
||||
submitRoles: (roleIds) => {
|
||||
const before = dialog?.user.roles.map((role) => role.id) ?? [];
|
||||
const addedSuper = roles.some(
|
||||
(role) => isSuperAdminRole(role) && roleIds.includes(role.id) && !before.includes(role.id),
|
||||
);
|
||||
setPendingRoleIds(roleIds);
|
||||
if (addedSuper) {
|
||||
setGrantSuper(true);
|
||||
return;
|
||||
}
|
||||
saveRoles(roleIds);
|
||||
},
|
||||
confirmGrant: () => {
|
||||
setGrantSuper(false);
|
||||
saveRoles(pendingRoleIds);
|
||||
},
|
||||
submitTransfer: (input) => {
|
||||
if (dialog === null) {
|
||||
return;
|
||||
}
|
||||
transferMutation.mutate(
|
||||
{ userId: dialog.user.id, input },
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifySuccess('客户已转交');
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/* eslint-disable max-lines-per-function -- 列表筛选与游标分页状态集中在一个 Hook */
|
||||
import type { UserSummary } from '@lingxi/shared';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { useUserListQuery } from '@/hooks/queries/use-users';
|
||||
import { useDebouncedValue } from '@/hooks/use-debounced-value';
|
||||
|
||||
import {
|
||||
DEFAULT_USER_LIST_FILTERS,
|
||||
toUserListQueryInput,
|
||||
type UserListFilters,
|
||||
} from './user-filters';
|
||||
|
||||
export interface UserListController {
|
||||
filters: UserListFilters;
|
||||
users: UserSummary[];
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
hasNext: boolean;
|
||||
hasPrev: boolean;
|
||||
setFilters: (next: Partial<UserListFilters>) => void;
|
||||
resetFilters: () => void;
|
||||
onNext: () => void;
|
||||
onPrev: () => void;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
/** 列表筛选 + 游标分页(cursor 历史栈,前进/后退都复用后端返回的 next_cursor) */
|
||||
export function useUserListController(): UserListController {
|
||||
const [filters, setFiltersState] = useState<UserListFilters>(DEFAULT_USER_LIST_FILTERS);
|
||||
const debouncedKeyword = useDebouncedValue(filters.keyword);
|
||||
const [cursors, setCursors] = useState<(string | null)[]>([null]);
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
|
||||
const queryInput = useMemo(
|
||||
() =>
|
||||
toUserListQueryInput(
|
||||
{ ...filters, keyword: debouncedKeyword },
|
||||
cursors[pageIndex] ?? undefined,
|
||||
),
|
||||
[filters, debouncedKeyword, cursors, pageIndex],
|
||||
);
|
||||
const query = useUserListQuery(queryInput);
|
||||
|
||||
const resetPaging = useCallback(() => {
|
||||
setCursors([null]);
|
||||
setPageIndex(0);
|
||||
}, []);
|
||||
|
||||
const setFilters = useCallback(
|
||||
(next: Partial<UserListFilters>) => {
|
||||
setFiltersState((current) => ({ ...current, ...next }));
|
||||
resetPaging();
|
||||
},
|
||||
[resetPaging],
|
||||
);
|
||||
|
||||
const resetFilters = useCallback(() => {
|
||||
setFiltersState(DEFAULT_USER_LIST_FILTERS);
|
||||
resetPaging();
|
||||
}, [resetPaging]);
|
||||
|
||||
const nextCursor = query.data?.next_cursor ?? null;
|
||||
|
||||
const onNext = useCallback(() => {
|
||||
if (nextCursor === null) {
|
||||
return;
|
||||
}
|
||||
setCursors((current) => [...current.slice(0, pageIndex + 1), nextCursor]);
|
||||
setPageIndex((current) => current + 1);
|
||||
}, [nextCursor, pageIndex]);
|
||||
|
||||
const onPrev = useCallback(() => {
|
||||
setPageIndex((current) => Math.max(0, current - 1));
|
||||
}, []);
|
||||
|
||||
const onRetry = useCallback(() => {
|
||||
void query.refetch();
|
||||
}, [query]);
|
||||
|
||||
return {
|
||||
filters,
|
||||
users: query.data?.items ?? [],
|
||||
loading: query.isPending,
|
||||
error: query.error,
|
||||
hasNext: nextCursor !== null,
|
||||
hasPrev: pageIndex > 0,
|
||||
setFilters,
|
||||
resetFilters,
|
||||
onNext,
|
||||
onPrev,
|
||||
onRetry,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { useNewUserForm } from './use-user-new-form';
|
||||
|
||||
const DEPARTMENT_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
|
||||
const ROLE_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ2';
|
||||
|
||||
describe('新建用户表单校验', () => {
|
||||
it('空表单提交时逐字段给出错误', () => {
|
||||
const { result } = renderHook(() => useNewUserForm());
|
||||
let submitted: unknown = null;
|
||||
act(() => {
|
||||
submitted = result.current.validate();
|
||||
});
|
||||
expect(submitted).toBeNull();
|
||||
expect(result.current.errors.name).toBeTruthy();
|
||||
expect(result.current.errors.phone).toBeTruthy();
|
||||
expect(result.current.errors.account).toBeTruthy();
|
||||
expect(result.current.errors.departmentId).toBeTruthy();
|
||||
expect(result.current.errors.roleIds).toBeTruthy();
|
||||
});
|
||||
|
||||
it('手机号与账号格式错误使用共享 schema 文案', () => {
|
||||
const { result } = renderHook(() => useNewUserForm());
|
||||
act(() => {
|
||||
result.current.setField('name', '测试成员');
|
||||
result.current.setField('phone', '12345');
|
||||
result.current.setField('account', 'ab');
|
||||
result.current.setField('departmentId', DEPARTMENT_ID);
|
||||
result.current.setField('roleIds', [ROLE_ID]);
|
||||
});
|
||||
act(() => {
|
||||
result.current.validate();
|
||||
});
|
||||
expect(result.current.errors.phone).toContain('11 位手机号');
|
||||
expect(result.current.errors.account).toBeTruthy();
|
||||
});
|
||||
|
||||
it('填写合规后返回提交入参(小组优先作为 departmentId)', () => {
|
||||
const { result } = renderHook(() => useNewUserForm());
|
||||
act(() => {
|
||||
result.current.setField('name', '林清');
|
||||
result.current.setField('phone', '13900001111');
|
||||
result.current.setField('account', 'lin.qing');
|
||||
result.current.setField('departmentId', DEPARTMENT_ID);
|
||||
result.current.setField('roleIds', [ROLE_ID]);
|
||||
result.current.setField('remark', ' 入职批次 A ');
|
||||
});
|
||||
let submitted: ReturnType<typeof result.current.validate> = null;
|
||||
act(() => {
|
||||
submitted = result.current.validate();
|
||||
});
|
||||
expect(submitted).toEqual({
|
||||
name: '林清',
|
||||
phone: '13900001111',
|
||||
account: 'lin.qing',
|
||||
password: expect.any(String) as string,
|
||||
departmentId: DEPARTMENT_ID,
|
||||
roleIds: [ROLE_ID],
|
||||
remark: '入职批次 A',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { type CreateUserInput, createUserInputSchema } from '@lingxi/shared';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { generateInitialPassword } from './user-new-password';
|
||||
|
||||
export interface NewUserFormValues {
|
||||
name: string;
|
||||
phone: string;
|
||||
account: string;
|
||||
password: string;
|
||||
departmentId: string;
|
||||
groupId: string;
|
||||
roleIds: string[];
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export type NewUserField = 'name' | 'phone' | 'account' | 'password' | 'departmentId' | 'roleIds';
|
||||
|
||||
export interface NewUserFormState {
|
||||
values: NewUserFormValues;
|
||||
errors: Partial<Record<NewUserField, string>>;
|
||||
setField: <K extends keyof NewUserFormValues>(field: K, value: NewUserFormValues[K]) => void;
|
||||
regeneratePassword: () => void;
|
||||
/** 校验通过返回提交入参,否则写回字段错误并返回 null */
|
||||
validate: () => CreateUserInput | null;
|
||||
}
|
||||
|
||||
function initialValues(): NewUserFormValues {
|
||||
return {
|
||||
name: '',
|
||||
phone: '',
|
||||
account: '',
|
||||
password: generateInitialPassword(),
|
||||
departmentId: '',
|
||||
groupId: '',
|
||||
roleIds: [],
|
||||
remark: '',
|
||||
};
|
||||
}
|
||||
|
||||
function toInput(values: NewUserFormValues): CreateUserInput {
|
||||
const departmentId = values.groupId === '' ? values.departmentId : values.groupId;
|
||||
const input: CreateUserInput = {
|
||||
name: values.name.trim(),
|
||||
phone: values.phone.trim(),
|
||||
account: values.account.trim(),
|
||||
password: values.password,
|
||||
departmentId,
|
||||
roleIds: values.roleIds,
|
||||
};
|
||||
const remark = values.remark.trim();
|
||||
if (remark !== '') {
|
||||
input.remark = remark;
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
/** 新建用户表单:与后端共用 createUserInputSchema 做同源校验 */
|
||||
export function useNewUserForm(): NewUserFormState {
|
||||
const [values, setValues] = useState<NewUserFormValues>(initialValues);
|
||||
const [errors, setErrors] = useState<Partial<Record<NewUserField, string>>>({});
|
||||
|
||||
const setField = <K extends keyof NewUserFormValues>(
|
||||
field: K,
|
||||
value: NewUserFormValues[K],
|
||||
): void => {
|
||||
setValues((current) => ({ ...current, [field]: value }));
|
||||
if (field in errors) {
|
||||
setErrors((current) => ({ ...current, [field]: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
const validate = (): CreateUserInput | null => {
|
||||
const base = toInput(values);
|
||||
const parsed = createUserInputSchema.safeParse(base);
|
||||
if (parsed.success) {
|
||||
setErrors({});
|
||||
return parsed.data;
|
||||
}
|
||||
const next: Partial<Record<NewUserField, string>> = {};
|
||||
for (const issue of parsed.error.issues) {
|
||||
const field = issue.path[0];
|
||||
if (typeof field === 'string' && field in base) {
|
||||
next[field as NewUserField] = issue.message;
|
||||
}
|
||||
}
|
||||
setErrors(next);
|
||||
return null;
|
||||
};
|
||||
|
||||
return {
|
||||
values,
|
||||
errors,
|
||||
setField,
|
||||
regeneratePassword: () => {
|
||||
setField('password', generateInitialPassword());
|
||||
},
|
||||
validate,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { UserDetail, UserSummary } from '@lingxi/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { displayPhone, formatDateTime, userInitial, userOrgLabel } from './user-display';
|
||||
|
||||
const base: UserSummary = {
|
||||
id: '01HZZZZZZZZZZZZZZZZZZZZZZZ',
|
||||
name: '陈默',
|
||||
phoneMasked: '138****2046',
|
||||
account: 'chen.mo',
|
||||
departmentId: '01HZZZZZZZZZZZZZZZZZZZZZZ1',
|
||||
departmentName: '内容生产中心',
|
||||
groupId: null,
|
||||
groupName: '素材一组',
|
||||
roles: [],
|
||||
status: 'active',
|
||||
lastLoginAt: '2026-11-02T09:41:00.000Z',
|
||||
createdAt: '2026-09-30T18:40:00.000Z',
|
||||
};
|
||||
|
||||
const detail: UserDetail = {
|
||||
...base,
|
||||
phone: '13800002046',
|
||||
mustChangePassword: false,
|
||||
updatedAt: '2026-11-02T09:41:00.000Z',
|
||||
};
|
||||
|
||||
describe('成员展示策略', () => {
|
||||
it('列表手机号始终掩码,详情仅在 user:manage 时展示全号', () => {
|
||||
expect(displayPhone(base, false)).toBe('138****2046');
|
||||
expect(displayPhone(base, true)).toBe('138****2046');
|
||||
expect(displayPhone(detail, true)).toBe('13800002046');
|
||||
expect(displayPhone(detail, false)).toBe('138****2046');
|
||||
});
|
||||
|
||||
it('空时间显示占位符,非法时间不崩溃', () => {
|
||||
expect(formatDateTime(null)).toBe('—');
|
||||
expect(formatDateTime(undefined)).toBe('—');
|
||||
expect(formatDateTime('not-a-date')).toBe('—');
|
||||
expect(formatDateTime('2026-11-02T09:41:00.000Z')).toMatch(/2026-11-02/);
|
||||
});
|
||||
|
||||
it('头像取姓氏首字,组织副标题小组优先', () => {
|
||||
expect(userInitial(' 陈默 ')).toBe('陈');
|
||||
expect(userOrgLabel(base)).toBe('素材一组');
|
||||
expect(userOrgLabel({ ...base, groupName: null })).toBe('内容生产中心');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { UserStatus, UserSummary } from '@lingxi/shared';
|
||||
|
||||
export const USER_STATUS_LABEL: Record<UserStatus, string> = {
|
||||
active: '在职',
|
||||
disabled: '停用',
|
||||
};
|
||||
|
||||
const DATE_TIME_FORMATTER = new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
/** ISO 时间 → `YYYY-MM-DD HH:mm`;空值显示占位符 */
|
||||
export function formatDateTime(value: string | null | undefined): string {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '—';
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '—';
|
||||
}
|
||||
return DATE_TIME_FORMATTER.format(date).replaceAll('/', '-');
|
||||
}
|
||||
|
||||
/** 手机号展示策略:有 user:manage 且后端返回全号时展示全号,否则始终展示掩码 */
|
||||
export function displayPhone(
|
||||
user: Pick<UserSummary, 'phoneMasked'> & { phone?: string },
|
||||
canManage: boolean,
|
||||
): string {
|
||||
if (canManage && user.phone !== undefined && user.phone !== '') {
|
||||
return user.phone;
|
||||
}
|
||||
return user.phoneMasked;
|
||||
}
|
||||
|
||||
export function userInitial(name: string): string {
|
||||
return name.trim().slice(0, 1);
|
||||
}
|
||||
|
||||
/** 列表/详情副标题:小组优先,无小组时展示部门 */
|
||||
export function userOrgLabel(user: Pick<UserSummary, 'departmentName' | 'groupName'>): string {
|
||||
return user.groupName ?? user.departmentName;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { DepartmentNode } from '@lingxi/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
childDepartments,
|
||||
DEFAULT_USER_LIST_FILTERS,
|
||||
departmentNameOf,
|
||||
parseUserSortValue,
|
||||
toDepartmentOptions,
|
||||
topLevelDepartments,
|
||||
toRoleOptions,
|
||||
toUserListQueryInput,
|
||||
} from './user-filters';
|
||||
|
||||
const ROOT_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
|
||||
const GROUP_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ2';
|
||||
const OTHER_ROOT_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ3';
|
||||
|
||||
const departments: DepartmentNode[] = [
|
||||
{
|
||||
id: ROOT_ID,
|
||||
name: '内容生产中心',
|
||||
parentId: null,
|
||||
leader: null,
|
||||
memberCount: 19,
|
||||
createdAt: '2026-09-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: GROUP_ID,
|
||||
name: '素材一组',
|
||||
parentId: ROOT_ID,
|
||||
leader: null,
|
||||
memberCount: 9,
|
||||
createdAt: '2026-09-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: OTHER_ROOT_ID,
|
||||
name: '交付中心',
|
||||
parentId: null,
|
||||
leader: null,
|
||||
memberCount: 14,
|
||||
createdAt: '2026-09-01T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
describe('用户列表筛选参数', () => {
|
||||
it('排序值拆成 sort/order', () => {
|
||||
expect(parseUserSortValue('last_login:desc')).toEqual({ sort: 'last_login', order: 'desc' });
|
||||
expect(parseUserSortValue('created_at:asc')).toEqual({ sort: 'created_at', order: 'asc' });
|
||||
});
|
||||
|
||||
it('默认筛选:在职 + 最近登录倒序 + 每页 10 条', () => {
|
||||
expect(toUserListQueryInput(DEFAULT_USER_LIST_FILTERS)).toEqual({
|
||||
limit: 10,
|
||||
status: 'active',
|
||||
sort: 'last_login',
|
||||
order: 'desc',
|
||||
});
|
||||
});
|
||||
|
||||
it('关键词 trim,部门/角色与游标按需带上', () => {
|
||||
expect(
|
||||
toUserListQueryInput(
|
||||
{
|
||||
keyword: ' 陈默 ',
|
||||
departmentId: ROOT_ID,
|
||||
roleId: GROUP_ID,
|
||||
status: 'all',
|
||||
sort: 'created_at:asc',
|
||||
},
|
||||
'cursor-1',
|
||||
),
|
||||
).toEqual({
|
||||
limit: 10,
|
||||
status: 'all',
|
||||
sort: 'created_at',
|
||||
order: 'asc',
|
||||
keyword: '陈默',
|
||||
department_id: ROOT_ID,
|
||||
role_id: GROUP_ID,
|
||||
cursor: 'cursor-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('部门两级拆分与选项生成', () => {
|
||||
expect(topLevelDepartments(departments).map((node) => node.name)).toEqual([
|
||||
'内容生产中心',
|
||||
'交付中心',
|
||||
]);
|
||||
expect(childDepartments(departments, ROOT_ID).map((node) => node.name)).toEqual(['素材一组']);
|
||||
expect(childDepartments(departments, OTHER_ROOT_ID)).toEqual([]);
|
||||
expect(departmentNameOf(departments, GROUP_ID)).toBe('素材一组');
|
||||
expect(departmentNameOf(departments, 'missing')).toBeNull();
|
||||
expect(toDepartmentOptions(departments)).toEqual([
|
||||
{ label: '内容生产中心(19 人)', value: ROOT_ID },
|
||||
{ label: '交付中心(14 人)', value: OTHER_ROOT_ID },
|
||||
]);
|
||||
});
|
||||
|
||||
it('角色选项标注内置角色', () => {
|
||||
expect(
|
||||
toRoleOptions([
|
||||
{
|
||||
id: ROOT_ID,
|
||||
code: 'super_admin',
|
||||
name: '超级管理员',
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
id: GROUP_ID,
|
||||
code: 'designer',
|
||||
name: '设计师',
|
||||
isSystem: false,
|
||||
},
|
||||
]),
|
||||
).toEqual([
|
||||
{ label: '超级管理员(内置)', value: ROOT_ID },
|
||||
{ label: '设计师', value: GROUP_ID },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { DepartmentNode, RoleSummary, UserListStatus } from '@lingxi/shared';
|
||||
|
||||
import type { UserListQueryInput } from '@/api/users';
|
||||
|
||||
/** 排序值:`<字段>:<方向>`,与后端 query 的 sort/order 一一对应 */
|
||||
export type UserSortValue =
|
||||
'last_login:desc' | 'last_login:asc' | 'created_at:desc' | 'created_at:asc';
|
||||
|
||||
export const USER_SORT_OPTIONS: readonly { value: UserSortValue; label: string }[] = [
|
||||
{ value: 'last_login:desc', label: '最近登录 · 新→旧' },
|
||||
{ value: 'last_login:asc', label: '最近登录 · 旧→新' },
|
||||
{ value: 'created_at:desc', label: '创建时间 · 新→旧' },
|
||||
{ value: 'created_at:asc', label: '创建时间 · 旧→新' },
|
||||
];
|
||||
|
||||
export const USER_STATUS_OPTIONS: readonly { value: UserListStatus; label: string }[] = [
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'active', label: '在职' },
|
||||
{ value: 'disabled', label: '停用' },
|
||||
];
|
||||
|
||||
export const USER_LIST_PAGE_SIZE = 10;
|
||||
|
||||
/** 列表筛选状态(keyword 为输入值,其余为已生效值) */
|
||||
export interface UserListFilters {
|
||||
keyword: string;
|
||||
departmentId: string;
|
||||
roleId: string;
|
||||
status: UserListStatus;
|
||||
sort: UserSortValue;
|
||||
}
|
||||
|
||||
export const DEFAULT_USER_LIST_FILTERS: UserListFilters = {
|
||||
keyword: '',
|
||||
departmentId: '',
|
||||
roleId: '',
|
||||
status: 'active',
|
||||
sort: 'last_login:desc',
|
||||
};
|
||||
|
||||
/** `last_login:desc` → `{ sort: 'last_login', order: 'desc' }` */
|
||||
export function parseUserSortValue(value: UserSortValue): {
|
||||
sort: 'last_login' | 'created_at';
|
||||
order: 'asc' | 'desc';
|
||||
} {
|
||||
const [sort, order] = value.split(':');
|
||||
return {
|
||||
sort: sort === 'created_at' ? 'created_at' : 'last_login',
|
||||
order: order === 'asc' ? 'asc' : 'desc',
|
||||
};
|
||||
}
|
||||
|
||||
/** 筛选状态 → GET /users query(空值不传,部门/角色为空表示不限) */
|
||||
export function toUserListQueryInput(
|
||||
filters: UserListFilters,
|
||||
cursor?: string,
|
||||
): UserListQueryInput {
|
||||
const { sort, order } = parseUserSortValue(filters.sort);
|
||||
const query: UserListQueryInput = {
|
||||
limit: USER_LIST_PAGE_SIZE,
|
||||
status: filters.status,
|
||||
sort,
|
||||
order,
|
||||
};
|
||||
if (cursor !== undefined && cursor !== '') {
|
||||
query.cursor = cursor;
|
||||
}
|
||||
if (filters.keyword.trim() !== '') {
|
||||
query.keyword = filters.keyword.trim();
|
||||
}
|
||||
if (filters.departmentId !== '') {
|
||||
query.department_id = filters.departmentId;
|
||||
}
|
||||
if (filters.roleId !== '') {
|
||||
query.role_id = filters.roleId;
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
/** 一级部门(父级为 null):列表筛选与新建表单的「所属部门」 */
|
||||
export function topLevelDepartments(nodes: readonly DepartmentNode[]): DepartmentNode[] {
|
||||
return nodes.filter((node) => node.parentId === null);
|
||||
}
|
||||
|
||||
/** 某部门下的小组;部门直属时返回空数组 */
|
||||
export function childDepartments(
|
||||
nodes: readonly DepartmentNode[],
|
||||
departmentId: string,
|
||||
): DepartmentNode[] {
|
||||
return nodes.filter((node) => node.parentId === departmentId);
|
||||
}
|
||||
|
||||
/** 部门 id → 名称(新建/编辑回显用) */
|
||||
export function departmentNameOf(
|
||||
nodes: readonly DepartmentNode[],
|
||||
departmentId: string,
|
||||
): string | null {
|
||||
return nodes.find((node) => node.id === departmentId)?.name ?? null;
|
||||
}
|
||||
|
||||
/** Select 选项:一级部门(含人数) */
|
||||
export function toDepartmentOptions(
|
||||
nodes: readonly DepartmentNode[],
|
||||
): { label: string; value: string }[] {
|
||||
return topLevelDepartments(nodes).map((node) => ({
|
||||
label: `${node.name}(${String(node.memberCount)} 人)`,
|
||||
value: node.id,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Select 选项:角色 */
|
||||
export function toRoleOptions(roles: readonly RoleSummary[]): { label: string; value: string }[] {
|
||||
return roles.map((role) => ({
|
||||
label: role.isSystem ? `${role.name}(内置)` : role.name,
|
||||
value: role.id,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { passwordSchema } from '@lingxi/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { generateInitialPassword } from './user-new-password';
|
||||
|
||||
describe('初始密码生成', () => {
|
||||
it('生成的密码符合共享密码策略', () => {
|
||||
for (let index = 0; index < 200; index += 1) {
|
||||
const password = generateInitialPassword();
|
||||
expect(passwordSchema.safeParse(password).success, password).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('多次生成结果不同(随机性)', () => {
|
||||
const samples = new Set(Array.from({ length: 20 }, () => generateInitialPassword()));
|
||||
expect(samples.size).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
const UPPER = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
|
||||
const LOWER = 'abcdefghijkmnpqrstuvwxyz';
|
||||
const DIGITS = '23456789';
|
||||
const SYMBOLS = '@#$';
|
||||
|
||||
function pick(source: string): string {
|
||||
return source.charAt(Math.floor(Math.random() * source.length));
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成符合密码策略的初始密码:8–64 位、同时包含字母与数字。
|
||||
* 管理员可重新生成后通过短信/线下方式交给成员,首次登录强制修改。
|
||||
*/
|
||||
export function generateInitialPassword(): string {
|
||||
const head = pick(UPPER) + pick(LOWER) + pick(DIGITS);
|
||||
const tail = [pick(LOWER), pick(DIGITS), pick(UPPER), pick(SYMBOLS), pick(DIGITS), pick(LOWER)];
|
||||
return `${head}Lx${tail.join('')}`;
|
||||
}
|
||||
+49
-1
@@ -1,5 +1,11 @@
|
||||
/* eslint-disable react-refresh/only-export-components -- 路由配置模块,lazy 组件仅作为路由元素,不做热更新边界 */
|
||||
import { Permission } from '@lingxi/shared';
|
||||
import { PageSkeleton } from '@lingxi/ui';
|
||||
import { lazy, type ReactNode, Suspense } from 'react';
|
||||
import { createBrowserRouter } from 'react-router';
|
||||
|
||||
import { RequireAuth } from '@/components/auth/RequireAuth';
|
||||
import { RequirePermission } from '@/components/auth/RequirePermission';
|
||||
import { AppLayout } from '@/layout/AppLayout';
|
||||
import { ForbiddenPage } from '@/pages/errors/ForbiddenPage';
|
||||
import { NotFoundPage } from '@/pages/errors/NotFoundPage';
|
||||
@@ -7,6 +13,20 @@ import { ServerErrorPage } from '@/pages/errors/ServerErrorPage';
|
||||
import { UnauthorizedPage } from '@/pages/errors/UnauthorizedPage';
|
||||
import { ROUTES } from '@/routes';
|
||||
|
||||
const UsersPage = lazy(async () => {
|
||||
const module = await import('@/pages/users/UsersPage');
|
||||
return { default: module.UsersPage };
|
||||
});
|
||||
|
||||
const UserNewPage = lazy(async () => {
|
||||
const module = await import('@/pages/users/UserNewPage');
|
||||
return { default: module.UserNewPage };
|
||||
});
|
||||
|
||||
function withSkeleton(node: ReactNode): ReactNode {
|
||||
return <Suspense fallback={<PageSkeleton rows={8} />}>{node}</Suspense>;
|
||||
}
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: ROUTES.login,
|
||||
@@ -16,7 +36,11 @@ export const router = createBrowserRouter([
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
element: <AppLayout />,
|
||||
element: (
|
||||
<RequireAuth>
|
||||
<AppLayout />
|
||||
</RequireAuth>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
@@ -24,6 +48,30 @@ export const router = createBrowserRouter([
|
||||
Component: (await import('@/pages/home/HomePage')).default,
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
element: (
|
||||
<RequirePermission permission={Permission.USER_VIEW}>
|
||||
{withSkeleton(<UsersPage />)}
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'users/new',
|
||||
element: (
|
||||
<RequirePermission permission={Permission.USER_MANAGE}>
|
||||
{withSkeleton(<UserNewPage />)}
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'users/:id',
|
||||
element: (
|
||||
<RequirePermission permission={Permission.USER_VIEW}>
|
||||
{withSkeleton(<UsersPage />)}
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: ':moduleKey/:itemKey?',
|
||||
lazy: async () => ({
|
||||
|
||||
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user