fix: 권한 트리 UX 개선 + 라벨 사이드바 일치 + EXPORT 가드

PermissionsPanel UI 수정:
- 같은 노드의 effective READ가 거부되면 C/U/D/E도 forced-denied
  (READ가 안 되면 그 페이지 자체에 접근 못 하므로 다른 작업도 의미 없음)
  → 사용자가 Read를 N으로 바꾸는 즉시 같은 행의 CUDE도 회색 비활성

DataTable EXPORT 권한 가드:
- exportResource prop 추가
- useAuth().hasPermission(resource, 'EXPORT')로 export 버튼 표시 여부 결정
- AccessControl의 사용자 관리 / 감사 로그 DataTable에 적용
  - exportResource="admin:user-management"
  - exportResource="admin:audit-logs"

Operation 의미 명확화:
- ParentExclusion release 엔드포인트를 UPDATE → DELETE 로 재분류
  (제외 항목을 "삭제(해제)"하는 의미가 더 정확)

V007 마이그레이션: 권한 트리 명칭을 사이드바 i18n 라벨과 일치
- Level 0 13개 + Level 1 32개 노드의 rsrc_nm을 nav.* / group.* 라벨에 맞춤
- 예: "어구탐지" → "어구 탐지", "Dark Vessel" → "다크베셀 탐지"
- 권한 관리 트리를 운영자가 사이드바와 동일한 명칭으로 이해 가능

API의 RCUDE 적용 현황 (참고):
- READ 19건, UPDATE 8건, CREATE 4건, DELETE 1→2건
- EXPORT는 백엔드 엔드포인트 별도 없음 → 프론트 EXPORT 가드로 처리
- 향후 백엔드 CSV/Excel 생성 API 추가 시 EXPORT operation으로 가드

검증:
- V007 마이그레이션 자동 적용 + Started in 3.272s
- Level 0 13개 모두 사이드바 라벨로 변경됨 확인
- 프론트 빌드 통과 (599ms)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-04-07 10:33:29 +09:00
부모 95ca1018b5
커밋 f545aeafac
5개의 변경된 파일101개의 추가작업 그리고 5개의 파일을 삭제

파일 보기

