feat(auth): 实现多租户切换功能

- 在前端AppLayout中添加租户选择下拉框组件
- 实现switchTenant API接口用于租户间切换
- 更新登录流程以支持租户信息存储和解析
- 修改后端认证服务以支持租户上下文管理
- 添加租户权限验证和访问控制逻辑
- 重构权限查询以基于当前租户进行过滤
- 更新数据访问层以正确处理租户隔离
- 添加租户切换相关的状态管理和UI显示
master
chenhao 2026-02-26 13:53:58 +08:00
parent 26c2b977d6
commit 86009e2602
29 changed files with 689 additions and 191 deletions

View File

@ -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<String> 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());

View File

@ -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<TenantInfo> availableTenants;
@Data
@Builder
public static class TenantInfo {
private Long tenantId;
private String tenantCode;
private String tenantName;
}
}

View File

@ -15,6 +15,12 @@ public class GlobalExceptionHandler {
return ApiResponse.error(ex.getMessage());
}
@ExceptionHandler(org.springframework.security.access.AccessDeniedException.class)
public ApiResponse<Void> handleAccessDenied(org.springframework.security.access.AccessDeniedException ex) {
log.warn("Access denied: {}", ex.getMessage());
return ApiResponse.error("无权限操作");
}
@ExceptionHandler(Exception.class)
public ApiResponse<Void> handleGeneric(Exception ex) {
log.error("Unhandled exception", ex);

View File

@ -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

View File

@ -74,6 +74,15 @@ public class AuthController {
return ApiResponse.ok(authService.refresh(request.getRefreshToken()));
}
@PostMapping("/switch-tenant")
public ApiResponse<TokenResponse> 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<Void> logout(@RequestHeader("Authorization") String authorization) {
String token = authorization.replace("Bearer ", "");

View File

@ -34,8 +34,7 @@ public class PermissionController {
@GetMapping("/me")
public ApiResponse<List<SysPermission>> 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<List<PermissionNode>> 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;

View File

@ -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<SysUser>> list(@RequestParam(required = false) Long tenantId, @RequestParam(required = false) Long orgId) {
LambdaQueryWrapper<SysUser> query = new LambdaQueryWrapper<>();
if (orgId != null) {
query.eq(SysUser::getOrgId, orgId);
Long currentTenantId = getCurrentTenantId();
if (Long.valueOf(0).equals(currentTenantId) && tenantId == null) {
List<SysUser> allUsers = sysUserService.list();
// 为每个用户加载其租户关系
if (allUsers != null && !allUsers.isEmpty()) {
for (SysUser user : allUsers) {
user.setMemberships(sysTenantUserService.listByUserId(user.getUserId()));
}
return ApiResponse.ok(sysUserService.list(query));
}
return ApiResponse.ok(allUsers);
}
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<SysUser> 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}")

View File

@ -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;

View File

@ -23,5 +23,5 @@ public class SysDictItem extends BaseEntity {
private Long tenantId;
@TableField(exist = false)
private Boolean isDeleted;
private Integer isDeleted;
}

View File

@ -21,5 +21,5 @@ public class SysDictType extends BaseEntity {
private Long tenantId;
@TableField(exist = false)
private Boolean isDeleted;
private Integer isDeleted;
}

View File

@ -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<SysTenantUser> memberships;
}

View File

@ -10,12 +10,14 @@ import java.util.List;
@Mapper
public interface SysPermissionMapper extends BaseMapper<SysPermission> {
@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<SysPermission> selectByUserId(@Param("userId") Long userId);
List<SysPermission> selectByUserId(@Param("userId") Long userId, @Param("tenantId") Long tenantId);
}

View File

@ -21,14 +21,25 @@ public interface SysUserMapper extends BaseMapper<SysUser> {
@Select("SELECT * FROM sys_user WHERE user_id = #{userId} AND is_deleted = 0")
SysUser selectByIdIgnoreTenant(@Param("userId") Long userId);
@InterceptorIgnore(tenantLine = "true")
@Select("<script>" +
"SELECT u.*, tu.org_id as orgId, tu.tenant_id as tenantId " +
"FROM sys_user u " +
"JOIN sys_tenant_user tu ON u.user_id = tu.user_id " +
"WHERE tu.tenant_id = #{tenantId} " +
"<if test='orgId != null'> AND tu.org_id = #{orgId} </if> " +
"AND u.is_deleted = 0 AND tu.is_deleted = 0" +
"</script>")
List<SysUser> 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<com.imeeting.auth.dto.TokenResponse.TenantInfo> selectTenantsByUsername(@Param("username") String username);
}

View File

@ -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);
}

View File

@ -7,7 +7,7 @@ import java.util.List;
import java.util.Set;
public interface SysPermissionService extends IService<SysPermission> {
List<SysPermission> listByUserId(Long userId);
List<SysPermission> listByUserId(Long userId, Long tenantId);
Set<String> listPermissionCodesByUserId(Long userId);
Set<String> listPermissionCodesByUserId(Long userId, Long tenantId);
}

View File

@ -12,6 +12,14 @@ public interface SysUserService extends IService<SysUser> {
List<SysUser> listUsersByRoleId(Long roleId);
}
SysUser getByIdIgnoreTenant(Long userId);
List<SysUser> listUsersByTenant(Long tenantId, Long orgId);
}

