feat(tkuser): 부서 마스터 + 개인 추가 부여 권한 시스템 구현

부서 권한을 바닥(마스터)으로 설정하고 개인은 추가 부여만 가능하도록 변경.
부서 허용 항목은 개인 페이지에서 잠금(해제 불가) 표시되며,
부서 이동 시 기존 개인 권한이 자동 초기화됨.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-16 11:49:25 +09:00
parent f711a721ec
commit 4108a6e64a
5 changed files with 130 additions and 39 deletions

View File

@@ -164,6 +164,7 @@ async function getDepartmentPermissions(req, res, next) {
/**
* POST /api/permissions/departments/:deptId/bulk-set - 부서 권한 일괄 설정
* 저장 후 소속 사용자의 중복 개인 레코드 정리
*/
async function bulkSetDepartmentPermissions(req, res, next) {
try {
@@ -177,10 +178,34 @@ async function bulkSetDepartmentPermissions(req, res, next) {
granted_by_id: grantedById
});
// 소속 사용자의 중복 개인 레코드 정리
const { getPool } = require('../models/userModel');
const db = getPool();
const [deptUsers] = await db.query(
'SELECT user_id FROM sso_users WHERE department_id = ?', [deptId]
);
// 부서가 허용한 페이지 목록
const grantedPages = (permissions || [])
.filter(p => p.can_access)
.map(p => p.page_name);
let syncedUsers = 0;
if (grantedPages.length > 0 && deptUsers.length > 0) {
for (const u of deptUsers) {
const [delResult] = await db.query(
`DELETE FROM user_page_permissions WHERE user_id = ? AND page_name IN (${grantedPages.map(() => '?').join(',')})`,
[u.user_id, ...grantedPages]
);
if (delResult.affectedRows > 0) syncedUsers++;
}
}
res.json({
success: true,
message: `${result.updated_count} 부서 권한 설정되었습니다`,
updated_count: result.updated_count
message: `${result.updated_count}개 부서 권한 설정 (${deptUsers.length}명 적용)`,
updated_count: result.updated_count,
synced_users: deptUsers.length
});
} catch (err) {
next(err);

View File

@@ -5,6 +5,7 @@
*/
const userModel = require('../models/userModel');
const permissionModel = require('../models/permissionModel');
/**
* GET /api/users - 전체 사용자 목록
@@ -62,6 +63,14 @@ async function updateUser(req, res, next) {
delete data.full_name;
}
// 부서 변경 감지 → 개인 권한 초기화
if (data.department_id !== undefined) {
const existingUser = await userModel.findById(userId);
if (existingUser && existingUser.department_id !== data.department_id) {
await permissionModel.clearUserPermissionsForDepartmentChange(userId);
}
}
const user = await userModel.update(userId, data);
if (!user) {
return res.status(404).json({ success: false, error: '사용자를 찾을 수 없습니다' });

View File

@@ -105,14 +105,39 @@ async function grantPermission({ user_id, page_name, can_access, granted_by_id,
}
/**
* 일괄 권한 부여
* 일괄 권한 부여 (부서 허용 페이지는 스킵 + 기존 중복 레코드 정리)
*/
async function bulkGrant({ user_id, permissions, granted_by_id }) {
const db = getPool();
let count = 0;
// 부서 권한 조회
const [userRows] = await db.query(
'SELECT department_id FROM sso_users WHERE user_id = ?', [user_id]
);
const deptId = userRows.length > 0 ? userRows[0].department_id : null;
const deptGranted = {};
if (deptId) {
const [deptPerms] = await db.query(
'SELECT page_name, can_access FROM department_page_permissions WHERE department_id = ?',
[deptId]
);
deptPerms.forEach(p => { if (p.can_access) deptGranted[p.page_name] = true; });
}
for (const perm of permissions) {
if (!DEFAULT_PAGES[perm.page_name]) continue;
// 부서가 허용한 페이지는 개인 레코드 삭제 (스킵)
if (deptGranted[perm.page_name]) {
await db.query(
'DELETE FROM user_page_permissions WHERE user_id = ? AND page_name = ?',
[user_id, perm.page_name]
);
continue;
}
await db.query(
`INSERT INTO user_page_permissions (user_id, page_name, can_access, granted_by_id)
VALUES (?, ?, ?, ?)
@@ -126,12 +151,27 @@ async function bulkGrant({ user_id, permissions, granted_by_id }) {
}
/**
* 접근 권한 확인 (우선순위: 개인 > 부서 > 기본값)
* 접근 권한 확인 (우선순위: 부서 OR 개인 OR 기본값)
* 부서가 허용하면 무조건 허용 (개인이 해제 불가)
*/
async function checkAccess(userId, pageName) {
const db = getPool();
// 1. 명시적 개인 권한
// 1. 부서 권한 (마스터) — 부서가 허용하면 무조건 허용
const [userRows] = await db.query(
'SELECT department_id FROM sso_users WHERE user_id = ?', [userId]
);
if (userRows.length > 0 && userRows[0].department_id) {
const [deptRows] = await db.query(
'SELECT can_access FROM department_page_permissions WHERE department_id = ? AND page_name = ?',
[userRows[0].department_id, pageName]
);
if (deptRows.length > 0 && deptRows[0].can_access) {
return { can_access: true, reason: 'department_permission' };
}
}
// 2. 개인 권한 (추가 부여)
const [rows] = await db.query(
'SELECT can_access FROM user_page_permissions WHERE user_id = ? AND page_name = ?',
[userId, pageName]
@@ -140,26 +180,9 @@ async function checkAccess(userId, pageName) {
return { can_access: rows[0].can_access, reason: 'explicit_permission' };
}
// 2. 부서 권한
const [userRows] = await db.query(
'SELECT department_id FROM sso_users WHERE user_id = ?',
[userId]
);
if (userRows.length > 0 && userRows[0].department_id) {
const [deptRows] = await db.query(
'SELECT can_access FROM department_page_permissions WHERE department_id = ? AND page_name = ?',
[userRows[0].department_id, pageName]
);
if (deptRows.length > 0) {
return { can_access: deptRows[0].can_access, reason: 'department_permission' };
}
}
// 3. 기본 권한
const pageConfig = DEFAULT_PAGES[pageName];
if (!pageConfig) {
return { can_access: false, reason: 'invalid_page' };
}
if (!pageConfig) return { can_access: false, reason: 'invalid_page' };
return { can_access: pageConfig.default_access, reason: 'default_permission' };
}
@@ -252,21 +275,36 @@ async function getUserPermissionsWithSource(userId) {
deptPerms.forEach(p => { deptPermMap[p.page_name] = !!p.can_access; });
}
// 모든 페이지에 대해 결과 조합
// 모든 페이지에 대해 결과 조합 (부서 OR 개인 OR 기본)
const result = {};
for (const [pageName, config] of Object.entries(DEFAULT_PAGES)) {
if (pageName in userPermMap) {
result[pageName] = { can_access: userPermMap[pageName], source: 'explicit' };
} else if (pageName in deptPermMap) {
result[pageName] = { can_access: deptPermMap[pageName], source: 'department' };
const deptGranted = deptPermMap[pageName] === true;
if (deptGranted) {
// 부서가 허용 → 무조건 허용, 잠금
result[pageName] = { can_access: true, source: 'department', dept_granted: true };
} else if (pageName in userPermMap) {
result[pageName] = { can_access: userPermMap[pageName], source: 'explicit', dept_granted: false };
} else {
result[pageName] = { can_access: config.default_access, source: 'default' };
result[pageName] = { can_access: config.default_access, source: 'default', dept_granted: false };
}
}
return { permissions: result, department_id: deptId };
}
/**
* 부서 이동 시 개인 권한 초기화 — user_page_permissions 전체 삭제
*/
async function clearUserPermissionsForDepartmentChange(userId) {
const db = getPool();
const [result] = await db.query(
'DELETE FROM user_page_permissions WHERE user_id = ?',
[userId]
);
return { deleted_count: result.affectedRows };
}
/**
* 권한 삭제 (기본값으로 되돌림)
*/
@@ -293,5 +331,6 @@ module.exports = {
setDepartmentPermission,
bulkSetDepartmentPermissions,
deleteDepartmentPermission,
getUserPermissionsWithSource
getUserPermissionsWithSource,
clearUserPermissionsForDepartmentChange
};

View File

@@ -251,6 +251,9 @@
</button>
<span id="deptPermSaveStatus" class="text-sm"></span>
</div>
<p class="text-xs text-gray-400 mt-1">
<i class="fas fa-info-circle mr-1"></i>부서 권한 저장 시 소속 사용자에게 자동 적용됩니다
</p>
</div>
<div id="deptPermEmpty" class="text-center text-gray-400 py-8 text-sm">
@@ -2001,7 +2004,7 @@
<!-- JS: Tabs -->
<script src="/static/js/tkuser-tabs.js?v=2026031401"></script>
<!-- JS: Individual modules -->
<script src="/static/js/tkuser-users.js?v=2026031401"></script>
<script src="/static/js/tkuser-users.js?v=2026031601"></script>
<script src="/static/js/tkuser-projects.js?v=2026031401"></script>
<script src="/static/js/tkuser-departments.js?v=2026031401"></script>
<script src="/static/js/tkuser-issue-types.js?v=2026031401"></script>

View File

@@ -104,7 +104,7 @@ function loadPermissionsTab() {
}
/* ===== Users State ===== */
let users = [], selectedUserId = null, currentPermissions = {}, currentPermSources = {};
let users = [], selectedUserId = null, currentPermissions = {}, currentPermSources = {}, currentDeptGranted = {};
/* ===== Users CRUD ===== */
async function loadUsers() {
@@ -219,14 +219,16 @@ document.getElementById('permissionUserSelect').addEventListener('change', async
async function loadUserPermissions(userId) {
currentPermissions = {};
currentPermSources = {};
currentDeptGranted = {};
const allDefs = { ...SYSTEM1_PAGES, ...SYSTEM3_PAGES, ...TKPURCHASE_PAGES, ...TKSAFETY_PAGES, ...TKUSER_PAGES };
Object.values(allDefs).flat().forEach(p => { currentPermissions[p.key] = p.def; currentPermSources[p.key] = 'default'; });
Object.values(allDefs).flat().forEach(p => { currentPermissions[p.key] = p.def; currentPermSources[p.key] = 'default'; currentDeptGranted[p.key] = false; });
try {
const result = await api(`/permissions/users/${userId}/effective-permissions`);
if (result.permissions) {
for (const [pageName, info] of Object.entries(result.permissions)) {
currentPermissions[pageName] = info.can_access;
currentPermSources[pageName] = info.source;
currentDeptGranted[pageName] = !!info.dept_granted;
}
}
} catch(e) { console.warn('권한 로드 실패:', e); }
@@ -270,6 +272,17 @@ function renderSystemPerms(containerId, pageDef, color) {
${pages.map(p => {
const checked = currentPermissions[p.key] || false;
const src = currentPermSources[p.key] || 'default';
const deptLocked = currentDeptGranted[p.key] || false;
if (deptLocked) {
return `
<label class="perm-item flex items-center gap-2.5 p-2.5 border rounded-lg opacity-75 cursor-not-allowed checked" data-group="${groupId}">
<input type="checkbox" id="perm_${p.key}" checked disabled class="h-4 w-4 text-${color}-500 rounded border-gray-300">
<i class="fas fa-lock text-xs text-gray-400"></i>
<i class="fas ${p.icon} text-sm text-${color}-500" data-color="${color}"></i>
<span class="text-sm text-gray-700">${p.title}</span>
<span class="ml-auto text-[10px] px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-500"><i class="fas fa-lock text-[8px] mr-0.5"></i>부서</span>
</label>`;
}
return `
<label class="perm-item flex items-center gap-2.5 p-2.5 border rounded-lg cursor-pointer ${checked?'checked':'border-gray-200'}" data-group="${groupId}">
<input type="checkbox" id="perm_${p.key}" ${checked?'checked':''} class="h-4 w-4 text-${color}-500 rounded border-gray-300 focus:ring-${color}-400"
@@ -308,7 +321,7 @@ function toggleGroup(groupId) {
}
function toggleGroupAll(groupId, checked) {
document.querySelectorAll(`#${groupId} input[type="checkbox"]`).forEach(cb => {
document.querySelectorAll(`#${groupId} input[type="checkbox"]:not(:disabled)`).forEach(cb => {
cb.checked = checked;
onPermChange(cb);
});
@@ -317,7 +330,7 @@ function toggleGroupAll(groupId, checked) {
function toggleSystemAll(prefix, checked) {
const containerMap = { s1: 's1-perms', s3: 's3-perms', tkpurchase: 'tkpurchase-perms' };
const containerId = containerMap[prefix] || prefix + '-perms';
document.querySelectorAll(`#${containerId} input[type="checkbox"]`).forEach(cb => {
document.querySelectorAll(`#${containerId} input[type="checkbox"]:not(:disabled)`).forEach(cb => {
cb.checked = checked;
onPermChange(cb);
});
@@ -334,10 +347,12 @@ document.getElementById('savePermissionsBtn').addEventListener('click', async ()
try {
const allPages = [...Object.values(SYSTEM1_PAGES).flat(), ...Object.values(SYSTEM3_PAGES).flat(), ...Object.values(TKPURCHASE_PAGES).flat(), ...Object.values(TKSAFETY_PAGES).flat(), ...Object.values(TKUSER_PAGES).flat()];
const permissions = allPages.map(p => {
const cb = document.getElementById('perm_' + p.key);
return { page_name: p.key, can_access: cb ? cb.checked : false };
});
const permissions = allPages
.filter(p => !currentDeptGranted[p.key])
.map(p => {
const cb = document.getElementById('perm_' + p.key);
return { page_name: p.key, can_access: cb ? cb.checked : false };
});
await api('/permissions/bulk-grant', { method:'POST', body: JSON.stringify({ user_id: parseInt(selectedUserId), permissions }) });
st.textContent = '저장 완료'; st.className = 'text-sm text-emerald-600';
showToast('권한이 저장되었습니다.');