[UX] #577 focus effective access on selected user

This commit is contained in:
devmrko
2026-06-30 18:05:04 +09:00
parent e7070663c3
commit 0ed048905a
6 changed files with 419 additions and 177 deletions

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>