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 }); 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) // 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) => { app.delete("/api/admin/apps/:id", async (c) => {
const auth = await getAuthenticatedUser(c); const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401); 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) => { app.delete("/api/admin/roles/:id", async (c) => {
const auth = await getAuthenticatedUser(c); const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401); if (!auth) return c.json({ error: "Unauthorized" }, 401);

View File

@ -9,39 +9,78 @@ export const AdminAppsPage = ({
<AdminLayout title="Application Registry" currentPath="/admin/apps"> <AdminLayout title="Application Registry" currentPath="/admin/apps">
<div <div
id="status-banner" 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;">
<h2 style="margin: 0; border: none; padding: 0;"> <div>
Connected Applications <h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
</h2> Connected Applications
<button </h1>
type="button" <p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
class="btn-action btn-success" Register and manage subsidiary workloads and Edge Ingress proxy
style="padding: 0.5rem 1rem; font-size: 0.9rem;" configurations.
onclick="toggleRegisterForm()" </p>
> </div>
+ Register Application
</button> <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;"
>
<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> </div>
{/* Register / Edit App Drawer */}
<div <div
id="register-app-card" id="appFormCard"
class="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>Register New Subsidiary Application</h3> <h3
<p style="color: #6c757d; font-size: 0.9rem;"> id="appFormTitle"
Register an internal microservice or subsidiary application. The style="margin: 0 0 0.5rem 0; color: var(--text-primary);"
system will authenticate incoming ConnectRPC requests against the >
application's SPIFFE ID. 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> </p>
<form id="registerAppForm" onsubmit="handleRegisterApp(event)"> <form id="appForm" onsubmit="handleSaveApp(event)">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem;"> <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> <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 * Application Name *
</label> </label>
<input <input
@ -50,11 +89,11 @@ export const AdminAppsPage = ({
name="name" name="name"
placeholder="e.g. Elite Dangerous Streaming Hub" placeholder="e.g. Elite Dangerous Streaming Hub"
required required
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;" style="width: 100%;"
/> />
</div> </div>
<div> <div id="spiffeIdContainer">
<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);">
SPIFFE ID (Workload Identity) * SPIFFE ID (Workload Identity) *
</label> </label>
<input <input
@ -63,52 +102,52 @@ export const AdminAppsPage = ({
name="spiffeId" name="spiffeId"
placeholder="e.g. spiffe://system.local/ed-droid-backend" placeholder="e.g. spiffe://system.local/ed-droid-backend"
required required
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;" style="width: 100%;"
/> />
</div> </div>
</div> </div>
<div style="margin-bottom: 1rem;"> <div style="margin-bottom: 1rem;">
<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 (Optional) Description
</label> </label>
<input <input
type="text" type="text"
id="appDescription" id="appDescription"
name="description" name="description"
placeholder="e.g. Headless data streaming hub and UI module system" placeholder="e.g. Data telemetry streaming and UI module system"
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;"> <div style="margin-bottom: 1rem;">
<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);">
Domain (Optional, for Edge Ingress) Domain (Edge Ingress Hostname)
</label> </label>
<input <input
type="text" type="text"
id="appDomain" id="appDomain"
name="domain" name="domain"
placeholder="e.g. api.example.com" placeholder="e.g. ed-droid.atyg.org"
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;"> <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 <input
type="checkbox" type="checkbox"
id="appIsPublic" id="appIsPublic"
name="is_public" name="is_public"
style="margin-right: 0.5rem;" style="width: 18px; height: 18px;"
/> />
Is Publicly Accessible (Bypass all auth checks) Is Publicly Accessible (Bypass all auth checks)
</label> </label>
</div> </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> <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) Bypass Paths (Comma-separated)
</label> </label>
<input <input
@ -116,11 +155,11 @@ export const AdminAppsPage = ({
id="appBypassPaths" id="appBypassPaths"
name="bypass_paths" name="bypass_paths"
placeholder="e.g. /public/*, /webhook" 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>
<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) Allowed CIDRs (Comma-separated)
</label> </label>
<input <input
@ -128,19 +167,20 @@ export const AdminAppsPage = ({
id="appAllowedCidrs" id="appAllowedCidrs"
name="allowed_cidrs" name="allowed_cidrs"
placeholder="e.g. 192.168.1.0/24" 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> </div>
<div style="display: flex; gap: 0.5rem;"> <div style="display: flex; gap: 0.75rem;">
<button type="submit" class="btn-action btn-success"> <button type="submit" class="btn-primary" style="min-height: 42px;">
Save Application Save Application
</button> </button>
<button <button
type="button" type="button"
class="btn-action" class="btn-outline"
onclick="toggleRegisterForm()" onclick="closeAppDrawer()"
style="min-height: 42px;"
> >
Cancel Cancel
</button> </button>
@ -148,16 +188,17 @@ export const AdminAppsPage = ({
</form> </form>
</div> </div>
<div class="card"> {/* Desktop Table View (≥ 768px) */}
<div class="card desktop-only" style="display: none;">
<div class="table-container"> <div class="table-container">
<table> <table id="appsTable">
<thead> <thead>
<tr> <tr>
<th>Application Name</th> <th>Application Name</th>
<th>SPIFFE Workload ID</th> <th>SPIFFE ID</th>
<th>Active Users / Grants</th> <th>Domain</th>
<th>Active Users</th>
<th>Description</th> <th>Description</th>
<th>Registered Date</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
@ -166,8 +207,8 @@ export const AdminAppsPage = ({
? ( ? (
<tr> <tr>
<td <td
colspan={6} colSpan={6}
style="text-align: center; color: #6c757d; padding: 2rem;" style="text-align: center; color: var(--text-muted); padding: 2rem;"
> >
No connected applications registered yet. No connected applications registered yet.
</td> </td>
@ -175,34 +216,55 @@ export const AdminAppsPage = ({
) )
: ( : (
apps.map((app) => ( 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> <td>
<strong>{app.name}</strong> <strong style="color: var(--text-primary);">
{app.name}
</strong>
</td> </td>
<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} {app.spiffe_id}
</code> </code>
</td> </td>
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary);">
{app.domain || "-"}
</td>
<td> <td>
<span class="badge badge-info"> <span class="badge badge-info">
{app.active_grants_count || 0} users {app.active_grants_count || 0} users
</span> </span>
</td> </td>
<td style="color: #6c757d; font-size: 0.85rem;"> <td style="color: var(--text-secondary); font-size: 0.85rem; max-width: 250px;">
{app.description || "-"} {app.description || "-"}
</td> </td>
<td style="font-size: 0.85rem;">
{new Date(app.created_at).toLocaleDateString()}
</td>
<td> <td>
<button <div style="display: flex; gap: 0.35rem;">
type="button" <button
class="btn-action btn-warning" type="button"
onclick={`deleteApp('${app.id}', '${app.name}')`} class="btn-outline"
> style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
Delete onclick={`openEditAppDrawer(${
</button> 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> </td>
</tr> </tr>
)) ))
@ -212,6 +274,99 @@ export const AdminAppsPage = ({
</div> </div>
</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 <script
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: ` __html: `
@ -219,19 +374,63 @@ export const AdminAppsPage = ({
const banner = document.getElementById('status-banner'); const banner = document.getElementById('status-banner');
banner.textContent = msg; banner.textContent = msg;
banner.style.display = 'block'; banner.style.display = 'block';
banner.style.background = isError ? '#f8d7da' : '#d1e7dd'; banner.style.background = isError ? 'var(--danger-bg)' : 'var(--success-bg)';
banner.style.color = isError ? '#842029' : '#0f5132'; banner.style.color = isError ? 'var(--danger-text)' : 'var(--success-text)';
banner.style.border = isError ? '1px solid #f5c2c7' : '1px solid #badbcc'; 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);
} }
function toggleRegisterForm() { function filterAppsList() {
const el = document.getElementById('register-app-card'); const query = document.getElementById('appSearchInput').value.toLowerCase().trim();
el.style.display = el.style.display === 'none' ? 'block' : 'none'; 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(); e.preventDefault();
const editId = document.getElementById('editAppId').value;
const name = document.getElementById('appName').value.trim(); const name = document.getElementById('appName').value.trim();
const spiffeId = document.getElementById('appSpiffeId').value.trim(); const spiffeId = document.getElementById('appSpiffeId').value.trim();
const description = document.getElementById('appDescription').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 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); const allowed_cidrs = document.getElementById('appAllowedCidrs').value.split(',').map(s => s.trim()).filter(Boolean);
if (!name || !spiffeId) { if (!name || (!editId && !spiffeId)) {
showNotice('Name and SPIFFE ID are required', true); showNotice('Application name and SPIFFE ID are required', true);
return; return;
} }
try { try {
const res = await fetch('/api/admin/apps', { const url = editId ? ('/api/admin/apps/' + editId) : '/api/admin/apps';
method: 'POST', const method = editId ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, spiffeId, description, domain, is_public, bypass_paths, allowed_cidrs }), body: JSON.stringify({ name, spiffeId, description, domain, is_public, bypass_paths, allowed_cidrs }),
}); });
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
showNotice('Application registered successfully!', false); showNotice(editId ? 'Application updated successfully!' : 'Application registered successfully!', false);
setTimeout(() => window.location.reload(), 800); setTimeout(() => window.location.reload(), 600);
} else { } else {
showNotice(data.error || 'Failed to register application', true); showNotice(data.error || 'Failed to save application', true);
} }
} catch (err) { } catch (err) {
showNotice('Network error registering application', true); showNotice('Network error saving application', true);
} }
} }
@ -273,7 +474,7 @@ export const AdminAppsPage = ({
}); });
if (res.ok) { if (res.ok) {
showNotice('Application deleted', false); showNotice('Application deleted', false);
setTimeout(() => window.location.reload(), 800); setTimeout(() => window.location.reload(), 600);
} else { } else {
const data = await res.json(); const data = await res.json();
showNotice(data.error || 'Failed to delete application', true); showNotice(data.error || 'Failed to delete application', true);
@ -284,7 +485,8 @@ export const AdminAppsPage = ({
} }
`, `,
}} }}
/> >
</script>
</AdminLayout> </AdminLayout>
); );
}; };

View File

@ -16,51 +16,78 @@ export const AdminInvitesPage = ({
> >
<div <div
id="status-banner" 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> <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 Invite & Onboarding Tokens
</h2> </h1>
<p style="color: #6c757d; font-size: 0.9rem; margin: 0.2rem 0 0 0;"> <p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
Issue single-use, team limited-use, or campaign-wide registration Issue single-use, team limited-use, or campaign registration tokens.
tokens.
</p> </p>
</div> </div>
<button
type="button" <div style="display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap;">
class="btn-action btn-success" {/* Instant Search Bar */}
style="padding: 0.5rem 1rem; font-size: 0.9rem;" <div style="position: relative; min-width: 220px;">
onclick="toggleCreateInviteForm()" <input
> type="text"
+ Generate Onboarding Token id="inviteSearchInput"
</button> 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-primary"
style="min-height: 40px;"
onclick="toggleCreateInviteForm()"
>
+ Generate Token
</button>
</div>
</div> </div>
<div <div
id="create-invite-card" id="create-invite-card"
class="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> <h3 style="margin: 0 0 0.5rem 0; color: var(--text-primary);">
<p style="color: #6c757d; font-size: 0.9rem;"> Generate User Onboarding Token
Configure time bounds, usage limits, role assignments, and initial </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. account activation status.
</p> </p>
<form id="createInviteForm" onsubmit="handleCreateInvite(event)"> <form id="createInviteForm" onsubmit="handleCreateInvite(event)">
{/* Row 1: Type & Target App */} {/* 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> <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 * Token Provisioning Type *
</label> </label>
<select <select
id="inviteType" id="inviteType"
onchange="handleInviteTypeChange()" 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"> <option value="site_scoped">
Type 2: Site-Scoped Token (Pre-Authorized for App) Type 2: Site-Scoped Token (Pre-Authorized for App)
@ -75,13 +102,13 @@ export const AdminInvitesPage = ({
</div> </div>
<div id="appSelectContainer"> <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 * Target Application *
</label> </label>
<select <select
id="inviteAppId" id="inviteAppId"
onchange="updateInviteRoleOptions()" 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) => ( {apps.map((app) => (
<option value={app.id}> <option value={app.id}>
@ -93,15 +120,15 @@ export const AdminInvitesPage = ({
</div> </div>
{/* Row 2: Usage Limits & Assigned Role */} {/* 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> <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);">
Usage Policy (Capacity) * Usage Capacity *
</label> </label>
<select <select
id="inviteUsageType" id="inviteUsageType"
onchange="handleUsageTypeChange()" 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"> <option value="single">
Single-Use (1 Person - Max Security) Single-Use (1 Person - Max Security)
@ -116,7 +143,7 @@ export const AdminInvitesPage = ({
</div> </div>
<div id="maxUsesContainer" style="display: none;"> <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 * Max Registrations *
</label> </label>
<input <input
@ -125,17 +152,17 @@ export const AdminInvitesPage = ({
value="5" value="5"
min="2" min="2"
max="1000" max="1000"
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;" style="width: 100%;"
/> />
</div> </div>
<div id="roleSelectContainer"> <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 * Assigned Role *
</label> </label>
<select <select
id="inviteRole" 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 */} {/* Dynamically populated */}
</select> </select>
@ -143,9 +170,9 @@ export const AdminInvitesPage = ({
</div> </div>
{/* Row 3: Expiration, Custom Code, Activation Toggle */} {/* 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> <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) Expires In (Days)
</label> </label>
<input <input
@ -154,43 +181,44 @@ export const AdminInvitesPage = ({
value="7" value="7"
min="1" min="1"
max="30" max="30"
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;" style="width: 100%;"
/> />
</div> </div>
<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) Custom Code (Optional)
</label> </label>
<input <input
type="text" type="text"
id="inviteCustomCode" id="inviteCustomCode"
placeholder="Leave blank to auto-generate" 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>
<div style="padding-bottom: 0.4rem;"> <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;"> <label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; font-weight: 600; cursor: pointer; color: var(--text-primary);">
<input <input
type="checkbox" type="checkbox"
id="inviteAutoActivate" id="inviteAutoActivate"
checked checked
style="width: 16px; height: 16px; cursor: pointer;" style="width: 18px; height: 18px;"
/> />
Auto-Activate Account Auto-Activate Account
</label> </label>
</div> </div>
</div> </div>
<div style="display: flex; gap: 0.5rem;"> <div style="display: flex; gap: 0.75rem;">
<button type="submit" class="btn-action btn-success"> <button type="submit" class="btn-primary" style="min-height: 42px;">
Create Invite Token Create Invite Token
</button> </button>
<button <button
type="button" type="button"
class="btn-action" class="btn-outline"
onclick="toggleCreateInviteForm()" onclick="toggleCreateInviteForm()"
style="min-height: 42px;"
> >
Cancel Cancel
</button> </button>
@ -199,18 +227,20 @@ export const AdminInvitesPage = ({
<div <div
id="generated-token-banner" 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> <strong style="color: var(--success-text);">
<div style="margin-top: 0.5rem; display: flex; gap: 0.5rem; align-items: center;"> Token Created Successfully!
</strong>
<div style="margin-top: 0.5rem; display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
<code <code
id="generatedTokenUrl" 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> </code>
<button <button
type="button" type="button"
class="btn-action btn-success" class="btn-primary"
onclick="copyGeneratedTokenUrl()" onclick="copyGeneratedTokenUrl()"
> >
Copy Link Copy Link
@ -219,18 +249,17 @@ export const AdminInvitesPage = ({
</div> </div>
</div> </div>
{/* Invites Ledger Table */} {/* Desktop Ledger Table (≥ 768px) */}
<div class="card"> <div class="card desktop-only" style="display: none;">
<div class="table-container"> <div class="table-container">
<table> <table id="invitesTable">
<thead> <thead>
<tr> <tr>
<th>Invite Code</th> <th>Invite Code</th>
<th>Scope / App</th> <th>Target App / Scope</th>
<th>Role</th> <th>Role</th>
<th>Usage & Capacity</th> <th>Capacity & Usage</th>
<th>Status</th> <th>Status</th>
<th>Activation</th>
<th>Expires</th> <th>Expires</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
@ -240,8 +269,8 @@ export const AdminInvitesPage = ({
? ( ? (
<tr> <tr>
<td <td
colspan={8} colSpan={7}
style="text-align: center; color: #6c757d; padding: 2rem;" style="text-align: center; color: var(--text-muted); padding: 2rem;"
> >
No active or historical invite tokens found. No active or historical invite tokens found.
</td> </td>
@ -250,27 +279,37 @@ export const AdminInvitesPage = ({
: ( : (
invites.map((inv) => { invites.map((inv) => {
const usesCount = inv.uses_count || 0; 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 isUnlimited = maxUses === null;
const isExhausted = !isUnlimited && usesCount >= maxUses; const isExhausted = !isUnlimited && usesCount >= maxUses;
const isExpired = new Date(inv.expires_at) < new Date(); const isExpired = new Date(inv.expires_at) < new Date();
const isActive = !isExhausted && !isExpired; const isActive = !isExhausted && !isExpired;
return ( return (
<tr key={inv.id}> <tr
key={inv.id}
class="invite-row"
data-search={`${inv.code} ${
inv.app_name || ""
} ${inv.role}`.toLowerCase()}
>
<td> <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} {inv.code}
</code> </code>
</td> </td>
<td> <td>
{inv.app_name {inv.app_name
? <strong>{inv.app_name}</strong> ? (
<strong style="color: var(--text-primary);">
{inv.app_name}
</strong>
)
: inv.role === "admin" : inv.role === "admin"
? <span class="badge badge-info">Global Admin</span> ? <span class="badge badge-info">Global Admin</span>
: ( : (
<span class="badge badge-secondary"> <span class="badge badge-secondary">
General (Unassigned) General (Open)
</span> </span>
)} )}
</td> </td>
@ -281,19 +320,21 @@ export const AdminInvitesPage = ({
<div style="min-width: 110px;"> <div style="min-width: 110px;">
{isUnlimited {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) {usesCount} claimed (Unlimited)
</span> </span>
) )
: ( : (
<div> <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 {usesCount} / {maxUses} used
</span> </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 <div
style={`background: ${ style={`background: ${
isExhausted ? "#6c757d" : "#28a745" isExhausted
? "var(--text-muted)"
: "var(--success)"
}; height: 100%; width: ${ }; height: 100%; width: ${
Math.min( Math.min(
100, 100,
@ -311,34 +352,22 @@ export const AdminInvitesPage = ({
<span class="badge badge-secondary">Exhausted</span> <span class="badge badge-secondary">Exhausted</span>
)} )}
{isExpired && !isExhausted && ( {isExpired && !isExhausted && (
<span class="badge badge-suspended">Expired</span> <span class="badge badge-danger">Expired</span>
)} )}
{isActive && ( {isActive && (
<span class="badge badge-active">Active</span> <span class="badge badge-success">Active</span>
)} )}
</td> </td>
<td> <td style="font-size: 0.85rem; color: var(--text-secondary);">
{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;">
{new Date(inv.expires_at).toLocaleDateString()} {new Date(inv.expires_at).toLocaleDateString()}
</td> </td>
<td> <td>
<div style="display: flex; gap: 0.3rem; flex-wrap: wrap;"> <div style="display: flex; gap: 0.35rem; flex-wrap: wrap;">
{isActive && ( {isActive && (
<button <button
type="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}')`} onclick={`copyInviteLink('${inv.code}')`}
> >
Copy Link Copy Link
@ -347,8 +376,8 @@ export const AdminInvitesPage = ({
{usesCount > 0 && ( {usesCount > 0 && (
<button <button
type="button" type="button"
class="btn-action" class="btn-outline"
style="background: #e2e3e5; color: #383d41;" style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
onclick={`showRedemptionsModal('${inv.id}', '${inv.code}')`} onclick={`showRedemptionsModal('${inv.id}', '${inv.code}')`}
> >
Claimed ({usesCount}) Claimed ({usesCount})
@ -357,7 +386,8 @@ export const AdminInvitesPage = ({
{isActive && ( {isActive && (
<button <button
type="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}')`} onclick={`revokeInvite('${inv.id}', '${inv.code}')`}
> >
Revoke Revoke
@ -374,21 +404,124 @@ export const AdminInvitesPage = ({
</div> </div>
</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 */} {/* Redemptions Modal */}
<div <div
id="redemptions-modal" id="redemptions-modal"
style="display: none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.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;"> <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:{" "} Users Claimed:{" "}
<code id="modal-invite-code" style="color: #0d6efd;"></code> <code id="modal-invite-code" style="color: var(--primary);">
</code>
</h3> </h3>
<button <button
type="button" type="button"
onclick="closeRedemptionsModal()" 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; &times;
</button> </button>
@ -398,7 +531,7 @@ export const AdminInvitesPage = ({
id="modal-redemptions-content" id="modal-redemptions-content"
style="max-height: 350px; overflow-y: auto;" 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... Loading claimed users...
</p> </p>
</div> </div>
@ -406,7 +539,7 @@ export const AdminInvitesPage = ({
<div style="text-align: right; margin-top: 1rem;"> <div style="text-align: right; margin-top: 1rem;">
<button <button
type="button" type="button"
class="btn-action" class="btn-outline"
onclick="closeRedemptionsModal()" onclick="closeRedemptionsModal()"
> >
Close Close
@ -415,6 +548,19 @@ export const AdminInvitesPage = ({
</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 <script
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: ` __html: `
@ -453,10 +599,10 @@ export const AdminInvitesPage = ({
const banner = document.getElementById('status-banner'); const banner = document.getElementById('status-banner');
banner.textContent = msg; banner.textContent = msg;
banner.style.display = 'block'; banner.style.display = 'block';
banner.style.background = isError ? '#f8d7da' : '#d1e7dd'; banner.style.background = isError ? 'var(--danger-bg)' : 'var(--success-bg)';
banner.style.color = isError ? '#842029' : '#0f5132'; banner.style.color = isError ? 'var(--danger-text)' : 'var(--success-text)';
banner.style.border = isError ? '1px solid #f5c2c7' : '1px solid #badbcc'; 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);
} }
function toggleCreateInviteForm() { function toggleCreateInviteForm() {
@ -485,6 +631,18 @@ export const AdminInvitesPage = ({
maxUsesContainer.style.display = usage === 'limited' ? 'block' : 'none'; 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) { async function handleCreateInvite(e) {
e.preventDefault(); e.preventDefault();
const type = document.getElementById('inviteType').value; const type = document.getElementById('inviteType').value;
@ -538,7 +696,7 @@ export const AdminInvitesPage = ({
function copyGeneratedTokenUrl() { function copyGeneratedTokenUrl() {
const text = document.getElementById('generatedTokenUrl').textContent; const text = document.getElementById('generatedTokenUrl').textContent;
navigator.clipboard.writeText(text); navigator.clipboard.writeText(text);
showNotice('Registration URL copied to clipboard: ' + text, false); showNotice('Registration URL copied to clipboard!', false);
} }
function copyInviteLink(code) { function copyInviteLink(code) {
@ -555,7 +713,7 @@ export const AdminInvitesPage = ({
}); });
if (res.ok) { if (res.ok) {
showNotice('Invite token revoked', false); showNotice('Invite token revoked', false);
setTimeout(() => window.location.reload(), 800); setTimeout(() => window.location.reload(), 600);
} else { } else {
const data = await res.json(); const data = await res.json();
showNotice(data.error || 'Failed to revoke invite', true); showNotice(data.error || 'Failed to revoke invite', true);
@ -571,7 +729,7 @@ export const AdminInvitesPage = ({
const contentEl = document.getElementById('modal-redemptions-content'); const contentEl = document.getElementById('modal-redemptions-content');
codeEl.textContent = code; 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'; modal.style.display = 'flex';
try { try {
@ -579,25 +737,25 @@ export const AdminInvitesPage = ({
const data = await res.json(); const data = await res.json();
if (res.ok && data.redemptions && data.redemptions.length > 0) { if (res.ok && data.redemptions && data.redemptions.length > 0) {
let html = '<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">'; 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 += '<thead><tr>';
html += '<th style="padding: 0.4rem;">Username</th>'; html += '<th style="padding: 0.5rem;">Username</th>';
html += '<th style="padding: 0.4rem;">Status</th>'; html += '<th style="padding: 0.5rem;">Status</th>';
html += '<th style="padding: 0.4rem;">Redeemed At</th>'; html += '<th style="padding: 0.5rem;">Redeemed At</th>';
html += '</tr></thead><tbody>'; html += '</tr></thead><tbody>';
data.redemptions.forEach(r => { data.redemptions.forEach(r => {
html += '<tr style="border-bottom: 1px solid #dee2e6;">'; html += '<tr>';
html += '<td style="padding: 0.4rem;"><strong>' + r.username + '</strong></td>'; html += '<td style="padding: 0.5rem;"><strong style="color: var(--text-primary); font-family: monospace;">@' + 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.5rem;"><span class="badge badge-' + (r.account_status === 'active' ? 'success' : 'warning') + '">' + r.account_status + '</span></td>';
html += '<td style="padding: 0.4rem; color: #6c757d;">' + new Date(r.redeemed_at).toLocaleString() + '</td>'; html += '<td style="padding: 0.5rem; color: var(--text-secondary);">' + new Date(r.redeemed_at).toLocaleString() + '</td>';
html += '</tr>'; html += '</tr>';
}); });
html += '</tbody></table>'; html += '</tbody></table>';
contentEl.innerHTML = html; contentEl.innerHTML = html;
} else { } 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) { } 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 { .admin-brand {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.65rem;
text-decoration: none; text-decoration: none;
color: var(--text-primary); color: var(--text-primary);
font-weight: 700; font-weight: 700;
@ -170,7 +170,11 @@ export const AdminLayout = ({
{/* Top Header */} {/* Top Header */}
<header class="admin-header"> <header class="admin-header">
<div style="display: flex; align-items: center; gap: 0.75rem;"> <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>Auth-Yes</span>
<span class="admin-badge">Admin</span> <span class="admin-badge">Admin</span>
</a> </a>
@ -180,14 +184,14 @@ export const AdminLayout = ({
<a <a
href="/dashboard" href="/dashboard"
class="btn-outline" 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 User Hub
</a> </a>
<a <a
href="/logout" href="/logout"
class="btn-danger" 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 Logout
</a> </a>

View File

@ -11,48 +11,60 @@ export const AdminRolesPage = ({
<AdminLayout title="Role & Permission Catalog" currentPath="/admin/roles"> <AdminLayout title="Role & Permission Catalog" currentPath="/admin/roles">
<div <div
id="status-banner" 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> <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 Role & Permission Catalog
</h2> </h1>
<p style="color: #6c757d; font-size: 0.9rem; margin: 0.2rem 0 0 0;"> <p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
Manage global and application-specific RBAC roles and permissions. Manage global and application-scoped RBAC roles and permissions.
</p> </p>
</div> </div>
<button <button
type="button" type="button"
class="btn-action btn-success" class="btn-primary"
style="padding: 0.5rem 1rem; font-size: 0.9rem;" style="min-height: 40px;"
onclick="toggleCreateRoleForm()" onclick="openCreateRoleDrawer()"
> >
+ Create Custom Role + Create Custom Role
</button> </button>
</div> </div>
{/* Create / Edit Role Drawer */}
<div <div
id="create-role-card" id="roleFormCard"
class="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>Create New Role</h3> <h3
<p style="color: #6c757d; font-size: 0.9rem;"> id="roleFormTitle"
Define a global shared role or an application-scoped custom role. 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> </p>
<form id="createRoleForm" onsubmit="handleCreateRole(event)"> <form id="roleForm" onsubmit="handleSaveRole(event)">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem;"> <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> <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) * Scope (Applicability) *
</label> </label>
<select <select
id="roleScope" id="roleScope"
onchange="handleScopeChange()" 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"> <option value="global">
Global (Shared across ALL applications) Global (Shared across ALL applications)
@ -64,12 +76,12 @@ export const AdminRolesPage = ({
</div> </div>
<div id="appSelectContainer" style="display: none;"> <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 * Target Application *
</label> </label>
<select <select
id="roleAppId" 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) => ( {apps.map((app) => (
<option value={app.id}> <option value={app.id}>
@ -80,9 +92,9 @@ export const AdminRolesPage = ({
</div> </div>
</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> <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 * Role Identifier *
</label> </label>
<input <input
@ -90,31 +102,32 @@ export const AdminRolesPage = ({
id="roleName" id="roleName"
placeholder="e.g. navigator, copilot, auditor" placeholder="e.g. navigator, copilot, auditor"
required required
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;" style="width: 100%;"
/> />
</div> </div>
<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 Description / Purpose
</label> </label>
<input <input
type="text" type="text"
id="roleDescription" id="roleDescription"
placeholder="e.g. Flight routing and navigational telemetry access" 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> </div>
<div style="display: flex; gap: 0.5rem;"> <div style="display: flex; gap: 0.75rem;">
<button type="submit" class="btn-action btn-success"> <button type="submit" class="btn-primary" style="min-height: 42px;">
Save Role Save Role
</button> </button>
<button <button
type="button" type="button"
class="btn-action" class="btn-outline"
onclick="toggleCreateRoleForm()" onclick="closeRoleDrawer()"
style="min-height: 42px;"
> >
Cancel Cancel
</button> </button>
@ -123,40 +136,67 @@ export const AdminRolesPage = ({
</div> </div>
<div class="card"> <div class="card">
{/* Filter Controls */} {/* Instant Search and Scope Filters */}
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem;"> <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.5rem; align-items: center;"> <div style="display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; flex: 1;">
<label style="font-weight: 600; font-size: 0.85rem;"> {/* Search */}
Filter Scope: <div style="position: relative; min-width: 200px; max-width: 300px; width: 100%;">
</label> <input
<select type="text"
id="filterScopeSelect" id="roleSearchInput"
onchange="filterRolesTable()" placeholder="Search roles..."
style="padding: 0.35rem 0.6rem; border: 1px solid #ced4da; border-radius: 4px; background: white; font-size: 0.85rem;" oninput="filterRoles()"
> style="width: 100%; padding: 0.45rem 0.85rem 0.45rem 2.1rem; font-size: 0.85rem;"
<option value="all">All Roles</option> />
<option value="global">Global (Shared) Only</option> <svg
{apps.map((app) => ( width="15"
<option value={app.id}> height="15"
{app.name} Only viewBox="0 0 24 24"
</option> fill="none"
))} stroke="currentColor"
</select> stroke-width="2"
style="position: absolute; left: 0.7rem; top: 50%; transform: translateY(-50%); color: var(--text-muted); pointer-events: none;"
>
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</div>
{/* Scope dropdown */}
<div style="display: flex; gap: 0.4rem; align-items: center;">
<label style="font-weight: 600; font-size: 0.85rem; color: var(--text-secondary); white-space: nowrap;">
Scope:
</label>
<select
id="filterScopeSelect"
onchange="filterRoles()"
style="padding: 0.4rem 0.75rem; font-size: 0.85rem;"
>
<option value="all">All Roles</option>
<option value="global">Global (Shared) Only</option>
{apps.map((app) => (
<option value={app.id}>
{app.name} Only
</option>
))}
</select>
</div>
</div> </div>
<span <span
id="roleCountDisplay" id="roleCountDisplay"
style="font-size: 0.85rem; color: #6c757d;" style="font-size: 0.85rem; color: var(--text-muted);"
> >
Showing {roles.length} roles Showing {roles.length} roles
</span> </span>
</div> </div>
<div class="table-container"> {/* Desktop Table View (≥ 768px) */}
<div class="table-container desktop-only" style="display: none;">
<table id="rolesTable"> <table id="rolesTable">
<thead> <thead>
<tr> <tr>
<th>Role Name</th> <th>Role Identifier</th>
<th>Scope</th> <th>Scope</th>
<th>Description</th> <th>Description</th>
<th>Created</th> <th>Created</th>
@ -168,8 +208,8 @@ export const AdminRolesPage = ({
? ( ? (
<tr> <tr>
<td <td
colspan={5} colSpan={5}
style="text-align: center; color: #6c757d; padding: 2rem;" style="text-align: center; color: var(--text-muted); padding: 2rem;"
> >
No roles found. No roles found.
</td> </td>
@ -181,9 +221,16 @@ export const AdminRolesPage = ({
const isCoreAdmin = isGlobal && r.name === "admin"; const isCoreAdmin = isGlobal && r.name === "admin";
return ( 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> <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} {r.name}
</strong> </strong>
</td> </td>
@ -195,30 +242,43 @@ export const AdminRolesPage = ({
</span> </span>
) )
: ( : (
<span class="badge badge-pending"> <span class="badge badge-warning">
{r.app_name || "App-Specific"} {r.app_name || "App-Specific"}
</span> </span>
)} )}
</td> </td>
<td style="color: #495057; font-size: 0.85rem;"> <td style="color: var(--text-secondary); font-size: 0.85rem;">
{r.description || "-"} {r.description || "-"}
</td> </td>
<td style="font-size: 0.85rem;"> <td style="font-size: 0.85rem; color: var(--text-secondary);">
{new Date(r.created_at).toLocaleDateString()} {new Date(r.created_at).toLocaleDateString()}
</td> </td>
<td> <td>
{!isCoreAdmin {!isCoreAdmin
? ( ? (
<button <div style="display: flex; gap: 0.35rem;">
type="button" <button
class="btn-action btn-warning" type="button"
onclick={`deleteRole('${r.id}', '${r.name}')`} class="btn-outline"
> style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
Delete onclick={`openEditRoleDrawer(${
</button> 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 System Core
</span> </span>
)} )}
@ -230,8 +290,91 @@ export const AdminRolesPage = ({
</tbody> </tbody>
</table> </table>
</div> </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> </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 <script
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: ` __html: `
@ -239,15 +382,35 @@ export const AdminRolesPage = ({
const banner = document.getElementById('status-banner'); const banner = document.getElementById('status-banner');
banner.textContent = msg; banner.textContent = msg;
banner.style.display = 'block'; banner.style.display = 'block';
banner.style.background = isError ? '#f8d7da' : '#d1e7dd'; banner.style.background = isError ? 'var(--danger-bg)' : 'var(--success-bg)';
banner.style.color = isError ? '#842029' : '#0f5132'; banner.style.color = isError ? 'var(--danger-text)' : 'var(--success-text)';
banner.style.border = isError ? '1px solid #f5c2c7' : '1px solid #badbcc'; 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);
} }
function toggleCreateRoleForm() { function openCreateRoleDrawer() {
const el = document.getElementById('create-role-card'); document.getElementById('editRoleId').value = '';
el.style.display = el.style.display === 'none' ? 'block' : 'none'; 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() { function handleScopeChange() {
@ -256,40 +419,47 @@ export const AdminRolesPage = ({
appContainer.style.display = scope === 'app_specific' ? 'block' : 'none'; appContainer.style.display = scope === 'app_specific' ? 'block' : 'none';
} }
function filterRolesTable() { function filterRoles() {
const selected = document.getElementById('filterScopeSelect').value; const query = (document.getElementById('roleSearchInput')?.value || '').toLowerCase().trim();
const rows = document.querySelectorAll('#rolesTable tbody tr'); const selectedScope = document.getElementById('filterScopeSelect')?.value || 'all';
const rows = document.querySelectorAll('.role-row');
const cards = document.querySelectorAll('.role-card');
let visibleCount = 0; let visibleCount = 0;
rows.forEach((row) => { const checkMatch = (appId, searchText) => {
const rowAppId = row.getAttribute('data-app-id'); const scopeMatch = selectedScope === 'all' || (selectedScope === 'global' && appId === 'global') || (appId === selectedScope);
if (!rowAppId) return; const textMatch = !query || searchText.includes(query);
return scopeMatch && textMatch;
};
if (selected === 'all') { rows.forEach(r => {
row.style.display = ''; const appId = r.getAttribute('data-app-id');
visibleCount++; const search = r.getAttribute('data-search') || '';
} else if (selected === 'global') { const match = checkMatch(appId, search);
const isGlobal = rowAppId === 'global'; r.style.display = match ? '' : 'none';
row.style.display = isGlobal ? '' : 'none'; if (match) visibleCount++;
if (isGlobal) visibleCount++; });
} else {
const isMatch = rowAppId === selected; cards.forEach(c => {
row.style.display = isMatch ? '' : 'none'; const appId = c.getAttribute('data-app-id');
if (isMatch) visibleCount++; const search = c.getAttribute('data-search') || '';
} const match = checkMatch(appId, search);
c.style.display = match ? '' : 'none';
}); });
document.getElementById('roleCountDisplay').textContent = 'Showing ' + visibleCount + ' roles'; document.getElementById('roleCountDisplay').textContent = 'Showing ' + visibleCount + ' roles';
} }
async function handleCreateRole(e) { async function handleSaveRole(e) {
e.preventDefault(); 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 name = document.getElementById('roleName').value.trim();
const description = document.getElementById('roleDescription').value.trim(); const description = document.getElementById('roleDescription').value.trim();
let appId = null; let appId = null;
if (scope === 'app_specific') { if (!editId && scope === 'app_specific') {
appId = document.getElementById('roleAppId').value; appId = document.getElementById('roleAppId').value;
} }
@ -299,20 +469,22 @@ export const AdminRolesPage = ({
} }
try { try {
const res = await fetch('/api/admin/roles', { const url = editId ? ('/api/admin/roles/' + editId) : '/api/admin/roles';
method: 'POST', const method = editId ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description, appId }), body: JSON.stringify({ name, description, appId }),
}); });
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
showNotice('Role "' + name + '" created successfully!', false); showNotice(editId ? 'Role updated successfully!' : 'Role created successfully!', false);
setTimeout(() => window.location.reload(), 800); setTimeout(() => window.location.reload(), 600);
} else { } else {
showNotice(data.error || 'Failed to create role', true); showNotice(data.error || 'Failed to save role', true);
} }
} catch (err) { } catch (err) {
showNotice('Network error creating role', true); showNotice('Network error saving role', true);
} }
} }
@ -324,7 +496,7 @@ export const AdminRolesPage = ({
}); });
if (res.ok) { if (res.ok) {
showNotice('Role deleted', false); showNotice('Role deleted', false);
setTimeout(() => window.location.reload(), 800); setTimeout(() => window.location.reload(), 600);
} else { } else {
const data = await res.json(); const data = await res.json();
showNotice(data.error || 'Failed to delete role', true); 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"> <AdminLayout title={`User: ${user.username}`} currentPath="/admin/users">
<div <div
id="status-banner" 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> <div>
<h2 style="margin: 0; border: none; padding: 0;"> <h1 style="margin: 0 0 0.25rem 0; font-size: 1.75rem; font-weight: 700; color: var(--text-primary);">
User Profile: {user.username} User Profile: @{user.username}
</h2> </h1>
<span style="font-size: 0.85rem; color: #6c757d;"> <span style="font-size: 0.85rem; color: var(--text-muted); font-family: monospace;">
UUID: {user.id} UUID: {user.id}
</span> </span>
</div> </div>
<a <a
href="/admin/users" 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 &larr; Back to Users
</a> </a>
</div> </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 */} {/* Application RBAC Access Matrix */}
<div class="card" style="border-left: 4px solid #0d6efd;"> <div
<div style="display: flex; justify-content: space-between; align-items: center;"> 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> <div>
<h3 style="margin: 0;">Application Access & RBAC Grants</h3> <h3 style="margin: 0; color: var(--text-primary);">
<p style="color: #6c757d; font-size: 0.9rem; margin-top: 0.2rem; margin-bottom: 0;"> 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 Manage this user's explicit permissions across registered
applications (Default-Deny Zero-Trust). applications (Default-Deny Zero-Trust).
</p> </p>
@ -52,8 +95,8 @@ export const AdminUserDetailsPage = ({
</div> </div>
{/* Grant New Application Form */} {/* Grant New Application Form */}
<div style="margin-top: 1rem; padding: 1rem; background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px;"> <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.5rem 0; font-size: 0.9rem;"> <h4 style="margin: 0 0 0.75rem 0; font-size: 0.95rem; color: var(--text-primary);">
Assign / Update Application Access Assign / Update Application Access
</h4> </h4>
<form <form
@ -62,14 +105,14 @@ export const AdminUserDetailsPage = ({
style="display: flex; gap: 0.8rem; align-items: flex-end; flex-wrap: wrap;" style="display: flex; gap: 0.8rem; align-items: flex-end; flex-wrap: wrap;"
> >
<div style="flex: 2; min-width: 200px;"> <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 Application
</label> </label>
<select <select
id="grantAppId" id="grantAppId"
onchange="updateRoleOptions()" onchange="updateRoleOptions()"
required required
style="width: 100%; padding: 0.45rem; border: 1px solid #ced4da; border-radius: 4px; background: white;" style="width: 100%;"
> >
{allApps.map((app) => ( {allApps.map((app) => (
<option value={app.id}> <option value={app.id}>
@ -80,13 +123,13 @@ export const AdminUserDetailsPage = ({
</div> </div>
<div style="flex: 1; min-width: 140px;"> <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 Assigned Role
</label> </label>
<select <select
id="grantRole" id="grantRole"
required required
style="width: 100%; padding: 0.45rem; border: 1px solid #ced4da; border-radius: 4px; background: white;" style="width: 100%;"
> >
{/* Dynamically populated */} {/* Dynamically populated */}
</select> </select>
@ -94,15 +137,15 @@ export const AdminUserDetailsPage = ({
<button <button
type="submit" type="submit"
class="btn-action btn-success" class="btn-primary"
style="padding: 0.5rem 1rem; height: fit-content;" style="min-height: 44px;"
> >
Save Grant Save Grant
</button> </button>
</form> </form>
</div> </div>
<div class="table-container" style="margin-top: 1rem;"> <div class="table-container" style="margin-top: 1.25rem;">
<table> <table>
<thead> <thead>
<tr> <tr>
@ -118,8 +161,8 @@ export const AdminUserDetailsPage = ({
? ( ? (
<tr> <tr>
<td <td
colspan={5} colSpan={5}
style="text-align: center; color: #dc3545; padding: 1.5rem;" style="text-align: center; color: var(--danger); padding: 1.5rem;"
> >
No application permissions granted (User is blocked from No application permissions granted (User is blocked from
all subsidiary apps). all subsidiary apps).
@ -130,31 +173,28 @@ export const AdminUserDetailsPage = ({
grants.map((grant) => ( grants.map((grant) => (
<tr key={grant.id}> <tr key={grant.id}>
<td> <td>
<strong>{grant.app_name}</strong> <strong style="color: var(--text-primary);">
{grant.app_name}
</strong>
</td> </td>
<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} {grant.spiffe_id}
</code> </code>
</td> </td>
<td> <td>
<span <span class="badge badge-info">
class={`badge ${
grant.role === "admin"
? "badge-suspended"
: "badge-info"
}`}
>
{grant.role} {grant.role}
</span> </span>
</td> </td>
<td style="font-size: 0.85rem;"> <td style="font-size: 0.85rem; color: var(--text-secondary);">
{new Date(grant.created_at).toLocaleDateString()} {new Date(grant.created_at).toLocaleDateString()}
</td> </td>
<td> <td>
<button <button
type="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}')`} onclick={`revokeGrant('${user.id}', '${grant.app_id}', '${grant.app_name}')`}
> >
Revoke Access Revoke Access
@ -169,51 +209,55 @@ export const AdminUserDetailsPage = ({
</div> </div>
{/* Out-of-band Recovery */} {/* Out-of-band Recovery */}
<div class="card"> <div class="card" style="margin-bottom: 1.5rem;">
<h3>Out-of-Band Account Recovery</h3> <h3 style="margin: 0 0 0.5rem 0; color: var(--text-primary);">
<p style="color: #6c757d; font-size: 0.9rem;"> 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 Generate a one-time recovery link to allow the user to bind a new
hardware passkey if all devices are lost. hardware passkey if all devices are lost.
</p> </p>
<button <button
type="button" type="button"
class="btn-action btn-success" class="btn-primary"
onclick={`generateRecoveryLink('${user.id}')`} onclick={`generateRecoveryLink('${user.id}')`}
> >
Generate Recovery Link Generate Recovery Link
</button> </button>
<div <div
id="recovery-link-container" 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: Provide this link to the user:
</p> </p>
<code <code
id="recovery-link-text" 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> </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. Link expires in 24 hours.
</p> </p>
</div> </div>
</div> </div>
{/* Active Sessions */} {/* Active Sessions */}
<div class="card"> <div class="card" style="margin-bottom: 1.5rem;">
<div style="display: flex; justify-content: space-between; align-items: center;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 style="margin: 0;">Active Sessions</h3> <h3 style="margin: 0; color: var(--text-primary);">
Active Sessions
</h3>
<button <button
type="button" type="button"
class="btn-action btn-warning" class="btn-danger"
onclick={`revokeAllSessions('${user.id}')`} onclick={`revokeAllSessions('${user.id}')`}
> >
Revoke All Sessions Revoke All Sessions
</button> </button>
</div> </div>
<div class="table-container" style="margin-top: 1rem;"> <div class="table-container">
<table> <table>
<thead> <thead>
<tr> <tr>
@ -227,7 +271,10 @@ export const AdminUserDetailsPage = ({
{sessions.length === 0 {sessions.length === 0
? ( ? (
<tr> <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. No active sessions.
</td> </td>
</tr> </tr>
@ -236,16 +283,21 @@ export const AdminUserDetailsPage = ({
sessions.map((session) => ( sessions.map((session) => (
<tr key={session.id}> <tr key={session.id}>
<td> <td>
<code style="background: #f8f9fa; padding: 0.2rem 0.4rem; border-radius: 3px;"> <code style="background: var(--surface-muted); padding: 0.2rem 0.4rem; border-radius: var(--radius-sm); font-family: monospace;">
{session.id.substring(0, 8)}... {session.id.substring(0, 12)}...
</code> </code>
</td> </td>
<td>{new Date(session.created_at).toLocaleString()}</td> <td style="color: var(--text-secondary);">
<td>{new Date(session.expires_at).toLocaleString()}</td> {new Date(session.created_at).toLocaleString()}
</td>
<td style="color: var(--text-secondary);">
{new Date(session.expires_at).toLocaleString()}
</td>
<td> <td>
<button <button
type="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}')`} onclick={`revokeSession('${session.id}')`}
> >
Revoke Revoke
@ -261,8 +313,10 @@ export const AdminUserDetailsPage = ({
{/* Registered Passkeys */} {/* Registered Passkeys */}
<div class="card"> <div class="card">
<h3 style="margin-top: 0;">Registered Passkeys</h3> <h3 style="margin: 0 0 1rem 0; color: var(--text-primary);">
<div class="table-container" style="margin-top: 1rem;"> Registered Passkeys
</h3>
<div class="table-container">
<table> <table>
<thead> <thead>
<tr> <tr>
@ -275,7 +329,10 @@ export const AdminUserDetailsPage = ({
{passkeys.length === 0 {passkeys.length === 0
? ( ? (
<tr> <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. No registered passkeys.
</td> </td>
</tr> </tr>
@ -284,15 +341,18 @@ export const AdminUserDetailsPage = ({
passkeys.map((pk) => ( passkeys.map((pk) => (
<tr key={pk.id}> <tr key={pk.id}>
<td> <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)}... {pk.credential_id.substring(0, 32)}...
</code> </code>
</td> </td>
<td>{pk.counter}</td> <td style="color: var(--text-secondary);">
{pk.counter}
</td>
<td> <td>
<button <button
type="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}')`} onclick={`deletePasskey('${user.id}', '${pk.id}')`}
> >
Delete Device Delete Device
@ -333,7 +393,6 @@ export const AdminUserDetailsPage = ({
}); });
} }
// Initial populate
if (document.getElementById('grantAppId')) { if (document.getElementById('grantAppId')) {
updateRoleOptions(); updateRoleOptions();
} }
@ -342,10 +401,32 @@ export const AdminUserDetailsPage = ({
const banner = document.getElementById('status-banner'); const banner = document.getElementById('status-banner');
banner.textContent = msg; banner.textContent = msg;
banner.style.display = 'block'; banner.style.display = 'block';
banner.style.background = isError ? '#f8d7da' : '#d1e7dd'; banner.style.background = isError ? 'var(--danger-bg)' : 'var(--success-bg)';
banner.style.color = isError ? '#842029' : '#0f5132'; banner.style.color = isError ? 'var(--danger-text)' : 'var(--success-text)';
banner.style.border = isError ? '1px solid #f5c2c7' : '1px solid #badbcc'; 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 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) { async function handleGrantAccess(e, userId) {
@ -362,7 +443,7 @@ export const AdminUserDetailsPage = ({
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
showNotice('Application access granted successfully!', false); showNotice('Application access granted successfully!', false);
setTimeout(() => window.location.reload(), 800); setTimeout(() => window.location.reload(), 600);
} else { } else {
showNotice(data.error || 'Failed to update application grant', true); showNotice(data.error || 'Failed to update application grant', true);
} }
@ -379,9 +460,8 @@ export const AdminUserDetailsPage = ({
}); });
if (res.ok) { if (res.ok) {
showNotice('Access revoked', false); showNotice('Access revoked', false);
setTimeout(() => window.location.reload(), 800); setTimeout(() => window.location.reload(), 600);
} else { } else {
const data = await res.json();
showNotice(data.error || 'Failed to revoke grant', true); showNotice(data.error || 'Failed to revoke grant', true);
} }
} catch (err) { } catch (err) {
@ -412,7 +492,7 @@ export const AdminUserDetailsPage = ({
const res = await fetch('/api/admin/sessions/' + sessionId, { method: 'DELETE' }); const res = await fetch('/api/admin/sessions/' + sessionId, { method: 'DELETE' });
if (res.ok) { if (res.ok) {
showNotice('Session revoked', false); showNotice('Session revoked', false);
setTimeout(() => window.location.reload(), 800); setTimeout(() => window.location.reload(), 600);
} else { } else {
showNotice('Failed to revoke session', true); showNotice('Failed to revoke session', true);
} }
@ -427,7 +507,7 @@ export const AdminUserDetailsPage = ({
const res = await fetch('/api/admin/users/' + userId + '/sessions', { method: 'DELETE' }); const res = await fetch('/api/admin/users/' + userId + '/sessions', { method: 'DELETE' });
if (res.ok) { if (res.ok) {
showNotice('All sessions revoked', false); showNotice('All sessions revoked', false);
setTimeout(() => window.location.reload(), 800); setTimeout(() => window.location.reload(), 600);
} else { } else {
showNotice('Failed to revoke all sessions', true); showNotice('Failed to revoke all sessions', true);
} }
@ -443,7 +523,7 @@ export const AdminUserDetailsPage = ({
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
showNotice('Passkey deleted', false); showNotice('Passkey deleted', false);
setTimeout(() => window.location.reload(), 800); setTimeout(() => window.location.reload(), 600);
} else { } else {
showNotice(data.error || 'Failed to delete passkey', true); showNotice(data.error || 'Failed to delete passkey', true);
} }
@ -453,7 +533,8 @@ export const AdminUserDetailsPage = ({
} }
`, `,
}} }}
/> >
</script>
</AdminLayout> </AdminLayout>
); );
}; };

View File

@ -12,23 +12,50 @@ export const AdminUsersPage = ({
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: var(--radius-md); 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="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;">
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);"> <div>
User Management <h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
</h1> User Management
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;"> </h1>
Manage identities, activate pending registrations, and inspect role <p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
grants. Manage identities, activate pending registrations, and inspect role
</p> grants.
</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> </div>
{/* Desktop Table View (≥ 768px) */} {/* Desktop Table View (≥ 768px) */}
<div class="card desktop-only" style="display: none;"> <div class="card desktop-only" style="display: none;">
<div class="table-container"> <div class="table-container">
<table> <table id="usersTable">
<thead> <thead>
<tr> <tr>
<th>Username</th> <th>Identity</th>
<th>Display Name</th> <th>Display Name</th>
<th>Status</th> <th>Status</th>
<th>Actions</th> <th>Actions</th>
@ -43,14 +70,30 @@ export const AdminUsersPage = ({
: "badge-warning"; : "badge-warning";
return ( return (
<tr key={user.id}> <tr
key={user.id}
class="user-row"
data-search={`${user.username} ${
user.display_name || ""
} ${user.account_status}`.toLowerCase()}
>
<td> <td>
<strong style="color: var(--text-primary);"> <strong style="color: var(--text-primary); font-family: monospace; font-size: 0.95rem;">
{user.username} @{user.username}
</strong> </strong>
</td> </td>
<td style="color: var(--text-secondary);"> <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>
<td> <td>
<span class={`badge ${statusClass}`}> <span class={`badge ${statusClass}`}>
@ -58,20 +101,32 @@ export const AdminUsersPage = ({
</span> </span>
</td> </td>
<td> <td>
<div style="display: flex; gap: 0.35rem;"> <div style="display: flex; gap: 0.5rem; align-items: center;">
<a <a
href={`/admin/users/${user.id}`} href={`/admin/users/${user.id}`}
class="btn-outline" 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> </a>
{user.account_status === "pending" && ( {user.account_status === "pending" && (
<button <button
type="button" type="button"
class="btn-primary" class="btn-primary"
style="background: var(--success); border-color: var(--success); padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;" 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')`} onclick={`updateStatus('${user.id}', 'active', '${user.username}')`}
> >
Activate Activate
</button> </button>
@ -80,8 +135,8 @@ export const AdminUsersPage = ({
<button <button
type="button" type="button"
class="btn-danger" class="btn-danger"
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;" style="padding: 0.35rem 0.75rem; font-size: 0.8rem; min-height: 32px; height: 32px;"
onclick={`updateStatus('${user.id}', 'suspended')`} onclick={`updateStatus('${user.id}', 'suspended', '${user.username}')`}
> >
Suspend Suspend
</button> </button>
@ -90,8 +145,8 @@ export const AdminUsersPage = ({
<button <button
type="button" type="button"
class="btn-primary" class="btn-primary"
style="background: var(--success); border-color: var(--success); padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;" 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')`} onclick={`updateStatus('${user.id}', 'active', '${user.username}')`}
> >
Re-Activate Re-Activate
</button> </button>
@ -108,6 +163,7 @@ export const AdminUsersPage = ({
{/* Mobile Card Deck (< 768px) */} {/* Mobile Card Deck (< 768px) */}
<div <div
id="usersMobileDeck"
class="mobile-only" class="mobile-only"
style="display: flex; flex-direction: column; gap: 1rem;" style="display: flex; flex-direction: column; gap: 1rem;"
> >
@ -119,18 +175,26 @@ export const AdminUsersPage = ({
: "badge-warning"; : "badge-warning";
return ( 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; 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; 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;"> <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.username.charAt(0).toUpperCase()} {(user.display_name || user.username).charAt(0)
.toUpperCase()}
</div> </div>
<div> <div>
<h3 style="margin: 0; font-size: 1.05rem; color: var(--text-primary);"> <h3 style="margin: 0; font-size: 1.05rem; color: var(--text-primary);">
{user.username} {user.display_name || user.username}
</h3> </h3>
<span style="font-size: 0.8rem; color: var(--text-muted);"> <span style="font-size: 0.8rem; color: var(--text-muted); font-family: monospace;">
{user.display_name || "No display name"} @{user.username}
</span> </span>
</div> </div>
</div> </div>
@ -144,16 +208,28 @@ export const AdminUsersPage = ({
<a <a
href={`/admin/users/${user.id}`} href={`/admin/users/${user.id}`}
class="btn-outline" 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> </a>
{user.account_status === "pending" && ( {user.account_status === "pending" && (
<button <button
type="button" type="button"
class="btn-primary" class="btn-primary"
style="flex: 1; background: var(--success); border-color: var(--success); justify-content: center; min-height: 40px; font-size: 0.85rem;" 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')`} onclick={`updateStatus('${user.id}', 'active', '${user.username}')`}
> >
Activate Activate
</button> </button>
@ -162,8 +238,8 @@ export const AdminUsersPage = ({
<button <button
type="button" type="button"
class="btn-danger" class="btn-danger"
style="flex: 1; justify-content: center; min-height: 40px; font-size: 0.85rem;" style="flex: 1; justify-content: center; min-height: 42px; font-size: 0.875rem;"
onclick={`updateStatus('${user.id}', 'suspended')`} onclick={`updateStatus('${user.id}', 'suspended', '${user.username}')`}
> >
Suspend Suspend
</button> </button>
@ -172,8 +248,8 @@ export const AdminUsersPage = ({
<button <button
type="button" type="button"
class="btn-primary" class="btn-primary"
style="flex: 1; background: var(--success); border-color: var(--success); justify-content: center; min-height: 40px; font-size: 0.85rem;" 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')`} onclick={`updateStatus('${user.id}', 'active', '${user.username}')`}
> >
Re-Activate Re-Activate
</button> </button>
@ -207,11 +283,27 @@ export const AdminUsersPage = ({
banner.style.background = isError ? 'var(--danger-bg)' : 'var(--success-bg)'; banner.style.background = isError ? 'var(--danger-bg)' : 'var(--success-bg)';
banner.style.color = isError ? 'var(--danger-text)' : 'var(--success-text)'; banner.style.color = isError ? 'var(--danger-text)' : 'var(--success-text)';
banner.style.border = isError ? '1px solid var(--danger-border)' : '1px solid var(--success-border)'; 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) { function filterUsersList() {
if (!confirm('Are you sure you want to set this user to ' + status + '?')) { 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; return;
} }
try { try {
@ -222,13 +314,13 @@ export const AdminUsersPage = ({
}); });
if (res.ok) { if (res.ok) {
showNotice('User status updated to ' + status, false); showNotice('User status updated to ' + status, false);
setTimeout(() => window.location.reload(), 800); setTimeout(() => window.location.reload(), 600);
} else { } else {
const data = await res.json(); const data = await res.json();
showNotice(data.error || 'Failed to update status', true); showNotice(data.error || 'Failed to update status', true);
} }
} catch (err) { } catch (err) {
showNotice('Network error', true); showNotice('Network error updating status', true);
} }
} }
`, `,

View File

@ -7,20 +7,45 @@ export const AuditLogPage = ({
}) => { }) => {
return ( return (
<AdminLayout title="Audit Logs" currentPath="/admin/audit-logs"> <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;">
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);"> <div>
Immutable Audit Ledger <h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
</h1> Immutable Audit Ledger
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;"> </h1>
Cryptographically chained Merkle audit trail for authentication, <p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
authorization, and administrative events. Cryptographically chained Merkle audit trail for authentication,
</p> authorization, and administrative events.
</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> </div>
{/* Desktop Table (≥ 768px) */} {/* Desktop Table (≥ 768px) */}
<div class="card desktop-only" style="display: none;"> <div class="card desktop-only" style="display: none;">
<div class="table-container"> <div class="table-container">
<table> <table id="auditTable">
<thead> <thead>
<tr> <tr>
<th>Timestamp</th> <th>Timestamp</th>
@ -37,7 +62,8 @@ export const AuditLogPage = ({
log.action.includes("denied"); log.action.includes("denied");
const isSuccess = log.action.includes("success") || const isSuccess = log.action.includes("success") ||
log.action.includes("create") || log.action.includes("create") ||
log.action.includes("activate"); log.action.includes("activate") ||
log.action.includes("updated");
const badgeClass = isFail const badgeClass = isFail
? "badge-danger" ? "badge-danger"
: isSuccess : isSuccess
@ -45,7 +71,13 @@ export const AuditLogPage = ({
: "badge-info"; : "badge-info";
return ( 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;"> <td style="font-size: 0.8rem; color: var(--text-muted); white-space: nowrap;">
{new Date(log.created_at).toLocaleString()} {new Date(log.created_at).toLocaleString()}
</td> </td>
@ -62,7 +94,7 @@ export const AuditLogPage = ({
<td style="color: var(--text-secondary);"> <td style="color: var(--text-secondary);">
{log.resource || "-"} {log.resource || "-"}
</td> </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 || "-"} {log.ip_address || "-"}
</td> </td>
<td> <td>
@ -90,6 +122,7 @@ export const AuditLogPage = ({
{/* Mobile Feed (< 768px) */} {/* Mobile Feed (< 768px) */}
<div <div
id="auditMobileFeed"
class="mobile-only" class="mobile-only"
style="display: flex; flex-direction: column; gap: 0.75rem;" style="display: flex; flex-direction: column; gap: 0.75rem;"
> >
@ -97,7 +130,8 @@ export const AuditLogPage = ({
const isFail = log.action.includes("fail") || const isFail = log.action.includes("fail") ||
log.action.includes("denied"); log.action.includes("denied");
const isSuccess = log.action.includes("success") || 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 const badgeClass = isFail
? "badge-danger" ? "badge-danger"
: isSuccess : isSuccess
@ -106,8 +140,11 @@ export const AuditLogPage = ({
return ( return (
<div <div
class="card" class="card log-card"
key={log.id} key={log.id}
data-search={`${log.action} ${log.user || "system"} ${
log.resource || ""
} ${log.ip_address || ""}`.toLowerCase()}
style="margin-bottom: 0; padding: 1rem;" style="margin-bottom: 0; padding: 1rem;"
> >
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem;"> <div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem;">
@ -162,6 +199,24 @@ export const AuditLogPage = ({
} }
`} `}
</style> </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> </AdminLayout>
); );
}; };

View File

@ -16,26 +16,26 @@ export const AuthenticatedLayout = ({
label: "Launchpad", label: "Launchpad",
href: "/dashboard", href: "/dashboard",
icon: 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", label: "Sessions",
href: "/dashboard/sessions", href: "/dashboard/sessions",
icon: 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", label: "Passkeys",
href: "/dashboard/passkeys", href: "/dashboard/passkeys",
icon: 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 ...(isAdmin
? [{ ? [{
label: "Admin", label: "Admin",
href: "/admin/users", href: "/admin/users",
icon: 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 { .brand {
display: flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.65rem; gap: 0.65rem;
text-decoration: none; text-decoration: none;
color: var(--text-primary); color: var(--text-primary);
font-weight: 700; font-weight: 700;
font-size: 1.15rem; font-size: 1.15rem;
height: 36px;
} }
.brand-badge { .brand-badge {
display: flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 32px; width: 32px;
@ -100,6 +101,7 @@ export const AuthenticatedLayout = ({
/* Desktop Navigation */ /* Desktop Navigation */
.desktop-nav { .desktop-nav {
display: none; display: none;
align-items: center;
gap: 0.5rem; gap: 0.5rem;
margin-left: 2rem; margin-left: 2rem;
} }
@ -108,12 +110,14 @@ export const AuthenticatedLayout = ({
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.4rem;
padding: 0.5rem 0.9rem; padding: 0.4rem 0.85rem;
border-radius: var(--radius-md); border-radius: var(--radius-md);
text-decoration: none; text-decoration: none;
color: var(--text-secondary); color: var(--text-secondary);
font-weight: 500; font-weight: 500;
font-size: 0.9rem; font-size: 0.9rem;
height: 36px;
box-sizing: border-box;
transition: all 0.15s ease; transition: all 0.15s ease;
} }
@ -137,8 +141,9 @@ export const AuthenticatedLayout = ({
.logout-link { .logout-link {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center;
gap: 0.35rem; gap: 0.35rem;
padding: 0.45rem 0.85rem; padding: 0.4rem 0.85rem;
border-radius: var(--radius-md); border-radius: var(--radius-md);
text-decoration: none; text-decoration: none;
color: var(--danger); color: var(--danger);
@ -146,6 +151,8 @@ export const AuthenticatedLayout = ({
font-size: 0.85rem; font-size: 0.85rem;
border: 1px solid var(--danger-border); border: 1px solid var(--danger-border);
background: var(--danger-bg); background: var(--danger-bg);
height: 36px;
box-sizing: border-box;
transition: all 0.15s ease; transition: all 0.15s ease;
} }
@ -291,9 +298,22 @@ export const AuthenticatedLayout = ({
<a <a
href="/admin/users" href="/admin/users"
class="btn-primary" 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>
)} )}
<a <a