From febfb2cbe821bc2e5cafbe09137184e3a01ddb27 Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 7 Apr 2026 10:11:27 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=205=20-=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20UI=20=EA=B3=A0=EB=8F=84=ED=99=94=20(?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=20RBAC=20PermissionsPanel)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 버그 수정: - 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) --- .../exception/GlobalExceptionHandler.java | 55 +++ .../gc/mda/kcg/permission/PermRepository.java | 5 + .../kcg/permission/PermTreeController.java | 79 ++- .../kcg/permission/RoleManagementService.java | 151 ++++++ .../dto/PermissionUpdateRequest.java | 23 + .../kcg/permission/dto/RoleCreateRequest.java | 10 + .../kcg/permission/dto/RoleUpdateRequest.java | 7 + .../permission/dto/UserRoleAssignRequest.java | 11 + frontend/src/features/admin/AccessControl.tsx | 98 +--- .../src/features/admin/PermissionsPanel.tsx | 454 ++++++++++++++++++ .../features/admin/UserRoleAssignDialog.tsx | 122 +++++ frontend/src/lib/permission/permResolver.ts | 128 +++++ frontend/src/services/adminApi.ts | 57 +++ 13 files changed, 1122 insertions(+), 78 deletions(-) create mode 100644 backend/src/main/java/gc/mda/kcg/common/exception/GlobalExceptionHandler.java create mode 100644 backend/src/main/java/gc/mda/kcg/permission/RoleManagementService.java create mode 100644 backend/src/main/java/gc/mda/kcg/permission/dto/PermissionUpdateRequest.java create mode 100644 backend/src/main/java/gc/mda/kcg/permission/dto/RoleCreateRequest.java create mode 100644 backend/src/main/java/gc/mda/kcg/permission/dto/RoleUpdateRequest.java create mode 100644 backend/src/main/java/gc/mda/kcg/permission/dto/UserRoleAssignRequest.java create mode 100644 frontend/src/features/admin/PermissionsPanel.tsx create mode 100644 frontend/src/features/admin/UserRoleAssignDialog.tsx create mode 100644 frontend/src/lib/permission/permResolver.ts diff --git a/backend/src/main/java/gc/mda/kcg/common/exception/GlobalExceptionHandler.java b/backend/src/main/java/gc/mda/kcg/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..c6689e7 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/common/exception/GlobalExceptionHandler.java @@ -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> 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> 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> handleAccessDenied(AccessDeniedException e) { + return ResponseEntity.status(403).body(Map.of( + "error", "FORBIDDEN", + "message", e.getMessage() == null ? "" : e.getMessage() + )); + } + + @ExceptionHandler(AuthenticationCredentialsNotFoundException.class) + public ResponseEntity> handleNoAuth(AuthenticationCredentialsNotFoundException e) { + return ResponseEntity.status(401).body(Map.of( + "error", "UNAUTHENTICATED", + "message", e.getMessage() == null ? "" : e.getMessage() + )); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/permission/PermRepository.java b/backend/src/main/java/gc/mda/kcg/permission/PermRepository.java index 2704c9a..fe57258 100644 --- a/backend/src/main/java/gc/mda/kcg/permission/PermRepository.java +++ b/backend/src/main/java/gc/mda/kcg/permission/PermRepository.java @@ -5,13 +5,18 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.util.List; +import java.util.Optional; public interface PermRepository extends JpaRepository { List findByRoleSn(Long roleSn); + Optional findByRoleSnAndRsrcCdAndOperCd(Long roleSn, String rsrcCd, String operCd); + @Query("SELECT p FROM Perm p WHERE p.roleSn IN :roleSns") List findByRoleSnIn(@Param("roleSns") List roleSns); void deleteByRoleSn(Long roleSn); + + void deleteByRoleSnAndRsrcCdAndOperCd(Long roleSn, String rsrcCd, String operCd); } diff --git a/backend/src/main/java/gc/mda/kcg/permission/PermTreeController.java b/backend/src/main/java/gc/mda/kcg/permission/PermTreeController.java index 7b00647..1e1df65 100644 --- a/backend/src/main/java/gc/mda/kcg/permission/PermTreeController.java +++ b/backend/src/main/java/gc/mda/kcg/permission/PermTreeController.java @@ -1,18 +1,23 @@ 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 jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.Map; +import java.util.UUID; /** - * 권한 트리 + 역할 조회 API. - * - GET /api/perm-tree: 모든 사용자 (메뉴/사이드바 구성용) - * - GET /api/roles: admin:permission-management 권한 필요 + * 권한 트리 + 역할 + 사용자 역할 배정 API. + * - 트리 조회: 모든 사용자 + * - 역할/권한 CRUD: admin:role-management 또는 admin:permission-management + * - 사용자 역할 배정: admin:user-management (UPDATE) */ @RestController @RequiredArgsConstructor @@ -21,12 +26,21 @@ public class PermTreeController { private final PermTreeRepository permTreeRepository; private final RoleRepository roleRepository; private final PermRepository permRepository; + private final RoleManagementService roleManagementService; + + // ======================================================================== + // 권한 트리 (모든 사용자) + // ======================================================================== @GetMapping("/api/perm-tree") public List getPermTree() { return permTreeRepository.findAllByOrderByRsrcLevelAscSortOrdAsc(); } + // ======================================================================== + // 역할 조회 + 권한 매트릭스 + // ======================================================================== + @GetMapping("/api/roles") @RequirePermission(resource = "admin:role-management", operation = "READ") public List> getRolesWithPermissions() { @@ -44,4 +58,57 @@ public class PermTreeController { ); }).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 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 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 assignUserRoles( + @PathVariable String userId, + @RequestBody UserRoleAssignRequest req + ) { + UUID uid = UUID.fromString(userId); + List roleSns = req.roleSns() == null ? List.of() : req.roleSns(); + List assigned = roleManagementService.assignUserRoles(uid, roleSns); + return Map.of("userId", userId, "roles", assigned); + } } diff --git a/backend/src/main/java/gc/mda/kcg/permission/RoleManagementService.java b/backend/src/main/java/gc/mda/kcg/permission/RoleManagementService.java new file mode 100644 index 0000000..57f867b --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/permission/RoleManagementService.java @@ -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 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 assignUserRoles(UUID userId, List 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); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/permission/dto/PermissionUpdateRequest.java b/backend/src/main/java/gc/mda/kcg/permission/dto/PermissionUpdateRequest.java new file mode 100644 index 0000000..be4a171 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/permission/dto/PermissionUpdateRequest.java @@ -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 permissions +) { + public record PermEntry( + String rsrcCd, + String operCd, + String grantYn // "Y" / "N" / null (제거) + ) {} +} diff --git a/backend/src/main/java/gc/mda/kcg/permission/dto/RoleCreateRequest.java b/backend/src/main/java/gc/mda/kcg/permission/dto/RoleCreateRequest.java new file mode 100644 index 0000000..600c750 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/permission/dto/RoleCreateRequest.java @@ -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 +) {} diff --git a/backend/src/main/java/gc/mda/kcg/permission/dto/RoleUpdateRequest.java b/backend/src/main/java/gc/mda/kcg/permission/dto/RoleUpdateRequest.java new file mode 100644 index 0000000..999908d --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/permission/dto/RoleUpdateRequest.java @@ -0,0 +1,7 @@ +package gc.mda.kcg.permission.dto; + +public record RoleUpdateRequest( + String roleNm, + String roleDc, + String dfltYn +) {} diff --git a/backend/src/main/java/gc/mda/kcg/permission/dto/UserRoleAssignRequest.java b/backend/src/main/java/gc/mda/kcg/permission/dto/UserRoleAssignRequest.java new file mode 100644 index 0000000..a92ae9e --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/permission/dto/UserRoleAssignRequest.java @@ -0,0 +1,11 @@ +package gc.mda.kcg.permission.dto; + +import java.util.List; + +/** + * 사용자에게 역할 일괄 배정. + * roleSns: 부여할 역할의 sn 리스트 (전체 교체 방식) + */ +public record UserRoleAssignRequest( + List roleSns +) {} diff --git a/frontend/src/features/admin/AccessControl.tsx b/frontend/src/features/admin/AccessControl.tsx index a2982bc..d41b908 100644 --- a/frontend/src/features/admin/AccessControl.tsx +++ b/frontend/src/features/admin/AccessControl.tsx @@ -4,21 +4,21 @@ import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/ import { Badge } from '@shared/components/ui/badge'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; import { - Shield, Users, UserCheck, Key, Lock, FileText, Loader2, RefreshCw, Eye, + Shield, Users, UserCheck, Key, Lock, FileText, Loader2, RefreshCw, UserCog, } from 'lucide-react'; import { fetchUsers, fetchUserStats, - fetchRoles, fetchAuditLogs, fetchAuditStats, unlockUser, type AdminUser, type UserStats, - type RoleWithPermissions, type AuditLog as ApiAuditLog, type AuditStats, } from '@/services/adminApi'; +import { PermissionsPanel } from './PermissionsPanel'; +import { UserRoleAssignDialog } from './UserRoleAssignDialog'; /* * SFR-01: 역할 기반 권한 관리(RBAC) - 백엔드 연동 버전 @@ -66,15 +66,14 @@ export function AccessControl() { const [userStats, setUserStats] = useState(null); const [usersLoading, setUsersLoading] = useState(false); - // 역할 목록 - const [roles, setRoles] = useState([]); - const [rolesLoading, setRolesLoading] = useState(false); - // 감사 로그 const [auditLogs, setAuditLogs] = useState([]); const [auditStats, setAuditStats] = useState(null); const [auditLoading, setAuditLoading] = useState(false); + // 역할 배정 다이얼로그 + const [assignTarget, setAssignTarget] = useState(null); + // 사용자 + 통계 로드 const loadUsers = useCallback(async () => { 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 () => { setAuditLoading(true); setError(''); try { @@ -119,12 +101,11 @@ export function AccessControl() { } }, []); - // 탭 전환 시 자동 로드 + // 탭 전환 시 자동 로드 (roles 탭은 PermissionsPanel이 자체 로드) useEffect(() => { - if (tab === 'roles') loadRoles(); - else if (tab === 'users') loadUsers(); + if (tab === 'users') loadUsers(); else if (tab === 'audit') loadAudit(); - }, [tab, loadRoles, loadUsers, loadAudit]); + }, [tab, loadUsers, loadAudit]); const handleUnlock = async (userId: string, acnt: string) => { if (!confirm(`계정 ${acnt} 잠금을 해제하시겠습니까?`)) return; @@ -137,6 +118,7 @@ export function AccessControl() { }; // ── 사용자 테이블 컬럼 ────────────── + // eslint-disable-next-line react-hooks/exhaustive-deps const userColumns: DataColumn>[] = useMemo(() => [ { key: 'userAcnt', label: '계정', width: '90px', render: (v) => {v as string} }, @@ -175,11 +157,12 @@ export function AccessControl() { ), }, - { key: 'userId', label: '관리', width: '70px', align: 'center', sortable: false, + { key: 'userId', label: '관리', width: '90px', align: 'center', sortable: false, render: (_v, row) => (
- {row.userSttsCd === 'LOCKED' && (
)} @@ -268,46 +251,8 @@ export function AccessControl() { {error &&
에러: {error}
} - {/* ── 역할 관리 ── */} - {tab === 'roles' && ( -
- {rolesLoading &&
} - {!rolesLoading && roles.map((r) => { - const userCount = userStats?.byRole?.[r.roleCd] ?? 0; - const grantCount = r.permissions?.filter((p) => p.grantYn === 'Y').length ?? 0; - return ( - - -
-
- - {r.roleCd} - -
-
{r.roleNm}
-
{r.roleDc || '-'}
-
-
-
-
- 할당 인원: - {userCount}명 -
-
- 명시 권한: - {grantCount}개 -
- {r.builtinYn === 'Y' && BUILT-IN} - {r.dfltYn === 'Y' && DEFAULT} -
-
-
-
- ); - })} - {!rolesLoading && roles.length === 0 &&
역할이 없습니다.
} -
- )} + {/* ── 역할 관리 (PermissionsPanel: 트리 + R/C/U/D 매트릭스) ── */} + {tab === 'roles' && } {/* ── 사용자 관리 ── */} {tab === 'users' && ( @@ -382,6 +327,15 @@ export function AccessControl() { )} + {/* 역할 배정 다이얼로그 */} + {assignTarget && ( + setAssignTarget(null)} + onSaved={loadUsers} + /> + )} + {/* ── 보안 정책 ── */} {tab === 'policy' && (
diff --git a/frontend/src/features/admin/PermissionsPanel.tsx b/frontend/src/features/admin/PermissionsPanel.tsx new file mode 100644 index 0000000..58b0dbd --- /dev/null +++ b/frontend/src/features/admin/PermissionsPanel.tsx @@ -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 = { + 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; // 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([]); + const [tree, setTree] = useState([]); + const [selectedRoleSn, setSelectedRoleSn] = useState(null); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + const [draftPerms, setDraftPerms] = useState(new Map()); + const [expanded, setExpanded] = useState>(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(); + 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(); + 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(); + 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 ( + <> + + +
+ {hasChildren ? ( + + ) : } + {node.rsrcNm} + ({node.rsrcCd}) +
+ + {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 ( + + + + ); + })} + + {isExpanded && children.map((c) => renderTreeRow(c, depth + 1))} + + ); + }; + + return ( +
+
+
+

권한 관리 (트리 RBAC)

+

+ 좌측 역할 선택 → 우측 트리 매트릭스에서 셀 클릭 (Y → N → 상속) → 저장 +

+
+
+ +
+
+ + {error &&
에러: {error}
} + + {loading &&
} + + {!loading && ( +
+ {/* 좌측: 역할 목록 */} + + +
+
역할
+
+ {canCreateRole && ( + + )} + {canDeleteRole && selectedRole && selectedRole.builtinYn !== 'Y' && ( + + )} +
+
+ + {showCreate && ( +
+ 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" /> + setNewRoleNm(e.target.value)} + placeholder="역할 이름" + className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" /> +
+ + +
+
+ )} + +
+ {roles.map((r) => { + const selected = r.roleSn === selectedRoleSn; + return ( + + ); + })} +
+
+
+ + {/* 우측: 권한 매트릭스 */} + + +
+
+
+ {selectedRole ? `${selectedRole.roleNm} (${selectedRole.roleCd})` : '역할 선택'} +
+
+ 셀 의미: ✓ 명시 허용 / + ✓ 상속 허용 / + — 명시 거부 / + × 강제 거부 / + · 미지정 +
+
+ {canUpdatePerm && selectedRole && ( + + )} +
+ + {selectedRole && ( +
+ + + + + {OPERATIONS.map((op) => ( + + ))} + + + + {(childrenMap.get(null) ?? []).map((root) => renderTreeRow(root, 0))} + +
리소스{op[0]}
+
+ )} +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/features/admin/UserRoleAssignDialog.tsx b/frontend/src/features/admin/UserRoleAssignDialog.tsx new file mode 100644 index 0000000..4414d3d --- /dev/null +++ b/frontend/src/features/admin/UserRoleAssignDialog.tsx @@ -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 = { + 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([]); + const [selected, setSelected] = useState>(new Set()); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + useEffect(() => { + fetchRoles() + .then((r) => { + setRoles(r); + const cur = new Set(); + 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 ( +
+
e.stopPropagation()}> +
+
+
역할 배정
+
+ {user.userAcnt} ({user.userNm}) - 다중 역할 가능 (OR 합집합) +
+
+ +
+ +
+ {loading &&
} + {!loading && roles.map((r) => { + const isSelected = selected.has(r.roleSn); + return ( + + ); + })} +
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/lib/permission/permResolver.ts b/frontend/src/lib/permission/permResolver.ts new file mode 100644 index 0000000..ac17a42 --- /dev/null +++ b/frontend/src/lib/permission/permResolver.ts @@ -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, + parentEffective: Set | 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> { + const explicit = new Map(); + for (const p of perms) { + explicit.set(makePermKey(p.rsrcCd, p.operCd), p.grantYn); + } + + // 트리 인덱싱 + const childrenMap = new Map(); + 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>(); + + function walk(node: TreeNode, parentEffective: Set | null) { + const effective = new Set(); + + // READ + const readState = resolveCellState(node.rsrcCd, 'READ', explicit, parentEffective as Set | 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 | 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; +} diff --git a/frontend/src/services/adminApi.ts b/frontend/src/services/adminApi.ts index 401fc5a..95571a9 100644 --- a/frontend/src/services/adminApi.ts +++ b/frontend/src/services/adminApi.ts @@ -99,6 +99,63 @@ export function fetchRoles() { return apiGet('/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(method: string, path: string, body?: unknown): Promise { + 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 }); +} + // ============================================================================ // 사용자 관리 // ============================================================================