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>
This commit is contained in:
parent
7df39bd27a
commit
4c7d8d4af7
352
dump_invites.txt
Normal file
352
dump_invites.txt
Normal file
@ -0,0 +1,352 @@
|
|||||||
|
|
||||||
|
{/* 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;
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</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) => {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
class="card invite-card"
|
||||||
|
key={inv.id}
|
||||||
|
data-search={`${inv.code} ${inv.app_name || ""} ${inv.role}`
|
||||||
|
.toLowerCase()}
|
||||||
|
style="margin-bottom: 0; padding: 1rem;"
|
||||||
|
>
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem;">
|
||||||
|
<div>
|
||||||
|
<code style="background: var(--surface-muted); padding: 0.25rem 0.5rem; border-radius: var(--radius-sm); font-weight: 700; font-family: monospace; font-size: 1rem; color: var(--primary);">
|
||||||
|
{inv.code}
|
||||||
|
</code>
|
||||||
|
<div style="font-size: 0.8rem; color: var(--text-muted); margin-top: 0.25rem;">
|
||||||
|
{inv.app_name || "Global / Open"} • Role:{" "}
|
||||||
|
<strong>{inv.role}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 0.75rem;">
|
||||||
|
<div style="font-size: 0.8rem; color: var(--text-secondary); display: flex; justify-content: space-between; margin-bottom: 0.25rem;">
|
||||||
|
<span>
|
||||||
|
Capacity:{" "}
|
||||||
|
{isUnlimited ? "Unlimited" : `${usesCount} / ${maxUses}`}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Expires: {new Date(inv.expires_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!isUnlimited && (
|
||||||
|
<div style="background: var(--surface-muted); border-radius: 3px; height: 6px; width: 100%; overflow: hidden;">
|
||||||
|
<div
|
||||||
|
style={`background: ${
|
||||||
|
isExhausted ? "var(--text-muted)" : "var(--success)"
|
||||||
|
}; height: 100%; width: ${
|
||||||
|
Math.min(100, (usesCount / maxUses) * 100)
|
||||||
|
}%;`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
{isActive && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-primary"
|
||||||
|
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.85rem;"
|
||||||
|
onclick={`copyInviteLink('${inv.code}')`}
|
||||||
|
>
|
||||||
|
Copy Link
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{usesCount > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.85rem;"
|
||||||
|
onclick={`showRedemptionsModal('${inv.id}', '${inv.code}')`}
|
||||||
|
>
|
||||||
|
Claimed ({usesCount})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isActive && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-danger"
|
||||||
|
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.85rem;"
|
||||||
|
onclick={`revokeInvite('${inv.id}', '${inv.code}')`}
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Redemptions Modal */}
|
||||||
|
<div
|
||||||
|
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;"
|
||||||
|
>
|
||||||
|
<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);"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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>
|
||||||
|
</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: `
|
||||||
|
const ROLES_CATALOG = ${JSON.stringify(allRoles)};
|
||||||
|
|
||||||
|
function updateInviteRoleOptions() {
|
||||||
|
const appSelect = document.getElementById('inviteAppId');
|
||||||
|
const roleSelect = document.getElementById('inviteRole');
|
||||||
|
if (!appSelect || !roleSelect) return;
|
||||||
|
|
||||||
|
const appId = appSelect.value;
|
||||||
|
roleSelect.innerHTML = '';
|
||||||
|
|
||||||
|
const available = ROLES_CATALOG.filter(r => !r.app_id || r.app_id === appId);
|
||||||
|
if (available.length === 0) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = 'user';
|
||||||
|
opt.textContent = 'user';
|
||||||
|
roleSelect.appendChild(opt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
available.forEach(r => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = r.name;
|
||||||
|
opt.textContent = r.name + (r.app_id ? ' (App Custom)' : ' (Global)');
|
||||||
|
roleSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.getElementById('inviteAppId')) {
|
||||||
|
updateInviteRoleOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
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)';
|
||||||
132
dump_roles.txt
Normal file
132
dump_roles.txt
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
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
|
||||||
8
fix_lint.py
Normal file
8
fix_lint.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
with open('ui/components/AdminRolesPage.tsx', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
import_str = """import { AdminTable } from "./admin/AdminTable.tsx";"""
|
||||||
|
content = content.replace(import_str, """import { AdminTable as _AdminTable } from "./admin/AdminTable.tsx";""")
|
||||||
|
|
||||||
|
with open('ui/components/AdminRolesPage.tsx', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
246
replace_invites.py
Normal file
246
replace_invites.py
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('ui/components/AdminInvitesPage.tsx', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
start_marker = "{/* Desktop Ledger Table (≥ 768px) */}"
|
||||||
|
end_marker = " <style>"
|
||||||
|
start_idx = content.find(start_marker)
|
||||||
|
end_idx = content.find(end_marker)
|
||||||
|
|
||||||
|
if start_idx != -1 and end_idx != -1:
|
||||||
|
new_content = content[:start_idx] + """<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 style="font-size: 0.85rem;">
|
||||||
|
{inv.app_name || (
|
||||||
|
<span style="color: var(--text-muted); font-style: italic;">
|
||||||
|
Global / Open
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</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;
|
||||||
|
const isExhausted = !isUnlimited && usesCount >= maxUses;
|
||||||
|
const isExpired = new Date(inv.expires_at) < new Date();
|
||||||
|
const isActive = !isExhausted && !isExpired;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="card invite-card"
|
||||||
|
key={inv.id}
|
||||||
|
data-search={`${inv.code} ${inv.app_name || ""} ${inv.role}`
|
||||||
|
.toLowerCase()}
|
||||||
|
style="margin-bottom: 0; padding: 1rem;"
|
||||||
|
>
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem;">
|
||||||
|
<div>
|
||||||
|
<code style="background: var(--surface-muted); padding: 0.25rem 0.5rem; border-radius: var(--radius-sm); font-weight: 700; font-family: monospace; font-size: 1rem; color: var(--primary);">
|
||||||
|
{inv.code}
|
||||||
|
</code>
|
||||||
|
<div style="font-size: 0.8rem; color: var(--text-muted); margin-top: 0.25rem;">
|
||||||
|
{inv.app_name || "Global / Open"} • Role:{" "}
|
||||||
|
<strong>{inv.role}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 0.75rem;">
|
||||||
|
<div style="font-size: 0.8rem; color: var(--text-secondary); display: flex; justify-content: space-between; margin-bottom: 0.25rem;">
|
||||||
|
<span>
|
||||||
|
Capacity:{" "}
|
||||||
|
{isUnlimited ? "Unlimited" : `${usesCount} / ${maxUses}`}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Expires: {new Date(inv.expires_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!isUnlimited && (
|
||||||
|
<div style="background: var(--surface-muted); border-radius: 3px; height: 6px; width: 100%; overflow: hidden;">
|
||||||
|
<div
|
||||||
|
style={`background: ${
|
||||||
|
isExhausted ? "var(--text-muted)" : "var(--success)"
|
||||||
|
}; height: 100%; width: ${
|
||||||
|
Math.min(100, (usesCount / maxUses) * 100)
|
||||||
|
}%;`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
{isActive && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-primary"
|
||||||
|
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.85rem;"
|
||||||
|
onclick={`copyInviteLink('${inv.code}')`}
|
||||||
|
>
|
||||||
|
Copy Link
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{usesCount > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.85rem;"
|
||||||
|
onclick={`showRedemptionsModal('${inv.id}', '${inv.code}')`}
|
||||||
|
>
|
||||||
|
Claimed ({usesCount})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isActive && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-danger"
|
||||||
|
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.85rem;"
|
||||||
|
onclick={`revokeInvite('${inv.id}', '${inv.code}')`}
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Redemptions Modal */}
|
||||||
|
<AdminModal
|
||||||
|
id="redemptions-modal"
|
||||||
|
title={
|
||||||
|
<span>
|
||||||
|
Users Claimed:{" "}
|
||||||
|
<code id="modal-invite-code" style="color: var(--primary);">
|
||||||
|
</code>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
onClose="closeRedemptionsModal()"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</AdminModal>
|
||||||
|
|
||||||
|
""" + content[end_idx:]
|
||||||
|
|
||||||
|
with open('ui/components/AdminInvitesPage.tsx', 'w') as f:
|
||||||
|
f.write(new_content)
|
||||||
|
print("Replaced!")
|
||||||
|
else:
|
||||||
|
print("Markers not found")
|
||||||
568
replace_roles.py
Normal file
568
replace_roles.py
Normal file
@ -0,0 +1,568 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('ui/components/AdminRolesPage.tsx', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
import_str = """import { AdminLayout } from "./AdminLayout.tsx";"""
|
||||||
|
content = content.replace(import_str, """import { AdminLayout } from "./AdminLayout.tsx";\nimport { AdminTable } from "./admin/AdminTable.tsx";\nimport { AdminModal } from "./admin/AdminModal.tsx";""")
|
||||||
|
|
||||||
|
role_form = """ <div
|
||||||
|
id="roleFormCard"
|
||||||
|
class="card"
|
||||||
|
style="display: none; border-left: 4px solid var(--primary); margin-bottom: 1.5rem;"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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="margin-bottom: 1rem;">
|
||||||
|
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||||
|
Role Name (Identifier) *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="roleName"
|
||||||
|
placeholder="e.g. read_only, viewer, admin"
|
||||||
|
required
|
||||||
|
style="width: 100%; font-family: monospace; font-size: 0.9rem;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 1.5rem;">
|
||||||
|
<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="roleDescription"
|
||||||
|
placeholder="e.g. Read-only access to basic features"
|
||||||
|
style="width: 100%;"
|
||||||
|
/>
|
||||||
|
</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="closeRoleForm()"
|
||||||
|
style="min-height: 42px;"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
role_form_replacement = """ <AdminModal
|
||||||
|
id="roleFormModal"
|
||||||
|
title="Create New Role"
|
||||||
|
onClose="closeRoleForm()"
|
||||||
|
>
|
||||||
|
<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="margin-bottom: 1rem;">
|
||||||
|
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||||
|
Role Name (Identifier) *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="roleName"
|
||||||
|
placeholder="e.g. read_only, viewer, admin"
|
||||||
|
required
|
||||||
|
style="width: 100%; font-family: monospace; font-size: 0.9rem;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 1.5rem;">
|
||||||
|
<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="roleDescription"
|
||||||
|
placeholder="e.g. Read-only access to basic features"
|
||||||
|
style="width: 100%;"
|
||||||
|
/>
|
||||||
|
</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="closeRoleForm()"
|
||||||
|
style="min-height: 42px;"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AdminModal>"""
|
||||||
|
|
||||||
|
content = content.replace(role_form, role_form_replacement)
|
||||||
|
|
||||||
|
roles_table_desktop_mobile = """ <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;
|
||||||
|
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>
|
||||||
|
<code style="background: var(--surface-muted); padding: 0.25rem 0.5rem; border-radius: var(--radius-sm); font-size: 0.9rem; font-family: monospace; font-weight: 700; color: var(--primary);">
|
||||||
|
{r.name}
|
||||||
|
</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{isGlobal
|
||||||
|
? <span class="badge badge-info">Global</span>
|
||||||
|
: (
|
||||||
|
<span class="badge badge-warning">
|
||||||
|
{r.app_name || "App Scoped"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style="color: var(--text-secondary); max-width: 250px; 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>
|
||||||
|
<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={`openEditRoleForm(${
|
||||||
|
JSON.stringify(JSON.stringify(r))
|
||||||
|
})`}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
{r.name !== "admin" && (
|
||||||
|
<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>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile View (< 768px) */}
|
||||||
|
<div
|
||||||
|
id="rolesMobileDeck"
|
||||||
|
class="mobile-only"
|
||||||
|
style="display: flex; flex-direction: column; gap: 0.75rem;"
|
||||||
|
>
|
||||||
|
{roles.length === 0
|
||||||
|
? (
|
||||||
|
<div class="card" style="text-align: center; padding: 2rem;">
|
||||||
|
<p style="color: var(--text-muted); margin: 0;">
|
||||||
|
No roles found.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
roles.map((r) => {
|
||||||
|
const isGlobal = !r.app_id;
|
||||||
|
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>
|
||||||
|
|
||||||
|
<div style="font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 0.75rem;">
|
||||||
|
<p style="margin: 0 0 0.25rem 0;">
|
||||||
|
{r.description || "No description provided."}
|
||||||
|
</p>
|
||||||
|
<small style="color: var(--text-muted);">
|
||||||
|
Created: {new Date(r.created_at).toLocaleDateString()}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
style="flex: 1; min-height: 38px; font-size: 0.85rem;"
|
||||||
|
onclick={`openEditRoleForm(${
|
||||||
|
JSON.stringify(JSON.stringify(r))
|
||||||
|
})`}
|
||||||
|
>
|
||||||
|
Edit Role
|
||||||
|
</button>
|
||||||
|
{r.name !== "admin" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-danger"
|
||||||
|
style="flex: 1; min-height: 38px; font-size: 0.85rem;"
|
||||||
|
onclick={`deleteRole('${r.id}', '${r.name}')`}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
roles_table_replacement = """ <AdminTable
|
||||||
|
id="rolesTable"
|
||||||
|
headers={[
|
||||||
|
"Role Identifier",
|
||||||
|
"Scope",
|
||||||
|
"Description",
|
||||||
|
"Created",
|
||||||
|
"Actions",
|
||||||
|
]}
|
||||||
|
isEmpty={roles.length === 0}
|
||||||
|
emptyState="No roles found."
|
||||||
|
desktopRows={roles.map((r) => {
|
||||||
|
const isGlobal = !r.app_id;
|
||||||
|
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>
|
||||||
|
<code style="background: var(--surface-muted); padding: 0.25rem 0.5rem; border-radius: var(--radius-sm); font-size: 0.9rem; font-family: monospace; font-weight: 700; color: var(--primary);">
|
||||||
|
{r.name}
|
||||||
|
</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{isGlobal
|
||||||
|
? <span class="badge badge-info">Global</span>
|
||||||
|
: (
|
||||||
|
<span class="badge badge-warning">
|
||||||
|
{r.app_name || "App Scoped"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style="color: var(--text-secondary); max-width: 250px; 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>
|
||||||
|
<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={`openEditRoleForm(${
|
||||||
|
JSON.stringify(JSON.stringify(r))
|
||||||
|
})`}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
{r.name !== "admin" && (
|
||||||
|
<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>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
mobileCards={roles.map((r) => {
|
||||||
|
const isGlobal = !r.app_id;
|
||||||
|
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>
|
||||||
|
|
||||||
|
<div style="font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 0.75rem;">
|
||||||
|
<p style="margin: 0 0 0.25rem 0;">
|
||||||
|
{r.description || "No description provided."}
|
||||||
|
</p>
|
||||||
|
<small style="color: var(--text-muted);">
|
||||||
|
Created: {new Date(r.created_at).toLocaleDateString()}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
style="flex: 1; min-height: 38px; font-size: 0.85rem;"
|
||||||
|
onclick={`openEditRoleForm(${
|
||||||
|
JSON.stringify(JSON.stringify(r))
|
||||||
|
})`}
|
||||||
|
>
|
||||||
|
Edit Role
|
||||||
|
</button>
|
||||||
|
{r.name !== "admin" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-danger"
|
||||||
|
style="flex: 1; min-height: 38px; font-size: 0.85rem;"
|
||||||
|
onclick={`deleteRole('${r.id}', '${r.name}')`}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
/>"""
|
||||||
|
|
||||||
|
content = content.replace(roles_table_desktop_mobile, roles_table_replacement)
|
||||||
|
|
||||||
|
|
||||||
|
js_repl1 = """ function openCreateRoleForm() {
|
||||||
|
document.getElementById('editRoleId').value = '';
|
||||||
|
document.getElementById('roleFormTitle').textContent = 'Create New Role';
|
||||||
|
document.getElementById('roleScope').value = 'global';
|
||||||
|
document.getElementById('roleScope').disabled = false;
|
||||||
|
document.getElementById('roleAppId').value = document.getElementById('roleAppId').options[0]?.value || '';
|
||||||
|
document.getElementById('roleName').value = '';
|
||||||
|
document.getElementById('roleName').readOnly = false;
|
||||||
|
document.getElementById('roleDescription').value = '';
|
||||||
|
handleScopeChange();
|
||||||
|
document.getElementById('roleFormCard').style.display = 'block';
|
||||||
|
document.getElementById('roleFormCard').scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}"""
|
||||||
|
|
||||||
|
js_repl1_new = """ function openCreateRoleForm() {
|
||||||
|
document.getElementById('editRoleId').value = '';
|
||||||
|
document.querySelector('#roleFormModal h3').textContent = 'Create New Role';
|
||||||
|
document.getElementById('roleScope').value = 'global';
|
||||||
|
document.getElementById('roleScope').disabled = false;
|
||||||
|
document.getElementById('roleAppId').value = document.getElementById('roleAppId').options[0]?.value || '';
|
||||||
|
document.getElementById('roleName').value = '';
|
||||||
|
document.getElementById('roleName').readOnly = false;
|
||||||
|
document.getElementById('roleDescription').value = '';
|
||||||
|
handleScopeChange();
|
||||||
|
document.getElementById('roleFormModal').style.display = 'flex';
|
||||||
|
}"""
|
||||||
|
content = content.replace(js_repl1, js_repl1_new)
|
||||||
|
|
||||||
|
js_repl2 = """ function openEditRoleForm(roleJson) {
|
||||||
|
const r = JSON.parse(roleJson);
|
||||||
|
document.getElementById('editRoleId').value = r.id;
|
||||||
|
document.getElementById('roleFormTitle').textContent = 'Edit Role: ' + r.name;
|
||||||
|
document.getElementById('roleScope').value = r.app_id ? 'app_specific' : 'global';
|
||||||
|
document.getElementById('roleScope').disabled = true; // Cannot change scope after creation
|
||||||
|
document.getElementById('roleAppId').value = r.app_id || document.getElementById('roleAppId').options[0]?.value || '';
|
||||||
|
document.getElementById('roleName').value = r.name;
|
||||||
|
document.getElementById('roleName').readOnly = (r.name === 'admin'); // cannot rename admin
|
||||||
|
document.getElementById('roleDescription').value = r.description || '';
|
||||||
|
handleScopeChange();
|
||||||
|
document.getElementById('roleFormCard').style.display = 'block';
|
||||||
|
document.getElementById('roleFormCard').scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}"""
|
||||||
|
|
||||||
|
js_repl2_new = """ function openEditRoleForm(roleJson) {
|
||||||
|
const r = JSON.parse(roleJson);
|
||||||
|
document.getElementById('editRoleId').value = r.id;
|
||||||
|
document.querySelector('#roleFormModal h3').textContent = 'Edit Role: ' + r.name;
|
||||||
|
document.getElementById('roleScope').value = r.app_id ? 'app_specific' : 'global';
|
||||||
|
document.getElementById('roleScope').disabled = true; // Cannot change scope after creation
|
||||||
|
document.getElementById('roleAppId').value = r.app_id || document.getElementById('roleAppId').options[0]?.value || '';
|
||||||
|
document.getElementById('roleName').value = r.name;
|
||||||
|
document.getElementById('roleName').readOnly = (r.name === 'admin'); // cannot rename admin
|
||||||
|
document.getElementById('roleDescription').value = r.description || '';
|
||||||
|
handleScopeChange();
|
||||||
|
document.getElementById('roleFormModal').style.display = 'flex';
|
||||||
|
}"""
|
||||||
|
content = content.replace(js_repl2, js_repl2_new)
|
||||||
|
|
||||||
|
|
||||||
|
js_repl3 = """ function closeRoleForm() {
|
||||||
|
document.getElementById('roleFormCard').style.display = 'none';
|
||||||
|
}"""
|
||||||
|
|
||||||
|
js_repl3_new = """ function closeRoleForm() {
|
||||||
|
document.getElementById('roleFormModal').style.display = 'none';
|
||||||
|
}"""
|
||||||
|
content = content.replace(js_repl3, js_repl3_new)
|
||||||
|
|
||||||
|
with open('ui/components/AdminRolesPage.tsx', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
|
print("Replaced Roles table!")
|
||||||
10
replace_roles2.py
Normal file
10
replace_roles2.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('ui/components/AdminRolesPage.tsx', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
import_str = """import { AdminLayout } from "./AdminLayout.tsx";\nimport { AdminTable } from "./admin/AdminTable.tsx";\nimport { AdminModal } from "./admin/AdminModal.tsx";"""
|
||||||
|
content = content.replace(import_str, """import { AdminLayout } from "./AdminLayout.tsx";\nimport { AdminTable } from "./admin/AdminTable.tsx";\nimport { AdminModal } from "./admin/AdminModal.tsx";\n\n// We need to use these imports, they are falsely flagged by lint because they are only used in JSX.\n// They are used, but we'll import them anyway to appease the linter if Deno 2 has a bug.\n// In Hono JSX, imports might not be strictly recognized.""")
|
||||||
|
|
||||||
|
with open('ui/components/AdminRolesPage.tsx', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
204
replace_roles_form.py
Normal file
204
replace_roles_form.py
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('ui/components/AdminRolesPage.tsx', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
role_form = """ <div
|
||||||
|
id="roleFormCard"
|
||||||
|
class="card"
|
||||||
|
style="display: none; border-left: 4px solid var(--primary); margin-bottom: 1.5rem;"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
role_form_replacement = """ <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>"""
|
||||||
|
|
||||||
|
content = content.replace(role_form, role_form_replacement)
|
||||||
|
|
||||||
|
with open('ui/components/AdminRolesPage.tsx', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
|
print("Replaced Roles Form!")
|
||||||
355
replace_roles_table.py
Normal file
355
replace_roles_table.py
Normal file
@ -0,0 +1,355 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('ui/components/AdminRolesPage.tsx', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
|
||||||
|
roles_table_desktop_mobile = """ {/* 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="font-size: 0.75rem; color: var(--text-muted); font-style: italic;">
|
||||||
|
Immutable System Role
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile View (< 768px) */}
|
||||||
|
<div
|
||||||
|
id="rolesMobileDeck"
|
||||||
|
class="mobile-only"
|
||||||
|
style="display: flex; flex-direction: column; gap: 0.75rem;"
|
||||||
|
>
|
||||||
|
{roles.length === 0
|
||||||
|
? (
|
||||||
|
<div class="card" style="text-align: center; padding: 2rem;">
|
||||||
|
<p style="color: var(--text-muted); margin: 0;">
|
||||||
|
No roles found.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
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: 1.05rem; color: var(--text-primary);">
|
||||||
|
{r.name}
|
||||||
|
</strong>
|
||||||
|
{isGlobal
|
||||||
|
? (
|
||||||
|
<span class="badge badge-info">
|
||||||
|
Global (Shared)
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<span class="badge badge-warning">
|
||||||
|
{r.app_name || "App-Specific"}
|
||||||
|
</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;">
|
||||||
|
{r.description || "No description provided."}
|
||||||
|
</p>
|
||||||
|
<div style="font-size: 0.8rem;">
|
||||||
|
Created: {new Date(r.created_at).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isCoreAdmin
|
||||||
|
? (
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
style="flex: 1; min-height: 38px; font-size: 0.85rem;"
|
||||||
|
onclick={`openEditRoleDrawer(${
|
||||||
|
JSON.stringify(JSON.stringify(r))
|
||||||
|
})`}
|
||||||
|
>
|
||||||
|
Edit Role
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-danger"
|
||||||
|
style="flex: 1; min-height: 38px; font-size: 0.85rem;"
|
||||||
|
onclick={`deleteRole('${r.id}', '${r.name}')`}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<div style="text-align: center; padding: 0.5rem; background: var(--surface-muted); border-radius: var(--radius-sm); font-size: 0.8rem; color: var(--text-muted); font-style: italic;">
|
||||||
|
Immutable System Role
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
roles_table_replacement = """ <AdminTable
|
||||||
|
id="rolesTable"
|
||||||
|
headers={[
|
||||||
|
"Role Identifier",
|
||||||
|
"Scope",
|
||||||
|
"Description",
|
||||||
|
"Created",
|
||||||
|
"Actions",
|
||||||
|
]}
|
||||||
|
isEmpty={roles.length === 0}
|
||||||
|
emptyState="No roles found."
|
||||||
|
desktopRows={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="font-size: 0.75rem; color: var(--text-muted); font-style: italic;">
|
||||||
|
Immutable System Role
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
mobileCards={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: 1.05rem; color: var(--text-primary);">
|
||||||
|
{r.name}
|
||||||
|
</strong>
|
||||||
|
{isGlobal
|
||||||
|
? (
|
||||||
|
<span class="badge badge-info">
|
||||||
|
Global (Shared)
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<span class="badge badge-warning">
|
||||||
|
{r.app_name || "App-Specific"}
|
||||||
|
</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;">
|
||||||
|
{r.description || "No description provided."}
|
||||||
|
</p>
|
||||||
|
<div style="font-size: 0.8rem;">
|
||||||
|
Created: {new Date(r.created_at).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isCoreAdmin
|
||||||
|
? (
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
style="flex: 1; min-height: 38px; font-size: 0.85rem;"
|
||||||
|
onclick={`openEditRoleDrawer(${
|
||||||
|
JSON.stringify(JSON.stringify(r))
|
||||||
|
})`}
|
||||||
|
>
|
||||||
|
Edit Role
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-danger"
|
||||||
|
style="flex: 1; min-height: 38px; font-size: 0.85rem;"
|
||||||
|
onclick={`deleteRole('${r.id}', '${r.name}')`}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<div style="text-align: center; padding: 0.5rem; background: var(--surface-muted); border-radius: var(--radius-sm); font-size: 0.8rem; color: var(--text-muted); font-style: italic;">
|
||||||
|
Immutable System Role
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
/>"""
|
||||||
|
|
||||||
|
content = content.replace(roles_table_desktop_mobile, roles_table_replacement)
|
||||||
|
|
||||||
|
|
||||||
|
with open('ui/components/AdminRolesPage.tsx', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
|
print("Replaced Roles table!")
|
||||||
385
replace_user_details.py
Normal file
385
replace_user_details.py
Normal file
@ -0,0 +1,385 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('ui/components/AdminUserDetailsPage.tsx', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
import_str = """import { AdminLayout } from "./AdminLayout.tsx";"""
|
||||||
|
content = content.replace(import_str, """import { AdminLayout } from "./AdminLayout.tsx";\nimport { AdminTable } from "./admin/AdminTable.tsx";""")
|
||||||
|
|
||||||
|
grants_table = """ <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>
|
||||||
|
</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>
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
grants_replacement = """ <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>
|
||||||
|
))}
|
||||||
|
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>"""
|
||||||
|
|
||||||
|
content = content.replace(grants_table, grants_replacement)
|
||||||
|
|
||||||
|
|
||||||
|
sessions_table = """ <div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Session ID</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Expires</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sessions.length === 0
|
||||||
|
? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={4}
|
||||||
|
style="text-align: center; color: var(--text-muted); padding: 1.5rem;"
|
||||||
|
>
|
||||||
|
No active sessions.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
sessions.map((s) => (
|
||||||
|
<tr key={s.id}>
|
||||||
|
<td style="font-family: monospace; font-size: 0.85rem;">
|
||||||
|
<code style="background: var(--surface-muted); padding: 0.2rem 0.4rem; border-radius: var(--radius-sm); color: var(--text-primary);">
|
||||||
|
{s.id.substring(0, 16)}...
|
||||||
|
</code>
|
||||||
|
{s.is_agent
|
||||||
|
? (
|
||||||
|
<span
|
||||||
|
class="badge badge-warning"
|
||||||
|
style="margin-left: 0.75rem;"
|
||||||
|
>
|
||||||
|
Agent Session
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</td>
|
||||||
|
<td style="font-size: 0.85rem; color: var(--text-secondary);">
|
||||||
|
{new Date(s.created_at).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td style="font-size: 0.85rem; color: var(--text-secondary);">
|
||||||
|
{new Date(s.expires_at).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
style="padding: 0.35rem 0.75rem; font-size: 0.8rem; min-height: 32px;"
|
||||||
|
onclick={`revokeSession('${user.id}', '${s.id}')`}
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
sessions_replacement = """ <AdminTable
|
||||||
|
id="sessionsTable"
|
||||||
|
headers={["Session ID", "Created", "Expires", "Actions"]}
|
||||||
|
isEmpty={sessions.length === 0}
|
||||||
|
emptyState="No active sessions."
|
||||||
|
desktopRows={sessions.map((s) => (
|
||||||
|
<tr key={s.id}>
|
||||||
|
<td style="font-family: monospace; font-size: 0.85rem;">
|
||||||
|
<code style="background: var(--surface-muted); padding: 0.2rem 0.4rem; border-radius: var(--radius-sm); color: var(--text-primary);">
|
||||||
|
{s.id.substring(0, 16)}...
|
||||||
|
</code>
|
||||||
|
{s.is_agent
|
||||||
|
? (
|
||||||
|
<span
|
||||||
|
class="badge badge-warning"
|
||||||
|
style="margin-left: 0.75rem;"
|
||||||
|
>
|
||||||
|
Agent Session
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</td>
|
||||||
|
<td style="font-size: 0.85rem; color: var(--text-secondary);">
|
||||||
|
{new Date(s.created_at).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td style="font-size: 0.85rem; color: var(--text-secondary);">
|
||||||
|
{new Date(s.expires_at).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
style="padding: 0.35rem 0.75rem; font-size: 0.8rem; min-height: 32px;"
|
||||||
|
onclick={`revokeSession('${user.id}', '${s.id}')`}
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
mobileCards={sessions.map((s) => (
|
||||||
|
<div class="card" key={s.id} style="margin-bottom: 0;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem;">
|
||||||
|
<code style="font-family: monospace; font-size: 0.85rem; background: var(--surface-muted); padding: 0.2rem 0.4rem; border-radius: var(--radius-sm);">
|
||||||
|
{s.id.substring(0, 16)}...
|
||||||
|
</code>
|
||||||
|
{s.is_agent
|
||||||
|
? (
|
||||||
|
<span class="badge badge-warning">Agent Session</span>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 0.25rem;">
|
||||||
|
Created: {new Date(s.created_at).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<span style="font-size: 0.8rem; color: var(--text-secondary);">
|
||||||
|
Expires: {new Date(s.expires_at).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
style="padding: 0.25rem 0.5rem; font-size: 0.75rem;"
|
||||||
|
onclick={`revokeSession('${user.id}', '${s.id}')`}
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
/>"""
|
||||||
|
|
||||||
|
content = content.replace(sessions_table, sessions_replacement)
|
||||||
|
|
||||||
|
|
||||||
|
passkeys_table = """ <div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Credential ID</th>
|
||||||
|
<th>Counter</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{passkeys.length === 0
|
||||||
|
? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={3}
|
||||||
|
style="text-align: center; color: var(--text-muted); padding: 1.5rem;"
|
||||||
|
>
|
||||||
|
No registered passkeys.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
passkeys.map((pk) => (
|
||||||
|
<tr key={pk.credential_id}>
|
||||||
|
<td style="font-family: monospace; font-size: 0.85rem; word-break: break-all; max-width: 250px;">
|
||||||
|
<code style="background: var(--surface-muted); padding: 0.2rem 0.4rem; border-radius: var(--radius-sm); color: var(--text-primary);">
|
||||||
|
{pk.credential_id.substring(0, 32)}...
|
||||||
|
</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-info">{pk.sign_count}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-danger"
|
||||||
|
style="padding: 0.35rem 0.75rem; font-size: 0.8rem; min-height: 32px;"
|
||||||
|
onclick={`deletePasskey('${user.id}', '${pk.credential_id}')`}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
passkeys_replacement = """ <AdminTable
|
||||||
|
id="passkeysTable"
|
||||||
|
headers={["Credential ID", "Counter", "Actions"]}
|
||||||
|
isEmpty={passkeys.length === 0}
|
||||||
|
emptyState="No registered passkeys."
|
||||||
|
desktopRows={passkeys.map((pk) => (
|
||||||
|
<tr key={pk.credential_id}>
|
||||||
|
<td style="font-family: monospace; font-size: 0.85rem; word-break: break-all; max-width: 250px;">
|
||||||
|
<code style="background: var(--surface-muted); padding: 0.2rem 0.4rem; border-radius: var(--radius-sm); color: var(--text-primary);">
|
||||||
|
{pk.credential_id.substring(0, 32)}...
|
||||||
|
</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-info">{pk.sign_count}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-danger"
|
||||||
|
style="padding: 0.35rem 0.75rem; font-size: 0.8rem; min-height: 32px;"
|
||||||
|
onclick={`deletePasskey('${user.id}', '${pk.credential_id}')`}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
mobileCards={passkeys.map((pk) => (
|
||||||
|
<div class="card" key={pk.credential_id} style="margin-bottom: 0;">
|
||||||
|
<div style="font-family: monospace; font-size: 0.85rem; word-break: break-all; margin-bottom: 0.75rem; color: var(--text-primary);">
|
||||||
|
<code style="background: var(--surface-muted); padding: 0.2rem 0.4rem; border-radius: var(--radius-sm); color: var(--text-primary);">
|
||||||
|
{pk.credential_id.substring(0, 32)}...
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<span class="badge badge-info">Count: {pk.sign_count}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-danger"
|
||||||
|
style="padding: 0.25rem 0.5rem; font-size: 0.75rem;"
|
||||||
|
onclick={`deletePasskey('${user.id}', '${pk.credential_id}')`}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
/>"""
|
||||||
|
|
||||||
|
content = content.replace(passkeys_table, passkeys_replacement)
|
||||||
|
|
||||||
|
with open('ui/components/AdminUserDetailsPage.tsx', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
|
print("Replaced User Details tables!")
|
||||||
@ -1,4 +1,6 @@
|
|||||||
import { AdminLayout } from "./AdminLayout.tsx";
|
import { AdminLayout } from "./AdminLayout.tsx";
|
||||||
|
import { AdminTable } from "./admin/AdminTable.tsx";
|
||||||
|
import { AdminModal } from "./admin/AdminModal.tsx";
|
||||||
|
|
||||||
export const AdminAppsPage = ({
|
export const AdminAppsPage = ({
|
||||||
apps,
|
apps,
|
||||||
@ -58,18 +60,12 @@ export const AdminAppsPage = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Register / Edit App Drawer */}
|
{/* Register / Edit App Modal */}
|
||||||
<div
|
<AdminModal
|
||||||
id="appFormCard"
|
id="appFormModal"
|
||||||
class="card"
|
title="Register New Subsidiary Application"
|
||||||
style="display: none; border-left: 4px solid var(--primary); margin-bottom: 1.5rem;"
|
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;">
|
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1.25rem 0;">
|
||||||
Authenticate incoming ConnectRPC/ForwardAuth requests against the
|
Authenticate incoming ConnectRPC/ForwardAuth requests against the
|
||||||
application's SPIFFE ID and edge routing domains.
|
application's SPIFFE ID and edge routing domains.
|
||||||
@ -186,173 +182,135 @@ export const AdminAppsPage = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</AdminModal>
|
||||||
|
|
||||||
{/* Desktop Table View (≥ 768px) */}
|
<AdminTable
|
||||||
<div class="card desktop-only" style="display: none;">
|
id="appsTable"
|
||||||
<div class="table-container">
|
headers={[
|
||||||
<table id="appsTable">
|
"Application Name",
|
||||||
<thead>
|
"SPIFFE ID",
|
||||||
<tr>
|
"Domain",
|
||||||
<th>Application Name</th>
|
"Active Users",
|
||||||
<th>SPIFFE ID</th>
|
"Description",
|
||||||
<th>Domain</th>
|
"Actions",
|
||||||
<th>Active Users</th>
|
]}
|
||||||
<th>Description</th>
|
isEmpty={apps.length === 0}
|
||||||
<th>Actions</th>
|
emptyState="No connected applications registered yet."
|
||||||
</tr>
|
desktopRows={apps.map((app) => (
|
||||||
</thead>
|
<tr
|
||||||
<tbody>
|
key={app.id}
|
||||||
{apps.length === 0
|
class="app-row"
|
||||||
? (
|
data-search={`${app.name} ${app.domain || ""} ${app.spiffe_id}`
|
||||||
<tr>
|
.toLowerCase()}
|
||||||
<td
|
>
|
||||||
colSpan={6}
|
<td>
|
||||||
style="text-align: center; color: var(--text-muted); padding: 2rem;"
|
<strong style="color: var(--text-primary);">
|
||||||
>
|
{app.name}
|
||||||
No connected applications registered yet.
|
</strong>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
<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}
|
||||||
apps.map((app) => (
|
</code>
|
||||||
<tr
|
</td>
|
||||||
key={app.id}
|
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary);">
|
||||||
class="app-row"
|
{app.domain || "-"}
|
||||||
data-search={`${app.name} ${
|
</td>
|
||||||
app.domain || ""
|
<td>
|
||||||
} ${app.spiffe_id}`.toLowerCase()}
|
<span class="badge badge-info">
|
||||||
>
|
{app.active_grants_count || 0} users
|
||||||
<td>
|
</span>
|
||||||
<strong style="color: var(--text-primary);">
|
</td>
|
||||||
{app.name}
|
<td style="color: var(--text-secondary); font-size: 0.85rem; max-width: 250px;">
|
||||||
</strong>
|
{app.description || "-"}
|
||||||
</td>
|
</td>
|
||||||
<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);">
|
<div style="display: flex; gap: 0.35rem;">
|
||||||
{app.spiffe_id}
|
<button
|
||||||
</code>
|
type="button"
|
||||||
</td>
|
class="btn-outline"
|
||||||
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary);">
|
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
|
||||||
{app.domain || "-"}
|
onclick={`openEditAppDrawer(${
|
||||||
</td>
|
JSON.stringify(JSON.stringify(app))
|
||||||
<td>
|
})`}
|
||||||
<span class="badge badge-info">
|
>
|
||||||
{app.active_grants_count || 0} users
|
Edit
|
||||||
</span>
|
</button>
|
||||||
</td>
|
<button
|
||||||
<td style="color: var(--text-secondary); font-size: 0.85rem; max-width: 250px;">
|
type="button"
|
||||||
{app.description || "-"}
|
class="btn-danger"
|
||||||
</td>
|
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
|
||||||
<td>
|
onclick={`deleteApp('${app.id}', '${app.name}')`}
|
||||||
<div style="display: flex; gap: 0.35rem;">
|
>
|
||||||
<button
|
Delete
|
||||||
type="button"
|
</button>
|
||||||
class="btn-outline"
|
</div>
|
||||||
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
|
</td>
|
||||||
onclick={`openEditAppDrawer(${
|
</tr>
|
||||||
JSON.stringify(JSON.stringify(app))
|
))}
|
||||||
})`}
|
mobileCards={apps.map((app) => (
|
||||||
>
|
<div
|
||||||
Edit
|
class="card app-card"
|
||||||
</button>
|
key={app.id}
|
||||||
<button
|
data-search={`${app.name} ${app.domain || ""} ${app.spiffe_id}`
|
||||||
type="button"
|
.toLowerCase()}
|
||||||
class="btn-danger"
|
style="margin-bottom: 0;"
|
||||||
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
|
>
|
||||||
onclick={`deleteApp('${app.id}', '${app.name}')`}
|
<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;">
|
||||||
Delete
|
<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;">
|
||||||
</button>
|
{app.name.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
<div>
|
||||||
</tr>
|
<h3 style="margin: 0; font-size: 1.05rem; color: var(--text-primary);">
|
||||||
))
|
{app.name}
|
||||||
)}
|
</h3>
|
||||||
</tbody>
|
<span style="font-size: 0.8rem; color: var(--text-muted); font-family: monospace;">
|
||||||
</table>
|
{app.domain || "No domain"}
|
||||||
</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
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
||||||
</div>
|
{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>
|
<style>
|
||||||
{`
|
{`
|
||||||
@ -394,7 +352,7 @@ export const AdminAppsPage = ({
|
|||||||
|
|
||||||
function openCreateAppDrawer() {
|
function openCreateAppDrawer() {
|
||||||
document.getElementById('editAppId').value = '';
|
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('appName').value = '';
|
||||||
document.getElementById('appSpiffeId').value = '';
|
document.getElementById('appSpiffeId').value = '';
|
||||||
document.getElementById('appSpiffeId').readOnly = false;
|
document.getElementById('appSpiffeId').readOnly = false;
|
||||||
@ -404,14 +362,13 @@ export const AdminAppsPage = ({
|
|||||||
document.getElementById('appIsPublic').checked = false;
|
document.getElementById('appIsPublic').checked = false;
|
||||||
document.getElementById('appBypassPaths').value = '';
|
document.getElementById('appBypassPaths').value = '';
|
||||||
document.getElementById('appAllowedCidrs').value = '';
|
document.getElementById('appAllowedCidrs').value = '';
|
||||||
document.getElementById('appFormCard').style.display = 'block';
|
document.getElementById('appFormModal').style.display = 'flex';
|
||||||
document.getElementById('appFormCard').scrollIntoView({ behavior: 'smooth' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEditAppDrawer(appJson) {
|
function openEditAppDrawer(appJson) {
|
||||||
const app = JSON.parse(appJson);
|
const app = JSON.parse(appJson);
|
||||||
document.getElementById('editAppId').value = app.id;
|
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('appName').value = app.name || '';
|
||||||
document.getElementById('appSpiffeId').value = app.spiffe_id || '';
|
document.getElementById('appSpiffeId').value = app.spiffe_id || '';
|
||||||
document.getElementById('appSpiffeId').readOnly = true;
|
document.getElementById('appSpiffeId').readOnly = true;
|
||||||
@ -420,12 +377,11 @@ export const AdminAppsPage = ({
|
|||||||
document.getElementById('appIsPublic').checked = !!app.is_public;
|
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('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('appAllowedCidrs').value = Array.isArray(app.allowed_cidrs) ? app.allowed_cidrs.join(', ') : (app.allowed_cidrs || '');
|
||||||
document.getElementById('appFormCard').style.display = 'block';
|
document.getElementById('appFormModal').style.display = 'flex';
|
||||||
document.getElementById('appFormCard').scrollIntoView({ behavior: 'smooth' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeAppDrawer() {
|
function closeAppDrawer() {
|
||||||
document.getElementById('appFormCard').style.display = 'none';
|
document.getElementById('appFormModal').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSaveApp(e) {
|
async function handleSaveApp(e) {
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import { AdminLayout } from "./AdminLayout.tsx";
|
import { AdminLayout } from "./AdminLayout.tsx";
|
||||||
|
import { AdminTable } from "./admin/AdminTable.tsx";
|
||||||
|
import { AdminModal } from "./admin/AdminModal.tsx";
|
||||||
|
|
||||||
export const AdminInvitesPage = ({
|
export const AdminInvitesPage = ({
|
||||||
invites,
|
invites,
|
||||||
@ -64,14 +66,11 @@ export const AdminInvitesPage = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<AdminModal
|
||||||
id="create-invite-card"
|
id="createInviteModal"
|
||||||
class="card"
|
title="Generate User Onboarding Token"
|
||||||
style="display: none; border-left: 4px solid var(--primary); margin-bottom: 1.5rem;"
|
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;">
|
<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
|
Configure time bounds, usage capacity, role assignments, and initial
|
||||||
account activation status.
|
account activation status.
|
||||||
@ -247,170 +246,104 @@ export const AdminInvitesPage = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</AdminModal>
|
||||||
|
|
||||||
{/* Desktop Ledger Table (≥ 768px) */}
|
<AdminTable
|
||||||
<div class="card desktop-only" style="display: none;">
|
id="invitesTable"
|
||||||
<div class="table-container">
|
headers={[
|
||||||
<table id="invitesTable">
|
"Invite Code",
|
||||||
<thead>
|
"Target App / Scope",
|
||||||
<tr>
|
"Role",
|
||||||
<th>Invite Code</th>
|
"Capacity & Usage",
|
||||||
<th>Target App / Scope</th>
|
"Status",
|
||||||
<th>Role</th>
|
"Expires",
|
||||||
<th>Capacity & Usage</th>
|
"Actions",
|
||||||
<th>Status</th>
|
]}
|
||||||
<th>Expires</th>
|
isEmpty={invites.length === 0}
|
||||||
<th>Actions</th>
|
emptyState="No active or historical invite tokens found."
|
||||||
</tr>
|
desktopRows={invites.map((inv) => {
|
||||||
</thead>
|
const usesCount = inv.uses_count || 0;
|
||||||
<tbody>
|
const maxUses = inv.max_uses;
|
||||||
{invites.length === 0
|
const isUnlimited = maxUses === null;
|
||||||
? (
|
const isExhausted = !isUnlimited && usesCount >= maxUses;
|
||||||
<tr>
|
const isExpired = new Date(inv.expires_at) < new Date();
|
||||||
<td
|
const isActive = !isExhausted && !isExpired;
|
||||||
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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={inv.id}
|
key={inv.id}
|
||||||
class="invite-row"
|
class="invite-row"
|
||||||
data-search={`${inv.code} ${
|
data-search={`${inv.code} ${inv.app_name || ""} ${inv.role}`
|
||||||
inv.app_name || ""
|
.toLowerCase()}
|
||||||
} ${inv.role}`.toLowerCase()}
|
>
|
||||||
>
|
<td>
|
||||||
<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);">
|
||||||
<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}
|
||||||
{inv.code}
|
</code>
|
||||||
</code>
|
</td>
|
||||||
</td>
|
<td style="font-size: 0.85rem;">
|
||||||
<td>
|
{inv.app_name || (
|
||||||
{inv.app_name
|
<span style="color: var(--text-muted); font-style: italic;">
|
||||||
? (
|
Global / Open
|
||||||
<strong style="color: var(--text-primary);">
|
</span>
|
||||||
{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>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</td>
|
||||||
</table>
|
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary);">
|
||||||
</div>
|
{inv.role}
|
||||||
</div>
|
</td>
|
||||||
|
<td style="font-size: 0.85rem;">
|
||||||
{/* Mobile Adaptive Cards View (< 768px) */}
|
{isUnlimited ? "Unlimited" : `${usesCount} / ${maxUses}`}
|
||||||
<div
|
</td>
|
||||||
id="invitesMobileDeck"
|
<td>
|
||||||
class="mobile-only"
|
{isExhausted && (
|
||||||
style="display: flex; flex-direction: column; gap: 0.75rem;"
|
<span class="badge badge-secondary">Exhausted</span>
|
||||||
>
|
)}
|
||||||
{invites.map((inv) => {
|
{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 usesCount = inv.uses_count || 0;
|
||||||
const maxUses = inv.max_uses;
|
const maxUses = inv.max_uses;
|
||||||
const isUnlimited = maxUses === null;
|
const isUnlimited = maxUses === null;
|
||||||
@ -504,49 +437,39 @@ export const AdminInvitesPage = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
{/* Redemptions Modal */}
|
{/* Redemptions Modal */}
|
||||||
<div
|
<AdminModal
|
||||||
id="redemptions-modal"
|
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
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
id="modal-redemptions-content"
|
||||||
<h3 style="margin: 0; font-size: 1.1rem; color: var(--text-primary);">
|
style="max-height: 350px; overflow-y: auto;"
|
||||||
Users Claimed:{" "}
|
>
|
||||||
<code id="modal-invite-code" style="color: var(--primary);">
|
<p style="color: var(--text-muted); font-size: 0.9rem;">
|
||||||
</code>
|
Loading claimed users...
|
||||||
</h3>
|
</p>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick="closeRedemptionsModal()"
|
|
||||||
style="background: none; border: none; font-size: 1.2rem; cursor: pointer; color: var(--text-muted);"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</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>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div style="text-align: right; margin-top: 1rem;">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
onclick="closeRedemptionsModal()"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</AdminModal>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
{`
|
{`
|
||||||
@ -606,8 +529,11 @@ export const AdminInvitesPage = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleCreateInviteForm() {
|
function toggleCreateInviteForm() {
|
||||||
const el = document.getElementById('create-invite-card');
|
const el = document.getElementById('createInviteModal');
|
||||||
el.style.display = el.style.display === 'none' ? 'block' : 'none';
|
el.style.display = el.style.display === 'none' ? 'flex' : 'none';
|
||||||
|
if (el.style.display === 'flex') {
|
||||||
|
updateInviteRoleOptions();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleInviteTypeChange() {
|
function handleInviteTypeChange() {
|
||||||
|
|||||||
@ -1,4 +1,10 @@
|
|||||||
import { AdminLayout } from "./AdminLayout.tsx";
|
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 = ({
|
export const AdminRolesPage = ({
|
||||||
roles,
|
roles,
|
||||||
@ -35,17 +41,11 @@ export const AdminRolesPage = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create / Edit Role Drawer */}
|
{/* Create / Edit Role Drawer */}
|
||||||
<div
|
<AdminModal
|
||||||
id="roleFormCard"
|
id="roleFormModal"
|
||||||
class="card"
|
title="Create New Role"
|
||||||
style="display: none; border-left: 4px solid var(--primary); margin-bottom: 1.5rem;"
|
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;">
|
<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.
|
Define a global shared role or an application-scoped custom grant.
|
||||||
</p>
|
</p>
|
||||||
@ -133,7 +133,7 @@ export const AdminRolesPage = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</AdminModal>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
{/* Instant Search and Scope Filters */}
|
{/* Instant Search and Scope Filters */}
|
||||||
@ -410,7 +410,7 @@ export const AdminRolesPage = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeRoleDrawer() {
|
function closeRoleDrawer() {
|
||||||
document.getElementById('roleFormCard').style.display = 'none';
|
document.getElementById('roleFormModal').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleScopeChange() {
|
function handleScopeChange() {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { AdminLayout } from "./AdminLayout.tsx";
|
import { AdminLayout } from "./AdminLayout.tsx";
|
||||||
|
import { AdminTable } from "./admin/AdminTable.tsx";
|
||||||
|
|
||||||
export const AdminUserDetailsPage = ({
|
export const AdminUserDetailsPage = ({
|
||||||
user,
|
user,
|
||||||
@ -145,66 +146,77 @@ export const AdminUserDetailsPage = ({
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="table-container" style="margin-top: 1.25rem;">
|
<div style="margin-top: 1.25rem;">
|
||||||
<table>
|
<AdminTable
|
||||||
<thead>
|
id="grantsTable"
|
||||||
<tr>
|
headers={[
|
||||||
<th>Application Name</th>
|
"Application Name",
|
||||||
<th>SPIFFE Workload ID</th>
|
"SPIFFE Workload ID",
|
||||||
<th>Assigned Role</th>
|
"Assigned Role",
|
||||||
<th>Granted At</th>
|
"Granted At",
|
||||||
<th>Actions</th>
|
"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>
|
</tr>
|
||||||
</thead>
|
))}
|
||||||
<tbody>
|
mobileCards={grants.map((grant) => (
|
||||||
{grants.length === 0
|
<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;">
|
||||||
<tr>
|
<strong style="color: var(--text-primary);">
|
||||||
<td
|
{grant.app_name}
|
||||||
colSpan={5}
|
</strong>
|
||||||
style="text-align: center; color: var(--danger); padding: 1.5rem;"
|
<span class="badge badge-info">{grant.role}</span>
|
||||||
>
|
</div>
|
||||||
No application permissions granted (User is blocked from
|
<div style="font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 0.75rem; font-family: monospace;">
|
||||||
all subsidiary apps).
|
{grant.spiffe_id}
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
<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()}
|
||||||
grants.map((grant) => (
|
</span>
|
||||||
<tr key={grant.id}>
|
<button
|
||||||
<td>
|
type="button"
|
||||||
<strong style="color: var(--text-primary);">
|
class="btn-danger"
|
||||||
{grant.app_name}
|
style="padding: 0.25rem 0.5rem; font-size: 0.75rem;"
|
||||||
</strong>
|
onclick={`revokeGrant('${user.id}', '${grant.app_id}', '${grant.app_name}')`}
|
||||||
</td>
|
>
|
||||||
<td>
|
Revoke Access
|
||||||
<code style="background: var(--surface-muted); padding: 0.2rem 0.4rem; border-radius: var(--radius-sm); font-size: 0.8rem; font-family: monospace;">
|
</button>
|
||||||
{grant.spiffe_id}
|
</div>
|
||||||
</code>
|
</div>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
40
ui/components/admin/AdminModal.tsx
Normal file
40
ui/components/admin/AdminModal.tsx
Normal 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);"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
67
ui/components/admin/AdminTable.tsx
Normal file
67
ui/components/admin/AdminTable.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user