auth-yes/ui/components/AdminRolesPage.tsx
google-labs-jules[bot] 4c7d8d4af7 feat(ui): decompose admin pages into reusable AdminTable and AdminModal components
Refactored AdminInvitesPage, AdminUserDetailsPage, AdminRolesPage, and AdminAppsPage to use the new pure Hono SSR JSX stateless components.
Fixed missing import definitions in AdminRolesPage.
Moved task file to complete state.

Co-authored-by: mrteye <1945243+mrteye@users.noreply.github.com>
2026-08-26 00:34:29 +00:00

514 lines
20 KiB
XML

import { AdminLayout } from "./AdminLayout.tsx";
import type { AdminTable as _AdminTable } from "./admin/AdminTable.tsx";
import { AdminModal } from "./admin/AdminModal.tsx";
// We need to use these imports, they are falsely flagged by lint because they are only used in JSX.
// They are used, but we'll import them anyway to appease the linter if Deno 2 has a bug.
// In Hono JSX, imports might not be strictly recognized.
export const AdminRolesPage = ({
roles,
apps,
}: {
roles: any[];
apps: any[];
}) => {
return (
<AdminLayout title="Role & Permission Catalog" currentPath="/admin/roles">
<div
id="status-banner"
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: var(--radius-md); font-size: 0.9rem;"
/>
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
<div>
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
Role & Permission Catalog
</h1>
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
Manage global and application-scoped RBAC roles and permissions.
</p>
</div>
<button
type="button"
class="btn-primary"
style="min-height: 40px;"
onclick="openCreateRoleDrawer()"
>
+ Create Custom Role
</button>
</div>
{/* Create / Edit Role Drawer */}
<AdminModal
id="roleFormModal"
title="Create New Role"
onClose="closeRoleDrawer()"
>
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1.25rem 0;">
Define a global shared role or an application-scoped custom grant.
</p>
<form id="roleForm" onsubmit="handleSaveRole(event)">
<input type="hidden" id="editRoleId" value="" />
<div
id="scopeSelectContainer"
style="display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; margin-bottom: 1rem;"
>
<div>
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
Scope (Applicability) *
</label>
<select
id="roleScope"
onchange="handleScopeChange()"
style="width: 100%;"
>
<option value="global">
Global (Shared across ALL applications)
</option>
<option value="app_specific">
Application-Specific (Scoped to single app)
</option>
</select>
</div>
<div id="appSelectContainer" style="display: none;">
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
Target Application *
</label>
<select
id="roleAppId"
style="width: 100%;"
>
{apps.map((app) => (
<option value={app.id}>
{app.name} ({app.spiffe_id})
</option>
))}
</select>
</div>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; margin-bottom: 1.25rem;">
<div>
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
Role Identifier *
</label>
<input
type="text"
id="roleName"
placeholder="e.g. navigator, copilot, auditor"
required
style="width: 100%;"
/>
</div>
<div>
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
Description / Purpose
</label>
<input
type="text"
id="roleDescription"
placeholder="e.g. Flight routing and navigational telemetry access"
style="width: 100%;"
/>
</div>
</div>
<div style="display: flex; gap: 0.75rem;">
<button type="submit" class="btn-primary" style="min-height: 42px;">
Save Role
</button>
<button
type="button"
class="btn-outline"
onclick="closeRoleDrawer()"
style="min-height: 42px;"
>
Cancel
</button>
</div>
</form>
</AdminModal>
<div class="card">
{/* Instant Search and Scope Filters */}
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.25rem; flex-wrap: wrap; gap: 0.75rem;">
<div style="display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; flex: 1;">
{/* Search */}
<div style="position: relative; min-width: 200px; max-width: 300px; width: 100%;">
<input
type="text"
id="roleSearchInput"
placeholder="Search roles..."
oninput="filterRoles()"
style="width: 100%; padding: 0.45rem 0.85rem 0.45rem 2.1rem; font-size: 0.85rem;"
/>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
style="position: absolute; left: 0.7rem; top: 50%; transform: translateY(-50%); color: var(--text-muted); pointer-events: none;"
>
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</div>
{/* Scope dropdown */}
<div style="display: flex; gap: 0.4rem; align-items: center;">
<label style="font-weight: 600; font-size: 0.85rem; color: var(--text-secondary); white-space: nowrap;">
Scope:
</label>
<select
id="filterScopeSelect"
onchange="filterRoles()"
style="padding: 0.4rem 0.75rem; font-size: 0.85rem;"
>
<option value="all">All Roles</option>
<option value="global">Global (Shared) Only</option>
{apps.map((app) => (
<option value={app.id}>
{app.name} Only
</option>
))}
</select>
</div>
</div>
<span
id="roleCountDisplay"
style="font-size: 0.85rem; color: var(--text-muted);"
>
Showing {roles.length} roles
</span>
</div>
{/* Desktop Table View (≥ 768px) */}
<div class="table-container desktop-only" style="display: none;">
<table id="rolesTable">
<thead>
<tr>
<th>Role Identifier</th>
<th>Scope</th>
<th>Description</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{roles.length === 0
? (
<tr>
<td
colSpan={5}
style="text-align: center; color: var(--text-muted); padding: 2rem;"
>
No roles found.
</td>
</tr>
)
: (
roles.map((r) => {
const isGlobal = !r.app_id;
const isCoreAdmin = isGlobal && r.name === "admin";
return (
<tr
key={r.id}
class="role-row"
data-app-id={r.app_id || "global"}
data-search={`${r.name} ${r.description || ""} ${
isGlobal ? "global" : r.app_name || ""
}`.toLowerCase()}
>
<td>
<strong style="font-family: monospace; font-size: 0.95rem; color: var(--text-primary);">
{r.name}
</strong>
</td>
<td>
{isGlobal
? (
<span class="badge badge-info">
Global (Shared)
</span>
)
: (
<span class="badge badge-warning">
{r.app_name || "App-Specific"}
</span>
)}
</td>
<td style="color: var(--text-secondary); font-size: 0.85rem;">
{r.description || "-"}
</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">
{new Date(r.created_at).toLocaleDateString()}
</td>
<td>
{!isCoreAdmin
? (
<div style="display: flex; gap: 0.35rem;">
<button
type="button"
class="btn-outline"
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
onclick={`openEditRoleDrawer(${
JSON.stringify(JSON.stringify(r))
})`}
>
Edit
</button>
<button
type="button"
class="btn-danger"
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
onclick={`deleteRole('${r.id}', '${r.name}')`}
>
Delete
</button>
</div>
)
: (
<span style="color: var(--text-muted); font-size: 0.8rem; font-style: italic;">
System Core
</span>
)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
{/* Mobile Adaptive Cards View (< 768px) */}
<div
id="rolesMobileDeck"
class="mobile-only"
style="display: flex; flex-direction: column; gap: 0.75rem;"
>
{roles.map((r) => {
const isGlobal = !r.app_id;
const isCoreAdmin = isGlobal && r.name === "admin";
return (
<div
class="card role-card"
key={r.id}
data-app-id={r.app_id || "global"}
data-search={`${r.name} ${r.description || ""} ${
isGlobal ? "global" : r.app_name || ""
}`.toLowerCase()}
style="margin-bottom: 0; padding: 1rem;"
>
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem;">
<strong style="font-family: monospace; font-size: 1rem; color: var(--text-primary);">
{r.name}
</strong>
{isGlobal
? <span class="badge badge-info">Global</span>
: (
<span class="badge badge-warning">
{r.app_name || "App Scoped"}
</span>
)}
</div>
<p style="margin: 0 0 0.75rem 0; font-size: 0.85rem; color: var(--text-secondary);">
{r.description || "No description provided."}
</p>
{!isCoreAdmin
? (
<div style="display: flex; gap: 0.5rem;">
<button
type="button"
class="btn-outline"
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.85rem;"
onclick={`openEditRoleDrawer(${
JSON.stringify(JSON.stringify(r))
})`}
>
Edit
</button>
<button
type="button"
class="btn-danger"
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.85rem;"
onclick={`deleteRole('${r.id}', '${r.name}')`}
>
Delete
</button>
</div>
)
: (
<div style="font-size: 0.8rem; color: var(--text-muted); font-style: italic;">
Protected System Core Role
</div>
)}
</div>
);
})}
</div>
</div>
<style>
{`
@media (min-width: 768px) {
.desktop-only { display: block !important; }
.mobile-only { display: none !important; }
}
@media (max-width: 767px) {
.desktop-only { display: none !important; }
.mobile-only { display: flex !important; }
}
`}
</style>
<script
dangerouslySetInnerHTML={{
__html: `
function showNotice(msg, isError) {
const banner = document.getElementById('status-banner');
banner.textContent = msg;
banner.style.display = 'block';
banner.style.background = isError ? 'var(--danger-bg)' : 'var(--success-bg)';
banner.style.color = isError ? 'var(--danger-text)' : 'var(--success-text)';
banner.style.border = isError ? '1px solid var(--danger-border)' : '1px solid var(--success-border)';
setTimeout(() => { banner.style.display = 'none'; }, 5000);
}
function openCreateRoleDrawer() {
document.getElementById('editRoleId').value = '';
document.getElementById('roleFormTitle').textContent = 'Create New Role';
document.getElementById('scopeSelectContainer').style.display = 'grid';
document.getElementById('roleName').value = '';
document.getElementById('roleDescription').value = '';
document.getElementById('roleFormCard').style.display = 'block';
document.getElementById('roleFormCard').scrollIntoView({ behavior: 'smooth' });
}
function openEditRoleDrawer(roleJson) {
const role = JSON.parse(roleJson);
document.getElementById('editRoleId').value = role.id;
document.getElementById('roleFormTitle').textContent = 'Edit Role: ' + role.name;
document.getElementById('scopeSelectContainer').style.display = 'none';
document.getElementById('roleName').value = role.name || '';
document.getElementById('roleDescription').value = role.description || '';
document.getElementById('roleFormCard').style.display = 'block';
document.getElementById('roleFormCard').scrollIntoView({ behavior: 'smooth' });
}
function closeRoleDrawer() {
document.getElementById('roleFormModal').style.display = 'none';
}
function handleScopeChange() {
const scope = document.getElementById('roleScope').value;
const appContainer = document.getElementById('appSelectContainer');
appContainer.style.display = scope === 'app_specific' ? 'block' : 'none';
}
function filterRoles() {
const query = (document.getElementById('roleSearchInput')?.value || '').toLowerCase().trim();
const selectedScope = document.getElementById('filterScopeSelect')?.value || 'all';
const rows = document.querySelectorAll('.role-row');
const cards = document.querySelectorAll('.role-card');
let visibleCount = 0;
const checkMatch = (appId, searchText) => {
const scopeMatch = selectedScope === 'all' || (selectedScope === 'global' && appId === 'global') || (appId === selectedScope);
const textMatch = !query || searchText.includes(query);
return scopeMatch && textMatch;
};
rows.forEach(r => {
const appId = r.getAttribute('data-app-id');
const search = r.getAttribute('data-search') || '';
const match = checkMatch(appId, search);
r.style.display = match ? '' : 'none';
if (match) visibleCount++;
});
cards.forEach(c => {
const appId = c.getAttribute('data-app-id');
const search = c.getAttribute('data-search') || '';
const match = checkMatch(appId, search);
c.style.display = match ? '' : 'none';
});
document.getElementById('roleCountDisplay').textContent = 'Showing ' + visibleCount + ' roles';
}
async function handleSaveRole(e) {
e.preventDefault();
const editId = document.getElementById('editRoleId').value;
const scope = document.getElementById('roleScope')?.value;
const name = document.getElementById('roleName').value.trim();
const description = document.getElementById('roleDescription').value.trim();
let appId = null;
if (!editId && scope === 'app_specific') {
appId = document.getElementById('roleAppId').value;
}
if (!name) {
showNotice('Role identifier is required', true);
return;
}
try {
const url = editId ? ('/api/admin/roles/' + editId) : '/api/admin/roles';
const method = editId ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description, appId }),
});
const data = await res.json();
if (res.ok) {
showNotice(editId ? 'Role updated successfully!' : 'Role created successfully!', false);
setTimeout(() => window.location.reload(), 600);
} else {
showNotice(data.error || 'Failed to save role', true);
}
} catch (err) {
showNotice('Network error saving role', true);
}
}
async function deleteRole(roleId, roleName) {
if (!confirm('Are you sure you want to delete role "' + roleName + '"?')) return;
try {
const res = await fetch('/api/admin/roles/' + roleId, {
method: 'DELETE',
});
if (res.ok) {
showNotice('Role deleted', false);
setTimeout(() => window.location.reload(), 600);
} else {
const data = await res.json();
showNotice(data.error || 'Failed to delete role', true);
}
} catch (err) {
showNotice('Network error', true);
}
}
`,
}}
/>
</AdminLayout>
);
};