Compare commits
No commits in common. "fa2778d4fe6fbe878a95bb2e7bc404d2bc486d16" and "42b336173ed132f83af00c7eacbaf17457662b0f" have entirely different histories.
fa2778d4fe
...
42b336173e
@ -1,7 +1,6 @@
|
|||||||
import { AppDrawer } from "./admin/drawers/AppDrawer.tsx";
|
|
||||||
import { AdminAppsScript } from "./admin/AdminAppsScript.tsx";
|
|
||||||
import { AdminLayout } from "./AdminLayout.tsx";
|
import { AdminLayout } from "./AdminLayout.tsx";
|
||||||
import { AdminTable } from "./admin/AdminTable.tsx";
|
import { AdminTable } from "./admin/AdminTable.tsx";
|
||||||
|
import { AdminModal } from "./admin/AdminModal.tsx";
|
||||||
|
|
||||||
export const AdminAppsPage = ({
|
export const AdminAppsPage = ({
|
||||||
apps,
|
apps,
|
||||||
@ -61,7 +60,129 @@ export const AdminAppsPage = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AppDrawer />
|
{/* Register / Edit App Modal */}
|
||||||
|
<AdminModal
|
||||||
|
id="appFormModal"
|
||||||
|
title="Register New Subsidiary Application"
|
||||||
|
onClose="closeAppDrawer()"
|
||||||
|
>
|
||||||
|
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1.25rem 0;">
|
||||||
|
Authenticate incoming ConnectRPC/ForwardAuth requests against the
|
||||||
|
application's SPIFFE ID and edge routing domains.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form id="appForm" onsubmit="handleSaveApp(event)">
|
||||||
|
<input type="hidden" id="editAppId" value="" />
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; margin-bottom: 1rem;">
|
||||||
|
<div>
|
||||||
|
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||||
|
Application Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="appName"
|
||||||
|
name="name"
|
||||||
|
placeholder="e.g. Elite Dangerous Streaming Hub"
|
||||||
|
required
|
||||||
|
style="width: 100%;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div id="spiffeIdContainer">
|
||||||
|
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||||
|
SPIFFE ID (Workload Identity) *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="appSpiffeId"
|
||||||
|
name="spiffeId"
|
||||||
|
placeholder="e.g. spiffe://system.local/ed-droid-backend"
|
||||||
|
required
|
||||||
|
style="width: 100%;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 1rem;">
|
||||||
|
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="appDescription"
|
||||||
|
name="description"
|
||||||
|
placeholder="e.g. Data telemetry streaming and UI module system"
|
||||||
|
style="width: 100%;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 1rem;">
|
||||||
|
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||||
|
Domain (Edge Ingress Hostname)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="appDomain"
|
||||||
|
name="domain"
|
||||||
|
placeholder="e.g. ed-droid.atyg.org"
|
||||||
|
style="width: 100%;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 1rem;">
|
||||||
|
<label style="display: flex; align-items: center; gap: 0.5rem; font-weight: 600; font-size: 0.875rem; cursor: pointer; color: var(--text-primary);">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="appIsPublic"
|
||||||
|
name="is_public"
|
||||||
|
style="width: 18px; height: 18px;"
|
||||||
|
/>
|
||||||
|
Is Publicly Accessible (Bypass all auth checks)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; margin-bottom: 1.25rem;">
|
||||||
|
<div>
|
||||||
|
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||||
|
Bypass Paths (Comma-separated)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="appBypassPaths"
|
||||||
|
name="bypass_paths"
|
||||||
|
placeholder="e.g. /public/*, /webhook"
|
||||||
|
style="width: 100%;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||||
|
Allowed CIDRs (Comma-separated)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="appAllowedCidrs"
|
||||||
|
name="allowed_cidrs"
|
||||||
|
placeholder="e.g. 192.168.1.0/24"
|
||||||
|
style="width: 100%;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 0.75rem;">
|
||||||
|
<button type="submit" class="btn-primary" style="min-height: 42px;">
|
||||||
|
Save Application
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
onclick="closeAppDrawer()"
|
||||||
|
style="min-height: 42px;"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AdminModal>
|
||||||
|
|
||||||
<AdminTable
|
<AdminTable
|
||||||
id="appsTable"
|
id="appsTable"
|
||||||
@ -204,7 +325,124 @@ export const AdminAppsPage = ({
|
|||||||
`}
|
`}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<AdminAppsScript />
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
function showNotice(msg, isError) {
|
||||||
|
const banner = document.getElementById('status-banner');
|
||||||
|
banner.textContent = msg;
|
||||||
|
banner.style.display = 'block';
|
||||||
|
banner.style.background = isError ? 'var(--danger-bg)' : 'var(--success-bg)';
|
||||||
|
banner.style.color = isError ? 'var(--danger-text)' : 'var(--success-text)';
|
||||||
|
banner.style.border = isError ? '1px solid var(--danger-border)' : '1px solid var(--success-border)';
|
||||||
|
setTimeout(() => { banner.style.display = 'none'; }, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterAppsList() {
|
||||||
|
const query = document.getElementById('appSearchInput').value.toLowerCase().trim();
|
||||||
|
document.querySelectorAll('.app-row').forEach(r => {
|
||||||
|
const text = r.getAttribute('data-search') || '';
|
||||||
|
r.style.display = text.includes(query) ? '' : 'none';
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.app-card').forEach(c => {
|
||||||
|
const text = c.getAttribute('data-search') || '';
|
||||||
|
c.style.display = text.includes(query) ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateAppDrawer() {
|
||||||
|
document.getElementById('editAppId').value = '';
|
||||||
|
document.querySelector('#appFormModal h3').textContent = 'Register New Subsidiary Application';
|
||||||
|
document.getElementById('appName').value = '';
|
||||||
|
document.getElementById('appSpiffeId').value = '';
|
||||||
|
document.getElementById('appSpiffeId').readOnly = false;
|
||||||
|
document.getElementById('spiffeIdContainer').style.display = 'block';
|
||||||
|
document.getElementById('appDescription').value = '';
|
||||||
|
document.getElementById('appDomain').value = '';
|
||||||
|
document.getElementById('appIsPublic').checked = false;
|
||||||
|
document.getElementById('appBypassPaths').value = '';
|
||||||
|
document.getElementById('appAllowedCidrs').value = '';
|
||||||
|
document.getElementById('appFormModal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditAppDrawer(appJson) {
|
||||||
|
const app = JSON.parse(appJson);
|
||||||
|
document.getElementById('editAppId').value = app.id;
|
||||||
|
document.querySelector('#appFormModal h3').textContent = 'Edit Application: ' + app.name;
|
||||||
|
document.getElementById('appName').value = app.name || '';
|
||||||
|
document.getElementById('appSpiffeId').value = app.spiffe_id || '';
|
||||||
|
document.getElementById('appSpiffeId').readOnly = true;
|
||||||
|
document.getElementById('appDescription').value = app.description || '';
|
||||||
|
document.getElementById('appDomain').value = app.domain || '';
|
||||||
|
document.getElementById('appIsPublic').checked = !!app.is_public;
|
||||||
|
document.getElementById('appBypassPaths').value = Array.isArray(app.bypass_paths) ? app.bypass_paths.join(', ') : (app.bypass_paths || '');
|
||||||
|
document.getElementById('appAllowedCidrs').value = Array.isArray(app.allowed_cidrs) ? app.allowed_cidrs.join(', ') : (app.allowed_cidrs || '');
|
||||||
|
document.getElementById('appFormModal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAppDrawer() {
|
||||||
|
document.getElementById('appFormModal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveApp(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const editId = document.getElementById('editAppId').value;
|
||||||
|
const name = document.getElementById('appName').value.trim();
|
||||||
|
const spiffeId = document.getElementById('appSpiffeId').value.trim();
|
||||||
|
const description = document.getElementById('appDescription').value.trim();
|
||||||
|
const domain = document.getElementById('appDomain').value.trim();
|
||||||
|
const is_public = document.getElementById('appIsPublic').checked;
|
||||||
|
const bypass_paths = document.getElementById('appBypassPaths').value.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
const allowed_cidrs = document.getElementById('appAllowedCidrs').value.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
if (!name || (!editId && !spiffeId)) {
|
||||||
|
showNotice('Application name and SPIFFE ID are required', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = editId ? ('/api/admin/apps/' + editId) : '/api/admin/apps';
|
||||||
|
const method = editId ? 'PUT' : 'POST';
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, spiffeId, description, domain, is_public, bypass_paths, allowed_cidrs }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
showNotice(editId ? 'Application updated successfully!' : 'Application registered successfully!', false);
|
||||||
|
setTimeout(() => window.location.reload(), 600);
|
||||||
|
} else {
|
||||||
|
showNotice(data.error || 'Failed to save application', true);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showNotice('Network error saving application', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteApp(appId, appName) {
|
||||||
|
if (!confirm('Are you sure you want to delete "' + appName + '"? All active user permissions for this app will be revoked.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/apps/' + appId, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
showNotice('Application deleted', false);
|
||||||
|
setTimeout(() => window.location.reload(), 600);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
showNotice(data.error || 'Failed to delete application', true);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showNotice('Network error', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
</script>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
import { AdminLayout } from "./AdminLayout.tsx";
|
import { AdminLayout } from "./AdminLayout.tsx";
|
||||||
import { AdminTable } from "./admin/AdminTable.tsx";
|
import { AdminTable } from "./admin/AdminTable.tsx";
|
||||||
import { AdminModal } from "./admin/AdminModal.tsx";
|
import { AdminModal } from "./admin/AdminModal.tsx";
|
||||||
import { InviteDrawer } from "./admin/drawers/InviteDrawer.tsx";
|
|
||||||
import { AdminInvitesScript } from "./admin/AdminInvitesScript.tsx";
|
|
||||||
|
|
||||||
export const AdminInvitesPage = ({
|
export const AdminInvitesPage = ({
|
||||||
invites,
|
invites,
|
||||||
@ -68,7 +66,187 @@ export const AdminInvitesPage = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<InviteDrawer apps={apps} />
|
<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>
|
||||||
|
|
||||||
<AdminTable
|
<AdminTable
|
||||||
id="invitesTable"
|
id="invitesTable"
|
||||||
@ -306,7 +484,213 @@ export const AdminInvitesPage = ({
|
|||||||
`}
|
`}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<AdminInvitesScript allRoles={allRoles} />
|
<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';
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,15 +1,10 @@
|
|||||||
import { AdminLayout } from "./AdminLayout.tsx";
|
import { AdminLayout } from "./AdminLayout.tsx";
|
||||||
import type { AdminTable as _AdminTable } from "./admin/AdminTable.tsx";
|
import type { AdminTable as _AdminTable } from "./admin/AdminTable.tsx";
|
||||||
import { AdminModal } from "./admin/AdminModal.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.
|
// 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.
|
// 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.
|
// In Hono JSX, imports might not be strictly recognized.
|
||||||
const _AdminModal = AdminModal;
|
|
||||||
const _AdminRolesScript = AdminRolesScript;
|
|
||||||
const _RoleEditorDrawer = RoleEditorDrawer;
|
|
||||||
|
|
||||||
export const AdminRolesPage = ({
|
export const AdminRolesPage = ({
|
||||||
roles,
|
roles,
|
||||||
@ -46,7 +41,99 @@ export const AdminRolesPage = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create / Edit Role Drawer */}
|
{/* Create / Edit Role Drawer */}
|
||||||
<RoleEditorDrawer apps={apps} />
|
<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>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
{/* Instant Search and Scope Filters */}
|
{/* Instant Search and Scope Filters */}
|
||||||
|
|||||||
@ -1,13 +1,5 @@
|
|||||||
import { AdminLayout } from "./AdminLayout.tsx";
|
import { AdminLayout } from "./AdminLayout.tsx";
|
||||||
import { AdminTable } from "./admin/AdminTable.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 = ({
|
export const AdminUserDetailsPage = ({
|
||||||
user,
|
user,
|
||||||
@ -104,7 +96,55 @@ export const AdminUserDetailsPage = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grant New Application Form */}
|
{/* Grant New Application Form */}
|
||||||
<GrantDrawer user={user} allApps={allApps} />
|
<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>
|
||||||
|
|
||||||
<div style="margin-top: 1.25rem;">
|
<div style="margin-top: 1.25rem;">
|
||||||
<AdminTable
|
<AdminTable
|
||||||
@ -338,7 +378,175 @@ export const AdminUserDetailsPage = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AdminUserDetailsScript allRoles={allRoles} />
|
<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>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,123 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,211 +0,0 @@
|
|||||||
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';
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,154 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,178 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,128 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,187 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
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