feat(ui): decompose Admin UI with separate Drawers and Scripts
Phase 2 Admin Drawers & Scripts execution: - Extract `AppDrawer`, `InviteDrawer`, `RoleEditorDrawer`, and `GrantDrawer`. - Extract `AdminAppsScript`, `AdminInvitesScript`, `AdminRolesScript`, and `AdminUserDetailsScript`. - Hook extracted components into their respective pages. - Format `AdminRolesPage.tsx` and all modified files using `deno fmt`. Co-authored-by: mrteye <1945243+mrteye@users.noreply.github.com>
This commit is contained in:
parent
42b336173e
commit
65f59521c0
@ -1,6 +1,7 @@
|
||||
import { AppDrawer } from "./admin/drawers/AppDrawer.tsx";
|
||||
import { AdminAppsScript } from "./admin/AdminAppsScript.tsx";
|
||||
import { AdminLayout } from "./AdminLayout.tsx";
|
||||
import { AdminTable } from "./admin/AdminTable.tsx";
|
||||
import { AdminModal } from "./admin/AdminModal.tsx";
|
||||
|
||||
export const AdminAppsPage = ({
|
||||
apps,
|
||||
@ -60,129 +61,7 @@ export const AdminAppsPage = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Register / Edit App Modal */}
|
||||
<AdminModal
|
||||
id="appFormModal"
|
||||
title="Register New Subsidiary Application"
|
||||
onClose="closeAppDrawer()"
|
||||
>
|
||||
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1.25rem 0;">
|
||||
Authenticate incoming ConnectRPC/ForwardAuth requests against the
|
||||
application's SPIFFE ID and edge routing domains.
|
||||
</p>
|
||||
|
||||
<form id="appForm" onsubmit="handleSaveApp(event)">
|
||||
<input type="hidden" id="editAppId" value="" />
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; margin-bottom: 1rem;">
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Application Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="appName"
|
||||
name="name"
|
||||
placeholder="e.g. Elite Dangerous Streaming Hub"
|
||||
required
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
<div id="spiffeIdContainer">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
SPIFFE ID (Workload Identity) *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="appSpiffeId"
|
||||
name="spiffeId"
|
||||
placeholder="e.g. spiffe://system.local/ed-droid-backend"
|
||||
required
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Description
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="appDescription"
|
||||
name="description"
|
||||
placeholder="e.g. Data telemetry streaming and UI module system"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Domain (Edge Ingress Hostname)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="appDomain"
|
||||
name="domain"
|
||||
placeholder="e.g. ed-droid.atyg.org"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; font-weight: 600; font-size: 0.875rem; cursor: pointer; color: var(--text-primary);">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="appIsPublic"
|
||||
name="is_public"
|
||||
style="width: 18px; height: 18px;"
|
||||
/>
|
||||
Is Publicly Accessible (Bypass all auth checks)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; margin-bottom: 1.25rem;">
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Bypass Paths (Comma-separated)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="appBypassPaths"
|
||||
name="bypass_paths"
|
||||
placeholder="e.g. /public/*, /webhook"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Allowed CIDRs (Comma-separated)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="appAllowedCidrs"
|
||||
name="allowed_cidrs"
|
||||
placeholder="e.g. 192.168.1.0/24"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.75rem;">
|
||||
<button type="submit" class="btn-primary" style="min-height: 42px;">
|
||||
Save Application
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
onclick="closeAppDrawer()"
|
||||
style="min-height: 42px;"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</AdminModal>
|
||||
<AppDrawer />
|
||||
|
||||
<AdminTable
|
||||
id="appsTable"
|
||||
@ -325,124 +204,7 @@ export const AdminAppsPage = ({
|
||||
`}
|
||||
</style>
|
||||
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
function showNotice(msg, isError) {
|
||||
const banner = document.getElementById('status-banner');
|
||||
banner.textContent = msg;
|
||||
banner.style.display = 'block';
|
||||
banner.style.background = isError ? 'var(--danger-bg)' : 'var(--success-bg)';
|
||||
banner.style.color = isError ? 'var(--danger-text)' : 'var(--success-text)';
|
||||
banner.style.border = isError ? '1px solid var(--danger-border)' : '1px solid var(--success-border)';
|
||||
setTimeout(() => { banner.style.display = 'none'; }, 5000);
|
||||
}
|
||||
|
||||
function filterAppsList() {
|
||||
const query = document.getElementById('appSearchInput').value.toLowerCase().trim();
|
||||
document.querySelectorAll('.app-row').forEach(r => {
|
||||
const text = r.getAttribute('data-search') || '';
|
||||
r.style.display = text.includes(query) ? '' : 'none';
|
||||
});
|
||||
document.querySelectorAll('.app-card').forEach(c => {
|
||||
const text = c.getAttribute('data-search') || '';
|
||||
c.style.display = text.includes(query) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function openCreateAppDrawer() {
|
||||
document.getElementById('editAppId').value = '';
|
||||
document.querySelector('#appFormModal h3').textContent = 'Register New Subsidiary Application';
|
||||
document.getElementById('appName').value = '';
|
||||
document.getElementById('appSpiffeId').value = '';
|
||||
document.getElementById('appSpiffeId').readOnly = false;
|
||||
document.getElementById('spiffeIdContainer').style.display = 'block';
|
||||
document.getElementById('appDescription').value = '';
|
||||
document.getElementById('appDomain').value = '';
|
||||
document.getElementById('appIsPublic').checked = false;
|
||||
document.getElementById('appBypassPaths').value = '';
|
||||
document.getElementById('appAllowedCidrs').value = '';
|
||||
document.getElementById('appFormModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function openEditAppDrawer(appJson) {
|
||||
const app = JSON.parse(appJson);
|
||||
document.getElementById('editAppId').value = app.id;
|
||||
document.querySelector('#appFormModal h3').textContent = 'Edit Application: ' + app.name;
|
||||
document.getElementById('appName').value = app.name || '';
|
||||
document.getElementById('appSpiffeId').value = app.spiffe_id || '';
|
||||
document.getElementById('appSpiffeId').readOnly = true;
|
||||
document.getElementById('appDescription').value = app.description || '';
|
||||
document.getElementById('appDomain').value = app.domain || '';
|
||||
document.getElementById('appIsPublic').checked = !!app.is_public;
|
||||
document.getElementById('appBypassPaths').value = Array.isArray(app.bypass_paths) ? app.bypass_paths.join(', ') : (app.bypass_paths || '');
|
||||
document.getElementById('appAllowedCidrs').value = Array.isArray(app.allowed_cidrs) ? app.allowed_cidrs.join(', ') : (app.allowed_cidrs || '');
|
||||
document.getElementById('appFormModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeAppDrawer() {
|
||||
document.getElementById('appFormModal').style.display = 'none';
|
||||
}
|
||||
|
||||
async function handleSaveApp(e) {
|
||||
e.preventDefault();
|
||||
const editId = document.getElementById('editAppId').value;
|
||||
const name = document.getElementById('appName').value.trim();
|
||||
const spiffeId = document.getElementById('appSpiffeId').value.trim();
|
||||
const description = document.getElementById('appDescription').value.trim();
|
||||
const domain = document.getElementById('appDomain').value.trim();
|
||||
const is_public = document.getElementById('appIsPublic').checked;
|
||||
const bypass_paths = document.getElementById('appBypassPaths').value.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const allowed_cidrs = document.getElementById('appAllowedCidrs').value.split(',').map(s => s.trim()).filter(Boolean);
|
||||
|
||||
if (!name || (!editId && !spiffeId)) {
|
||||
showNotice('Application name and SPIFFE ID are required', true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = editId ? ('/api/admin/apps/' + editId) : '/api/admin/apps';
|
||||
const method = editId ? 'PUT' : 'POST';
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, spiffeId, description, domain, is_public, bypass_paths, allowed_cidrs }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
showNotice(editId ? 'Application updated successfully!' : 'Application registered successfully!', false);
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
} else {
|
||||
showNotice(data.error || 'Failed to save application', true);
|
||||
}
|
||||
} catch (err) {
|
||||
showNotice('Network error saving application', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteApp(appId, appName) {
|
||||
if (!confirm('Are you sure you want to delete "' + appName + '"? All active user permissions for this app will be revoked.')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/admin/apps/' + appId, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (res.ok) {
|
||||
showNotice('Application deleted', false);
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
showNotice(data.error || 'Failed to delete application', true);
|
||||
}
|
||||
} catch (err) {
|
||||
showNotice('Network error', true);
|
||||
}
|
||||
}
|
||||
`,
|
||||
}}
|
||||
>
|
||||
</script>
|
||||
<AdminAppsScript />
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { AdminLayout } from "./AdminLayout.tsx";
|
||||
import { AdminTable } from "./admin/AdminTable.tsx";
|
||||
import { AdminModal } from "./admin/AdminModal.tsx";
|
||||
import { InviteDrawer } from "./admin/drawers/InviteDrawer.tsx";
|
||||
import { AdminInvitesScript } from "./admin/AdminInvitesScript.tsx";
|
||||
|
||||
export const AdminInvitesPage = ({
|
||||
invites,
|
||||
@ -66,187 +68,7 @@ export const AdminInvitesPage = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminModal
|
||||
id="createInviteModal"
|
||||
title="Generate User Onboarding Token"
|
||||
onClose="toggleCreateInviteForm()"
|
||||
>
|
||||
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1.25rem 0;">
|
||||
Configure time bounds, usage capacity, role assignments, and initial
|
||||
account activation status.
|
||||
</p>
|
||||
|
||||
<form id="createInviteForm" onsubmit="handleCreateInvite(event)">
|
||||
{/* Row 1: Type & Target App */}
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; margin-bottom: 1rem;">
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Token Provisioning Type *
|
||||
</label>
|
||||
<select
|
||||
id="inviteType"
|
||||
onchange="handleInviteTypeChange()"
|
||||
style="width: 100%;"
|
||||
>
|
||||
<option value="site_scoped">
|
||||
Type 2: Site-Scoped Token (Pre-Authorized for App)
|
||||
</option>
|
||||
<option value="global_admin">
|
||||
Type 1: Global Admin Token (Full System Access)
|
||||
</option>
|
||||
<option value="open_pending">
|
||||
Type 3: General Open Token (Unassigned Access)
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="appSelectContainer">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Target Application *
|
||||
</label>
|
||||
<select
|
||||
id="inviteAppId"
|
||||
onchange="updateInviteRoleOptions()"
|
||||
style="width: 100%;"
|
||||
>
|
||||
{apps.map((app) => (
|
||||
<option value={app.id}>
|
||||
{app.name} ({app.spiffe_id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Usage Limits & Assigned Role */}
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 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);">
|
||||
Usage Capacity *
|
||||
</label>
|
||||
<select
|
||||
id="inviteUsageType"
|
||||
onchange="handleUsageTypeChange()"
|
||||
style="width: 100%;"
|
||||
>
|
||||
<option value="single">
|
||||
Single-Use (1 Person - Max Security)
|
||||
</option>
|
||||
<option value="limited">
|
||||
Limited Multi-Use (Cap at N People)
|
||||
</option>
|
||||
<option value="unlimited">
|
||||
Unlimited Time-Bound (Campaign / Beta)
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="maxUsesContainer" style="display: none;">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Max Registrations *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="inviteMaxUses"
|
||||
value="5"
|
||||
min="2"
|
||||
max="1000"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="roleSelectContainer">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Assigned Role *
|
||||
</label>
|
||||
<select
|
||||
id="inviteRole"
|
||||
style="width: 100%;"
|
||||
>
|
||||
{/* Dynamically populated */}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: Expiration, Custom Code, Activation Toggle */}
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; margin-bottom: 1.25rem; align-items: flex-end;">
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Expires In (Days)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="inviteExpiresInDays"
|
||||
value="7"
|
||||
min="1"
|
||||
max="30"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Custom Code (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="inviteCustomCode"
|
||||
placeholder="Leave blank to auto-generate"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style="padding-bottom: 0.5rem;">
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; font-weight: 600; cursor: pointer; color: var(--text-primary);">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="inviteAutoActivate"
|
||||
checked
|
||||
style="width: 18px; height: 18px;"
|
||||
/>
|
||||
Auto-Activate Account
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.75rem;">
|
||||
<button type="submit" class="btn-primary" style="min-height: 42px;">
|
||||
Create Invite Token
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
onclick="toggleCreateInviteForm()"
|
||||
style="min-height: 42px;"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div
|
||||
id="generated-token-banner"
|
||||
style="display: none; margin-top: 1.25rem; padding: 1rem; background: var(--success-bg); border: 1px solid var(--success-border); border-radius: var(--radius-md);"
|
||||
>
|
||||
<strong style="color: var(--success-text);">
|
||||
Token Created Successfully!
|
||||
</strong>
|
||||
<div style="margin-top: 0.5rem; display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
|
||||
<code
|
||||
id="generatedTokenUrl"
|
||||
style="padding: 0.5rem 0.75rem; background: var(--surface-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); font-size: 0.85rem; flex: 1; min-width: 220px; word-break: break-all; font-family: monospace; color: var(--primary);"
|
||||
>
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
onclick="copyGeneratedTokenUrl()"
|
||||
>
|
||||
Copy Link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</AdminModal>
|
||||
<InviteDrawer apps={apps} />
|
||||
|
||||
<AdminTable
|
||||
id="invitesTable"
|
||||
@ -484,213 +306,7 @@ export const AdminInvitesPage = ({
|
||||
`}
|
||||
</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)';
|
||||
banner.style.color = isError ? 'var(--danger-text)' : 'var(--success-text)';
|
||||
banner.style.border = isError ? '1px solid var(--danger-border)' : '1px solid var(--success-border)';
|
||||
setTimeout(() => { banner.style.display = 'none'; }, 5000);
|
||||
}
|
||||
|
||||
function toggleCreateInviteForm() {
|
||||
const el = document.getElementById('createInviteModal');
|
||||
el.style.display = el.style.display === 'none' ? 'flex' : 'none';
|
||||
if (el.style.display === 'flex') {
|
||||
updateInviteRoleOptions();
|
||||
}
|
||||
}
|
||||
|
||||
function handleInviteTypeChange() {
|
||||
const type = document.getElementById('inviteType').value;
|
||||
const appContainer = document.getElementById('appSelectContainer');
|
||||
const roleContainer = document.getElementById('roleSelectContainer');
|
||||
|
||||
if (type === 'global_admin' || type === 'open_pending') {
|
||||
appContainer.style.display = 'none';
|
||||
roleContainer.style.display = 'none';
|
||||
} else {
|
||||
appContainer.style.display = 'block';
|
||||
roleContainer.style.display = 'block';
|
||||
updateInviteRoleOptions();
|
||||
}
|
||||
}
|
||||
|
||||
function handleUsageTypeChange() {
|
||||
const usage = document.getElementById('inviteUsageType').value;
|
||||
const maxUsesContainer = document.getElementById('maxUsesContainer');
|
||||
maxUsesContainer.style.display = usage === 'limited' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function filterInvitesList() {
|
||||
const query = (document.getElementById('inviteSearchInput')?.value || '').toLowerCase().trim();
|
||||
document.querySelectorAll('.invite-row').forEach(r => {
|
||||
const text = r.getAttribute('data-search') || '';
|
||||
r.style.display = text.includes(query) ? '' : 'none';
|
||||
});
|
||||
document.querySelectorAll('.invite-card').forEach(c => {
|
||||
const text = c.getAttribute('data-search') || '';
|
||||
c.style.display = text.includes(query) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCreateInvite(e) {
|
||||
e.preventDefault();
|
||||
const type = document.getElementById('inviteType').value;
|
||||
let appId = null;
|
||||
let role = 'user';
|
||||
|
||||
if (type === 'global_admin') {
|
||||
role = 'admin';
|
||||
} else if (type === 'open_pending') {
|
||||
role = 'user';
|
||||
} else {
|
||||
appId = document.getElementById('inviteAppId').value;
|
||||
role = document.getElementById('inviteRole').value;
|
||||
}
|
||||
|
||||
const usageLimitType = document.getElementById('inviteUsageType').value;
|
||||
const maxUses = usageLimitType === 'limited' ? parseInt(document.getElementById('inviteMaxUses').value) || 5 : null;
|
||||
const autoActivate = document.getElementById('inviteAutoActivate').checked;
|
||||
const expiresInDays = parseInt(document.getElementById('inviteExpiresInDays').value) || 7;
|
||||
const customCode = document.getElementById('inviteCustomCode').value.trim();
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/invites/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
appId,
|
||||
role,
|
||||
usageLimitType,
|
||||
maxUses,
|
||||
autoActivate,
|
||||
expiresInDays,
|
||||
customCode: customCode || undefined,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
const regUrl = window.location.origin + '/register?code=' + data.inviteCode;
|
||||
document.getElementById('generatedTokenUrl').textContent = regUrl;
|
||||
document.getElementById('generated-token-banner').style.display = 'block';
|
||||
showNotice('Invite token created successfully!', false);
|
||||
setTimeout(() => { window.location.reload(); }, 2500);
|
||||
} else {
|
||||
showNotice(data.error || 'Failed to create invite token', true);
|
||||
}
|
||||
} catch (err) {
|
||||
showNotice('Network error creating invite', true);
|
||||
}
|
||||
}
|
||||
|
||||
function copyGeneratedTokenUrl() {
|
||||
const text = document.getElementById('generatedTokenUrl').textContent;
|
||||
navigator.clipboard.writeText(text);
|
||||
showNotice('Registration URL copied to clipboard!', false);
|
||||
}
|
||||
|
||||
function copyInviteLink(code) {
|
||||
const url = window.location.origin + '/register?code=' + code;
|
||||
navigator.clipboard.writeText(url);
|
||||
showNotice('Registration link copied: ' + url, false);
|
||||
}
|
||||
|
||||
async function revokeInvite(inviteId, code) {
|
||||
if (!confirm('Revoke invite code "' + code + '"?')) return;
|
||||
try {
|
||||
const res = await fetch('/api/admin/invites/' + inviteId, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (res.ok) {
|
||||
showNotice('Invite token revoked', false);
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
showNotice(data.error || 'Failed to revoke invite', true);
|
||||
}
|
||||
} catch (err) {
|
||||
showNotice('Network error', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function showRedemptionsModal(inviteId, code) {
|
||||
const modal = document.getElementById('redemptions-modal');
|
||||
const codeEl = document.getElementById('modal-invite-code');
|
||||
const contentEl = document.getElementById('modal-redemptions-content');
|
||||
|
||||
codeEl.textContent = code;
|
||||
contentEl.innerHTML = '<p style="color: var(--text-muted);">Loading...</p>';
|
||||
modal.style.display = 'flex';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/invites/' + inviteId + '/redemptions');
|
||||
const data = await res.json();
|
||||
if (res.ok && data.redemptions && data.redemptions.length > 0) {
|
||||
let html = '<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';
|
||||
html += '<thead><tr>';
|
||||
html += '<th style="padding: 0.5rem;">Username</th>';
|
||||
html += '<th style="padding: 0.5rem;">Status</th>';
|
||||
html += '<th style="padding: 0.5rem;">Redeemed At</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
data.redemptions.forEach(r => {
|
||||
html += '<tr>';
|
||||
html += '<td style="padding: 0.5rem;"><strong style="color: var(--text-primary); font-family: monospace;">@' + r.username + '</strong></td>';
|
||||
html += '<td style="padding: 0.5rem;"><span class="badge badge-' + (r.account_status === 'active' ? 'success' : 'warning') + '">' + r.account_status + '</span></td>';
|
||||
html += '<td style="padding: 0.5rem; color: var(--text-secondary);">' + new Date(r.redeemed_at).toLocaleString() + '</td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
contentEl.innerHTML = html;
|
||||
} else {
|
||||
contentEl.innerHTML = '<p style="color: var(--text-muted); text-align: center; padding: 1rem;">No users have redeemed this token yet.</p>';
|
||||
}
|
||||
} catch (err) {
|
||||
contentEl.innerHTML = '<p style="color: var(--danger);">Failed to load redemption details.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function closeRedemptionsModal() {
|
||||
document.getElementById('redemptions-modal').style.display = 'none';
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
<AdminInvitesScript allRoles={allRoles} />
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,10 +1,15 @@
|
||||
import { AdminLayout } from "./AdminLayout.tsx";
|
||||
import type { AdminTable as _AdminTable } from "./admin/AdminTable.tsx";
|
||||
import { AdminModal } from "./admin/AdminModal.tsx";
|
||||
import { RoleEditorDrawer } from "./admin/drawers/RoleEditorDrawer.tsx";
|
||||
import { AdminRolesScript } from "./admin/AdminRolesScript.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.
|
||||
const _AdminModal = AdminModal;
|
||||
const _AdminRolesScript = AdminRolesScript;
|
||||
const _RoleEditorDrawer = RoleEditorDrawer;
|
||||
|
||||
export const AdminRolesPage = ({
|
||||
roles,
|
||||
@ -41,99 +46,7 @@ export const AdminRolesPage = ({
|
||||
</div>
|
||||
|
||||
{/* Create / Edit Role Drawer */}
|
||||
<AdminModal
|
||||
id="roleFormModal"
|
||||
title="Create New Role"
|
||||
onClose="closeRoleDrawer()"
|
||||
>
|
||||
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1.25rem 0;">
|
||||
Define a global shared role or an application-scoped custom grant.
|
||||
</p>
|
||||
|
||||
<form id="roleForm" onsubmit="handleSaveRole(event)">
|
||||
<input type="hidden" id="editRoleId" value="" />
|
||||
|
||||
<div
|
||||
id="scopeSelectContainer"
|
||||
style="display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; margin-bottom: 1rem;"
|
||||
>
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Scope (Applicability) *
|
||||
</label>
|
||||
<select
|
||||
id="roleScope"
|
||||
onchange="handleScopeChange()"
|
||||
style="width: 100%;"
|
||||
>
|
||||
<option value="global">
|
||||
Global (Shared across ALL applications)
|
||||
</option>
|
||||
<option value="app_specific">
|
||||
Application-Specific (Scoped to single app)
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="appSelectContainer" style="display: none;">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Target Application *
|
||||
</label>
|
||||
<select
|
||||
id="roleAppId"
|
||||
style="width: 100%;"
|
||||
>
|
||||
{apps.map((app) => (
|
||||
<option value={app.id}>
|
||||
{app.name} ({app.spiffe_id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; margin-bottom: 1.25rem;">
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Role Identifier *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="roleName"
|
||||
placeholder="e.g. navigator, copilot, auditor"
|
||||
required
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Description / Purpose
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="roleDescription"
|
||||
placeholder="e.g. Flight routing and navigational telemetry access"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.75rem;">
|
||||
<button type="submit" class="btn-primary" style="min-height: 42px;">
|
||||
Save Role
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
onclick="closeRoleDrawer()"
|
||||
style="min-height: 42px;"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</AdminModal>
|
||||
<RoleEditorDrawer apps={apps} />
|
||||
|
||||
<div class="card">
|
||||
{/* Instant Search and Scope Filters */}
|
||||
|
||||
@ -1,5 +1,13 @@
|
||||
import { AdminLayout } from "./AdminLayout.tsx";
|
||||
import { AdminTable } from "./admin/AdminTable.tsx";
|
||||
import { GrantDrawer } from "./admin/drawers/GrantDrawer.tsx";
|
||||
import type { GrantDrawer as _GrantDrawer } from "./admin/drawers/GrantDrawer.tsx";
|
||||
import { AdminUserDetailsScript } from "./admin/AdminUserDetailsScript.tsx";
|
||||
import type { AdminUserDetailsScript as _AdminUserDetailsScript } from "./admin/AdminUserDetailsScript.tsx";
|
||||
|
||||
// In Hono JSX, imports might not be strictly recognized.
|
||||
|
||||
|
||||
|
||||
export const AdminUserDetailsPage = ({
|
||||
user,
|
||||
@ -96,55 +104,7 @@ export const AdminUserDetailsPage = ({
|
||||
</div>
|
||||
|
||||
{/* Grant New Application Form */}
|
||||
<div style="margin-top: 1rem; padding: 1.25rem; background: var(--surface-muted); border: 1px solid var(--border-subtle); border-radius: var(--radius-md);">
|
||||
<h4 style="margin: 0 0 0.75rem 0; font-size: 0.95rem; color: var(--text-primary);">
|
||||
Assign / Update Application Access
|
||||
</h4>
|
||||
<form
|
||||
id="grantAccessForm"
|
||||
onsubmit={`handleGrantAccess(event, '${user.id}')`}
|
||||
style="display: flex; gap: 0.8rem; align-items: flex-end; flex-wrap: wrap;"
|
||||
>
|
||||
<div style="flex: 2; min-width: 200px;">
|
||||
<label style="display: block; font-size: 0.8rem; font-weight: 600; margin-bottom: 0.3rem; color: var(--text-secondary);">
|
||||
Application
|
||||
</label>
|
||||
<select
|
||||
id="grantAppId"
|
||||
onchange="updateRoleOptions()"
|
||||
required
|
||||
style="width: 100%;"
|
||||
>
|
||||
{allApps.map((app) => (
|
||||
<option value={app.id}>
|
||||
{app.name} ({app.spiffe_id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="flex: 1; min-width: 140px;">
|
||||
<label style="display: block; font-size: 0.8rem; font-weight: 600; margin-bottom: 0.3rem; color: var(--text-secondary);">
|
||||
Assigned Role
|
||||
</label>
|
||||
<select
|
||||
id="grantRole"
|
||||
required
|
||||
style="width: 100%;"
|
||||
>
|
||||
{/* Dynamically populated */}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary"
|
||||
style="min-height: 44px;"
|
||||
>
|
||||
Save Grant
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<GrantDrawer user={user} allApps={allApps} />
|
||||
|
||||
<div style="margin-top: 1.25rem;">
|
||||
<AdminTable
|
||||
@ -378,175 +338,7 @@ export const AdminUserDetailsPage = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
const ROLES_CATALOG = ${JSON.stringify(allRoles)};
|
||||
|
||||
function updateRoleOptions() {
|
||||
const appId = document.getElementById('grantAppId').value;
|
||||
const roleSelect = document.getElementById('grantRole');
|
||||
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('grantAppId')) {
|
||||
updateRoleOptions();
|
||||
}
|
||||
|
||||
function showNotice(msg, isError) {
|
||||
const banner = document.getElementById('status-banner');
|
||||
banner.textContent = msg;
|
||||
banner.style.display = 'block';
|
||||
banner.style.background = isError ? 'var(--danger-bg)' : 'var(--success-bg)';
|
||||
banner.style.color = isError ? 'var(--danger-text)' : 'var(--success-text)';
|
||||
banner.style.border = isError ? '1px solid var(--danger-border)' : '1px solid var(--success-border)';
|
||||
setTimeout(() => { banner.style.display = 'none'; }, 5000);
|
||||
}
|
||||
|
||||
async function handleUpdateProfile(e, userId) {
|
||||
e.preventDefault();
|
||||
const displayName = document.getElementById('displayNameInput').value.trim();
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/users/' + userId + '/profile', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ displayName }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
showNotice('User display name saved successfully!', false);
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
} else {
|
||||
showNotice(data.error || 'Failed to update display name', true);
|
||||
}
|
||||
} catch (err) {
|
||||
showNotice('Network error updating display name', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGrantAccess(e, userId) {
|
||||
e.preventDefault();
|
||||
const appId = document.getElementById('grantAppId').value;
|
||||
const role = document.getElementById('grantRole').value;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/users/' + userId + '/grants', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ appId, role }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
showNotice('Application access granted successfully!', false);
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
} else {
|
||||
showNotice(data.error || 'Failed to update application grant', true);
|
||||
}
|
||||
} catch (err) {
|
||||
showNotice('Network error updating grant', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeGrant(userId, appId, appName) {
|
||||
if (!confirm('Revoke access to "' + appName + '" for this user?')) return;
|
||||
try {
|
||||
const res = await fetch('/api/admin/users/' + userId + '/grants/' + appId, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (res.ok) {
|
||||
showNotice('Access revoked', false);
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
} else {
|
||||
showNotice(data.error || 'Failed to revoke grant', true);
|
||||
}
|
||||
} catch (err) {
|
||||
showNotice('Network error revoking grant', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateRecoveryLink(userId) {
|
||||
try {
|
||||
const res = await fetch('/api/admin/users/' + userId + '/recovery', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
const link = window.location.origin + '/recovery?code=' + data.recoveryCode;
|
||||
document.getElementById('recovery-link-text').textContent = link;
|
||||
document.getElementById('recovery-link-container').style.display = 'block';
|
||||
showNotice('Recovery link generated!', false);
|
||||
} else {
|
||||
showNotice(data.error || 'Failed to generate link', true);
|
||||
}
|
||||
} catch (err) {
|
||||
showNotice('Network error', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeSession(sessionId) {
|
||||
if (!confirm('Revoke this session?')) return;
|
||||
try {
|
||||
const res = await fetch('/api/admin/sessions/' + sessionId, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
showNotice('Session revoked', false);
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
} else {
|
||||
showNotice('Failed to revoke session', true);
|
||||
}
|
||||
} catch (err) {
|
||||
showNotice('Network error', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeAllSessions(userId) {
|
||||
if (!confirm('Revoke ALL sessions for this user? They will be immediately logged out.')) return;
|
||||
try {
|
||||
const res = await fetch('/api/admin/users/' + userId + '/sessions', { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
showNotice('All sessions revoked', false);
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
} else {
|
||||
showNotice('Failed to revoke all sessions', true);
|
||||
}
|
||||
} catch (err) {
|
||||
showNotice('Network error', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePasskey(userId, passkeyId) {
|
||||
if (!confirm('Permanently delete this device? The user will no longer be able to log in with it.')) return;
|
||||
try {
|
||||
const res = await fetch('/api/admin/users/' + userId + '/passkeys/' + passkeyId, { method: 'DELETE' });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
showNotice('Passkey deleted', false);
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
} else {
|
||||
showNotice(data.error || 'Failed to delete passkey', true);
|
||||
}
|
||||
} catch (err) {
|
||||
showNotice('Network error', true);
|
||||
}
|
||||
}
|
||||
`,
|
||||
}}
|
||||
>
|
||||
</script>
|
||||
<AdminUserDetailsScript allRoles={allRoles} />
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
123
ui/components/admin/AdminAppsScript.tsx
Normal file
123
ui/components/admin/AdminAppsScript.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
export const AdminAppsScript = () => {
|
||||
return (
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
function showNotice(msg, isError) {
|
||||
const banner = document.getElementById('status-banner');
|
||||
if (banner) {
|
||||
banner.textContent = msg;
|
||||
banner.style.display = 'block';
|
||||
banner.style.background = isError ? 'var(--danger-bg)' : 'var(--success-bg)';
|
||||
banner.style.color = isError ? 'var(--danger-text)' : 'var(--success-text)';
|
||||
banner.style.border = isError ? '1px solid var(--danger-border)' : '1px solid var(--success-border)';
|
||||
setTimeout(() => { banner.style.display = 'none'; }, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function filterAppsList() {
|
||||
const query = document.getElementById('appSearchInput').value.toLowerCase().trim();
|
||||
document.querySelectorAll('.app-row').forEach(r => {
|
||||
const text = r.getAttribute('data-search') || '';
|
||||
r.style.display = text.includes(query) ? '' : 'none';
|
||||
});
|
||||
document.querySelectorAll('.app-card').forEach(c => {
|
||||
const text = c.getAttribute('data-search') || '';
|
||||
c.style.display = text.includes(query) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function openCreateAppDrawer() {
|
||||
document.getElementById('editAppId').value = '';
|
||||
document.querySelector('#appFormModal h3').textContent = 'Register New Subsidiary Application';
|
||||
document.getElementById('appName').value = '';
|
||||
document.getElementById('appSpiffeId').value = '';
|
||||
document.getElementById('appSpiffeId').readOnly = false;
|
||||
document.getElementById('spiffeIdContainer').style.display = 'block';
|
||||
document.getElementById('appDescription').value = '';
|
||||
document.getElementById('appDomain').value = '';
|
||||
document.getElementById('appIsPublic').checked = false;
|
||||
document.getElementById('appBypassPaths').value = '';
|
||||
document.getElementById('appAllowedCidrs').value = '';
|
||||
document.getElementById('appFormModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function openEditAppDrawer(appJson) {
|
||||
const app = JSON.parse(appJson);
|
||||
document.getElementById('editAppId').value = app.id;
|
||||
document.querySelector('#appFormModal h3').textContent = 'Edit Application: ' + app.name;
|
||||
document.getElementById('appName').value = app.name || '';
|
||||
document.getElementById('appSpiffeId').value = app.spiffe_id || '';
|
||||
document.getElementById('appSpiffeId').readOnly = true;
|
||||
document.getElementById('appDescription').value = app.description || '';
|
||||
document.getElementById('appDomain').value = app.domain || '';
|
||||
document.getElementById('appIsPublic').checked = !!app.is_public;
|
||||
document.getElementById('appBypassPaths').value = Array.isArray(app.bypass_paths) ? app.bypass_paths.join(', ') : (app.bypass_paths || '');
|
||||
document.getElementById('appAllowedCidrs').value = Array.isArray(app.allowed_cidrs) ? app.allowed_cidrs.join(', ') : (app.allowed_cidrs || '');
|
||||
document.getElementById('appFormModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeAppDrawer() {
|
||||
document.getElementById('appFormModal').style.display = 'none';
|
||||
}
|
||||
|
||||
async function handleSaveApp(e) {
|
||||
e.preventDefault();
|
||||
const editId = document.getElementById('editAppId').value;
|
||||
const name = document.getElementById('appName').value.trim();
|
||||
const spiffeId = document.getElementById('appSpiffeId').value.trim();
|
||||
const description = document.getElementById('appDescription').value.trim();
|
||||
const domain = document.getElementById('appDomain').value.trim();
|
||||
const is_public = document.getElementById('appIsPublic').checked;
|
||||
const bypass_paths = document.getElementById('appBypassPaths').value.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const allowed_cidrs = document.getElementById('appAllowedCidrs').value.split(',').map(s => s.trim()).filter(Boolean);
|
||||
|
||||
if (!name || (!editId && !spiffeId)) {
|
||||
showNotice('Application name and SPIFFE ID are required', true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = editId ? ('/api/admin/apps/' + editId) : '/api/admin/apps';
|
||||
const method = editId ? 'PUT' : 'POST';
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, spiffeId, description, domain, is_public, bypass_paths, allowed_cidrs }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
showNotice(editId ? 'Application updated successfully!' : 'Application registered successfully!', false);
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
} else {
|
||||
showNotice(data.error || 'Failed to save application', true);
|
||||
}
|
||||
} catch (err) {
|
||||
showNotice('Network error saving application', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteApp(appId, appName) {
|
||||
if (!confirm('Are you sure you want to delete "' + appName + '"? All active user permissions for this app will be revoked.')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/admin/apps/' + appId, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (res.ok) {
|
||||
showNotice('Application deleted', false);
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
showNotice(data.error || 'Failed to delete application', true);
|
||||
}
|
||||
} catch (err) {
|
||||
showNotice('Network error', true);
|
||||
}
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
211
ui/components/admin/AdminInvitesScript.tsx
Normal file
211
ui/components/admin/AdminInvitesScript.tsx
Normal file
@ -0,0 +1,211 @@
|
||||
export const AdminInvitesScript = ({ allRoles }: { allRoles: any[] }) => {
|
||||
return (
|
||||
<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)';
|
||||
banner.style.color = isError ? 'var(--danger-text)' : 'var(--success-text)';
|
||||
banner.style.border = isError ? '1px solid var(--danger-border)' : '1px solid var(--success-border)';
|
||||
setTimeout(() => { banner.style.display = 'none'; }, 5000);
|
||||
}
|
||||
|
||||
function toggleCreateInviteForm() {
|
||||
const el = document.getElementById('createInviteModal');
|
||||
el.style.display = el.style.display === 'none' ? 'flex' : 'none';
|
||||
if (el.style.display === 'flex') {
|
||||
updateInviteRoleOptions();
|
||||
}
|
||||
}
|
||||
|
||||
function handleInviteTypeChange() {
|
||||
const type = document.getElementById('inviteType').value;
|
||||
const appContainer = document.getElementById('appSelectContainer');
|
||||
const roleContainer = document.getElementById('roleSelectContainer');
|
||||
|
||||
if (type === 'global_admin' || type === 'open_pending') {
|
||||
appContainer.style.display = 'none';
|
||||
roleContainer.style.display = 'none';
|
||||
} else {
|
||||
appContainer.style.display = 'block';
|
||||
roleContainer.style.display = 'block';
|
||||
updateInviteRoleOptions();
|
||||
}
|
||||
}
|
||||
|
||||
function handleUsageTypeChange() {
|
||||
const usage = document.getElementById('inviteUsageType').value;
|
||||
const maxUsesContainer = document.getElementById('maxUsesContainer');
|
||||
maxUsesContainer.style.display = usage === 'limited' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function filterInvitesList() {
|
||||
const query = (document.getElementById('inviteSearchInput')?.value || '').toLowerCase().trim();
|
||||
document.querySelectorAll('.invite-row').forEach(r => {
|
||||
const text = r.getAttribute('data-search') || '';
|
||||
r.style.display = text.includes(query) ? '' : 'none';
|
||||
});
|
||||
document.querySelectorAll('.invite-card').forEach(c => {
|
||||
const text = c.getAttribute('data-search') || '';
|
||||
c.style.display = text.includes(query) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCreateInvite(e) {
|
||||
e.preventDefault();
|
||||
const type = document.getElementById('inviteType').value;
|
||||
let appId = null;
|
||||
let role = 'user';
|
||||
|
||||
if (type === 'global_admin') {
|
||||
role = 'admin';
|
||||
} else if (type === 'open_pending') {
|
||||
role = 'user';
|
||||
} else {
|
||||
appId = document.getElementById('inviteAppId').value;
|
||||
role = document.getElementById('inviteRole').value;
|
||||
}
|
||||
|
||||
const usageLimitType = document.getElementById('inviteUsageType').value;
|
||||
const maxUses = usageLimitType === 'limited' ? parseInt(document.getElementById('inviteMaxUses').value) || 5 : null;
|
||||
const autoActivate = document.getElementById('inviteAutoActivate').checked;
|
||||
const expiresInDays = parseInt(document.getElementById('inviteExpiresInDays').value) || 7;
|
||||
const customCode = document.getElementById('inviteCustomCode').value.trim();
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/invites/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
appId,
|
||||
role,
|
||||
usageLimitType,
|
||||
maxUses,
|
||||
autoActivate,
|
||||
expiresInDays,
|
||||
customCode: customCode || undefined,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
const regUrl = window.location.origin + '/register?code=' + data.inviteCode;
|
||||
document.getElementById('generatedTokenUrl').textContent = regUrl;
|
||||
document.getElementById('generated-token-banner').style.display = 'block';
|
||||
showNotice('Invite token created successfully!', false);
|
||||
setTimeout(() => { window.location.reload(); }, 2500);
|
||||
} else {
|
||||
showNotice(data.error || 'Failed to create invite token', true);
|
||||
}
|
||||
} catch (err) {
|
||||
showNotice('Network error creating invite', true);
|
||||
}
|
||||
}
|
||||
|
||||
function copyGeneratedTokenUrl() {
|
||||
const text = document.getElementById('generatedTokenUrl').textContent;
|
||||
navigator.clipboard.writeText(text);
|
||||
showNotice('Registration URL copied to clipboard!', false);
|
||||
}
|
||||
|
||||
function copyInviteLink(code) {
|
||||
const url = window.location.origin + '/register?code=' + code;
|
||||
navigator.clipboard.writeText(url);
|
||||
showNotice('Registration link copied: ' + url, false);
|
||||
}
|
||||
|
||||
async function revokeInvite(inviteId, code) {
|
||||
if (!confirm('Revoke invite code "' + code + '"?')) return;
|
||||
try {
|
||||
const res = await fetch('/api/admin/invites/' + inviteId, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (res.ok) {
|
||||
showNotice('Invite token revoked', false);
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
showNotice(data.error || 'Failed to revoke invite', true);
|
||||
}
|
||||
} catch (err) {
|
||||
showNotice('Network error', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function showRedemptionsModal(inviteId, code) {
|
||||
const modal = document.getElementById('redemptions-modal');
|
||||
const codeEl = document.getElementById('modal-invite-code');
|
||||
const contentEl = document.getElementById('modal-redemptions-content');
|
||||
|
||||
codeEl.textContent = code;
|
||||
contentEl.innerHTML = '<p style="color: var(--text-muted);">Loading...</p>';
|
||||
modal.style.display = 'flex';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/invites/' + inviteId + '/redemptions');
|
||||
const data = await res.json();
|
||||
if (res.ok && data.redemptions && data.redemptions.length > 0) {
|
||||
let html = '<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';
|
||||
html += '<thead><tr>';
|
||||
html += '<th style="padding: 0.5rem;">Username</th>';
|
||||
html += '<th style="padding: 0.5rem;">Status</th>';
|
||||
html += '<th style="padding: 0.5rem;">Redeemed At</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
data.redemptions.forEach(r => {
|
||||
html += '<tr>';
|
||||
html += '<td style="padding: 0.5rem;"><strong style="color: var(--text-primary); font-family: monospace;">@' + r.username + '</strong></td>';
|
||||
html += '<td style="padding: 0.5rem;"><span class="badge badge-' + (r.account_status === 'active' ? 'success' : 'warning') + '">' + r.account_status + '</span></td>';
|
||||
html += '<td style="padding: 0.5rem; color: var(--text-secondary);">' + new Date(r.redeemed_at).toLocaleString() + '</td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
contentEl.innerHTML = html;
|
||||
} else {
|
||||
contentEl.innerHTML = '<p style="color: var(--text-muted); text-align: center; padding: 1rem;">No users have redeemed this token yet.</p>';
|
||||
}
|
||||
} catch (err) {
|
||||
contentEl.innerHTML = '<p style="color: var(--danger);">Failed to load redemption details.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function closeRedemptionsModal() {
|
||||
document.getElementById('redemptions-modal').style.display = 'none';
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
154
ui/components/admin/AdminRolesScript.tsx
Normal file
154
ui/components/admin/AdminRolesScript.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
export const AdminRolesScript = () => {
|
||||
return (
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
function showNotice(msg, isError) {
|
||||
const banner = document.getElementById('status-banner');
|
||||
banner.textContent = msg;
|
||||
banner.style.display = 'block';
|
||||
banner.style.background = isError ? 'var(--danger-bg)' : 'var(--success-bg)';
|
||||
banner.style.color = isError ? 'var(--danger-text)' : 'var(--success-text)';
|
||||
banner.style.border = isError ? '1px solid var(--danger-border)' : '1px solid var(--success-border)';
|
||||
setTimeout(() => { banner.style.display = 'none'; }, 5000);
|
||||
}
|
||||
|
||||
function openCreateRoleDrawer() {
|
||||
document.getElementById('editRoleId').value = '';
|
||||
const titleEl = document.getElementById('roleFormTitle');
|
||||
if(titleEl) titleEl.textContent = 'Create New Role';
|
||||
document.getElementById('scopeSelectContainer').style.display = 'grid';
|
||||
document.getElementById('roleName').value = '';
|
||||
document.getElementById('roleDescription').value = '';
|
||||
|
||||
const modal = document.getElementById('roleFormModal');
|
||||
if (modal) modal.style.display = 'flex';
|
||||
|
||||
const card = document.getElementById('roleFormCard');
|
||||
if (card) {
|
||||
card.style.display = 'block';
|
||||
card.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
|
||||
function openEditRoleDrawer(roleJson) {
|
||||
const role = JSON.parse(roleJson);
|
||||
document.getElementById('editRoleId').value = role.id;
|
||||
const titleEl = document.getElementById('roleFormTitle');
|
||||
if(titleEl) titleEl.textContent = 'Edit Role: ' + role.name;
|
||||
document.getElementById('scopeSelectContainer').style.display = 'none';
|
||||
document.getElementById('roleName').value = role.name || '';
|
||||
document.getElementById('roleDescription').value = role.description || '';
|
||||
|
||||
const modal = document.getElementById('roleFormModal');
|
||||
if (modal) modal.style.display = 'flex';
|
||||
|
||||
const card = document.getElementById('roleFormCard');
|
||||
if (card) {
|
||||
card.style.display = 'block';
|
||||
card.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
|
||||
function closeRoleDrawer() {
|
||||
const modal = document.getElementById('roleFormModal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
}
|
||||
|
||||
function handleScopeChange() {
|
||||
const scope = document.getElementById('roleScope').value;
|
||||
const appContainer = document.getElementById('appSelectContainer');
|
||||
if (appContainer) appContainer.style.display = scope === 'app_specific' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function filterRoles() {
|
||||
const query = (document.getElementById('roleSearchInput')?.value || '').toLowerCase().trim();
|
||||
const selectedScope = document.getElementById('filterScopeSelect')?.value || 'all';
|
||||
|
||||
const rows = document.querySelectorAll('.role-row');
|
||||
const cards = document.querySelectorAll('.role-card');
|
||||
let visibleCount = 0;
|
||||
|
||||
const checkMatch = (appId, searchText) => {
|
||||
const scopeMatch = selectedScope === 'all' || (selectedScope === 'global' && appId === 'global') || (appId === selectedScope);
|
||||
const textMatch = !query || searchText.includes(query);
|
||||
return scopeMatch && textMatch;
|
||||
};
|
||||
|
||||
rows.forEach(r => {
|
||||
const appId = r.getAttribute('data-app-id');
|
||||
const search = r.getAttribute('data-search') || '';
|
||||
const match = checkMatch(appId, search);
|
||||
r.style.display = match ? '' : 'none';
|
||||
if (match) visibleCount++;
|
||||
});
|
||||
|
||||
cards.forEach(c => {
|
||||
const appId = c.getAttribute('data-app-id');
|
||||
const search = c.getAttribute('data-search') || '';
|
||||
const match = checkMatch(appId, search);
|
||||
c.style.display = match ? '' : 'none';
|
||||
});
|
||||
|
||||
document.getElementById('roleCountDisplay').textContent = 'Showing ' + visibleCount + ' roles';
|
||||
}
|
||||
|
||||
async function handleSaveRole(e) {
|
||||
e.preventDefault();
|
||||
const editId = document.getElementById('editRoleId').value;
|
||||
const scope = document.getElementById('roleScope')?.value;
|
||||
const name = document.getElementById('roleName').value.trim();
|
||||
const description = document.getElementById('roleDescription').value.trim();
|
||||
let appId = null;
|
||||
|
||||
if (!editId && scope === 'app_specific') {
|
||||
appId = document.getElementById('roleAppId').value;
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
showNotice('Role identifier is required', true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = editId ? ('/api/admin/roles/' + editId) : '/api/admin/roles';
|
||||
const method = editId ? 'PUT' : 'POST';
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, description, appId }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
showNotice(editId ? 'Role updated successfully!' : 'Role created successfully!', false);
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
} else {
|
||||
showNotice(data.error || 'Failed to save role', true);
|
||||
}
|
||||
} catch (err) {
|
||||
showNotice('Network error saving role', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRole(roleId, roleName) {
|
||||
if (!confirm('Are you sure you want to delete role "' + roleName + '"?')) return;
|
||||
try {
|
||||
const res = await fetch('/api/admin/roles/' + roleId, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (res.ok) {
|
||||
showNotice('Role deleted', false);
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
showNotice(data.error || 'Failed to delete role', true);
|
||||
}
|
||||
} catch (err) {
|
||||
showNotice('Network error', true);
|
||||
}
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
178
ui/components/admin/AdminUserDetailsScript.tsx
Normal file
178
ui/components/admin/AdminUserDetailsScript.tsx
Normal file
@ -0,0 +1,178 @@
|
||||
export const AdminUserDetailsScript = ({ allRoles }: { allRoles: any[] }) => {
|
||||
return (
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
const ROLES_CATALOG = ${JSON.stringify(allRoles)};
|
||||
|
||||
function updateRoleOptions() {
|
||||
const grantAppIdEl = document.getElementById('grantAppId');
|
||||
if(!grantAppIdEl) return;
|
||||
const appId = grantAppIdEl.value;
|
||||
const roleSelect = document.getElementById('grantRole');
|
||||
if(!roleSelect) return;
|
||||
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('grantAppId')) {
|
||||
updateRoleOptions();
|
||||
}
|
||||
|
||||
function showNotice(msg, isError) {
|
||||
const banner = document.getElementById('status-banner');
|
||||
if(banner){
|
||||
banner.textContent = msg;
|
||||
banner.style.display = 'block';
|
||||
banner.style.background = isError ? 'var(--danger-bg)' : 'var(--success-bg)';
|
||||
banner.style.color = isError ? 'var(--danger-text)' : 'var(--success-text)';
|
||||
banner.style.border = isError ? '1px solid var(--danger-border)' : '1px solid var(--success-border)';
|
||||
setTimeout(() => { banner.style.display = 'none'; }, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateProfile(e, userId) {
|
||||
e.preventDefault();
|
||||
const displayName = document.getElementById('displayNameInput').value.trim();
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/users/' + userId + '/profile', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ displayName }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
showNotice('User display name saved successfully!', false);
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
} else {
|
||||
showNotice(data.error || 'Failed to update display name', true);
|
||||
}
|
||||
} catch (err) {
|
||||
showNotice('Network error updating display name', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGrantAccess(e, userId) {
|
||||
e.preventDefault();
|
||||
const appId = document.getElementById('grantAppId').value;
|
||||
const role = document.getElementById('grantRole').value;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/users/' + userId + '/grants', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ appId, role }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
showNotice('Application access granted successfully!', false);
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
} else {
|
||||
showNotice(data.error || 'Failed to update application grant', true);
|
||||
}
|
||||
} catch (err) {
|
||||
showNotice('Network error updating grant', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeGrant(userId, appId, appName) {
|
||||
if (!confirm('Revoke access to "' + appName + '" for this user?')) return;
|
||||
try {
|
||||
const res = await fetch('/api/admin/users/' + userId + '/grants/' + appId, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (res.ok) {
|
||||
showNotice('Access revoked', false);
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
showNotice(data.error || 'Failed to revoke grant', true);
|
||||
}
|
||||
} catch (err) {
|
||||
showNotice('Network error revoking grant', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateRecoveryLink(userId) {
|
||||
try {
|
||||
const res = await fetch('/api/admin/users/' + userId + '/recovery', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
const link = window.location.origin + '/recovery?code=' + data.recoveryCode;
|
||||
document.getElementById('recovery-link-text').textContent = link;
|
||||
document.getElementById('recovery-link-container').style.display = 'block';
|
||||
showNotice('Recovery link generated!', false);
|
||||
} else {
|
||||
showNotice(data.error || 'Failed to generate link', true);
|
||||
}
|
||||
} catch (err) {
|
||||
showNotice('Network error', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeSession(sessionId) {
|
||||
if (!confirm('Revoke this session?')) return;
|
||||
try {
|
||||
const res = await fetch('/api/admin/sessions/' + sessionId, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
showNotice('Session revoked', false);
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
} else {
|
||||
showNotice('Failed to revoke session', true);
|
||||
}
|
||||
} catch (err) {
|
||||
showNotice('Network error', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeAllSessions(userId) {
|
||||
if (!confirm('Revoke ALL sessions for this user? They will be immediately logged out.')) return;
|
||||
try {
|
||||
const res = await fetch('/api/admin/users/' + userId + '/sessions', { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
showNotice('All sessions revoked', false);
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
} else {
|
||||
showNotice('Failed to revoke all sessions', true);
|
||||
}
|
||||
} catch (err) {
|
||||
showNotice('Network error', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePasskey(userId, passkeyId) {
|
||||
if (!confirm('Permanently delete this device? The user will no longer be able to log in with it.')) return;
|
||||
try {
|
||||
const res = await fetch('/api/admin/users/' + userId + '/passkeys/' + passkeyId, { method: 'DELETE' });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
showNotice('Passkey deleted', false);
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
} else {
|
||||
showNotice(data.error || 'Failed to delete passkey', true);
|
||||
}
|
||||
} catch (err) {
|
||||
showNotice('Network error', true);
|
||||
}
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
128
ui/components/admin/drawers/AppDrawer.tsx
Normal file
128
ui/components/admin/drawers/AppDrawer.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
import { AdminModal } from "../AdminModal.tsx";
|
||||
|
||||
export const AppDrawer = () => {
|
||||
return (
|
||||
<AdminModal
|
||||
id="appFormModal"
|
||||
title="Register New Subsidiary Application"
|
||||
onClose="closeAppDrawer()"
|
||||
>
|
||||
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1.25rem 0;">
|
||||
Authenticate incoming ConnectRPC/ForwardAuth requests against the
|
||||
application's SPIFFE ID and edge routing domains.
|
||||
</p>
|
||||
|
||||
<form id="appForm" onsubmit="handleSaveApp(event)">
|
||||
<input type="hidden" id="editAppId" value="" />
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; margin-bottom: 1rem;">
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Application Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="appName"
|
||||
name="name"
|
||||
placeholder="e.g. Elite Dangerous Streaming Hub"
|
||||
required
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
<div id="spiffeIdContainer">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
SPIFFE ID (Workload Identity) *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="appSpiffeId"
|
||||
name="spiffeId"
|
||||
placeholder="e.g. spiffe://system.local/ed-droid-backend"
|
||||
required
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Description
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="appDescription"
|
||||
name="description"
|
||||
placeholder="e.g. Data telemetry streaming and UI module system"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Domain (Edge Ingress Hostname)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="appDomain"
|
||||
name="domain"
|
||||
placeholder="e.g. ed-droid.atyg.org"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; font-weight: 600; font-size: 0.875rem; cursor: pointer; color: var(--text-primary);">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="appIsPublic"
|
||||
name="is_public"
|
||||
style="width: 18px; height: 18px;"
|
||||
/>
|
||||
Is Publicly Accessible (Bypass all auth checks)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; margin-bottom: 1.25rem;">
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Bypass Paths (Comma-separated)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="appBypassPaths"
|
||||
name="bypass_paths"
|
||||
placeholder="e.g. /api/public, /webhooks/github"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Allowed CIDRs (Comma-separated)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="appAllowedCidrs"
|
||||
name="allowed_cidrs"
|
||||
placeholder="e.g. 192.168.1.0/24, 10.0.0.0/8"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.75rem;">
|
||||
<button type="submit" class="btn-primary" style="min-height: 42px;">
|
||||
Save Registration
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
onclick="closeAppDrawer()"
|
||||
style="min-height: 42px;"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</AdminModal>
|
||||
);
|
||||
};
|
||||
55
ui/components/admin/drawers/GrantDrawer.tsx
Normal file
55
ui/components/admin/drawers/GrantDrawer.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
export const GrantDrawer = (
|
||||
{ user, allApps }: { user: any; allApps: any[] },
|
||||
) => {
|
||||
return (
|
||||
<div style="margin-top: 1rem; padding: 1.25rem; background: var(--surface-muted); border: 1px solid var(--border-subtle); border-radius: var(--radius-md);">
|
||||
<h4 style="margin: 0 0 0.75rem 0; font-size: 0.95rem; color: var(--text-primary);">
|
||||
Assign / Update Application Access
|
||||
</h4>
|
||||
<form
|
||||
id="grantAccessForm"
|
||||
onsubmit={`handleGrantAccess(event, '${user.id}')`}
|
||||
style="display: flex; gap: 0.8rem; align-items: flex-end; flex-wrap: wrap;"
|
||||
>
|
||||
<div style="flex: 2; min-width: 200px;">
|
||||
<label style="display: block; font-size: 0.8rem; font-weight: 600; margin-bottom: 0.3rem; color: var(--text-secondary);">
|
||||
Application
|
||||
</label>
|
||||
<select
|
||||
id="grantAppId"
|
||||
onchange="updateRoleOptions()"
|
||||
required
|
||||
style="width: 100%;"
|
||||
>
|
||||
{allApps.map((app) => (
|
||||
<option value={app.id}>
|
||||
{app.name} ({app.spiffe_id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="flex: 1; min-width: 140px;">
|
||||
<label style="display: block; font-size: 0.8rem; font-weight: 600; margin-bottom: 0.3rem; color: var(--text-secondary);">
|
||||
Assigned Role
|
||||
</label>
|
||||
<select
|
||||
id="grantRole"
|
||||
required
|
||||
style="width: 100%;"
|
||||
>
|
||||
{/* Dynamically populated */}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary"
|
||||
style="min-height: 44px;"
|
||||
>
|
||||
Save Grant
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
187
ui/components/admin/drawers/InviteDrawer.tsx
Normal file
187
ui/components/admin/drawers/InviteDrawer.tsx
Normal file
@ -0,0 +1,187 @@
|
||||
import { AdminModal } from "../AdminModal.tsx";
|
||||
|
||||
export const InviteDrawer = ({ apps }: { apps: any[] }) => {
|
||||
return (
|
||||
<AdminModal
|
||||
id="createInviteModal"
|
||||
title="Generate User Onboarding Token"
|
||||
onClose="toggleCreateInviteForm()"
|
||||
>
|
||||
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1.25rem 0;">
|
||||
Configure time bounds, usage capacity, role assignments, and initial
|
||||
account activation status.
|
||||
</p>
|
||||
|
||||
<form id="createInviteForm" onsubmit="handleCreateInvite(event)">
|
||||
{/* Row 1: Type & Target App */}
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; margin-bottom: 1rem;">
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Token Provisioning Type *
|
||||
</label>
|
||||
<select
|
||||
id="inviteType"
|
||||
onchange="handleInviteTypeChange()"
|
||||
style="width: 100%;"
|
||||
>
|
||||
<option value="site_scoped">
|
||||
Type 2: Site-Scoped Token (Pre-Authorized for App)
|
||||
</option>
|
||||
<option value="global_admin">
|
||||
Type 1: Global Admin Token (Full System Access)
|
||||
</option>
|
||||
<option value="open_pending">
|
||||
Type 3: General Open Token (Unassigned Access)
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="appSelectContainer">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Target Application *
|
||||
</label>
|
||||
<select
|
||||
id="inviteAppId"
|
||||
onchange="updateInviteRoleOptions()"
|
||||
style="width: 100%;"
|
||||
>
|
||||
{apps.map((app) => (
|
||||
<option value={app.id}>
|
||||
{app.name} ({app.spiffe_id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Usage Limits & Assigned Role */}
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 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);">
|
||||
Usage Capacity *
|
||||
</label>
|
||||
<select
|
||||
id="inviteUsageType"
|
||||
onchange="handleUsageTypeChange()"
|
||||
style="width: 100%;"
|
||||
>
|
||||
<option value="single">
|
||||
Single-Use (1 Person - Max Security)
|
||||
</option>
|
||||
<option value="limited">
|
||||
Limited Multi-Use (Cap at N People)
|
||||
</option>
|
||||
<option value="unlimited">
|
||||
Unlimited Time-Bound (Campaign / Beta)
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="maxUsesContainer" style="display: none;">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Max Registrations *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="inviteMaxUses"
|
||||
value="5"
|
||||
min="2"
|
||||
max="1000"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="roleSelectContainer">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Assigned Role *
|
||||
</label>
|
||||
<select
|
||||
id="inviteRole"
|
||||
style="width: 100%;"
|
||||
>
|
||||
{/* Dynamically populated */}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: Expiration, Custom Code, Activation Toggle */}
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; margin-bottom: 1.25rem; align-items: flex-end;">
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Expires In (Days)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="inviteExpiresInDays"
|
||||
value="7"
|
||||
min="1"
|
||||
max="30"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Custom Code (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="inviteCustomCode"
|
||||
placeholder="Leave blank to auto-generate"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style="padding-bottom: 0.5rem;">
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; font-weight: 600; cursor: pointer; color: var(--text-primary);">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="inviteAutoActivate"
|
||||
checked
|
||||
style="width: 18px; height: 18px;"
|
||||
/>
|
||||
Auto-Activate Account
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.75rem;">
|
||||
<button type="submit" class="btn-primary" style="min-height: 42px;">
|
||||
Create Invite Token
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
onclick="toggleCreateInviteForm()"
|
||||
style="min-height: 42px;"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div
|
||||
id="generated-token-banner"
|
||||
style="display: none; margin-top: 1.25rem; padding: 1rem; background: var(--success-bg); border: 1px solid var(--success-border); border-radius: var(--radius-md);"
|
||||
>
|
||||
<strong style="color: var(--success-text);">
|
||||
Token Created Successfully!
|
||||
</strong>
|
||||
<div style="margin-top: 0.5rem; display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
|
||||
<code
|
||||
id="generatedTokenUrl"
|
||||
style="padding: 0.5rem 0.75rem; background: var(--surface-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); font-size: 0.85rem; flex: 1; min-width: 220px; word-break: break-all; font-family: monospace; color: var(--primary);"
|
||||
>
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
onclick="copyGeneratedTokenUrl()"
|
||||
>
|
||||
Copy Link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</AdminModal>
|
||||
);
|
||||
};
|
||||
99
ui/components/admin/drawers/RoleEditorDrawer.tsx
Normal file
99
ui/components/admin/drawers/RoleEditorDrawer.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import { AdminModal } from "../AdminModal.tsx";
|
||||
|
||||
export const RoleEditorDrawer = ({ apps }: { apps: any[] }) => {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user