diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 1e0a735..c8e8b80 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -36,6 +36,7 @@ import { TenantModule } from './tenant/tenant.module'; import { SuperAdminModule } from './super-admin/super-admin.module'; import { AdminModule } from './admin/admin.module'; import { FeishuModule } from './feishu/feishu.module'; +import { PermissionModule } from './auth/permission/permission.module'; @Module({ imports: [ @@ -82,6 +83,7 @@ import { FeishuModule } from './feishu/feishu.module'; SuperAdminModule, AdminModule, FeishuModule, + PermissionModule, ], controllers: [AppController], providers: [ diff --git a/server/src/auth/permission/permission.constants.ts b/server/src/auth/permission/permission.constants.ts new file mode 100644 index 0000000..90f3836 --- /dev/null +++ b/server/src/auth/permission/permission.constants.ts @@ -0,0 +1,108 @@ +/** + * 所有可用权限的常量定义 + * 格式: resource:action + * + * 分类说明: + * - user: 用户管理 + * - tenant: 租户管理 + * - kb: 知识库 + * - assess: 考核评估 + * - model: 模型配置 + * - plugin: 插件管理 + * - settings: 系统设置 + */ + +export const PERMISSIONS = { + // ── 用户管理 ── + USER_VIEW: 'user:view', + USER_CREATE: 'user:create', + USER_EDIT: 'user:edit', + USER_DELETE: 'user:delete', + USER_ROLE: 'user:role', // 修改他人角色/权限 + USER_PASSWORD: 'user:password', // 重置他人密码 + + // ── 租户管理 ── + TENANT_VIEW: 'tenant:view', + TENANT_CREATE: 'tenant:create', + TENANT_EDIT: 'tenant:edit', + TENANT_DELETE: 'tenant:delete', + TENANT_MEMBERS: 'tenant:members', + + // ── 知识库 ── + KB_VIEW: 'kb:view', + KB_CREATE: 'kb:create', + KB_EDIT: 'kb:edit', + KB_DELETE: 'kb:delete', + KB_PUBLISH: 'kb:publish', + + // ── 考核评估 ── + ASSESS_VIEW: 'assess:view', + ASSESS_MANAGE: 'assess:manage', + ASSESS_TEMPLATE: 'assess:template', + ASSESS_BANK: 'assess:bank', + + // ── 模型配置 ── + MODEL_VIEW: 'model:view', + MODEL_CONFIG: 'model:config', + + // ── 插件管理 ── + PLUGIN_VIEW: 'plugin:view', + PLUGIN_MANAGE: 'plugin:manage', + + // ── 系统设置 ── + SETTINGS_VIEW: 'settings:view', + SETTINGS_SYSTEM: 'settings:system', +} as const; + +export type PermissionKey = (typeof PERMISSIONS)[keyof typeof PERMISSIONS]; +export const ALL_PERMISSIONS = Object.values(PERMISSIONS) as PermissionKey[]; + +/** 权限分类元数据——给前端渲染用 */ +export interface PermissionMeta { + key: PermissionKey; + category: string; + label: string; + description: string; +} + +export const PERMISSION_META: PermissionMeta[] = [ + // ── 用户管理 ── + { key: PERMISSIONS.USER_VIEW, category: '用户管理', label: '查看用户', description: '查看用户列表和基本信息' }, + { key: PERMISSIONS.USER_CREATE, category: '用户管理', label: '创建用户', description: '添加新用户到系统' }, + { key: PERMISSIONS.USER_EDIT, category: '用户管理', label: '编辑用户', description: '修改用户基本信息' }, + { key: PERMISSIONS.USER_DELETE, category: '用户管理', label: '删除用户', description: '从系统删除用户' }, + { key: PERMISSIONS.USER_ROLE, category: '用户管理', label: '管理角色', description: '修改用户角色和权限' }, + { key: PERMISSIONS.USER_PASSWORD, category: '用户管理', label: '重置密码', description: '重置其他用户的密码' }, + + // ── 租户管理 ── + { key: PERMISSIONS.TENANT_VIEW, category: '租户管理', label: '查看租户', description: '查看租户信息和成员' }, + { key: PERMISSIONS.TENANT_CREATE, category: '租户管理', label: '创建租户', description: '创建新的租户' }, + { key: PERMISSIONS.TENANT_EDIT, category: '租户管理', label: '编辑租户', description: '修改租户设置' }, + { key: PERMISSIONS.TENANT_DELETE, category: '租户管理', label: '删除租户', description: '删除租户' }, + { key: PERMISSIONS.TENANT_MEMBERS, category: '租户管理', label: '管理成员', description: '添加/移除租户成员' }, + + // ── 知识库 ── + { key: PERMISSIONS.KB_VIEW, category: '知识库', label: '查看知识库', description: '查看知识库内容' }, + { key: PERMISSIONS.KB_CREATE, category: '知识库', label: '创建知识库', description: '创建新的知识库' }, + { key: PERMISSIONS.KB_EDIT, category: '知识库', label: '编辑知识库', description: '编辑知识库内容' }, + { key: PERMISSIONS.KB_DELETE, category: '知识库', label: '删除知识库', description: '删除知识库' }, + { key: PERMISSIONS.KB_PUBLISH, category: '知识库', label: '发布知识库', description: '将知识库发布上线' }, + + // ── 考核评估 ── + { key: PERMISSIONS.ASSESS_VIEW, category: '考核评估', label: '查看考核', description: '查看考核结果和报告' }, + { key: PERMISSIONS.ASSESS_MANAGE, category: '考核评估', label: '管理考核', description: '管理考核会话' }, + { key: PERMISSIONS.ASSESS_TEMPLATE, category: '考核评估', label: '管理模板', description: '创建和编辑考核模板' }, + { key: PERMISSIONS.ASSESS_BANK, category: '考核评估', label: '管理题库', description: '管理题库内容' }, + + // ── 模型配置 ── + { key: PERMISSIONS.MODEL_VIEW, category: '模型配置', label: '查看模型', description: '查看模型配置' }, + { key: PERMISSIONS.MODEL_CONFIG, category: '模型配置', label: '配置模型', description: '修改模型配置' }, + + // ── 插件管理 ── + { key: PERMISSIONS.PLUGIN_VIEW, category: '插件管理', label: '查看插件', description: '查看插件列表' }, + { key: PERMISSIONS.PLUGIN_MANAGE, category: '插件管理', label: '管理插件', description: '启停和配置插件' }, + + // ── 系统设置 ── + { key: PERMISSIONS.SETTINGS_VIEW, category: '系统设置', label: '查看设置', description: '查看系统设置' }, + { key: PERMISSIONS.SETTINGS_SYSTEM, category: '系统设置', label: '系统设置', description: '修改系统级设置(仅超级管理员)' }, +]; diff --git a/server/src/auth/permission/permission.controller.ts b/server/src/auth/permission/permission.controller.ts new file mode 100644 index 0000000..6477678 --- /dev/null +++ b/server/src/auth/permission/permission.controller.ts @@ -0,0 +1,30 @@ +import { Controller, Get, Request, UseGuards } from '@nestjs/common'; +import { PermissionService } from './permission.service'; +import { CombinedAuthGuard } from '../combined-auth.guard'; + +@Controller('permissions') +@UseGuards(CombinedAuthGuard) +export class PermissionController { + constructor(private readonly permissionService: PermissionService) {} + + /** 获取所有可用权限(含分类) */ + @Get() + getAll() { + return this.permissionService.getPermissionsByCategory(); + } + + /** 获取所有权限的扁平元数据列表 */ + @Get('meta') + getMeta() { + return this.permissionService.getAllPermissionMeta(); + } + + /** 获取当前用户在活动租户下的权限集 */ + @Get('mine') + async getMine(@Request() req) { + const userId = req.user.id; + const tenantId = req.tenantId || req.user.tenantId; + const perms = await this.permissionService.getUserPermissions(userId, tenantId); + return { permissions: [...perms] }; + } +} diff --git a/server/src/auth/permission/permission.decorator.ts b/server/src/auth/permission/permission.decorator.ts new file mode 100644 index 0000000..b501494 --- /dev/null +++ b/server/src/auth/permission/permission.decorator.ts @@ -0,0 +1,16 @@ +import { SetMetadata } from '@nestjs/common'; + +export const PERMISSIONS_KEY = 'permissions'; + +/** + * 权限装饰器——标记路由需要的权限 + * 多个权限之间为 OR 关系(有任一匹配即可) + * + * @example + * ```typescript + * @Permission('user:view') // 需要 user:view 权限 + * @Permission('user:create', 'user:edit') // 需要 user:create 或 user:edit + * ``` + */ +export const Permission = (...permissions: string[]) => + SetMetadata(PERMISSIONS_KEY, permissions); diff --git a/server/src/auth/permission/permission.guard.ts b/server/src/auth/permission/permission.guard.ts new file mode 100644 index 0000000..456043e --- /dev/null +++ b/server/src/auth/permission/permission.guard.ts @@ -0,0 +1,49 @@ +import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { PermissionService } from './permission.service'; +import { PERMISSIONS_KEY } from './permission.decorator'; + +/** + * 权限守卫——配合 @Permission() 装饰器使用 + * + * 在 CombinedAuthGuard 和 RolesGuard 之后运行 + * 检查 request.user 是否有 @Permission() 指定的任一权限 + */ +@Injectable() +export class PermissionsGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly permissionService: PermissionService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const requiredPermissions = this.reflector.getAllAndOverride( + PERMISSIONS_KEY, + [context.getHandler(), context.getClass()], + ); + + if (!requiredPermissions || requiredPermissions.length === 0) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const user = request.user; + + if (!user) return false; + + const userId = user.id; + const tenantId = request.tenantId || user.tenantId; + if (!userId || !tenantId) return false; + + const userPermissions = await this.permissionService.getUserPermissions(userId, tenantId); + + // OR 模式:任一权限匹配即可 + const hasPermission = requiredPermissions.some(p => userPermissions.has(p)); + + if (!hasPermission) { + throw new ForbiddenException(`需要权限: ${requiredPermissions.join(', ')}`); + } + + return true; + } +} diff --git a/server/src/auth/permission/permission.module.ts b/server/src/auth/permission/permission.module.ts new file mode 100644 index 0000000..5a793ec --- /dev/null +++ b/server/src/auth/permission/permission.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Role } from './role.entity'; +import { RolePermission } from './role-permission.entity'; +import { TenantMember } from '../../tenant/tenant-member.entity'; +import { User } from '../../user/user.entity'; +import { PermissionService } from './permission.service'; +import { PermissionController } from './permission.controller'; +import { RoleController } from './role.controller'; +import { PermissionsGuard } from './permission.guard'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Role, RolePermission, TenantMember, User]), + ], + controllers: [PermissionController, RoleController], + providers: [PermissionService, PermissionsGuard], + exports: [PermissionService, PermissionsGuard], +}) +export class PermissionModule {} diff --git a/server/src/auth/permission/permission.service.ts b/server/src/auth/permission/permission.service.ts new file mode 100644 index 0000000..8dbde2d --- /dev/null +++ b/server/src/auth/permission/permission.service.ts @@ -0,0 +1,247 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Role } from './role.entity'; +import { RolePermission } from './role-permission.entity'; +import { ALL_PERMISSIONS, PERMISSION_META, PermissionKey, PermissionMeta } from './permission.constants'; +import { UserRole } from '../../user/user-role.enum'; +import { TenantMember } from '../../tenant/tenant-member.entity'; +import { User } from '../../user/user.entity'; +import { ConfigService } from '@nestjs/config'; + +/** + * 角色预设——系统内置角色默认挂载的权限 + */ +const ROLE_DEFAULT_PERMISSIONS: Record = { + [UserRole.SUPER_ADMIN]: [...ALL_PERMISSIONS], + + [UserRole.TENANT_ADMIN]: [ + // 用户管理(不含删除和角色管理——安全考虑) + 'user:view', 'user:create', 'user:edit', 'user:password', + // 租户管理(只能查看和编辑自己的) + 'tenant:view', 'tenant:edit', 'tenant:members', + // 知识库 + 'kb:view', 'kb:create', 'kb:edit', 'kb:delete', 'kb:publish', + // 考核评估 + 'assess:view', 'assess:manage', 'assess:template', 'assess:bank', + // 模型配置 + 'model:view', 'model:config', + // 插件 + 'plugin:view', 'plugin:manage', + // 设置 + 'settings:view', + ], + + [UserRole.USER]: [ + 'kb:view', 'kb:create', 'kb:edit', + 'assess:view', + 'plugin:view', + ], +}; + +@Injectable() +export class PermissionService implements OnModuleInit { + private readonly logger = new Logger(PermissionService.name); + + constructor( + @InjectRepository(Role) + private readonly roleRepository: Repository, + @InjectRepository(RolePermission) + private readonly rolePermissionRepository: Repository, + @InjectRepository(TenantMember) + private readonly tenantMemberRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, + private readonly configService: ConfigService, + ) {} + + /** + * 启动时自动种子化系统角色和权限 + */ + async onModuleInit() { + const isTest = this.configService.get('NODE_ENV') === 'test'; + if (isTest) return; + + try { + const existing = await this.roleRepository.count({ where: { isSystem: true } }); + if (existing > 0) { + this.logger.log(`[Permission Seed] ${existing} 个系统角色已存在,跳过`); + return; + } + await this.seedSystemRoles(); + this.logger.log('[Permission Seed] ✅ 系统角色和权限已初始化'); + } catch (err) { + this.logger.error('[Permission Seed] ❌ 初始化失败:', err); + } + } + + private async seedSystemRoles() { + const roles = [ + { name: UserRole.SUPER_ADMIN, baseRole: UserRole.SUPER_ADMIN }, + { name: UserRole.TENANT_ADMIN, baseRole: UserRole.TENANT_ADMIN }, + { name: UserRole.USER, baseRole: UserRole.USER }, + ]; + + for (const r of roles) { + const role = await this.roleRepository.save({ + name: r.name, + description: this.getRoleDescription(r.name as UserRole), + isSystem: true, + baseRole: r.baseRole, + tenantId: null, + }); + + const perms = ROLE_DEFAULT_PERMISSIONS[r.name] || []; + if (perms.length > 0) { + await this.rolePermissionRepository.save( + perms.map(key => ({ roleId: role.id, permissionKey: key })), + ); + } + this.logger.log(` - ${r.name}: ${perms.length} 项权限`); + } + } + + private getRoleDescription(role: UserRole): string { + switch (role) { + case UserRole.SUPER_ADMIN: return '全局超级管理员——拥有系统全部权限'; + case UserRole.TENANT_ADMIN: return '租户管理员——管理本租户内的用户和资源'; + case UserRole.USER: return '普通用户——使用系统功能'; + } + } + + // ──────────── 角色 CRUD ──────────── + + async findAllRoles(tenantId?: string): Promise { + const where: any[] = [{ isSystem: true, tenantId: null }]; + if (tenantId) { + where.push({ tenantId, isSystem: false }); + } + return this.roleRepository.find({ where, order: { isSystem: 'DESC', name: 'ASC' } }); + } + + async findRoleById(id: string): Promise { + return this.roleRepository.findOne({ where: { id } }); + } + + async createRole(name: string, description: string, tenantId: string): Promise { + const existing = await this.roleRepository.findOne({ where: { name } }); + if (existing) throw new Error(`角色名 "${name}" 已存在`); + + return this.roleRepository.save({ + name, + description, + isSystem: false, + baseRole: null, + tenantId, + }); + } + + async updateRole(id: string, data: { name?: string; description?: string }): Promise { + const role = await this.roleRepository.findOne({ where: { id } }); + if (!role) throw new Error('角色不存在'); + if (role.isSystem) throw new Error('系统角色不可编辑'); + + if (data.name) role.name = data.name; + if (data.description !== undefined) role.description = data.description; + return this.roleRepository.save(role); + } + + async deleteRole(id: string): Promise { + const role = await this.roleRepository.findOne({ where: { id } }); + if (!role) throw new Error('角色不存在'); + if (role.isSystem) throw new Error('系统角色不可删除'); + await this.roleRepository.remove(role); + } + + // ──────────── 权限管理 ──────────── + + async getRolePermissions(roleId: string): Promise { + const rps = await this.rolePermissionRepository.find({ + where: { roleId }, + }); + return rps.map(rp => rp.permissionKey); + } + + async setRolePermissions(roleId: string, permissionKeys: string[]): Promise { + const role = await this.roleRepository.findOne({ where: { id: roleId } }); + if (!role) throw new Error('角色不存在'); + + // 验证权限键是否有效 + const valid = ALL_PERMISSIONS; + const invalid = permissionKeys.filter(k => !valid.includes(k as any)); + if (invalid.length > 0) throw new Error(`无效的权限: ${invalid.join(', ')}`); + + // 替换角色的所有权限 + await this.rolePermissionRepository.delete({ roleId }); + if (permissionKeys.length > 0) { + await this.rolePermissionRepository.save( + permissionKeys.map(key => ({ roleId, permissionKey: key })), + ); + } + } + + // ──────────── 用户权限解析 ──────────── + + /** + * 获取用户在指定租户下的最终权限集 + * + * 解析链路: + * 1. 如果是 global admin(isAdmin=true),直接返回所有权限 + * 2. 通过 TenantMember.role → 对应 role → role_permissions + */ + async getUserPermissions(userId: string, tenantId: string): Promise> { + // 检查全局管理员(遗留 isAdmin 字段) + const user = await this.userRepository.findOne({ + where: { id: userId }, + select: ['id', 'isAdmin'], + }); + if (user?.isAdmin) { + const superAdminRole = await this.roleRepository.findOne({ + where: { baseRole: UserRole.SUPER_ADMIN, isSystem: true }, + }); + if (superAdminRole) { + const perms = await this.getRolePermissions(superAdminRole.id); + return new Set(perms); + } + } + + // 通过租户成员角色 + const membership = await this.tenantMemberRepository.findOne({ + where: { userId, tenantId }, + }); + + if (!membership) return new Set(); + + const role = await this.roleRepository.findOne({ + where: { baseRole: membership.role, isSystem: true }, + }); + if (!role) return new Set(); + + const perms = await this.getRolePermissions(role.id); + return new Set(perms); + } + + /** + * 检查用户是否有指定权限 + */ + async checkPermission(userId: string, tenantId: string, permissionKey: string): Promise { + // 先尝试从请求级缓存获取(由 PermissionsGuard 设置) + const perms = await this.getUserPermissions(userId, tenantId); + return perms.has(permissionKey); + } + + // ──────────── 元数据 ──────────── + + getAllPermissionMeta(): PermissionMeta[] { + return PERMISSION_META; + } + + getPermissionsByCategory(): Record { + const grouped: Record = {}; + for (const meta of PERMISSION_META) { + if (!grouped[meta.category]) grouped[meta.category] = []; + grouped[meta.category].push(meta); + } + return grouped; + } +} diff --git a/server/src/auth/permission/role-permission.entity.ts b/server/src/auth/permission/role-permission.entity.ts new file mode 100644 index 0000000..ac9013a --- /dev/null +++ b/server/src/auth/permission/role-permission.entity.ts @@ -0,0 +1,32 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Role } from './role.entity'; + +/** + * 角色-权限关联表 + * 每个角色可以挂载多个权限 + */ +@Entity('role_permissions') +export class RolePermission { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'role_id' }) + roleId: string; + + @ManyToOne(() => Role, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'role_id' }) + role: Role; + + @Column({ name: 'permission_key', length: 50 }) + permissionKey: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/server/src/auth/permission/role.controller.ts b/server/src/auth/permission/role.controller.ts new file mode 100644 index 0000000..a26e1e5 --- /dev/null +++ b/server/src/auth/permission/role.controller.ts @@ -0,0 +1,100 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Request, + UseGuards, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; +import { PermissionService } from './permission.service'; +import { CombinedAuthGuard } from '../combined-auth.guard'; +import { RolesGuard } from '../roles.guard'; +import { Roles } from '../roles.decorator'; +import { UserRole } from '../../user/user-role.enum'; + +@Controller('roles') +@UseGuards(CombinedAuthGuard, RolesGuard) +export class RoleController { + constructor(private readonly permissionService: PermissionService) {} + + /** 列出角色(系统角色 + 租户自定义角色) */ + @Get() + @Roles(UserRole.SUPER_ADMIN, UserRole.TENANT_ADMIN) + async findAll(@Request() req) { + const tenantId = req.tenantId || req.user.tenantId; + return this.permissionService.findAllRoles(tenantId); + } + + /** 获取单个角色 */ + @Get(':id') + @Roles(UserRole.SUPER_ADMIN, UserRole.TENANT_ADMIN) + async findOne(@Param('id') id: string) { + const role = await this.permissionService.findRoleById(id); + if (!role) throw new NotFoundException('角色不存在'); + return role; + } + + /** 创建自定义角色 */ + @Post() + @Roles(UserRole.SUPER_ADMIN, UserRole.TENANT_ADMIN) + async create(@Body() body: { name: string; description?: string }, @Request() req) { + if (!body.name) throw new BadRequestException('角色名不能为空'); + const tenantId = req.tenantId || req.user.tenantId; + try { + return await this.permissionService.createRole(body.name, body.description || '', tenantId); + } catch (err: any) { + throw new BadRequestException(err.message); + } + } + + /** 修改角色基本信息 */ + @Put(':id') + @Roles(UserRole.SUPER_ADMIN, UserRole.TENANT_ADMIN) + async update(@Param('id') id: string, @Body() body: { name?: string; description?: string }) { + try { + return await this.permissionService.updateRole(id, body); + } catch (err: any) { + throw new BadRequestException(err.message); + } + } + + /** 删除自定义角色 */ + @Delete(':id') + @Roles(UserRole.SUPER_ADMIN, UserRole.TENANT_ADMIN) + async remove(@Param('id') id: string) { + try { + await this.permissionService.deleteRole(id); + return { success: true }; + } catch (err: any) { + throw new BadRequestException(err.message); + } + } + + /** 获取角色的权限列表 */ + @Get(':id/permissions') + @Roles(UserRole.SUPER_ADMIN, UserRole.TENANT_ADMIN) + async getPermissions(@Param('id') id: string) { + const perms = await this.permissionService.getRolePermissions(id); + return { permissions: perms }; + } + + /** 设置角色的权限(全量替换) */ + @Put(':id/permissions') + @Roles(UserRole.SUPER_ADMIN, UserRole.TENANT_ADMIN) + async setPermissions(@Param('id') id: string, @Body() body: { permissions: string[] }) { + if (!Array.isArray(body.permissions)) { + throw new BadRequestException('permissions 必须是数组'); + } + try { + await this.permissionService.setRolePermissions(id, body.permissions); + return { success: true, permissions: body.permissions }; + } catch (err: any) { + throw new BadRequestException(err.message); + } + } +} diff --git a/server/src/auth/permission/role.entity.ts b/server/src/auth/permission/role.entity.ts new file mode 100644 index 0000000..2ed04be --- /dev/null +++ b/server/src/auth/permission/role.entity.ts @@ -0,0 +1,49 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { UserRole } from '../../user/user-role.enum'; + +/** + * 角色表 + * is_system = true: 系统内置角色(SUPER_ADMIN/TENANT_ADMIN/USER),不可删除 + * tenant_id = null: 系统级角色(所有租户可见) + * tenant_id != null: 租户自定义角色 + */ +@Entity('roles') +export class Role { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true, length: 50 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + /** 是否为系统内置角色 */ + @Column({ name: 'is_system', default: false }) + isSystem: boolean; + + /** 关联的内置角色 enum(仅 is_system=true 时有值) */ + @Column({ + name: 'base_role', + type: 'simple-enum', + enum: UserRole, + nullable: true, + }) + baseRole: UserRole | null; + + /** 所属租户:null=系统级,非 null=租户自定义 */ + @Column({ name: 'tenant_id', nullable: true, type: 'text' }) + tenantId: string | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/server/src/user/user.controller.ts b/server/src/user/user.controller.ts index 1ddb84a..aabfa75 100644 --- a/server/src/user/user.controller.ts +++ b/server/src/user/user.controller.ts @@ -20,6 +20,8 @@ import { UpdateUserDto } from './dto/update-user.dto'; import { I18nService } from '../i18n/i18n.service'; import { UserRole } from './user-role.enum'; import { UserSettingService } from './user-setting.service'; +import { Permission } from '../auth/permission/permission.decorator'; +import { PermissionsGuard } from '../auth/permission/permission.guard'; @Controller('users') @UseGuards(CombinedAuthGuard) @@ -92,25 +94,17 @@ export class UserController { } @Get() + @UseGuards(PermissionsGuard) + @Permission('user:view') async findAll( @Request() req, @Query('page') page?: string, @Query('limit') limit?: string, ) { - const callerRole = req.user.role; - if ( - callerRole !== UserRole.SUPER_ADMIN && - callerRole !== UserRole.TENANT_ADMIN - ) { - throw new ForbiddenException( - this.i18nService.getErrorMessage('adminOnlyViewList'), - ); - } - const p = page ? parseInt(page) : undefined; const l = limit ? parseInt(limit) : undefined; - if (callerRole === UserRole.SUPER_ADMIN) { + if (req.user.role === UserRole.SUPER_ADMIN) { return this.userService.findAll(p, l); } else { return this.userService.findByTenantId(req.user.tenantId, p, l); @@ -144,17 +138,9 @@ export class UserController { } @Post() + @UseGuards(PermissionsGuard) + @Permission('user:create') async createUser(@Request() req, @Body() body: CreateUserDto) { - const callerRole = req.user.role; - if ( - callerRole !== UserRole.SUPER_ADMIN && - callerRole !== UserRole.TENANT_ADMIN - ) { - throw new ForbiddenException( - this.i18nService.getErrorMessage('adminOnlyCreateUser'), - ); - } - const { username, password } = body; if (!username || !password) { @@ -169,16 +155,9 @@ export class UserController { ); } - // All new global users default to non-admin. - // Elevation to Super Admin status is handled separately. + // All new users default to non-admin. let isAdmin = false; - if (callerRole === UserRole.SUPER_ADMIN) { - isAdmin = false; - } else if (callerRole === UserRole.TENANT_ADMIN) { - isAdmin = false; - } - // Pass the calculated params to the service return this.userService.createUser( username, @@ -190,20 +169,14 @@ export class UserController { } @Put(':id') + @UseGuards(PermissionsGuard) + @Permission('user:edit') async updateUser( @Request() req, @Body() body: UpdateUserDto, @Param('id') id: string, ) { const callerRole = req.user.role; - if ( - callerRole !== UserRole.SUPER_ADMIN && - callerRole !== UserRole.TENANT_ADMIN - ) { - throw new ForbiddenException( - this.i18nService.getErrorMessage('adminOnlyUpdateUser'), - ); - } // Get user info to update const userToUpdate = await this.userService.findOneById(id); @@ -228,7 +201,6 @@ export class UserController { } // Role modification is now obsolete on global level. - // If Admin wants to elevate, they set isAdmin property directly. if (body.isAdmin !== undefined && userToUpdate.isAdmin !== body.isAdmin) { if (callerRole !== UserRole.SUPER_ADMIN) { throw new ForbiddenException( @@ -248,16 +220,10 @@ export class UserController { } @Delete(':id') + @UseGuards(PermissionsGuard) + @Permission('user:delete') async deleteUser(@Request() req, @Param('id') id: string) { const callerRole = req.user.role; - if ( - callerRole !== UserRole.SUPER_ADMIN && - callerRole !== UserRole.TENANT_ADMIN - ) { - throw new ForbiddenException( - this.i18nService.getErrorMessage('adminOnlyDeleteUser'), - ); - } // Prevent admin from deleting themselves if (req.user.id === id) { diff --git a/server/src/user/user.module.ts b/server/src/user/user.module.ts index d4d5eb0..e1b06e7 100644 --- a/server/src/user/user.module.ts +++ b/server/src/user/user.module.ts @@ -8,12 +8,14 @@ import { ApiKey } from '../auth/entities/api-key.entity'; import { UserService } from './user.service'; import { UserController } from './user.controller'; import { TenantModule } from '../tenant/tenant.module'; +import { PermissionModule } from '../auth/permission/permission.module'; @Global() @Module({ imports: [ TypeOrmModule.forFeature([User, ApiKey, TenantMember, UserSetting]), TenantModule, + PermissionModule, ], controllers: [UserController], providers: [UserService, UserSettingService], diff --git a/test-multiround.mjs b/test-multiround.mjs index a531cf0..fbfbe2d 100644 --- a/test-multiround.mjs +++ b/test-multiround.mjs @@ -1,7 +1,18 @@ import { chromium } from 'playwright'; const BASE = 'http://localhost:13001'; -const SA_REPLIES = ['需要审查代码质量和安全性', '还要检查逻辑正确性和性能问题']; +const SA_REPLIES = [ + '需要审查代码质量和安全性', // #1 代码审查 + '还要检查逻辑正确性和性能问题', // #2 代码质量 + '用清晰的提示词告诉AI具体需求', // #3 prompt技巧 + '要注意数据安全和隐私保护', // #4 安全 + 'AI协作时要明确分工,人做决策', // #5 协作 + '检查AI生成的内容是否准确', // #6 验证输出 + '要测试边界情况和异常处理', // #7 测试 + '把大任务拆成小步骤和AI沟通', // #8 任务拆分 + '持续学习和更新对AI工具的认识', // #9 学习成长 + '评估成本效益,选最合适的方案', // #10 综合 +]; /** Fill a textarea via native setter + input event (reliable for React controlled inputs) */ async function fillTextarea(page, text) { @@ -24,14 +35,19 @@ async function clickSendButton(page) { }, { timeout: 15000 }); await page.locator('button:has(svg.lucide-send)').last().click({ timeout: 5000 }); } catch { - // Regular click failed (stale/detached element); wait briefly and force await new Promise(r => setTimeout(r, 1000)); await page.locator('button:has(svg.lucide-send)').last().click({ force: true, timeout: 5000 }).catch(() => {}); } } +/** Wait for spinner to disappear */ +async function waitForIdle(page) { + await page.waitForFunction(() => !document.querySelector('.animate-spin'), { timeout: 90000 }).catch(() => {}); + await new Promise(r => setTimeout(r, 1500)); +} + async function run() { - console.log('=== AuraK 考核多轮对话测试 ===\n'); + console.log('=== AuraK 10题考核多轮对话测试 ===\n'); const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); @@ -62,40 +78,67 @@ async function run() { await new Promise(r => setTimeout(r, 2000)); } console.log(' ✅ 第 1 题已出现'); - - // Wait spinner to finish - await page.waitForFunction(() => !document.querySelector('.animate-spin'), { timeout: 60000 }).catch(() => {}); - await new Promise(r => setTimeout(r, 2000)); + await waitForIdle(page); // Answer questions let qIdx = 1; let saCount = 0, followUpCount = 0; - const totalQs = 4; + const totalQs = 10; + const startTime = Date.now(); + // Per-question timeout: 5 minutes (AI generation can be slow) + const Q_TIMEOUT = 300; // 5 min in seconds while (qIdx <= totalQs) { + // Detect question type const state = await page.evaluate(() => { - const choiceBtns = Array.from(document.querySelectorAll('button')) - .filter(b => /^[A-D]/.test(b.textContent || '') && (b.textContent || '').length > 5) - .filter(b => !(b.textContent || '').startsWith('AuraK')) - .filter(b => !(b.textContent || '').startsWith('Admin')); - + const allBtns = Array.from(document.querySelectorAll('button')); + const optionBtns = allBtns.filter(b => + /^[A-D]/.test(b.textContent || '') && (b.textContent || '').length > 5 && + !(b.textContent || '').includes('AuraK') && !(b.textContent || '').includes('Admin') + ); + const confirmBtn = allBtns.find(b => (b.textContent || '').includes('确认答案')); const ta = document.querySelector('textarea'); return { - choiceCount: choiceBtns.length, + choiceCount: optionBtns.length, hasTextarea: ta !== null && ta.offsetParent !== null, - firstChoice: choiceBtns[0]?.textContent || '', + firstChoice: optionBtns[0]?.textContent?.trim().substring(0, 40) || '', + hasConfirmBtn: confirmBtn !== null, busy: document.querySelector('.animate-spin') !== null, + hasQuestion: (document.body.textContent || '').includes('问题 ') || (document.body.textContent || '').includes('Question '), }; }); + // If busy (spinner visible), wait and retry if (state.busy) { - await new Promise(r => setTimeout(r, 2000)); + console.log(` ⏳ AI正在处理...`); + await new Promise(r => setTimeout(r, 3000)); continue; } + // If neither question type detected but question text exists, wait more + if (state.choiceCount === 0 && !state.hasTextarea && state.hasQuestion) { + console.log(` ⏳ 题型未就绪,等待...`); + await new Promise(r => setTimeout(r, 3000)); + continue; + } + + // Read current question text + const questionText = await page.evaluate(() => { + const bubbles = Array.from(document.querySelectorAll('.px-5.py-4')); + for (let i = bubbles.length - 1; i >= 0; i--) { + const el = bubbles[i]; + const text = el.textContent || ''; + if (text.length > 25 && !(el.getAttribute('class') || '').includes('bg-indigo')) { + return text.substring(0, 160); + } + } + return ''; + }); + if (state.choiceCount > 0) { // ── CHOICE ── - console.log(`\n 🟦 第 ${qIdx}/${totalQs} 题 (选择) "${state.firstChoice.substring(0, 30)}..."`); + const qShort = questionText.replace(/\s+/g, ' ').substring(0, 100); + console.log(`\n 🟦 第 ${qIdx}/${totalQs} 题 (选择) "${qShort}..."`); const btns = page.locator('button.w-full.text-left.px-5.py-4'); const count = await btns.count(); @@ -104,12 +147,14 @@ async function run() { await new Promise(r => setTimeout(r, 500)); } - const confirm = page.locator('button:has-text("确认答案")'); - if (await confirm.isVisible().catch(() => false)) { - await confirm.click(); + if (state.hasConfirmBtn) { + await page.locator('button:has-text("确认答案")').click(); console.log(` ✅ 已提交`); } qIdx++; + // After submitting a choice, wait for transition + await waitForIdle(page); + } else if (state.hasTextarea) { // ── SHORT ANSWER ── saCount++; @@ -123,17 +168,15 @@ async function run() { console.log(` ✅ 已提交`); // Wait for grading - await new Promise(r => setTimeout(r, 3000)); - await page.waitForFunction(() => !document.querySelector('.animate-spin'), { timeout: 60000 }).catch(() => {}); - await new Promise(r => setTimeout(r, 2000)); + await waitForIdle(page); - // Check for follow-up: textarea visible again at same question position + // Check for follow-up question const stillTA = await page.evaluate(() => { const ta = document.querySelector('textarea'); return ta !== null && ta.offsetParent !== null; }); - if (stillTA && followUpCount < SA_REPLIES.length - 1) { + if (stillTA) { followUpCount++; const fReply = SA_REPLIES[Math.min(followUpCount, SA_REPLIES.length - 1)]; console.log(` 🔄 AI 追问 #${followUpCount} 已触发!`); @@ -144,33 +187,62 @@ async function run() { await clickSendButton(page); console.log(` ✅ 追问已提交`); - await page.waitForFunction(() => !document.querySelector('.animate-spin'), { timeout: 60000 }).catch(() => {}); - await new Promise(r => setTimeout(r, 2000)); + await waitForIdle(page); } qIdx++; + } else { - // Wait for anything to appear - console.log(` ⏳ 等待第 ${qIdx} 题...`); - await new Promise(r => setTimeout(r, 3000)); + // ── WAITING for question to appear ── + // Check for question text in body + const bodyText = await page.textContent('body').catch(() => ''); + if (bodyText.includes('问题 ') || bodyText.includes('Question ')) { + // Question is there but types not detected yet - wait for spinner then retry + console.log(` ⏳ 第 ${qIdx} 题文本已见,等待组件渲染...`); + await waitForIdle(page); + continue; + } + + // Check if assessment completed + if (bodyText.includes('合格') || bodyText.includes('VERIFIED') || bodyText.includes('LEVEL')) { + console.log(`\n 📋 考核已完成!`); + break; + } + + // Check per-question timeout + const elapsed = Math.round((Date.now() - startTime) / 1000); + if (elapsed > Q_TIMEOUT * qIdx + 120) { + console.log(` ⏰ 第 ${qIdx} 题生成超时,跳过`); + qIdx++; + continue; + } + + console.log(` ⏳ 等待 AI 生成第 ${qIdx} 题...`); + await waitForIdle(page); + await new Promise(r => setTimeout(r, 5000)); continue; } - - // Wait for next question - await page.waitForFunction(() => !document.querySelector('.animate-spin'), { timeout: 30000 }).catch(() => {}); - await new Promise(r => setTimeout(r, 2000)); } - // Results - await new Promise(r => setTimeout(r, 4000)); + // Wait for results page + console.log('\n ⏳ 等待考核结果完成...'); + await waitForIdle(page); + await new Promise(r => setTimeout(r, 5000)); + + const elapsed = Math.round((Date.now() - startTime) / 1000); const body = await page.textContent('body'); const scores = body.match(/\d+\/10/g); + const level = body.match(/LEVEL:\s*(\w+)/i)?.[1] || body.match(/等级[::]\s*(\w+)/)?.[1] || '?'; + const passed = body.includes('合格') || body.includes('VERIFIED'); - console.log(`\n 📊 结果:`); + console.log(`\n 📊 结果 (耗时 ${Math.floor(elapsed/60)}分${elapsed%60}秒):`); + console.log(` 总题数: ${totalQs}`); console.log(` 选择题: ${totalQs - saCount}`); console.log(` 简答题: ${saCount}`); - console.log(` AI追问: ${followUpCount}`); + console.log(` AI追问: ${followUpCount}次`); console.log(` 分数: ${scores ? scores.join(', ') : '无'}`); + console.log(` 等级: ${level}`); + console.log(` ${passed ? '🎉 合格!' : '😅 未合格'}`); if (followUpCount > 0) { console.log(`\n 🎉 多轮对话正常工作!`); @@ -180,6 +252,9 @@ async function run() { console.log(`\n ⚠️ 未遇到简答题,需要确认 shuffle 是否生效。`); } + await page.screenshot({ path: 'assessment-10q-result.png', fullPage: true }); + console.log(' 📸 截图已保存'); + await browser.close(); console.log('\n=== 完成 ==='); } diff --git a/web/components/PermissionGate.tsx b/web/components/PermissionGate.tsx new file mode 100644 index 0000000..4e19793 --- /dev/null +++ b/web/components/PermissionGate.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { usePermissions } from '../src/hooks/usePermissions'; + +interface PermissionGateProps { + /** 需要的权限(OR 关系:有任一即可) */ + permission?: string; + /** 多个权限 OR 关系 */ + any?: string[]; + /** 多个权限 AND 关系(必须全部拥有) */ + all?: string[]; + /** 加载中时显示的内容(默认不显示) */ + fallback?: React.ReactNode; + /** 无权限时显示的内容(默认不显示) */ + denied?: React.ReactNode; + children: React.ReactNode; +} + +/** + * 组件级权限门控 + * 根据用户权限集有条件的渲染子组件 + * + * @example + * ```tsx + * + * + * + * + * + * + * + * ``` + */ +export const PermissionGate: React.FC = ({ + permission, + any, + all, + fallback = null, + denied = null, + children, +}) => { + const { hasPermission, hasAnyPermission, hasAllPermissions, isLoading } = usePermissions(); + + if (isLoading) { + return <>{fallback}; + } + + let granted = false; + + if (permission) { + granted = hasPermission(permission); + } else if (any && any.length > 0) { + granted = hasAnyPermission(...any); + } else if (all && all.length > 0) { + granted = hasAllPermissions(...all); + } else { + // 没有指定权限要求 → 放行 + granted = true; + } + + return <>{granted ? children : denied}; +}; diff --git a/web/components/views/PermissionSettingsView.tsx b/web/components/views/PermissionSettingsView.tsx new file mode 100644 index 0000000..5ee5c9e --- /dev/null +++ b/web/components/views/PermissionSettingsView.tsx @@ -0,0 +1,440 @@ +import React, { useState, useEffect } from 'react'; +import { + Shield, + Plus, + Save, + X, + Edit2, + Trash2, + Loader2, + Check, + Users, + Key, +} from 'lucide-react'; +import { useAuth } from '../../src/contexts/AuthContext'; +import { useConfirm } from '../../contexts/ConfirmContext'; +import { useToast } from '../../contexts/ToastContext'; +import { useLanguage } from '../../contexts/LanguageContext'; +import { cn } from '../../src/utils/cn'; + +interface PermissionMeta { + key: string; + category: string; + label: string; + description: string; +} + +interface Role { + id: string; + name: string; + description?: string; + isSystem: boolean; + baseRole?: string; + tenantId?: string; +} + +/** 按分类分组的权限 */ +interface PermissionsByCategory { + [category: string]: PermissionMeta[]; +} + +export const PermissionSettingsView: React.FC = () => { + const { apiKey, activeTenant } = useAuth(); + const { confirm } = useConfirm(); + const { showError, showSuccess } = useToast(); + const { t } = useLanguage(); + + const [roles, setRoles] = useState([]); + const [allPermissions, setAllPermissions] = useState({}); + const [selectedRoleId, setSelectedRoleId] = useState(null); + const [rolePermissions, setRolePermissions] = useState>(new Set()); + const [isLoadingRoles, setIsLoadingRoles] = useState(true); + const [isLoadingPerms, setIsLoadingPerms] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [showCreateRole, setShowCreateRole] = useState(false); + const [newRoleName, setNewRoleName] = useState(''); + const [newRoleDesc, setNewRoleDesc] = useState(''); + const [editingPermissions, setEditingPermissions] = useState>(new Set()); + const [hasChanges, setHasChanges] = useState(false); + + const headers = { + 'x-api-key': apiKey, + 'x-tenant-id': activeTenant?.tenantId || '', + }; + + // 加载角色列表 + const fetchRoles = async () => { + try { + setIsLoadingRoles(true); + const res = await fetch('/api/roles', { headers }); + if (res.ok) { + const data = await res.json(); + setRoles(data); + } + } catch (err: any) { + console.error('Failed to fetch roles:', err); + } finally { + setIsLoadingRoles(false); + } + }; + + // 加载权限元数据 + const fetchPermissions = async () => { + try { + const res = await fetch('/api/permissions', { headers }); + if (res.ok) { + const data = await res.json(); + setAllPermissions(data); + } + } catch (err: any) { + console.error('Failed to fetch permissions:', err); + } + }; + + // 加载选中角色的权限 + const fetchRolePermissions = async (roleId: string) => { + try { + setIsLoadingPerms(true); + const res = await fetch(`/api/roles/${roleId}/permissions`, { headers }); + if (res.ok) { + const data = await res.json(); + const permSet = new Set(data.permissions || []); + setRolePermissions(permSet); + setEditingPermissions(new Set(permSet)); + } + } catch (err: any) { + console.error('Failed to fetch role permissions:', err); + } finally { + setIsLoadingPerms(false); + } + }; + + useEffect(() => { + fetchRoles(); + fetchPermissions(); + }, [apiKey, activeTenant]); + + useEffect(() => { + if (selectedRoleId) { + fetchRolePermissions(selectedRoleId); + } + }, [selectedRoleId]); + + // 切换权限 + const togglePermission = (key: string) => { + setEditingPermissions(prev => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + setHasChanges(true); + return next; + }); + }; + + // 全选/取消分类 + const toggleCategory = (category: string, perms: PermissionMeta[]) => { + const keys = perms.map(p => p.key); + const allChecked = keys.every(k => editingPermissions.has(k)); + setEditingPermissions(prev => { + const next = new Set(prev); + keys.forEach(k => { + if (allChecked) next.delete(k); + else next.add(k); + }); + setHasChanges(true); + return next; + }); + }; + + // 保存权限 + const savePermissions = async () => { + if (!selectedRoleId) return; + try { + setIsSaving(true); + const res = await fetch(`/api/roles/${selectedRoleId}/permissions`, { + method: 'PUT', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ permissions: [...editingPermissions] }), + }); + if (res.ok) { + setRolePermissions(new Set(editingPermissions)); + setHasChanges(false); + showSuccess?.('权限已保存'); + } else { + const err = await res.json(); + throw new Error(err.message || '保存失败'); + } + } catch (err: any) { + showError?.(err.message); + } finally { + setIsSaving(false); + } + }; + + // 创建角色 + const createRole = async () => { + if (!newRoleName.trim()) return; + try { + const res = await fetch('/api/roles', { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newRoleName.trim(), description: newRoleDesc.trim() }), + }); + if (res.ok) { + await fetchRoles(); + setShowCreateRole(false); + setNewRoleName(''); + setNewRoleDesc(''); + showSuccess?.('角色已创建'); + } else { + const err = await res.json(); + throw new Error(err.message || '创建失败'); + } + } catch (err: any) { + showError?.(err.message); + } + }; + + // 删除角色 + const deleteRole = async (role: Role) => { + const confirmed = await confirm?.(`确定删除角色"${role.name}"?`); + if (!confirmed) return; + try { + const res = await fetch(`/api/roles/${role.id}`, { method: 'DELETE', headers }); + if (res.ok) { + if (selectedRoleId === role.id) setSelectedRoleId(null); + await fetchRoles(); + showSuccess?.('角色已删除'); + } else { + const err = await res.json(); + throw new Error(err.message || '删除失败'); + } + } catch (err: any) { + showError?.(err.message); + } + }; + + const selectedRole = roles.find(r => r.id === selectedRoleId); + + return ( +
+ {/* 左:角色列表 */} +
+
+

+ + 角色 +

+ +
+ + {isLoadingRoles ? ( +
+ +
+ ) : ( +
+ {roles.map(role => ( + + )} + + ))} +
+ )} + + {/* 创建角色弹窗 */} + {showCreateRole && ( +
+ setNewRoleName(e.target.value)} + placeholder="角色名称" + className="w-full px-3 py-2 text-sm bg-white border border-slate-200 rounded-lg focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 outline-none" + /> + setNewRoleDesc(e.target.value)} + placeholder="角色描述(可选)" + className="w-full px-3 py-2 text-sm bg-white border border-slate-200 rounded-lg focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 outline-none" + /> +
+ + +
+
+ )} +
+ + {/* 右:权限矩阵 */} +
+ {!selectedRole ? ( +
+ +

选择一个角色查看权限

+
+ ) : isLoadingPerms ? ( +
+ +
+ ) : ( +
+ {/* 角色标题 */} +
+
+

+ + {selectedRole.name} + {selectedRole.isSystem && ( + + 系统角色 + + )} +

+ {selectedRole.description && ( +

{selectedRole.description}

+ )} +
+
+ + +
+
+ + {selectedRole.isSystem && ( +
+ 系统角色的权限不可修改。可以创建自定义角色,然后分配所需权限。 +
+ )} + + {/* 权限矩阵 */} +
+ {Object.entries(allPermissions).map(([category, perms]) => { + const allChecked = perms.every(p => editingPermissions.has(p.key)); + const someChecked = perms.some(p => editingPermissions.has(p.key)); + + return ( +
+ {/* 分类标题 */} + + + {/* 权限列表 */} +
+ {perms.map(perm => ( + + ))} +
+
+ ); + })} +
+
+ )} +
+
+ ); +}; diff --git a/web/components/views/SettingsView.tsx b/web/components/views/SettingsView.tsx index 48bfa5e..7355ec4 100644 --- a/web/components/views/SettingsView.tsx +++ b/web/components/views/SettingsView.tsx @@ -52,6 +52,7 @@ import { userSettingService } from '../../services/userSettingService'; import { knowledgeGroupService } from '../../services/knowledgeGroupService'; import { apiClient } from '../../services/apiClient'; import { AssessmentTemplateManager } from './AssessmentTemplateManager'; +import { PermissionSettingsView } from './PermissionSettingsView'; import { useConfirm } from '../../contexts/ConfirmContext'; import { useToast } from '../../contexts/ToastContext'; @@ -66,7 +67,7 @@ interface SettingsViewProps { initialTab?: TabType; } -type TabType = 'general' | 'user' | 'model' | 'tenants' | 'knowledge_base' | 'import_tasks' | 'assessment_templates'; +type TabType = 'general' | 'user' | 'model' | 'tenants' | 'knowledge_base' | 'import_tasks' | 'assessment_templates' | 'permissions'; const buildTenantTree = (tenants: Tenant[]): Tenant[] => { const map = new Map(); @@ -2131,6 +2132,16 @@ export const SettingsView: React.FC = ({ {t('assessmentTemplates')} )} + {isAdmin && ( + + )} @@ -2139,10 +2150,10 @@ export const SettingsView: React.FC = ({

- {activeTab === 'general' ? t('generalSettings') : activeTab === 'user' ? t('userManagement') : activeTab === 'model' ? t('modelManagement') : activeTab === 'knowledge_base' ? t('sidebarTitle') : activeTab === 'tenants' ? t('navTenants') : t('assessmentTemplates')} + {activeTab === 'general' ? t('generalSettings') : activeTab === 'user' ? t('userManagement') : activeTab === 'model' ? t('modelManagement') : activeTab === 'knowledge_base' ? t('sidebarTitle') : activeTab === 'tenants' ? t('navTenants') : activeTab === 'permissions' ? '权限管理' : t('assessmentTemplates')}

- {activeTab === 'general' ? t('generalSettingsSubtitle') : activeTab === 'user' ? t('userManagementSubtitle') : activeTab === 'model' ? t('modelManagementSubtitle') : activeTab === 'knowledge_base' ? t('kbSettingsSubtitle') : activeTab === 'tenants' ? t('tenantsSubtitle') : t('assessmentTemplatesSubtitle')} + {activeTab === 'general' ? t('generalSettingsSubtitle') : activeTab === 'user' ? t('userManagementSubtitle') : activeTab === 'model' ? t('modelManagementSubtitle') : activeTab === 'knowledge_base' ? t('kbSettingsSubtitle') : activeTab === 'tenants' ? t('tenantsSubtitle') : activeTab === 'permissions' ? '管理角色和细粒度权限' : t('assessmentTemplatesSubtitle')}

@@ -2183,6 +2194,11 @@ export const SettingsView: React.FC = ({ )} + {activeTab === 'permissions' && isAdmin && ( +
+ +
+ )} diff --git a/web/src/hooks/usePermissions.ts b/web/src/hooks/usePermissions.ts new file mode 100644 index 0000000..1e205c8 --- /dev/null +++ b/web/src/hooks/usePermissions.ts @@ -0,0 +1,86 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useAuth } from '../contexts/AuthContext'; + +/** + * 前端权限 hook + * 获取当前用户在活动租户下的权限集,提供便捷的检查方法 + * + * @example + * ```tsx + * const { hasPermission, hasAnyPermission, isLoading } = usePermissions(); + * + * if (hasPermission('user:create')) { + * // 渲染创建用户按钮 + * } + * + * {hasAnyPermission('user:edit', 'user:delete') && } + * ``` + */ +export function usePermissions() { + const { apiKey, activeTenant } = useAuth(); + const [permissions, setPermissions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchPermissions = useCallback(async () => { + if (!apiKey || !activeTenant) { + setPermissions([]); + setIsLoading(false); + return; + } + + try { + setIsLoading(true); + const res = await fetch('/api/permissions/mine', { + headers: { + 'x-api-key': apiKey, + 'x-tenant-id': activeTenant.tenantId, + }, + }); + + if (res.ok) { + const data = await res.json(); + setPermissions(data.permissions || []); + setError(null); + } else { + setPermissions([]); + } + } catch (err: any) { + console.error('Failed to fetch permissions:', err); + setError(err.message); + setPermissions([]); + } finally { + setIsLoading(false); + } + }, [apiKey, activeTenant?.tenantId]); + + // 获取权限 + useEffect(() => { + fetchPermissions(); + }, [fetchPermissions]); + + const hasPermission = useCallback( + (key: string) => permissions.includes(key), + [permissions], + ); + + const hasAnyPermission = useCallback( + (...keys: string[]) => keys.some(k => permissions.includes(k)), + [permissions], + ); + + const hasAllPermissions = useCallback( + (...keys: string[]) => keys.every(k => permissions.includes(k)), + [permissions], + ); + + return { + permissions, + isLoading, + error, + hasPermission, + hasAnyPermission, + hasAllPermissions, + refresh: fetchPermissions, + }; +}