Compare commits

...

2 Commits

Author SHA1 Message Date
devmrko
0ed048905a [UX] #577 focus effective access on selected user 2026-06-30 18:05:04 +09:00
devmrko
e7070663c3 docs: add DDS persona UX review 2026-06-30 17:53:03 +09:00
7 changed files with 542 additions and 177 deletions

View File

@@ -0,0 +1,77 @@
# VPD Backoffice 전 페이지 페르소나 UX 리뷰와 개선 로드맵
> **상태**: Review complete · UX-1 in progress
> **작성**: [AI] UX Facilitator · **최종수정**: 2026-06-30
> **추적성** — Redmine: UX audit 상위 이슈 등록 예정 · 선행 개선: #575 대시보드, #576 기본 검증 사용자명
## 1. 리뷰 방법
세 페르소나가 같은 화면을 다른 기준으로 검토했다.
| 페르소나 | 실패로 보는 상황 | 판단 기준 |
|---|---|---|
| UI/UX 전문가 | 정보가 같은 밀도로 쏟아져 다음 행동을 고르지 못함 | 시선 순서, 한 화면의 결정 수, 진행 상태, 빈 상태 |
| 권한 설계자 | 편한 조작이 권한 영향·fail-closed 원칙을 숨김 | 대상/주체/효과/영향 범위, 복구 가능성, 증적 |
| 엔드유저 | “그래서 지금 뭘 해야 하지?”를 5초 안에 답하지 못함 | 업무 언어, 기존 값 확인, 성공 기준, 되돌리기 |
공통 결론은 **설명은 충분하지만, 실행 화면이 ‘관리 매뉴얼’처럼 길다**는 점이다. 모든 페이지를 카드로 더 꾸미는 대신 다음 원칙으로 정리한다.
1. 첫 화면에는 주 행동 하나와 현재 상태만 둔다.
2. 대상·주체·영향을 입력 전에 보여 주고, 기술 설명은 접는다.
3. 목록은 탐색용, 상세/편집은 선택한 항목 중심으로 분리한다.
4. VPD·토큰·ORDS는 같은 검증 여정으로 연결하고 성공 증적을 다음 화면으로 넘긴다.
## 2. 페이지별 토론과 개선 백로그
| 페이지 | UI/UX 전문가 | 권한 설계자 | 엔드유저 | 합의한 개선 | 우선순위 |
|---|---|---|---|---|---|
| 대시보드 | 설명·현황·표가 동등하게 경쟁 | 보호 상태의 근거가 약함 | 다음 행동이 불명확 | 핵심 작업·현재 상태·4단계 레일만 전면, 상세 접기 — **#575 완료** | 완료 |
| 사용자 | 생성/목록/역할 부여가 세 구역으로 흩어짐 | 역할 해제 영향이 안 보임 | “이 사람에게 지금 어떤 권한이 있나?”를 다시 찾아야 함 | 행 선택 시 현재 역할·그룹·토큰 요약을 보여 주는 사용자 작업 패널, 역할 부여 전 영향 미리보기 | P0 |
| 그룹 | 사용자와 역할 배정이 긴 한 페이지에 누적 | 그룹 상속 범위가 숨음 | 그룹을 바꾸면 누가 영향받는지 모름 | 그룹 선택형 master-detail, 영향 사용자 수·상속 역할을 상단에 고정, 해제 전 영향 확인 | P0 |
| 역할 | 민감도·설명·삭제가 표 안에서 동등 | 역할 삭제/변경 영향이 중요 | 역할이 누구에게 쓰이는지 모름 | 역할 상세에서 연결 사용자·그룹·권한을 먼저 보여 주고, 삭제는 영향 없음일 때만 노출 | P0 |
| 권한 관리 | wizard가 길고 선택의 결과가 늦게 보임 | ALLOW/DENY, 행/열 규칙의 조합 위험 | ALL을 누르면 무엇이 보이는지 불안 | 역할→객체→행 범위→컬럼→검토의 5단계 progress, 즉시 영향 문장과 변경 요약 고정 | P0 |
| 유효 권한 매트릭스 | 사용자/그룹/역할 표 세 개가 한꺼번에 큼 | 최종 권한 근거를 추적해야 함 | 내 사용자 하나만 보고 싶음 | 기본은 사용자 탭+검색, 선택 시 권한 근거 타임라인; 그룹/역할은 보조 탭으로 분리 | P0 |
| ORDS 조회 대상 | 등록·경로·핸들러 설정이 한 화면에 섞임 | 보호 대상과 handler 연결 상태를 분리해야 함 | 무엇을 등록해야 하는지 모름 | 객체 등록 후 ‘보호 연결하기’ 단계 CTA, 목록에는 VPD/handler/검증 상태 배지 | P1 |
| DB 보호 연결 | 객체 목록·개별 적용·일괄 적용·정책 목록이 매우 김 | 적용/해제의 DB 영향이 중요 | 어떤 객체부터 연결할지 모름 | 미연결 객체를 기본 큐로, 선택 시 영향/복구 설명과 검증 CTA; 일괄 적용은 고급으로 접기 | P0 |
| 검증 세션 | 발급과 이력이 분리되어 다음 행동이 약함 | 토큰 원문 보안과 만료가 핵심 | 발급 뒤 무엇을 복사/어디로 가나 | 사용자 선택→만료→발급→즉시 결과 확인의 단일 handoff, 이력은 보조 탭 | P0 |
| 권한 결과 확인 | 입력과 증적이 좋지만 초보자가 object/token 선택에서 멈춤 | VPD predicate와 실제 결과는 반드시 함께 보여야 함 | 내 상황으로 빠르게 시험하고 싶음 | ‘검증 사용자’ 기본 모드와 Bearer 직접 입력’ 고급 모드 분리, 결과에 다음 조치 추천 | P0 |
| ORDS 핸들러 | 소스·편집이 기술자 화면으로 바로 열림 | handler 변경은 VPD 우회 위험 | 보통 사용자는 건드릴 이유가 없음 | 상태/경로/보호 객체만 기본 표시, 소스·수정은 고급 상세와 영향 경고로 격리 | P1 |
| 지식 검색 관리 | 등록·정책·검색 세 큰 작업이 세로로 길게 누적 | 벡터 검색과 접근 조건을 혼동하기 쉬움 | 검색만 해 보고 싶음 | ‘자료 등록 / 접근 정책 / 검색 검증’ 탭화, 검색을 기본 탭으로 하고 정책은 요약 배지 | P1 |
| MCP Chatbot | 대화, 토큰, 기술 설명의 경계가 약함 | 도구 호출과 권한 결과의 근거 필요 | 질문만 하고 싶음 | 질문 패널 우선, 호출된 도구·권한 근거는 답변 아래 접힌 evidence로 표시 | P1 |
| MCP Reasoning | 도구 목록과 실행 입력이 같은 밀도 | 자동 선택 근거/권한 거부를 보여야 함 | 왜 이 도구가 선택됐는지 알고 싶음 | 질문→선택 도구→권한 결과→답변의 단계형 결과 타임라인 | P1 |
| MCP Client | Java 호출, 흐름, 설정이 한 페이지 | 실행 대상 endpoint 검증 필요 | API 호출 방법만 알고 싶음 | 복사 가능한 최소 호출 예시를 전면, 런타임 진단·프로토콜 설명은 접기 | P2 |
| MCP SSE | endpoint/tool/JSON-RPC 예시가 문서형 | 배포/인증 상태가 우선 | 연결 가능한지부터 알고 싶음 | 연결 상태·endpoint 복사·도구 수를 상단 요약, 프로토콜 예시는 탭/상세로 이동 | P2 |
| 운영 상태 | 표 중심의 상태 나열 | 장애 시 영향 범위/복구가 필요 | 지금 정상인가만 알고 싶음 | 건강/주의/장애를 요약 배너로, 항목별 영향·권장 조치·최근 확인 시각 제공 | P1 |
| 설정 | ORDS, schema preview, 초기화가 한 화면에 있고 위험 | 초기화는 오조작 방지가 필요 | URL만 바꾸고 싶음 | 연결 설정과 DB 초기화를 분리; 초기화는 별도 위험 화면·명시 확인·백업 안내 | P0 |
| 별도 Filter 관리 | 고급 내용을 충분히 경고하지만 길다 | 기본 권한체계 우회 여부가 중요 | 이 화면을 써야 하는지 모르겠음 | 시작 시 ‘기본 권한으로 가능한가? 결정 카드, 실제 filter 편집은 조건 충족 시에만 열기 | P1 |
| 로그인 | 업무 시작점/권한 범위 설명 없음 | 운영 계정임을 명확히 해야 함 | 어디에 로그인하는지 모름 | 제품 목적·환경 표시·지원 경로를 최소한으로 추가 | P2 |
## 3. 우선순위와 실행 묶음
| 실행 묶음 | 페이지 | 목표 | 선행/완료 조건 |
|---|---|---|---|
| UX-1 권한 주체와 최종 권한 | 사용자, 그룹, 역할, 유효 권한 매트릭스 | ‘누가 어떤 역할을 통해 무엇을 보는가’를 한 흐름으로 이해 | 영향 미리보기, 선택형 상세, 사용자 중심 매트릭스 |
| UX-2 보호·검증 여정 | 권한, DB 보호 연결, 토큰, probe, 설정 | 권한 생성부터 실제 DB 증적까지 끊기지 않게 연결 | 단계별 handoff, 위험/복구 안내, 고급 분리 |
| UX-3 연동 도구 정리 | objects, ORDS, vector, MCP 4종, 운영 상태, filter | 운영 화면과 개발자 화면의 밀도를 분리 | 요약 상태 우선, 기술 상세 접기/탭화 |
| UX-4 일관성 정리 | 로그인, 빈 상태, 문구, 배지, 모바일 | 모든 화면의 용어·행동·상태 표현을 정렬 | 공통 컴포넌트/카피 가이드 |
## 4. 첫 구현 범위: UX-1
UX-1부터 시작한다. 기존 데이터 모델과 VPD 로직은 바꾸지 않고 화면의 정보 구조만 바꾼다.
1. 사용자/그룹/역할 화면에 선택된 주체의 현재 연결과 영향 수를 먼저 보여 준다.
2. 유효 권한 매트릭스는 기본을 ‘사용자’로 두고, 선택한 사용자 한 명의 직접 역할·그룹 상속·최종 역할·보호 객체 근거를 순서대로 표시한다.
3. 그룹/역할 기준 전체 표는 보조 탐색으로 유지하되 기본 접힘 또는 탭 뒤로 보낸다.
4. 이 변경 뒤 실제 사용자 `김어드민`, `박파이넨스`, `이에이치알`로 권한 근거와 probe 결과를 교차 검증한다.
## 5. 완료 정의
- 각 실행 묶음은 Redmine 하위 이슈, 설계서, 테스트, 실제 배포 HTTP 확인을 남긴다.
- 화면 변경은 권한 계산·VPD predicate·토큰 보안 동작을 바꾸지 않는다.
- UI/UX 전문가, 설계자, 엔드유저 관점의 미해결 반론이 있으면 이 문서와 Redmine에 기록하고 다음 묶음에서 해소한다.
## 6. UX-1 진행 기록
- 유효 권한 매트릭스를 사용자 중심 탭형 구조로 변경했다. 기본 화면은 선택한 사용자 한 명의 직접 역할, 그룹 상속, 최종 역할, 보호 객체, 권한 수를 보여 준다.
- 그룹/역할 전체 표는 제거하지 않고 보조 탭으로 이동했다.
- 복합 Thymeleaf 속성과 layout fragment를 실제로 처리하는 `EffectiveMatrixTemplateRenderTest`를 추가해 UI template parse 오류를 빌드에서 검출한다.

