diff --git a/docs/design/577-persona-ux-audit/README.md b/docs/design/577-persona-ux-audit/README.md index 88c6760..d29f504 100644 --- a/docs/design/577-persona-ux-audit/README.md +++ b/docs/design/577-persona-ux-audit/README.md @@ -1,8 +1,8 @@ # VPD Backoffice 전 페이지 페르소나 UX 리뷰와 개선 로드맵 -> **상태**: Review complete · UX-1 in progress +> **상태**: Review complete · UX-1/UX-2 implementation in progress > **작성**: [AI] UX Facilitator · **최종수정**: 2026-06-30 -> **추적성** — Redmine: UX audit 상위 이슈 등록 예정 · 선행 개선: #575 대시보드, #576 기본 검증 사용자명 +> **추적성** — Redmine: #577 · 하위 구현: #575 대시보드, #576 기본 검증 사용자명, #599 설정, #605 권한 관리 ## 1. 리뷰 방법 @@ -75,3 +75,15 @@ UX-1부터 시작한다. 기존 데이터 모델과 VPD 로직은 바꾸지 않 - 유효 권한 매트릭스를 사용자 중심 탭형 구조로 변경했다. 기본 화면은 선택한 사용자 한 명의 직접 역할, 그룹 상속, 최종 역할, 보호 객체, 권한 수를 보여 준다. - 그룹/역할 전체 표는 제거하지 않고 보조 탭으로 이동했다. - 복합 Thymeleaf 속성과 layout fragment를 실제로 처리하는 `EffectiveMatrixTemplateRenderTest`를 추가해 UI template parse 오류를 빌드에서 검출한다. + +## 7. UX-2 진행 기록 — 권한 생성 안전성 (#605) + +권한 관리 화면은 5단계 구조가 있었지만, 이전에는 다음 단계와 단계 표시를 눌러 필수 선택을 건너뛸 수 있었다. `ALL`과 조건 규칙을 섞으면 서버가 저장 시 거부했기 때문에, 사용자는 긴 입력 뒤에야 실패를 알게 됐다. + +- 역할과 보호 객체에 빈 선택지를 두어 적용 주체와 대상을 명시적으로 고르게 한다. +- 다음/단계 이동/저장 시 1~3단계를 검증한다. 빠진 역할·객체·비교 값, 중복 규칙, `ALL`과 조건 규칙의 혼용은 해당 단계에서 이유를 보여 주고 이동을 막는다. +- 상단 요약에 직접 사용자·그룹·그룹 상속 사용자 수와 저장 준비 상태를 고정한다. +- 최종 검토에 `전체 행 허용/거부` 또는 조건부 영향 문장과 되돌리기 방법을 표시한다. 마지막 권한을 삭제하면 보호 객체가 비활성화될 수 있음을 저장 전에 안내한다. +- 브라우저 검증을 우회한 POST도 역할 누락·객체 형식·행 규칙 오류를 flash 오류로 돌려준다. 서버의 `PermissionService` 검증은 그대로 최종 방어선으로 유지한다. + +검증 범위는 `GuidedFlowTemplateTest`, `PermissionServiceTest`, 전체 Maven 테스트 및 배포 후 `/permissions` HTTP 확인이다. diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/PermissionController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/PermissionController.java index 01bc6e5..9c4310d 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/PermissionController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/PermissionController.java @@ -195,25 +195,32 @@ public class PermissionController { @PostMapping("/permissions") public String save( - @RequestParam long roleId, - @RequestParam String objectRef, + @RequestParam(required = false) Long roleId, + @RequestParam(required = false) String objectRef, @RequestParam(defaultValue = "ALLOW") String permissionEffect, @RequestParam(required = false) List ruleColumn, - @RequestParam List ruleType, + @RequestParam(required = false) List ruleType, @RequestParam(required = false) List ruleValue, @RequestParam(required = false) String visibleColumns, RedirectAttributes redirectAttributes ) { - long objectId = resolveObjectId(objectRef); - permissionService.savePermissionSet(new PermissionSetCommand( - roleId, - objectId, - "SELECT", - permissionEffect, - buildRules(ruleColumn, ruleType, ruleValue), - splitColumns(visibleColumns) - )); - redirectAttributes.addFlashAttribute("message", "권한을 저장했습니다."); + try { + if (roleId == null) { + throw new AppException("권한을 적용할 역할을 선택하세요."); + } + long objectId = resolveObjectId(objectRef); + permissionService.savePermissionSet(new PermissionSetCommand( + roleId, + objectId, + "SELECT", + permissionEffect, + buildRules(ruleColumn, ruleType, ruleValue), + splitColumns(visibleColumns) + )); + redirectAttributes.addFlashAttribute("message", "권한을 저장했습니다."); + } catch (AppException | IllegalArgumentException exception) { + redirectAttributes.addFlashAttribute("error", exception.getMessage()); + } return "redirect:/permissions"; } diff --git a/src/main/resources/static/css/app.css b/src/main/resources/static/css/app.css index 51b0728..6a0a42a 100644 --- a/src/main/resources/static/css/app.css +++ b/src/main/resources/static/css/app.css @@ -531,6 +531,11 @@ body { min-width: 0; } +.section-description { + color: var(--rw-muted); + margin: .2rem 0 0; +} + .wizard-progress { display: flex; flex-wrap: wrap; @@ -584,6 +589,15 @@ body { overflow-wrap: anywhere; } +.wizard-validation { + background: #fff4e5; + border: 1px solid #e7a84c; + border-radius: 8px; + color: #7a4600; + font-weight: 700; + padding: .75rem .9rem; +} + .wizard-panel { border: 1px solid var(--rw-border); border-radius: 8px; @@ -615,6 +629,18 @@ body { margin: .15rem 0 0; } +.rule-safety-note { + background: var(--rw-primary-soft); + border-left: 3px solid var(--rw-primary); + color: var(--rw-text); + margin: .5rem 0 .75rem; + padding: .65rem .75rem; +} + +.policy-preview-emphasis { + border-color: var(--rw-primary) !important; +} + .wizard-step-number { align-items: center; background: var(--rw-primary-soft); diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js index dc2ddb7..b9b0dc1 100644 --- a/src/main/resources/static/js/app.js +++ b/src/main/resources/static/js/app.js @@ -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')); } diff --git a/src/main/resources/templates/permissions.html b/src/main/resources/templates/permissions.html index d44d26e..4340f8f 100644 --- a/src/main/resources/templates/permissions.html +++ b/src/main/resources/templates/permissions.html @@ -20,7 +20,10 @@
-

권한 추가 Wizard

+
+

새 권한 규칙

+

저장 전 적용 대상과 행 범위를 검토한 뒤 VPD 권한을 반영합니다.

+
@@ -31,6 +34,7 @@
+
@@ -61,6 +73,7 @@