feat(ui,api): polish a11y, mobile decks, app/role edit drawers, display name management, and instant search

This commit is contained in:
Tyler Gillispie 2026-08-24 23:21:35 -07:00
parent 509e6019b0
commit 85772659b7
9 changed files with 1329 additions and 436 deletions

View File

@ -1097,6 +1097,32 @@ app.post("/api/admin/users/:id/status", async (c) => {
return c.json({ success: true });
});
app.post("/api/admin/users/:id/profile", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
if (!(await isGlobalAdmin(auth.userId))) {
return c.json({ error: "Forbidden: Global admin access required" }, 403);
}
const targetUserId = c.req.param("id");
const { displayName } = await c.req.json();
const targetUser = await sqlWrapper
.sql`UPDATE users SET display_name = ${
displayName?.trim() || null
} WHERE id = ${targetUserId} RETURNING id, username, display_name`
.then((res: any) => res[0]);
if (!targetUser) return c.json({ error: "User not found" }, 404);
auditWrapper.auditLog(auth.userId, "user_profile_updated", targetUserId, {
display_name: targetUser.display_name,
}, getClientIp(c));
return c.json({ success: true, user: targetUser });
});
// ---------------------------------------------------------
// Traefik ForwardAuth Edge Proxy Route (Tier 2)
// ---------------------------------------------------------
@ -1309,6 +1335,52 @@ app.post("/api/admin/apps", async (c) => {
}
});
app.put("/api/admin/apps/:id", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
if (!(await isGlobalAdmin(auth.userId))) {
return c.json({ error: "Forbidden" }, 403);
}
const appId = c.req.param("id");
const {
name,
description,
domain,
is_public,
bypass_paths,
allowed_cidrs,
} = await c.req.json();
if (!name) {
return c.json({ error: "Application name is required" }, 400);
}
try {
const updatedApp = await sqlWrapper.sql`
UPDATE apps
SET name = ${name.trim()},
description = ${description?.trim() || null},
domain = ${domain?.trim() || null},
is_public = ${is_public || false},
bypass_paths = ${bypass_paths || []},
allowed_cidrs = ${allowed_cidrs || []}
WHERE id = ${appId}
RETURNING id, name, spiffe_id, description, domain, is_public, bypass_paths, allowed_cidrs
`.then((res: any) => res[0]);
if (!updatedApp) return c.json({ error: "Application not found" }, 404);
auditWrapper.auditLog(auth.userId, "app_updated", updatedApp.id, {
name: updatedApp.name,
}, getClientIp(c));
return c.json({ success: true, app: updatedApp });
} catch (_err: any) {
return c.json({ error: "Failed to update application" }, 500);
}
});
app.delete("/api/admin/apps/:id", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
@ -1429,6 +1501,43 @@ app.post("/api/admin/roles", async (c) => {
}
});
app.put("/api/admin/roles/:id", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
if (!(await isGlobalAdmin(auth.userId))) {
return c.json({ error: "Forbidden" }, 403);
}
const roleId = c.req.param("id");
const { name, description } = await c.req.json();
if (!name || typeof name !== "string" || name.trim().length < 2) {
return c.json({ error: "Role name must be at least 2 characters" }, 400);
}
const normalizedName = name.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "_");
try {
const updatedRole = await sqlWrapper.sql`
UPDATE roles
SET name = ${normalizedName},
description = ${description?.trim() || null}
WHERE id = ${roleId}
RETURNING id, name, description, app_id, created_at
`.then((res: any) => res[0]);
if (!updatedRole) return c.json({ error: "Role not found" }, 404);
auditWrapper.auditLog(auth.userId, "role_updated", updatedRole.app_id, {
role_name: updatedRole.name,
}, getClientIp(c));
return c.json({ success: true, role: updatedRole });
} catch (_err: any) {
return c.json({ error: "Failed to update role" }, 500);
}
});
app.delete("/api/admin/roles/:id", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);

View File