View File

@@ -0,0 +1,123 @@
# DDS 트랙 페르소나 UX 리뷰 및 개선 백로그
> **상태**: 리뷰 완료 · Redmine 등록 진행
>
> **범위**: DDS 독립 인스턴스의 홈, 보호 연결, 권한 반영, 지식 검색, 보호 객체 상세, 공통 메뉴/흐름 바
>
> **대상 파일**: `dds-home.html`, `vpd-policies.html`, `dds-provision.html`, `vector-knowledge.html`, `dds.html`, `fragments/layout.html`
## 1. 검토 방식
같은 화면을 세 관점에서 검토했다.
| 페르소나 | 성공 기준 | 가장 민감한 실패 |
|---|---|---|
| UI/UX 전문가 | 한 화면에서 한 가지 주 행동이 분명하고, 설명은 필요할 때만 열린다. | 모든 정보가 같은 무게로 노출되어 사용자가 다음 행동을 고르지 못함 |
| 보안 설계자 | DDS 집행 경계와 권한 변경의 영향이 틀림없이 드러난다. | 토큰 Context 경로와 순수 DDS END USER 경로를 같은 의미처럼 오해함 |
| 운영 사용자 | 지금 상태, 다음 조치, 실패 원인을 짧은 시간에 파악한다. | 설명을 읽어도 현재 객체가 보호되는지·게시가 필요한지 모름 |
## 2. 세 페르소나의 합의
1. 첫 화면은 설명이 아니라 **상태와 다음 행동**을 보여 준다.
2. 제품 기본 경로인 **서비스 토큰 기반 검색**과 보조 기술 검증인 **DDS END USER 직접 비교**를 같은 무게로 놓지 않는다.
3. `DATA GRANT` 게시·회수는 보안 변경이다. 단순 브라우저 confirm이 아니라 **diff, 영향, 사유, 결과 검증**이 필요하다.
4. `Context`, `predicate`, `DATA GRANT` 같은 기술 용어는 제거하지 않되, 기본 표면에서는 업무 결과로 번역하고 상세에서 근거를 보인다.
5. 홈·메뉴·객체 상세의 숫자는 단순 건수가 아니라 `정상/미게시/드리프트/검증 실패` 같은 운영 상태로 연결돼야 한다.
## 3. 핵심 설계 경계
현재 DDS 트랙에는 두 종류의 식별 경로가 공존한다. 이 둘을 명확히 구분하지 않으면 제품 사용자가 “Bearer 토큰만으로 DDS END USER가 된다”고 오해할 수 있다.
| 구분 | 기본 서비스 경로 | 보조 직접 비교 경로 |
|---|---|---|
| 사용자 식별 | 기술 사용자 세션 + Bearer로 만든 애플리케이션 Context | DDS `END USER` 직접 연결 |
| 집행 | 보호 객체별 `DATA GRANT` predicate가 공통 권한 테이블을 평가 | `END USER → DATA ROLE → DATA GRANT` |
| 목적 | 제품 기능: 권한 기반 지식 검색 | DDS 선언형 권한 동작 검증 |
| 화면 위치 | 기본 경로 | 고급 검증/상세 화면 |
화면 표준 용어는 아래로 고정한다.
- **토큰 기반 서비스 경로**: 제품에서 사용하는 기본 검색 경로
- **DDS END USER 직접 비교**: DDS Data Role/Data Grant 동작을 확인하는 고급 검증
- **보호 객체**: DDS Data Grant가 연결된 VIEW/TABLE
- **권한 게시**: 공통 권한의 변경을 DDS 선언으로 반영하는 보안 변경
## 4. 페이지별 리뷰
### A. 홈 (`/` · `dds-home.html`)
| 관점 | 관찰 | 합의된 개선 |
|---|---|---|
| UI/UX | 단계 카드, 매크로/마이크로, 숫자, 검증 대상이 같은 위계로 보여 첫 행동이 흐리다. | 상단을 상태 요약과 추천 행동으로 재구성하고, 단계 카드는 상태가 있는 작업 카드로 전환한다. |
| 설계 | 역할·권한 규칙 건수는 보호 상태를 보장하지 않는다. | 보호 객체의 Grant 상태, 마지막 게시, 마지막 검색 검증, 드리프트 여부를 핵심 지표로 올린다. |
| 운영 | “오늘 무엇을 해야 하나?”에 답이 없다. | `게시 필요`, `검색 검증 필요`, `정상` 중 하나를 명확한 CTA로 제공한다. |
**완료 조건**: 설명을 열지 않아도 보호 상태와 다음 행동을 파악하고, 두 번 이하의 클릭으로 수정·검증을 시작한다.
### B. 지식 검색 (`/vector-knowledge` · `vector-knowledge.html`)
| 관점 | 관찰 | 합의된 개선 |
|---|---|---|
| UI/UX | 자료 등록, 권한 설정, 토큰 검색, END USER 직접 비교가 한 화면에 이어져 처음 보는 사용자는 두 검색의 목적을 구분하기 어렵다. | 기본 표면은 `자료 등록``토큰 기반 서비스 검색`에 집중하고, 직접 비교는 고급 검증으로 접거나 별도 화면으로 이동한다. |
| 설계 | 토큰 경로와 END USER 경로의 집행·신뢰 경계가 다르다. | 두 경로에 목적·입력·결과 해석을 다른 배지와 문구로 표시한다. |
| 운영 | 결과가 0건일 때 권한 차단인지 검색 관련도 문제인지 판단하기 어렵다. | 결과 상단에 사용자, 허용 TAG, 제외 수, 반환 수, 토큰 상태를 요약한다. |
**완료 조건**: 일반 사용자는 토큰 검색 하나만으로 업무를 완료하고, 직접 비교는 의도적으로 고급 검증을 선택했을 때만 사용한다.
### C. DDS 보호 연결 (`/vpd-policies` · `vpd-policies.html`)
| 관점 | 관찰 | 합의된 개선 |
|---|---|---|
| UI/UX | DDS 화면인데 route와 일부 용어가 VPD를 드러내며, 설명은 많지만 보호 상태가 보이지 않는다. | 표면 용어를 `DDS 보호 객체`로 통일하고, 객체 카드를 중심으로 정보 구조를 바꾼다. |
| 설계 | 공통 권한 관리와 DDS 집행이 섞여 있고, 토큰 Context/END USER의 구분이 약하다. | 객체마다 집행 모드, Grant 상태, predicate 버전, 마지막 검증을 보여 준다. |
| 운영 | 어떤 객체가 미게시·드리프트·검증 실패인지 알 수 없다. | `정상/미게시/드리프트/검증 실패` 상태와 바로 실행 가능한 조치를 제공한다. |
**완료 조건**: 한 화면에서 보호 객체의 상태, 집행 모드, 마지막 검증, 필요한 조치를 판별한다.
### D. DDS 권한 반영 (`/dds-provision` · `dds-provision.html`)
| 관점 | 관찰 | 합의된 개선 |
|---|---|---|
| UI/UX | 큰 Grant 표와 SQL은 보이지만 변경 전후 차이와 영향이 먼저 보이지 않는다. | 게시 전 요약을 `추가/변경/회수/경고`로 분리하고, 상세 SQL은 접는다. |
| 설계 | Data Grant 교체·회수는 권한 확대/축소를 만들 수 있는 보안 변경이다. | 영향 사용자·객체·원문 컬럼, 게시 사유, 승인 확인, 검증 결과를 남긴다. |
| 운영 | 브라우저 confirm만으로 게시하면 사후 근거와 실패 복구 경로가 부족하다. | `미리보기 → 변경 사유 → 영향 확인 → 게시 → DB 결과/검증` 단계로 바꾼다. |
**완료 조건**: 게시 전에 영향과 변경 방향을 알 수 있고, 게시 후 결과·검증·재적용 기준이 감사 가능한 형태로 남는다.
### E. 보호 객체 상세 (`/dds` · `dds.html`)
| 관점 | 관찰 | 합의된 개선 |
|---|---|---|
| UI/UX | 정적 세일즈 사례와 구현 단계가 길고 현재 접근 결과를 탐색하는 기능이 약하다. | 기본 화면은 `주체 → 규칙 → 보호 객체 → 결과` 근거 체인으로 재구성한다. |
| 설계 | 객체, 행 판단 컬럼, Grant, 공통 권한 규칙의 연결 근거가 한 번에 추적되지 않는다. | 선택한 사용자/토큰 기준으로 적용된 Data Grant와 허용·차단 사유를 표시한다. |
| 운영 | 차단됐을 때 토큰 문제인지 권한 문제인지 Grant 문제인지 모른다. | `토큰 무효`, `권한 없음`, `객체 Grant 없음`, `TAG 불일치`별 다음 조치를 제공한다. |
**완료 조건**: 하나의 주체가 왜 허용·차단됐는지를 한 화면에서 추적하고, 정적 사례와 실제 상태를 혼동하지 않는다.
### F. 공통 메뉴·구조 바 (`fragments/layout.html`)
| 관점 | 관찰 | 합의된 개선 |
|---|---|---|
| UI/UX | 모든 페이지에 긴 구조 설명과 메뉴가 반복되어 콘텐츠보다 프레임이 무겁다. | 구조 바는 현재 단계·상태만 남기고 설명은 접는다. 메뉴는 기본 작업과 고급 검증을 분리한다. |
| 설계 | 기술 경계가 반복 문구마다 조금씩 달라질 위험이 있다. | 표준 용어와 상태 배지의 단일 사전을 적용한다. |
| 운영 | 현재 위치와 다음 작업이 메뉴에서 분명하지 않다. | 활성 메뉴, 경고 배지, 최근 실패 링크를 제공한다. |
**완료 조건**: 화면 공통 프레임이 주 작업을 방해하지 않고, 같은 개념이 일관된 용어로 보인다.
## 5. 우선순위와 진행 순서
| 우선순위 | 개선 묶음 | 이유 |
|---|---|---|
| P0 | 서비스 토큰 경로와 DDS END USER 직접 비교 경로 분리, 게시 안전 흐름 | 보안 의미를 잘못 전달하거나 권한 변경을 오조작할 위험 |
| P1 | 홈 상태·다음 행동, 보호 객체 상태/드리프트 표시 | 운영자가 무엇을 해야 할지 판단하지 못하는 문제 |
| P2 | 보호 객체 근거 체인, 공통 용어/점진적 공개/접근성 | 제품 학습 비용과 반복적인 설명 과다를 줄임 |
| P3 | 모바일 밀도, 결과 시각화, 추가 자동화 | 기본 경로가 안정된 뒤 개선 |
## 6. 검증 시나리오
1. 세일즈 토큰으로 SALES 태그 자료만 검색된다.
2. 다른 부서 태그는 관련도가 높아도 제외된다.
3. 회수·만료 토큰은 사용자 정보와 자료를 표시하지 않는다.
4. DDS END USER 직접 비교에서 Grant 없음은 객체 미노출/차단으로 해석된다.
5. 권한 게시 화면은 변경·회수·경고·검증 결과를 구분해 보여 준다.
6. 키보드와 모바일에서도 기본 검색과 고급 검증을 혼동하지 않고 완료할 수 있다.

