[UX] #605 make permission wizard safety review actionable

This commit is contained in:
devmrko
2026-06-30 20:39:54 +09:00
parent 1766696ab4
commit f162851bc6
7 changed files with 289 additions and 25 deletions

View File

@@ -770,6 +770,10 @@ function updatePermissionWizardPreview(root = document) {
const rules = collectWizardRules(wizard);
const predicates = collectWizardPredicates(wizard);
const ruleText = rules.length ? rules.join(', ') : '행 규칙 없음';
const hasRole = Boolean(roleSelect?.value);
const hasObject = Boolean(objectSelect?.value);
const hasAllRule = rules.includes('ALL');
const hasAllRuleConflict = hasAllRule && rules.length > 1;
const directUsers = splitList(roleOption?.dataset.directUsers || '');
const groups = splitList(roleOption?.dataset.groups || '');
const groupUsers = splitList(roleOption?.dataset.groupUsers || '');
@@ -790,11 +794,23 @@ function updatePermissionWizardPreview(root = document) {
? '마스킹 대상 컬럼 없음'
: (nullColumns.length ? `NULL 처리: ${nullColumns.join(', ')}` : '선택한 마스킹 컬럼 모두 원문 표시 허용');
const affectedPrincipals = `직접 사용자 ${directUsers.length}명 / 그룹 ${groups.length}개 / 그룹 상속 사용자 ${groupUsers.length}`;
const readiness = !hasRole
? '역할을 선택하세요'
: !hasObject
? '보호 객체를 선택하세요'
: hasAllRuleConflict
? 'ALL 규칙을 단독으로 정리하세요'
: '저장 전 검토 가능';
const saveGuard = hasAllRule
? `${effect === 'DENY' ? '전체 행 거부' : '전체 행 허용'}입니다. ${affectedPrincipals}에게 영향을 줄 수 있습니다.`
: `${effect === 'DENY' ? '선택 조건의 행을 거부' : '선택 조건의 행만 허용'}합니다. 저장 후 결과 확인에서 실제 VPD predicate를 검증하세요.`;
wizard.querySelector('[data-wizard-summary="role"]').textContent = selectedText(roleSelect);
wizard.querySelector('[data-wizard-summary="object"]').textContent = selectedText(objectSelect);
wizard.querySelector('[data-wizard-summary="effect"]').textContent = effect;
wizard.querySelector('[data-wizard-summary="rules"]').textContent = ruleText;
wizard.querySelector('[data-wizard-summary="affected"]').textContent = hasRole ? affectedPrincipals : '역할 선택 후 확인';
wizard.querySelector('[data-wizard-summary="readiness"]').textContent = readiness;
setWizardPreview(wizard, 'role', selectedText(roleSelect));
setWizardPreview(wizard, 'sensitivity', roleOption?.dataset.maxSensitivity || 'PUBLIC');
@@ -810,6 +826,84 @@ function updatePermissionWizardPreview(root = document) {
setWizardPreview(wizard, 'predicatePreview', predicateText);
setWizardPreview(wizard, 'columnPolicy', columnPolicy);
setWizardPreview(wizard, 'nullPolicy', nullPolicy);
setWizardPreview(wizard, 'saveGuard', hasRole && hasObject ? saveGuard : '역할과 보호 객체를 선택하면 저장 영향을 계산합니다.');
}
function permissionWizardIndicators(wizard) {
return wizard.closest('.content-band')?.querySelectorAll('[data-wizard-target]') || [];
}
function showPermissionWizardValidation(wizard, message) {
const validation = wizard.querySelector('[data-wizard-validation]');
if (!validation) {
return;
}
validation.textContent = message;
validation.hidden = false;
}
function clearPermissionWizardValidation(wizard) {
const validation = wizard.querySelector('[data-wizard-validation]');
if (validation) {
validation.hidden = true;
validation.textContent = '';
}
}
function permissionWizardRuleValidationMessage(wizard) {
const rows = Array.from(wizard.querySelectorAll('.rule-row'));
if (!rows.length) {
return '행 규칙을 하나 이상 추가하세요.';
}
const values = rows.map((row) => ({
column: row.querySelector('[name="ruleColumn"]')?.value || '',
type: row.querySelector('[name="ruleType"]')?.value || '',
value: row.querySelector('[name="ruleValue"]')?.value.trim() || ''
}));
if (values.some((rule) => rule.type === 'ALL') && values.length > 1) {
return 'ALL 규칙은 다른 조건 규칙과 함께 저장할 수 없습니다. ALL만 남기거나 ALL을 삭제하세요.';
}
const valueRequired = ['=', '!=', 'DEPT', 'EMP_NO', 'TAG'];
const seen = new Set();
for (const [index, rule] of values.entries()) {
const position = index + 1;
if (!rule.type) {
return `${position}번째 행 규칙 유형을 선택하세요.`;
}
if (['=', '!='].includes(rule.type) && !rule.column) {
return `${position}번째 ${rule.type} 규칙에는 비교할 컬럼이 필요합니다.`;
}
if (valueRequired.includes(rule.type) && !rule.value) {
return `${position}번째 ${rule.type} 규칙의 값을 입력하세요.`;
}
if (rule.type === 'TAG' && rule.value && !/^[A-Za-z0-9_-]+$/.test(rule.value)) {
return `${position}번째 TAG 값은 영문·숫자, '_' 또는 '-'만 사용할 수 있습니다.`;
}
const signature = `${rule.column}:${rule.type}:${rule.value.toUpperCase()}`;
if (seen.has(signature)) {
return `${position}번째 행 규칙이 앞의 규칙과 중복됩니다.`;
}
seen.add(signature);
}
return '';
}
function validatePermissionWizardStep(wizard, step) {
if (step === 1 && !wizard.querySelector('[name="roleId"]')?.value) {
return '권한을 적용할 역할을 선택하세요.';
}
if (step === 2 && !wizard.querySelector('[name="objectRef"]')?.value) {
return '권한을 적용할 보호 객체를 선택하세요.';
}
if (step === 3) {
return permissionWizardRuleValidationMessage(wizard);
}
return '';
}
function focusPermissionWizardStep(wizard, step) {
const panel = wizard.querySelector(`[data-wizard-step="${step}"]`);
panel?.querySelector('select, input, button')?.focus();
}
function activatePermissionWizardStep(wizard, step) {
@@ -819,8 +913,10 @@ function activatePermissionWizardStep(wizard, step) {
panels.forEach((panel) => {
panel.classList.toggle('active', Number(panel.dataset.wizardStep) === nextStep);
});
document.querySelectorAll('[data-wizard-target]').forEach((button) => {
button.classList.toggle('active', Number(button.dataset.wizardTarget) === nextStep);
permissionWizardIndicators(wizard).forEach((button) => {
const active = Number(button.dataset.wizardTarget) === nextStep;
button.classList.toggle('active', active);
button.setAttribute('aria-current', active ? 'step' : 'false');
});
wizard.dataset.currentStep = String(nextStep);
const prev = wizard.querySelector('[data-wizard-prev]');
@@ -838,6 +934,27 @@ function activatePermissionWizardStep(wizard, step) {
updatePermissionWizardPreview(document);
}
function movePermissionWizard(wizard, targetStep) {
const currentStep = Number(wizard.dataset.currentStep || '1');
if (targetStep <= currentStep) {
clearPermissionWizardValidation(wizard);
activatePermissionWizardStep(wizard, targetStep);
return true;
}
for (let step = 1; step < targetStep; step += 1) {
const message = validatePermissionWizardStep(wizard, step);
if (message) {
activatePermissionWizardStep(wizard, step);
showPermissionWizardValidation(wizard, message);
focusPermissionWizardStep(wizard, step);
return false;
}
}
clearPermissionWizardValidation(wizard);
activatePermissionWizardStep(wizard, targetStep);
return true;
}
function initPermissionWizard() {
const wizard = document.querySelector('[data-permission-wizard]');
if (!wizard) {
@@ -845,16 +962,34 @@ function initPermissionWizard() {
}
wizard.dataset.currentStep = wizard.dataset.currentStep || '1';
wizard.querySelector('[data-wizard-prev]')?.addEventListener('click', () => {
activatePermissionWizardStep(wizard, Number(wizard.dataset.currentStep || '1') - 1);
movePermissionWizard(wizard, Number(wizard.dataset.currentStep || '1') - 1);
});
wizard.querySelector('[data-wizard-next]')?.addEventListener('click', () => {
activatePermissionWizardStep(wizard, Number(wizard.dataset.currentStep || '1') + 1);
movePermissionWizard(wizard, Number(wizard.dataset.currentStep || '1') + 1);
});
document.querySelectorAll('[data-wizard-target]').forEach((button) => {
button.addEventListener('click', () => activatePermissionWizardStep(wizard, Number(button.dataset.wizardTarget)));
permissionWizardIndicators(wizard).forEach((button) => {
button.addEventListener('click', () => movePermissionWizard(wizard, Number(button.dataset.wizardTarget)));
});
wizard.addEventListener('input', () => {
clearPermissionWizardValidation(wizard);
updatePermissionWizardPreview(document);
});
wizard.addEventListener('change', () => {
clearPermissionWizardValidation(wizard);
updatePermissionWizardPreview(document);
});
wizard.addEventListener('submit', (event) => {
for (let step = 1; step <= 3; step += 1) {
const message = validatePermissionWizardStep(wizard, step);
if (message) {
event.preventDefault();
activatePermissionWizardStep(wizard, step);
showPermissionWizardValidation(wizard, message);
focusPermissionWizardStep(wizard, step);
return;
}
}
});
wizard.addEventListener('input', () => updatePermissionWizardPreview(document));
wizard.addEventListener('change', () => updatePermissionWizardPreview(document));
activatePermissionWizardStep(wizard, Number(wizard.dataset.currentStep || '1'));
}