diff --git a/docs/design/468-permission-wizard/README.md b/docs/design/468-permission-wizard/README.md new file mode 100644 index 0000000..21de99c --- /dev/null +++ b/docs/design/468-permission-wizard/README.md @@ -0,0 +1,34 @@ +# #468 권한 등록 Wizard + +## 배경 + +권한 등록은 역할, 보호 객체, 허용/거부, 행 규칙, 컬럼 redaction 예외를 한 번에 설정해야 한다. 기존 단일 폼은 빠르게 입력할 수 있지만 신규 운영자가 순서를 놓치기 쉽고, 저장 전에 최종 정책 의미를 확인하기 어렵다. + +## 결정 + +- 서버 저장 API(`/permissions`)는 유지한다. +- 권한 추가 영역만 단계형 wizard UI로 재구성한다. +- 기존 권한 목록은 그대로 유지한다. +- 단계는 다음 순서로 고정한다. + 1. 역할 선택 + 2. 보호 객체 선택 + 3. 권한 효과와 행 규칙 + 4. 컬럼 NULL 처리 예외 + 5. 저장 전 검토 +- 저장 전 검토 단계에서 effective policy preview를 표시한다. + +## Preview 규칙 + +- `ALLOW + ALL`: 모든 행 조회 허용 +- `ALLOW + 조건`: 조건에 맞는 행만 조회 허용 +- `DENY + ALL`: 해당 역할의 객체 조회 거부 +- `DENY + 조건`: 조건에 맞는 행을 제외 +- 컬럼 예외가 비어 있으면 민감 컬럼은 NULL 처리 대상으로 설명한다. +- 컬럼 예외가 있으면 해당 컬럼을 NULL 처리 예외로 표시한다. + +## 완료 기준 + +- `/permissions`에서 단계 이동과 요약 갱신이 동작한다. +- 기존 저장 POST parameter 이름은 유지한다. +- 행 규칙 컬럼 동적 조회와 규칙 추가/삭제가 계속 동작한다. +- Maven 테스트와 인증 화면 렌더링 확인을 통과한다. diff --git a/src/main/resources/static/css/app.css b/src/main/resources/static/css/app.css index 094cd41..2c3a410 100644 --- a/src/main/resources/static/css/app.css +++ b/src/main/resources/static/css/app.css @@ -336,6 +336,119 @@ body { grid-template-columns: 1fr 1fr 1fr auto; } +.permission-wizard { + display: grid; + gap: 1rem; +} + +.wizard-progress { + display: flex; + flex-wrap: wrap; + gap: .4rem; +} + +.wizard-step-indicator { + background: var(--rw-surface-muted); + border: 1px solid var(--rw-border); + border-radius: 999px; + color: var(--rw-muted); + font-size: .82rem; + font-weight: 700; + min-height: 2rem; + padding: .3rem .7rem; +} + +.wizard-step-indicator.active { + background: var(--rw-primary); + border-color: var(--rw-primary); + color: #fff; +} + +.wizard-summary { + display: grid; + gap: .75rem; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); +} + +.wizard-summary > div, +.policy-preview dl > div { + background: var(--rw-surface-muted); + border: 1px solid var(--rw-border); + border-radius: 8px; + padding: .75rem; +} + +.wizard-summary span, +.policy-preview dt { + color: var(--rw-muted); + display: block; + font-size: .78rem; + font-weight: 700; +} + +.wizard-summary strong, +.policy-preview dd { + display: block; + margin: .25rem 0 0; + overflow-wrap: anywhere; +} + +.wizard-panel { + border: 1px solid var(--rw-border); + border-radius: 8px; + display: none; + padding: 1rem; +} + +.wizard-panel.active { + display: grid; + gap: 1rem; +} + +.wizard-panel-heading { + align-items: flex-start; + display: flex; + gap: .75rem; +} + +.wizard-panel-heading h3 { + font-size: 1rem; + font-weight: 700; + margin: 0; +} + +.wizard-panel-heading p, +.wizard-hint { + color: var(--rw-muted); + margin: .15rem 0 0; +} + +.wizard-step-number { + align-items: center; + background: var(--rw-primary-soft); + border-radius: 999px; + color: var(--rw-primary); + display: inline-flex; + font-weight: 800; + height: 2rem; + justify-content: center; + min-width: 2rem; +} + +.wizard-actions { + align-items: center; + display: flex; + gap: .5rem; + justify-content: flex-end; +} + +.policy-preview dl { + display: grid; + gap: .75rem; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + margin: 0; +} + .question-presets { display: flex; flex-wrap: wrap; diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js index 0962d37..cf1ccb6 100644 --- a/src/main/resources/static/js/app.js +++ b/src/main/resources/static/js/app.js @@ -233,6 +233,113 @@ async function syncObjectCatalogSelection() { } } +function selectedText(select) { + if (!select || select.selectedIndex < 0) { + return '-'; + } + return select.options[select.selectedIndex].textContent.trim(); +} + +function collectWizardRules(root) { + return Array.from(root.querySelectorAll('.rule-row')).map((row) => { + const column = row.querySelector('[name="ruleColumn"]')?.value || ''; + const type = row.querySelector('[name="ruleType"]')?.value || ''; + const value = row.querySelector('[name="ruleValue"]')?.value || ''; + const displayColumn = column || { + MY_DEPT: 'DEPT_CODE', + SELF: 'OWNER_EMP_NO', + DEPT: 'DEPT_CODE', + EMP_NO: 'OWNER_EMP_NO', + ALL: '' + }[type] || ''; + if (type === 'ALL') { + return 'ALL'; + } + if (['MY_DEPT', 'SELF'].includes(type)) { + return `${displayColumn} ${type}`; + } + return [displayColumn, type, value].filter(Boolean).join(' '); + }).filter(Boolean); +} + +function updatePermissionWizardPreview(root = document) { + const wizard = root.querySelector('[data-permission-wizard]'); + if (!wizard) { + return; + } + const roleSelect = wizard.querySelector('[name="roleId"]'); + const objectSelect = wizard.querySelector('[name="objectRef"]'); + const effectSelect = wizard.querySelector('[name="permissionEffect"]'); + const visibleColumns = wizard.querySelector('[name="visibleColumns"]')?.value.trim() || ''; + const roleOption = roleSelect?.options[roleSelect.selectedIndex]; + const effect = effectSelect?.value || 'ALLOW'; + const rules = collectWizardRules(wizard); + const ruleText = rules.length ? rules.join(', ') : '행 규칙 없음'; + const rowPolicy = effect === 'DENY' + ? `거부 규칙: ${ruleText}` + : `허용 규칙: ${ruleText}`; + const columnPolicy = visibleColumns + ? `NULL 처리 예외: ${visibleColumns}` + : '민감 컬럼은 NULL 처리 대상'; + + 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-preview="role"]').textContent = selectedText(roleSelect); + wizard.querySelector('[data-preview="sensitivity"]').textContent = roleOption?.dataset.maxSensitivity || 'PUBLIC'; + wizard.querySelector('[data-preview="object"]').textContent = selectedText(objectSelect); + wizard.querySelector('[data-preview="rowPolicy"]').textContent = rowPolicy; + wizard.querySelector('[data-preview="columnPolicy"]').textContent = columnPolicy; +} + +function activatePermissionWizardStep(wizard, step) { + const panels = Array.from(wizard.querySelectorAll('[data-wizard-step]')); + const maxStep = panels.length; + const nextStep = Math.max(1, Math.min(step, maxStep)); + 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); + }); + wizard.dataset.currentStep = String(nextStep); + const prev = wizard.querySelector('[data-wizard-prev]'); + const next = wizard.querySelector('[data-wizard-next]'); + const submit = wizard.querySelector('[data-wizard-submit]'); + if (prev) { + prev.disabled = nextStep === 1; + } + if (next) { + next.hidden = nextStep === maxStep; + } + if (submit) { + submit.hidden = nextStep !== maxStep; + } + updatePermissionWizardPreview(document); +} + +function initPermissionWizard() { + const wizard = document.querySelector('[data-permission-wizard]'); + if (!wizard) { + return; + } + wizard.dataset.currentStep = wizard.dataset.currentStep || '1'; + wizard.querySelector('[data-wizard-prev]')?.addEventListener('click', () => { + activatePermissionWizardStep(wizard, Number(wizard.dataset.currentStep || '1') - 1); + }); + wizard.querySelector('[data-wizard-next]')?.addEventListener('click', () => { + activatePermissionWizardStep(wizard, Number(wizard.dataset.currentStep || '1') + 1); + }); + document.querySelectorAll('[data-wizard-target]').forEach((button) => { + button.addEventListener('click', () => activatePermissionWizardStep(wizard, Number(button.dataset.wizardTarget))); + }); + wizard.addEventListener('input', () => updatePermissionWizardPreview(document)); + wizard.addEventListener('change', () => updatePermissionWizardPreview(document)); + activatePermissionWizardStep(wizard, Number(wizard.dataset.currentStep || '1')); +} + document.addEventListener('DOMContentLoaded', () => { renderMarkdownViews(); const master = document.getElementById('userRoleMaster'); @@ -242,11 +349,18 @@ document.addEventListener('DOMContentLoaded', () => { } const objectSelect = document.querySelector('select[name="objectRef"]'); if (objectSelect) { - objectSelect.addEventListener('change', syncRuleColumnOptions); + objectSelect.addEventListener('change', () => { + syncRuleColumnOptions(); + updatePermissionWizardPreview(document); + }); syncRuleColumnOptions(); } + initPermissionWizard(); document.querySelectorAll('.rule-type-select').forEach((select) => { - select.addEventListener('change', () => syncRuleTypeHints()); + select.addEventListener('change', () => { + syncRuleTypeHints(); + updatePermissionWizardPreview(document); + }); }); syncRuleTypeHints(); const catalog = document.getElementById('objectCatalogSelect'); @@ -264,15 +378,23 @@ document.addEventListener('DOMContentLoaded', () => { clone.querySelectorAll('input').forEach((input) => input.value = ''); const cloneButton = clone.querySelector('[data-rule-add]'); cloneButton.textContent = '삭제'; - cloneButton.addEventListener('click', () => clone.remove()); + cloneButton.addEventListener('click', () => { + clone.remove(); + updatePermissionWizardPreview(document); + }); clone.querySelectorAll('.rule-type-select').forEach((select) => { - select.addEventListener('change', () => syncRuleTypeHints()); + select.addEventListener('change', () => { + syncRuleTypeHints(); + updatePermissionWizardPreview(document); + }); }); list.appendChild(clone); syncRuleColumnOptions(); syncRuleTypeHints(clone); + updatePermissionWizardPreview(document); }); }); + updatePermissionWizardPreview(document); }); document.body.addEventListener('htmx:afterSwap', (event) => { diff --git a/src/main/resources/templates/permissions.html b/src/main/resources/templates/permissions.html index d42995d..f4fd3b6 100644 --- a/src/main/resources/templates/permissions.html +++ b/src/main/resources/templates/permissions.html @@ -12,68 +12,165 @@