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:
htlee 2026-04-07 10:11:27 +09:00
부모 fc1a686700
커밋 febfb2cbe8
13개의 변경된 파일1122개의 추가작업 그리고 78개의 파일을 삭제

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -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 });
}
// ============================================================================ // ============================================================================
// 사용자 관리 // 사용자 관리
// ============================================================================ // ============================================================================