feat: Phase 5 - 권한 관리 UI 고도화 (트리 RBAC PermissionsPanel)
버그 수정:
- AccessControl 무한 새로고침 (loadRoles의 userStats 의존성 → setUserStats 호출 → 무한 루프)
loadRoles에서 항상 fetchUserStats를 같이 호출하도록 변경
백엔드 API 추가:
- RoleManagementService (역할/권한 매트릭스 CRUD)
- createRole / updateRole / deleteRole (built-in 보호)
- updatePermissions (Y/N upsert + null 시 명시 권한 제거)
- assignUserRoles (전체 교체 방식, 권한 캐시 evict)
- 모든 액션에 @Auditable 자동 기록
- PermTreeController 확장:
- POST /api/roles (admin:role-management:CREATE)
- PUT /api/roles/{sn} (admin:role-management:UPDATE)
- DELETE /api/roles/{sn} (admin:role-management:DELETE)
- PUT /api/roles/{sn}/permissions (admin:permission-management:UPDATE)
- PUT /api/admin/users/{id}/roles (admin:user-management:UPDATE)
- DTO: RoleCreateRequest, RoleUpdateRequest, PermissionUpdateRequest, UserRoleAssignRequest
- GlobalExceptionHandler:
- IllegalArgumentException → 400 BAD_REQUEST
- IllegalStateException → 409 CONFLICT
- AccessDeniedException → 403 FORBIDDEN
프론트엔드:
- lib/permission/permResolver.ts (TypeScript 미러)
- resolveSingleRoleEffective: 백엔드 PermResolver와 동일 알고리즘
- 4가지 셀 상태 계산 (explicit-granted/inherited-granted/explicit-denied/forced-denied)
- PermissionsPanel.tsx (트리 + R/C/U/D/E 매트릭스)
- 좌측: 역할 목록 + 신규 생성 + 삭제 (built-in 보호)
- 우측: 트리 표 + 셀 클릭 (Y → N → 미지정 순환)
- 부모 READ 게이팅 시각화 (강제 거부 회색 비활성)
- 변경된 셀만 일괄 저장 (dirty 추적)
- UserRoleAssignDialog.tsx
- 사용자에게 역할 다중 선택 배정 (체크박스)
- adminApi.ts 확장: createRole/updateRole/deleteRole/updateRolePermissions/assignUserRoles
- AccessControl.tsx 갱신:
- 역할 관리 탭 → PermissionsPanel 통합
- 사용자 관리 탭 → 역할 배정 버튼 추가 (UserCog 아이콘)
검증:
- 역할 생성 → TESTROLE 6번으로 추가
- 권한 매트릭스 갱신 → dashboard/monitoring READ 부여 (changed: 2)
- 역할 삭제 → built-in이 아니면 OK
- built-in ADMIN 삭제 시도 → 400 BAD_REQUEST (BUILTIN_ROLE_CANNOT_DELETE)
- viewer에게 OPERATOR + ANALYST 다중 배정 → roles=[OPERATOR, ANALYST]
→ 재로그인 시 detection READ 등 자동 상속 확인
- 권한 캐시 evictAllPermissions 즉시 반영
- 프론트 빌드 통과 (533ms)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
fc1a686700
커밋
febfb2cbe8
@ -0,0 +1,55 @@
|
|||||||
|
package gc.mda.kcg.common.exception;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.AccessDeniedException;
|
||||||
|
import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전역 예외 처리.
|
||||||
|
* - IllegalArgumentException → 400
|
||||||
|
* - AccessDeniedException → 403
|
||||||
|
* - AuthenticationCredentialsNotFoundException → 401
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
@ExceptionHandler(IllegalArgumentException.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleIllegal(IllegalArgumentException e) {
|
||||||
|
log.debug("400 Bad Request: {}", e.getMessage());
|
||||||
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
|
"error", "BAD_REQUEST",
|
||||||
|
"message", e.getMessage() == null ? "" : e.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(IllegalStateException.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleIllegalState(IllegalStateException e) {
|
||||||
|
log.debug("409 Conflict: {}", e.getMessage());
|
||||||
|
return ResponseEntity.status(409).body(Map.of(
|
||||||
|
"error", "CONFLICT",
|
||||||
|
"message", e.getMessage() == null ? "" : e.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(AccessDeniedException.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleAccessDenied(AccessDeniedException e) {
|
||||||
|
return ResponseEntity.status(403).body(Map.of(
|
||||||
|
"error", "FORBIDDEN",
|
||||||
|
"message", e.getMessage() == null ? "" : e.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(AuthenticationCredentialsNotFoundException.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleNoAuth(AuthenticationCredentialsNotFoundException e) {
|
||||||
|
return ResponseEntity.status(401).body(Map.of(
|
||||||
|
"error", "UNAUTHENTICATED",
|
||||||
|
"message", e.getMessage() == null ? "" : e.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,13 +5,18 @@ import org.springframework.data.jpa.repository.Query;
|
|||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface PermRepository extends JpaRepository<Perm, Long> {
|
public interface PermRepository extends JpaRepository<Perm, Long> {
|
||||||
|
|
||||||
List<Perm> findByRoleSn(Long roleSn);
|
List<Perm> findByRoleSn(Long roleSn);
|
||||||
|
|
||||||
|
Optional<Perm> findByRoleSnAndRsrcCdAndOperCd(Long roleSn, String rsrcCd, String operCd);
|
||||||
|
|
||||||
@Query("SELECT p FROM Perm p WHERE p.roleSn IN :roleSns")
|
@Query("SELECT p FROM Perm p WHERE p.roleSn IN :roleSns")
|
||||||
List<Perm> findByRoleSnIn(@Param("roleSns") List<Long> roleSns);
|
List<Perm> findByRoleSnIn(@Param("roleSns") List<Long> roleSns);
|
||||||
|
|
||||||
void deleteByRoleSn(Long roleSn);
|
void deleteByRoleSn(Long roleSn);
|
||||||
|
|
||||||
|
void deleteByRoleSnAndRsrcCdAndOperCd(Long roleSn, String rsrcCd, String operCd);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,18 +1,23 @@
|
|||||||
package gc.mda.kcg.permission;
|
package gc.mda.kcg.permission;
|
||||||
|
|
||||||
|
import gc.mda.kcg.permission.dto.PermissionUpdateRequest;
|
||||||
|
import gc.mda.kcg.permission.dto.RoleCreateRequest;
|
||||||
|
import gc.mda.kcg.permission.dto.RoleUpdateRequest;
|
||||||
|
import gc.mda.kcg.permission.dto.UserRoleAssignRequest;
|
||||||
import gc.mda.kcg.permission.annotation.RequirePermission;
|
import gc.mda.kcg.permission.annotation.RequirePermission;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 권한 트리 + 역할 조회 API.
|
* 권한 트리 + 역할 + 사용자 역할 배정 API.
|
||||||
* - GET /api/perm-tree: 모든 사용자 (메뉴/사이드바 구성용)
|
* - 트리 조회: 모든 사용자
|
||||||
* - GET /api/roles: admin:permission-management 권한 필요
|
* - 역할/권한 CRUD: admin:role-management 또는 admin:permission-management
|
||||||
|
* - 사용자 역할 배정: admin:user-management (UPDATE)
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@ -21,12 +26,21 @@ public class PermTreeController {
|
|||||||
private final PermTreeRepository permTreeRepository;
|
private final PermTreeRepository permTreeRepository;
|
||||||
private final RoleRepository roleRepository;
|
private final RoleRepository roleRepository;
|
||||||
private final PermRepository permRepository;
|
private final PermRepository permRepository;
|
||||||
|
private final RoleManagementService roleManagementService;
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 권한 트리 (모든 사용자)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
@GetMapping("/api/perm-tree")
|
@GetMapping("/api/perm-tree")
|
||||||
public List<PermTree> getPermTree() {
|
public List<PermTree> getPermTree() {
|
||||||
return permTreeRepository.findAllByOrderByRsrcLevelAscSortOrdAsc();
|
return permTreeRepository.findAllByOrderByRsrcLevelAscSortOrdAsc();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 역할 조회 + 권한 매트릭스
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
@GetMapping("/api/roles")
|
@GetMapping("/api/roles")
|
||||||
@RequirePermission(resource = "admin:role-management", operation = "READ")
|
@RequirePermission(resource = "admin:role-management", operation = "READ")
|
||||||
public List<Map<String, Object>> getRolesWithPermissions() {
|
public List<Map<String, Object>> getRolesWithPermissions() {
|
||||||
@ -44,4 +58,57 @@ public class PermTreeController {
|
|||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 역할 CRUD
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
@PostMapping("/api/roles")
|
||||||
|
@RequirePermission(resource = "admin:role-management", operation = "CREATE")
|
||||||
|
public Role createRole(@Valid @RequestBody RoleCreateRequest req) {
|
||||||
|
return roleManagementService.createRole(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/api/roles/{roleSn}")
|
||||||
|
@RequirePermission(resource = "admin:role-management", operation = "UPDATE")
|
||||||
|
public Role updateRole(@PathVariable Long roleSn, @RequestBody RoleUpdateRequest req) {
|
||||||
|
return roleManagementService.updateRole(roleSn, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/api/roles/{roleSn}")
|
||||||
|
@RequirePermission(resource = "admin:role-management", operation = "DELETE")
|
||||||
|
public Map<String, Object> deleteRole(@PathVariable Long roleSn) {
|
||||||
|
roleManagementService.deleteRole(roleSn);
|
||||||
|
return Map.of("ok", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 권한 매트릭스 일괄 갱신
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
@PutMapping("/api/roles/{roleSn}/permissions")
|
||||||
|
@RequirePermission(resource = "admin:permission-management", operation = "UPDATE")
|
||||||
|
public Map<String, Object> updatePermissions(
|
||||||
|
@PathVariable Long roleSn,
|
||||||
|
@Valid @RequestBody PermissionUpdateRequest req
|
||||||
|
) {
|
||||||
|
int changed = roleManagementService.updatePermissions(roleSn, req);
|
||||||
|
return Map.of("ok", true, "changed", changed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 사용자 역할 배정
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
@PutMapping("/api/admin/users/{userId}/roles")
|
||||||
|
@RequirePermission(resource = "admin:user-management", operation = "UPDATE")
|
||||||
|
public Map<String, Object> assignUserRoles(
|
||||||
|
@PathVariable String userId,
|
||||||
|
@RequestBody UserRoleAssignRequest req
|
||||||
|
) {
|
||||||
|
UUID uid = UUID.fromString(userId);
|
||||||
|
List<Long> roleSns = req.roleSns() == null ? List.of() : req.roleSns();
|
||||||
|
List<String> assigned = roleManagementService.assignUserRoles(uid, roleSns);
|
||||||
|
return Map.of("userId", userId, "roles", assigned);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,151 @@
|
|||||||
|
package gc.mda.kcg.permission;
|
||||||
|
|
||||||
|
import gc.mda.kcg.audit.annotation.Auditable;
|
||||||
|
import gc.mda.kcg.permission.dto.PermissionUpdateRequest;
|
||||||
|
import gc.mda.kcg.permission.dto.RoleCreateRequest;
|
||||||
|
import gc.mda.kcg.permission.dto.RoleUpdateRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 역할/권한 매트릭스 CRUD 서비스.
|
||||||
|
*
|
||||||
|
* 권한 변경 시 PermissionService.evictAllPermissions() 호출 → 캐시 무효화.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RoleManagementService {
|
||||||
|
|
||||||
|
private final RoleRepository roleRepository;
|
||||||
|
private final PermRepository permRepository;
|
||||||
|
private final PermTreeRepository permTreeRepository;
|
||||||
|
private final UserRoleRepository userRoleRepository;
|
||||||
|
private final PermissionService permissionService;
|
||||||
|
|
||||||
|
@Auditable(action = "ROLE_CREATE", resourceType = "ROLE")
|
||||||
|
@Transactional
|
||||||
|
public Role createRole(RoleCreateRequest req) {
|
||||||
|
if (roleRepository.findByRoleCd(req.roleCd()).isPresent()) {
|
||||||
|
throw new IllegalArgumentException("ROLE_CD_DUPLICATED: " + req.roleCd());
|
||||||
|
}
|
||||||
|
Role role = Role.builder()
|
||||||
|
.roleCd(req.roleCd().toUpperCase())
|
||||||
|
.roleNm(req.roleNm())
|
||||||
|
.roleDc(req.roleDc())
|
||||||
|
.dfltYn("Y".equalsIgnoreCase(req.dfltYn()) ? "Y" : "N")
|
||||||
|
.builtinYn("N")
|
||||||
|
.build();
|
||||||
|
Role saved = roleRepository.save(role);
|
||||||
|
|
||||||
|
// 신규 역할은 최상위 트리 노드의 READ를 N으로 명시 (deny)하여 안전한 기본값 설정
|
||||||
|
// 운영자가 PermissionsPanel에서 점진적으로 권한 부여
|
||||||
|
List<PermTree> rootNodes = permTreeRepository.findAllByOrderByRsrcLevelAscSortOrdAsc()
|
||||||
|
.stream().filter(n -> n.getRsrcLevel() == 0).toList();
|
||||||
|
for (PermTree node : rootNodes) {
|
||||||
|
permRepository.save(Perm.builder()
|
||||||
|
.roleSn(saved.getRoleSn())
|
||||||
|
.rsrcCd(node.getRsrcCd())
|
||||||
|
.operCd("READ")
|
||||||
|
.grantYn("N")
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
permissionService.evictAllPermissions();
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Auditable(action = "ROLE_UPDATE", resourceType = "ROLE")
|
||||||
|
@Transactional
|
||||||
|
public Role updateRole(Long roleSn, RoleUpdateRequest req) {
|
||||||
|
Role role = roleRepository.findById(roleSn)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("ROLE_NOT_FOUND: " + roleSn));
|
||||||
|
if ("Y".equals(role.getBuiltinYn())) {
|
||||||
|
// builtin 역할도 이름/설명/기본여부는 수정 가능 (코드만 보호)
|
||||||
|
}
|
||||||
|
if (req.roleNm() != null) role.setRoleNm(req.roleNm());
|
||||||
|
if (req.roleDc() != null) role.setRoleDc(req.roleDc());
|
||||||
|
if (req.dfltYn() != null) role.setDfltYn("Y".equalsIgnoreCase(req.dfltYn()) ? "Y" : "N");
|
||||||
|
return roleRepository.save(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Auditable(action = "ROLE_DELETE", resourceType = "ROLE")
|
||||||
|
@Transactional
|
||||||
|
public void deleteRole(Long roleSn) {
|
||||||
|
Role role = roleRepository.findById(roleSn)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("ROLE_NOT_FOUND: " + roleSn));
|
||||||
|
if ("Y".equals(role.getBuiltinYn())) {
|
||||||
|
throw new IllegalArgumentException("BUILTIN_ROLE_CANNOT_DELETE: " + role.getRoleCd());
|
||||||
|
}
|
||||||
|
// 권한, 사용자 매핑 cascade로 자동 삭제
|
||||||
|
roleRepository.delete(role);
|
||||||
|
permissionService.evictAllPermissions();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 역할의 권한 매트릭스 일괄 갱신.
|
||||||
|
* - grantYn=Y/N → upsert
|
||||||
|
* - grantYn=null/빈값 → 명시 권한 제거 (트리 상속 모드 복귀)
|
||||||
|
*/
|
||||||
|
@Auditable(action = "PERM_UPDATE", resourceType = "ROLE")
|
||||||
|
@Transactional
|
||||||
|
public int updatePermissions(Long roleSn, PermissionUpdateRequest req) {
|
||||||
|
Role role = roleRepository.findById(roleSn)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("ROLE_NOT_FOUND: " + roleSn));
|
||||||
|
|
||||||
|
int changed = 0;
|
||||||
|
for (PermissionUpdateRequest.PermEntry entry : req.permissions()) {
|
||||||
|
String grantYn = entry.grantYn();
|
||||||
|
if (grantYn == null || grantYn.isBlank()) {
|
||||||
|
// 명시 권한 제거
|
||||||
|
permRepository.findByRoleSnAndRsrcCdAndOperCd(roleSn, entry.rsrcCd(), entry.operCd())
|
||||||
|
.ifPresent(p -> permRepository.delete(p));
|
||||||
|
changed++;
|
||||||
|
} else if ("Y".equalsIgnoreCase(grantYn) || "N".equalsIgnoreCase(grantYn)) {
|
||||||
|
Perm perm = permRepository.findByRoleSnAndRsrcCdAndOperCd(roleSn, entry.rsrcCd(), entry.operCd())
|
||||||
|
.orElseGet(() -> Perm.builder()
|
||||||
|
.roleSn(roleSn)
|
||||||
|
.rsrcCd(entry.rsrcCd())
|
||||||
|
.operCd(entry.operCd())
|
||||||
|
.build());
|
||||||
|
perm.setGrantYn(grantYn.toUpperCase());
|
||||||
|
permRepository.save(perm);
|
||||||
|
changed++;
|
||||||
|
} else {
|
||||||
|
throw new IllegalArgumentException("INVALID_GRANT_YN: " + grantYn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
permissionService.evictAllPermissions();
|
||||||
|
log.info("역할 {} 권한 {}건 갱신", role.getRoleCd(), changed);
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자에게 역할 일괄 배정 (전체 교체).
|
||||||
|
*/
|
||||||
|
@Auditable(action = "USER_ROLE_ASSIGN", resourceType = "USER")
|
||||||
|
@Transactional
|
||||||
|
public List<String> assignUserRoles(UUID userId, List<Long> roleSns) {
|
||||||
|
// 기존 매핑 전체 삭제
|
||||||
|
userRoleRepository.deleteByUserId(userId);
|
||||||
|
|
||||||
|
// 신규 매핑 생성
|
||||||
|
for (Long roleSn : roleSns) {
|
||||||
|
roleRepository.findById(roleSn)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("ROLE_NOT_FOUND: " + roleSn));
|
||||||
|
userRoleRepository.save(UserRole.builder()
|
||||||
|
.userId(userId)
|
||||||
|
.roleSn(roleSn)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
permissionService.evictUserPermissions(userId);
|
||||||
|
return userRoleRepository.findRoleCodesByUserId(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package gc.mda.kcg.permission.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 역할의 권한 매트릭스 일괄 업데이트.
|
||||||
|
* 클라이언트에서 변경된 셀(또는 전체)을 보내고, 백엔드는 upsert/delete 처리.
|
||||||
|
*
|
||||||
|
* 규칙:
|
||||||
|
* - grantYn = "Y" 또는 "N" → 명시적 권한으로 upsert
|
||||||
|
* - grantYn = null 또는 "" → 명시적 권한 제거 (트리 상속 모드로 복귀)
|
||||||
|
*/
|
||||||
|
public record PermissionUpdateRequest(
|
||||||
|
@NotEmpty List<PermEntry> permissions
|
||||||
|
) {
|
||||||
|
public record PermEntry(
|
||||||
|
String rsrcCd,
|
||||||
|
String operCd,
|
||||||
|
String grantYn // "Y" / "N" / null (제거)
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
package gc.mda.kcg.permission.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record RoleCreateRequest(
|
||||||
|
@NotBlank String roleCd,
|
||||||
|
@NotBlank String roleNm,
|
||||||
|
String roleDc,
|
||||||
|
String dfltYn
|
||||||
|
) {}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
package gc.mda.kcg.permission.dto;
|
||||||
|
|
||||||
|
public record RoleUpdateRequest(
|
||||||
|
String roleNm,
|
||||||
|
String roleDc,
|
||||||
|
String dfltYn
|
||||||
|
) {}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
package gc.mda.kcg.permission.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자에게 역할 일괄 배정.
|
||||||
|
* roleSns: 부여할 역할의 sn 리스트 (전체 교체 방식)
|
||||||
|
*/
|
||||||
|
public record UserRoleAssignRequest(
|
||||||
|
List<Long> roleSns
|
||||||
|
) {}
|
||||||
@ -4,21 +4,21 @@ import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/
|
|||||||
import { Badge } from '@shared/components/ui/badge';
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||||
import {
|
import {
|
||||||
Shield, Users, UserCheck, Key, Lock, FileText, Loader2, RefreshCw, Eye,
|
Shield, Users, UserCheck, Key, Lock, FileText, Loader2, RefreshCw, UserCog,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
fetchUsers,
|
fetchUsers,
|
||||||
fetchUserStats,
|
fetchUserStats,
|
||||||
fetchRoles,
|
|
||||||
fetchAuditLogs,
|
fetchAuditLogs,
|
||||||
fetchAuditStats,
|
fetchAuditStats,
|
||||||
unlockUser,
|
unlockUser,
|
||||||
type AdminUser,
|
type AdminUser,
|
||||||
type UserStats,
|
type UserStats,
|
||||||
type RoleWithPermissions,
|
|
||||||
type AuditLog as ApiAuditLog,
|
type AuditLog as ApiAuditLog,
|
||||||
type AuditStats,
|
type AuditStats,
|
||||||
} from '@/services/adminApi';
|
} from '@/services/adminApi';
|
||||||
|
import { PermissionsPanel } from './PermissionsPanel';
|
||||||
|
import { UserRoleAssignDialog } from './UserRoleAssignDialog';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* SFR-01: 역할 기반 권한 관리(RBAC) - 백엔드 연동 버전
|
* SFR-01: 역할 기반 권한 관리(RBAC) - 백엔드 연동 버전
|
||||||
@ -66,15 +66,14 @@ export function AccessControl() {
|
|||||||
const [userStats, setUserStats] = useState<UserStats | null>(null);
|
const [userStats, setUserStats] = useState<UserStats | null>(null);
|
||||||
const [usersLoading, setUsersLoading] = useState(false);
|
const [usersLoading, setUsersLoading] = useState(false);
|
||||||
|
|
||||||
// 역할 목록
|
|
||||||
const [roles, setRoles] = useState<RoleWithPermissions[]>([]);
|
|
||||||
const [rolesLoading, setRolesLoading] = useState(false);
|
|
||||||
|
|
||||||
// 감사 로그
|
// 감사 로그
|
||||||
const [auditLogs, setAuditLogs] = useState<ApiAuditLog[]>([]);
|
const [auditLogs, setAuditLogs] = useState<ApiAuditLog[]>([]);
|
||||||
const [auditStats, setAuditStats] = useState<AuditStats | null>(null);
|
const [auditStats, setAuditStats] = useState<AuditStats | null>(null);
|
||||||
const [auditLoading, setAuditLoading] = useState(false);
|
const [auditLoading, setAuditLoading] = useState(false);
|
||||||
|
|
||||||
|
// 역할 배정 다이얼로그
|
||||||
|
const [assignTarget, setAssignTarget] = useState<AdminUser | null>(null);
|
||||||
|
|
||||||
// 사용자 + 통계 로드
|
// 사용자 + 통계 로드
|
||||||
const loadUsers = useCallback(async () => {
|
const loadUsers = useCallback(async () => {
|
||||||
setUsersLoading(true); setError('');
|
setUsersLoading(true); setError('');
|
||||||
@ -89,23 +88,6 @@ export function AccessControl() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadRoles = useCallback(async () => {
|
|
||||||
setRolesLoading(true); setError('');
|
|
||||||
try {
|
|
||||||
const r = await fetchRoles();
|
|
||||||
setRoles(r);
|
|
||||||
// 사용자 통계도 같이 로드 (역할별 카운트 사용)
|
|
||||||
if (!userStats) {
|
|
||||||
const s = await fetchUserStats();
|
|
||||||
setUserStats(s);
|
|
||||||
}
|
|
||||||
} catch (e: unknown) {
|
|
||||||
setError(e instanceof Error ? e.message : 'unknown');
|
|
||||||
} finally {
|
|
||||||
setRolesLoading(false);
|
|
||||||
}
|
|
||||||
}, [userStats]);
|
|
||||||
|
|
||||||
const loadAudit = useCallback(async () => {
|
const loadAudit = useCallback(async () => {
|
||||||
setAuditLoading(true); setError('');
|
setAuditLoading(true); setError('');
|
||||||
try {
|
try {
|
||||||
@ -119,12 +101,11 @@ export function AccessControl() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 탭 전환 시 자동 로드
|
// 탭 전환 시 자동 로드 (roles 탭은 PermissionsPanel이 자체 로드)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tab === 'roles') loadRoles();
|
if (tab === 'users') loadUsers();
|
||||||
else if (tab === 'users') loadUsers();
|
|
||||||
else if (tab === 'audit') loadAudit();
|
else if (tab === 'audit') loadAudit();
|
||||||
}, [tab, loadRoles, loadUsers, loadAudit]);
|
}, [tab, loadUsers, loadAudit]);
|
||||||
|
|
||||||
const handleUnlock = async (userId: string, acnt: string) => {
|
const handleUnlock = async (userId: string, acnt: string) => {
|
||||||
if (!confirm(`계정 ${acnt} 잠금을 해제하시겠습니까?`)) return;
|
if (!confirm(`계정 ${acnt} 잠금을 해제하시겠습니까?`)) return;
|
||||||
@ -137,6 +118,7 @@ export function AccessControl() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── 사용자 테이블 컬럼 ──────────────
|
// ── 사용자 테이블 컬럼 ──────────────
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
const userColumns: DataColumn<AdminUser & Record<string, unknown>>[] = useMemo(() => [
|
const userColumns: DataColumn<AdminUser & Record<string, unknown>>[] = useMemo(() => [
|
||||||
{ key: 'userAcnt', label: '계정', width: '90px',
|
{ key: 'userAcnt', label: '계정', width: '90px',
|
||||||
render: (v) => <span className="text-cyan-400 font-mono text-[11px]">{v as string}</span> },
|
render: (v) => <span className="text-cyan-400 font-mono text-[11px]">{v as string}</span> },
|
||||||
@ -175,11 +157,12 @@ export function AccessControl() {
|
|||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ key: 'userId', label: '관리', width: '70px', align: 'center', sortable: false,
|
{ key: 'userId', label: '관리', width: '90px', align: 'center', sortable: false,
|
||||||
render: (_v, row) => (
|
render: (_v, row) => (
|
||||||
<div className="flex items-center justify-center gap-1">
|
<div className="flex items-center justify-center gap-1">
|
||||||
<button type="button" className="p-1 text-hint hover:text-blue-400" title="상세">
|
<button type="button" onClick={() => setAssignTarget(row)}
|
||||||
<Eye className="w-3 h-3" />
|
className="p-1 text-hint hover:text-purple-400" title="역할 배정">
|
||||||
|
<UserCog className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
{row.userSttsCd === 'LOCKED' && (
|
{row.userSttsCd === 'LOCKED' && (
|
||||||
<button type="button" onClick={() => handleUnlock(row.userId, row.userAcnt)}
|
<button type="button" onClick={() => handleUnlock(row.userId, row.userAcnt)}
|
||||||
@ -237,7 +220,7 @@ export function AccessControl() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button type="button"
|
<button type="button"
|
||||||
onClick={() => { if (tab === 'roles') loadRoles(); else if (tab === 'users') loadUsers(); else if (tab === 'audit') loadAudit(); }}
|
onClick={() => { if (tab === 'users') loadUsers(); else if (tab === 'audit') loadAudit(); }}
|
||||||
className="p-1.5 rounded text-hint hover:text-blue-400 hover:bg-surface-overlay" title="새로고침">
|
className="p-1.5 rounded text-hint hover:text-blue-400 hover:bg-surface-overlay" title="새로고침">
|
||||||
<RefreshCw className="w-3.5 h-3.5" />
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@ -268,46 +251,8 @@ export function AccessControl() {
|
|||||||
|
|
||||||
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
||||||
|
|
||||||
{/* ── 역할 관리 ── */}
|
{/* ── 역할 관리 (PermissionsPanel: 트리 + R/C/U/D 매트릭스) ── */}
|
||||||
{tab === 'roles' && (
|
{tab === 'roles' && <PermissionsPanel />}
|
||||||
<div className="space-y-3">
|
|
||||||
{rolesLoading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
|
|
||||||
{!rolesLoading && roles.map((r) => {
|
|
||||||
const userCount = userStats?.byRole?.[r.roleCd] ?? 0;
|
|
||||||
const grantCount = r.permissions?.filter((p) => p.grantYn === 'Y').length ?? 0;
|
|
||||||
return (
|
|
||||||
<Card key={r.roleSn} className="bg-surface-raised border-border">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Badge className={`${ROLE_COLORS[r.roleCd] || 'bg-gray-500/20 text-gray-400'} border-0 text-[10px] px-2 py-0.5`}>
|
|
||||||
{r.roleCd}
|
|
||||||
</Badge>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-bold text-heading">{r.roleNm}</div>
|
|
||||||
<div className="text-[10px] text-hint">{r.roleDc || '-'}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-6 text-[10px]">
|
|
||||||
<div>
|
|
||||||
<span className="text-hint">할당 인원: </span>
|
|
||||||
<span className="text-heading font-bold">{userCount}명</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-hint">명시 권한: </span>
|
|
||||||
<span className="text-cyan-400 font-bold">{grantCount}개</span>
|
|
||||||
</div>
|
|
||||||
{r.builtinYn === 'Y' && <Badge className="bg-blue-500/20 text-blue-400 border-0 text-[9px]">BUILT-IN</Badge>}
|
|
||||||
{r.dfltYn === 'Y' && <Badge className="bg-green-500/20 text-green-400 border-0 text-[9px]">DEFAULT</Badge>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{!rolesLoading && roles.length === 0 && <div className="text-center text-hint py-8">역할이 없습니다.</div>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── 사용자 관리 ── */}
|
{/* ── 사용자 관리 ── */}
|
||||||
{tab === 'users' && (
|
{tab === 'users' && (
|
||||||
@ -382,6 +327,15 @@ export function AccessControl() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 역할 배정 다이얼로그 */}
|
||||||
|
{assignTarget && (
|
||||||
|
<UserRoleAssignDialog
|
||||||
|
user={assignTarget}
|
||||||
|
onClose={() => setAssignTarget(null)}
|
||||||
|
onSaved={loadUsers}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── 보안 정책 ── */}
|
{/* ── 보안 정책 ── */}
|
||||||
{tab === 'policy' && (
|
{tab === 'policy' && (
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
|||||||
454
frontend/src/features/admin/PermissionsPanel.tsx
Normal file
454
frontend/src/features/admin/PermissionsPanel.tsx
Normal file
@ -0,0 +1,454 @@
|
|||||||
|
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Loader2, Save, Plus, Trash2, RefreshCw, ChevronRight, ChevronDown,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
|
import {
|
||||||
|
fetchRoles, fetchPermTree, createRole, deleteRole, updateRolePermissions,
|
||||||
|
type RoleWithPermissions, type PermTreeNode, type PermEntry,
|
||||||
|
} from '@/services/adminApi';
|
||||||
|
import {
|
||||||
|
resolveSingleRoleEffective, OPERATIONS,
|
||||||
|
type Operation, type TreeNode, type PermRow,
|
||||||
|
} from '@/lib/permission/permResolver';
|
||||||
|
import { useAuth } from '@/app/auth/AuthContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 트리 기반 권한 관리 패널 (wing 패턴).
|
||||||
|
*
|
||||||
|
* - 좌측: 역할 목록
|
||||||
|
* - 우측: 권한 트리 + R/C/U/D/E 체크박스 매트릭스
|
||||||
|
*
|
||||||
|
* 셀 상태 (4가지):
|
||||||
|
* • explicit-granted (✓ 파랑) - 명시적 Y
|
||||||
|
* • explicit-denied (— 빨강) - 명시적 N
|
||||||
|
* • inherited-granted (✓ 연파랑) - 부모로부터 상속
|
||||||
|
* • forced-denied (회색) - 부모 READcandid가 N → 강제 거부
|
||||||
|
*
|
||||||
|
* 클릭 사이클: explicit-granted → explicit-denied → 미지정(상속) → ...
|
||||||
|
*
|
||||||
|
* 권한:
|
||||||
|
* - admin:role-management (READ): 역할 목록 조회
|
||||||
|
* - admin:role-management (CREATE/DELETE): 역할 생성/삭제
|
||||||
|
* - admin:permission-management (UPDATE): 권한 매트릭스 갱신
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ROLE_COLORS: Record<string, string> = {
|
||||||
|
ADMIN: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||||
|
OPERATOR: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||||
|
ANALYST: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
|
||||||
|
FIELD: 'bg-green-500/20 text-green-400 border-green-500/30',
|
||||||
|
VIEWER: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
|
||||||
|
};
|
||||||
|
|
||||||
|
type DraftPerms = Map<string, 'Y' | 'N' | null>; // null = 명시 권한 제거
|
||||||
|
|
||||||
|
function makeKey(rsrcCd: string, operCd: string) { return `${rsrcCd}::${operCd}`; }
|
||||||
|
|
||||||
|
export function PermissionsPanel() {
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
|
const canCreateRole = hasPermission('admin:role-management', 'CREATE');
|
||||||
|
const canDeleteRole = hasPermission('admin:role-management', 'DELETE');
|
||||||
|
const canUpdatePerm = hasPermission('admin:permission-management', 'UPDATE');
|
||||||
|
|
||||||
|
const [roles, setRoles] = useState<RoleWithPermissions[]>([]);
|
||||||
|
const [tree, setTree] = useState<PermTreeNode[]>([]);
|
||||||
|
const [selectedRoleSn, setSelectedRoleSn] = useState<number | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const [draftPerms, setDraftPerms] = useState<DraftPerms>(new Map());
|
||||||
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [newRoleCd, setNewRoleCd] = useState('');
|
||||||
|
const [newRoleNm, setNewRoleNm] = useState('');
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true); setError('');
|
||||||
|
try {
|
||||||
|
const [r, t] = await Promise.all([fetchRoles(), fetchPermTree()]);
|
||||||
|
setRoles(r);
|
||||||
|
setTree(t);
|
||||||
|
if (r.length > 0 && selectedRoleSn === null) {
|
||||||
|
setSelectedRoleSn(r[0].roleSn);
|
||||||
|
}
|
||||||
|
// Level 0 노드 자동 펼침
|
||||||
|
setExpanded(new Set(t.filter((n) => n.rsrcLevel === 0).map((n) => n.rsrcCd)));
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : 'unknown');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [selectedRoleSn]);
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
useEffect(() => { load(); /* 최초 1회만 로드 */ }, []);
|
||||||
|
|
||||||
|
// 역할 선택 시 draft 초기화
|
||||||
|
const selectedRole = useMemo(
|
||||||
|
() => roles.find((r) => r.roleSn === selectedRoleSn) ?? null,
|
||||||
|
[roles, selectedRoleSn],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedRole) return;
|
||||||
|
const m: DraftPerms = new Map();
|
||||||
|
for (const p of selectedRole.permissions) {
|
||||||
|
m.set(makeKey(p.rsrcCd, p.operCd), p.grantYn as 'Y' | 'N');
|
||||||
|
}
|
||||||
|
setDraftPerms(m);
|
||||||
|
}, [selectedRole]);
|
||||||
|
|
||||||
|
// 트리 → 트리 인덱싱 (parent → children)
|
||||||
|
const childrenMap = useMemo(() => {
|
||||||
|
const m = new Map<string | null, PermTreeNode[]>();
|
||||||
|
for (const n of tree) {
|
||||||
|
if (n.useYn !== 'Y') continue;
|
||||||
|
const arr = m.get(n.parentCd) ?? [];
|
||||||
|
arr.push(n);
|
||||||
|
m.set(n.parentCd, arr);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [tree]);
|
||||||
|
|
||||||
|
// draft 기반 effective 권한 해석 (PermResolver TS 미러)
|
||||||
|
const effective = useMemo(() => {
|
||||||
|
const treeNodes: TreeNode[] = tree.map((n) => ({
|
||||||
|
rsrcCd: n.rsrcCd, parentCd: n.parentCd, rsrcNm: n.rsrcNm,
|
||||||
|
rsrcLevel: n.rsrcLevel, sortOrd: n.sortOrd, useYn: n.useYn,
|
||||||
|
}));
|
||||||
|
const perms: PermRow[] = [];
|
||||||
|
draftPerms.forEach((v, k) => {
|
||||||
|
if (v === 'Y' || v === 'N') {
|
||||||
|
const [rsrcCd, operCd] = k.split('::');
|
||||||
|
perms.push({ rsrcCd, operCd, grantYn: v });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return resolveSingleRoleEffective(treeNodes, perms);
|
||||||
|
}, [tree, draftPerms]);
|
||||||
|
|
||||||
|
const cellState = useCallback((rsrcCd: string, operCd: Operation, parentCd: string | null) => {
|
||||||
|
const key = makeKey(rsrcCd, operCd);
|
||||||
|
const explicit = draftPerms.get(key);
|
||||||
|
|
||||||
|
// 부모의 effective READ 확인
|
||||||
|
let parentReadDenied = false;
|
||||||
|
if (parentCd) {
|
||||||
|
const parentEff = effective.get(parentCd);
|
||||||
|
parentReadDenied = !parentEff || !parentEff.has('READ');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parentReadDenied && operCd !== 'READ') return 'forced-denied';
|
||||||
|
if (parentReadDenied && operCd === 'READ' && parentCd) return 'forced-denied';
|
||||||
|
|
||||||
|
if (explicit === 'Y') return 'explicit-granted';
|
||||||
|
if (explicit === 'N') return 'explicit-denied';
|
||||||
|
|
||||||
|
// 상속 체크
|
||||||
|
const eff = effective.get(rsrcCd);
|
||||||
|
if (eff?.has(operCd)) return 'inherited-granted';
|
||||||
|
return 'inherited-denied';
|
||||||
|
}, [draftPerms, effective]);
|
||||||
|
|
||||||
|
const isDirty = useMemo(() => {
|
||||||
|
if (!selectedRole) return false;
|
||||||
|
const original = new Map<string, 'Y' | 'N'>();
|
||||||
|
for (const p of selectedRole.permissions) {
|
||||||
|
original.set(makeKey(p.rsrcCd, p.operCd), p.grantYn as 'Y' | 'N');
|
||||||
|
}
|
||||||
|
if (original.size !== Array.from(draftPerms.values()).filter((v) => v !== null).length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (const [k, v] of draftPerms) {
|
||||||
|
if (v === null) {
|
||||||
|
if (original.has(k)) return true;
|
||||||
|
} else if (original.get(k) !== v) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, [selectedRole, draftPerms]);
|
||||||
|
|
||||||
|
// 셀 클릭: explicit Y → explicit N → 미지정(상속) → ...
|
||||||
|
const handleCellClick = (rsrcCd: string, operCd: Operation) => {
|
||||||
|
if (!canUpdatePerm) return;
|
||||||
|
const key = makeKey(rsrcCd, operCd);
|
||||||
|
setDraftPerms((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const cur = next.get(key);
|
||||||
|
if (cur === 'Y') next.set(key, 'N');
|
||||||
|
else if (cur === 'N') next.set(key, null); // 명시 권한 제거
|
||||||
|
else next.set(key, 'Y');
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!selectedRole || !canUpdatePerm) return;
|
||||||
|
setSaving(true); setError('');
|
||||||
|
try {
|
||||||
|
const original = new Map<string, 'Y' | 'N'>();
|
||||||
|
for (const p of selectedRole.permissions) {
|
||||||
|
original.set(makeKey(p.rsrcCd, p.operCd), p.grantYn as 'Y' | 'N');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 변경된 셀만 수집
|
||||||
|
const changes: PermEntry[] = [];
|
||||||
|
const allKeys = new Set([...original.keys(), ...draftPerms.keys()]);
|
||||||
|
for (const k of allKeys) {
|
||||||
|
const [rsrcCd, operCd] = k.split('::');
|
||||||
|
const oldVal = original.get(k);
|
||||||
|
const newVal = draftPerms.get(k);
|
||||||
|
if (newVal === null && oldVal !== undefined) {
|
||||||
|
changes.push({ rsrcCd, operCd, grantYn: null });
|
||||||
|
} else if ((newVal === 'Y' || newVal === 'N') && newVal !== oldVal) {
|
||||||
|
changes.push({ rsrcCd, operCd, grantYn: newVal });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changes.length === 0) {
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateRolePermissions(selectedRole.roleSn, changes);
|
||||||
|
await load(); // 새로 가져와서 동기화
|
||||||
|
alert(`권한 ${changes.length}건 갱신되었습니다.`);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : 'unknown');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateRole = async () => {
|
||||||
|
if (!newRoleCd || !newRoleNm) return;
|
||||||
|
try {
|
||||||
|
await createRole({ roleCd: newRoleCd, roleNm: newRoleNm });
|
||||||
|
setShowCreate(false);
|
||||||
|
setNewRoleCd(''); setNewRoleNm('');
|
||||||
|
await load();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
alert('생성 실패: ' + (e instanceof Error ? e.message : 'unknown'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteRole = async () => {
|
||||||
|
if (!selectedRole) return;
|
||||||
|
if (selectedRole.builtinYn === 'Y') {
|
||||||
|
alert('내장 역할은 삭제할 수 없습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!confirm(`"${selectedRole.roleNm}" 역할을 삭제하시겠습니까?`)) return;
|
||||||
|
try {
|
||||||
|
await deleteRole(selectedRole.roleSn);
|
||||||
|
setSelectedRoleSn(null);
|
||||||
|
await load();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
alert('삭제 실패: ' + (e instanceof Error ? e.message : 'unknown'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleExpand = (rsrcCd: string) => {
|
||||||
|
setExpanded((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(rsrcCd)) next.delete(rsrcCd); else next.add(rsrcCd);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderTreeRow = (node: PermTreeNode, depth: number): React.ReactNode => {
|
||||||
|
const children = childrenMap.get(node.rsrcCd) ?? [];
|
||||||
|
const hasChildren = children.length > 0;
|
||||||
|
const isExpanded = expanded.has(node.rsrcCd);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr key={node.rsrcCd} className="border-t border-border hover:bg-surface-overlay/30">
|
||||||
|
<td className="py-1.5 pl-2" style={{ paddingLeft: 8 + depth * 20 }}>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{hasChildren ? (
|
||||||
|
<button type="button" onClick={() => toggleExpand(node.rsrcCd)}
|
||||||
|
className="p-0.5 text-hint hover:text-heading">
|
||||||
|
{isExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
||||||
|
</button>
|
||||||
|
) : <span className="w-4" />}
|
||||||
|
<span className="text-[11px] text-heading font-medium">{node.rsrcNm}</span>
|
||||||
|
<span className="text-[9px] text-hint font-mono">({node.rsrcCd})</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{OPERATIONS.map((op) => {
|
||||||
|
const state = cellState(node.rsrcCd, op as Operation, node.parentCd);
|
||||||
|
const cls =
|
||||||
|
state === 'explicit-granted' ? 'bg-blue-500 text-white border-blue-400 font-bold'
|
||||||
|
: state === 'inherited-granted' ? 'bg-blue-500/30 text-blue-300 border-blue-500/40'
|
||||||
|
: state === 'explicit-denied' ? 'bg-red-500/40 text-red-300 border-red-500/50 font-bold'
|
||||||
|
: state === 'forced-denied' ? 'bg-gray-700/40 text-gray-600 border-gray-700/40 cursor-not-allowed'
|
||||||
|
: 'bg-surface-overlay text-hint border-border';
|
||||||
|
const icon =
|
||||||
|
state === 'explicit-granted' || state === 'inherited-granted' ? '✓'
|
||||||
|
: state === 'explicit-denied' ? '—'
|
||||||
|
: state === 'forced-denied' ? '×'
|
||||||
|
: '·';
|
||||||
|
return (
|
||||||
|
<td key={op} className="text-center py-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canUpdatePerm || state === 'forced-denied'}
|
||||||
|
onClick={() => handleCellClick(node.rsrcCd, op as Operation)}
|
||||||
|
className={`w-7 h-6 rounded border text-[11px] transition-colors ${cls} ${canUpdatePerm && state !== 'forced-denied' ? 'hover:opacity-80 cursor-pointer' : ''}`}
|
||||||
|
title={`${op} - ${state}`}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
{isExpanded && children.map((c) => renderTreeRow(c, depth + 1))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold text-heading">권한 관리 (트리 RBAC)</h2>
|
||||||
|
<p className="text-[10px] text-hint mt-0.5">
|
||||||
|
좌측 역할 선택 → 우측 트리 매트릭스에서 셀 클릭 (Y → N → 상속) → 저장
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button type="button" onClick={load}
|
||||||
|
className="p-1.5 rounded text-hint hover:text-blue-400 hover:bg-surface-overlay" title="새로고침">
|
||||||
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
||||||
|
|
||||||
|
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
|
||||||
|
|
||||||
|
{!loading && (
|
||||||
|
<div className="grid grid-cols-12 gap-3">
|
||||||
|
{/* 좌측: 역할 목록 */}
|
||||||
|
<Card className="col-span-3">
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="text-xs text-label font-bold">역할</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{canCreateRole && (
|
||||||
|
<button type="button" onClick={() => setShowCreate(!showCreate)}
|
||||||
|
className="p-1 text-hint hover:text-green-400" title="신규 역할">
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canDeleteRole && selectedRole && selectedRole.builtinYn !== 'Y' && (
|
||||||
|
<button type="button" onClick={handleDeleteRole}
|
||||||
|
className="p-1 text-hint hover:text-red-400" title="역할 삭제">
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCreate && (
|
||||||
|
<div className="mb-2 p-2 bg-surface-overlay rounded space-y-1">
|
||||||
|
<input value={newRoleCd} onChange={(e) => setNewRoleCd(e.target.value.toUpperCase())}
|
||||||
|
placeholder="ROLE_CD (대문자)"
|
||||||
|
className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" />
|
||||||
|
<input value={newRoleNm} onChange={(e) => setNewRoleNm(e.target.value)}
|
||||||
|
placeholder="역할 이름"
|
||||||
|
className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" />
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button type="button" onClick={handleCreateRole} disabled={!newRoleCd || !newRoleNm}
|
||||||
|
className="flex-1 py-1 bg-green-600 hover:bg-green-500 disabled:bg-green-600/40 text-white text-[10px] rounded">생성</button>
|
||||||
|
<button type="button" onClick={() => setShowCreate(false)}
|
||||||
|
className="flex-1 py-1 bg-gray-600 hover:bg-gray-500 text-white text-[10px] rounded">취소</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
{roles.map((r) => {
|
||||||
|
const selected = r.roleSn === selectedRoleSn;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={r.roleSn}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedRoleSn(r.roleSn)}
|
||||||
|
className={`w-full text-left px-2 py-1.5 rounded border transition-colors ${
|
||||||
|
selected
|
||||||
|
? 'bg-blue-600/20 border-blue-500/40 text-heading'
|
||||||
|
: 'bg-surface-overlay border-border text-muted-foreground hover:text-heading hover:bg-surface-overlay/80'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Badge className={`${ROLE_COLORS[r.roleCd] || 'bg-gray-500/20 text-gray-400'} border text-[9px]`}>
|
||||||
|
{r.roleCd}
|
||||||
|
</Badge>
|
||||||
|
{r.builtinYn === 'Y' && <span className="text-[8px] text-hint">BUILT-IN</span>}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] mt-0.5">{r.roleNm}</div>
|
||||||
|
<div className="text-[9px] text-hint mt-0.5">권한 {r.permissions.length}건</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 우측: 권한 매트릭스 */}
|
||||||
|
<Card className="col-span-9">
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-label font-bold">
|
||||||
|
{selectedRole ? `${selectedRole.roleNm} (${selectedRole.roleCd})` : '역할 선택'}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-hint mt-0.5">
|
||||||
|
셀 의미: <span className="text-blue-400">✓ 명시 허용</span> /
|
||||||
|
<span className="text-blue-300/80 ml-1">✓ 상속 허용</span> /
|
||||||
|
<span className="text-red-400 ml-1">— 명시 거부</span> /
|
||||||
|
<span className="text-gray-500 ml-1">× 강제 거부</span> /
|
||||||
|
<span className="text-hint ml-1">· 미지정</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{canUpdatePerm && selectedRole && (
|
||||||
|
<button type="button" onClick={handleSave} disabled={!isDirty || saving}
|
||||||
|
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-600/40 text-white text-xs rounded flex items-center gap-1">
|
||||||
|
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
||||||
|
저장 {isDirty && <span className="text-yellow-300">●</span>}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedRole && (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="sticky top-0 bg-surface-overlay">
|
||||||
|
<tr className="border-b border-border">
|
||||||
|
<th className="text-left py-2 pl-2 text-hint font-medium">리소스</th>
|
||||||
|
{OPERATIONS.map((op) => (
|
||||||
|
<th key={op} className="w-16 text-center py-2 text-hint font-medium">{op[0]}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(childrenMap.get(null) ?? []).map((root) => renderTreeRow(root, 0))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
frontend/src/features/admin/UserRoleAssignDialog.tsx
Normal file
122
frontend/src/features/admin/UserRoleAssignDialog.tsx
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { X, Check, Loader2 } from 'lucide-react';
|
||||||
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
|
import { fetchRoles, assignUserRoles, type RoleWithPermissions, type AdminUser } from '@/services/adminApi';
|
||||||
|
|
||||||
|
const ROLE_COLORS: Record<string, string> = {
|
||||||
|
ADMIN: 'bg-red-500/20 text-red-400',
|
||||||
|
OPERATOR: 'bg-blue-500/20 text-blue-400',
|
||||||
|
ANALYST: 'bg-purple-500/20 text-purple-400',
|
||||||
|
FIELD: 'bg-green-500/20 text-green-400',
|
||||||
|
VIEWER: 'bg-yellow-500/20 text-yellow-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: AdminUser;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserRoleAssignDialog({ user, onClose, onSaved }: Props) {
|
||||||
|
const [roles, setRoles] = useState<RoleWithPermissions[]>([]);
|
||||||
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRoles()
|
||||||
|
.then((r) => {
|
||||||
|
setRoles(r);
|
||||||
|
const cur = new Set<number>();
|
||||||
|
for (const role of r) {
|
||||||
|
if (user.roles.includes(role.roleCd)) cur.add(role.roleSn);
|
||||||
|
}
|
||||||
|
setSelected(cur);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const toggle = (sn: number) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(sn)) next.delete(sn); else next.add(sn);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await assignUserRoles(user.userId, Array.from(selected));
|
||||||
|
onSaved();
|
||||||
|
onClose();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||||||
|
<div className="bg-card border border-border rounded-lg shadow-2xl w-full max-w-lg" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold text-heading">역할 배정</div>
|
||||||
|
<div className="text-[10px] text-hint mt-0.5">
|
||||||
|
{user.userAcnt} ({user.userNm}) - 다중 역할 가능 (OR 합집합)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={onClose} className="text-hint hover:text-heading">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 space-y-2 max-h-96 overflow-y-auto">
|
||||||
|
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
|
||||||
|
{!loading && roles.map((r) => {
|
||||||
|
const isSelected = selected.has(r.roleSn);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={r.roleSn}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggle(r.roleSn)}
|
||||||
|
className={`w-full flex items-center justify-between p-3 rounded border transition-colors ${
|
||||||
|
isSelected ? 'bg-blue-600/10 border-blue-500/40' : 'bg-surface-overlay border-border hover:bg-surface-overlay/80'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-5 h-5 rounded border flex items-center justify-center ${
|
||||||
|
isSelected ? 'bg-blue-600 border-blue-500' : 'border-border'
|
||||||
|
}`}>
|
||||||
|
{isSelected && <Check className="w-3.5 h-3.5 text-white" />}
|
||||||
|
</div>
|
||||||
|
<Badge className={`${ROLE_COLORS[r.roleCd] || 'bg-gray-500/20 text-gray-400'} border-0 text-[10px]`}>
|
||||||
|
{r.roleCd}
|
||||||
|
</Badge>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-xs text-heading font-medium">{r.roleNm}</div>
|
||||||
|
<div className="text-[10px] text-hint">{r.roleDc || '-'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-hint">권한 {r.permissions.length}건</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-border">
|
||||||
|
<button type="button" onClick={onClose}
|
||||||
|
className="px-4 py-1.5 bg-surface-overlay text-muted-foreground text-xs rounded hover:text-heading">
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={handleSave} disabled={saving}
|
||||||
|
className="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-600/40 text-white text-xs rounded flex items-center gap-1">
|
||||||
|
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
|
||||||
|
저장
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
frontend/src/lib/permission/permResolver.ts
Normal file
128
frontend/src/lib/permission/permResolver.ts
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* 트리 기반 RBAC 권한 해석기 (백엔드 PermResolver.java의 TypeScript 미러).
|
||||||
|
* - PermissionsPanel UI에서 셀 상태를 미리 계산해 표시하기 위함.
|
||||||
|
* - 백엔드와 동일한 알고리즘이어야 사용자 혼란 없음.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface TreeNode {
|
||||||
|
rsrcCd: string;
|
||||||
|
parentCd: string | null;
|
||||||
|
rsrcNm: string;
|
||||||
|
rsrcLevel: number;
|
||||||
|
sortOrd: number;
|
||||||
|
useYn: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PermRow {
|
||||||
|
rsrcCd: string;
|
||||||
|
operCd: string;
|
||||||
|
grantYn: 'Y' | 'N';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OPERATIONS = ['READ', 'CREATE', 'UPDATE', 'DELETE', 'EXPORT'] as const;
|
||||||
|
export type Operation = (typeof OPERATIONS)[number];
|
||||||
|
|
||||||
|
export type CellState =
|
||||||
|
| 'explicit-granted' // ✓ 파란 - 명시적 Y
|
||||||
|
| 'explicit-denied' // — 빨간 - 명시적 N
|
||||||
|
| 'inherited-granted' // ✓ 연파랑 - 부모로부터 상속
|
||||||
|
| 'forced-denied'; // 회색 비활성 - 부모 READ가 N이라 강제 거부
|
||||||
|
|
||||||
|
export function makePermKey(rsrcCd: string, operCd: string) {
|
||||||
|
return `${rsrcCd}::${operCd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단일 (rsrcCd, operCd) 셀의 상태를 계산.
|
||||||
|
*
|
||||||
|
* @param rsrcCd 대상 리소스
|
||||||
|
* @param operCd 대상 오퍼레이션
|
||||||
|
* @param explicit 명시 권한 맵 (key → 'Y'/'N')
|
||||||
|
* @param parentEffective 부모 노드의 effective 권한 셋 (resolved 결과)
|
||||||
|
* @returns CellState
|
||||||
|
*/
|
||||||
|
export function resolveCellState(
|
||||||
|
rsrcCd: string,
|
||||||
|
operCd: Operation,
|
||||||
|
explicit: Map<string, 'Y' | 'N'>,
|
||||||
|
parentEffective: Set<string> | null,
|
||||||
|
): CellState {
|
||||||
|
const key = makePermKey(rsrcCd, operCd);
|
||||||
|
const explicitVal = explicit.get(key);
|
||||||
|
|
||||||
|
// 부모의 READ가 deny면 모든 작업 강제 deny
|
||||||
|
const parentReadDenied = parentEffective !== null && !parentEffective.has('READ');
|
||||||
|
|
||||||
|
if (parentEffective && parentReadDenied && operCd !== 'READ') {
|
||||||
|
return 'forced-denied';
|
||||||
|
}
|
||||||
|
|
||||||
|
// READ도 부모가 deny면 강제 거부 (부모가 정의되었고 READ가 없다면)
|
||||||
|
if (operCd === 'READ' && parentEffective && parentReadDenied) {
|
||||||
|
return 'forced-denied';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (explicitVal === 'Y') return 'explicit-granted';
|
||||||
|
if (explicitVal === 'N') return 'explicit-denied';
|
||||||
|
|
||||||
|
// 명시값 없음 → 부모로부터 상속
|
||||||
|
if (parentEffective?.has(operCd)) return 'inherited-granted';
|
||||||
|
|
||||||
|
// 부모가 없거나 부모도 권한 없음
|
||||||
|
return 'explicit-denied'; // 미정 = 거부 (시각적으로는 빨간 — 보다는 회색이 적합)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단일 역할의 effective 권한 맵을 계산 (resource → granted operations).
|
||||||
|
* 백엔드 PermResolver.resolveSingleRole과 동일 로직.
|
||||||
|
*/
|
||||||
|
export function resolveSingleRoleEffective(
|
||||||
|
treeNodes: TreeNode[],
|
||||||
|
perms: PermRow[],
|
||||||
|
): Map<string, Set<Operation>> {
|
||||||
|
const explicit = new Map<string, 'Y' | 'N'>();
|
||||||
|
for (const p of perms) {
|
||||||
|
explicit.set(makePermKey(p.rsrcCd, p.operCd), p.grantYn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 트리 인덱싱
|
||||||
|
const childrenMap = new Map<string | null, TreeNode[]>();
|
||||||
|
for (const n of treeNodes) {
|
||||||
|
if (n.useYn !== 'Y') continue;
|
||||||
|
const arr = childrenMap.get(n.parentCd) ?? [];
|
||||||
|
arr.push(n);
|
||||||
|
childrenMap.set(n.parentCd, arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = new Map<string, Set<Operation>>();
|
||||||
|
|
||||||
|
function walk(node: TreeNode, parentEffective: Set<Operation> | null) {
|
||||||
|
const effective = new Set<Operation>();
|
||||||
|
|
||||||
|
// READ
|
||||||
|
const readState = resolveCellState(node.rsrcCd, 'READ', explicit, parentEffective as Set<string> | null);
|
||||||
|
if (readState === 'explicit-granted' || readState === 'inherited-granted') {
|
||||||
|
effective.add('READ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다른 작업: READ가 부여된 경우만
|
||||||
|
if (effective.has('READ')) {
|
||||||
|
for (const op of ['CREATE', 'UPDATE', 'DELETE', 'EXPORT'] as Operation[]) {
|
||||||
|
const st = resolveCellState(node.rsrcCd, op, explicit, parentEffective as Set<string> | null);
|
||||||
|
if (st === 'explicit-granted' || st === 'inherited-granted') {
|
||||||
|
effective.add(op);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effective.size > 0) resolved.set(node.rsrcCd, effective);
|
||||||
|
|
||||||
|
const children = childrenMap.get(node.rsrcCd) ?? [];
|
||||||
|
for (const child of children) walk(child, effective);
|
||||||
|
}
|
||||||
|
|
||||||
|
const roots = childrenMap.get(null) ?? [];
|
||||||
|
for (const root of roots) walk(root, null);
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
@ -99,6 +99,63 @@ export function fetchRoles() {
|
|||||||
return apiGet<RoleWithPermissions[]>('/roles');
|
return apiGet<RoleWithPermissions[]>('/roles');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 역할 CRUD ───────────────────────────────
|
||||||
|
export interface RoleCreatePayload {
|
||||||
|
roleCd: string;
|
||||||
|
roleNm: string;
|
||||||
|
roleDc?: string;
|
||||||
|
dfltYn?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoleUpdatePayload {
|
||||||
|
roleNm?: string;
|
||||||
|
roleDc?: string;
|
||||||
|
dfltYn?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiSend<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
method,
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = `API ${res.status}`;
|
||||||
|
try { const b = await res.json(); if (b?.message) msg += `: ${b.message}`; } catch { /* */ }
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRole(payload: RoleCreatePayload) {
|
||||||
|
return apiSend<{ roleSn: number; roleCd: string; roleNm: string }>('POST', '/roles', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateRole(roleSn: number, payload: RoleUpdatePayload) {
|
||||||
|
return apiSend('PUT', `/roles/${roleSn}`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteRole(roleSn: number) {
|
||||||
|
return apiSend('DELETE', `/roles/${roleSn}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 권한 매트릭스 갱신 ────────────────────────
|
||||||
|
export interface PermEntry {
|
||||||
|
rsrcCd: string;
|
||||||
|
operCd: string;
|
||||||
|
grantYn: 'Y' | 'N' | null; // null = 명시 권한 제거 (상속 모드)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateRolePermissions(roleSn: number, permissions: PermEntry[]) {
|
||||||
|
return apiSend<{ ok: boolean; changed: number }>('PUT', `/roles/${roleSn}/permissions`, { permissions });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 사용자 역할 배정 ─────────────────────────
|
||||||
|
export function assignUserRoles(userId: string, roleSns: number[]) {
|
||||||
|
return apiSend<{ userId: string; roles: string[] }>('PUT', `/admin/users/${userId}/roles`, { roleSns });
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 사용자 관리
|
// 사용자 관리
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user