From 86009e2602b738f0abcb8266e2119d0515864990 Mon Sep 17 00:00:00 2001 From: chenhao Date: Thu, 26 Feb 2026 13:53:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E5=AE=9E=E7=8E=B0=E5=A4=9A?= =?UTF-8?q?=E7=A7=9F=E6=88=B7=E5=88=87=E6=8D=A2=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在前端AppLayout中添加租户选择下拉框组件 - 实现switchTenant API接口用于租户间切换 - 更新登录流程以支持租户信息存储和解析 - 修改后端认证服务以支持租户上下文管理 - 添加租户权限验证和访问控制逻辑 - 重构权限查询以基于当前租户进行过滤 - 更新数据访问层以正确处理租户隔离 - 添加租户切换相关的状态管理和UI显示 --- .../auth/JwtAuthenticationFilter.java | 27 ++- .../com/imeeting/auth/dto/TokenResponse.java | 14 +- .../common/GlobalExceptionHandler.java | 6 + .../imeeting/config/MybatisPlusConfig.java | 9 +- .../imeeting/controller/AuthController.java | 9 + .../controller/PermissionController.java | 13 +- .../imeeting/controller/UserController.java | 51 +++- .../java/com/imeeting/entity/BaseEntity.java | 4 +- .../java/com/imeeting/entity/SysDictItem.java | 2 +- .../java/com/imeeting/entity/SysDictType.java | 2 +- .../java/com/imeeting/entity/SysUser.java | 10 +- .../imeeting/mapper/SysPermissionMapper.java | 8 +- .../com/imeeting/mapper/SysUserMapper.java | 25 +- .../com/imeeting/service/AuthService.java | 1 + .../service/SysPermissionService.java | 4 +- .../com/imeeting/service/SysUserService.java | 12 +- .../service/impl/AuthServiceImpl.java | 129 +++++++--- .../service/impl/SysOrgServiceImpl.java | 3 + .../impl/SysPermissionServiceImpl.java | 23 +- .../service/impl/SysUserServiceImpl.java | 12 +- frontend/src/api/auth.ts | 12 + frontend/src/api/http.ts | 15 +- frontend/src/api/index.ts | 5 + frontend/src/layouts/AppLayout.tsx | 73 +++++- frontend/src/pages/Login.tsx | 21 +- frontend/src/pages/Orgs.tsx | 59 +++-- frontend/src/pages/Permissions.tsx | 38 ++- frontend/src/pages/Roles.tsx | 71 +++++- frontend/src/pages/Users.tsx | 222 +++++++++++++----- 29 files changed, 689 insertions(+), 191 deletions(-) diff --git a/backend/src/main/java/com/imeeting/auth/JwtAuthenticationFilter.java b/backend/src/main/java/com/imeeting/auth/JwtAuthenticationFilter.java index 4d2c667..bd97140 100644 --- a/backend/src/main/java/com/imeeting/auth/JwtAuthenticationFilter.java +++ b/backend/src/main/java/com/imeeting/auth/JwtAuthenticationFilter.java @@ -69,17 +69,22 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { // 1. Validate User Status (Ignore Tenant isolation here) SysUser user = sysUserMapper.selectByIdIgnoreTenant(userId); - if (user == null || user.getStatus() != 1 || user.getIsDeleted() ) { - response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "User account is disabled or deleted"); + if (user == null || user.getStatus() != 1 || user.getIsDeleted() != 0) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"code\":\"401\",\"msg\":\"User account is disabled or deleted\"}"); return; } // 2. Validate Tenant Status & Grace Period // Skip validation for system platform tenant (ID=0) - if (tenantId != null && !Long.valueOf(0).equals(tenantId)) { - SysTenant tenant = sysTenantMapper.selectByIdIgnoreTenant(tenantId); + Long activeTenantId = tenantId; + if (activeTenantId != null && !Long.valueOf(0).equals(activeTenantId)) { + SysTenant tenant = sysTenantMapper.selectByIdIgnoreTenant(activeTenantId); if (tenant == null || tenant.getStatus() != 1) { - response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Tenant is disabled"); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"code\":\"401\",\"msg\":\"Tenant is disabled\"}"); return; } @@ -89,27 +94,29 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { String graceDaysStr = sysParamService.getParamValue("sys.tenant.grace_period_days", "0"); int graceDays = Integer.parseInt(graceDaysStr); if (now.isAfter(tenant.getExpireTime().plusDays(graceDays))) { - response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Tenant subscription expired"); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"code\":\"401\",\"msg\":\"Tenant subscription expired\"}"); return; } } } } - // 3. Get Permissions (With Redis Cache) - String permKey = "sys:auth:perm:" + userId; + // 3. Get Permissions (With Redis Cache, Key must include tenantId) + String permKey = "sys:auth:perm:" + userId + ":" + activeTenantId; Set permissions; String cachedPerms = redisTemplate.opsForValue().get(permKey); if (cachedPerms != null) { permissions = Set.of(cachedPerms.split(",")); } else { - permissions = sysPermissionService.listPermissionCodesByUserId(userId); + permissions = sysPermissionService.listPermissionCodesByUserId(userId, activeTenantId); if (permissions != null && !permissions.isEmpty()) { redisTemplate.opsForValue().set(permKey, String.join(",", permissions), java.time.Duration.ofHours(2)); } } - LoginUser loginUser = new LoginUser(userId, tenantId, username, user.getIsPlatformAdmin(), permissions); + LoginUser loginUser = new LoginUser(userId, activeTenantId, username, user.getIsPlatformAdmin(), permissions); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities()); diff --git a/backend/src/main/java/com/imeeting/auth/dto/TokenResponse.java b/backend/src/main/java/com/imeeting/auth/dto/TokenResponse.java index 85ab970..ce7c7bb 100644 --- a/backend/src/main/java/com/imeeting/auth/dto/TokenResponse.java +++ b/backend/src/main/java/com/imeeting/auth/dto/TokenResponse.java @@ -1,13 +1,23 @@ package com.imeeting.auth.dto; -import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; +import java.util.List; @Data -@AllArgsConstructor +@Builder public class TokenResponse { private String accessToken; private String refreshToken; private long accessExpiresInMinutes; private long refreshExpiresInDays; + private List availableTenants; + + @Data + @Builder + public static class TenantInfo { + private Long tenantId; + private String tenantCode; + private String tenantName; + } } diff --git a/backend/src/main/java/com/imeeting/common/GlobalExceptionHandler.java b/backend/src/main/java/com/imeeting/common/GlobalExceptionHandler.java index c7494c6..c23c8c6 100644 --- a/backend/src/main/java/com/imeeting/common/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/imeeting/common/GlobalExceptionHandler.java @@ -15,6 +15,12 @@ public class GlobalExceptionHandler { return ApiResponse.error(ex.getMessage()); } + @ExceptionHandler(org.springframework.security.access.AccessDeniedException.class) + public ApiResponse handleAccessDenied(org.springframework.security.access.AccessDeniedException ex) { + log.warn("Access denied: {}", ex.getMessage()); + return ApiResponse.error("无权限操作"); + } + @ExceptionHandler(Exception.class) public ApiResponse handleGeneric(Exception ex) { log.error("Unhandled exception", ex); diff --git a/backend/src/main/java/com/imeeting/config/MybatisPlusConfig.java b/backend/src/main/java/com/imeeting/config/MybatisPlusConfig.java index 35eb545..2ec99da 100644 --- a/backend/src/main/java/com/imeeting/config/MybatisPlusConfig.java +++ b/backend/src/main/java/com/imeeting/config/MybatisPlusConfig.java @@ -43,17 +43,18 @@ public class MybatisPlusConfig { @Override public boolean ignoreTable(String tableName) { - // System level tables that should not be filtered by tenant_id - // and check if current user is platform admin to skip filtering Authentication auth = SecurityContextHolder.getContext().getAuthentication(); if (auth != null && auth.getPrincipal() instanceof LoginUser) { LoginUser user = (LoginUser) auth.getPrincipal(); + // 只有当平台管理员处于系统租户(0)时,才忽略所有过滤。 + // 如果他切换到了具体租户(>0),则必须接受过滤,确保只能看到当前租户数据。 if (Boolean.TRUE.equals(user.getIsPlatformAdmin()) && Long.valueOf(0).equals(user.getTenantId())) { return true; } } - return List.of("sys_tenant", "sys_permission", "sys_role_permission", "sys_user_role", "sys_dict_type", "sys_dict_item", "sys_param").contains(tableName.toLowerCase()); + // 公共表始终忽略过滤 + return List.of("sys_tenant", "sys_user", "sys_tenant_user", "sys_permission", "sys_role_permission", "sys_user_role", "sys_dict_type", "sys_dict_item", "sys_param").contains(tableName.toLowerCase()); } })); return interceptor; @@ -67,7 +68,7 @@ public class MybatisPlusConfig { strictInsertFill(metaObject, "createdAt", LocalDateTime::now, LocalDateTime.class); strictInsertFill(metaObject, "updatedAt", LocalDateTime::now, LocalDateTime.class); strictInsertFill(metaObject, "status", () -> 1, Integer.class); - strictInsertFill(metaObject, "isDeleted", () -> Boolean.FALSE, Boolean.class); + strictInsertFill(metaObject, "isDeleted", () -> 0, Integer.class); } @Override diff --git a/backend/src/main/java/com/imeeting/controller/AuthController.java b/backend/src/main/java/com/imeeting/controller/AuthController.java index d178563..be956cc 100644 --- a/backend/src/main/java/com/imeeting/controller/AuthController.java +++ b/backend/src/main/java/com/imeeting/controller/AuthController.java @@ -74,6 +74,15 @@ public class AuthController { return ApiResponse.ok(authService.refresh(request.getRefreshToken())); } + @PostMapping("/switch-tenant") + public ApiResponse switchTenant(@RequestParam Long tenantId, @RequestHeader("Authorization") String authorization) { + String token = authorization.replace("Bearer ", ""); + var claims = jwtTokenProvider.parseToken(token); + Long userId = claims.get("userId", Long.class); + String deviceCode = claims.get("deviceCode", String.class); + return ApiResponse.ok(authService.switchTenant(userId, tenantId, deviceCode)); + } + @PostMapping("/logout") public ApiResponse logout(@RequestHeader("Authorization") String authorization) { String token = authorization.replace("Bearer ", ""); diff --git a/backend/src/main/java/com/imeeting/controller/PermissionController.java b/backend/src/main/java/com/imeeting/controller/PermissionController.java index 800dac5..d53d6a6 100644 --- a/backend/src/main/java/com/imeeting/controller/PermissionController.java +++ b/backend/src/main/java/com/imeeting/controller/PermissionController.java @@ -34,8 +34,7 @@ public class PermissionController { @GetMapping("/me") public ApiResponse> myPermissions() { - // Implementation can use SecurityContext to get current userId - return ApiResponse.ok(sysPermissionService.listByUserId(getCurrentUserId())); + return ApiResponse.ok(sysPermissionService.listByUserId(getCurrentUserId(), getCurrentTenantId())); } @GetMapping("/tree") @@ -46,7 +45,7 @@ public class PermissionController { @GetMapping("/tree/me") public ApiResponse> myTree() { - return ApiResponse.ok(buildTree(sysPermissionService.listByUserId(getCurrentUserId()))); + return ApiResponse.ok(buildTree(sysPermissionService.listByUserId(getCurrentUserId(), getCurrentTenantId()))); } @GetMapping("/{id}") @@ -97,6 +96,14 @@ public class PermissionController { return null; } + private Long getCurrentTenantId() { + org.springframework.security.core.Authentication authentication = org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null && authentication.getPrincipal() instanceof com.imeeting.security.LoginUser) { + return ((com.imeeting.security.LoginUser) authentication.getPrincipal()).getTenantId(); + } + return null; + } + private String validateParent(SysPermission perm) { if (perm.getLevel() == null) { return null; diff --git a/backend/src/main/java/com/imeeting/controller/UserController.java b/backend/src/main/java/com/imeeting/controller/UserController.java index 9519f86..da3795d 100644 --- a/backend/src/main/java/com/imeeting/controller/UserController.java +++ b/backend/src/main/java/com/imeeting/controller/UserController.java @@ -28,22 +28,35 @@ public class UserController { private final PasswordEncoder passwordEncoder; private final JwtTokenProvider jwtTokenProvider; private final SysUserRoleMapper sysUserRoleMapper; + private final com.imeeting.service.SysTenantUserService sysTenantUserService; - public UserController(SysUserService sysUserService, PasswordEncoder passwordEncoder, JwtTokenProvider jwtTokenProvider, SysUserRoleMapper sysUserRoleMapper) { + public UserController(SysUserService sysUserService, PasswordEncoder passwordEncoder, JwtTokenProvider jwtTokenProvider, SysUserRoleMapper sysUserRoleMapper, com.imeeting.service.SysTenantUserService sysTenantUserService) { this.sysUserService = sysUserService; this.passwordEncoder = passwordEncoder; this.jwtTokenProvider = jwtTokenProvider; this.sysUserRoleMapper = sysUserRoleMapper; + this.sysTenantUserService = sysTenantUserService; } @GetMapping @PreAuthorize("@ss.hasPermi('sys_user:list')") public ApiResponse> list(@RequestParam(required = false) Long tenantId, @RequestParam(required = false) Long orgId) { - LambdaQueryWrapper query = new LambdaQueryWrapper<>(); - if (orgId != null) { - query.eq(SysUser::getOrgId, orgId); + Long currentTenantId = getCurrentTenantId(); + if (Long.valueOf(0).equals(currentTenantId) && tenantId == null) { + List allUsers = sysUserService.list(); + // 为每个用户加载其租户关系 + if (allUsers != null && !allUsers.isEmpty()) { + for (SysUser user : allUsers) { + user.setMemberships(sysTenantUserService.listByUserId(user.getUserId())); + } + } + return ApiResponse.ok(allUsers); } - return ApiResponse.ok(sysUserService.list(query)); + Long targetTenantId = tenantId != null ? tenantId : currentTenantId; + if (targetTenantId == null) { + return ApiResponse.error("Tenant ID required"); + } + return ApiResponse.ok(sysUserService.listUsersByTenant(targetTenantId, orgId)); } @GetMapping("/me") @@ -55,7 +68,7 @@ public class UserController { LoginUser loginUser = (LoginUser) authentication.getPrincipal(); Long userId = loginUser.getUserId(); - SysUser user = sysUserService.getById(userId); + SysUser user = sysUserService.getByIdIgnoreTenant(userId); if (user == null) { return ApiResponse.error("User not found"); } @@ -74,7 +87,19 @@ public class UserController { @GetMapping("/{id}") @PreAuthorize("@ss.hasPermi('sys_user:query')") public ApiResponse get(@PathVariable Long id) { - return ApiResponse.ok(sysUserService.getById(id)); + SysUser user = sysUserService.getByIdIgnoreTenant(id); + if (user != null) { + user.setMemberships(sysTenantUserService.listByUserId(id)); + } + return ApiResponse.ok(user); + } + + private Long getCurrentTenantId() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.getPrincipal() instanceof LoginUser) { + return ((LoginUser) auth.getPrincipal()).getTenantId(); + } + return null; } @PostMapping @@ -84,7 +109,11 @@ public class UserController { if (user.getPasswordHash() != null && !user.getPasswordHash().isEmpty()) { user.setPasswordHash(passwordEncoder.encode(user.getPasswordHash())); } - return ApiResponse.ok(sysUserService.save(user)); + boolean saved = sysUserService.save(user); + if (saved) { + sysTenantUserService.syncMemberships(user.getUserId(), user.getMemberships()); + } + return ApiResponse.ok(saved); } @PutMapping("/{id}") @@ -95,7 +124,11 @@ public class UserController { if (user.getPasswordHash() != null && !user.getPasswordHash().isEmpty()) { user.setPasswordHash(passwordEncoder.encode(user.getPasswordHash())); } - return ApiResponse.ok(sysUserService.updateById(user)); + boolean updated = sysUserService.updateById(user); + if (updated) { + sysTenantUserService.syncMemberships(id, user.getMemberships()); + } + return ApiResponse.ok(updated); } @DeleteMapping("/{id}") diff --git a/backend/src/main/java/com/imeeting/entity/BaseEntity.java b/backend/src/main/java/com/imeeting/entity/BaseEntity.java index 7d475e7..00cf1d7 100644 --- a/backend/src/main/java/com/imeeting/entity/BaseEntity.java +++ b/backend/src/main/java/com/imeeting/entity/BaseEntity.java @@ -12,8 +12,8 @@ public class BaseEntity { private Long tenantId; private Integer status; - @TableLogic - private Boolean isDeleted; + @TableLogic(value = "0", delval = "1") + private Integer isDeleted; @TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt; diff --git a/backend/src/main/java/com/imeeting/entity/SysDictItem.java b/backend/src/main/java/com/imeeting/entity/SysDictItem.java index ad895e4..47da67b 100644 --- a/backend/src/main/java/com/imeeting/entity/SysDictItem.java +++ b/backend/src/main/java/com/imeeting/entity/SysDictItem.java @@ -23,5 +23,5 @@ public class SysDictItem extends BaseEntity { private Long tenantId; @TableField(exist = false) - private Boolean isDeleted; + private Integer isDeleted; } diff --git a/backend/src/main/java/com/imeeting/entity/SysDictType.java b/backend/src/main/java/com/imeeting/entity/SysDictType.java index b8245c5..9600d2e 100644 --- a/backend/src/main/java/com/imeeting/entity/SysDictType.java +++ b/backend/src/main/java/com/imeeting/entity/SysDictType.java @@ -21,5 +21,5 @@ public class SysDictType extends BaseEntity { private Long tenantId; @TableField(exist = false) - private Boolean isDeleted; + private Integer isDeleted; } diff --git a/backend/src/main/java/com/imeeting/entity/SysUser.java b/backend/src/main/java/com/imeeting/entity/SysUser.java index 2eec747..d70443e 100644 --- a/backend/src/main/java/com/imeeting/entity/SysUser.java +++ b/backend/src/main/java/com/imeeting/entity/SysUser.java @@ -16,6 +16,14 @@ public class SysUser extends BaseEntity { private String phone; private String passwordHash; - private Long orgId; private Boolean isPlatformAdmin; + + @com.baomidou.mybatisplus.annotation.TableField(exist = false) + private Long tenantId; + + @com.baomidou.mybatisplus.annotation.TableField(exist = false) + private Long orgId; + + @com.baomidou.mybatisplus.annotation.TableField(exist = false) + private java.util.List memberships; } diff --git a/backend/src/main/java/com/imeeting/mapper/SysPermissionMapper.java b/backend/src/main/java/com/imeeting/mapper/SysPermissionMapper.java index e0e2aa8..0b69567 100644 --- a/backend/src/main/java/com/imeeting/mapper/SysPermissionMapper.java +++ b/backend/src/main/java/com/imeeting/mapper/SysPermissionMapper.java @@ -10,12 +10,14 @@ import java.util.List; @Mapper public interface SysPermissionMapper extends BaseMapper { + @com.baomidou.mybatisplus.annotation.InterceptorIgnore(tenantLine = "true") @Select(""" SELECT DISTINCT p.* FROM sys_permission p JOIN sys_role_permission rp ON rp.perm_id = p.perm_id - JOIN sys_user_role ur ON ur.role_id = rp.role_id - WHERE ur.user_id = #{userId} + JOIN sys_role r ON r.role_id = rp.role_id + JOIN sys_user_role ur ON ur.role_id = r.role_id + WHERE ur.user_id = #{userId} AND r.tenant_id = #{tenantId} """) - List selectByUserId(@Param("userId") Long userId); + List selectByUserId(@Param("userId") Long userId, @Param("tenantId") Long tenantId); } diff --git a/backend/src/main/java/com/imeeting/mapper/SysUserMapper.java b/backend/src/main/java/com/imeeting/mapper/SysUserMapper.java index 29c78e3..dd3d48d 100644 --- a/backend/src/main/java/com/imeeting/mapper/SysUserMapper.java +++ b/backend/src/main/java/com/imeeting/mapper/SysUserMapper.java @@ -21,14 +21,25 @@ public interface SysUserMapper extends BaseMapper { @Select("SELECT * FROM sys_user WHERE user_id = #{userId} AND is_deleted = 0") SysUser selectByIdIgnoreTenant(@Param("userId") Long userId); + @InterceptorIgnore(tenantLine = "true") + @Select("") + List selectUsersByTenant(@Param("tenantId") Long tenantId, @Param("orgId") Long orgId); + @InterceptorIgnore(tenantLine = "true") @Select(""" - SELECT u.* - FROM sys_user u - JOIN sys_tenant t ON u.tenant_id = t.id - WHERE u.username = #{username} - AND t.tenant_code = #{tenantCode} - AND u.is_deleted = 0 + SELECT t.id as tenantId, t.tenant_code as tenantCode, t.tenant_name as tenantName + FROM sys_tenant t + JOIN sys_tenant_user tu ON t.id = tu.tenant_id + JOIN sys_user u ON u.user_id = tu.user_id + WHERE u.username = #{username} AND u.is_deleted = 0 AND t.is_deleted = 0 + ORDER BY t.id ASC """) - SysUser selectByUsernameAndTenantCode(@Param("username") String username, @Param("tenantCode") String tenantCode); + List selectTenantsByUsername(@Param("username") String username); } diff --git a/backend/src/main/java/com/imeeting/service/AuthService.java b/backend/src/main/java/com/imeeting/service/AuthService.java index a2efa3f..c409bf5 100644 --- a/backend/src/main/java/com/imeeting/service/AuthService.java +++ b/backend/src/main/java/com/imeeting/service/AuthService.java @@ -8,4 +8,5 @@ public interface AuthService { TokenResponse refresh(String refreshToken); void logout(Long userId, String deviceCode); String createDeviceCode(LoginRequest request, String deviceName); + TokenResponse switchTenant(Long userId, Long targetTenantId, String deviceCode); } diff --git a/backend/src/main/java/com/imeeting/service/SysPermissionService.java b/backend/src/main/java/com/imeeting/service/SysPermissionService.java index 21320ed..e25eb54 100644 --- a/backend/src/main/java/com/imeeting/service/SysPermissionService.java +++ b/backend/src/main/java/com/imeeting/service/SysPermissionService.java @@ -7,7 +7,7 @@ import java.util.List; import java.util.Set; public interface SysPermissionService extends IService { - List listByUserId(Long userId); + List listByUserId(Long userId, Long tenantId); - Set listPermissionCodesByUserId(Long userId); + Set listPermissionCodesByUserId(Long userId, Long tenantId); } diff --git a/backend/src/main/java/com/imeeting/service/SysUserService.java b/backend/src/main/java/com/imeeting/service/SysUserService.java index f0a00db..d01cae2 100644 --- a/backend/src/main/java/com/imeeting/service/SysUserService.java +++ b/backend/src/main/java/com/imeeting/service/SysUserService.java @@ -10,8 +10,16 @@ import java.util.List; public interface SysUserService extends IService { - List listUsersByRoleId(Long roleId); + List listUsersByRoleId(Long roleId); -} + SysUser getByIdIgnoreTenant(Long userId); + + List listUsersByTenant(Long tenantId, Long orgId); + + } + + + + diff --git a/backend/src/main/java/com/imeeting/service/impl/AuthServiceImpl.java b/backend/src/main/java/com/imeeting/service/impl/AuthServiceImpl.java index 003d4e5..015a778 100644 --- a/backend/src/main/java/com/imeeting/service/impl/AuthServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/impl/AuthServiceImpl.java @@ -71,20 +71,51 @@ public class AuthServiceImpl implements AuthService { validateCaptcha(request.getCaptchaId(), request.getCaptchaCode()); } - SysUser user; - if (request.getTenantCode() == null || request.getTenantCode().trim().isEmpty()) { - // 平台管理登录逻辑 (全局搜索) - user = sysUserMapper.selectByUsernameIgnoreTenant(request.getUsername()); - if (user != null && !Boolean.TRUE.equals(user.getIsPlatformAdmin())) { - throw new IllegalArgumentException("非平台管理账号请提供租户编码"); - } - } else { - // 租户用户登录逻辑 (按租户搜索) - user = sysUserMapper.selectByUsernameAndTenantCode(request.getUsername(), request.getTenantCode().trim()); - } + SysUser user = sysUserMapper.selectByUsernameIgnoreTenant(request.getUsername()); if (user == null || user.getStatus() != 1 || !passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) { - throw new IllegalArgumentException("用户名、密码或租户编码错误"); + throw new IllegalArgumentException("用户名或密码错误"); + } + + // 获取该用户关联的所有租户 + java.util.List availableTenants = sysUserMapper.selectTenantsByUsername(user.getUsername()); + + // 如果是平台管理员,且没有在租户列表中,手动添加系统租户(ID=0) + if (Boolean.TRUE.equals(user.getIsPlatformAdmin())) { + boolean hasSystemTenant = availableTenants.stream().anyMatch(t -> t.getTenantId() == 0L); + if (!hasSystemTenant) { + availableTenants.add(0, TokenResponse.TenantInfo.builder() + .tenantId(0L).tenantCode("SYSTEM").tenantName("系统平台").build()); + } + } + + if (availableTenants.isEmpty()) { + throw new IllegalArgumentException("该账号未关联任何租户"); + } + + // 确定当前租户: + // 1. 如果请求指定了租户,且用户属于该租户,则使用该租户 + // 2. 否则,如果用户是平台管理员,默认进入系统租户(0) + // 3. 否则,使用第一个非0的业务租户 + Long activeTenantId = null; + if (request.getTenantCode() != null && !request.getTenantCode().trim().isEmpty()) { + String tc = request.getTenantCode().trim(); + activeTenantId = availableTenants.stream() + .filter(t -> t.getTenantCode().equals(tc)) + .map(TokenResponse.TenantInfo::getTenantId) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("您不属于指定的租户: " + tc)); + } else { + if (Boolean.TRUE.equals(user.getIsPlatformAdmin())) { + activeTenantId = 0L; + } else { + // 优先选择非0的租户 + activeTenantId = availableTenants.stream() + .map(TokenResponse.TenantInfo::getTenantId) + .filter(id -> id != 0L) + .findFirst() + .orElse(availableTenants.get(0).getTenantId()); + } } String deviceCode = request.getDeviceCode(); @@ -107,10 +138,11 @@ public class AuthServiceImpl implements AuthService { if (deviceCode == null || deviceCode.isEmpty()) { deviceCode = "default"; } - TokenResponse tokens = issueTokens(user, deviceCode, accessMinutes, refreshDays); + TokenResponse tokens = issueTokens(user, activeTenantId, deviceCode, accessMinutes, refreshDays); + tokens.setAvailableTenants(availableTenants); cacheRefreshToken(user.getUserId(), deviceCode, tokens.getRefreshToken(), refreshDays); - recordLoginLog(user.getUserId(), user.getTenantId(), user.getUsername(), 1, "登录成功", System.currentTimeMillis() - start); + recordLoginLog(user.getUserId(), activeTenantId, user.getUsername(), 1, "登录成功", System.currentTimeMillis() - start); return tokens; } catch (Exception e) { recordLoginLog(null, null, request.getUsername(), 0, e.getMessage(), System.currentTimeMillis() - start); @@ -141,6 +173,7 @@ public class AuthServiceImpl implements AuthService { throw new IllegalArgumentException("无效的刷新令牌"); } Long userId = claims.get("userId", Long.class); + Long tenantId = claims.get("tenantId", Long.class); String deviceCode = claims.get("deviceCode", String.class); String cached = stringRedisTemplate.opsForValue().get(RedisKeys.refreshTokenKey(userId, deviceCode)); if (cached == null || !cached.equals(refreshToken)) { @@ -152,12 +185,54 @@ public class AuthServiceImpl implements AuthService { long refreshDays = parseLong(sysParamService.getParamValue("security.token.refresh_ttl_days", String.valueOf(refreshDefaultDays)), refreshDefaultDays); - SysUser user = sysUserService.getById(userId); - TokenResponse tokens = issueTokens(user, deviceCode, accessMinutes, refreshDays); + SysUser user = sysUserMapper.selectByIdIgnoreTenant(userId); + TokenResponse tokens = issueTokens(user, tenantId, deviceCode, accessMinutes, refreshDays); cacheRefreshToken(userId, deviceCode, tokens.getRefreshToken(), refreshDays); return tokens; } + @Override + public TokenResponse switchTenant(Long userId, Long targetTenantId, String deviceCode) { + SysUser user = sysUserMapper.selectByIdIgnoreTenant(userId); + if (user == null) { + throw new IllegalArgumentException("用户不存在"); + } + + // 校验权限:平台管理员可以直接进入租户0,或者用户确实关联了目标租户 + boolean hasAccess = false; + if (targetTenantId == 0L && Boolean.TRUE.equals(user.getIsPlatformAdmin())) { + hasAccess = true; + } else { + java.util.List tenants = sysUserMapper.selectTenantsByUsername(user.getUsername()); + hasAccess = tenants.stream().anyMatch(t -> t.getTenantId().equals(targetTenantId)); + } + + if (!hasAccess) { + throw new IllegalArgumentException("您不属于目标租户"); + } + + long accessMinutes = parseLong(sysParamService.getParamValue("security.token.access_ttl_minutes", + String.valueOf(accessDefaultMinutes)), accessDefaultMinutes); + long refreshDays = parseLong(sysParamService.getParamValue("security.token.refresh_ttl_days", + String.valueOf(refreshDefaultDays)), refreshDefaultDays); + + TokenResponse tokens = issueTokens(user, targetTenantId, deviceCode, accessMinutes, refreshDays); + cacheRefreshToken(userId, deviceCode, tokens.getRefreshToken(), refreshDays); + + // 重新获取该用户关联的所有租户信息返回 + java.util.List availableTenants = sysUserMapper.selectTenantsByUsername(user.getUsername()); + if (Boolean.TRUE.equals(user.getIsPlatformAdmin())) { + boolean hasSystemTenant = availableTenants.stream().anyMatch(t -> t.getTenantId() == 0L); + if (!hasSystemTenant) { + availableTenants.add(0, TokenResponse.TenantInfo.builder() + .tenantId(0L).tenantCode("SYSTEM").tenantName("系统平台").build()); + } + } + tokens.setAvailableTenants(availableTenants); + + return tokens; + } + @Override public void logout(Long userId, String deviceCode) { stringRedisTemplate.delete(RedisKeys.refreshTokenKey(userId, deviceCode)); @@ -169,15 +244,10 @@ public class AuthServiceImpl implements AuthService { validateCaptcha(request.getCaptchaId(), request.getCaptchaCode()); } - SysUser user; - if (request.getTenantCode() == null || request.getTenantCode().trim().isEmpty()) { - user = sysUserMapper.selectByUsernameIgnoreTenant(request.getUsername()); - } else { - user = sysUserMapper.selectByUsernameAndTenantCode(request.getUsername(), request.getTenantCode().trim()); - } + SysUser user = sysUserMapper.selectByUsernameIgnoreTenant(request.getUsername()); if (user == null || user.getStatus() != 1 || !passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) { - throw new IllegalArgumentException("用户名、密码或租户编码错误"); + throw new IllegalArgumentException("用户名或密码错误"); } String deviceCode = UUID.randomUUID().toString().replace("-", ""); @@ -227,23 +297,28 @@ public class AuthServiceImpl implements AuthService { return Boolean.parseBoolean(value); } - private TokenResponse issueTokens(SysUser user, String deviceCode, long accessMinutes, long refreshDays) { + private TokenResponse issueTokens(SysUser user, Long tenantId, String deviceCode, long accessMinutes, long refreshDays) { Map accessClaims = new HashMap<>(); accessClaims.put("tokenType", "access"); accessClaims.put("userId", user.getUserId()); - accessClaims.put("tenantId", user.getTenantId()); + accessClaims.put("tenantId", tenantId); accessClaims.put("username", user.getUsername()); accessClaims.put("deviceCode", deviceCode); Map refreshClaims = new HashMap<>(); refreshClaims.put("tokenType", "refresh"); refreshClaims.put("userId", user.getUserId()); - refreshClaims.put("tenantId", user.getTenantId()); + refreshClaims.put("tenantId", tenantId); refreshClaims.put("deviceCode", deviceCode); String access = jwtTokenProvider.createToken(accessClaims, Duration.ofMinutes(accessMinutes).toMillis()); String refresh = jwtTokenProvider.createToken(refreshClaims, Duration.ofDays(refreshDays).toMillis()); - return new TokenResponse(access, refresh, accessMinutes, refreshDays); + return TokenResponse.builder() + .accessToken(access) + .refreshToken(refresh) + .accessExpiresInMinutes(accessMinutes) + .refreshExpiresInDays(refreshDays) + .build(); } private void cacheRefreshToken(Long userId, String deviceCode, String refreshToken, long refreshDays) { diff --git a/backend/src/main/java/com/imeeting/service/impl/SysOrgServiceImpl.java b/backend/src/main/java/com/imeeting/service/impl/SysOrgServiceImpl.java index 3537dbc..28d66d0 100644 --- a/backend/src/main/java/com/imeeting/service/impl/SysOrgServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/impl/SysOrgServiceImpl.java @@ -13,6 +13,9 @@ public class SysOrgServiceImpl extends ServiceImpl impleme @Override public List listTree(Long tenantId) { LambdaQueryWrapper query = new LambdaQueryWrapper<>(); + if (tenantId != null) { + query.eq(SysOrg::getTenantId, tenantId); + } query.orderByAsc(SysOrg::getSortOrder); return list(query); } diff --git a/backend/src/main/java/com/imeeting/service/impl/SysPermissionServiceImpl.java b/backend/src/main/java/com/imeeting/service/impl/SysPermissionServiceImpl.java index d92b8ee..84503ee 100644 --- a/backend/src/main/java/com/imeeting/service/impl/SysPermissionServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/impl/SysPermissionServiceImpl.java @@ -23,32 +23,25 @@ public class SysPermissionServiceImpl extends ServiceImpl listByUserId(Long userId) { + public List listByUserId(Long userId, Long tenantId) { if (userId == null) { return List.of(); } - // Get current login user from Security Context - org.springframework.security.core.Authentication auth = org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication(); - if (auth != null && auth.getPrincipal() instanceof com.imeeting.security.LoginUser) { - com.imeeting.security.LoginUser loginUser = (com.imeeting.security.LoginUser) auth.getPrincipal(); - // If current user is Platform Admin (tenantId=0 & isPlatformAdmin=true), return all permissions - if (Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) && Long.valueOf(0).equals(loginUser.getTenantId())) { + SysUser user = sysUserService.getByIdIgnoreTenant(userId); + if (user != null && Boolean.TRUE.equals(user.getIsPlatformAdmin()) && Long.valueOf(0).equals(tenantId)) { return list(); - } } - // Fallback or specific user logic (for tenant users or internal calls) - if (userId == 1L) { - return list(); - } + // 如果没有指定租户,或者租户为0但用户不是平台管理员,则返回空或按默认逻辑(通常需要指定租户) + if (tenantId == null) return List.of(); - return baseMapper.selectByUserId(userId); + return baseMapper.selectByUserId(userId, tenantId); } @Override - public Set listPermissionCodesByUserId(Long userId) { - List perms = listByUserId(userId); + public Set listPermissionCodesByUserId(Long userId, Long tenantId) { + List perms = listByUserId(userId, tenantId); return perms.stream() .map(SysPermission::getCode) .filter(code -> code != null && !code.isEmpty()) diff --git a/backend/src/main/java/com/imeeting/service/impl/SysUserServiceImpl.java b/backend/src/main/java/com/imeeting/service/impl/SysUserServiceImpl.java index cd502da..b328b42 100644 --- a/backend/src/main/java/com/imeeting/service/impl/SysUserServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/impl/SysUserServiceImpl.java @@ -17,6 +17,16 @@ public class SysUserServiceImpl extends ServiceImpl impl return baseMapper.selectUsersByRoleId(roleId); } + @Override + public SysUser getByIdIgnoreTenant(Long userId) { + return baseMapper.selectByIdIgnoreTenant(userId); + } + + @Override + public List listUsersByTenant(Long tenantId, Long orgId) { + return baseMapper.selectUsersByTenant(tenantId, orgId); + } + @Override public boolean save(SysUser entity) { validateUniqueUsername(entity); @@ -40,7 +50,7 @@ public class SysUserServiceImpl extends ServiceImpl impl } if (count(query) > 0) { - throw new IllegalArgumentException("该租户下已存在名为 [" + user.getUsername() + "] 的用户"); + throw new IllegalArgumentException("用户名 [" + user.getUsername() + "] 已被占用"); } } } diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 16f3363..e1fcc68 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -5,11 +5,18 @@ export interface CaptchaResponse { imageBase64: string; } +export interface TenantInfo { + tenantId: number; + tenantCode: string; + tenantName: string; +} + export interface TokenResponse { accessToken: string; refreshToken: string; accessExpiresInMinutes: number; refreshExpiresInDays: number; + availableTenants?: TenantInfo[]; } export interface LoginPayload { @@ -48,3 +55,8 @@ export async function refreshToken(refreshToken: string) { const resp = await http.post("/auth/refresh", { refreshToken }); return resp.data.data as TokenResponse; } + +export async function switchTenant(tenantId: number) { + const resp = await http.post(`/auth/switch-tenant?tenantId=${tenantId}`); + return resp.data.data as TokenResponse; +} diff --git a/frontend/src/api/http.ts b/frontend/src/api/http.ts index 188148f..3f9b75c 100644 --- a/frontend/src/api/http.ts +++ b/frontend/src/api/http.ts @@ -18,7 +18,10 @@ http.interceptors.response.use( (resp) => { const body = resp.data; if (body && body.code !== "0") { - return Promise.reject(new Error(body.msg || "请求失败")); + const err = new Error(body.msg || "请求失败"); + (err as any).code = body.code; + (err as any).msg = body.msg; + return Promise.reject(err); } return resp; }, @@ -31,6 +34,16 @@ http.interceptors.response.use( // Force redirect to login with timeout flag window.location.href = "/login?timeout=1"; } + + // Process backend error message if available in response body even for non-200 status + const body = error.response?.data; + if (body && body.msg) { + const err = new Error(body.msg); + (err as any).code = body.code; + (err as any).msg = body.msg; + return Promise.reject(err); + } + return Promise.reject(error); } ); diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index e797092..7e9f9e3 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -22,6 +22,11 @@ export async function deleteUser(id: number) { return resp.data.data as boolean; } +export async function getUserDetail(id: number) { + const resp = await http.get(`/api/users/${id}`); + return resp.data.data as SysUser; +} + export async function listRoles() { const resp = await http.get("/api/roles"); return resp.data.data as SysRole[]; diff --git a/frontend/src/layouts/AppLayout.tsx b/frontend/src/layouts/AppLayout.tsx index 469c3ea..e116479 100644 --- a/frontend/src/layouts/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout.tsx @@ -1,5 +1,5 @@ -import { Layout, Menu, Button, Space, Avatar, Dropdown, message, type MenuProps } from "antd"; -import { useEffect, useState } from "react"; +import { Layout, Menu, Button, Space, Avatar, Dropdown, message, type MenuProps, Select } from "antd"; +import { useEffect, useState, useMemo } from "react"; import { Link, Outlet, useLocation, useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { @@ -14,11 +14,13 @@ import { MenuFoldOutlined, BellOutlined, SettingOutlined, - GlobalOutlined + GlobalOutlined, + ShopOutlined } from "@ant-design/icons"; import { useAuth } from "../hooks/useAuth"; import { usePermission } from "../hooks/usePermission"; -import { listMyPermissions } from "../api"; +import { listMyPermissions, getCurrentUser } from "../api"; +import { switchTenant, type TenantInfo } from "../api/auth"; import { SysPermission } from "../types"; const { Header, Sider, Content } = Layout; @@ -36,13 +38,34 @@ export default function AppLayout() { const { t, i18n } = useTranslation(); const [collapsed, setCollapsed] = useState(false); const [menus, setMenus] = useState([]); + const [availableTenants, setAvailableTenants] = useState([]); + const [currentTenantId, setCurrentTenantId] = useState(null); + const location = useLocation(); const navigate = useNavigate(); const { logout } = useAuth(); const { load: loadPermissions } = usePermission(); - const fetchMenus = async () => { + const fetchInitialData = async () => { try { + // Load tenants from localStorage + const storedTenants = localStorage.getItem("availableTenants"); + if (storedTenants) { + const tenants = JSON.parse(storedTenants) as TenantInfo[]; + setAvailableTenants(tenants); + } + + // Get current profile to know current tenant + const profileStr = sessionStorage.getItem("userProfile"); + if (profileStr) { + const profile = JSON.parse(profileStr); + // We need to know which tenant is active. The token has it, + // but for UI we can infer from profile if we update profile on switch. + // For now, let's assume we store activeTenantId in localStorage on login/switch + const activeId = localStorage.getItem("activeTenantId"); + if (activeId) setCurrentTenantId(Number(activeId)); + } + const data = await listMyPermissions(); // Load permissions into localStorage as well await loadPermissions(); @@ -58,9 +81,31 @@ export default function AppLayout() { }; useEffect(() => { - fetchMenus(); + fetchInitialData(); }, []); + const handleSwitchTenant = async (tenantId: number) => { + try { + const data = await switchTenant(tenantId); + localStorage.setItem("accessToken", data.accessToken); + localStorage.setItem("refreshToken", data.refreshToken); + localStorage.setItem("activeTenantId", String(tenantId)); + if (data.availableTenants) { + localStorage.setItem("availableTenants", JSON.stringify(data.availableTenants)); + } + + // Refresh profile + const profile = await getCurrentUser(); + sessionStorage.setItem("userProfile", JSON.stringify(profile)); + + message.success(t('common.success')); + // Reload to refresh all states and permissions + window.location.reload(); + } catch (e: any) { + message.error(e.message || t('common.error')); + } + }; + const handleLogout = () => { logout(); navigate("/login"); @@ -179,6 +224,22 @@ export default function AppLayout() { onClick={() => setCollapsed(!collapsed)} style={{ fontSize: '16px', width: 64, height: 64 }} /> +
+ {availableTenants.length > 0 && ( + } - placeholder={t('login.tenantCodePlaceholder')} - aria-label={t('login.tenantCode')} - /> - - ([]); const [selectedTenantId, setSelectedTenantId] = useState(undefined); + // Platform admin check + const isPlatformMode = useMemo(() => { + const profileStr = sessionStorage.getItem("userProfile"); + if (profileStr) { + const profile = JSON.parse(profileStr); + return profile.isPlatformAdmin && localStorage.getItem("activeTenantId") === "0"; + } + return false; + }, []); + + const activeTenantId = useMemo(() => Number(localStorage.getItem("activeTenantId") || 0), []); + const [drawerOpen, setDrawerOpen] = useState(false); const [editing, setEditing] = useState(null); const [form] = Form.useForm(); @@ -74,7 +86,10 @@ export default function Orgs() { const resp = await listTenants({ current: 1, size: 100 }); const list = resp.records || []; setTenants(list); - if (list.length > 0 && selectedTenantId === undefined) { + + if (!isPlatformMode) { + setSelectedTenantId(activeTenantId); + } else if (list.length > 0 && selectedTenantId === undefined) { setSelectedTenantId(list[0].id); } } catch (e) { @@ -219,20 +234,22 @@ export default function Orgs() { )}
- - - {t('users.tenant')}: - ({ label: t.tenantName, value: t.id }))} + suffixIcon={ + + )} {selectedTenantId !== undefined ? ( @@ -271,9 +288,19 @@ export default function Orgs() { } >
- + + )} 一级入口 二级子项 + 三级按钮 @@ -387,8 +415,8 @@ export default function Permissions() { dependencies={["level"]} rules={[ ({ getFieldValue }) => ({ - required: getFieldValue("level") === 2, - message: "二级子项必须选择父级菜单" + required: getFieldValue("level") > 1, + message: "非一级入口必须选择父级" }) ]} > @@ -397,7 +425,7 @@ export default function Permissions() { showSearch placeholder={level === 1 ? "一级入口无须父级" : "请选择父级菜单…"} options={parentOptions} - disabled={level !== 2} + disabled={level === 1} aria-label={t('permissions.parentId')} /> diff --git a/frontend/src/pages/Roles.tsx b/frontend/src/pages/Roles.tsx index 26e8796..96f99bd 100644 --- a/frontend/src/pages/Roles.tsx +++ b/frontend/src/pages/Roles.tsx @@ -106,6 +106,18 @@ export default function Roles() { const [permissions, setPermissions] = useState([]); const [selectedRole, setSelectedRole] = useState(null); + // Platform admin check + const isPlatformMode = useMemo(() => { + const profileStr = sessionStorage.getItem("userProfile"); + if (profileStr) { + const profile = JSON.parse(profileStr); + return profile.isPlatformAdmin && localStorage.getItem("activeTenantId") === "0"; + } + return false; + }, []); + + const activeTenantId = useMemo(() => Number(localStorage.getItem("activeTenantId") || 0), []); + // Right side states const [selectedPermIds, setSelectedPermIds] = useState([]); const [halfCheckedIds, setHalfCheckedIds] = useState([]); @@ -120,14 +132,28 @@ export default function Roles() { // Search const [searchText, setSearchText] = useState(""); + const [filterTenantId, setFilterTenantId] = useState(undefined); // Drawer (Only for Add/Edit basic info) const [drawerOpen, setDrawerOpen] = useState(false); const [editing, setEditing] = useState(null); + const [tenants, setTenants] = useState([]); const [form] = Form.useForm(); const { can } = usePermission(); + const loadTenants = async () => { + if (!isPlatformMode) return; + try { + const resp = await (await import("../api")).listTenants({ current: 1, size: 100 }); + setTenants(resp.records || []); + } catch (e) {} + }; + + useEffect(() => { + loadTenants(); + }, [isPlatformMode]); + const permissionTreeData = useMemo( () => toTreeData(buildPermissionTree(permissions), t), [permissions, t] @@ -193,7 +219,14 @@ export default function Roles() { setLoading(true); try { const list = await listRoles(); - const roles = list || []; + let roles = list || []; + + if (isPlatformMode && filterTenantId !== undefined) { + roles = roles.filter(r => r.tenantId === filterTenantId); + } else if (!isPlatformMode) { + roles = roles.filter(r => r.tenantId === activeTenantId); + } + setData(roles); if (roles.length > 0 && !selectedRole) { selectRole(roles[0]); @@ -260,7 +293,10 @@ export default function Roles() { const openCreate = () => { setEditing(null); form.resetFields(); - form.setFieldsValue({ status: 1 }); + form.setFieldsValue({ + status: 1, + tenantId: isPlatformMode ? undefined : activeTenantId + }); setDrawerOpen(true); }; @@ -291,7 +327,8 @@ export default function Roles() { roleCode: editing?.roleCode || values.roleCode || generateRoleCode(), roleName: values.roleName, remark: values.remark, - status: values.status ?? DEFAULT_STATUS + status: values.status ?? DEFAULT_STATUS, + tenantId: values.tenantId }; if (editing) { @@ -344,7 +381,17 @@ export default function Roles() { )} > -
+
+ {isPlatformMode && ( + } @@ -578,6 +625,22 @@ export default function Roles() { } > + + )} diff --git a/frontend/src/pages/Users.tsx b/frontend/src/pages/Users.tsx index fc648d1..bafb1c6 100644 --- a/frontend/src/pages/Users.tsx +++ b/frontend/src/pages/Users.tsx @@ -38,7 +38,8 @@ import { UserOutlined, ShopOutlined, ApartmentOutlined, - ReloadOutlined + ReloadOutlined, + MinusCircleOutlined } from "@ant-design/icons"; import type { SysRole, SysUser, SysTenant, SysOrg } from "../types"; import "./Users.css"; @@ -65,6 +66,39 @@ function buildOrgTree(list: SysOrg[]): any[] { return roots; } +const MembershipOrgSelect = ({ fieldProps, name, tenantId }: { fieldProps: any, name: number, tenantId?: number }) => { + const { t } = useTranslation(); + const [orgs, setOrgs] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (tenantId) { + setLoading(true); + listOrgs(tenantId).then(data => { + setOrgs(buildOrgTree(data || [])); + }).finally(() => setLoading(false)); + } else { + setOrgs([]); + } + }, [tenantId]); + + return ( + + + + ); +}; + export default function Users() { const { t } = useTranslation(); const { can } = usePermission(); @@ -75,6 +109,18 @@ export default function Users() { const [tenants, setTenants] = useState([]); const [orgs, setOrgs] = useState([]); + // Platform admin check + const isPlatformMode = useMemo(() => { + const profileStr = sessionStorage.getItem("userProfile"); + if (profileStr) { + const profile = JSON.parse(profileStr); + return profile.isPlatformAdmin && localStorage.getItem("activeTenantId") === "0"; + } + return false; + }, []); + + const activeTenantId = useMemo(() => Number(localStorage.getItem("activeTenantId") || 0), []); + // Search state const [searchText, setSearchText] = useState(""); const [filterTenantId, setFilterTenantId] = useState(undefined); @@ -151,23 +197,31 @@ export default function Users() { const openCreate = () => { setEditing(null); form.resetFields(); - form.setFieldsValue({ status: 1, roleIds: [], isPlatformAdmin: false }); + form.setFieldsValue({ + status: 1, + roleIds: [], + isPlatformAdmin: false, + tenantId: isPlatformMode ? undefined : activeTenantId, + memberships: isPlatformMode ? [] : [{ tenantId: activeTenantId }] + }); setDrawerOpen(true); }; const openEdit = async (record: SysUser) => { setEditing(record); try { + // Fetch full detail including memberships + const detail = await (await import("../api")).getUserDetail(record.userId); const roleIds = await listUserRoles(record.userId); - // Ensure orgs for the tenant are loaded - if (record.tenantId) { - const orgList = await listOrgs(record.tenantId); - setOrgs(orgList || []); - } + form.setFieldsValue({ - ...record, + ...detail, roleIds: roleIds || [], - password: "" + password: "", + // For compatibility with single tenant view if needed + tenantId: detail.tenantId || (detail.memberships?.[0]?.tenantId), + orgId: detail.orgId || (detail.memberships?.[0]?.orgId), + memberships: detail.memberships || [] }); setDrawerOpen(true); } catch (e) { @@ -196,11 +250,15 @@ export default function Users() { email: values.email, phone: values.phone, status: values.status, - tenantId: values.tenantId, - orgId: values.orgId, - isPlatformAdmin: values.isPlatformAdmin + isPlatformAdmin: values.isPlatformAdmin, + memberships: values.memberships || [] }; + // If single tenant view, ensure current active tenant is included + if (!isPlatformMode && userPayload.memberships!.length === 0) { + userPayload.memberships = [{ tenantId: activeTenantId, orgId: values.orgId } as any]; + } + if (values.password) { userPayload.passwordHash = values.password; } @@ -251,20 +309,40 @@ export default function Users() { { title: t('users.org'), key: "org", - render: (_: any, record: SysUser) => ( -
- - - {tenantMap[record.tenantId] || "未知租户"} - - {record.orgId && ( - - - {orgs.find(o => o.id === record.orgId)?.orgName || "组织节点"} + render: (_: any, record: SysUser) => { + // Platform mode: show all tenants as tags + if (isPlatformMode && record.memberships && record.memberships.length > 0) { + return ( +
+ {record.memberships.slice(0, 3).map(m => ( + + {tenantMap[m.tenantId] || `租户${m.tenantId}`} + + ))} + {record.memberships.length > 3 && 等 {record.memberships.length} 个} +
+ ); + } + + // Single tenant mode or fallback: show specific tenant and org + const tid = record.tenantId || record.memberships?.[0]?.tenantId; + const oid = record.orgId || record.memberships?.[0]?.orgId; + + return ( +
+ + + {tenantMap[tid || 0] || "未知租户"} - )} -
- ) + {oid && ( + + + 关联组织已设置 + + )} +
+ ); + } }, { title: t('common.status'), @@ -318,15 +396,17 @@ export default function Users() {
- ({ label: t.tenantName, value: t.id }))} + suffixIcon={