View File

@ -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<TokenResponse.TenantInfo> 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<TokenResponse.TenantInfo> 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<TokenResponse.TenantInfo> 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<String, Object> 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<String, Object> 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) {

View File

@ -13,6 +13,9 @@ public class SysOrgServiceImpl extends ServiceImpl<SysOrgMapper, SysOrg> impleme
@Override
public List<SysOrg> listTree(Long tenantId) {
LambdaQueryWrapper<SysOrg> query = new LambdaQueryWrapper<>();
if (tenantId != null) {
query.eq(SysOrg::getTenantId, tenantId);
}
query.orderByAsc(SysOrg::getSortOrder);
return list(query);
}

View File

@ -23,32 +23,25 @@ public class SysPermissionServiceImpl extends ServiceImpl<SysPermissionMapper, S
}
@Override
public List<SysPermission> listByUserId(Long userId) {
public List<SysPermission> 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())) {
return list();
}
}
// Fallback or specific user logic (for tenant users or internal calls)
if (userId == 1L) {
SysUser user = sysUserService.getByIdIgnoreTenant(userId);
if (user != null && Boolean.TRUE.equals(user.getIsPlatformAdmin()) && Long.valueOf(0).equals(tenantId)) {
return list();
}
return baseMapper.selectByUserId(userId);
// 如果没有指定租户或者租户为0但用户不是平台管理员则返回空或按默认逻辑通常需要指定租户
if (tenantId == null) return List.of();
return baseMapper.selectByUserId(userId, tenantId);
}
@Override
public Set<String> listPermissionCodesByUserId(Long userId) {
List<SysPermission> perms = listByUserId(userId);
public Set<String> listPermissionCodesByUserId(Long userId, Long tenantId) {
List<SysPermission> perms = listByUserId(userId, tenantId);
return perms.stream()
.map(SysPermission::getCode)
.filter(code -> code != null && !code.isEmpty())

View File

@ -17,6 +17,16 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
return baseMapper.selectUsersByRoleId(roleId);
}
@Override
public SysUser getByIdIgnoreTenant(Long userId) {
return baseMapper.selectByIdIgnoreTenant(userId);
}
@Override
public List<SysUser> 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<SysUserMapper, SysUser> impl
}
if (count(query) > 0) {
throw new IllegalArgumentException("该租户下已存在名为 [" + user.getUsername() + "] 的用户");
throw new IllegalArgumentException("用户名 [" + user.getUsername() + "] 已被占用");
}
}
}

View File

@ -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;
}

View File

@ -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);
}
);

View File

@ -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[];

View File