@ -9,39 +9,78 @@ export const AdminAppsPage = ({
<AdminLayout title="Application Registry" currentPath="/admin/apps">
<div
id="status-banner"
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: 4px; font-size: 0.9rem;"
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: var(--radius-md); font-size: 0.9rem;"
/>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h2 style="margin: 0; border: none; padding: 0;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
<div>
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
Connected Applications
</h2>
<button
type="button"
class="btn-action btn-success"
style="padding: 0.5rem 1rem; font-size: 0.9rem;"
onclick="toggleRegisterForm()"
>
+ Register Application
</button>
</h1>
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
Register and manage subsidiary workloads and Edge Ingress proxy
configurations.
</p>
</div>
<div
id="register-app-card"
class="card"
style="display: none; border-left: 4px solid #28a745; margin-bottom: 1.5rem;"
<div style="display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap;">
{/* Instant Search Bar */}
<div style="position: relative; min-width: 220px;">
<input
type="text"
id="appSearchInput"
placeholder="Search apps by name, domain..."
oninput="filterAppsList()"
style="width: 100%; padding: 0.5rem 1rem 0.5rem 2.25rem; font-size: 0.875rem;"
/>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
style="position: absolute; left: 0.75rem; top: 50%; transform: translateY(-50%); color: var(--text-muted); pointer-events: none;"
>
<h3>Register New Subsidiary Application</h3>
<p style="color: #6c757d; font-size: 0.9rem;">
Register an internal microservice or subsidiary application. The
system will authenticate incoming ConnectRPC requests against the
application's SPIFFE ID.
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</div>
<button
type="button"
class="btn-primary"
style="min-height: 40px;"
onclick="openCreateAppDrawer()"
>
+ Register App
</button>
</div>
</div>
{/* Register / Edit App Drawer */}
<div
id="appFormCard"
class="card"
style="display: none; border-left: 4px solid var(--primary); margin-bottom: 1.5rem;"
>
<h3
id="appFormTitle"
style="margin: 0 0 0.5rem 0; color: var(--text-primary);"
>
Register New Subsidiary Application
</h3>
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1.25rem 0;">
Authenticate incoming ConnectRPC/ForwardAuth requests against the
application's SPIFFE ID and edge routing domains.
</p>
<form id="registerAppForm" onsubmit="handleRegisterApp(event)">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem;">
<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.3rem; font-size: 0.85rem;">
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
Application Name *
</label>
<input
@ -50,11 +89,11 @@ export const AdminAppsPage = ({
name="name"
placeholder="e.g. Elite Dangerous Streaming Hub"
required
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"
style="width: 100%;"
/>
</div>
<div>
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
<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
@ -63,52 +102,52 @@ export const AdminAppsPage = ({
name="spiffeId"
placeholder="e.g. spiffe://system.local/ed-droid-backend"
required
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"
style="width: 100%;"
/>
</div>
</div>
<div style="margin-bottom: 1rem;">
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
Description (Optional)
<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. Headless data streaming hub and UI module system"
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"
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.3rem; font-size: 0.85rem;">
Domain (Optional, for Edge Ingress)
<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. api.example.com"
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"
placeholder="e.g. ed-droid.atyg.org"
style="width: 100%;"
/>
</div>
<div style="margin-bottom: 1rem;">
<label style="display: flex; align-items: center; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
<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="margin-right: 0.5rem;"
style="width: 18px; height: 18px;"
/>
Is Publicly Accessible (Bypass all auth checks)
</label>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem;">
<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.3rem; font-size: 0.85rem;">
<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
@ -116,11 +155,11 @@ export const AdminAppsPage = ({
id="appBypassPaths"
name="bypass_paths"
placeholder="e.g. /public/*, /webhook"
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"
style="width: 100%;"
/>
</div>
<div>
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
<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
@ -128,19 +167,20 @@ export const AdminAppsPage = ({
id="appAllowedCidrs"
name="allowed_cidrs"
placeholder="e.g. 192.168.1.0/24"
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"
style="width: 100%;"
/>
</div>
</div>
<div style="display: flex; gap: 0.5rem;">
<button type="submit" class="btn-action btn-success">
<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-action"
onclick="toggleRegisterForm()"
class="btn-outline"
onclick="closeAppDrawer()"
style="min-height: 42px;"
>
Cancel
</button>
@ -148,16 +188,17 @@ export const AdminAppsPage = ({
</form>
</div>
<div class="card">
{/* Desktop Table View (≥ 768px) */}
<div class="card desktop-only" style="display: none;">
<div class="table-container">
<table>
<table id="appsTable">
<thead>
<tr>
<th>Application Name</th>
<th>SPIFFE Workload ID</th>
<th>Active Users / Grants</th>
<th>SPIFFE ID</th>
<th>Domain</th>
<th>Active Users</th>
<th>Description</th>
<th>Registered Date</th>
<th>Actions</th>
</tr>
</thead>
@ -166,8 +207,8 @@ export const AdminAppsPage = ({
? (
<tr>
<td
colspan={6}
style="text-align: center; color: #6c757d; padding: 2rem;"
colSpan={6}
style="text-align: center; color: var(--text-muted); padding: 2rem;"
>
No connected applications registered yet.
</td>
@ -175,34 +216,55 @@ export const AdminAppsPage = ({
)
: (
apps.map((app) => (
<tr key={app.id}>
<tr
key={app.id}
class="app-row"
data-search={`${app.name} ${
app.domain || ""
} ${app.spiffe_id}`.toLowerCase()}
>
<td>
<strong>{app.name}</strong>
<strong style="color: var(--text-primary);">
{app.name}
</strong>
</td>
<td>
<code style="background: #e9ecef; padding: 0.2rem 0.4rem; border-radius: 3px; font-size: 0.8rem; color: #0d6efd;">
<code style="background: var(--surface-muted); padding: 0.2rem 0.4rem; border-radius: var(--radius-sm); font-size: 0.8rem; font-family: monospace; color: var(--primary);">
{app.spiffe_id}
</code>
</td>
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary);">
{app.domain || "-"}
</td>
<td>
<span class="badge badge-info">
{app.active_grants_count || 0} users
</span>
</td>
<td style="color: #6c757d; font-size: 0.85rem;">
<td style="color: var(--text-secondary); font-size: 0.85rem; max-width: 250px;">
{app.description || "-"}
</td>
<td style="font-size: 0.85rem;">
{new Date(app.created_at).toLocaleDateString()}
</td>
<td>
<div style="display: flex; gap: 0.35rem;">
<button
type="button"
class="btn-action btn-warning"
class="btn-outline"
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
onclick={`openEditAppDrawer(${
JSON.stringify(JSON.stringify(app))
})`}
>
Edit
</button>
<button
type="button"
class="btn-danger"
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
onclick={`deleteApp('${app.id}', '${app.name}')`}
>
Delete
</button>
</div>
</td>
</tr>
))
@ -212,6 +274,99 @@ export const AdminAppsPage = ({
</div>
</div>
{/* Mobile Adaptive Cards View (< 768px) */}
<div
id="appsMobileDeck"
class="mobile-only"
style="display: flex; flex-direction: column; gap: 1rem;"
>
{apps.length === 0
? (
<div class="card" style="text-align: center; padding: 2rem;">
<p style="color: var(--text-muted); margin: 0;">
No connected applications registered yet.
</p>
</div>
)
: (
apps.map((app) => (
<div
class="card app-card"
key={app.id}
data-search={`${app.name} ${app.domain || ""} ${app.spiffe_id}`
.toLowerCase()}
style="margin-bottom: 0;"
>
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.75rem;">
<div style="display: flex; align-items: center; gap: 0.65rem;">
<div style="display: flex; align-items: center; justify-content: center; width: 38px; height: 38px; background: var(--primary-light); color: var(--primary); border-radius: var(--radius-md); font-weight: 700;">
{app.name.charAt(0).toUpperCase()}
</div>
<div>
<h3 style="margin: 0; font-size: 1.05rem; color: var(--text-primary);">
{app.name}
</h3>
<span style="font-size: 0.8rem; color: var(--text-muted); font-family: monospace;">
{app.domain || "No domain"}
</span>
</div>
</div>
<span class="badge badge-info">
{app.active_grants_count || 0} Users
</span>
</div>
<div style="font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 1rem; line-height: 1.5;">
<p style="margin: 0 0 0.5rem 0;">
{app.description || "No description provided."}
</p>
<div>
<strong>SPIFFE:</strong>{" "}
<code style="font-family: monospace; font-size: 0.8rem;">
{app.spiffe_id}
</code>
</div>
</div>
<div style="display: flex; gap: 0.5rem;">
<button
type="button"
class="btn-outline"
style="flex: 1; justify-content: center; min-height: 40px; font-size: 0.85rem;"
onclick={`openEditAppDrawer(${
JSON.stringify(JSON.stringify(app))
})`}
>
Edit App
</button>
<button
type="button"
class="btn-danger"
style="flex: 1; justify-content: center; min-height: 40px; font-size: 0.85rem;"
onclick={`deleteApp('${app.id}', '${app.name}')`}
>
Delete
</button>
</div>
</div>
))
)}
</div>
<style>
{`
@media (min-width: 768px) {
.desktop-only { display: block !important; }
.mobile-only { display: none !important; }
}
@media (max-width: 767px) {
.desktop-only { display: none !important; }
.mobile-only { display: flex !important; }
}
`}
</style>
<script
dangerouslySetInnerHTML={{
__html: `
@ -219,19 +374,63 @@ export const AdminAppsPage = ({
const banner = document.getElementById('status-banner');
banner.textContent = msg;
banner.style.display = 'block';
banner.style.background = isError ? '#f8d7da' : '#d1e7dd';
banner.style.color = isError ? '#842029' : '#0f5132';
banner.style.border = isError ? '1px solid #f5c2c7' : '1px solid #badbcc';
setTimeout(() => { banner.style.display = 'none'; }, 6000);
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 toggleRegisterForm() {
const el = document.getElementById('register-app-card');
el.style.display = el.style.display === 'none' ? 'block' : 'none';
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';
});
}
async function handleRegisterApp(e) {
function openCreateAppDrawer() {
document.getElementById('editAppId').value = '';
document.getElementById('appFormTitle').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('appFormCard').style.display = 'block';
document.getElementById('appFormCard').scrollIntoView({ behavior: 'smooth' });
}
function openEditAppDrawer(appJson) {
const app = JSON.parse(appJson);
document.getElementById('editAppId').value = app.id;
document.getElementById('appFormTitle').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('appFormCard').style.display = 'block';
document.getElementById('appFormCard').scrollIntoView({ behavior: 'smooth' });
}
function closeAppDrawer() {
document.getElementById('appFormCard').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();
@ -240,26 +439,28 @@ export const AdminAppsPage = ({
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 || !spiffeId) {
showNotice('Name and SPIFFE ID are required', true);
if (!name || (!editId && !spiffeId)) {
showNotice('Application name and SPIFFE ID are required', true);
return;
}
try {
const res = await fetch('/api/admin/apps', {
method: 'POST',
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('Application registered successfully!', false);
setTimeout(() => window.location.reload(), 800);
showNotice(editId ? 'Application updated successfully!' : 'Application registered successfully!', false);
setTimeout(() => window.location.reload(), 600);
} else {
showNotice(data.error || 'Failed to register application', true);
showNotice(data.error || 'Failed to save application', true);
}
} catch (err) {
showNotice('Network error registering application', true);
showNotice('Network error saving application', true);
}
}
@ -273,7 +474,7 @@ export const AdminAppsPage = ({
});
if (res.ok) {
showNotice('Application deleted', false);
setTimeout(() => window.location.reload(), 800);
setTimeout(() => window.location.reload(), 600);
} else {
const data = await res.json();
showNotice(data.error || 'Failed to delete application', true);
@ -284,7 +485,8 @@ export const AdminAppsPage = ({
}
`,
}}
/>
>
</script>
</AdminLayout>
);
};

View File

@ -16,51 +16,78 @@ export const AdminInvitesPage = ({
>
<div
id="status-banner"
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: 4px; font-size: 0.9rem;"
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: var(--radius-md); font-size: 0.9rem;"
/>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
<div>
<h2 style="margin: 0; border: none; padding: 0;">
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
Invite & Onboarding Tokens
</h2>
<p style="color: #6c757d; font-size: 0.9rem; margin: 0.2rem 0 0 0;">
Issue single-use, team limited-use, or campaign-wide registration
tokens.
</h1>
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
Issue single-use, team limited-use, or campaign registration tokens.
</p>
</div>
<div style="display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap;">
{/* Instant Search Bar */}
<div style="position: relative; min-width: 220px;">
<input
type="text"
id="inviteSearchInput"
placeholder="Search invite tokens..."
oninput="filterInvitesList()"
style="width: 100%; padding: 0.5rem 1rem 0.5rem 2.25rem; font-size: 0.875rem;"
/>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
style="position: absolute; left: 0.75rem; top: 50%; transform: translateY(-50%); color: var(--text-muted); pointer-events: none;"
>
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</div>
<button
type="button"
class="btn-action btn-success"
style="padding: 0.5rem 1rem; font-size: 0.9rem;"
class="btn-primary"
style="min-height: 40px;"
onclick="toggleCreateInviteForm()"
>
+ Generate Onboarding Token
+ Generate Token
</button>
</div>
</div>
<div
id="create-invite-card"
class="card"
style="display: none; border-left: 4px solid #28a745; margin-bottom: 1.5rem;"
style="display: none; border-left: 4px solid var(--primary); margin-bottom: 1.5rem;"
>
<h3>Generate User Onboarding Token</h3>
<p style="color: #6c757d; font-size: 0.9rem;">
Configure time bounds, usage limits, role assignments, and initial
<h3 style="margin: 0 0 0.5rem 0; color: var(--text-primary);">
Generate User Onboarding Token
</h3>
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1.25rem 0;">
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: 1fr 1fr; gap: 1rem; margin-bottom: 1rem;">
<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.3rem; font-size: 0.85rem;">
<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%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box; background: white;"
style="width: 100%;"
>
<option value="site_scoped">
Type 2: Site-Scoped Token (Pre-Authorized for App)
@ -75,13 +102,13 @@ export const AdminInvitesPage = ({
</div>
<div id="appSelectContainer">
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
<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%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box; background: white;"
style="width: 100%;"
>
{apps.map((app) => (
<option value={app.id}>
@ -93,15 +120,15 @@ export const AdminInvitesPage = ({
</div>
{/* Row 2: Usage Limits & Assigned Role */}
<div style="display: grid; grid-template-columns: 1.5fr 1fr 1.5fr; gap: 1rem; margin-bottom: 1rem;">
<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.3rem; font-size: 0.85rem;">
Usage Policy (Capacity) *
<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%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box; background: white;"
style="width: 100%;"
>
<option value="single">
Single-Use (1 Person - Max Security)
@ -116,7 +143,7 @@ export const AdminInvitesPage = ({
</div>
<div id="maxUsesContainer" style="display: none;">
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
Max Registrations *
</label>
<input
@ -125,17 +152,17 @@ export const AdminInvitesPage = ({
value="5"
min="2"
max="1000"
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"
style="width: 100%;"
/>
</div>
<div id="roleSelectContainer">
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
<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%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box; background: white;"
style="width: 100%;"
>
{/* Dynamically populated */}
</select>
@ -143,9 +170,9 @@ export const AdminInvitesPage = ({
</div>
{/* Row 3: Expiration, Custom Code, Activation Toggle */}
<div style="display: grid; grid-template-columns: 1fr 1.5fr 1fr; gap: 1rem; margin-bottom: 1.2rem; align-items: flex-end;">
<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.3rem; font-size: 0.85rem;">
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
Expires In (Days)
</label>
<input
@ -154,43 +181,44 @@ export const AdminInvitesPage = ({
value="7"
min="1"
max="30"
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"
style="width: 100%;"
/>
</div>
<div>
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
<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%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"
style="width: 100%;"
/>
</div>
<div style="padding-bottom: 0.4rem;">
<label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; font-weight: 600; cursor: pointer;">
<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: 16px; height: 16px; cursor: pointer;"
style="width: 18px; height: 18px;"
/>
Auto-Activate Account
</label>
</div>
</div>
<div style="display: flex; gap: 0.5rem;">
<button type="submit" class="btn-action btn-success">
<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-action"
class="btn-outline"
onclick="toggleCreateInviteForm()"
style="min-height: 42px;"
>
Cancel
</button>
@ -199,18 +227,20 @@ export const AdminInvitesPage = ({
<div
id="generated-token-banner"
style="display: none; margin-top: 1rem; padding: 1rem; background: #e7f5ea; border: 1px solid #28a745; border-radius: 4px;"
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: #155724;">Token Created Successfully!</strong>
<div style="margin-top: 0.5rem; display: flex; gap: 0.5rem; align-items: center;">
<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.4rem 0.6rem; background: white; border: 1px solid #ced4da; border-radius: 4px; font-size: 0.9rem; flex: 1; word-break: break-all;"
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-action btn-success"
class="btn-primary"
onclick="copyGeneratedTokenUrl()"
>
Copy Link
@ -219,18 +249,17 @@ export const AdminInvitesPage = ({
</div>
</div>
{/* Invites Ledger Table */}
<div class="card">
{/* Desktop Ledger Table (≥ 768px) */}
<div class="card desktop-only" style="display: none;">
<div class="table-container">
<table>
<table id="invitesTable">
<thead>
<tr>
<th>Invite Code</th>
<th>Scope / App</th>
<th>Target App / Scope</th>
<th>Role</th>
<th>Usage & Capacity</th>
<th>Capacity & Usage</th>
<th>Status</th>
<th>Activation</th>
<th>Expires</th>
<th>Actions</th>
</tr>
@ -240,8 +269,8 @@ export const AdminInvitesPage = ({
? (
<tr>
<td
colspan={8}
style="text-align: center; color: #6c757d; padding: 2rem;"
colSpan={7}
style="text-align: center; color: var(--text-muted); padding: 2rem;"
>
No active or historical invite tokens found.
</td>
@ -250,27 +279,37 @@ export const AdminInvitesPage = ({
: (
invites.map((inv) => {
const usesCount = inv.uses_count || 0;
const maxUses = inv.max_uses; // null = unlimited, number = limit
const maxUses = inv.max_uses;
const isUnlimited = maxUses === null;
const isExhausted = !isUnlimited && usesCount >= maxUses;
const isExpired = new Date(inv.expires_at) < new Date();
const isActive = !isExhausted && !isExpired;
return (
<tr key={inv.id}>
<tr
key={inv.id}
class="invite-row"
data-search={`${inv.code} ${
inv.app_name || ""
} ${inv.role}`.toLowerCase()}
>
<td>
<code style="background: #e9ecef; padding: 0.2rem 0.4rem; border-radius: 3px; font-weight: bold; color: #212529;">
<code style="background: var(--surface-muted); padding: 0.25rem 0.5rem; border-radius: var(--radius-sm); font-weight: 700; font-family: monospace; color: var(--primary);">
{inv.code}
</code>
</td>
<td>
{inv.app_name
? <strong>{inv.app_name}</strong>
? (
<strong style="color: var(--text-primary);">
{inv.app_name}
</strong>
)
: inv.role === "admin"
? <span class="badge badge-info">Global Admin</span>
: (
<span class="badge badge-secondary">
General (Unassigned)
General (Open)
</span>
)}
</td>
@ -281,19 +320,21 @@ export const AdminInvitesPage = ({
<div style="min-width: 110px;">
{isUnlimited
? (
<span style="font-size: 0.85rem; font-weight: 500; color: #0d6efd;">
<span style="font-size: 0.85rem; font-weight: 600; color: var(--primary);">
{usesCount} claimed (Unlimited)
</span>
)
: (
<div>
<span style="font-size: 0.85rem; font-weight: 600;">
<span style="font-size: 0.85rem; font-weight: 600; color: var(--text-primary);">
{usesCount} / {maxUses} used
</span>
<div style="background: #e9ecef; border-radius: 3px; height: 6px; width: 100%; margin-top: 4px; overflow: hidden;">
<div style="background: var(--surface-muted); border-radius: 3px; height: 6px; width: 100%; margin-top: 4px; overflow: hidden;">
<div
style={`background: ${
isExhausted ? "#6c757d" : "#28a745"
isExhausted
? "var(--text-muted)"
: "var(--success)"
}; height: 100%; width: ${
Math.min(
100,
@ -311,34 +352,22 @@ export const AdminInvitesPage = ({
<span class="badge badge-secondary">Exhausted</span>
)}
{isExpired && !isExhausted && (
<span class="badge badge-suspended">Expired</span>
<span class="badge badge-danger">Expired</span>
)}
{isActive && (
<span class="badge badge-active">Active</span>
<span class="badge badge-success">Active</span>
)}
</td>
<td>
{inv.auto_activate !== false
? (
<span style="font-size: 0.8rem; color: #198754; font-weight: 500;">
Auto-Active
</span>
)
: (
<span style="font-size: 0.8rem; color: #fd7e14; font-weight: 500;">
Requires Approval
</span>
)}
</td>
<td style="font-size: 0.85rem;">
<td style="font-size: 0.85rem; color: var(--text-secondary);">
{new Date(inv.expires_at).toLocaleDateString()}
</td>
<td>
<div style="display: flex; gap: 0.3rem; flex-wrap: wrap;">
<div style="display: flex; gap: 0.35rem; flex-wrap: wrap;">
{isActive && (
<button
type="button"
class="btn-action btn-success"
class="btn-primary"
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
onclick={`copyInviteLink('${inv.code}')`}
>
Copy Link
@ -347,8 +376,8 @@ export const AdminInvitesPage = ({
{usesCount > 0 && (
<button
type="button"
class="btn-action"
style="background: #e2e3e5; color: #383d41;"
class="btn-outline"
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
onclick={`showRedemptionsModal('${inv.id}', '${inv.code}')`}
>
Claimed ({usesCount})
@ -357,7 +386,8 @@ export const AdminInvitesPage = ({
{isActive && (
<button
type="button"
class="btn-action btn-warning"
class="btn-danger"
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
onclick={`revokeInvite('${inv.id}', '${inv.code}')`}
>
Revoke
@ -374,21 +404,124 @@ export const AdminInvitesPage = ({
</div>
</div>
{/* Mobile Adaptive Cards View (< 768px) */}
<div
id="invitesMobileDeck"
class="mobile-only"
style="display: flex; flex-direction: column; gap: 0.75rem;"
>
{invites.map((inv) => {
const usesCount = inv.uses_count || 0;
const maxUses = inv.max_uses;
const isUnlimited = maxUses === null;
const isExhausted = !isUnlimited && usesCount >= maxUses;
const isExpired = new Date(inv.expires_at) < new Date();
const isActive = !isExhausted && !isExpired;
return (
<div
class="card invite-card"
key={inv.id}
data-search={`${inv.code} ${inv.app_name || ""} ${inv.role}`
.toLowerCase()}
style="margin-bottom: 0; padding: 1rem;"
>
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem;">
<div>
<code style="background: var(--surface-muted); padding: 0.25rem 0.5rem; border-radius: var(--radius-sm); font-weight: 700; font-family: monospace; font-size: 1rem; color: var(--primary);">
{inv.code}
</code>
<div style="font-size: 0.8rem; color: var(--text-muted); margin-top: 0.25rem;">
{inv.app_name || "Global / Open"} Role:{" "}
<strong>{inv.role}</strong>
</div>
</div>
{isExhausted && (
<span class="badge badge-secondary">Exhausted</span>
)}
{isExpired && !isExhausted && (
<span class="badge badge-danger">Expired</span>
)}
{isActive && <span class="badge badge-success">Active</span>}
</div>
<div style="margin-bottom: 0.75rem;">
<div style="font-size: 0.8rem; color: var(--text-secondary); display: flex; justify-content: space-between; margin-bottom: 0.25rem;">
<span>
Capacity:{" "}
{isUnlimited ? "Unlimited" : `${usesCount} / ${maxUses}`}
</span>
<span>
Expires: {new Date(inv.expires_at).toLocaleDateString()}
</span>
</div>
{!isUnlimited && (
<div style="background: var(--surface-muted); border-radius: 3px; height: 6px; width: 100%; overflow: hidden;">
<div
style={`background: ${
isExhausted ? "var(--text-muted)" : "var(--success)"
}; height: 100%; width: ${
Math.min(100, (usesCount / maxUses) * 100)
}%;`}
/>
</div>
)}
</div>
<div style="display: flex; gap: 0.5rem;">
{isActive && (
<button
type="button"
class="btn-primary"
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.85rem;"
onclick={`copyInviteLink('${inv.code}')`}
>
Copy Link
</button>
)}
{usesCount > 0 && (
<button
type="button"
class="btn-outline"
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.85rem;"
onclick={`showRedemptionsModal('${inv.id}', '${inv.code}')`}
>
Claimed ({usesCount})
</button>
)}
{isActive && (
<button
type="button"
class="btn-danger"
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.85rem;"
onclick={`revokeInvite('${inv.id}', '${inv.code}')`}
>
Revoke
</button>
)}
</div>
</div>
);
})}
</div>
{/* Redemptions Modal */}
<div
id="redemptions-modal"
style="display: none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.5); z-index: 9999; justify-content: center; align-items: center;"
style="display: none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.6); z-index: 9999; justify-content: center; align-items: center;"
>
<div style="background: white; border-radius: 8px; width: 90%; max-width: 550px; padding: 1.5rem; box-shadow: 0 4px 12px rgba(0,0,0,0.15);">
<div style="background: var(--surface-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); width: 90%; max-width: 550px; padding: 1.5rem; box-shadow: var(--shadow-lg);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 style="margin: 0; font-size: 1.1rem;">
<h3 style="margin: 0; font-size: 1.1rem; color: var(--text-primary);">
Users Claimed:{" "}
<code id="modal-invite-code" style="color: #0d6efd;"></code>
<code id="modal-invite-code" style="color: var(--primary);">
</code>
</h3>
<button
type="button"
onclick="closeRedemptionsModal()"
style="background: none; border: none; font-size: 1.2rem; cursor: pointer; color: #6c757d;"
style="background: none; border: none; font-size: 1.2rem; cursor: pointer; color: var(--text-muted);"
>
&times;
</button>
@ -398,7 +531,7 @@ export const AdminInvitesPage = ({
id="modal-redemptions-content"
style="max-height: 350px; overflow-y: auto;"
>
<p style="color: #6c757d; font-size: 0.9rem;">
<p style="color: var(--text-muted); font-size: 0.9rem;">
Loading claimed users...
</p>
</div>
@ -406,7 +539,7 @@ export const AdminInvitesPage = ({
<div style="text-align: right; margin-top: 1rem;">
<button
type="button"
class="btn-action"
class="btn-outline"
onclick="closeRedemptionsModal()"
>
Close
@ -415,6 +548,19 @@ export const AdminInvitesPage = ({
</div>
</div>
<style>
{`
@media (min-width: 768px) {
.desktop-only { display: block !important; }
.mobile-only { display: none !important; }
}
@media (max-width: 767px) {
.desktop-only { display: none !important; }
.mobile-only { display: flex !important; }
}
`}
</style>
<script
dangerouslySetInnerHTML={{
__html: `
@ -453,10 +599,10 @@ export const AdminInvitesPage = ({
const banner = document.getElementById('status-banner');
banner.textContent = msg;
banner.style.display = 'block';
banner.style.background = isError ? '#f8d7da' : '#d1e7dd';
banner.style.color = isError ? '#842029' : '#0f5132';
banner.style.border = isError ? '1px solid #f5c2c7' : '1px solid #badbcc';
setTimeout(() => { banner.style.display = 'none'; }, 6000);
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() {
@ -485,6 +631,18 @@ export const AdminInvitesPage = ({
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;
@ -538,7 +696,7 @@ export const AdminInvitesPage = ({
function copyGeneratedTokenUrl() {
const text = document.getElementById('generatedTokenUrl').textContent;
navigator.clipboard.writeText(text);
showNotice('Registration URL copied to clipboard: ' + text, false);
showNotice('Registration URL copied to clipboard!', false);
}
function copyInviteLink(code) {
@ -555,7 +713,7 @@ export const AdminInvitesPage = ({
});
if (res.ok) {
showNotice('Invite token revoked', false);
setTimeout(() => window.location.reload(), 800);
setTimeout(() => window.location.reload(), 600);
} else {
const data = await res.json();
showNotice(data.error || 'Failed to revoke invite', true);
@ -571,7 +729,7 @@ export const AdminInvitesPage = ({
const contentEl = document.getElementById('modal-redemptions-content');
codeEl.textContent = code;
contentEl.innerHTML = '<p style="color: #6c757d;">Loading...</p>';
contentEl.innerHTML = '<p style="color: var(--text-muted);">Loading...</p>';
modal.style.display = 'flex';
try {
@ -579,25 +737,25 @@ export const AdminInvitesPage = ({
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 style="text-align: left; border-bottom: 2px solid #dee2e6;">';
html += '<th style="padding: 0.4rem;">Username</th>';
html += '<th style="padding: 0.4rem;">Status</th>';
html += '<th style="padding: 0.4rem;">Redeemed At</th>';
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 style="border-bottom: 1px solid #dee2e6;">';
html += '<td style="padding: 0.4rem;"><strong>' + r.username + '</strong></td>';
html += '<td style="padding: 0.4rem;"><span class="badge badge-' + r.account_status + '">' + r.account_status + '</span></td>';
html += '<td style="padding: 0.4rem; color: #6c757d;">' + new Date(r.redeemed_at).toLocaleString() + '</td>';
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: #6c757d; text-align: center; padding: 1rem;">No users have redeemed this token yet.</p>';
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: #dc3545;">Failed to load redemption details.</p>';
contentEl.innerHTML = '<p style="color: var(--danger);">Failed to load redemption details.</p>';
}
}

View File

@ -52,7 +52,7 @@ export const AdminLayout = ({
.admin-brand {
display: flex;
align-items: center;
gap: 0.75rem;
gap: 0.65rem;
text-decoration: none;
color: var(--text-primary);
font-weight: 700;
@ -170,7 +170,11 @@ export const AdminLayout = ({
{/* Top Header */}
<header class="admin-header">
<div style="display: flex; align-items: center; gap: 0.75rem;">
<a href="/dashboard" class="admin-brand">
<a
href="/admin/users"
class="admin-brand"
title="Auth-Yes Admin Console"
>
<span>Auth-Yes</span>
<span class="admin-badge">Admin</span>
</a>
@ -180,14 +184,14 @@ export const AdminLayout = ({
<a
href="/dashboard"
class="btn-outline"
style="padding: 0.35rem 0.75rem; font-size: 0.85rem; min-height: 36px; text-decoration: none;"
style="padding: 0.35rem 0.75rem; font-size: 0.85rem; min-height: 36px; display: inline-flex; align-items: center; justify-content: center; text-decoration: none;"
>
User Hub
</a>
<a
href="/logout"
class="btn-danger"
style="padding: 0.35rem 0.75rem; font-size: 0.85rem; min-height: 36px; text-decoration: none;"
style="padding: 0.35rem 0.75rem; font-size: 0.85rem; min-height: 36px; display: inline-flex; align-items: center; justify-content: center; text-decoration: none;"
>
Logout
</a>

View File

@ -11,48 +11,60 @@ export const AdminRolesPage = ({
<AdminLayout title="Role & Permission Catalog" currentPath="/admin/roles">
<div
id="status-banner"
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: 4px; font-size: 0.9rem;"
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: var(--radius-md); font-size: 0.9rem;"
/>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
<div>
<h2 style="margin: 0; border: none; padding: 0;">
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
Role & Permission Catalog
</h2>
<p style="color: #6c757d; font-size: 0.9rem; margin: 0.2rem 0 0 0;">
Manage global and application-specific RBAC roles and permissions.
</h1>
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
Manage global and application-scoped RBAC roles and permissions.
</p>
</div>
<button
type="button"
class="btn-action btn-success"
style="padding: 0.5rem 1rem; font-size: 0.9rem;"
onclick="toggleCreateRoleForm()"
class="btn-primary"
style="min-height: 40px;"
onclick="openCreateRoleDrawer()"
>
+ Create Custom Role
</button>
</div>
{/* Create / Edit Role Drawer */}
<div
id="create-role-card"
id="roleFormCard"
class="card"
style="display: none; border-left: 4px solid #28a745; margin-bottom: 1.5rem;"
style="display: none; border-left: 4px solid var(--primary); margin-bottom: 1.5rem;"
>
<h3>Create New Role</h3>
<p style="color: #6c757d; font-size: 0.9rem;">
Define a global shared role or an application-scoped custom role.
<h3
id="roleFormTitle"
style="margin: 0 0 0.5rem 0; color: var(--text-primary);"
>
Create New Role
</h3>
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1.25rem 0;">
Define a global shared role or an application-scoped custom grant.
</p>
<form id="createRoleForm" onsubmit="handleCreateRole(event)">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem;">
<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.3rem; font-size: 0.85rem;">
<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%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box; background: white;"
style="width: 100%;"
>
<option value="global">
Global (Shared across ALL applications)
@ -64,12 +76,12 @@ export const AdminRolesPage = ({
</div>
<div id="appSelectContainer" style="display: none;">
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
<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%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box; background: white;"
style="width: 100%;"
>
{apps.map((app) => (
<option value={app.id}>
@ -80,9 +92,9 @@ export const AdminRolesPage = ({
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 1rem; margin-bottom: 1rem;">
<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.3rem; font-size: 0.85rem;">
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
Role Identifier *
</label>
<input
@ -90,31 +102,32 @@ export const AdminRolesPage = ({
id="roleName"
placeholder="e.g. navigator, copilot, auditor"
required
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"
style="width: 100%;"
/>
</div>
<div>
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
<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%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"
style="width: 100%;"
/>
</div>
</div>
<div style="display: flex; gap: 0.5rem;">
<button type="submit" class="btn-action btn-success">
<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-action"
onclick="toggleCreateRoleForm()"
class="btn-outline"
onclick="closeRoleDrawer()"
style="min-height: 42px;"
>
Cancel
</button>
@ -123,16 +136,41 @@ export const AdminRolesPage = ({
</div>
<div class="card">
{/* Filter Controls */}
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem;">
<div style="display: flex; gap: 0.5rem; align-items: center;">
<label style="font-weight: 600; font-size: 0.85rem;">
Filter Scope:
{/* Instant Search and Scope Filters */}
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.25rem; flex-wrap: wrap; gap: 0.75rem;">
<div style="display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; flex: 1;">
{/* Search */}
<div style="position: relative; min-width: 200px; max-width: 300px; width: 100%;">
<input
type="text"
id="roleSearchInput"
placeholder="Search roles..."
oninput="filterRoles()"
style="width: 100%; padding: 0.45rem 0.85rem 0.45rem 2.1rem; font-size: 0.85rem;"
/>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
style="position: absolute; left: 0.7rem; top: 50%; transform: translateY(-50%); color: var(--text-muted); pointer-events: none;"
>
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</div>
{/* Scope dropdown */}
<div style="display: flex; gap: 0.4rem; align-items: center;">
<label style="font-weight: 600; font-size: 0.85rem; color: var(--text-secondary); white-space: nowrap;">
Scope:
</label>
<select
id="filterScopeSelect"
onchange="filterRolesTable()"
style="padding: 0.35rem 0.6rem; border: 1px solid #ced4da; border-radius: 4px; background: white; font-size: 0.85rem;"
onchange="filterRoles()"
style="padding: 0.4rem 0.75rem; font-size: 0.85rem;"
>
<option value="all">All Roles</option>
<option value="global">Global (Shared) Only</option>
@ -143,20 +181,22 @@ export const AdminRolesPage = ({
))}
</select>
</div>
</div>
<span
id="roleCountDisplay"
style="font-size: 0.85rem; color: #6c757d;"
style="font-size: 0.85rem; color: var(--text-muted);"
>
Showing {roles.length} roles
</span>
</div>
<div class="table-container">
{/* Desktop Table View (≥ 768px) */}
<div class="table-container desktop-only" style="display: none;">
<table id="rolesTable">
<thead>
<tr>
<th>Role Name</th>
<th>Role Identifier</th>
<th>Scope</th>
<th>Description</th>
<th>Created</th>
@ -168,8 +208,8 @@ export const AdminRolesPage = ({
? (
<tr>
<td
colspan={5}
style="text-align: center; color: #6c757d; padding: 2rem;"
colSpan={5}
style="text-align: center; color: var(--text-muted); padding: 2rem;"
>
No roles found.
</td>
@ -181,9 +221,16 @@ export const AdminRolesPage = ({
const isCoreAdmin = isGlobal && r.name === "admin";
return (
<tr key={r.id} data-app-id={r.app_id || "global"}>
<tr
key={r.id}
class="role-row"
data-app-id={r.app_id || "global"}
data-search={`${r.name} ${r.description || ""} ${
isGlobal ? "global" : r.app_name || ""
}`.toLowerCase()}
>
<td>
<strong style="font-family: monospace; font-size: 0.95rem; color: #212529;">
<strong style="font-family: monospace; font-size: 0.95rem; color: var(--text-primary);">
{r.name}
</strong>
</td>
@ -195,30 +242,43 @@ export const AdminRolesPage = ({
</span>
)
: (
<span class="badge badge-pending">
<span class="badge badge-warning">
{r.app_name || "App-Specific"}
</span>
)}
</td>
<td style="color: #495057; font-size: 0.85rem;">
<td style="color: var(--text-secondary); font-size: 0.85rem;">
{r.description || "-"}
</td>
<td style="font-size: 0.85rem;">
<td style="font-size: 0.85rem; color: var(--text-secondary);">
{new Date(r.created_at).toLocaleDateString()}
</td>
<td>
{!isCoreAdmin
? (
<div style="display: flex; gap: 0.35rem;">
<button
type="button"
class="btn-action btn-warning"
class="btn-outline"
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
onclick={`openEditRoleDrawer(${
JSON.stringify(JSON.stringify(r))
})`}
>
Edit
</button>
<button
type="button"
class="btn-danger"
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
onclick={`deleteRole('${r.id}', '${r.name}')`}
>
Delete
</button>
</div>
)
: (
<span style="color: #6c757d; font-size: 0.8rem; font-style: italic;">
<span style="color: var(--text-muted); font-size: 0.8rem; font-style: italic;">
System Core
</span>
)}
@ -230,8 +290,91 @@ export const AdminRolesPage = ({
</tbody>
</table>
</div>
{/* Mobile Adaptive Cards View (< 768px) */}
<div
id="rolesMobileDeck"
class="mobile-only"
style="display: flex; flex-direction: column; gap: 0.75rem;"
>
{roles.map((r) => {
const isGlobal = !r.app_id;
const isCoreAdmin = isGlobal && r.name === "admin";
return (
<div
class="card role-card"
key={r.id}
data-app-id={r.app_id || "global"}
data-search={`${r.name} ${r.description || ""} ${
isGlobal ? "global" : r.app_name || ""
}`.toLowerCase()}
style="margin-bottom: 0; padding: 1rem;"
>
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem;">
<strong style="font-family: monospace; font-size: 1rem; color: var(--text-primary);">
{r.name}
</strong>
{isGlobal
? <span class="badge badge-info">Global</span>
: (
<span class="badge badge-warning">
{r.app_name || "App Scoped"}
</span>
)}
</div>
<p style="margin: 0 0 0.75rem 0; font-size: 0.85rem; color: var(--text-secondary);">
{r.description || "No description provided."}
</p>
{!isCoreAdmin
? (
<div style="display: flex; gap: 0.5rem;">
<button
type="button"
class="btn-outline"
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.85rem;"
onclick={`openEditRoleDrawer(${
JSON.stringify(JSON.stringify(r))
})`}
>
Edit
</button>
<button
type="button"
class="btn-danger"
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.85rem;"
onclick={`deleteRole('${r.id}', '${r.name}')`}
>
Delete
</button>
</div>
)
: (
<div style="font-size: 0.8rem; color: var(--text-muted); font-style: italic;">
Protected System Core Role
</div>
)}
</div>
);
})}
</div>
</div>
<style>
{`
@media (min-width: 768px) {
.desktop-only { display: block !important; }
.mobile-only { display: none !important; }
}
@media (max-width: 767px) {
.desktop-only { display: none !important; }
.mobile-only { display: flex !important; }
}
`}
</style>
<script
dangerouslySetInnerHTML={{
__html: `
@ -239,15 +382,35 @@ export const AdminRolesPage = ({
const banner = document.getElementById('status-banner');
banner.textContent = msg;
banner.style.display = 'block';
banner.style.background = isError ? '#f8d7da' : '#d1e7dd';
banner.style.color = isError ? '#842029' : '#0f5132';
banner.style.border = isError ? '1px solid #f5c2c7' : '1px solid #badbcc';
setTimeout(() => { banner.style.display = 'none'; }, 6000);
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 toggleCreateRoleForm() {
const el = document.getElementById('create-role-card');
el.style.display = el.style.display === 'none' ? 'block' : 'none';
function openCreateRoleDrawer() {
document.getElementById('editRoleId').value = '';
document.getElementById('roleFormTitle').textContent = 'Create New Role';
document.getElementById('scopeSelectContainer').style.display = 'grid';
document.getElementById('roleName').value = '';
document.getElementById('roleDescription').value = '';
document.getElementById('roleFormCard').style.display = 'block';
document.getElementById('roleFormCard').scrollIntoView({ behavior: 'smooth' });
}
function openEditRoleDrawer(roleJson) {
const role = JSON.parse(roleJson);
document.getElementById('editRoleId').value = role.id;
document.getElementById('roleFormTitle').textContent = 'Edit Role: ' + role.name;
document.getElementById('scopeSelectContainer').style.display = 'none';
document.getElementById('roleName').value = role.name || '';
document.getElementById('roleDescription').value = role.description || '';
document.getElementById('roleFormCard').style.display = 'block';
document.getElementById('roleFormCard').scrollIntoView({ behavior: 'smooth' });
}
function closeRoleDrawer() {
document.getElementById('roleFormCard').style.display = 'none';
}
function handleScopeChange() {
@ -256,40 +419,47 @@ export const AdminRolesPage = ({
appContainer.style.display = scope === 'app_specific' ? 'block' : 'none';
}
function filterRolesTable() {
const selected = document.getElementById('filterScopeSelect').value;
const rows = document.querySelectorAll('#rolesTable tbody tr');
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;
rows.forEach((row) => {
const rowAppId = row.getAttribute('data-app-id');
if (!rowAppId) return;
const checkMatch = (appId, searchText) => {
const scopeMatch = selectedScope === 'all' || (selectedScope === 'global' && appId === 'global') || (appId === selectedScope);
const textMatch = !query || searchText.includes(query);
return scopeMatch && textMatch;
};
if (selected === 'all') {
row.style.display = '';
visibleCount++;
} else if (selected === 'global') {
const isGlobal = rowAppId === 'global';
row.style.display = isGlobal ? '' : 'none';
if (isGlobal) visibleCount++;
} else {
const isMatch = rowAppId === selected;
row.style.display = isMatch ? '' : 'none';
if (isMatch) visibleCount++;
}
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 handleCreateRole(e) {
async function handleSaveRole(e) {
e.preventDefault();
const scope = document.getElementById('roleScope').value;
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 (scope === 'app_specific') {
if (!editId && scope === 'app_specific') {
appId = document.getElementById('roleAppId').value;
}
@ -299,20 +469,22 @@ export const AdminRolesPage = ({
}
try {
const res = await fetch('/api/admin/roles', {
method: 'POST',
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('Role "' + name + '" created successfully!', false);
setTimeout(() => window.location.reload(), 800);
showNotice(editId ? 'Role updated successfully!' : 'Role created successfully!', false);
setTimeout(() => window.location.reload(), 600);
} else {
showNotice(data.error || 'Failed to create role', true);
showNotice(data.error || 'Failed to save role', true);
}
} catch (err) {
showNotice('Network error creating role', true);
showNotice('Network error saving role', true);
}
}
@ -324,7 +496,7 @@ export const AdminRolesPage = ({
});
if (res.ok) {
showNotice('Role deleted', false);
setTimeout(() => window.location.reload(), 800);
setTimeout(() => window.location.reload(), 600);
} else {
const data = await res.json();
showNotice(data.error || 'Failed to delete role', true);

View File

@ -19,32 +19,75 @@ export const AdminUserDetailsPage = ({
<AdminLayout title={`User: ${user.username}`} currentPath="/admin/users">
<div
id="status-banner"
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: 4px; font-size: 0.9rem;"
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: var(--radius-md); font-size: 0.9rem;"
/>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
<div>
<h2 style="margin: 0; border: none; padding: 0;">
User Profile: {user.username}
</h2>
<span style="font-size: 0.85rem; color: #6c757d;">
<h1 style="margin: 0 0 0.25rem 0; font-size: 1.75rem; font-weight: 700; color: var(--text-primary);">
User Profile: @{user.username}
</h1>
<span style="font-size: 0.85rem; color: var(--text-muted); font-family: monospace;">
UUID: {user.id}
</span>
</div>
<a
href="/admin/users"
style="color: #007bff; text-decoration: none; font-weight: 500;"
class="btn-outline"
style="text-decoration: none; font-size: 0.85rem; min-height: 36px;"
>
&larr; Back to Users
</a>
</div>
{/* Profile & Display Name Editor Card */}
<div class="card" style="margin-bottom: 1.5rem;">
<h3 style="margin: 0 0 0.5rem 0; color: var(--text-primary);">
Identity Details & Display Name
</h3>
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1.25rem 0;">
Human-friendly display name passed to connected apps in the{" "}
<code style="font-family: monospace;">X-Forwarded-User</code> header.
</p>
<form
id="editProfileForm"
onsubmit={`handleUpdateProfile(event, '${user.id}')`}
style="display: flex; gap: 0.75rem; align-items: flex-end; flex-wrap: wrap; max-width: 550px;"
>
<div style="flex: 1; min-width: 240px;">
<label style="display: block; font-size: 0.85rem; font-weight: 600; margin-bottom: 0.35rem; color: var(--text-secondary);">
Display Name
</label>
<input
type="text"
id="displayNameInput"
value={user.display_name || ""}
placeholder={`e.g. Tyler Gillispie (defaults to @${user.username})`}
style="width: 100%;"
/>
</div>
<button
type="submit"
class="btn-primary"
style="min-height: 44px;"
>
Save Display Name
</button>
</form>
</div>
{/* Application RBAC Access Matrix */}
<div class="card" style="border-left: 4px solid #0d6efd;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div
class="card"
style="border-left: 4px solid var(--primary); margin-bottom: 1.5rem;"
>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;">
<div>
<h3 style="margin: 0;">Application Access & RBAC Grants</h3>
<p style="color: #6c757d; font-size: 0.9rem; margin-top: 0.2rem; margin-bottom: 0;">
<h3 style="margin: 0; color: var(--text-primary);">
Application Access & RBAC Grants
</h3>
<p style="color: var(--text-secondary); font-size: 0.9rem; margin-top: 0.2rem; margin-bottom: 0;">
Manage this user's explicit permissions across registered
applications (Default-Deny Zero-Trust).
</p>
@ -52,8 +95,8 @@ export const AdminUserDetailsPage = ({
</div>
{/* Grant New Application Form */}
<div style="margin-top: 1rem; padding: 1rem; background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px;">
<h4 style="margin: 0 0 0.5rem 0; font-size: 0.9rem;">
<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
@ -62,14 +105,14 @@ export const AdminUserDetailsPage = ({
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.2rem;">
<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%; padding: 0.45rem; border: 1px solid #ced4da; border-radius: 4px; background: white;"
style="width: 100%;"
>
{allApps.map((app) => (
<option value={app.id}>
@ -80,13 +123,13 @@ export const AdminUserDetailsPage = ({
</div>
<div style="flex: 1; min-width: 140px;">
<label style="display: block; font-size: 0.8rem; font-weight: 600; margin-bottom: 0.2rem;">
<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%; padding: 0.45rem; border: 1px solid #ced4da; border-radius: 4px; background: white;"
style="width: 100%;"
>
{/* Dynamically populated */}
</select>
@ -94,15 +137,15 @@ export const AdminUserDetailsPage = ({
<button
type="submit"
class="btn-action btn-success"
style="padding: 0.5rem 1rem; height: fit-content;"
class="btn-primary"
style="min-height: 44px;"
>
Save Grant
</button>
</form>
</div>
<div class="table-container" style="margin-top: 1rem;">
<div class="table-container" style="margin-top: 1.25rem;">
<table>
<thead>
<tr>
@ -118,8 +161,8 @@ export const AdminUserDetailsPage = ({
? (
<tr>
<td
colspan={5}
style="text-align: center; color: #dc3545; padding: 1.5rem;"
colSpan={5}
style="text-align: center; color: var(--danger); padding: 1.5rem;"
>
No application permissions granted (User is blocked from
all subsidiary apps).
@ -130,31 +173,28 @@ export const AdminUserDetailsPage = ({
grants.map((grant) => (
<tr key={grant.id}>
<td>
<strong>{grant.app_name}</strong>
<strong style="color: var(--text-primary);">
{grant.app_name}
</strong>
</td>
<td>
<code style="background: #e9ecef; padding: 0.2rem 0.4rem; border-radius: 3px; font-size: 0.8rem; color: #0d6efd;">
<code style="background: var(--surface-muted); padding: 0.2rem 0.4rem; border-radius: var(--radius-sm); font-size: 0.8rem; font-family: monospace;">
{grant.spiffe_id}
</code>
</td>
<td>
<span
class={`badge ${
grant.role === "admin"
? "badge-suspended"
: "badge-info"
}`}
>
<span class="badge badge-info">
{grant.role}
</span>
</td>
<td style="font-size: 0.85rem;">
<td style="font-size: 0.85rem; color: var(--text-secondary);">
{new Date(grant.created_at).toLocaleDateString()}
</td>
<td>
<button
type="button"
class="btn-action btn-warning"
class="btn-danger"
style="padding: 0.35rem 0.75rem; font-size: 0.8rem; min-height: 32px;"
onclick={`revokeGrant('${user.id}', '${grant.app_id}', '${grant.app_name}')`}
>
Revoke Access
@ -169,51 +209,55 @@ export const AdminUserDetailsPage = ({
</div>
{/* Out-of-band Recovery */}
<div class="card">
<h3>Out-of-Band Account Recovery</h3>
<p style="color: #6c757d; font-size: 0.9rem;">
<div class="card" style="margin-bottom: 1.5rem;">
<h3 style="margin: 0 0 0.5rem 0; color: var(--text-primary);">
Out-of-Band Account Recovery
</h3>
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1rem 0;">
Generate a one-time recovery link to allow the user to bind a new
hardware passkey if all devices are lost.
</p>
<button
type="button"
class="btn-action btn-success"
class="btn-primary"
onclick={`generateRecoveryLink('${user.id}')`}
>
Generate Recovery Link
</button>
<div
id="recovery-link-container"
style="display: none; margin-top: 1rem; padding: 1rem; background: #f8f9fa; border: 1px solid #ced4da; border-radius: 4px;"
style="display: none; margin-top: 1rem; padding: 1rem; background: var(--surface-muted); border: 1px solid var(--border-subtle); border-radius: var(--radius-md);"
>
<p style="margin-top: 0; font-weight: 500;">
<p style="margin-top: 0; font-weight: 600; color: var(--text-primary);">
Provide this link to the user:
</p>
<code
id="recovery-link-text"
style="display: block; word-break: break-all; margin-bottom: 0.5rem; color: #d63384;"
style="display: block; word-break: break-all; margin-bottom: 0.5rem; color: var(--primary); font-family: monospace; background: var(--surface-card); padding: 0.5rem; border-radius: var(--radius-sm); border: 1px solid var(--border-subtle);"
>
</code>
<p style="margin-bottom: 0; font-size: 0.85rem; color: #6c757d;">
<p style="margin-bottom: 0; font-size: 0.85rem; color: var(--text-muted);">
Link expires in 24 hours.
</p>
</div>
</div>
{/* Active Sessions */}
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h3 style="margin: 0;">Active Sessions</h3>
<div class="card" style="margin-bottom: 1.5rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 style="margin: 0; color: var(--text-primary);">
Active Sessions
</h3>
<button
type="button"
class="btn-action btn-warning"
class="btn-danger"
onclick={`revokeAllSessions('${user.id}')`}
>
Revoke All Sessions
</button>
</div>
<div class="table-container" style="margin-top: 1rem;">
<div class="table-container">
<table>
<thead>
<tr>
@ -227,7 +271,10 @@ export const AdminUserDetailsPage = ({
{sessions.length === 0
? (
<tr>
<td colspan={4} style="text-align: center; color: #6c757d;">
<td
colSpan={4}
style="text-align: center; color: var(--text-muted); padding: 1.5rem;"
>
No active sessions.
</td>
</tr>
@ -236,16 +283,21 @@ export const AdminUserDetailsPage = ({
sessions.map((session) => (
<tr key={session.id}>
<td>
<code style="background: #f8f9fa; padding: 0.2rem 0.4rem; border-radius: 3px;">
{session.id.substring(0, 8)}...
<code style="background: var(--surface-muted); padding: 0.2rem 0.4rem; border-radius: var(--radius-sm); font-family: monospace;">
{session.id.substring(0, 12)}...
</code>
</td>
<td>{new Date(session.created_at).toLocaleString()}</td>
<td>{new Date(session.expires_at).toLocaleString()}</td>
<td style="color: var(--text-secondary);">
{new Date(session.created_at).toLocaleString()}
</td>
<td style="color: var(--text-secondary);">
{new Date(session.expires_at).toLocaleString()}
</td>
<td>
<button
type="button"
class="btn-action btn-warning"
class="btn-danger"
style="padding: 0.35rem 0.75rem; font-size: 0.8rem; min-height: 32px;"
onclick={`revokeSession('${session.id}')`}
>
Revoke
@ -261,8 +313,10 @@ export const AdminUserDetailsPage = ({
{/* Registered Passkeys */}
<div class="card">
<h3 style="margin-top: 0;">Registered Passkeys</h3>
<div class="table-container" style="margin-top: 1rem;">
<h3 style="margin: 0 0 1rem 0; color: var(--text-primary);">
Registered Passkeys
</h3>
<div class="table-container">
<table>
<thead>
<tr>
@ -275,7 +329,10 @@ export const AdminUserDetailsPage = ({
{passkeys.length === 0
? (
<tr>
<td colspan={3} style="text-align: center; color: #6c757d;">
<td
colSpan={3}
style="text-align: center; color: var(--text-muted); padding: 1.5rem;"
>
No registered passkeys.
</td>
</tr>
@ -284,15 +341,18 @@ export const AdminUserDetailsPage = ({
passkeys.map((pk) => (
<tr key={pk.id}>
<td>
<code style="background: #f8f9fa; padding: 0.2rem 0.4rem; border-radius: 3px; word-break: break-all;">
<code style="background: var(--surface-muted); padding: 0.2rem 0.4rem; border-radius: var(--radius-sm); word-break: break-all; font-family: monospace;">
{pk.credential_id.substring(0, 32)}...
</code>
</td>
<td>{pk.counter}</td>
<td style="color: var(--text-secondary);">
{pk.counter}
</td>
<td>
<button
type="button"
class="btn-action btn-warning"
class="btn-danger"
style="padding: 0.35rem 0.75rem; font-size: 0.8rem; min-height: 32px;"
onclick={`deletePasskey('${user.id}', '${pk.id}')`}
>
Delete Device
@ -333,7 +393,6 @@ export const AdminUserDetailsPage = ({
});
}
// Initial populate
if (document.getElementById('grantAppId')) {
updateRoleOptions();
}
@ -342,10 +401,32 @@ export const AdminUserDetailsPage = ({
const banner = document.getElementById('status-banner');
banner.textContent = msg;
banner.style.display = 'block';
banner.style.background = isError ? '#f8d7da' : '#d1e7dd';
banner.style.color = isError ? '#842029' : '#0f5132';
banner.style.border = isError ? '1px solid #f5c2c7' : '1px solid #badbcc';
setTimeout(() => { banner.style.display = 'none'; }, 6000);
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) {
@ -362,7 +443,7 @@ export const AdminUserDetailsPage = ({
const data = await res.json();
if (res.ok) {
showNotice('Application access granted successfully!', false);
setTimeout(() => window.location.reload(), 800);
setTimeout(() => window.location.reload(), 600);
} else {
showNotice(data.error || 'Failed to update application grant', true);
}
@ -379,9 +460,8 @@ export const AdminUserDetailsPage = ({
});
if (res.ok) {
showNotice('Access revoked', false);
setTimeout(() => window.location.reload(), 800);
setTimeout(() => window.location.reload(), 600);
} else {
const data = await res.json();
showNotice(data.error || 'Failed to revoke grant', true);
}
} catch (err) {
@ -412,7 +492,7 @@ export const AdminUserDetailsPage = ({
const res = await fetch('/api/admin/sessions/' + sessionId, { method: 'DELETE' });
if (res.ok) {
showNotice('Session revoked', false);
setTimeout(() => window.location.reload(), 800);
setTimeout(() => window.location.reload(), 600);
} else {
showNotice('Failed to revoke session', true);
}
@ -427,7 +507,7 @@ export const AdminUserDetailsPage = ({
const res = await fetch('/api/admin/users/' + userId + '/sessions', { method: 'DELETE' });
if (res.ok) {
showNotice('All sessions revoked', false);
setTimeout(() => window.location.reload(), 800);
setTimeout(() => window.location.reload(), 600);
} else {
showNotice('Failed to revoke all sessions', true);
}
@ -443,7 +523,7 @@ export const AdminUserDetailsPage = ({
const data = await res.json();
if (res.ok) {
showNotice('Passkey deleted', false);
setTimeout(() => window.location.reload(), 800);
setTimeout(() => window.location.reload(), 600);
} else {
showNotice(data.error || 'Failed to delete passkey', true);
}
@ -453,7 +533,8 @@ export const AdminUserDetailsPage = ({
}
`,
}}
/>
>
</script>
</AdminLayout>
);
};

View File

@ -12,7 +12,8 @@ export const AdminUsersPage = ({
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: var(--radius-md); font-size: 0.9rem;"
/>
<div style="margin-bottom: 1.5rem;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
<div>
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
User Management
</h1>
@ -22,13 +23,39 @@ export const AdminUsersPage = ({
</p>
</div>
{/* Instant Search Bar */}
<div style="position: relative; min-width: 260px; max-width: 360px; width: 100%;">
<input
type="text"
id="userSearchInput"
placeholder="Search users by name, handle, status..."
oninput="filterUsersList()"
style="width: 100%; padding: 0.55rem 1rem 0.55rem 2.25rem; font-size: 0.875rem;"
/>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
style="position: absolute; left: 0.8rem; top: 50%; transform: translateY(-50%); color: var(--text-muted); pointer-events: none;"
>
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</div>
</div>
{/* Desktop Table View (≥ 768px) */}
<div class="card desktop-only" style="display: none;">
<div class="table-container">
<table>
<table id="usersTable">
<thead>
<tr>
<th>Username</th>
<th>Identity</th>
<th>Display Name</th>
<th>Status</th>
<th>Actions</th>
@ -43,14 +70,30 @@ export const AdminUsersPage = ({
: "badge-warning";
return (
<tr key={user.id}>
<tr
key={user.id}
class="user-row"
data-search={`${user.username} ${
user.display_name || ""
} ${user.account_status}`.toLowerCase()}
>
<td>
<strong style="color: var(--text-primary);">
{user.username}
<strong style="color: var(--text-primary); font-family: monospace; font-size: 0.95rem;">
@{user.username}
</strong>
</td>
<td style="color: var(--text-secondary);">
{user.display_name || "-"}
{user.display_name
? (
<span style="font-weight: 600; color: var(--text-primary);">
{user.display_name}
</span>
)
: (
<span style="color: var(--text-muted); font-style: italic;">
(Matches @{user.username})
</span>
)}
</td>
<td>
<span class={`badge ${statusClass}`}>
@ -58,20 +101,32 @@ export const AdminUsersPage = ({
</span>
</td>
<td>
<div style="display: flex; gap: 0.35rem;">
<div style="display: flex; gap: 0.5rem; align-items: center;">
<a
href={`/admin/users/${user.id}`}
class="btn-outline"
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px; text-decoration: none;"
style="padding: 0.35rem 0.75rem; font-size: 0.8rem; min-height: 32px; height: 32px; display: inline-flex; align-items: center; gap: 0.35rem; text-decoration: none; font-weight: 600;"
>
Manage
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M12 20h9"></path>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z">
</path>
</svg>
<span>Manage</span>
</a>
{user.account_status === "pending" && (
<button
type="button"
class="btn-primary"
style="background: var(--success); border-color: var(--success); padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
onclick={`updateStatus('${user.id}', 'active')`}
style="background: var(--success); border-color: var(--success); padding: 0.35rem 0.75rem; font-size: 0.8rem; min-height: 32px; height: 32px;"
onclick={`updateStatus('${user.id}', 'active', '${user.username}')`}
>
Activate
</button>
@ -80,8 +135,8 @@ export const AdminUsersPage = ({
<button
type="button"
class="btn-danger"
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
onclick={`updateStatus('${user.id}', 'suspended')`}
style="padding: 0.35rem 0.75rem; font-size: 0.8rem; min-height: 32px; height: 32px;"
onclick={`updateStatus('${user.id}', 'suspended', '${user.username}')`}
>
Suspend
</button>
@ -90,8 +145,8 @@ export const AdminUsersPage = ({
<button
type="button"
class="btn-primary"
style="background: var(--success); border-color: var(--success); padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
onclick={`updateStatus('${user.id}', 'active')`}
style="background: var(--success); border-color: var(--success); padding: 0.35rem 0.75rem; font-size: 0.8rem; min-height: 32px; height: 32px;"
onclick={`updateStatus('${user.id}', 'active', '${user.username}')`}
>
Re-Activate
</button>
@ -108,6 +163,7 @@ export const AdminUsersPage = ({
{/* Mobile Card Deck (< 768px) */}
<div
id="usersMobileDeck"
class="mobile-only"
style="display: flex; flex-direction: column; gap: 1rem;"
>
@ -119,18 +175,26 @@ export const AdminUsersPage = ({
: "badge-warning";
return (
<div class="card" key={user.id} style="margin-bottom: 0;">
<div
class="card user-card"
key={user.id}
data-search={`${user.username} ${
user.display_name || ""
} ${user.account_status}`.toLowerCase()}
style="margin-bottom: 0;"
>
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.75rem;">
<div style="display: flex; align-items: center; gap: 0.65rem;">
<div style="display: flex; align-items: center; justify-content: center; width: 38px; height: 38px; background: var(--primary-light); color: var(--primary); border-radius: var(--radius-md); font-weight: 700; font-size: 1rem;">
{user.username.charAt(0).toUpperCase()}
<div style="display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; background: var(--primary-light); color: var(--primary); border-radius: var(--radius-md); font-weight: 700; font-size: 1.1rem;">
{(user.display_name || user.username).charAt(0)
.toUpperCase()}
</div>
<div>
<h3 style="margin: 0; font-size: 1.05rem; color: var(--text-primary);">
{user.username}
{user.display_name || user.username}
</h3>
<span style="font-size: 0.8rem; color: var(--text-muted);">
{user.display_name || "No display name"}
<span style="font-size: 0.8rem; color: var(--text-muted); font-family: monospace;">
@{user.username}
</span>
</div>
</div>
@ -144,16 +208,28 @@ export const AdminUsersPage = ({
<a
href={`/admin/users/${user.id}`}
class="btn-outline"
style="flex: 1; text-decoration: none; justify-content: center; min-height: 40px; font-size: 0.85rem;"
style="flex: 1; text-decoration: none; justify-content: center; min-height: 42px; font-size: 0.875rem; font-weight: 600; gap: 0.4rem;"
>
Manage User
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M12 20h9"></path>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z">
</path>
</svg>
<span>Manage User</span>
</a>
{user.account_status === "pending" && (
<button
type="button"
class="btn-primary"
style="flex: 1; background: var(--success); border-color: var(--success); justify-content: center; min-height: 40px; font-size: 0.85rem;"
onclick={`updateStatus('${user.id}', 'active')`}
style="flex: 1; background: var(--success); border-color: var(--success); justify-content: center; min-height: 42px; font-size: 0.875rem;"
onclick={`updateStatus('${user.id}', 'active', '${user.username}')`}
>
Activate
</button>
@ -162,8 +238,8 @@ export const AdminUsersPage = ({
<button
type="button"
class="btn-danger"
style="flex: 1; justify-content: center; min-height: 40px; font-size: 0.85rem;"
onclick={`updateStatus('${user.id}', 'suspended')`}
style="flex: 1; justify-content: center; min-height: 42px; font-size: 0.875rem;"
onclick={`updateStatus('${user.id}', 'suspended', '${user.username}')`}
>
Suspend
</button>
@ -172,8 +248,8 @@ export const AdminUsersPage = ({
<button
type="button"
class="btn-primary"
style="flex: 1; background: var(--success); border-color: var(--success); justify-content: center; min-height: 40px; font-size: 0.85rem;"
onclick={`updateStatus('${user.id}', 'active')`}
style="flex: 1; background: var(--success); border-color: var(--success); justify-content: center; min-height: 42px; font-size: 0.875rem;"
onclick={`updateStatus('${user.id}', 'active', '${user.username}')`}
>
Re-Activate
</button>
@ -207,11 +283,27 @@ export const AdminUsersPage = ({
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'; }, 6000);
setTimeout(() => { banner.style.display = 'none'; }, 5000);
}
async function updateStatus(userId, status) {
if (!confirm('Are you sure you want to set this user to ' + status + '?')) {
function filterUsersList() {
const query = document.getElementById('userSearchInput').value.toLowerCase().trim();
const rows = document.querySelectorAll('.user-row');
const cards = document.querySelectorAll('.user-card');
rows.forEach(r => {
const text = r.getAttribute('data-search') || '';
r.style.display = text.includes(query) ? '' : 'none';
});
cards.forEach(c => {
const text = c.getAttribute('data-search') || '';
c.style.display = text.includes(query) ? '' : 'none';
});
}
async function updateStatus(userId, status, username) {
if (!confirm('Set user @' + username + ' to ' + status.toUpperCase() + '?')) {
return;
}
try {
@ -222,13 +314,13 @@ export const AdminUsersPage = ({
});
if (res.ok) {
showNotice('User status updated to ' + status, false);
setTimeout(() => window.location.reload(), 800);
setTimeout(() => window.location.reload(), 600);
} else {
const data = await res.json();
showNotice(data.error || 'Failed to update status', true);
}
} catch (err) {
showNotice('Network error', true);
showNotice('Network error updating status', true);
}
}
`,

View File

@ -7,7 +7,8 @@ export const AuditLogPage = ({
}) => {
return (
<AdminLayout title="Audit Logs" currentPath="/admin/audit-logs">
<div style="margin-bottom: 1.5rem;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
<div>
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
Immutable Audit Ledger
</h1>
@ -17,10 +18,34 @@ export const AuditLogPage = ({
</p>
</div>
{/* Instant Search Bar */}
<div style="position: relative; min-width: 240px; max-width: 320px; width: 100%;">
<input
type="text"
id="auditSearchInput"
placeholder="Search action, user, IP..."
oninput="filterAuditLogs()"
style="width: 100%; padding: 0.5rem 1rem 0.5rem 2.25rem; font-size: 0.875rem;"
/>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
style="position: absolute; left: 0.75rem; top: 50%; transform: translateY(-50%); color: var(--text-muted); pointer-events: none;"
>
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</div>
</div>
{/* Desktop Table (≥ 768px) */}
<div class="card desktop-only" style="display: none;">
<div class="table-container">
<table>
<table id="auditTable">
<thead>
<tr>
<th>Timestamp</th>
@ -37,7 +62,8 @@ export const AuditLogPage = ({
log.action.includes("denied");
const isSuccess = log.action.includes("success") ||
log.action.includes("create") ||
log.action.includes("activate");
log.action.includes("activate") ||
log.action.includes("updated");
const badgeClass = isFail
? "badge-danger"
: isSuccess
@ -45,7 +71,13 @@ export const AuditLogPage = ({
: "badge-info";
return (
<tr key={log.id}>
<tr
key={log.id}
class="log-row"
data-search={`${log.action} ${log.user || "system"} ${
log.resource || ""
} ${log.ip_address || ""}`.toLowerCase()}
>
<td style="font-size: 0.8rem; color: var(--text-muted); white-space: nowrap;">
{new Date(log.created_at).toLocaleString()}
</td>
@ -62,7 +94,7 @@ export const AuditLogPage = ({
<td style="color: var(--text-secondary);">
{log.resource || "-"}
</td>
<td style="font-family: monospace; font-size: 0.85rem;">
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary);">
{log.ip_address || "-"}
</td>
<td>
@ -90,6 +122,7 @@ export const AuditLogPage = ({
{/* Mobile Feed (< 768px) */}
<div
id="auditMobileFeed"
class="mobile-only"
style="display: flex; flex-direction: column; gap: 0.75rem;"
>
@ -97,7 +130,8 @@ export const AuditLogPage = ({
const isFail = log.action.includes("fail") ||
log.action.includes("denied");
const isSuccess = log.action.includes("success") ||
log.action.includes("create") || log.action.includes("activate");
log.action.includes("create") || log.action.includes("activate") ||
log.action.includes("updated");
const badgeClass = isFail
? "badge-danger"
: isSuccess
@ -106,8 +140,11 @@ export const AuditLogPage = ({
return (
<div
class="card"
class="card log-card"
key={log.id}
data-search={`${log.action} ${log.user || "system"} ${
log.resource || ""
} ${log.ip_address || ""}`.toLowerCase()}
style="margin-bottom: 0; padding: 1rem;"
>
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem;">
@ -162,6 +199,24 @@ export const AuditLogPage = ({
}
`}
</style>
<script
dangerouslySetInnerHTML={{
__html: `
function filterAuditLogs() {
const query = (document.getElementById('auditSearchInput')?.value || '').toLowerCase().trim();
document.querySelectorAll('.log-row').forEach(r => {
const text = r.getAttribute('data-search') || '';
r.style.display = text.includes(query) ? '' : 'none';
});
document.querySelectorAll('.log-card').forEach(c => {
const text = c.getAttribute('data-search') || '';
c.style.display = text.includes(query) ? '' : 'none';
});
}
`,
}}
/>
</AdminLayout>
);
};

View File

@ -16,26 +16,26 @@ export const AuthenticatedLayout = ({
label: "Launchpad",
href: "/dashboard",
icon:
`<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"></path><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"></path><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"></path><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"></path></svg>`,
`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"></path><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"></path><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"></path><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"></path></svg>`,
},
{
label: "Sessions",
href: "/dashboard/sessions",
icon:
`<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="3" rx="2"></rect><line x1="8" x2="16" y1="21" y2="21"></line><line x1="12" x2="12" y1="17" y2="21"></line></svg>`,
`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="3" rx="2"></rect><line x1="8" x2="16" y1="21" y2="21"></line><line x1="12" x2="12" y1="17" y2="21"></line></svg>`,
},
{
label: "Passkeys",
href: "/dashboard/passkeys",
icon:
`<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="7.5" cy="15.5" r="5.5"></circle><path d="m21 2-9.6 9.6"></path><path d="m15.5 7.5 3 3L22 7l-3-3"></path></svg>`,
`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="7.5" cy="15.5" r="5.5"></circle><path d="m21 2-9.6 9.6"></path><path d="m15.5 7.5 3 3L22 7l-3-3"></path></svg>`,
},
...(isAdmin
? [{
label: "Admin",
href: "/admin/users",
icon:
`<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path></svg>`,
`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path></svg>`,
}]
: []),
];
@ -76,17 +76,18 @@ export const AuthenticatedLayout = ({
}
.brand {
display: flex;
display: inline-flex;
align-items: center;
gap: 0.65rem;
text-decoration: none;
color: var(--text-primary);
font-weight: 700;
font-size: 1.15rem;
height: 36px;
}
.brand-badge {
display: flex;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
@ -100,6 +101,7 @@ export const AuthenticatedLayout = ({
/* Desktop Navigation */
.desktop-nav {
display: none;
align-items: center;
gap: 0.5rem;
margin-left: 2rem;
}
@ -108,12 +110,14 @@ export const AuthenticatedLayout = ({
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.9rem;
padding: 0.4rem 0.85rem;
border-radius: var(--radius-md);
text-decoration: none;
color: var(--text-secondary);
font-weight: 500;
font-size: 0.9rem;
height: 36px;
box-sizing: border-box;
transition: all 0.15s ease;
}
@ -137,8 +141,9 @@ export const AuthenticatedLayout = ({
.logout-link {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
padding: 0.45rem 0.85rem;
padding: 0.4rem 0.85rem;
border-radius: var(--radius-md);
text-decoration: none;
color: var(--danger);
@ -146,6 +151,8 @@ export const AuthenticatedLayout = ({
font-size: 0.85rem;
border: 1px solid var(--danger-border);
background: var(--danger-bg);
height: 36px;
box-sizing: border-box;
transition: all 0.15s ease;
}
@ -291,9 +298,22 @@ export const AuthenticatedLayout = ({
<a
href="/admin/users"
class="btn-primary"
style="padding: 0.4rem 0.85rem; font-size: 0.85rem; min-height: 36px; text-decoration: none;"
style="padding: 0.4rem 0.85rem; font-size: 0.85rem; min-height: 36px; height: 36px; box-sizing: border-box; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; gap: 0.35rem;"
>
Admin Console
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z">
</path>
</svg>
<span>Admin Console</span>
</a>
)}
<a