@ -64,7 +64,7 @@ public class ParentInferenceWorkflowController {
}
@PostMapping("/exclusions/{exclusionId}/release")
@RequirePermission(resource = "parent-inference-workflow:parent-exclusion", operation = "UPDATE")
@RequirePermission(resource = "parent-inference-workflow:parent-exclusion", operation = "DELETE")
public CandidateExclusion releaseExclusion(
@PathVariable Long exclusionId,
@RequestBody(required = false) CancelRequest req

파일 보기

@ -0,0 +1,80 @@
-- ============================================================================
-- V007: 권한 트리 노드의 한국어 명칭을 좌측 사이드바 i18n 라벨과 일치시킴
-- (구조는 유지, rsrc_nm만 갱신)
-- ============================================================================
-- ─── Level 0 (탭/그룹) ─────────────────────────
UPDATE kcg.auth_perm_tree SET rsrc_nm = '종합 상황판' WHERE rsrc_cd = 'dashboard';
UPDATE kcg.auth_perm_tree SET rsrc_nm = '경보 현황판' WHERE rsrc_cd = 'monitoring';
UPDATE kcg.auth_perm_tree SET rsrc_nm = '감시' WHERE rsrc_cd = 'surveillance';
UPDATE kcg.auth_perm_tree SET rsrc_nm = '탐지·분석' WHERE rsrc_cd = 'detection';
UPDATE kcg.auth_perm_tree SET rsrc_nm = '선박' WHERE rsrc_cd = 'vessel';
UPDATE kcg.auth_perm_tree SET rsrc_nm = '위험평가' WHERE rsrc_cd = 'risk-assessment';
UPDATE kcg.auth_perm_tree SET rsrc_nm = '순찰·경로' WHERE rsrc_cd = 'patrol';
UPDATE kcg.auth_perm_tree SET rsrc_nm = '단속·이력' WHERE rsrc_cd = 'enforcement';
UPDATE kcg.auth_perm_tree SET rsrc_nm = '현장 대응' WHERE rsrc_cd = 'field-ops';
UPDATE kcg.auth_perm_tree SET rsrc_nm = 'AI 운영' WHERE rsrc_cd = 'ai-operations';
UPDATE kcg.auth_perm_tree SET rsrc_nm = '통계·보고' WHERE rsrc_cd = 'statistics';
UPDATE kcg.auth_perm_tree SET rsrc_nm = '모선 워크플로우' WHERE rsrc_cd = 'parent-inference-workflow';
UPDATE kcg.auth_perm_tree SET rsrc_nm = '시스템 관리' WHERE rsrc_cd = 'admin';
-- ─── Level 1 (서브탭/패널) ─────────────────────
-- monitoring 자식 (사이드바엔 없으나 향후 분리 가능성)
UPDATE kcg.auth_perm_tree SET rsrc_nm = '알림 목록' WHERE rsrc_cd = 'monitoring:alert-list';
UPDATE kcg.auth_perm_tree SET rsrc_nm = 'KPI 패널' WHERE rsrc_cd = 'monitoring:kpi-panel';
-- surveillance 자식 (좌측: /events = live-map, /map-control)
UPDATE kcg.auth_perm_tree SET rsrc_nm = '이벤트 목록' WHERE rsrc_cd = 'surveillance:live-map';
UPDATE kcg.auth_perm_tree SET rsrc_nm = '위험도 지도' WHERE rsrc_cd = 'surveillance:map-control';
-- detection 자식 (사이드바: /dark-vessel, /gear-detection, /china-fishing)
UPDATE kcg.auth_perm_tree SET rsrc_nm = '어구 탐지' WHERE rsrc_cd = 'detection:gear-detection';
UPDATE kcg.auth_perm_tree SET rsrc_nm = '다크베셀 탐지' WHERE rsrc_cd = 'detection:dark-vessel';
UPDATE kcg.auth_perm_tree SET rsrc_nm = '중국어선 분석' WHERE rsrc_cd = 'detection:china-fishing';
UPDATE kcg.auth_perm_tree SET rsrc_nm = '어구식별' WHERE rsrc_cd = 'detection:gear-identification';
-- vessel 자식 (사이드바: /vessel/:id 단일)
UPDATE kcg.auth_perm_tree SET rsrc_nm = '선박 상세' WHERE rsrc_cd = 'vessel:vessel-detail';
UPDATE kcg.auth_perm_tree SET rsrc_nm = '환적·접촉 탐지' WHERE rsrc_cd = 'vessel:transfer-detection';
-- risk-assessment 자식 (사이드바: /risk-map, /enforcement-plan)
UPDATE kcg.auth_perm_tree SET rsrc_nm = '위험도 지도' WHERE rsrc_cd = 'risk-assessment:risk-map';
UPDATE kcg.auth_perm_tree SET rsrc_nm = '단속 계획' WHERE rsrc_cd = 'risk-assessment:enforcement-plan';
-- patrol 자식 (사이드바: /patrol-route, /fleet-optimization)
UPDATE kcg.auth_perm_tree SET rsrc_nm = '순찰경로 추천' WHERE rsrc_cd = 'patrol:patrol-route';
UPDATE kcg.auth_perm_tree SET rsrc_nm = '다함정 최적화' WHERE rsrc_cd = 'patrol:fleet-optimization';
-- enforcement 자식 (사이드바: /enforcement-history, /event-list)
UPDATE kcg.auth_perm_tree SET rsrc_nm = '단속 이력' WHERE rsrc_cd = 'enforcement:enforcement-history';
UPDATE kcg.auth_perm_tree SET rsrc_nm = '이벤트 목록' WHERE rsrc_cd = 'enforcement:event-list';
-- field-ops 자식 (사이드바: /mobile-service, /ship-agent, /ai-alert)
UPDATE kcg.auth_perm_tree SET rsrc_nm = '모바일 서비스' WHERE rsrc_cd = 'field-ops:mobile-service';
UPDATE kcg.auth_perm_tree SET rsrc_nm = '함정 Agent' WHERE rsrc_cd = 'field-ops:ship-agent';
UPDATE kcg.auth_perm_tree SET rsrc_nm = 'AI 알림 발송' WHERE rsrc_cd = 'field-ops:ai-alert';
-- ai-operations 자식
UPDATE kcg.auth_perm_tree SET rsrc_nm = 'AI 의사결정 지원' WHERE rsrc_cd = 'ai-operations:ai-assistant';
UPDATE kcg.auth_perm_tree SET rsrc_nm = 'AI 모델관리' WHERE rsrc_cd = 'ai-operations:ai-model';
UPDATE kcg.auth_perm_tree SET rsrc_nm = 'MLOps' WHERE rsrc_cd = 'ai-operations:mlops';
-- statistics 자식
UPDATE kcg.auth_perm_tree SET rsrc_nm = '통계 분석' WHERE rsrc_cd = 'statistics:statistics';
UPDATE kcg.auth_perm_tree SET rsrc_nm = '외부 서비스' WHERE rsrc_cd = 'statistics:external-service';
-- parent-inference-workflow 자식
UPDATE kcg.auth_perm_tree SET rsrc_nm = '모선 확정/거부' WHERE rsrc_cd = 'parent-inference-workflow:parent-review';
UPDATE kcg.auth_perm_tree SET rsrc_nm = '후보 제외' WHERE rsrc_cd = 'parent-inference-workflow:parent-exclusion';
UPDATE kcg.auth_perm_tree SET rsrc_nm = '학습 세션' WHERE rsrc_cd = 'parent-inference-workflow:label-session';
UPDATE kcg.auth_perm_tree SET rsrc_nm = '전역 제외 관리' WHERE rsrc_cd = 'parent-inference-workflow:exclusion-management';
-- admin 자식
UPDATE kcg.auth_perm_tree SET rsrc_nm = '사용자 관리' WHERE rsrc_cd = 'admin:user-management';
UPDATE kcg.auth_perm_tree SET rsrc_nm = '역할 관리' WHERE rsrc_cd = 'admin:role-management';
UPDATE kcg.auth_perm_tree SET rsrc_nm = '권한 관리' WHERE rsrc_cd = 'admin:permission-management';
UPDATE kcg.auth_perm_tree SET rsrc_nm = '메뉴 설정' WHERE rsrc_cd = 'admin:menu-management';
UPDATE kcg.auth_perm_tree SET rsrc_nm = '감사 로그' WHERE rsrc_cd = 'admin:audit-logs';
UPDATE kcg.auth_perm_tree SET rsrc_nm = '접근 이력' WHERE rsrc_cd = 'admin:access-logs';
UPDATE kcg.auth_perm_tree SET rsrc_nm = '로그인 이력' WHERE rsrc_cd = 'admin:login-history';
UPDATE kcg.auth_perm_tree SET rsrc_nm = '환경설정' WHERE rsrc_cd = 'admin:system-config';

파일 보기

@ -276,6 +276,7 @@ export function AccessControl() {
searchPlaceholder="계정, 이름, 이메일 검색..."
searchKeys={['userAcnt', 'userNm', 'email', 'rnkpNm']}
exportFilename="사용자목록"
exportResource="admin:user-management"
showPagination
/>
)}
@ -320,6 +321,7 @@ export function AccessControl() {
searchPlaceholder="사용자, 액션, IP 검색..."
searchKeys={['userAcnt', 'actionCd', 'resourceType', 'ipAddress']}
exportFilename="감사로그"
exportResource="admin:audit-logs"
title="모든 운영자 의사결정 자동 기록 (audit_log)"
showPagination
/>

파일 보기

@ -134,15 +134,21 @@ export function PermissionsPanel() {
const key = makeKey(rsrcCd, operCd);
const explicit = draftPerms.get(key);
// 부모의 effective READ 확인
// 1) 부모 노드의 effective READ가 거부되면 자식의 모든 작업 강제 거부
let parentReadDenied = false;
if (parentCd) {
const parentEff = effective.get(parentCd);
parentReadDenied = !parentEff || !parentEff.has('READ');
}
if (parentReadDenied) return 'forced-denied';
if (parentReadDenied && operCd !== 'READ') return 'forced-denied';
if (parentReadDenied && operCd === 'READ' && parentCd) return 'forced-denied';
// 2) 같은 노드의 READ가 effective로 거부되면 C/U/D/E도 강제 거부
// (READ가 안 되면 그 페이지/리소스 자체에 접근 못 하므로 다른 작업 권한도 의미 없음)
if (operCd !== 'READ') {
const ownEff = effective.get(rsrcCd);
const ownReadGranted = ownEff?.has('READ') ?? false;
if (!ownReadGranted) return 'forced-denied';
}
if (explicit === 'Y') return 'explicit-granted';
if (explicit === 'N') return 'explicit-denied';

파일 보기

@ -4,6 +4,7 @@ import { Pagination } from './Pagination';
import { ExcelExport } from './ExcelExport';
import { PrintButton } from './PrintButton';
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
import { useAuth } from '@/app/auth/AuthContext';
/*
* SFR-02 공통컴포넌트: 데이터
@ -38,6 +39,8 @@ interface DataTableProps<T extends Record<string, unknown>> {
showPagination?: boolean;
onRowClick?: (row: T) => void;
className?: string;
/** 이 표가 속한 리소스 코드 (예: "admin:audit-logs"). EXPORT 권한 가드에 사용. */
exportResource?: string;
}
export function DataTable<T extends Record<string, unknown>>({
@ -54,7 +57,12 @@ export function DataTable<T extends Record<string, unknown>>({
showPagination = true,
onRowClick,
className = '',
exportResource,
}: DataTableProps<T>) {
// EXPORT 권한 체크: exportResource가 지정되면 hasPermission으로 가드
// 미지정 시 항상 표시 (하위 호환)
const { hasPermission } = useAuth();
const canExport = exportResource ? hasPermission(exportResource, 'EXPORT') : true;
const [query, setQuery] = useState('');
const [page, setPage] = useState(0);
const [sortKey, setSortKey] = useState<string | null>(null);
@ -118,7 +126,7 @@ export function DataTable<T extends Record<string, unknown>>({
/>
)}
<div className="flex items-center gap-1.5 ml-auto">
{showExport && (
{showExport && canExport && (
<ExcelExport
data={sorted as Record<string, unknown>[]}
columns={columns.map((c) => ({ key: c.key, label: c.label }))}