feat(auth): 实现多租户切换功能
- 在前端AppLayout中添加租户选择下拉框组件 - 实现switchTenant API接口用于租户间切换 - 更新登录流程以支持租户信息存储和解析 - 修改后端认证服务以支持租户上下文管理 - 添加租户权限验证和访问控制逻辑 - 重构权限查询以基于当前租户进行过滤 - 更新数据访问层以正确处理租户隔离 - 添加租户切换相关的状态管理和UI显示master
parent
26c2b977d6
commit
86009e2602
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ", "");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -23,5 +23,5 @@ public class SysDictItem extends BaseEntity {
|
|||
private Long tenantId;
|
||||
|
||||
@TableField(exist = false)
|
||||
private Boolean isDeleted;
|
||||
private Integer isDeleted;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,5 +21,5 @@ public class SysDictType extends BaseEntity {
|
|||
private Long tenantId;
|
||||
|
||||
@TableField(exist = false)
|
||||
private Boolean isDeleted;
|
||||
private Integer isDeleted;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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() + "] 已被占用");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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' }} />
|
||||
|
|
|
|||
|
|
@ -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') }]}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue