Compare commits

...

2 Commits

Author SHA1 Message Date
54c98c1e4a chore: clean up temporary python and text scratch files from repo root 2026-08-25 17:37:04 -07:00
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
7 changed files with 461 additions and 460 deletions

View File

@ -1,4 +1,6 @@
import { AdminLayout } from "./AdminLayout.tsx";
import { AdminTable } from "./admin/AdminTable.tsx";
import { AdminModal } from "./admin/AdminModal.tsx";
export const AdminAppsPage = ({
apps,
@ -58,18 +60,12 @@ export const AdminAppsPage = ({
</div>
</div>
{/* Register / Edit App Drawer */}
<div
id="appFormCard"
class="card"
style="display: none; border-left: 4px solid var(--primary); margin-bottom: 1.5rem;"
{/* Register / Edit App Modal */}
<AdminModal
id="appFormModal"
title="Register New Subsidiary Application"
onClose="closeAppDrawer()"
>
<h3
id="appFormTitle"
style="margin: 0 0 0.5rem 0; color: var(--text-primary);"
>
Register New Subsidiary Application
</h3>
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1.25rem 0;">
Authenticate incoming ConnectRPC/ForwardAuth requests against the
application's SPIFFE ID and edge routing domains.
@ -186,173 +182,135 @@ export const AdminAppsPage = ({
</button>
</div>
</form>
</div>
</AdminModal>
{/* Desktop Table View (≥ 768px) */}
<div class="card desktop-only" style="display: none;">
<div class="table-container">
<table id="appsTable">
<thead>
<tr>
<th>Application Name</th>
<th>SPIFFE ID</th>
<th>Domain</th>
<th>Active Users</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{apps.length === 0
? (
<tr>
<td
colSpan={6}
style="text-align: center; color: var(--text-muted); padding: 2rem;"
>
No connected applications registered yet.
</td>
</tr>
)
: (
apps.map((app) => (
<tr
key={app.id}
class="app-row"
data-search={`${app.name} ${
app.domain || ""
} ${app.spiffe_id}`.toLowerCase()}
>
<td>
<strong style="color: var(--text-primary);">
{app.name}
</strong>
</td>
<td>
<code style="background: var(--surface-muted); padding: 0.2rem 0.4rem; border-radius: var(--radius-sm); font-size: 0.8rem; font-family: monospace; color: var(--primary);">
{app.spiffe_id}
</code>
</td>
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary);">
{app.domain || "-"}
</td>
<td>
<span class="badge badge-info">
{app.active_grants_count || 0} users
</span>
</td>
<td style="color: var(--text-secondary); font-size: 0.85rem; max-width: 250px;">
{app.description || "-"}
</td>
<td>
<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={`openEditAppDrawer(${
JSON.stringify(JSON.stringify(app))
})`}
>
Edit
</button>
<button
type="button"
class="btn-danger"
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
onclick={`deleteApp('${app.id}', '${app.name}')`}
>
Delete
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Mobile Adaptive Cards View (< 768px) */}
<div
id="appsMobileDeck"
class="mobile-only"
style="display: flex; flex-direction: column; gap: 1rem;"
>
{apps.length === 0
? (
<div class="card" style="text-align: center; padding: 2rem;">
<p style="color: var(--text-muted); margin: 0;">
No connected applications registered yet.
</p>
</div>
)
: (
apps.map((app) => (
<div
class="card app-card"
key={app.id}
data-search={`${app.name} ${app.domain || ""} ${app.spiffe_id}`
.toLowerCase()}
style="margin-bottom: 0;"
>
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.75rem;">
<div style="display: flex; align-items: center; gap: 0.65rem;">
<div style="display: flex; align-items: center; justify-content: center; width: 38px; height: 38px; background: var(--primary-light); color: var(--primary); border-radius: var(--radius-md); font-weight: 700;">
{app.name.charAt(0).toUpperCase()}
</div>
<div>
<h3 style="margin: 0; font-size: 1.05rem; color: var(--text-primary);">
{app.name}
</h3>
<span style="font-size: 0.8rem; color: var(--text-muted); font-family: monospace;">
{app.domain || "No domain"}
</span>
</div>
</div>
<span class="badge badge-info">
{app.active_grants_count || 0} Users
<AdminTable
id="appsTable"
headers={[
"Application Name",
"SPIFFE ID",
"Domain",
"Active Users",
"Description",
"Actions",
]}
isEmpty={apps.length === 0}
emptyState="No connected applications registered yet."
desktopRows={apps.map((app) => (
<tr
key={app.id}
class="app-row"
data-search={`${app.name} ${app.domain || ""} ${app.spiffe_id}`
.toLowerCase()}
>
<td>
<strong style="color: var(--text-primary);">
{app.name}
</strong>
</td>
<td>
<code style="background: var(--surface-muted); padding: 0.2rem 0.4rem; border-radius: var(--radius-sm); font-size: 0.8rem; font-family: monospace; color: var(--primary);">
{app.spiffe_id}
</code>
</td>
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary);">
{app.domain || "-"}
</td>
<td>
<span class="badge badge-info">
{app.active_grants_count || 0} users
</span>
</td>
<td style="color: var(--text-secondary); font-size: 0.85rem; max-width: 250px;">
{app.description || "-"}
</td>
<td>
<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={`openEditAppDrawer(${
JSON.stringify(JSON.stringify(app))
})`}
>
Edit
</button>
<button
type="button"
class="btn-danger"
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
onclick={`deleteApp('${app.id}', '${app.name}')`}
>
Delete
</button>
</div>
</td>
</tr>
))}
mobileCards={apps.map((app) => (
<div
class="card app-card"
key={app.id}
data-search={`${app.name} ${app.domain || ""} ${app.spiffe_id}`
.toLowerCase()}
style="margin-bottom: 0;"
>
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.75rem;">
<div style="display: flex; align-items: center; gap: 0.65rem;">
<div style="display: flex; align-items: center; justify-content: center; width: 38px; height: 38px; background: var(--primary-light); color: var(--primary); border-radius: var(--radius-md); font-weight: 700;">
{app.name.charAt(0).toUpperCase()}
</div>
<div>
<h3 style="margin: 0; font-size: 1.05rem; color: var(--text-primary);">
{app.name}
</h3>
<span style="font-size: 0.8rem; color: var(--text-muted); font-family: monospace;">
{app.domain || "No domain"}
</span>
</div>
<div style="font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 1rem; line-height: 1.5;">
<p style="margin: 0 0 0.5rem 0;">
{app.description || "No description provided."}
</p>
<div>
<strong>SPIFFE:</strong>{" "}
<code style="font-family: monospace; font-size: 0.8rem;">
{app.spiffe_id}
</code>
</div>
</div>
<div style="display: flex; gap: 0.5rem;">
<button
type="button"
class="btn-outline"
style="flex: 1; justify-content: center; min-height: 40px; font-size: 0.85rem;"
onclick={`openEditAppDrawer(${
JSON.stringify(JSON.stringify(app))
})`}
>
Edit App
</button>
<button
type="button"
class="btn-danger"
style="flex: 1; justify-content: center; min-height: 40px; font-size: 0.85rem;"
onclick={`deleteApp('${app.id}', '${app.name}')`}
>
Delete
</button>
</div>
</div>
))
)}
</div>
<span class="badge badge-info">
{app.active_grants_count || 0} Users
</span>
</div>
<div style="font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 1rem; line-height: 1.5;">
<p style="margin: 0 0 0.5rem 0;">
{app.description || "No description provided."}
</p>
<div>
<strong>SPIFFE:</strong>{" "}
<code style="font-family: monospace; font-size: 0.8rem;">
{app.spiffe_id}
</code>
</div>
</div>
<div style="display: flex; gap: 0.5rem;">
<button
type="button"
class="btn-outline"
style="flex: 1; justify-content: center; min-height: 40px; font-size: 0.85rem;"
onclick={`openEditAppDrawer(${
JSON.stringify(JSON.stringify(app))
})`}
>
Edit App
</button>
<button
type="button"
class="btn-danger"
style="flex: 1; justify-content: center; min-height: 40px; font-size: 0.85rem;"
onclick={`deleteApp('${app.id}', '${app.name}')`}
>
Delete
</button>
</div>
</div>
))}
/>
<style>
{`
@ -394,7 +352,7 @@ export const AdminAppsPage = ({
function openCreateAppDrawer() {
document.getElementById('editAppId').value = '';
document.getElementById('appFormTitle').textContent = 'Register New Subsidiary Application';
document.querySelector('#appFormModal h3').textContent = 'Register New Subsidiary Application';
document.getElementById('appName').value = '';
document.getElementById('appSpiffeId').value = '';
document.getElementById('appSpiffeId').readOnly = false;
@ -404,14 +362,13 @@ export const AdminAppsPage = ({
document.getElementById('appIsPublic').checked = false;
document.getElementById('appBypassPaths').value = '';
document.getElementById('appAllowedCidrs').value = '';
document.getElementById('appFormCard').style.display = 'block';
document.getElementById('appFormCard').scrollIntoView({ behavior: 'smooth' });
document.getElementById('appFormModal').style.display = 'flex';
}
function openEditAppDrawer(appJson) {
const app = JSON.parse(appJson);
document.getElementById('editAppId').value = app.id;
document.getElementById('appFormTitle').textContent = 'Edit Application: ' + app.name;
document.querySelector('#appFormModal h3').textContent = 'Edit Application: ' + app.name;
document.getElementById('appName').value = app.name || '';
document.getElementById('appSpiffeId').value = app.spiffe_id || '';
document.getElementById('appSpiffeId').readOnly = true;
@ -420,12 +377,11 @@ export const AdminAppsPage = ({
document.getElementById('appIsPublic').checked = !!app.is_public;
document.getElementById('appBypassPaths').value = Array.isArray(app.bypass_paths) ? app.bypass_paths.join(', ') : (app.bypass_paths || '');
document.getElementById('appAllowedCidrs').value = Array.isArray(app.allowed_cidrs) ? app.allowed_cidrs.join(', ') : (app.allowed_cidrs || '');
document.getElementById('appFormCard').style.display = 'block';
document.getElementById('appFormCard').scrollIntoView({ behavior: 'smooth' });
document.getElementById('appFormModal').style.display = 'flex';
}
function closeAppDrawer() {
document.getElementById('appFormCard').style.display = 'none';
document.getElementById('appFormModal').style.display = 'none';
}
async function handleSaveApp(e) {

View File

@ -1,4 +1,6 @@
import { AdminLayout } from "./AdminLayout.tsx";
import { AdminTable } from "./admin/AdminTable.tsx";
import { AdminModal } from "./admin/AdminModal.tsx";
export const AdminInvitesPage = ({
invites,
@ -64,14 +66,11 @@ export const AdminInvitesPage = ({
</div>
</div>
<div
id="create-invite-card"
class="card"
style="display: none; border-left: 4px solid var(--primary); margin-bottom: 1.5rem;"
<AdminModal
id="createInviteModal"
title="Generate User Onboarding Token"
onClose="toggleCreateInviteForm()"
>
<h3 style="margin: 0 0 0.5rem 0; color: var(--text-primary);">
Generate User Onboarding Token
</h3>
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1.25rem 0;">
Configure time bounds, usage capacity, role assignments, and initial
account activation status.
@ -247,170 +246,104 @@ export const AdminInvitesPage = ({
</button>
</div>
</div>
</div>
</AdminModal>
{/* Desktop Ledger Table (≥ 768px) */}
<div class="card desktop-only" style="display: none;">
<div class="table-container">
<table id="invitesTable">
<thead>
<tr>
<th>Invite Code</th>
<th>Target App / Scope</th>
<th>Role</th>
<th>Capacity & Usage</th>
<th>Status</th>
<th>Expires</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{invites.length === 0
? (
<tr>
<td
colSpan={7}
style="text-align: center; color: var(--text-muted); padding: 2rem;"
>
No active or historical invite tokens found.
</td>
</tr>
)
: (
invites.map((inv) => {
const usesCount = inv.uses_count || 0;
const maxUses = inv.max_uses;
const isUnlimited = maxUses === null;
const isExhausted = !isUnlimited && usesCount >= maxUses;
const isExpired = new Date(inv.expires_at) < new Date();
const isActive = !isExhausted && !isExpired;
<AdminTable
id="invitesTable"
headers={[
"Invite Code",
"Target App / Scope",
"Role",
"Capacity & Usage",
"Status",
"Expires",
"Actions",
]}
isEmpty={invites.length === 0}
emptyState="No active or historical invite tokens found."
desktopRows={invites.map((inv) => {
const usesCount = inv.uses_count || 0;
const maxUses = inv.max_uses;
const isUnlimited = maxUses === null;
const isExhausted = !isUnlimited && usesCount >= maxUses;
const isExpired = new Date(inv.expires_at) < new Date();
const isActive = !isExhausted && !isExpired;
return (
<tr
key={inv.id}
class="invite-row"
data-search={`${inv.code} ${
inv.app_name || ""
} ${inv.role}`.toLowerCase()}
>
<td>
<code style="background: var(--surface-muted); padding: 0.25rem 0.5rem; border-radius: var(--radius-sm); font-weight: 700; font-family: monospace; color: var(--primary);">
{inv.code}
</code>
</td>
<td>
{inv.app_name
? (
<strong style="color: var(--text-primary);">
{inv.app_name}
</strong>
)
: inv.role === "admin"
? <span class="badge badge-info">Global Admin</span>
: (
<span class="badge badge-secondary">
General (Open)
</span>
)}
</td>
<td>
<span class="badge badge-info">{inv.role}</span>
</td>
<td>
<div style="min-width: 110px;">
{isUnlimited
? (
<span style="font-size: 0.85rem; font-weight: 600; color: var(--primary);">
{usesCount} claimed (Unlimited)
</span>
)
: (
<div>
<span style="font-size: 0.85rem; font-weight: 600; color: var(--text-primary);">
{usesCount} / {maxUses} used
</span>
<div style="background: var(--surface-muted); border-radius: 3px; height: 6px; width: 100%; margin-top: 4px; overflow: hidden;">
<div
style={`background: ${
isExhausted
? "var(--text-muted)"
: "var(--success)"
}; height: 100%; width: ${
Math.min(
100,
(usesCount / maxUses) * 100,
)
}%;`}
/>
</div>
</div>
)}
</div>
</td>
<td>
{isExhausted && (
<span class="badge badge-secondary">Exhausted</span>
)}
{isExpired && !isExhausted && (
<span class="badge badge-danger">Expired</span>
)}
{isActive && (
<span class="badge badge-success">Active</span>
)}
</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">
{new Date(inv.expires_at).toLocaleDateString()}
</td>
<td>
<div style="display: flex; gap: 0.35rem; flex-wrap: wrap;">
{isActive && (
<button
type="button"
class="btn-primary"
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
onclick={`copyInviteLink('${inv.code}')`}
>
Copy Link
</button>
)}
{usesCount > 0 && (
<button
type="button"
class="btn-outline"
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
onclick={`showRedemptionsModal('${inv.id}', '${inv.code}')`}
>
Claimed ({usesCount})
</button>
)}
{isActive && (
<button
type="button"
class="btn-danger"
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
onclick={`revokeInvite('${inv.id}', '${inv.code}')`}
>
Revoke
</button>
)}
</div>
</td>
</tr>
);
})
return (
<tr
key={inv.id}
class="invite-row"
data-search={`${inv.code} ${inv.app_name || ""} ${inv.role}`
.toLowerCase()}
>
<td>
<code style="background: var(--surface-muted); padding: 0.25rem 0.5rem; border-radius: var(--radius-sm); font-weight: 700; font-family: monospace; color: var(--primary);">
{inv.code}
</code>
</td>
<td style="font-size: 0.85rem;">
{inv.app_name || (
<span style="color: var(--text-muted); font-style: italic;">
Global / Open
</span>
)}
</tbody>
</table>
</div>
</div>
{/* Mobile Adaptive Cards View (< 768px) */}
<div
id="invitesMobileDeck"
class="mobile-only"
style="display: flex; flex-direction: column; gap: 0.75rem;"
>
{invites.map((inv) => {
</td>
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary);">
{inv.role}
</td>
<td style="font-size: 0.85rem;">
{isUnlimited ? "Unlimited" : `${usesCount} / ${maxUses}`}
</td>
<td>
{isExhausted && (
<span class="badge badge-secondary">Exhausted</span>
)}
{isExpired && !isExhausted && (
<span class="badge badge-danger">Expired</span>
)}
{isActive && <span class="badge badge-success">Active</span>}
</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">
{new Date(inv.expires_at).toLocaleDateString()}
</td>
<td>
<div style="display: flex; gap: 0.35rem; flex-wrap: wrap;">
{isActive && (
<button
type="button"
class="btn-primary"
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
onclick={`copyInviteLink('${inv.code}')`}
>
Copy Link
</button>
)}
{usesCount > 0 && (
<button
type="button"
class="btn-outline"
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
onclick={`showRedemptionsModal('${inv.id}', '${inv.code}')`}
>
Claimed ({usesCount})
</button>
)}
{isActive && (
<button
type="button"
class="btn-danger"
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
onclick={`revokeInvite('${inv.id}', '${inv.code}')`}
>
Revoke
</button>
)}
</div>
</td>
</tr>
);
})}
mobileCards={invites.map((inv) => {
const usesCount = inv.uses_count || 0;
const maxUses = inv.max_uses;
const isUnlimited = maxUses === null;
@ -504,49 +437,39 @@ export const AdminInvitesPage = ({
</div>
);
})}
</div>
/>
{/* Redemptions Modal */}
<div
<AdminModal
id="redemptions-modal"
style="display: none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.6); z-index: 9999; justify-content: center; align-items: center;"
title={
<span>
Users Claimed:{" "}
<code id="modal-invite-code" style="color: var(--primary);">
</code>
</span>
}
onClose="closeRedemptionsModal()"
>
<div style="background: var(--surface-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); width: 90%; max-width: 550px; padding: 1.5rem; box-shadow: var(--shadow-lg);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 style="margin: 0; font-size: 1.1rem; color: var(--text-primary);">
Users Claimed:{" "}
<code id="modal-invite-code" style="color: var(--primary);">
</code>
</h3>
<button
type="button"
onclick="closeRedemptionsModal()"
style="background: none; border: none; font-size: 1.2rem; cursor: pointer; color: var(--text-muted);"
>
&times;
</button>
</div>
<div
id="modal-redemptions-content"
style="max-height: 350px; overflow-y: auto;"
>
<p style="color: var(--text-muted); font-size: 0.9rem;">
Loading claimed users...
</p>
</div>
<div style="text-align: right; margin-top: 1rem;">
<button
type="button"
class="btn-outline"
onclick="closeRedemptionsModal()"
>
Close
</button>
</div>
<div
id="modal-redemptions-content"
style="max-height: 350px; overflow-y: auto;"
>
<p style="color: var(--text-muted); font-size: 0.9rem;">
Loading claimed users...
</p>
</div>
</div>
<div style="text-align: right; margin-top: 1rem;">
<button
type="button"
class="btn-outline"
onclick="closeRedemptionsModal()"
>
Close
</button>
</div>
</AdminModal>
<style>
{`
@ -606,8 +529,11 @@ export const AdminInvitesPage = ({
}
function toggleCreateInviteForm() {
const el = document.getElementById('create-invite-card');
el.style.display = el.style.display === 'none' ? 'block' : 'none';
const el = document.getElementById('createInviteModal');
el.style.display = el.style.display === 'none' ? 'flex' : 'none';
if (el.style.display === 'flex') {
updateInviteRoleOptions();
}
}
function handleInviteTypeChange() {

View File

@ -1,4 +1,10 @@
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,
@ -35,17 +41,11 @@ export const AdminRolesPage = ({
</div>
{/* Create / Edit Role Drawer */}
<div
id="roleFormCard"
class="card"
style="display: none; border-left: 4px solid var(--primary); margin-bottom: 1.5rem;"
<AdminModal
id="roleFormModal"
title="Create New Role"
onClose="closeRoleDrawer()"
>
<h3
id="roleFormTitle"
style="margin: 0 0 0.5rem 0; color: var(--text-primary);"
>
Create New Role
</h3>
<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>
@ -133,7 +133,7 @@ export const AdminRolesPage = ({
</button>
</div>
</form>
</div>
</AdminModal>
<div class="card">
{/* Instant Search and Scope Filters */}
@ -410,7 +410,7 @@ export const AdminRolesPage = ({
}
function closeRoleDrawer() {
document.getElementById('roleFormCard').style.display = 'none';
document.getElementById('roleFormModal').style.display = 'none';
}
function handleScopeChange() {

View File

@ -1,4 +1,5 @@
import { AdminLayout } from "./AdminLayout.tsx";
import { AdminTable } from "./admin/AdminTable.tsx";
export const AdminUserDetailsPage = ({
user,
@ -145,66 +146,77 @@ export const AdminUserDetailsPage = ({
</form>
</div>
<div class="table-container" style="margin-top: 1.25rem;">
<table>
<thead>
<tr>
<th>Application Name</th>
<th>SPIFFE Workload ID</th>
<th>Assigned Role</th>
<th>Granted At</th>
<th>Actions</th>
<div style="margin-top: 1.25rem;">
<AdminTable
id="grantsTable"
headers={[
"Application Name",
"SPIFFE Workload ID",
"Assigned Role",
"Granted At",
"Actions",
]}
isEmpty={grants.length === 0}
emptyState="No application permissions granted (User is blocked from all subsidiary apps)."
desktopRows={grants.map((grant) => (
<tr key={grant.id}>
<td>
<strong style="color: var(--text-primary);">
{grant.app_name}
</strong>
</td>
<td>
<code style="background: var(--surface-muted); padding: 0.2rem 0.4rem; border-radius: var(--radius-sm); font-size: 0.8rem; font-family: monospace;">
{grant.spiffe_id}
</code>
</td>
<td>
<span class="badge badge-info">
{grant.role}
</span>
</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">
{new Date(grant.created_at).toLocaleDateString()}
</td>
<td>
<button
type="button"
class="btn-danger"
style="padding: 0.35rem 0.75rem; font-size: 0.8rem; min-height: 32px;"
onclick={`revokeGrant('${user.id}', '${grant.app_id}', '${grant.app_name}')`}
>
Revoke Access
</button>
</td>
</tr>
</thead>
<tbody>
{grants.length === 0
? (
<tr>
<td
colSpan={5}
style="text-align: center; color: var(--danger); padding: 1.5rem;"
>
No application permissions granted (User is blocked from
all subsidiary apps).
</td>
</tr>
)
: (
grants.map((grant) => (
<tr key={grant.id}>
<td>
<strong style="color: var(--text-primary);">
{grant.app_name}
</strong>
</td>
<td>
<code style="background: var(--surface-muted); padding: 0.2rem 0.4rem; border-radius: var(--radius-sm); font-size: 0.8rem; font-family: monospace;">
{grant.spiffe_id}
</code>
</td>
<td>
<span class="badge badge-info">
{grant.role}
</span>
</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">
{new Date(grant.created_at).toLocaleDateString()}
</td>
<td>
<button
type="button"
class="btn-danger"
style="padding: 0.35rem 0.75rem; font-size: 0.8rem; min-height: 32px;"
onclick={`revokeGrant('${user.id}', '${grant.app_id}', '${grant.app_name}')`}
>
Revoke Access
</button>
</td>
</tr>
))
)}
</tbody>
</table>
))}
mobileCards={grants.map((grant) => (
<div class="card" key={grant.id} style="margin-bottom: 0;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem;">
<strong style="color: var(--text-primary);">
{grant.app_name}
</strong>
<span class="badge badge-info">{grant.role}</span>
</div>
<div style="font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 0.75rem; font-family: monospace;">
{grant.spiffe_id}
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 0.8rem; color: var(--text-secondary);">
{new Date(grant.created_at).toLocaleDateString()}
</span>
<button
type="button"
class="btn-danger"
style="padding: 0.25rem 0.5rem; font-size: 0.75rem;"
onclick={`revokeGrant('${user.id}', '${grant.app_id}', '${grant.app_name}')`}
>
Revoke Access
</button>
</div>
</div>
))}
/>
</div>
</div>

View File

@ -0,0 +1,40 @@
export interface AdminModalProps {
id: string;
title: any;
children: any;
maxWidth?: string;
onClose?: string; // e.g. "closeModal('myModalId')"
}
export const AdminModal = ({
id,
title,
children,
maxWidth = "550px",
onClose,
}: AdminModalProps) => {
return (
<div
id={id}
style="display: none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.6); z-index: 9999; justify-content: center; align-items: center;"
>
<div
style={`background: var(--surface-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); width: 90%; max-width: ${maxWidth}; padding: 1.5rem; box-shadow: var(--shadow-lg); max-height: 90vh; overflow-y: auto;`}
>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 style="margin: 0; font-size: 1.1rem; color: var(--text-primary);">
{title}
</h3>
<button
type="button"
onclick={onClose}
style="background: none; border: none; font-size: 1.2rem; cursor: pointer; color: var(--text-muted);"
>
&times;
</button>
</div>
{children}
</div>
</div>
);
};

View File

@ -0,0 +1,67 @@
export interface AdminTableProps {
id: string;
headers: any[];
desktopRows: any;
mobileCards: any;
emptyState?: any;
colSpan?: number;
isEmpty?: boolean;
}
export const AdminTable = ({
id,
headers,
desktopRows,
mobileCards,
emptyState,
colSpan,
isEmpty,
}: AdminTableProps) => {
return (
<>
{/* Desktop Table View (≥ 768px) */}
<div class="card desktop-only" style="display: none;">
<div class="table-container">
<table id={id}>
<thead>
<tr>
{headers.map((header, i) => <th key={i}>{header}</th>)}
</tr>
</thead>
<tbody>
{isEmpty
? (
<tr>
<td
colSpan={colSpan || headers.length}
style="text-align: center; color: var(--text-muted); padding: 2rem;"
>
{emptyState}
</td>
</tr>
)
: desktopRows}
</tbody>
</table>
</div>
</div>
{/* Mobile Deck View (< 768px) */}
<div
id={`${id}MobileDeck`}
class="mobile-only"
style="display: flex; flex-direction: column; gap: 1rem;"
>
{isEmpty
? (
<div class="card" style="text-align: center; padding: 2rem;">
<p style="color: var(--text-muted); margin: 0;">
{emptyState}
</p>
</div>
)
: mobileCards}
</div>
</>
);
};