533 lines
30 KiB
PHP
533 lines
30 KiB
PHP
<?php
|
|
// 권한 분석 — 선택한 메뉴/액션 기준으로 효과권한(EFFECTIVE) 분석
|
|
$CURRENT_SECTION = 'permission';
|
|
include '../inc/header.php';
|
|
?>
|
|
<div class="container" style="max-width:1280px; margin-top:24px;">
|
|
<div class="card shadow-sm">
|
|
<div class="card-header d-flex flex-wrap gap-2 align-items-center">
|
|
<strong>권한 분석</strong>
|
|
<select class="form-select form-select-sm" id="actionSelect" style="width:160px;">
|
|
<option value="read">읽기(read)</option>
|
|
<option value="create">쓰기(create)</option>
|
|
<option value="update">수정(update)</option>
|
|
<option value="delete">삭제(delete)</option>
|
|
<option value="approve">결재(approve)</option>
|
|
</select>
|
|
<input class="form-control form-control-sm" id="keyword" placeholder="사용자/부서/역할 검색" style="width:260px;">
|
|
<button class="btn btn-sm btn-outline-secondary" id="btnRecalc">권한 재계산</button>
|
|
<div class="ms-auto d-flex gap-2">
|
|
<button class="btn btn-sm btn-outline-success" id="btnExport">CSV 내보내기</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card-body">
|
|
<div class="row g-3">
|
|
<!-- 좌: 메뉴 트리 -->
|
|
<div class="col-md-5">
|
|
<div class="border rounded p-2">
|
|
<div class="d-flex align-items-center justify-content-between mb-2">
|
|
<div class="small text-muted">분석 메뉴</div>
|
|
<div class="input-group input-group-sm" style="max-width:280px;">
|
|
<input type="text" class="form-control" id="menuSearch" placeholder="메뉴/코드 검색">
|
|
<button class="btn btn-outline-secondary" id="btnMenuSearch">검색</button>
|
|
<button class="btn btn-outline-secondary" id="btnMenuReset">초기화</button>
|
|
</div>
|
|
<!-- (기존) 메뉴 검색 input-group 바로 뒤에 추가 -->
|
|
<div class="form-check form-check-sm ms-2">
|
|
<input class="form-check-input" type="checkbox" id="highlightAllowed">
|
|
<label class="form-check-label small" for="highlightAllowed">접근가능</label>
|
|
</div>
|
|
</div>
|
|
<div class="table-responsive" style="max-height:64vh; overflow:auto;">
|
|
<table class="table table-sm align-middle" id="menuTable">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th style="width:60%;">메뉴</th>
|
|
<th>코드</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody><!-- PermissionMenu로 렌더 --></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 우: 분석 결과 -->
|
|
<div class="col-md-7">
|
|
<div class="border rounded p-2">
|
|
<div class="d-flex align-items-center justify-content-between mb-2">
|
|
<div>
|
|
<div class="small text-muted">선택 메뉴</div>
|
|
<div id="selMenuTitle" class="fw-semibold">-</div>
|
|
</div>
|
|
<div class="small text-muted">
|
|
결론 규칙: <code>ALLOW = 부서 OR 역할 OR (개인 ALLOW)</code> → <code>개인 DENY 최우선</code>
|
|
</div>
|
|
</div>
|
|
|
|
<ul class="nav nav-pills mb-2" role="tablist">
|
|
<li class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tabAllow">접근 가능</button></li>
|
|
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabDeny">접근 불가</button></li>
|
|
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabWhy">근거/상세</button></li>
|
|
<!-- 탭 버튼들 (기존 3개 뒤에 추가) -->
|
|
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabUser">사용자 역추적</button></li>
|
|
</ul>
|
|
<div class="tab-content">
|
|
<!-- 접근 가능 -->
|
|
<div class="tab-pane fade show active" id="tabAllow">
|
|
<div class="table-responsive" style="max-height:28vh; overflow:auto;">
|
|
<table class="table table-sm align-middle" id="tblAllow">
|
|
<thead class="table-light">
|
|
<tr><th style="width:30%;">사용자</th><th>부서</th><th>역할</th><th>개인모드</th><th>최종</th></tr>
|
|
</thead>
|
|
<tbody></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<!-- 접근 불가 -->
|
|
<div class="tab-pane fade" id="tabDeny">
|
|
<div class="table-responsive" style="max-height:28vh; overflow:auto;">
|
|
<table class="table table-sm align-middle" id="tblDeny">
|
|
<thead class="table-light">
|
|
<tr><th style="width:30%;">사용자</th><th>부서</th><th>역할</th><th>개인모드</th><th>사유</th></tr>
|
|
</thead>
|
|
<tbody></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<!-- 근거/상세 -->
|
|
<div class="tab-pane fade" id="tabWhy">
|
|
<div class="table-responsive" style="max-height:28vh; overflow:auto;">
|
|
<table class="table table-sm align-middle" id="tblWhy">
|
|
<thead class="table-light">
|
|
<tr><th style="width:24%;">사용자</th><th>부서 허용</th><th>역할 허용</th><th>개인 허용</th><th>개인 DENY</th><th>최종</th></tr>
|
|
</thead>
|
|
<tbody></tbody>
|
|
</table>
|
|
</div>
|
|
<div class="small text-muted">* “개인 DENY”가 하나라도 있으면 최종 DENY (최우선)</div>
|
|
</div>
|
|
<!-- 탭 콘텐츠 (아래 블록을 기존 tab-content 맨 아래에 추가) -->
|
|
<div class="tab-pane fade" id="tabUser">
|
|
<div class="d-flex flex-wrap gap-2 align-items-center mb-2">
|
|
<select class="form-select form-select-sm" id="userTraceSelect" style="width:260px;">
|
|
<!-- 샘플: USERS 배열과 동일하게 -->
|
|
<option value="101">권혁성(kevin)</option>
|
|
<option value="102">김슬기(sally)</option>
|
|
<option value="103">이민수(minsu)</option>
|
|
</select>
|
|
<select class="form-select form-select-sm" id="userTraceAction" style="width:160px;">
|
|
<option value="read">읽기(read)</option>
|
|
<option value="create">쓰기(create)</option>
|
|
<option value="update">수정(update)</option>
|
|
<option value="delete">삭제(delete)</option>
|
|
<option value="approve">결재(approve)</option>
|
|
</select>
|
|
<div class="form-check ms-2">
|
|
<input class="form-check-input" type="checkbox" id="onlyAllow">
|
|
<label class="form-check-label" for="onlyAllow">ALLOW만 보기</label>
|
|
</div>
|
|
<div class="ms-auto d-flex gap-2">
|
|
<button class="btn btn-sm btn-outline-secondary" id="btnTraceExport">CSV</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="table-responsive" style="max-height:50vh; overflow:auto;">
|
|
<table class="table table-sm align-middle" id="tblUserTrace">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th style="width:40%;">메뉴</th>
|
|
<th>코드</th>
|
|
<th class="text-center">최종</th>
|
|
<th class="text-center">부서</th>
|
|
<th class="text-center">역할</th>
|
|
<th class="text-center">개인 ALLOW</th>
|
|
<th class="text-center">개인 DENY</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div> <!-- row -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.menu-name { font-size:14px; font-weight:500; }
|
|
.menu-url { color:#6c757d; font-size:12px;}
|
|
.indent { font-family: ui-monospace, Menlo, Consolas, monospace; color:#6c757d; }
|
|
#menuTable tbody tr.active { background: #eef3ff; }
|
|
.badge-inherit{ background:#6c757d; }
|
|
.badge-allow{ background:#0d6efd; }
|
|
.badge-deny{ background:#dc3545; }
|
|
/* ALLOW 행은 왼쪽 라인 + 은은한 배경, DENY는 살짝 흐리게 */
|
|
#menuTable tbody tr.allow-row {
|
|
background: #f3f8ff;
|
|
border-left: 4px solid #0d6efd;
|
|
}
|
|
#menuTable tbody tr.deny-dim {
|
|
opacity: .45;
|
|
}
|
|
</style>
|
|
|
|
<script src="/tenant/assets/js/permission_menu.js"></script>
|
|
<script>
|
|
// ================== 샘플 데이터 (실전: AJAX로 교체) ==================
|
|
const MENU_DATA = [
|
|
{id:1,parent_id:null,title:'대시보드',code:'dashboard',path:'/tenant/member/dashboard.php',source:'system'},
|
|
{id:2,parent_id:null,title:'수주',code:'order',path:null,source:'system'},
|
|
{id:21,parent_id:2,title:'수주 관리',code:'order.manage',path:'/tenant/order/manage.php',source:'system'},
|
|
{id:22,parent_id:2,title:'수주 등록/수정',code:'order.edit',path:'/tenant/order/edit.php',source:'system'},
|
|
{id:3,parent_id:null,title:'생산',code:'production',path:null,source:'system'},
|
|
{id:31,parent_id:3,title:'작업 지시',code:'production.wi',path:'/tenant/production/work_instruction.php',source:'workflow'},
|
|
{id:32,parent_id:3,title:'스크린 작업',code:'production.screen',path:'/tenant/production/screen_work.php',source:'workflow'},
|
|
{id:5,parent_id:null,title:'게시판',code:'board',path:null,source:'system'},
|
|
{id:51,parent_id:5,title:'공지사항',code:'board.notice',path:'/tenant/board/notice_list.php',source:'board'},
|
|
];
|
|
const TENANT_USE = {dashboard:true, order:true,'order.manage':true,'order.edit':false,
|
|
production:true,'production.wi':true,'production.screen':true,
|
|
board:true,'board.notice':true};
|
|
|
|
// 조직/역할/유저 샘플
|
|
const DEPARTMENTS = [{id:201,name:'개발팀'},{id:202,name:'영업팀'}];
|
|
const ROLES = [{id:301,name:'최고관리자'},{id:302,name:'일반관리자'},{id:303,name:'직원'}];
|
|
const USERS = [
|
|
{id:101, name:'권혁성', uid:'kevin', dept_id:201, role_ids:[301,302]},
|
|
{id:102, name:'김슬기', uid:'sally', dept_id:201, role_ids:[303]},
|
|
{id:103, name:'이민수', uid:'minsu', dept_id:202, role_ids:[303]},
|
|
];
|
|
|
|
// 부서/역할 권한 정의(샘플) — 실제는 DB에서 로드
|
|
const DEPT_PERMS = {
|
|
// dept_id: { code: {read,create,update,delete,approve} }
|
|
201: { 'order.manage': {read:true,create:true,update:true,delete:false,approve:false},
|
|
'production.wi':{read:true,create:true,update:false,delete:false,approve:false},
|
|
'dashboard': {read:true,create:false,update:false,delete:false,approve:false},
|
|
'board.notice': {read:true,create:false,update:false,delete:false,approve:false} },
|
|
202: { 'order.manage': {read:true,create:false,update:false,delete:false,approve:false},
|
|
'dashboard': {read:true,create:false,update:false,delete:false,approve:false} }
|
|
};
|
|
const ROLE_PERMS = {
|
|
// role_id: { code: {...} }
|
|
301: { 'order.manage':{read:true,create:true,update:true,delete:true,approve:true},
|
|
'production': {read:true,create:false,update:false,delete:false,approve:false} },
|
|
302: { 'production.wi':{read:true,create:true,update:true,delete:false,approve:false} },
|
|
303: { 'board.notice':{read:true,create:true,update:true,delete:false,approve:false} }
|
|
};
|
|
|
|
// 개인 예외(샘플)
|
|
// 모드: 사용자별 전역 모드 (INHERIT/ALLOW/DENY)
|
|
const USER_MODE = { 101:'ALLOW', 102:'INHERIT', 103:'DENY' };
|
|
// 개인 허용(ALLOW 모드일 때만 의미) — user_id: { code: {...} }
|
|
const USER_PERMS = {
|
|
101: { 'production.screen': {read:true,create:true,update:false,delete:false,approve:false} }
|
|
};
|
|
// 개인 DENY(선택사항: 메뉴별 차단) — user_id: Set(menu_code)
|
|
const USER_DENY = {
|
|
102: new Set(['order.manage']) // 예: 상속으로 되더라도 이 메뉴만 차단
|
|
};
|
|
|
|
// ================== 공통 트리 세팅 + 좌측 메뉴 렌더 ==================
|
|
PermissionMenu.setData(MENU_DATA, TENANT_USE);
|
|
|
|
function renderMenuList(){
|
|
const rows = PermissionMenu.buildRows((node)=>{
|
|
return `<td class="text-muted">${node.code}</td>`;
|
|
});
|
|
document.querySelector('#menuTable tbody').innerHTML = rows;
|
|
}
|
|
renderMenuList();
|
|
|
|
let selectedMenuCode = null;
|
|
function selectFirstMenuIfNeeded(){
|
|
if (!selectedMenuCode){
|
|
const first = document.querySelector('#menuTable tbody tr');
|
|
if (first){ first.classList.add('active'); selectedMenuCode = first.dataset.code; updateSelectedInfo(); runAnalyze(); }
|
|
}
|
|
}
|
|
function updateSelectedInfo(){
|
|
const tr = document.querySelector('#menuTable tbody tr.active');
|
|
const title = tr ? tr.querySelector('td:first-child').innerText.trim() : '-';
|
|
document.querySelector('#selMenuTitle').innerText = title;
|
|
}
|
|
|
|
// 메뉴 선택
|
|
document.querySelector('#menuTable').addEventListener('click', (e)=>{
|
|
const tr = e.target.closest('tr'); if(!tr) return;
|
|
document.querySelectorAll('#menuTable tbody tr').forEach(x=> x.classList.remove('active'));
|
|
tr.classList.add('active');
|
|
selectedMenuCode = tr.dataset.code;
|
|
updateSelectedInfo();
|
|
runAnalyze();
|
|
});
|
|
|
|
// 메뉴 검색
|
|
document.querySelector('#btnMenuSearch').addEventListener('click', ()=>{
|
|
const q = document.querySelector('#menuSearch').value.trim().toLowerCase();
|
|
document.querySelectorAll('#menuTable tbody tr').forEach(tr=>{
|
|
const name = tr.querySelector('td:first-child').innerText.toLowerCase();
|
|
const code = tr.dataset.code.toLowerCase();
|
|
tr.style.display = (q==='' || name.includes(q) || code.includes(q)) ? '' : 'none';
|
|
});
|
|
});
|
|
document.querySelector('#btnMenuReset').addEventListener('click', ()=>{
|
|
document.querySelector('#menuSearch').value=''; document.querySelectorAll('#menuTable tbody tr').forEach(tr=> tr.style.display='');
|
|
});
|
|
|
|
// ================== 분석 로직 ==================
|
|
function hasDeptAllow(userId, code, action){
|
|
const user = USERS.find(u=>u.id==userId); if(!user) return false;
|
|
const map = DEPT_PERMS[user.dept_id]||{};
|
|
return !!(map[code] && map[code][action]);
|
|
}
|
|
function hasRoleAllow(userId, code, action){
|
|
const user = USERS.find(u=>u.id==userId); if(!user) return false;
|
|
return user.role_ids.some(rid => !!(ROLE_PERMS[rid] && ROLE_PERMS[rid][code] && ROLE_PERMS[rid][code][action]));
|
|
}
|
|
function hasUserAllow(userId, code, action){
|
|
const mode = USER_MODE[userId] || 'INHERIT';
|
|
if (mode!=='ALLOW') return false;
|
|
const up = USER_PERMS[userId]||{};
|
|
return !!(up[code] && up[code][action]);
|
|
}
|
|
function hasUserDeny(userId, code){
|
|
// 전역 DENY 우선
|
|
if ((USER_MODE[userId]||'INHERIT')==='DENY') return true;
|
|
// 메뉴별 DENY
|
|
return !!(USER_DENY[userId] && USER_DENY[userId].has(code));
|
|
}
|
|
function effective(userId, code, action){
|
|
// 개인 DENY 최우선
|
|
if (hasUserDeny(userId, code)) return {allow:false, reason:'개인 DENY'};
|
|
const allow = hasDeptAllow(userId, code, action) || hasRoleAllow(userId, code, action) || hasUserAllow(userId, code, action);
|
|
return {allow, reason: allow?'ALLOW':'NO MATCH'};
|
|
}
|
|
|
|
function runAnalyze(){
|
|
if (!selectedMenuCode){ selectFirstMenuIfNeeded(); return; }
|
|
const action = document.querySelector('#actionSelect').value;
|
|
const kw = document.querySelector('#keyword').value.trim().toLowerCase();
|
|
|
|
const rowsAllow=[], rowsDeny=[], rowsWhy=[];
|
|
USERS.forEach(u=>{
|
|
const name = `${u.name}(${u.uid})`;
|
|
if (kw && !name.toLowerCase().includes(kw)) return;
|
|
|
|
const dept = (DEPARTMENTS.find(d=>d.id===u.dept_id)||{}).name || '-';
|
|
const roleNames = u.role_ids.map(id => (ROLES.find(r=>r.id===id)||{}).name).filter(Boolean).join(', ') || '-';
|
|
const mode = USER_MODE[u.id] || 'INHERIT';
|
|
|
|
const deptOk = hasDeptAllow(u.id, selectedMenuCode, action);
|
|
const roleOk = hasRoleAllow(u.id, selectedMenuCode, action);
|
|
const userOk = hasUserAllow(u.id, selectedMenuCode, action);
|
|
const uDeny = hasUserDeny(u.id, selectedMenuCode);
|
|
|
|
const eff = effective(u.id, selectedMenuCode, action);
|
|
|
|
const modeBadge = mode==='INHERIT' ? '<span class="badge badge-inherit">INHERIT</span>' :
|
|
mode==='ALLOW' ? '<span class="badge badge-allow">ALLOW</span>' :
|
|
'<span class="badge badge-deny">DENY</span>';
|
|
|
|
const finalBadge = eff.allow ? '<span class="badge bg-primary">ALLOW</span>' :
|
|
'<span class="badge bg-danger">DENY</span>';
|
|
|
|
// WHY 표
|
|
rowsWhy.push(`<tr>
|
|
<td>${name}</td>
|
|
<td>${deptOk?'Y':''}</td>
|
|
<td>${roleOk?'Y':''}</td>
|
|
<td>${userOk?'Y':''}</td>
|
|
<td>${uDeny?'Y':''}</td>
|
|
<td>${eff.allow? 'ALLOW':'DENY'}</td>
|
|
</tr>`);
|
|
|
|
if (eff.allow){
|
|
rowsAllow.push(`<tr>
|
|
<td>${name}</td><td>${dept}</td><td>${roleNames}</td><td>${modeBadge}</td><td>${finalBadge}</td>
|
|
</tr>`);
|
|
} else {
|
|
const reason = uDeny ? '개인 DENY' : '권한 없음';
|
|
rowsDeny.push(`<tr>
|
|
<td>${name}</td><td>${dept}</td><td>${roleNames}</td><td>${modeBadge}</td><td>${reason}</td>
|
|
</tr>`);
|
|
}
|
|
});
|
|
|
|
document.querySelector('#tblAllow tbody').innerHTML = rowsAllow.join('') || `<tr><td colspan="5" class="text-center text-muted">해당 없음</td></tr>`;
|
|
document.querySelector('#tblDeny tbody').innerHTML = rowsDeny.join('') || `<tr><td colspan="5" class="text-center text-muted">해당 없음</td></tr>`;
|
|
document.querySelector('#tblWhy tbody').innerHTML = rowsWhy.join('') || `<tr><td colspan="6" class="text-center text-muted">해당 없음</td></tr>`;
|
|
}
|
|
|
|
// 액션/검색/재계산
|
|
document.querySelector('#actionSelect').addEventListener('change', runAnalyze);
|
|
document.querySelector('#keyword').addEventListener('keyup', (e)=>{ if(e.key==='Enter') runAnalyze(); });
|
|
document.querySelector('#btnRecalc').addEventListener('click', runAnalyze);
|
|
|
|
// CSV 내보내기(접근 가능 탭 기준 + WHY 탭 함께)
|
|
document.querySelector('#btnExport').addEventListener('click', ()=>{
|
|
const rows = [['tab','user','dept','roles','mode','final','dept_allow','role_allow','user_allow','user_deny']];
|
|
USERS.forEach(u=>{
|
|
const dept = (DEPARTMENTS.find(d=>d.id===u.dept_id)||{}).name || '-';
|
|
const roleNames = u.role_ids.map(id => (ROLES.find(r=>r.id===id)||{}).name).filter(Boolean).join('|') || '-';
|
|
const mode = USER_MODE[u.id] || 'INHERIT';
|
|
const a = document.querySelector('#actionSelect').value;
|
|
const code = selectedMenuCode || '';
|
|
const deptOk = hasDeptAllow(u.id, code, a);
|
|
const roleOk = hasRoleAllow(u.id, code, a);
|
|
const userOk = hasUserAllow(u.id, code, a);
|
|
const uDeny = hasUserDeny(u.id, code);
|
|
const eff = effective(u.id, code, a);
|
|
rows.push(['result', `${u.name}(${u.uid})`, dept, roleNames, mode, eff.allow?'ALLOW':'DENY', deptOk?'Y':'', roleOk?'Y':'', userOk?'Y':'', uDeny?'Y':'']);
|
|
});
|
|
const csv = rows.map(r=> r.map(x=> `"${String(x).replace(/"/g,'""')}"`).join(',')).join('\n');
|
|
const blob = new Blob([csv], {type:'text/csv;charset=utf-8;'});
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a'); a.href = url; a.download = 'permission_analysis.csv'; a.click();
|
|
URL.revokeObjectURL(url);
|
|
});
|
|
|
|
// 최초 선택/분석
|
|
selectFirstMenuIfNeeded();
|
|
|
|
// ---------- 사용자 역추적 ----------
|
|
function traceEffectiveForUser(userId, code, action){
|
|
// anlyze 탭에서 쓰던 로직 재사용
|
|
const deptOk = hasDeptAllow(userId, code, action);
|
|
const roleOk = hasRoleAllow(userId, code, action);
|
|
const userOk = hasUserAllow(userId, code, action);
|
|
const uDeny = hasUserDeny(userId, code);
|
|
const allow = !uDeny && (deptOk || roleOk || userOk);
|
|
return {allow, deptOk, roleOk, userOk, uDeny};
|
|
}
|
|
|
|
function renderUserTrace(){
|
|
const userId = parseInt(document.getElementById('userTraceSelect').value,10);
|
|
const action = document.getElementById('userTraceAction').value;
|
|
const onlyAllow = document.getElementById('onlyAllow').checked;
|
|
|
|
const rows = [];
|
|
// PermissionMenu.activeCodes() 순회해서 메뉴별 결과 뽑기
|
|
const { maps } = PermissionMenu;
|
|
const codes = PermissionMenu.activeCodes();
|
|
const { byCode } = maps();
|
|
|
|
codes.forEach(code=>{
|
|
const node = byCode[code];
|
|
if (!node) return;
|
|
const r = traceEffectiveForUser(userId, code, action);
|
|
if (onlyAllow && !r.allow) return;
|
|
|
|
const finalBadge = r.allow ? '<span class="badge bg-primary">ALLOW</span>'
|
|
: '<span class="badge bg-danger">DENY</span>';
|
|
rows.push(`
|
|
<tr>
|
|
<td><span class="menu-name">${node.title}</span>
|
|
${node.source==='workflow' ? '<span class="badge bg-warning text-dark ms-1">workflow</span>':''}
|
|
${node.source==='board' ? '<span class="badge bg-success ms-1">board</span>':''}
|
|
${node.path? `<span class="menu-url ms-2 text-muted">${node.path}</span>`:''}
|
|
</td>
|
|
<td class="text-muted">${node.code}</td>
|
|
<td class="text-center">${finalBadge}</td>
|
|
<td class="text-center">${r.deptOk?'Y':''}</td>
|
|
<td class="text-center">${r.roleOk?'Y':''}</td>
|
|
<td class="text-center">${r.userOk?'Y':''}</td>
|
|
<td class="text-center">${r.uDeny?'Y':''}</td>
|
|
</tr>
|
|
`);
|
|
});
|
|
|
|
const tbody = document.querySelector('#tblUserTrace tbody');
|
|
tbody.innerHTML = rows.join('') || `<tr><td colspan="7" class="text-center text-muted">해당 없음</td></tr>`;
|
|
}
|
|
|
|
// 이벤트 바인딩
|
|
['userTraceSelect','userTraceAction','onlyAllow'].forEach(id=>{
|
|
const el = document.getElementById(id);
|
|
if (el) el.addEventListener('change', renderUserTrace);
|
|
});
|
|
|
|
// CSV 내보내기
|
|
document.getElementById('btnTraceExport').addEventListener('click', ()=>{
|
|
const userId = parseInt(document.getElementById('userTraceSelect').value,10);
|
|
const action = document.getElementById('userTraceAction').value;
|
|
|
|
const user = USERS.find(u=>u.id===userId);
|
|
const rows = [['menu','code','final','dept','role','user_allow','user_deny','action',`user:${user?.name||''}(${user?.uid||''})`]];
|
|
|
|
const { maps } = PermissionMenu; const { byCode } = maps();
|
|
PermissionMenu.activeCodes().forEach(code=>{
|
|
const node = byCode[code]; if (!node) return;
|
|
const r = traceEffectiveForUser(userId, code, action);
|
|
rows.push([
|
|
node.title, code, r.allow?'ALLOW':'DENY',
|
|
r.deptOk?'Y':'', r.roleOk?'Y':'', r.userOk?'Y':'', r.uDeny?'Y':'', action
|
|
]);
|
|
});
|
|
|
|
const csv = rows.map(r=> r.map(x=>`"${String(x).replace(/"/g,'""')}"`).join(',')).join('\n');
|
|
const blob = new Blob([csv], {type:'text/csv;charset=utf-8;'});
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a'); a.href = url; a.download = 'user_trace.csv'; a.click();
|
|
URL.revokeObjectURL(url);
|
|
});
|
|
|
|
// 처음 들어오면 한 번 렌더
|
|
renderUserTrace();
|
|
|
|
|
|
// 좌측 트리에 허용 하이라이트 적용
|
|
function updateMenuHighlight() {
|
|
const chk = document.getElementById('highlightAllowed');
|
|
const userSel = document.getElementById('userTraceSelect');
|
|
const actSel = document.getElementById('userTraceAction');
|
|
if (!chk || !userSel || !actSel) return;
|
|
|
|
const on = chk.checked;
|
|
const userId = parseInt(userSel.value, 10);
|
|
const action = actSel.value;
|
|
|
|
document.querySelectorAll('#menuTable tbody tr').forEach(tr=>{
|
|
tr.classList.remove('allow-row','deny-dim');
|
|
if (!on) return; // 꺼져 있으면 리셋만
|
|
|
|
const code = tr.dataset.code;
|
|
// analyze 탭에서 쓰던 판단식 재사용
|
|
const r = effective(userId, code, action); // {allow:boolean}
|
|
if (r.allow) tr.classList.add('allow-row');
|
|
else tr.classList.add('deny-dim');
|
|
});
|
|
}
|
|
|
|
// 탭/선택 변경 시 갱신
|
|
['highlightAllowed','userTraceSelect','userTraceAction'].forEach(id=>{
|
|
const el = document.getElementById(id);
|
|
if (el) el.addEventListener('change', updateMenuHighlight);
|
|
});
|
|
|
|
// 최초 렌더/재렌더 후에도 호출
|
|
document.addEventListener('DOMContentLoaded', updateMenuHighlight);
|
|
|
|
// 좌측 메뉴 다시 그릴 때도 호출 (이미 있는 렌더 뒤쪽에 한 줄만 추가)
|
|
const _origRenderMenuList = renderMenuList;
|
|
renderMenuList = function() {
|
|
_origRenderMenuList();
|
|
updateMenuHighlight();
|
|
};
|
|
|
|
// 사용자 역추적 테이블 재계산 후에도 한 번 더 (이미 만든 함수 끝에 한 줄 추가)
|
|
const _origRenderUserTrace = renderUserTrace;
|
|
renderUserTrace = function() {
|
|
_origRenderUserTrace();
|
|
updateMenuHighlight();
|
|
};
|
|
</script>
|
|
|
|
<?php include '../inc/footer.php'; ?>
|