View File

@@ -177,6 +177,10 @@ body {
}
@media (max-width: 920px) {
.effective-user-focus dl {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.rw-menu-panel {
position: static;
}
@@ -665,6 +669,114 @@ body {
margin-top: .25rem;
}
.effective-workbench {
background: var(--rw-surface);
border: 1px solid var(--rw-border);
border-radius: 8px;
box-shadow: var(--rw-shadow);
margin-bottom: 1rem;
overflow: hidden;
}
.effective-tabs {
background: var(--rw-surface-muted);
border-bottom: 1px solid var(--rw-border);
display: flex;
gap: .25rem;
padding: .5rem;
}
.effective-tab {
background: transparent;
border: 0;
border-radius: 6px;
color: var(--rw-muted);
font-size: .88rem;
font-weight: 800;
padding: .55rem .7rem;
}
.effective-tab:hover,
.effective-tab:focus,
.effective-tab.is-active {
background: var(--rw-surface);
color: var(--rw-primary);
}
.effective-pane {
min-width: 0;
padding: 1rem;
}
.effective-user-focus {
background: var(--rw-primary-soft);
border: 1px solid color-mix(in srgb, var(--rw-primary) 35%, var(--rw-border));
border-radius: 8px;
margin-bottom: 1rem;
padding: 1rem;
}
.effective-user-focus-heading strong,
.effective-user-focus-heading small {
display: block;
}
.effective-user-focus-heading strong {
font-size: 1.2rem;
margin-top: .25rem;
}
.effective-user-focus-heading small {
color: var(--rw-muted);
margin-top: .15rem;
}
.effective-user-focus dl {
display: grid;
gap: .7rem;
grid-template-columns: repeat(4, minmax(0, 1fr));
margin: 1rem 0;
}
.effective-user-focus dl > div {
min-width: 0;
}
.effective-user-focus dt {
color: var(--rw-muted);
font-size: .75rem;
font-weight: 800;
}
.effective-user-focus dd {
font-size: .86rem;
font-weight: 700;
margin: .25rem 0 0;
overflow-wrap: anywhere;
}
.effective-user-focus-footer {
align-items: center;
border-top: 1px solid var(--rw-border);
display: flex;
flex-wrap: wrap;
font-size: .84rem;
gap: .75rem;
justify-content: space-between;
padding-top: .75rem;
}
.effective-user-focus-footer a {
color: var(--rw-primary);
font-weight: 800;
text-decoration: none;
}
.effective-table-detail {
border-top: 1px solid var(--rw-border);
padding-top: 1rem;
}
.setup-steps {
color: var(--rw-muted);
margin: 0;
@@ -1475,6 +1587,18 @@ body {
}
@media (max-width: 640px) {
.effective-tabs {
overflow-x: auto;
}
.effective-tab {
flex: 0 0 auto;
}
.effective-user-focus dl {
grid-template-columns: 1fr;
}
.schema-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}

View File

@@ -630,6 +630,54 @@ function filterEffectiveMatrixTable(select) {
row.innerHTML = `<td colspan="${columnCount}" class="text-muted">${table.dataset.emptyMessage || '선택한 항목이 없습니다.'}</td>`;
body?.appendChild(row);
}
if (select.matches('[data-effective-user-select]')) {
updateEffectiveUserFocus(table, value);
}
}
function updateEffectiveUserFocus(table, userId) {
const focus = document.querySelector('[data-effective-user-focus]');
if (!focus || !table) {
return;
}
const row = Array.from(table.querySelectorAll('tbody tr[data-effective-id]'))
.find((item) => item.dataset.effectiveId === (userId || ''));
const values = {
username: row?.dataset.username || '사용자 선택',
profile: row?.dataset.profile || '사번 / 부서',
directRoles: row?.dataset.directRoles || '-',
inheritance: row?.dataset.inheritance || '-',
effectiveRoles: row?.dataset.effectiveRoles || '-',
objects: row?.dataset.objects || '-',
permissionCount: row?.dataset.permissionCount || '0'
};
Object.entries(values).forEach(([field, value]) => {
const target = focus.querySelector(`[data-effective-user-field="${field}"]`);
if (target) {
target.textContent = value;
}
});
}
function initEffectiveMatrixTabs() {
const tabs = document.querySelectorAll('[data-effective-tab]');
const panes = document.querySelectorAll('[data-effective-pane]');
if (!tabs.length || !panes.length) {
return;
}
tabs.forEach((tab) => {
tab.addEventListener('click', () => {
const target = tab.dataset.effectiveTab;
tabs.forEach((item) => {
const active = item === tab;
item.classList.toggle('is-active', active);
item.setAttribute('aria-selected', String(active));
});
panes.forEach((pane) => {
pane.hidden = pane.dataset.effectivePane !== target;
});
});
});
}
function initEffectiveMatrixFilters() {
@@ -955,6 +1003,7 @@ document.addEventListener('DOMContentLoaded', () => {
});
initTokenContextPreviews();
initEffectiveMatrixFilters();
initEffectiveMatrixTabs();
const objectSelect = document.querySelector('select[name="objectRef"]');
if (objectSelect) {
objectSelect.addEventListener('change', () => {

View File

@@ -5,203 +5,122 @@
<nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4">
<div class="page-title">
<h1>유효 권한 매트릭스</h1>
<p class="context-summary">사용자별로 최종 적용되는 역할·권한·보호 객체를 확인합니다.</p>
<h1>유효 권한 확인</h1>
<p class="context-summary">사용자가 어떤 역할 경로를 거쳐 어떤 보호 객체를 볼 수 있는지 확인합니다.</p>
<details class="explanation-details">
<summary>표를 읽는 방법</summary>
<p>백오피스 사용자, 그룹, 역할, 권한의 최종 상속 결과를 확인합니다. 직접 역할과 그룹을 통한 상속 역할을 나누어 보여주므로 권한이 어디에서 왔는지 추적할 수 있습니다.</p>
<summary>화면의 권한 근거 보기</summary>
<p>직접 역할과 그룹 상속 역할을 합쳐 최종 권한을 계산합니다. 여기의 결과는 토큰을 발급해 ORDS/VPD 조회를 검증하기 전 확인하는 설계 근거입니다.</p>
</details>
</div>
<div class="alert alert-warning" th:if="${runtimeError}">
<strong th:text="${runtimeError.title()}">DB 연결 설정이 필요합니다.</strong>
<span th:text="${runtimeError.message()}">message</span>
<div th:if="${runtimeError.showSupportCommand()}">
<code>./run.sh backoffice-support</code>
</div>
<div th:if="${runtimeError.showSupportCommand()}"><code>./run.sh backoffice-support</code></div>
</div>
<section th:replace="~{fragments/layout :: architectureStrip('permission')}"></section>
<section class="content-band">
<section class="content-band effective-overview">
<div class="section-heading">
<div>
<h2>권한 해석 기준</h2>
<p class="section-subtitle">Bearer Token으로 식별되는 백오피스 사용자 기준입니다.</p>
<span class="architecture-kicker">권한 근거</span>
<h2>누가 어떤 경로로 접근하는지 먼저 확인하세요.</h2>
<p class="section-subtitle">사용자 기준으로 시작하고, 필요할 때 그룹과 역할 기준으로 전환합니다.</p>
</div>
<a class="btn btn-sm rw-btn-secondary" href="/permissions">권한 관리로 이동</a>
<a class="btn btn-sm rw-btn-primary" href="/probe">이어서 실제 결과 확인</a>
</div>
<div class="matrix-summary">
<div>
<span>사용자</span>
<strong th:text="${#lists.size(matrix.users())}">0</strong>
</div>
<div>
<span>그룹</span>
<strong th:text="${#lists.size(matrix.groups())}">0</strong>
</div>
<div>
<span>역할</span>
<strong th:text="${#lists.size(matrix.roles())}">0</strong>
</div>
<div>
<span>권한</span>
<strong th:text="${matrix.permissionCount()}">0</strong>
</div>
</div>
<details class="explanation-details">
<summary>권한이 계산되는 순서 보기</summary>
<ol class="setup-steps">
<li>사용자 관리에서 application user를 등록합니다.</li>
<li>그룹 관리에서 사용자를 그룹에 넣고 그룹에 역할을 부여합니다.</li>
<li>권한 관리에서 역할에 TABLE/VIEW 행 규칙과 컬럼 NULL 정책을 부여합니다.</li>
<li>토큰을 발급하면 ORDS handler가 token user context를 설정하고 VPD가 이 권한을 적용합니다.</li>
</ol>
</details>
</section>
<section class="content-band">
<div class="section-heading">
<div>
<h2>사용자 기준</h2>
<p class="section-subtitle">직접 역할과 그룹을 통해 상속된 역할을 분리해서 봅니다.</p>
</div>
<label class="matrix-filter">
사용자
<select class="form-select form-select-sm" data-effective-filter="user-effective-table">
<option value="">전체 사용자</option>
<option th:each="row : ${matrix.users()}" th:value="${row.userId()}" th:text="${row.username()}"></option>
</select>
</label>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle" id="user-effective-table" data-empty-message="선택한 사용자에 대한 유효 권한이 없습니다. 사용자 등록, 그룹 배정, 역할 부여 순서로 확인하세요.">
<thead>
<tr>
<th>사용자</th>
<th>직접 역할</th>
<th>소속 그룹</th>
<th>그룹 상속 역할</th>
<th>최종 역할</th>
<th>권한</th>
<th>보호 객체</th>
</tr>
</thead>
<tbody>
<tr th:each="row : ${matrix.users()}" th:attr="data-effective-id=${row.userId()}">
<td>
<strong th:text="${row.username()}">이에이치알</strong>
<div class="text-muted small" th:text="${row.empNo() + ' / ' + row.deptCode()}">E100 / HR</div>
<span class="badge" th:classappend="${row.active()} ? ' text-bg-success' : ' text-bg-secondary'"
th:text="${row.active()} ? 'ACTIVE APP USER' : 'INACTIVE APP USER'">ACTIVE</span>
</td>
<td th:text="${#lists.isEmpty(row.directRoles()) ? '-' : #strings.listJoin(row.directRoles(), ', ')}">-</td>
<td th:text="${#lists.isEmpty(row.groups()) ? '-' : #strings.listJoin(row.groups(), ', ')}">-</td>
<td th:text="${#lists.isEmpty(row.inheritedRoles()) ? '-' : #strings.listJoin(row.inheritedRoles(), ', ')}">-</td>
<td><strong th:text="${#lists.isEmpty(row.effectiveRoles()) ? '-' : #strings.listJoin(row.effectiveRoles(), ', ')}">-</strong></td>
<td><span class="badge text-bg-secondary" th:text="${row.permissionCount()}">0</span></td>
<td class="matrix-list" th:text="${#lists.isEmpty(row.objectNames()) ? '권한 객체 없음' : #strings.listJoin(row.objectNames(), ', ')}">objects</td>
</tr>
<tr th:if="${#lists.isEmpty(matrix.users())}">
<td colspan="7" class="text-muted">등록된 사용자가 없습니다. 사용자 관리에서 application user를 먼저 등록하세요.</td>
</tr>
</tbody>
</table>
<div><span>사용자</span><strong th:text="${#lists.size(matrix.users())}">0</strong></div>
<div><span>그룹</span><strong th:text="${#lists.size(matrix.groups())}">0</strong></div>
<div><span>역할</span><strong th:text="${#lists.size(matrix.roles())}">0</strong></div>
<div><span>권한</span><strong th:text="${matrix.permissionCount()}">0</strong></div>
</div>
</section>
<section class="content-band">
<div class="section-heading">
<div>
<h2>그룹 기준</h2>
<p class="section-subtitle">그룹에 속한 사용자와 그룹에 부여된 역할이 제공하는 권한을 봅니다.</p>
</div>
<label class="matrix-filter">
그룹
<select class="form-select form-select-sm" data-effective-filter="group-effective-table">
<option value="">전체 그룹</option>
<option th:each="row : ${matrix.groups()}" th:value="${row.groupId()}" th:text="${row.groupCode() + ' / ' + row.groupName()}"></option>
</select>
</label>
<section class="effective-workbench" aria-label="유효 권한 탐색">
<div class="effective-tabs" role="tablist" aria-label="유효 권한 기준">
<button class="effective-tab is-active" type="button" role="tab" aria-selected="true" data-effective-tab="user">사용자 기준</button>
<button class="effective-tab" type="button" role="tab" aria-selected="false" data-effective-tab="group">그룹 기준</button>
<button class="effective-tab" type="button" role="tab" aria-selected="false" data-effective-tab="role">역할 기준</button>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle" id="group-effective-table" data-empty-message="선택한 그룹의 사용자/역할 매핑이 없습니다. 그룹 사용자와 그룹 역할을 먼저 등록하세요.">
<thead>
<tr>
<th>그룹</th>
<th>포함 사용자</th>
<th>부여 역할</th>
<th>권한</th>
<th>보호 객체</th>
</tr>
</thead>
<tbody>
<tr th:each="row : ${matrix.groups()}" th:attr="data-effective-id=${row.groupId()}">
<td>
<code th:text="${row.groupCode()}">GROUP</code>
<div th:text="${row.groupName()}">그룹명</div>
<span class="badge" th:classappend="${row.active()} ? ' text-bg-success' : ' text-bg-secondary'"
th:text="${row.active()} ? 'ACTIVE' : 'INACTIVE'">ACTIVE</span>
</td>
<td th:text="${#lists.isEmpty(row.users()) ? '사용자 없음' : #strings.listJoin(row.users(), ', ')}">users</td>
<td th:text="${#lists.isEmpty(row.roles()) ? '역할 없음' : #strings.listJoin(row.roles(), ', ')}">roles</td>
<td><span class="badge text-bg-secondary" th:text="${row.permissionCount()}">0</span></td>
<td class="matrix-list" th:text="${#lists.isEmpty(row.objectNames()) ? '권한 객체 없음' : #strings.listJoin(row.objectNames(), ', ')}">objects</td>
</tr>
<tr th:if="${#lists.isEmpty(matrix.groups())}">
<td colspan="5" class="text-muted">등록된 그룹이 없습니다. 그룹 관리에서 그룹을 먼저 생성하세요.</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="content-band">
<div class="section-heading">
<div>
<h2>역할 기준</h2>
<p class="section-subtitle">역할이 직접 사용자와 그룹 사용자에게 어떤 영향을 주는지 봅니다.</p>
<section class="effective-pane" data-effective-pane="user" role="tabpanel">
<div class="section-heading">
<div>
<h2>선택한 사용자의 최종 권한</h2>
<p class="section-subtitle">직접 부여와 그룹 상속을 구분해, 실제 검증 전 권한 경로를 확인합니다.</p>
</div>
<label class="matrix-filter">
검증할 사용자
<select class="form-select form-select-sm" id="user-effective-select" data-effective-filter="user-effective-table" data-effective-user-select>
<option th:each="row, rowStat : ${matrix.users()}"
th:value="${row.userId()}"
th:selected="${rowStat.index == 0}"
th:text="${row.username() + ' · ' + row.empNo()}">사용자</option>
<option th:if="${#lists.isEmpty(matrix.users())}" value="">등록된 사용자가 없습니다</option>
</select>
</label>
</div>
<label class="matrix-filter">
역할
<select class="form-select form-select-sm" data-effective-filter="role-effective-table">
<option value="">전체 역할</option>
<option th:each="row : ${matrix.roles()}" th:value="${row.roleId()}" th:text="${row.roleName()}"></option>
</select>
</label>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle" id="role-effective-table" data-empty-message="선택한 역할의 사용자/그룹/권한 연결이 없습니다. 사용자 역할, 그룹 역할, 권한 등록을 확인하세요.">
<thead>
<tr>
<th>역할</th>
<th>직접 사용자</th>
<th>연결 그룹</th>
<th>그룹 상속 사용자</th>
<th>영향 사용자</th>
<th>권한</th>
<th>보호 객체</th>
</tr>
</thead>
<tbody>
<tr th:each="row : ${matrix.roles()}" th:attr="data-effective-id=${row.roleId()}">
<td>
<strong th:text="${row.roleName()}">ROLE</strong>
<div class="text-muted small">민감도 <span th:text="${row.maxSensitivityLevel()}">PUBLIC</span></div>
</td>
<td th:text="${#lists.isEmpty(row.directUsers()) ? '직접 사용자 없음' : #strings.listJoin(row.directUsers(), ', ')}">users</td>
<td th:text="${#lists.isEmpty(row.groups()) ? '그룹 없음' : #strings.listJoin(row.groups(), ', ')}">groups</td>
<td th:text="${#lists.isEmpty(row.inheritedUsers()) ? '상속 사용자 없음' : #strings.listJoin(row.inheritedUsers(), ', ')}">group users</td>
<td><strong th:text="${#lists.isEmpty(row.affectedUsers()) ? '-' : #strings.listJoin(row.affectedUsers(), ', ')}">affected</strong></td>
<td><span class="badge text-bg-secondary" th:text="${row.permissionCount()}">0</span></td>
<td class="matrix-list" th:text="${#lists.isEmpty(row.objectNames()) ? '권한 객체 없음' : #strings.listJoin(row.objectNames(), ', ')}">objects</td>
</tr>
<tr th:if="${#lists.isEmpty(matrix.roles())}">
<td colspan="7" class="text-muted">등록된 역할이 없습니다. 역할 관리에서 역할을 먼저 생성하세요.</td>
</tr>
</tbody>
</table>
</div>
<div class="effective-user-focus" data-effective-user-focus>
<div class="effective-user-focus-heading">
<span class="architecture-kicker">선택 사용자</span>
<strong data-effective-user-field="username">사용자 선택</strong>
<small data-effective-user-field="profile">사번 / 부서</small>
</div>
<dl>
<div><dt>직접 역할</dt><dd data-effective-user-field="directRoles">-</dd></div>
<div><dt>그룹 상속</dt><dd data-effective-user-field="inheritance">-</dd></div>
<div><dt>최종 역할</dt><dd data-effective-user-field="effectiveRoles">-</dd></div>
<div><dt>보호 객체</dt><dd data-effective-user-field="objects">-</dd></div>
</dl>
<div class="effective-user-focus-footer">
<span><strong data-effective-user-field="permissionCount">0</strong>개 권한이 적용됩니다.</span>
<a href="/probe">토큰으로 실제 결과 확인 <span aria-hidden="true"></span></a>
</div>
</div>
<div class="table-responsive effective-table-detail">
<table class="table table-sm align-middle" id="user-effective-table" data-empty-message="선택한 사용자에 대한 유효 권한이 없습니다. 사용자 등록, 그룹 배정, 역할 부여 순서로 확인하세요.">
<thead>
<tr><th>사용자</th><th>직접 역할</th><th>그룹/상속 역할</th><th>최종 역할</th><th>권한</th><th>보호 객체</th></tr>
</thead>
<tbody>
<tr th:each="row : ${matrix.users()}"
th:attr="data-effective-id=${row.userId()},data-username=${row.username()},data-profile=${row.empNo() + ' / ' + row.deptCode()},data-direct-roles=${#lists.isEmpty(row.directRoles()) ? '직접 역할 없음' : #strings.listJoin(row.directRoles(), ', ')},data-inheritance=${(#lists.isEmpty(row.groups()) ? '그룹 없음' : #strings.listJoin(row.groups(), ', ')) + ' / ' + (#lists.isEmpty(row.inheritedRoles()) ? '상속 역할 없음' : #strings.listJoin(row.inheritedRoles(), ', '))},data-effective-roles=${#lists.isEmpty(row.effectiveRoles()) ? '최종 역할 없음' : #strings.listJoin(row.effectiveRoles(), ', ')},data-objects=${#lists.isEmpty(row.objectNames()) ? '권한 객체 없음' : #strings.listJoin(row.objectNames(), ', ')},data-permission-count=${row.permissionCount()}">
<td><strong th:text="${row.username()}">이에이치알</strong><div class="text-muted small" th:text="${row.empNo() + ' / ' + row.deptCode()}">E100 / HR</div></td>
<td th:text="${#lists.isEmpty(row.directRoles()) ? '-' : #strings.listJoin(row.directRoles(), ', ')}">-</td>
<td><span th:text="${#lists.isEmpty(row.groups()) ? '그룹 없음' : #strings.listJoin(row.groups(), ', ')}">그룹 없음</span><small class="d-block text-muted" th:text="${#lists.isEmpty(row.inheritedRoles()) ? '상속 역할 없음' : #strings.listJoin(row.inheritedRoles(), ', ')}">상속 역할 없음</small></td>
<td><strong th:text="${#lists.isEmpty(row.effectiveRoles()) ? '-' : #strings.listJoin(row.effectiveRoles(), ', ')}">-</strong></td>
<td><span class="badge text-bg-secondary" th:text="${row.permissionCount()}">0</span></td>
<td class="matrix-list" th:text="${#lists.isEmpty(row.objectNames()) ? '권한 객체 없음' : #strings.listJoin(row.objectNames(), ', ')}">objects</td>
</tr>
<tr th:if="${#lists.isEmpty(matrix.users())}"><td colspan="6" class="text-muted">등록된 사용자가 없습니다. 사용자 관리에서 application user를 먼저 등록하세요.</td></tr>
</tbody>
</table>
</div>
</section>
<section class="effective-pane" data-effective-pane="group" role="tabpanel" hidden>
<div class="section-heading">
<div><h2>그룹을 통한 상속 권한</h2><p class="section-subtitle">그룹에 속한 사용자와 그룹 역할이 만드는 접근 범위를 봅니다.</p></div>
<label class="matrix-filter">그룹<select class="form-select form-select-sm" data-effective-filter="group-effective-table"><option value="">전체 그룹</option><option th:each="row : ${matrix.groups()}" th:value="${row.groupId()}" th:text="${row.groupCode() + ' / ' + row.groupName()}"></option></select></label>
</div>
<div class="table-responsive"><table class="table table-sm align-middle" id="group-effective-table" data-empty-message="선택한 그룹의 사용자/역할 매핑이 없습니다. 그룹 사용자와 그룹 역할을 먼저 등록하세요."><thead><tr><th>그룹</th><th>포함 사용자</th><th>부여 역할</th><th>권한</th><th>보호 객체</th></tr></thead><tbody>
<tr th:each="row : ${matrix.groups()}" th:attr="data-effective-id=${row.groupId()}"><td><code th:text="${row.groupCode()}">GROUP</code><div th:text="${row.groupName()}">그룹명</div></td><td th:text="${#lists.isEmpty(row.users()) ? '사용자 없음' : #strings.listJoin(row.users(), ', ')}">users</td><td th:text="${#lists.isEmpty(row.roles()) ? '역할 없음' : #strings.listJoin(row.roles(), ', ')}">roles</td><td><span class="badge text-bg-secondary" th:text="${row.permissionCount()}">0</span></td><td class="matrix-list" th:text="${#lists.isEmpty(row.objectNames()) ? '권한 객체 없음' : #strings.listJoin(row.objectNames(), ', ')}">objects</td></tr>
<tr th:if="${#lists.isEmpty(matrix.groups())}"><td colspan="5" class="text-muted">등록된 그룹이 없습니다. 그룹 관리에서 그룹을 먼저 생성하세요.</td></tr>
</tbody></table></div>
</section>
<section class="effective-pane" data-effective-pane="role" role="tabpanel" hidden>
<div class="section-heading">
<div><h2>역할의 영향 범위</h2><p class="section-subtitle">직접 사용자와 그룹 상속 사용자가 받는 권한을 봅니다.</p></div>
<label class="matrix-filter">역할<select class="form-select form-select-sm" data-effective-filter="role-effective-table"><option value="">전체 역할</option><option th:each="row : ${matrix.roles()}" th:value="${row.roleId()}" th:text="${row.roleName()}"></option></select></label>
</div>
<div class="table-responsive"><table class="table table-sm align-middle" id="role-effective-table" data-empty-message="선택한 역할의 사용자/그룹/권한 연결이 없습니다. 사용자 역할, 그룹 역할, 권한 등록을 확인하세요."><thead><tr><th>역할</th><th>직접 사용자</th><th>연결 그룹</th><th>그룹 상속 사용자</th><th>영향 사용자</th><th>권한</th><th>보호 객체</th></tr></thead><tbody>
<tr th:each="row : ${matrix.roles()}" th:attr="data-effective-id=${row.roleId()}"><td><strong th:text="${row.roleName()}">ROLE</strong><div class="text-muted small">민감도 <span th:text="${row.maxSensitivityLevel()}">PUBLIC</span></div></td><td th:text="${#lists.isEmpty(row.directUsers()) ? '직접 사용자 없음' : #strings.listJoin(row.directUsers(), ', ')}">users</td><td th:text="${#lists.isEmpty(row.groups()) ? '그룹 없음' : #strings.listJoin(row.groups(), ', ')}">groups</td><td th:text="${#lists.isEmpty(row.inheritedUsers()) ? '상속 사용자 없음' : #strings.listJoin(row.inheritedUsers(), ', ')}">group users</td><td><strong th:text="${#lists.isEmpty(row.affectedUsers()) ? '-' : #strings.listJoin(row.affectedUsers(), ', ')}">affected</strong></td><td><span class="badge text-bg-secondary" th:text="${row.permissionCount()}">0</span></td><td class="matrix-list" th:text="${#lists.isEmpty(row.objectNames()) ? '권한 객체 없음' : #strings.listJoin(row.objectNames(), ', ')}">objects</td></tr>
<tr th:if="${#lists.isEmpty(matrix.roles())}"><td colspan="7" class="text-muted">등록된 역할이 없습니다. 역할 관리에서 역할을 먼저 생성하세요.</td></tr>
</tbody></table></div>
</section>
</section>
</main>
</body>

View File

@@ -0,0 +1,57 @@
package com.cloudhandson.vpdbackoffice.web;
import static org.assertj.core.api.Assertions.assertThat;
import com.cloudhandson.vpdbackoffice.domain.effective.EffectiveMatrixView;
import com.cloudhandson.vpdbackoffice.domain.effective.GroupEffectiveAccessView;
import com.cloudhandson.vpdbackoffice.domain.effective.RoleEffectiveImpactView;
import com.cloudhandson.vpdbackoffice.domain.effective.UserEffectiveAccessView;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import org.junit.jupiter.api.Test;
import org.thymeleaf.context.Context;
import org.thymeleaf.spring6.SpringTemplateEngine;
import org.thymeleaf.templateresolver.FileTemplateResolver;
class EffectiveMatrixTemplateRenderTest {
@Test
void rendersTheUserFocusedEffectiveMatrixWithItsLayoutFragments() {
var resolver = new FileTemplateResolver();
resolver.setPrefix(Path.of("src/main/resources/templates").toAbsolutePath() + "/");
resolver.setSuffix(".html");
resolver.setTemplateMode("HTML");
resolver.setCacheable(false);
var engine = new SpringTemplateEngine();
engine.setTemplateResolver(resolver);
var matrix = new EffectiveMatrixView(
List.of(new UserEffectiveAccessView(
103L, "김어드민", "E99999", "HQ", true,
List.of("ALL_DOC_ROLE"), List.of(), List.of(), List.of("ALL_DOC_ROLE"),
1, List.of("CB_VECTOR_SEARCH_DOCUMENTS"))),
List.of(new GroupEffectiveAccessView(
1L, "OPS", "운영", true, List.of("김어드민"), List.of("ALL_DOC_ROLE"),
1, List.of("CB_VECTOR_SEARCH_DOCUMENTS"))),
List.of(new RoleEffectiveImpactView(
30L, "ALL_DOC_ROLE", "CONFIDENTIAL", List.of("김어드민"), List.of(), List.of(),
List.of("김어드민"), 1, List.of("CB_VECTOR_SEARCH_DOCUMENTS"))),
1
);
var context = new Context(Locale.KOREAN);
context.setVariable("matrix", matrix);
context.setVariable("_csrf", new CsrfFixture("_csrf", "test-token"));
String rendered = engine.process("effective-matrix", context);
assertThat(rendered)
.contains("선택한 사용자의 최종 권한")
.contains("김어드민")
.contains("data-effective-user-focus")
.contains("data-inheritance=\"그룹 없음 / 상속 역할 없음\"");
}
private record CsrfFixture(String parameterName, String token) {
}
}

View File

@@ -113,6 +113,22 @@ class GuidedFlowTemplateTest {
.contains("기술 태그 비교 예시");
}
@Test
void effectiveMatrixStartsWithOneUserAndKeepsGroupAndRoleAsSecondaryViews() throws IOException {
String html = template("effective-matrix.html");
String javascript = Files.readString(Path.of("src/main/resources/static/js/app.js"));
assertThat(html)
.contains("선택한 사용자의 최종 권한")
.contains("data-effective-user-focus")
.contains("data-effective-tab=\"user\"")
.contains("data-effective-pane=\"group\"")
.contains("data-effective-pane=\"role\"");
assertThat(javascript)
.contains("updateEffectiveUserFocus")
.contains("initEffectiveMatrixTabs");
}
@Test
void mcpPagesDescribeReasoningAndProtocolBoundaries() throws IOException {
String chatbot = template("mcp-chatbot.html");