@ -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<SysPermission[]>([]);
const [availableTenants, setAvailableTenants] = useState<TenantInfo[]>([]);
const [currentTenantId, setCurrentTenantId] = useState<number | null>(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 }}
/>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', paddingLeft: 12 }}>
{availableTenants.length > 0 && (
<Select
value={currentTenantId}
onChange={handleSwitchTenant}
style={{ width: 200 }}
placeholder="切换租户"
variant="borderless"
suffixIcon={<ShopOutlined />}
options={availableTenants.map(t => ({
label: t.tenantName,
value: t.tenantId
}))}
/>
)}
</div>
<Space size={20}>
<Dropdown menu={{ items: langMenuItems }} placement="bottomRight">
<GlobalOutlined style={{ fontSize: '18px', color: '#666', cursor: 'pointer' }} />

View File

@ -66,6 +66,15 @@ export default function Login() {
localStorage.setItem("accessToken", data.accessToken);
localStorage.setItem("refreshToken", data.refreshToken);
localStorage.setItem("username", values.username);
if (data.availableTenants) {
localStorage.setItem("availableTenants", JSON.stringify(data.availableTenants));
// We should infer activeTenantId from token or just use the first/default logic
// For simplicity, we can parse the JWT to get tenantId, or backend can return it.
// Let's assume for now we use the first one if not platform admin, or the backend logic.
// Actually, if we use a helper to parse JWT:
const payload = JSON.parse(atob(data.accessToken.split('.')[1]));
localStorage.setItem("activeTenantId", String(payload.tenantId));
}
try {
const profile = await getCurrentUser();
sessionStorage.setItem("userProfile", JSON.stringify(profile));
@ -125,18 +134,6 @@ export default function Login() {
requiredMark={false}
autoComplete="off"
>
<Form.Item
name="tenantCode"
rules={[{ required: false }]}
>
<Input
size="large"
prefix={<ShopOutlined className="text-gray-400" aria-hidden="true" />}
placeholder={t('login.tenantCodePlaceholder')}
aria-label={t('login.tenantCode')}
/>
</Form.Item>
<Form.Item
name="username"
rules={[{ required: true, message: t('login.username') }]}

View File

@ -65,6 +65,18 @@ export default function Orgs() {
const [tenants, setTenants] = useState<SysTenant[]>([]);
const [selectedTenantId, setSelectedTenantId] = useState<number | undefined>(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<SysOrg | null>(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,6 +234,7 @@ export default function Orgs() {
)}
</div>
{isPlatformMode && (
<Card className="shadow-sm mb-4">
<Space>
<Text strong>{t('users.tenant')}</Text>
@ -233,6 +249,7 @@ export default function Orgs() {
<Button icon={<ReloadOutlined aria-hidden="true" />} onClick={loadOrgs}>{t('common.refresh')}</Button>
</Space>
</Card>
)}
<Card className="shadow-sm" styles={{ body: { padding: 0 } }}>
{selectedTenantId !== undefined ? (
@ -271,9 +288,19 @@ export default function Orgs() {
}
>
<Form form={form} layout="vertical">
<Form.Item label={t('users.tenant')} name="tenantId" rules={[{ required: true }]}>
<Form.Item
label={t('users.tenant')}
name="tenantId"
rules={[{ required: true }]}
hidden={!isPlatformMode}
>
<Select disabled options={tenants.map(t => ({ label: t.tenantName, value: t.id }))} />
</Form.Item>
{!isPlatformMode && (
<Form.Item label={t('users.tenant')}>
<Input value={tenants.find(t => t.id === activeTenantId)?.tenantName || "当前租户"} disabled />
</Form.Item>
)}
<Form.Item label={t('orgs.parentOrg')} name="parentId">
<Select

View File

@ -102,7 +102,7 @@ export default function Permissions() {
const parentOptions = useMemo(() => {
return data
.filter((p) => p.permType === "menu" && (p.level === 1 || !p.parentId))
.filter((p) => p.permType === "menu")
.map((p) => ({ value: p.permId, label: p.name }));
}, [data]);
@ -119,6 +119,20 @@ export default function Permissions() {
setOpen(true);
};
const openAddChild = (record: SysPermission) => {
setEditing(null);
form.resetFields();
form.setFieldsValue({
parentId: record.permId,
level: Math.min((record.level || 1) + 1, 3),
permType: record.level === 1 ? "menu" : "button",
status: 1,
isVisible: 1,
sortOrder: 0
});
setOpen(true);
};
const submit = async () => {
try {
const values = await form.validateFields();
@ -229,10 +243,20 @@ export default function Permissions() {
},
{
title: t('common.action'),
width: 120,
width: 150,
fixed: "right" as const,
render: (_: any, record: SysPermission) => (
<Space>
{can("sys_permission:create") && record.permType === 'menu' && (
<Tooltip title="添加子项">
<Button
type="text"
size="small"
icon={<PlusOutlined aria-hidden="true" />}
onClick={() => openAddChild(record)}
/>
</Tooltip>
)}
{can("sys_permission:update") && (
<Button
type="text"
@ -363,6 +387,9 @@ export default function Permissions() {
if (changed.level === 1) {
form.setFieldsValue({ parentId: undefined });
}
if (changed.level === 3) {
form.setFieldsValue({ permType: "button" });
}
}}
>
<Row gutter={16}>
@ -371,6 +398,7 @@ export default function Permissions() {
<Select aria-label={t('permissions.level')}>
<Select.Option value={1}></Select.Option>
<Select.Option value={2}></Select.Option>
<Select.Option value={3}></Select.Option>
</Select>
</Form.Item>
</Col>
@ -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')}
/>
</Form.Item>

View File

@ -106,6 +106,18 @@ export default function Roles() {
const [permissions, setPermissions] = useState<SysPermission[]>([]);
const [selectedRole, setSelectedRole] = useState<SysRole | null>(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<number[]>([]);
const [halfCheckedIds, setHalfCheckedIds] = useState<number[]>([]);
@ -120,14 +132,28 @@ export default function Roles() {
// Search
const [searchText, setSearchText] = useState("");
const [filterTenantId, setFilterTenantId] = useState<number | undefined>(undefined);
// Drawer (Only for Add/Edit basic info)
const [drawerOpen, setDrawerOpen] = useState(false);
const [editing, setEditing] = useState<SysRole | null>(null);
const [tenants, setTenants] = useState<SysTenant[]>([]);
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() {
</Button>
)}
>
<div className="mb-4">
<div className="mb-4 flex gap-2">
{isPlatformMode && (
<Select
placeholder={t('users.tenantFilter')}
style={{ width: 150 }}
allowClear
value={filterTenantId}
onChange={setFilterTenantId}
options={tenants.map(t => ({ label: t.tenantName, value: t.id }))}
/>
)}
<Input
placeholder={t('roles.searchPlaceholder')}
prefix={<SearchOutlined aria-hidden="true" />}
@ -578,6 +625,22 @@ export default function Roles() {
}
>
<Form form={form} layout="vertical">
<Form.Item
label={t('users.tenant')}
name="tenantId"
rules={[{ required: true }]}
hidden={!isPlatformMode}
>
<Select
options={tenants.map(t => ({ label: t.tenantName, value: t.id }))}
disabled={!!editing}
/>
</Form.Item>
{!isPlatformMode && (
<Form.Item label={t('users.tenant')}>
<Input value={tenants.find(t => t.id === activeTenantId)?.tenantName || "当前租户"} disabled />
</Form.Item>
)}
<Form.Item label={t('roles.roleName')} name="roleName" rules={[{ required: true }]}>
<Input placeholder={t('roles.roleName')} />
</Form.Item>

View File

@ -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<any[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (tenantId) {
setLoading(true);
listOrgs(tenantId).then(data => {
setOrgs(buildOrgTree(data || []));
}).finally(() => setLoading(false));
} else {
setOrgs([]);
}
}, [tenantId]);
return (
<Form.Item
{...fieldProps}
label={t('users.orgNode')}
name={[name, 'orgId']}
>
<TreeSelect
placeholder="选择部门"
allowClear
treeData={orgs}
loading={loading}
disabled={!tenantId}
/>
</Form.Item>
);
};
export default function Users() {
const { t } = useTranslation();
const { can } = usePermission();
@ -75,6 +109,18 @@ export default function Users() {
const [tenants, setTenants] = useState<SysTenant[]>([]);
const [orgs, setOrgs] = useState<SysOrg[]>([]);
// 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<number | undefined>(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) => (
render: (_: any, record: SysUser) => {
// Platform mode: show all tenants as tags
if (isPlatformMode && record.memberships && record.memberships.length > 0) {
return (
<div className="flex flex-col gap-1">
{record.memberships.slice(0, 3).map(m => (
<Tag key={m.tenantId} color="blue" style={{ margin: 0, padding: '0 4px', fontSize: 11 }}>
{tenantMap[m.tenantId] || `租户${m.tenantId}`}
</Tag>
))}
{record.memberships.length > 3 && <Text type="secondary" style={{ fontSize: 11 }}> {record.memberships.length} </Text>}
</div>
);
}
// 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 (
<div className="flex flex-col gap-1">
<Space size={4} style={{ fontSize: 13 }}>
<ShopOutlined style={{ color: '#8c8c8c' }} />
<span>{tenantMap[record.tenantId] || "未知租户"}</span>
<span>{tenantMap[tid || 0] || "未知租户"}</span>
</Space>
{record.orgId && (
{oid && (
<Space size={4} style={{ fontSize: 12, color: '#8c8c8c' }}>
<ApartmentOutlined />
<span>{orgs.find(o => o.id === record.orgId)?.orgName || "组织节点"}</span>
<span></span>
</Space>
)}
</div>
)
);
}
},
{
title: t('common.status'),
@ -318,6 +396,7 @@ export default function Users() {
<Card className="users-table-card shadow-sm">
<div className="users-table-toolbar mb-4">
<Space size="middle" wrap>
{isPlatformMode && (
<Select
placeholder={t('users.tenantFilter')}
style={{ width: 200 }}
@ -327,6 +406,7 @@ export default function Users() {
options={tenants.map(t => ({ label: t.tenantName, value: t.id }))}
suffixIcon={<ShopOutlined aria-hidden="true" />}
/>
)}
<Input
placeholder={t('users.searchPlaceholder')}
prefix={<SearchOutlined aria-hidden="true" />}
@ -375,29 +455,7 @@ export default function Users() {
}
>
<Form form={form} layout="vertical" className="user-form">
<Row gutter={16}>
<Col span={12}>
<Form.Item label={t('users.tenant')} name="tenantId" rules={[{ required: true, message: t('users.tenant') }]}>
<Select
placeholder={t('users.tenant')}
showSearch
optionFilterProp="label"
options={tenants.map(t => ({ label: t.tenantName, value: t.id }))}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label={t('users.orgNode')} name="orgId">
<TreeSelect
placeholder={t('users.orgNode')}
allowClear
treeData={orgTreeData}
disabled={!selectedTenantId}
/>
</Form.Item>
</Col>
</Row>
<Title level={5} style={{ marginBottom: 16 }}></Title>
<Row gutter={16}>
<Col span={12}>
<Form.Item label={t('users.username')} name="username" rules={[{ required: true, message: t('users.username') }]}>
@ -452,6 +510,56 @@ export default function Users() {
</Form.Item>
</Col>
</Row>
<Title level={5} style={{ marginTop: 24, marginBottom: 16 }}></Title>
<Form.List name="memberships">
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...restField }) => (
<Card
key={key}
size="small"
className="mb-3"
styles={{ body: { padding: '12px' } }}
title={isPlatformMode ? `身份 #${name + 1}` : undefined}
extra={isPlatformMode && fields.length > 1 && (
<Button type="text" danger icon={<MinusCircleOutlined />} onClick={() => remove(name)} />
)}
>
<Row gutter={12}>
<Col span={12}>
<Form.Item
{...restField}
label={t('users.tenant')}
name={[name, 'tenantId']}
rules={[{ required: true, message: '必填' }]}
>
<Select
options={tenants.map(t => ({ label: t.tenantName, value: t.id }))}
disabled={!isPlatformMode}
placeholder="选择租户"
/>
</Form.Item>
</Col>
<Col span={12}>
<MembershipOrgSelect
fieldProps={{...restField}}
name={name}
tenantId={form.getFieldValue(['memberships', name, 'tenantId'])}
/>
</Col>
</Row>
</Card>
))}
{isPlatformMode && (
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
</Button>
)}
</>
)}
</Form.List>
</Form>
</Drawer>
</div>