auth-yes/ui/components/AdminAppsPage.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

449 lines
18 KiB
XML

import { AdminLayout } from "./AdminLayout.tsx";
import { AdminTable } from "./admin/AdminTable.tsx";
import { AdminModal } from "./admin/AdminModal.tsx";
export const AdminAppsPage = ({
apps,
}: {
apps: any[];
}) => {
return (
<AdminLayout title="Application Registry" currentPath="/admin/apps">
<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);">
Connected Applications
</h1>
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
Register and manage subsidiary workloads and Edge Ingress proxy
configurations.
</p>
</div>
<div style="display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap;">
{/* Instant Search Bar */}
<div style="position: relative; min-width: 220px;">
<input
type="text"
id="appSearchInput"
placeholder="Search apps by name, domain..."
oninput="filterAppsList()"
style="width: 100%; padding: 0.5rem 1rem 0.5rem 2.25rem; font-size: 0.875rem;"
/>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
style="position: absolute; left: 0.75rem; 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>
<button
type="button"
class="btn-primary"
style="min-height: 40px;"
onclick="openCreateAppDrawer()"
>
+ Register App
</button>
</div>
</div>
{/* Register / Edit App Modal */}
<AdminModal
id="appFormModal"
title="Register New Subsidiary Application"
onClose="closeAppDrawer()"
>
<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.
</p>
<form id="appForm" onsubmit="handleSaveApp(event)">
<input type="hidden" id="editAppId" value="" />
<div 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);">
Application Name *
</label>
<input
type="text"
id="appName"
name="name"
placeholder="e.g. Elite Dangerous Streaming Hub"
required
style="width: 100%;"
/>
</div>
<div id="spiffeIdContainer">
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
SPIFFE ID (Workload Identity) *
</label>
<input
type="text"
id="appSpiffeId"
name="spiffeId"
placeholder="e.g. spiffe://system.local/ed-droid-backend"
required
style="width: 100%;"
/>
</div>
</div>
<div style="margin-bottom: 1rem;">
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
Description
</label>
<input
type="text"
id="appDescription"
name="description"
placeholder="e.g. Data telemetry streaming and UI module system"
style="width: 100%;"
/>
</div>
<div style="margin-bottom: 1rem;">
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
Domain (Edge Ingress Hostname)
</label>
<input
type="text"
id="appDomain"
name="domain"
placeholder="e.g. ed-droid.atyg.org"
style="width: 100%;"
/>
</div>
<div style="margin-bottom: 1rem;">
<label style="display: flex; align-items: center; gap: 0.5rem; font-weight: 600; font-size: 0.875rem; cursor: pointer; color: var(--text-primary);">
<input
type="checkbox"
id="appIsPublic"
name="is_public"
style="width: 18px; height: 18px;"
/>
Is Publicly Accessible (Bypass all auth checks)
</label>
</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);">
Bypass Paths (Comma-separated)
</label>
<input
type="text"
id="appBypassPaths"
name="bypass_paths"
placeholder="e.g. /public/*, /webhook"
style="width: 100%;"
/>
</div>
<div>
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
Allowed CIDRs (Comma-separated)
</label>
<input
type="text"
id="appAllowedCidrs"
name="allowed_cidrs"
placeholder="e.g. 192.168.1.0/24"
style="width: 100%;"
/>
</div>
</div>
<div style="display: flex; gap: 0.75rem;">
<button type="submit" class="btn-primary" style="min-height: 42px;">
Save Application
</button>
<button
type="button"
class="btn-outline"
onclick="closeAppDrawer()"
style="min-height: 42px;"
>
Cancel
</button>
</div>
</form>
</AdminModal>
<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>
<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>
{`
@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 filterAppsList() {
const query = document.getElementById('appSearchInput').value.toLowerCase().trim();
document.querySelectorAll('.app-row').forEach(r => {
const text = r.getAttribute('data-search') || '';
r.style.display = text.includes(query) ? '' : 'none';
});
document.querySelectorAll('.app-card').forEach(c => {
const text = c.getAttribute('data-search') || '';
c.style.display = text.includes(query) ? '' : 'none';
});
}
function openCreateAppDrawer() {
document.getElementById('editAppId').value = '';
document.querySelector('#appFormModal h3').textContent = 'Register New Subsidiary Application';
document.getElementById('appName').value = '';
document.getElementById('appSpiffeId').value = '';
document.getElementById('appSpiffeId').readOnly = false;
document.getElementById('spiffeIdContainer').style.display = 'block';
document.getElementById('appDescription').value = '';
document.getElementById('appDomain').value = '';
document.getElementById('appIsPublic').checked = false;
document.getElementById('appBypassPaths').value = '';
document.getElementById('appAllowedCidrs').value = '';
document.getElementById('appFormModal').style.display = 'flex';
}
function openEditAppDrawer(appJson) {
const app = JSON.parse(appJson);
document.getElementById('editAppId').value = app.id;
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;
document.getElementById('appDescription').value = app.description || '';
document.getElementById('appDomain').value = app.domain || '';
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('appFormModal').style.display = 'flex';
}
function closeAppDrawer() {
document.getElementById('appFormModal').style.display = 'none';
}
async function handleSaveApp(e) {
e.preventDefault();
const editId = document.getElementById('editAppId').value;
const name = document.getElementById('appName').value.trim();
const spiffeId = document.getElementById('appSpiffeId').value.trim();
const description = document.getElementById('appDescription').value.trim();
const domain = document.getElementById('appDomain').value.trim();
const is_public = document.getElementById('appIsPublic').checked;
const bypass_paths = document.getElementById('appBypassPaths').value.split(',').map(s => s.trim()).filter(Boolean);
const allowed_cidrs = document.getElementById('appAllowedCidrs').value.split(',').map(s => s.trim()).filter(Boolean);
if (!name || (!editId && !spiffeId)) {
showNotice('Application name and SPIFFE ID are required', true);
return;
}
try {
const url = editId ? ('/api/admin/apps/' + editId) : '/api/admin/apps';
const method = editId ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, spiffeId, description, domain, is_public, bypass_paths, allowed_cidrs }),
});
const data = await res.json();
if (res.ok) {
showNotice(editId ? 'Application updated successfully!' : 'Application registered successfully!', false);
setTimeout(() => window.location.reload(), 600);
} else {
showNotice(data.error || 'Failed to save application', true);
}
} catch (err) {
showNotice('Network error saving application', true);
}
}
async function deleteApp(appId, appName) {
if (!confirm('Are you sure you want to delete "' + appName + '"? All active user permissions for this app will be revoked.')) {
return;
}
try {
const res = await fetch('/api/admin/apps/' + appId, {
method: 'DELETE',
});
if (res.ok) {
showNotice('Application deleted', false);
setTimeout(() => window.location.reload(), 600);
} else {
const data = await res.json();
showNotice(data.error || 'Failed to delete application', true);
}
} catch (err) {
showNotice('Network error', true);
}
}
`,
}}
>
</script>
</AdminLayout>
);
};