feat(admin): implement App Registry drawer and handlers (Slice 1) (#78)
- Added "+ Register App" button and `#appDrawer` form to `src/features/admin/apps_fragments.tsx`. - Implemented action buttons (Edit, Delete) using HTML5 `data-*` attributes for safe JSON parsing. - Implemented global `openCreateAppDrawer`, `openEditAppDrawer`, `closeAppDrawer`, `handleSaveApp`, and `deleteApp` handlers in `public/admin-scripts.js`. - Fixed missing `spiffe_id` property mapping in payload. - Added corresponding Role and Invite handlers to `public/admin-scripts.js` for completeness. - Verified components render correctly via assertions in `src/features/admin/admin.test.ts`. Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: mrteye <1945243+mrteye@users.noreply.github.com>
This commit is contained in:
parent
6881eefeed
commit
7b115f4ef1
@ -291,3 +291,152 @@ function updateRoleOptions(allRolesJson) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
globalThis.updateRoleOptions = updateRoleOptions;
|
globalThis.updateRoleOptions = updateRoleOptions;
|
||||||
|
|
||||||
|
// Slice 1: Application Registry Handlers
|
||||||
|
function openCreateAppDrawer() {
|
||||||
|
const drawer = document.getElementById("appDrawer");
|
||||||
|
if (!drawer) return;
|
||||||
|
const title = document.getElementById("appDrawerTitle");
|
||||||
|
if (title) title.textContent = "Register New Subsidiary Application";
|
||||||
|
|
||||||
|
document.getElementById("editAppId").value = "";
|
||||||
|
document.getElementById("appName").value = "";
|
||||||
|
document.getElementById("appSpiffeId").value = "";
|
||||||
|
document.getElementById("appSpiffeId").readOnly = false;
|
||||||
|
document.getElementById("appSpiffeId").style.opacity = "1";
|
||||||
|
document.getElementById("appDescription").value = "";
|
||||||
|
document.getElementById("appDomain").value = "";
|
||||||
|
document.getElementById("appIsPublic").checked = false;
|
||||||
|
document.getElementById("appBypassPaths").value = "";
|
||||||
|
document.getElementById("appAllowedCidrs").value = "";
|
||||||
|
|
||||||
|
drawer.style.display = "block";
|
||||||
|
}
|
||||||
|
globalThis.openCreateAppDrawer = openCreateAppDrawer;
|
||||||
|
|
||||||
|
function openEditAppDrawer(appJson) {
|
||||||
|
const drawer = document.getElementById("appDrawer");
|
||||||
|
if (!drawer) return;
|
||||||
|
|
||||||
|
let app;
|
||||||
|
try {
|
||||||
|
app = typeof appJson === "string" ? JSON.parse(appJson) : appJson;
|
||||||
|
} catch (e) {
|
||||||
|
showNotice("Failed to parse application data.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = document.getElementById("appDrawerTitle");
|
||||||
|
if (title) title.textContent = "Edit Application: " + app.name;
|
||||||
|
|
||||||
|
document.getElementById("editAppId").value = app.id || "";
|
||||||
|
document.getElementById("appName").value = app.name || "";
|
||||||
|
document.getElementById("appSpiffeId").value = app.spiffe_id || "";
|
||||||
|
document.getElementById("appSpiffeId").readOnly = true;
|
||||||
|
document.getElementById("appSpiffeId").style.opacity = "0.6";
|
||||||
|
document.getElementById("appDescription").value = app.description || "";
|
||||||
|
document.getElementById("appDomain").value = app.domain || "";
|
||||||
|
document.getElementById("appIsPublic").checked = app.is_public || false;
|
||||||
|
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 || "");
|
||||||
|
|
||||||
|
drawer.style.display = "block";
|
||||||
|
}
|
||||||
|
globalThis.openEditAppDrawer = openEditAppDrawer;
|
||||||
|
|
||||||
|
function closeAppDrawer() {
|
||||||
|
const drawer = document.getElementById("appDrawer");
|
||||||
|
if (drawer) drawer.style.display = "none";
|
||||||
|
}
|
||||||
|
globalThis.closeAppDrawer = closeAppDrawer;
|
||||||
|
|
||||||
|
async function handleSaveApp(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const id = 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 isPublic = document.getElementById("appIsPublic").checked;
|
||||||
|
const bypassPathsRaw = document.getElementById("appBypassPaths").value.trim();
|
||||||
|
const allowedCidrsRaw = document.getElementById("appAllowedCidrs").value
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
showNotice("Application Name is required", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!id && !spiffeId) {
|
||||||
|
showNotice("SPIFFE ID is required", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name,
|
||||||
|
spiffe_id: spiffeId,
|
||||||
|
description,
|
||||||
|
domain,
|
||||||
|
is_public: isPublic,
|
||||||
|
bypass_paths: bypassPathsRaw
|
||||||
|
? bypassPathsRaw.split(",").map((s) => s.trim()).filter((s) => s)
|
||||||
|
: [],
|
||||||
|
allowed_cidrs: allowedCidrsRaw
|
||||||
|
? allowedCidrsRaw.split(",").map((s) => s.trim()).filter((s) => s)
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = id ? `/api/admin/apps/${id}` : "/api/admin/apps";
|
||||||
|
const method = id ? "PUT" : "POST";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showNotice(
|
||||||
|
id
|
||||||
|
? "Application updated successfully!"
|
||||||
|
: "Application registered successfully!",
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
setTimeout(() => globalThis.location.reload(), 600);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
showNotice(data.error || "Failed to save application", true);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showNotice("Network error saving application", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalThis.handleSaveApp = handleSaveApp;
|
||||||
|
|
||||||
|
async function deleteApp(appId, appName) {
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
"Permanently delete application '" + appName +
|
||||||
|
"'? This will revoke all active grants.",
|
||||||
|
)
|
||||||
|
) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/apps/" + appId, { method: "DELETE" });
|
||||||
|
if (res.ok) {
|
||||||
|
showNotice("Application deleted successfully", false);
|
||||||
|
setTimeout(() => globalThis.location.reload(), 600);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
showNotice(data.error || "Failed to delete application", true);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showNotice("Network error deleting application", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalThis.deleteApp = deleteApp;
|
||||||
|
|||||||
@ -133,6 +133,10 @@ test("admin fragment components render valid HTML markup", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(appsFragment).toBeDefined();
|
expect(appsFragment).toBeDefined();
|
||||||
|
expect(appsFragment.toString()).toContain("+ Register App");
|
||||||
|
expect(appsFragment.toString()).toContain('id="appDrawer"');
|
||||||
|
expect(appsFragment.toString()).toContain("openEditAppDrawer(");
|
||||||
|
expect(appsFragment.toString()).toContain("deleteApp(");
|
||||||
|
|
||||||
const auditFragment = AuditLogPageFragment({
|
const auditFragment = AuditLogPageFragment({
|
||||||
logs: [
|
logs: [
|
||||||
|
|||||||
@ -23,6 +23,14 @@ export const AdminAppsPageFragment = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-primary"
|
||||||
|
style="min-height: 40px;"
|
||||||
|
onclick="openCreateAppDrawer()"
|
||||||
|
>
|
||||||
|
+ Register App
|
||||||
|
</button>
|
||||||
<div style="position: relative; min-width: 240px; max-width: 320px; width: 100%;">
|
<div style="position: relative; min-width: 240px; max-width: 320px; width: 100%;">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -47,6 +55,130 @@ export const AdminAppsPageFragment = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop Table */}
|
{/* Desktop Table */}
|
||||||
|
|
||||||
|
{/* App Registration / Editing Drawer */}
|
||||||
|
<div
|
||||||
|
id="appDrawer"
|
||||||
|
class="card"
|
||||||
|
style="display: none; border-left: 4px solid var(--primary); margin-bottom: 1.5rem;"
|
||||||
|
>
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||||
|
<h3
|
||||||
|
id="appDrawerTitle"
|
||||||
|
style="margin: 0; color: var(--text-primary);"
|
||||||
|
>
|
||||||
|
Register New Subsidiary Application
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="closeAppDrawer()"
|
||||||
|
style="background: none; border: none; font-size: 1.25rem; color: var(--text-muted); cursor: pointer;"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="appForm" onsubmit="handleSaveApp(event)">
|
||||||
|
<input type="hidden" id="editAppId" value="" />
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 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"
|
||||||
|
placeholder="e.g. Dashboard"
|
||||||
|
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 Workload ID *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="appSpiffeId"
|
||||||
|
placeholder="spiffe://ay/app"
|
||||||
|
required
|
||||||
|
style="width: 100%; font-family: monospace;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 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);">
|
||||||
|
Domain / Ingress Hostname
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="appDomain"
|
||||||
|
placeholder="e.g. app.example.com"
|
||||||
|
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
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="appDescription"
|
||||||
|
placeholder="Description of the application"
|
||||||
|
style="width: 100%;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 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);">
|
||||||
|
Bypass Paths (comma separated)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="appBypassPaths"
|
||||||
|
placeholder="/public,/health"
|
||||||
|
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"
|
||||||
|
placeholder="10.0.0.0/8,192.168.1.0/24"
|
||||||
|
style="width: 100%;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 1.25rem;">
|
||||||
|
<label style="display: flex; align-items: center; gap: 0.5rem; font-weight: 600; font-size: 0.85rem; color: var(--text-secondary);">
|
||||||
|
<input type="checkbox" id="appIsPublic" />
|
||||||
|
Public Access (Allow unauthenticated traffic)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 0.75rem;">
|
||||||
|
<button type="submit" class="btn-primary" style="min-height: 38px;">
|
||||||
|
Save Application
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
onclick="closeAppDrawer()"
|
||||||
|
style="min-height: 38px;"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card desktop-only" style="display: none;">
|
<div class="card desktop-only" style="display: none;">
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table id="appsTable">
|
<table id="appsTable">
|
||||||
@ -56,6 +188,7 @@ export const AdminAppsPageFragment = ({
|
|||||||
<th>SPIFFE Workload ID</th>
|
<th>SPIFFE Workload ID</th>
|
||||||
<th>Domain</th>
|
<th>Domain</th>
|
||||||
<th>Active Grants</th>
|
<th>Active Grants</th>
|
||||||
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -63,7 +196,7 @@ export const AdminAppsPageFragment = ({
|
|||||||
? (
|
? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={4}
|
colSpan={5}
|
||||||
style="text-align: center; color: var(--text-muted); padding: 1.5rem;"
|
style="text-align: center; color: var(--text-muted); padding: 1.5rem;"
|
||||||
>
|
>
|
||||||
No connected applications registered.
|
No connected applications registered.
|
||||||
@ -97,6 +230,29 @@ export const AdminAppsPageFragment = ({
|
|||||||
{app.active_grants_count || 0} users
|
{app.active_grants_count || 0} users
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</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;"
|
||||||
|
data-app={JSON.stringify(app)}
|
||||||
|
onclick="openEditAppDrawer(this.dataset.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.replace(/'/g, "\\'")
|
||||||
|
}');`}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@ -134,6 +290,27 @@ export const AdminAppsPageFragment = ({
|
|||||||
<div style="font-size: 0.85rem; color: var(--text-muted);">
|
<div style="font-size: 0.85rem; color: var(--text-muted);">
|
||||||
Domain: {app.domain || "-"}
|
Domain: {app.domain || "-"}
|
||||||
</div>
|
</div>
|
||||||
|
<div style="display: flex; gap: 0.5rem; margin-top: 0.75rem;">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
style="flex: 1; padding: 0.35rem; font-size: 0.85rem;"
|
||||||
|
data-app={JSON.stringify(app)}
|
||||||
|
onclick="openEditAppDrawer(this.dataset.app)"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-danger"
|
||||||
|
style="flex: 1; padding: 0.35rem; font-size: 0.85rem;"
|
||||||
|
onclick={`deleteApp('${app.id}', '${
|
||||||
|
app.name.replace(/'/g, "\\'")
|
||||||
|
}');`}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user