feat(ui,api): polish a11y, mobile decks, app/role edit drawers, display name management, and instant search
This commit is contained in:
parent
509e6019b0
commit
85772659b7
109
server/main.ts
109
server/main.ts
@ -1097,6 +1097,32 @@ app.post("/api/admin/users/:id/status", async (c) => {
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
app.post("/api/admin/users/:id/profile", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
if (!(await isGlobalAdmin(auth.userId))) {
|
||||
return c.json({ error: "Forbidden: Global admin access required" }, 403);
|
||||
}
|
||||
|
||||
const targetUserId = c.req.param("id");
|
||||
const { displayName } = await c.req.json();
|
||||
|
||||
const targetUser = await sqlWrapper
|
||||
.sql`UPDATE users SET display_name = ${
|
||||
displayName?.trim() || null
|
||||
} WHERE id = ${targetUserId} RETURNING id, username, display_name`
|
||||
.then((res: any) => res[0]);
|
||||
|
||||
if (!targetUser) return c.json({ error: "User not found" }, 404);
|
||||
|
||||
auditWrapper.auditLog(auth.userId, "user_profile_updated", targetUserId, {
|
||||
display_name: targetUser.display_name,
|
||||
}, getClientIp(c));
|
||||
|
||||
return c.json({ success: true, user: targetUser });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Traefik ForwardAuth Edge Proxy Route (Tier 2)
|
||||
// ---------------------------------------------------------
|
||||
@ -1309,6 +1335,52 @@ app.post("/api/admin/apps", async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.put("/api/admin/apps/:id", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
if (!(await isGlobalAdmin(auth.userId))) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
const appId = c.req.param("id");
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
domain,
|
||||
is_public,
|
||||
bypass_paths,
|
||||
allowed_cidrs,
|
||||
} = await c.req.json();
|
||||
|
||||
if (!name) {
|
||||
return c.json({ error: "Application name is required" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedApp = await sqlWrapper.sql`
|
||||
UPDATE apps
|
||||
SET name = ${name.trim()},
|
||||
description = ${description?.trim() || null},
|
||||
domain = ${domain?.trim() || null},
|
||||
is_public = ${is_public || false},
|
||||
bypass_paths = ${bypass_paths || []},
|
||||
allowed_cidrs = ${allowed_cidrs || []}
|
||||
WHERE id = ${appId}
|
||||
RETURNING id, name, spiffe_id, description, domain, is_public, bypass_paths, allowed_cidrs
|
||||
`.then((res: any) => res[0]);
|
||||
|
||||
if (!updatedApp) return c.json({ error: "Application not found" }, 404);
|
||||
|
||||
auditWrapper.auditLog(auth.userId, "app_updated", updatedApp.id, {
|
||||
name: updatedApp.name,
|
||||
}, getClientIp(c));
|
||||
|
||||
return c.json({ success: true, app: updatedApp });
|
||||
} catch (_err: any) {
|
||||
return c.json({ error: "Failed to update application" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/admin/apps/:id", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
@ -1429,6 +1501,43 @@ app.post("/api/admin/roles", async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.put("/api/admin/roles/:id", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
if (!(await isGlobalAdmin(auth.userId))) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
const roleId = c.req.param("id");
|
||||
const { name, description } = await c.req.json();
|
||||
|
||||
if (!name || typeof name !== "string" || name.trim().length < 2) {
|
||||
return c.json({ error: "Role name must be at least 2 characters" }, 400);
|
||||
}
|
||||
|
||||
const normalizedName = name.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "_");
|
||||
|
||||
try {
|
||||
const updatedRole = await sqlWrapper.sql`
|
||||
UPDATE roles
|
||||
SET name = ${normalizedName},
|
||||
description = ${description?.trim() || null}
|
||||
WHERE id = ${roleId}
|
||||
RETURNING id, name, description, app_id, created_at
|
||||
`.then((res: any) => res[0]);
|
||||
|
||||
if (!updatedRole) return c.json({ error: "Role not found" }, 404);
|
||||
|
||||
auditWrapper.auditLog(auth.userId, "role_updated", updatedRole.app_id, {
|
||||
role_name: updatedRole.name,
|
||||
}, getClientIp(c));
|
||||
|
||||
return c.json({ success: true, role: updatedRole });
|
||||
} catch (_err: any) {
|
||||
return c.json({ error: "Failed to update role" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/admin/roles/:id", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
@ -9,39 +9,78 @@ export const AdminAppsPage = ({
|
||||
<AdminLayout title="Application Registry" currentPath="/admin/apps">
|
||||
<div
|
||||
id="status-banner"
|
||||
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: 4px; font-size: 0.9rem;"
|
||||
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: var(--radius-md); font-size: 0.9rem;"
|
||||
/>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
||||
<h2 style="margin: 0; border: none; padding: 0;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
|
||||
<div>
|
||||
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
|
||||
Connected Applications
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-action btn-success"
|
||||
style="padding: 0.5rem 1rem; font-size: 0.9rem;"
|
||||
onclick="toggleRegisterForm()"
|
||||
>
|
||||
+ Register Application
|
||||
</button>
|
||||
</h1>
|
||||
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
|
||||
Register and manage subsidiary workloads and Edge Ingress proxy
|
||||
configurations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="register-app-card"
|
||||
class="card"
|
||||
style="display: none; border-left: 4px solid #28a745; margin-bottom: 1.5rem;"
|
||||
<div style="display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap;">
|
||||
{/* Instant Search Bar */}
|
||||
<div style="position: relative; min-width: 220px;">
|
||||
<input
|
||||
type="text"
|
||||
id="appSearchInput"
|
||||
placeholder="Search apps by name, domain..."
|
||||
oninput="filterAppsList()"
|
||||
style="width: 100%; padding: 0.5rem 1rem 0.5rem 2.25rem; font-size: 0.875rem;"
|
||||
/>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
style="position: absolute; left: 0.75rem; top: 50%; transform: translateY(-50%); color: var(--text-muted); pointer-events: none;"
|
||||
>
|
||||
<h3>Register New Subsidiary Application</h3>
|
||||
<p style="color: #6c757d; font-size: 0.9rem;">
|
||||
Register an internal microservice or subsidiary application. The
|
||||
system will authenticate incoming ConnectRPC requests against the
|
||||
application's SPIFFE ID.
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
style="min-height: 40px;"
|
||||
onclick="openCreateAppDrawer()"
|
||||
>
|
||||
+ Register App
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Register / Edit App Drawer */}
|
||||
<div
|
||||
id="appFormCard"
|
||||
class="card"
|
||||
style="display: none; border-left: 4px solid var(--primary); margin-bottom: 1.5rem;"
|
||||
>
|
||||
<h3
|
||||
id="appFormTitle"
|
||||
style="margin: 0 0 0.5rem 0; color: var(--text-primary);"
|
||||
>
|
||||
Register New Subsidiary Application
|
||||
</h3>
|
||||
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1.25rem 0;">
|
||||
Authenticate incoming ConnectRPC/ForwardAuth requests against the
|
||||
application's SPIFFE ID and edge routing domains.
|
||||
</p>
|
||||
|
||||
<form id="registerAppForm" onsubmit="handleRegisterApp(event)">
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem;">
|
||||
<form id="appForm" onsubmit="handleSaveApp(event)">
|
||||
<input type="hidden" id="editAppId" value="" />
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; margin-bottom: 1rem;">
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Application Name *
|
||||
</label>
|
||||
<input
|
||||
@ -50,11 +89,11 @@ export const AdminAppsPage = ({
|
||||
name="name"
|
||||
placeholder="e.g. Elite Dangerous Streaming Hub"
|
||||
required
|
||||
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
|
||||
<div id="spiffeIdContainer">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
SPIFFE ID (Workload Identity) *
|
||||
</label>
|
||||
<input
|
||||
@ -63,52 +102,52 @@ export const AdminAppsPage = ({
|
||||
name="spiffeId"
|
||||
placeholder="e.g. spiffe://system.local/ed-droid-backend"
|
||||
required
|
||||
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
|
||||
Description (Optional)
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Description
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="appDescription"
|
||||
name="description"
|
||||
placeholder="e.g. Headless data streaming hub and UI module system"
|
||||
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"
|
||||
placeholder="e.g. Data telemetry streaming and UI module system"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
|
||||
Domain (Optional, for Edge Ingress)
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Domain (Edge Ingress Hostname)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="appDomain"
|
||||
name="domain"
|
||||
placeholder="e.g. api.example.com"
|
||||
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"
|
||||
placeholder="e.g. ed-droid.atyg.org"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label style="display: flex; align-items: center; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; font-weight: 600; font-size: 0.875rem; cursor: pointer; color: var(--text-primary);">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="appIsPublic"
|
||||
name="is_public"
|
||||
style="margin-right: 0.5rem;"
|
||||
style="width: 18px; height: 18px;"
|
||||
/>
|
||||
Is Publicly Accessible (Bypass all auth checks)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem;">
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; margin-bottom: 1.25rem;">
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Bypass Paths (Comma-separated)
|
||||
</label>
|
||||
<input
|
||||
@ -116,11 +155,11 @@ export const AdminAppsPage = ({
|
||||
id="appBypassPaths"
|
||||
name="bypass_paths"
|
||||
placeholder="e.g. /public/*, /webhook"
|
||||
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Allowed CIDRs (Comma-separated)
|
||||
</label>
|
||||
<input
|
||||
@ -128,19 +167,20 @@ export const AdminAppsPage = ({
|
||||
id="appAllowedCidrs"
|
||||
name="allowed_cidrs"
|
||||
placeholder="e.g. 192.168.1.0/24"
|
||||
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<button type="submit" class="btn-action btn-success">
|
||||
<div style="display: flex; gap: 0.75rem;">
|
||||
<button type="submit" class="btn-primary" style="min-height: 42px;">
|
||||
Save Application
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-action"
|
||||
onclick="toggleRegisterForm()"
|
||||
class="btn-outline"
|
||||
onclick="closeAppDrawer()"
|
||||
style="min-height: 42px;"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@ -148,16 +188,17 @@ export const AdminAppsPage = ({
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
{/* Desktop Table View (≥ 768px) */}
|
||||
<div class="card desktop-only" style="display: none;">
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<table id="appsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Application Name</th>
|
||||
<th>SPIFFE Workload ID</th>
|
||||
<th>Active Users / Grants</th>
|
||||
<th>SPIFFE ID</th>
|
||||
<th>Domain</th>
|
||||
<th>Active Users</th>
|
||||
<th>Description</th>
|
||||
<th>Registered Date</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -166,8 +207,8 @@ export const AdminAppsPage = ({
|
||||
? (
|
||||
<tr>
|
||||
<td
|
||||
colspan={6}
|
||||
style="text-align: center; color: #6c757d; padding: 2rem;"
|
||||
colSpan={6}
|
||||
style="text-align: center; color: var(--text-muted); padding: 2rem;"
|
||||
>
|
||||
No connected applications registered yet.
|
||||
</td>
|
||||
@ -175,34 +216,55 @@ export const AdminAppsPage = ({
|
||||
)
|
||||
: (
|
||||
apps.map((app) => (
|
||||
<tr key={app.id}>
|
||||
<tr
|
||||
key={app.id}
|
||||
class="app-row"
|
||||
data-search={`${app.name} ${
|
||||
app.domain || ""
|
||||
} ${app.spiffe_id}`.toLowerCase()}
|
||||
>
|
||||
<td>
|
||||
<strong>{app.name}</strong>
|
||||
<strong style="color: var(--text-primary);">
|
||||
{app.name}
|
||||
</strong>
|
||||
</td>
|
||||
<td>
|
||||
<code style="background: #e9ecef; padding: 0.2rem 0.4rem; border-radius: 3px; font-size: 0.8rem; color: #0d6efd;">
|
||||
<code style="background: var(--surface-muted); padding: 0.2rem 0.4rem; border-radius: var(--radius-sm); font-size: 0.8rem; font-family: monospace; color: var(--primary);">
|
||||
{app.spiffe_id}
|
||||
</code>
|
||||
</td>
|
||||
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
{app.domain || "-"}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-info">
|
||||
{app.active_grants_count || 0} users
|
||||
</span>
|
||||
</td>
|
||||
<td style="color: #6c757d; font-size: 0.85rem;">
|
||||
<td style="color: var(--text-secondary); font-size: 0.85rem; max-width: 250px;">
|
||||
{app.description || "-"}
|
||||
</td>
|
||||
<td style="font-size: 0.85rem;">
|
||||
{new Date(app.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td>
|
||||
<div style="display: flex; gap: 0.35rem;">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-action btn-warning"
|
||||
class="btn-outline"
|
||||
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
|
||||
onclick={`openEditAppDrawer(${
|
||||
JSON.stringify(JSON.stringify(app))
|
||||
})`}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger"
|
||||
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
|
||||
onclick={`deleteApp('${app.id}', '${app.name}')`}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
@ -212,6 +274,99 @@ export const AdminAppsPage = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Adaptive Cards View (< 768px) */}
|
||||
<div
|
||||
id="appsMobileDeck"
|
||||
class="mobile-only"
|
||||
style="display: flex; flex-direction: column; gap: 1rem;"
|
||||
>
|
||||
{apps.length === 0
|
||||
? (
|
||||
<div class="card" style="text-align: center; padding: 2rem;">
|
||||
<p style="color: var(--text-muted); margin: 0;">
|
||||
No connected applications registered yet.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
apps.map((app) => (
|
||||
<div
|
||||
class="card app-card"
|
||||
key={app.id}
|
||||
data-search={`${app.name} ${app.domain || ""} ${app.spiffe_id}`
|
||||
.toLowerCase()}
|
||||
style="margin-bottom: 0;"
|
||||
>
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.75rem;">
|
||||
<div style="display: flex; align-items: center; gap: 0.65rem;">
|
||||
<div style="display: flex; align-items: center; justify-content: center; width: 38px; height: 38px; background: var(--primary-light); color: var(--primary); border-radius: var(--radius-md); font-weight: 700;">
|
||||
{app.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<h3 style="margin: 0; font-size: 1.05rem; color: var(--text-primary);">
|
||||
{app.name}
|
||||
</h3>
|
||||
<span style="font-size: 0.8rem; color: var(--text-muted); font-family: monospace;">
|
||||
{app.domain || "No domain"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="badge badge-info">
|
||||
{app.active_grants_count || 0} Users
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style="font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 1rem; line-height: 1.5;">
|
||||
<p style="margin: 0 0 0.5rem 0;">
|
||||
{app.description || "No description provided."}
|
||||
</p>
|
||||
<div>
|
||||
<strong>SPIFFE:</strong>{" "}
|
||||
<code style="font-family: monospace; font-size: 0.8rem;">
|
||||
{app.spiffe_id}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
style="flex: 1; justify-content: center; min-height: 40px; font-size: 0.85rem;"
|
||||
onclick={`openEditAppDrawer(${
|
||||
JSON.stringify(JSON.stringify(app))
|
||||
})`}
|
||||
>
|
||||
Edit App
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger"
|
||||
style="flex: 1; justify-content: center; min-height: 40px; font-size: 0.85rem;"
|
||||
onclick={`deleteApp('${app.id}', '${app.name}')`}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
{`
|
||||
@media (min-width: 768px) {
|
||||
.desktop-only { display: block !important; }
|
||||
.mobile-only { display: none !important; }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.desktop-only { display: none !important; }
|
||||
.mobile-only { display: flex !important; }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@ -219,19 +374,63 @@ export const AdminAppsPage = ({
|
||||
const banner = document.getElementById('status-banner');
|
||||
banner.textContent = msg;
|
||||
banner.style.display = 'block';
|
||||
banner.style.background = isError ? '#f8d7da' : '#d1e7dd';
|
||||
banner.style.color = isError ? '#842029' : '#0f5132';
|
||||
banner.style.border = isError ? '1px solid #f5c2c7' : '1px solid #badbcc';
|
||||
setTimeout(() => { banner.style.display = 'none'; }, 6000);
|
||||
banner.style.background = isError ? 'var(--danger-bg)' : 'var(--success-bg)';
|
||||
banner.style.color = isError ? 'var(--danger-text)' : 'var(--success-text)';
|
||||
banner.style.border = isError ? '1px solid var(--danger-border)' : '1px solid var(--success-border)';
|
||||
setTimeout(() => { banner.style.display = 'none'; }, 5000);
|
||||
}
|
||||
|
||||
function toggleRegisterForm() {
|
||||
const el = document.getElementById('register-app-card');
|
||||
el.style.display = el.style.display === 'none' ? 'block' : 'none';
|
||||
function filterAppsList() {
|
||||
const query = document.getElementById('appSearchInput').value.toLowerCase().trim();
|
||||
document.querySelectorAll('.app-row').forEach(r => {
|
||||
const text = r.getAttribute('data-search') || '';
|
||||
r.style.display = text.includes(query) ? '' : 'none';
|
||||
});
|
||||
document.querySelectorAll('.app-card').forEach(c => {
|
||||
const text = c.getAttribute('data-search') || '';
|
||||
c.style.display = text.includes(query) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRegisterApp(e) {
|
||||
function openCreateAppDrawer() {
|
||||
document.getElementById('editAppId').value = '';
|
||||
document.getElementById('appFormTitle').textContent = 'Register New Subsidiary Application';
|
||||
document.getElementById('appName').value = '';
|
||||
document.getElementById('appSpiffeId').value = '';
|
||||
document.getElementById('appSpiffeId').readOnly = false;
|
||||
document.getElementById('spiffeIdContainer').style.display = 'block';
|
||||
document.getElementById('appDescription').value = '';
|
||||
document.getElementById('appDomain').value = '';
|
||||
document.getElementById('appIsPublic').checked = false;
|
||||
document.getElementById('appBypassPaths').value = '';
|
||||
document.getElementById('appAllowedCidrs').value = '';
|
||||
document.getElementById('appFormCard').style.display = 'block';
|
||||
document.getElementById('appFormCard').scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function openEditAppDrawer(appJson) {
|
||||
const app = JSON.parse(appJson);
|
||||
document.getElementById('editAppId').value = app.id;
|
||||
document.getElementById('appFormTitle').textContent = 'Edit Application: ' + app.name;
|
||||
document.getElementById('appName').value = app.name || '';
|
||||
document.getElementById('appSpiffeId').value = app.spiffe_id || '';
|
||||
document.getElementById('appSpiffeId').readOnly = true;
|
||||
document.getElementById('appDescription').value = app.description || '';
|
||||
document.getElementById('appDomain').value = app.domain || '';
|
||||
document.getElementById('appIsPublic').checked = !!app.is_public;
|
||||
document.getElementById('appBypassPaths').value = Array.isArray(app.bypass_paths) ? app.bypass_paths.join(', ') : (app.bypass_paths || '');
|
||||
document.getElementById('appAllowedCidrs').value = Array.isArray(app.allowed_cidrs) ? app.allowed_cidrs.join(', ') : (app.allowed_cidrs || '');
|
||||
document.getElementById('appFormCard').style.display = 'block';
|
||||
document.getElementById('appFormCard').scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function closeAppDrawer() {
|
||||
document.getElementById('appFormCard').style.display = 'none';
|
||||
}
|
||||
|
||||
async function handleSaveApp(e) {
|
||||
e.preventDefault();
|
||||
const editId = document.getElementById('editAppId').value;
|
||||
const name = document.getElementById('appName').value.trim();
|
||||
const spiffeId = document.getElementById('appSpiffeId').value.trim();
|
||||
const description = document.getElementById('appDescription').value.trim();
|
||||
@ -240,26 +439,28 @@ export const AdminAppsPage = ({
|
||||
const bypass_paths = document.getElementById('appBypassPaths').value.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const allowed_cidrs = document.getElementById('appAllowedCidrs').value.split(',').map(s => s.trim()).filter(Boolean);
|
||||
|
||||
if (!name || !spiffeId) {
|
||||
showNotice('Name and SPIFFE ID are required', true);
|
||||
if (!name || (!editId && !spiffeId)) {
|
||||
showNotice('Application name and SPIFFE ID are required', true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/apps', {
|
||||
method: 'POST',
|
||||
const url = editId ? ('/api/admin/apps/' + editId) : '/api/admin/apps';
|
||||
const method = editId ? 'PUT' : 'POST';
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, spiffeId, description, domain, is_public, bypass_paths, allowed_cidrs }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
showNotice('Application registered successfully!', false);
|
||||
setTimeout(() => window.location.reload(), 800);
|
||||
showNotice(editId ? 'Application updated successfully!' : 'Application registered successfully!', false);
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
} else {
|
||||
showNotice(data.error || 'Failed to register application', true);
|
||||
showNotice(data.error || 'Failed to save application', true);
|
||||
}
|
||||
} catch (err) {
|
||||
showNotice('Network error registering application', true);
|
||||
showNotice('Network error saving application', true);
|
||||
}
|
||||
}
|
||||
|
||||
@ -273,7 +474,7 @@ export const AdminAppsPage = ({
|
||||
});
|
||||
if (res.ok) {
|
||||
showNotice('Application deleted', false);
|
||||
setTimeout(() => window.location.reload(), 800);
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
showNotice(data.error || 'Failed to delete application', true);
|
||||
@ -284,7 +485,8 @@ export const AdminAppsPage = ({
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
>
|
||||
</script>
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@ -16,51 +16,78 @@ export const AdminInvitesPage = ({
|
||||
>
|
||||
<div
|
||||
id="status-banner"
|
||||
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: 4px; font-size: 0.9rem;"
|
||||
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: var(--radius-md); font-size: 0.9rem;"
|
||||
/>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
|
||||
<div>
|
||||
<h2 style="margin: 0; border: none; padding: 0;">
|
||||
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
|
||||
Invite & Onboarding Tokens
|
||||
</h2>
|
||||
<p style="color: #6c757d; font-size: 0.9rem; margin: 0.2rem 0 0 0;">
|
||||
Issue single-use, team limited-use, or campaign-wide registration
|
||||
tokens.
|
||||
</h1>
|
||||
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
|
||||
Issue single-use, team limited-use, or campaign registration tokens.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap;">
|
||||
{/* Instant Search Bar */}
|
||||
<div style="position: relative; min-width: 220px;">
|
||||
<input
|
||||
type="text"
|
||||
id="inviteSearchInput"
|
||||
placeholder="Search invite tokens..."
|
||||
oninput="filterInvitesList()"
|
||||
style="width: 100%; padding: 0.5rem 1rem 0.5rem 2.25rem; font-size: 0.875rem;"
|
||||
/>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
style="position: absolute; left: 0.75rem; top: 50%; transform: translateY(-50%); color: var(--text-muted); pointer-events: none;"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn-action btn-success"
|
||||
style="padding: 0.5rem 1rem; font-size: 0.9rem;"
|
||||
class="btn-primary"
|
||||
style="min-height: 40px;"
|
||||
onclick="toggleCreateInviteForm()"
|
||||
>
|
||||
+ Generate Onboarding Token
|
||||
+ Generate Token
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="create-invite-card"
|
||||
class="card"
|
||||
style="display: none; border-left: 4px solid #28a745; margin-bottom: 1.5rem;"
|
||||
style="display: none; border-left: 4px solid var(--primary); margin-bottom: 1.5rem;"
|
||||
>
|
||||
<h3>Generate User Onboarding Token</h3>
|
||||
<p style="color: #6c757d; font-size: 0.9rem;">
|
||||
Configure time bounds, usage limits, role assignments, and initial
|
||||
<h3 style="margin: 0 0 0.5rem 0; color: var(--text-primary);">
|
||||
Generate User Onboarding Token
|
||||
</h3>
|
||||
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1.25rem 0;">
|
||||
Configure time bounds, usage capacity, role assignments, and initial
|
||||
account activation status.
|
||||
</p>
|
||||
|
||||
<form id="createInviteForm" onsubmit="handleCreateInvite(event)">
|
||||
{/* Row 1: Type & Target App */}
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem;">
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; margin-bottom: 1rem;">
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Token Provisioning Type *
|
||||
</label>
|
||||
<select
|
||||
id="inviteType"
|
||||
onchange="handleInviteTypeChange()"
|
||||
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box; background: white;"
|
||||
style="width: 100%;"
|
||||
>
|
||||
<option value="site_scoped">
|
||||
Type 2: Site-Scoped Token (Pre-Authorized for App)
|
||||
@ -75,13 +102,13 @@ export const AdminInvitesPage = ({
|
||||
</div>
|
||||
|
||||
<div id="appSelectContainer">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Target Application *
|
||||
</label>
|
||||
<select
|
||||
id="inviteAppId"
|
||||
onchange="updateInviteRoleOptions()"
|
||||
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box; background: white;"
|
||||
style="width: 100%;"
|
||||
>
|
||||
{apps.map((app) => (
|
||||
<option value={app.id}>
|
||||
@ -93,15 +120,15 @@ export const AdminInvitesPage = ({
|
||||
</div>
|
||||
|
||||
{/* Row 2: Usage Limits & Assigned Role */}
|
||||
<div style="display: grid; grid-template-columns: 1.5fr 1fr 1.5fr; gap: 1rem; margin-bottom: 1rem;">
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 1rem;">
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
|
||||
Usage Policy (Capacity) *
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Usage Capacity *
|
||||
</label>
|
||||
<select
|
||||
id="inviteUsageType"
|
||||
onchange="handleUsageTypeChange()"
|
||||
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box; background: white;"
|
||||
style="width: 100%;"
|
||||
>
|
||||
<option value="single">
|
||||
Single-Use (1 Person - Max Security)
|
||||
@ -116,7 +143,7 @@ export const AdminInvitesPage = ({
|
||||
</div>
|
||||
|
||||
<div id="maxUsesContainer" style="display: none;">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Max Registrations *
|
||||
</label>
|
||||
<input
|
||||
@ -125,17 +152,17 @@ export const AdminInvitesPage = ({
|
||||
value="5"
|
||||
min="2"
|
||||
max="1000"
|
||||
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="roleSelectContainer">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Assigned Role *
|
||||
</label>
|
||||
<select
|
||||
id="inviteRole"
|
||||
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box; background: white;"
|
||||
style="width: 100%;"
|
||||
>
|
||||
{/* Dynamically populated */}
|
||||
</select>
|
||||
@ -143,9 +170,9 @@ export const AdminInvitesPage = ({
|
||||
</div>
|
||||
|
||||
{/* Row 3: Expiration, Custom Code, Activation Toggle */}
|
||||
<div style="display: grid; grid-template-columns: 1fr 1.5fr 1fr; gap: 1rem; margin-bottom: 1.2rem; align-items: flex-end;">
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; margin-bottom: 1.25rem; align-items: flex-end;">
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Expires In (Days)
|
||||
</label>
|
||||
<input
|
||||
@ -154,43 +181,44 @@ export const AdminInvitesPage = ({
|
||||
value="7"
|
||||
min="1"
|
||||
max="30"
|
||||
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Custom Code (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="inviteCustomCode"
|
||||
placeholder="Leave blank to auto-generate"
|
||||
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style="padding-bottom: 0.4rem;">
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; font-weight: 600; cursor: pointer;">
|
||||
<div style="padding-bottom: 0.5rem;">
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; font-weight: 600; cursor: pointer; color: var(--text-primary);">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="inviteAutoActivate"
|
||||
checked
|
||||
style="width: 16px; height: 16px; cursor: pointer;"
|
||||
style="width: 18px; height: 18px;"
|
||||
/>
|
||||
Auto-Activate Account
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<button type="submit" class="btn-action btn-success">
|
||||
<div style="display: flex; gap: 0.75rem;">
|
||||
<button type="submit" class="btn-primary" style="min-height: 42px;">
|
||||
Create Invite Token
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-action"
|
||||
class="btn-outline"
|
||||
onclick="toggleCreateInviteForm()"
|
||||
style="min-height: 42px;"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@ -199,18 +227,20 @@ export const AdminInvitesPage = ({
|
||||
|
||||
<div
|
||||
id="generated-token-banner"
|
||||
style="display: none; margin-top: 1rem; padding: 1rem; background: #e7f5ea; border: 1px solid #28a745; border-radius: 4px;"
|
||||
style="display: none; margin-top: 1.25rem; padding: 1rem; background: var(--success-bg); border: 1px solid var(--success-border); border-radius: var(--radius-md);"
|
||||
>
|
||||
<strong style="color: #155724;">Token Created Successfully!</strong>
|
||||
<div style="margin-top: 0.5rem; display: flex; gap: 0.5rem; align-items: center;">
|
||||
<strong style="color: var(--success-text);">
|
||||
Token Created Successfully!
|
||||
</strong>
|
||||
<div style="margin-top: 0.5rem; display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
|
||||
<code
|
||||
id="generatedTokenUrl"
|
||||
style="padding: 0.4rem 0.6rem; background: white; border: 1px solid #ced4da; border-radius: 4px; font-size: 0.9rem; flex: 1; word-break: break-all;"
|
||||
style="padding: 0.5rem 0.75rem; background: var(--surface-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); font-size: 0.85rem; flex: 1; min-width: 220px; word-break: break-all; font-family: monospace; color: var(--primary);"
|
||||
>
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-action btn-success"
|
||||
class="btn-primary"
|
||||
onclick="copyGeneratedTokenUrl()"
|
||||
>
|
||||
Copy Link
|
||||
@ -219,18 +249,17 @@ export const AdminInvitesPage = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invites Ledger Table */}
|
||||
<div class="card">
|
||||
{/* Desktop Ledger Table (≥ 768px) */}
|
||||
<div class="card desktop-only" style="display: none;">
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<table id="invitesTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Invite Code</th>
|
||||
<th>Scope / App</th>
|
||||
<th>Target App / Scope</th>
|
||||
<th>Role</th>
|
||||
<th>Usage & Capacity</th>
|
||||
<th>Capacity & Usage</th>
|
||||
<th>Status</th>
|
||||
<th>Activation</th>
|
||||
<th>Expires</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
@ -240,8 +269,8 @@ export const AdminInvitesPage = ({
|
||||
? (
|
||||
<tr>
|
||||
<td
|
||||
colspan={8}
|
||||
style="text-align: center; color: #6c757d; padding: 2rem;"
|
||||
colSpan={7}
|
||||
style="text-align: center; color: var(--text-muted); padding: 2rem;"
|
||||
>
|
||||
No active or historical invite tokens found.
|
||||
</td>
|
||||
@ -250,27 +279,37 @@ export const AdminInvitesPage = ({
|
||||
: (
|
||||
invites.map((inv) => {
|
||||
const usesCount = inv.uses_count || 0;
|
||||
const maxUses = inv.max_uses; // null = unlimited, number = limit
|
||||
const maxUses = inv.max_uses;
|
||||
const isUnlimited = maxUses === null;
|
||||
const isExhausted = !isUnlimited && usesCount >= maxUses;
|
||||
const isExpired = new Date(inv.expires_at) < new Date();
|
||||
const isActive = !isExhausted && !isExpired;
|
||||
|
||||
return (
|
||||
<tr key={inv.id}>
|
||||
<tr
|
||||
key={inv.id}
|
||||
class="invite-row"
|
||||
data-search={`${inv.code} ${
|
||||
inv.app_name || ""
|
||||
} ${inv.role}`.toLowerCase()}
|
||||
>
|
||||
<td>
|
||||
<code style="background: #e9ecef; padding: 0.2rem 0.4rem; border-radius: 3px; font-weight: bold; color: #212529;">
|
||||
<code style="background: var(--surface-muted); padding: 0.25rem 0.5rem; border-radius: var(--radius-sm); font-weight: 700; font-family: monospace; color: var(--primary);">
|
||||
{inv.code}
|
||||
</code>
|
||||
</td>
|
||||
<td>
|
||||
{inv.app_name
|
||||
? <strong>{inv.app_name}</strong>
|
||||
? (
|
||||
<strong style="color: var(--text-primary);">
|
||||
{inv.app_name}
|
||||
</strong>
|
||||
)
|
||||
: inv.role === "admin"
|
||||
? <span class="badge badge-info">Global Admin</span>
|
||||
: (
|
||||
<span class="badge badge-secondary">
|
||||
General (Unassigned)
|
||||
General (Open)
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
@ -281,19 +320,21 @@ export const AdminInvitesPage = ({
|
||||
<div style="min-width: 110px;">
|
||||
{isUnlimited
|
||||
? (
|
||||
<span style="font-size: 0.85rem; font-weight: 500; color: #0d6efd;">
|
||||
<span style="font-size: 0.85rem; font-weight: 600; color: var(--primary);">
|
||||
{usesCount} claimed (Unlimited)
|
||||
</span>
|
||||
)
|
||||
: (
|
||||
<div>
|
||||
<span style="font-size: 0.85rem; font-weight: 600;">
|
||||
<span style="font-size: 0.85rem; font-weight: 600; color: var(--text-primary);">
|
||||
{usesCount} / {maxUses} used
|
||||
</span>
|
||||
<div style="background: #e9ecef; border-radius: 3px; height: 6px; width: 100%; margin-top: 4px; overflow: hidden;">
|
||||
<div style="background: var(--surface-muted); border-radius: 3px; height: 6px; width: 100%; margin-top: 4px; overflow: hidden;">
|
||||
<div
|
||||
style={`background: ${
|
||||
isExhausted ? "#6c757d" : "#28a745"
|
||||
isExhausted
|
||||
? "var(--text-muted)"
|
||||
: "var(--success)"
|
||||
}; height: 100%; width: ${
|
||||
Math.min(
|
||||
100,
|
||||
@ -311,34 +352,22 @@ export const AdminInvitesPage = ({
|
||||
<span class="badge badge-secondary">Exhausted</span>
|
||||
)}
|
||||
{isExpired && !isExhausted && (
|
||||
<span class="badge badge-suspended">Expired</span>
|
||||
<span class="badge badge-danger">Expired</span>
|
||||
)}
|
||||
{isActive && (
|
||||
<span class="badge badge-active">Active</span>
|
||||
<span class="badge badge-success">Active</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{inv.auto_activate !== false
|
||||
? (
|
||||
<span style="font-size: 0.8rem; color: #198754; font-weight: 500;">
|
||||
Auto-Active
|
||||
</span>
|
||||
)
|
||||
: (
|
||||
<span style="font-size: 0.8rem; color: #fd7e14; font-weight: 500;">
|
||||
Requires Approval
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style="font-size: 0.85rem;">
|
||||
<td style="font-size: 0.85rem; color: var(--text-secondary);">
|
||||
{new Date(inv.expires_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td>
|
||||
<div style="display: flex; gap: 0.3rem; flex-wrap: wrap;">
|
||||
<div style="display: flex; gap: 0.35rem; flex-wrap: wrap;">
|
||||
{isActive && (
|
||||
<button
|
||||
type="button"
|
||||
class="btn-action btn-success"
|
||||
class="btn-primary"
|
||||
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
|
||||
onclick={`copyInviteLink('${inv.code}')`}
|
||||
>
|
||||
Copy Link
|
||||
@ -347,8 +376,8 @@ export const AdminInvitesPage = ({
|
||||
{usesCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
class="btn-action"
|
||||
style="background: #e2e3e5; color: #383d41;"
|
||||
class="btn-outline"
|
||||
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
|
||||
onclick={`showRedemptionsModal('${inv.id}', '${inv.code}')`}
|
||||
>
|
||||
Claimed ({usesCount})
|
||||
@ -357,7 +386,8 @@ export const AdminInvitesPage = ({
|
||||
{isActive && (
|
||||
<button
|
||||
type="button"
|
||||
class="btn-action btn-warning"
|
||||
class="btn-danger"
|
||||
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
|
||||
onclick={`revokeInvite('${inv.id}', '${inv.code}')`}
|
||||
>
|
||||
Revoke
|
||||
@ -374,21 +404,124 @@ export const AdminInvitesPage = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Adaptive Cards View (< 768px) */}
|
||||
<div
|
||||
id="invitesMobileDeck"
|
||||
class="mobile-only"
|
||||
style="display: flex; flex-direction: column; gap: 0.75rem;"
|
||||
>
|
||||
{invites.map((inv) => {
|
||||
const usesCount = inv.uses_count || 0;
|
||||
const maxUses = inv.max_uses;
|
||||
const isUnlimited = maxUses === null;
|
||||
const isExhausted = !isUnlimited && usesCount >= maxUses;
|
||||
const isExpired = new Date(inv.expires_at) < new Date();
|
||||
const isActive = !isExhausted && !isExpired;
|
||||
|
||||
return (
|
||||
<div
|
||||
class="card invite-card"
|
||||
key={inv.id}
|
||||
data-search={`${inv.code} ${inv.app_name || ""} ${inv.role}`
|
||||
.toLowerCase()}
|
||||
style="margin-bottom: 0; padding: 1rem;"
|
||||
>
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem;">
|
||||
<div>
|
||||
<code style="background: var(--surface-muted); padding: 0.25rem 0.5rem; border-radius: var(--radius-sm); font-weight: 700; font-family: monospace; font-size: 1rem; color: var(--primary);">
|
||||
{inv.code}
|
||||
</code>
|
||||
<div style="font-size: 0.8rem; color: var(--text-muted); margin-top: 0.25rem;">
|
||||
{inv.app_name || "Global / Open"} • Role:{" "}
|
||||
<strong>{inv.role}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExhausted && (
|
||||
<span class="badge badge-secondary">Exhausted</span>
|
||||
)}
|
||||
{isExpired && !isExhausted && (
|
||||
<span class="badge badge-danger">Expired</span>
|
||||
)}
|
||||
{isActive && <span class="badge badge-success">Active</span>}
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 0.75rem;">
|
||||
<div style="font-size: 0.8rem; color: var(--text-secondary); display: flex; justify-content: space-between; margin-bottom: 0.25rem;">
|
||||
<span>
|
||||
Capacity:{" "}
|
||||
{isUnlimited ? "Unlimited" : `${usesCount} / ${maxUses}`}
|
||||
</span>
|
||||
<span>
|
||||
Expires: {new Date(inv.expires_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
{!isUnlimited && (
|
||||
<div style="background: var(--surface-muted); border-radius: 3px; height: 6px; width: 100%; overflow: hidden;">
|
||||
<div
|
||||
style={`background: ${
|
||||
isExhausted ? "var(--text-muted)" : "var(--success)"
|
||||
}; height: 100%; width: ${
|
||||
Math.min(100, (usesCount / maxUses) * 100)
|
||||
}%;`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
{isActive && (
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.85rem;"
|
||||
onclick={`copyInviteLink('${inv.code}')`}
|
||||
>
|
||||
Copy Link
|
||||
</button>
|
||||
)}
|
||||
{usesCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.85rem;"
|
||||
onclick={`showRedemptionsModal('${inv.id}', '${inv.code}')`}
|
||||
>
|
||||
Claimed ({usesCount})
|
||||
</button>
|
||||
)}
|
||||
{isActive && (
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger"
|
||||
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.85rem;"
|
||||
onclick={`revokeInvite('${inv.id}', '${inv.code}')`}
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Redemptions Modal */}
|
||||
<div
|
||||
id="redemptions-modal"
|
||||
style="display: none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.5); z-index: 9999; justify-content: center; align-items: center;"
|
||||
style="display: none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.6); z-index: 9999; justify-content: center; align-items: center;"
|
||||
>
|
||||
<div style="background: white; border-radius: 8px; width: 90%; max-width: 550px; padding: 1.5rem; box-shadow: 0 4px 12px rgba(0,0,0,0.15);">
|
||||
<div style="background: var(--surface-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); width: 90%; max-width: 550px; padding: 1.5rem; box-shadow: var(--shadow-lg);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h3 style="margin: 0; font-size: 1.1rem;">
|
||||
<h3 style="margin: 0; font-size: 1.1rem; color: var(--text-primary);">
|
||||
Users Claimed:{" "}
|
||||
<code id="modal-invite-code" style="color: #0d6efd;"></code>
|
||||
<code id="modal-invite-code" style="color: var(--primary);">
|
||||
</code>
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onclick="closeRedemptionsModal()"
|
||||
style="background: none; border: none; font-size: 1.2rem; cursor: pointer; color: #6c757d;"
|
||||
style="background: none; border: none; font-size: 1.2rem; cursor: pointer; color: var(--text-muted);"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@ -398,7 +531,7 @@ export const AdminInvitesPage = ({
|
||||
id="modal-redemptions-content"
|
||||
style="max-height: 350px; overflow-y: auto;"
|
||||
>
|
||||
<p style="color: #6c757d; font-size: 0.9rem;">
|
||||
<p style="color: var(--text-muted); font-size: 0.9rem;">
|
||||
Loading claimed users...
|
||||
</p>
|
||||
</div>
|
||||
@ -406,7 +539,7 @@ export const AdminInvitesPage = ({
|
||||
<div style="text-align: right; margin-top: 1rem;">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-action"
|
||||
class="btn-outline"
|
||||
onclick="closeRedemptionsModal()"
|
||||
>
|
||||
Close
|
||||
@ -415,6 +548,19 @@ export const AdminInvitesPage = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
{`
|
||||
@media (min-width: 768px) {
|
||||
.desktop-only { display: block !important; }
|
||||
.mobile-only { display: none !important; }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.desktop-only { display: none !important; }
|
||||
.mobile-only { display: flex !important; }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@ -453,10 +599,10 @@ export const AdminInvitesPage = ({
|
||||
const banner = document.getElementById('status-banner');
|
||||
banner.textContent = msg;
|
||||
banner.style.display = 'block';
|
||||
banner.style.background = isError ? '#f8d7da' : '#d1e7dd';
|
||||
banner.style.color = isError ? '#842029' : '#0f5132';
|
||||
banner.style.border = isError ? '1px solid #f5c2c7' : '1px solid #badbcc';
|
||||
setTimeout(() => { banner.style.display = 'none'; }, 6000);
|
||||
banner.style.background = isError ? 'var(--danger-bg)' : 'var(--success-bg)';
|
||||
banner.style.color = isError ? 'var(--danger-text)' : 'var(--success-text)';
|
||||
banner.style.border = isError ? '1px solid var(--danger-border)' : '1px solid var(--success-border)';
|
||||
setTimeout(() => { banner.style.display = 'none'; }, 5000);
|
||||
}
|
||||
|
||||
function toggleCreateInviteForm() {
|
||||
@ -485,6 +631,18 @@ export const AdminInvitesPage = ({
|
||||
maxUsesContainer.style.display = usage === 'limited' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function filterInvitesList() {
|
||||
const query = (document.getElementById('inviteSearchInput')?.value || '').toLowerCase().trim();
|
||||
document.querySelectorAll('.invite-row').forEach(r => {
|
||||
const text = r.getAttribute('data-search') || '';
|
||||
r.style.display = text.includes(query) ? '' : 'none';
|
||||
});
|
||||
document.querySelectorAll('.invite-card').forEach(c => {
|
||||
const text = c.getAttribute('data-search') || '';
|
||||
c.style.display = text.includes(query) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCreateInvite(e) {
|
||||
e.preventDefault();
|
||||
const type = document.getElementById('inviteType').value;
|
||||
@ -538,7 +696,7 @@ export const AdminInvitesPage = ({
|
||||
function copyGeneratedTokenUrl() {
|
||||
const text = document.getElementById('generatedTokenUrl').textContent;
|
||||
navigator.clipboard.writeText(text);
|
||||
showNotice('Registration URL copied to clipboard: ' + text, false);
|
||||
showNotice('Registration URL copied to clipboard!', false);
|
||||
}
|
||||
|
||||
function copyInviteLink(code) {
|
||||
@ -555,7 +713,7 @@ export const AdminInvitesPage = ({
|
||||
});
|
||||
if (res.ok) {
|
||||
showNotice('Invite token revoked', false);
|
||||
setTimeout(() => window.location.reload(), 800);
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
showNotice(data.error || 'Failed to revoke invite', true);
|
||||
@ -571,7 +729,7 @@ export const AdminInvitesPage = ({
|
||||
const contentEl = document.getElementById('modal-redemptions-content');
|
||||
|
||||
codeEl.textContent = code;
|
||||
contentEl.innerHTML = '<p style="color: #6c757d;">Loading...</p>';
|
||||
contentEl.innerHTML = '<p style="color: var(--text-muted);">Loading...</p>';
|
||||
modal.style.display = 'flex';
|
||||
|
||||
try {
|
||||
@ -579,25 +737,25 @@ export const AdminInvitesPage = ({
|
||||
const data = await res.json();
|
||||
if (res.ok && data.redemptions && data.redemptions.length > 0) {
|
||||
let html = '<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';
|
||||
html += '<thead><tr style="text-align: left; border-bottom: 2px solid #dee2e6;">';
|
||||
html += '<th style="padding: 0.4rem;">Username</th>';
|
||||
html += '<th style="padding: 0.4rem;">Status</th>';
|
||||
html += '<th style="padding: 0.4rem;">Redeemed At</th>';
|
||||
html += '<thead><tr>';
|
||||
html += '<th style="padding: 0.5rem;">Username</th>';
|
||||
html += '<th style="padding: 0.5rem;">Status</th>';
|
||||
html += '<th style="padding: 0.5rem;">Redeemed At</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
data.redemptions.forEach(r => {
|
||||
html += '<tr style="border-bottom: 1px solid #dee2e6;">';
|
||||
html += '<td style="padding: 0.4rem;"><strong>' + r.username + '</strong></td>';
|
||||
html += '<td style="padding: 0.4rem;"><span class="badge badge-' + r.account_status + '">' + r.account_status + '</span></td>';
|
||||
html += '<td style="padding: 0.4rem; color: #6c757d;">' + new Date(r.redeemed_at).toLocaleString() + '</td>';
|
||||
html += '<tr>';
|
||||
html += '<td style="padding: 0.5rem;"><strong style="color: var(--text-primary); font-family: monospace;">@' + r.username + '</strong></td>';
|
||||
html += '<td style="padding: 0.5rem;"><span class="badge badge-' + (r.account_status === 'active' ? 'success' : 'warning') + '">' + r.account_status + '</span></td>';
|
||||
html += '<td style="padding: 0.5rem; color: var(--text-secondary);">' + new Date(r.redeemed_at).toLocaleString() + '</td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
contentEl.innerHTML = html;
|
||||
} else {
|
||||
contentEl.innerHTML = '<p style="color: #6c757d; text-align: center; padding: 1rem;">No users have redeemed this token yet.</p>';
|
||||
contentEl.innerHTML = '<p style="color: var(--text-muted); text-align: center; padding: 1rem;">No users have redeemed this token yet.</p>';
|
||||
}
|
||||
} catch (err) {
|
||||
contentEl.innerHTML = '<p style="color: #dc3545;">Failed to load redemption details.</p>';
|
||||
contentEl.innerHTML = '<p style="color: var(--danger);">Failed to load redemption details.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -52,7 +52,7 @@ export const AdminLayout = ({
|
||||
.admin-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
gap: 0.65rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
@ -170,7 +170,11 @@ export const AdminLayout = ({
|
||||
{/* Top Header */}
|
||||
<header class="admin-header">
|
||||
<div style="display: flex; align-items: center; gap: 0.75rem;">
|
||||
<a href="/dashboard" class="admin-brand">
|
||||
<a
|
||||
href="/admin/users"
|
||||
class="admin-brand"
|
||||
title="Auth-Yes Admin Console"
|
||||
>
|
||||
<span>Auth-Yes</span>
|
||||
<span class="admin-badge">Admin</span>
|
||||
</a>
|
||||
@ -180,14 +184,14 @@ export const AdminLayout = ({
|
||||
<a
|
||||
href="/dashboard"
|
||||
class="btn-outline"
|
||||
style="padding: 0.35rem 0.75rem; font-size: 0.85rem; min-height: 36px; text-decoration: none;"
|
||||
style="padding: 0.35rem 0.75rem; font-size: 0.85rem; min-height: 36px; display: inline-flex; align-items: center; justify-content: center; text-decoration: none;"
|
||||
>
|
||||
← User Hub
|
||||
</a>
|
||||
<a
|
||||
href="/logout"
|
||||
class="btn-danger"
|
||||
style="padding: 0.35rem 0.75rem; font-size: 0.85rem; min-height: 36px; text-decoration: none;"
|
||||
style="padding: 0.35rem 0.75rem; font-size: 0.85rem; min-height: 36px; display: inline-flex; align-items: center; justify-content: center; text-decoration: none;"
|
||||
>
|
||||
Logout
|
||||
</a>
|
||||
|
||||
@ -11,48 +11,60 @@ export const AdminRolesPage = ({
|
||||
<AdminLayout title="Role & Permission Catalog" currentPath="/admin/roles">
|
||||
<div
|
||||
id="status-banner"
|
||||
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: 4px; font-size: 0.9rem;"
|
||||
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: var(--radius-md); font-size: 0.9rem;"
|
||||
/>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
|
||||
<div>
|
||||
<h2 style="margin: 0; border: none; padding: 0;">
|
||||
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
|
||||
Role & Permission Catalog
|
||||
</h2>
|
||||
<p style="color: #6c757d; font-size: 0.9rem; margin: 0.2rem 0 0 0;">
|
||||
Manage global and application-specific RBAC roles and permissions.
|
||||
</h1>
|
||||
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
|
||||
Manage global and application-scoped RBAC roles and permissions.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn-action btn-success"
|
||||
style="padding: 0.5rem 1rem; font-size: 0.9rem;"
|
||||
onclick="toggleCreateRoleForm()"
|
||||
class="btn-primary"
|
||||
style="min-height: 40px;"
|
||||
onclick="openCreateRoleDrawer()"
|
||||
>
|
||||
+ Create Custom Role
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create / Edit Role Drawer */}
|
||||
<div
|
||||
id="create-role-card"
|
||||
id="roleFormCard"
|
||||
class="card"
|
||||
style="display: none; border-left: 4px solid #28a745; margin-bottom: 1.5rem;"
|
||||
style="display: none; border-left: 4px solid var(--primary); margin-bottom: 1.5rem;"
|
||||
>
|
||||
<h3>Create New Role</h3>
|
||||
<p style="color: #6c757d; font-size: 0.9rem;">
|
||||
Define a global shared role or an application-scoped custom role.
|
||||
<h3
|
||||
id="roleFormTitle"
|
||||
style="margin: 0 0 0.5rem 0; color: var(--text-primary);"
|
||||
>
|
||||
Create New Role
|
||||
</h3>
|
||||
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1.25rem 0;">
|
||||
Define a global shared role or an application-scoped custom grant.
|
||||
</p>
|
||||
|
||||
<form id="createRoleForm" onsubmit="handleCreateRole(event)">
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem;">
|
||||
<form id="roleForm" onsubmit="handleSaveRole(event)">
|
||||
<input type="hidden" id="editRoleId" value="" />
|
||||
|
||||
<div
|
||||
id="scopeSelectContainer"
|
||||
style="display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; margin-bottom: 1rem;"
|
||||
>
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Scope (Applicability) *
|
||||
</label>
|
||||
<select
|
||||
id="roleScope"
|
||||
onchange="handleScopeChange()"
|
||||
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box; background: white;"
|
||||
style="width: 100%;"
|
||||
>
|
||||
<option value="global">
|
||||
Global (Shared across ALL applications)
|
||||
@ -64,12 +76,12 @@ export const AdminRolesPage = ({
|
||||
</div>
|
||||
|
||||
<div id="appSelectContainer" style="display: none;">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Target Application *
|
||||
</label>
|
||||
<select
|
||||
id="roleAppId"
|
||||
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box; background: white;"
|
||||
style="width: 100%;"
|
||||
>
|
||||
{apps.map((app) => (
|
||||
<option value={app.id}>
|
||||
@ -80,9 +92,9 @@ export const AdminRolesPage = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 1rem; margin-bottom: 1rem;">
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; margin-bottom: 1.25rem;">
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Role Identifier *
|
||||
</label>
|
||||
<input
|
||||
@ -90,31 +102,32 @@ export const AdminRolesPage = ({
|
||||
id="roleName"
|
||||
placeholder="e.g. navigator, copilot, auditor"
|
||||
required
|
||||
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
Description / Purpose
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="roleDescription"
|
||||
placeholder="e.g. Flight routing and navigational telemetry access"
|
||||
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<button type="submit" class="btn-action btn-success">
|
||||
<div style="display: flex; gap: 0.75rem;">
|
||||
<button type="submit" class="btn-primary" style="min-height: 42px;">
|
||||
Save Role
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-action"
|
||||
onclick="toggleCreateRoleForm()"
|
||||
class="btn-outline"
|
||||
onclick="closeRoleDrawer()"
|
||||
style="min-height: 42px;"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@ -123,16 +136,41 @@ export const AdminRolesPage = ({
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
{/* Filter Controls */}
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem;">
|
||||
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||
<label style="font-weight: 600; font-size: 0.85rem;">
|
||||
Filter Scope:
|
||||
{/* Instant Search and Scope Filters */}
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.25rem; flex-wrap: wrap; gap: 0.75rem;">
|
||||
<div style="display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; flex: 1;">
|
||||
{/* Search */}
|
||||
<div style="position: relative; min-width: 200px; max-width: 300px; width: 100%;">
|
||||
<input
|
||||
type="text"
|
||||
id="roleSearchInput"
|
||||
placeholder="Search roles..."
|
||||
oninput="filterRoles()"
|
||||
style="width: 100%; padding: 0.45rem 0.85rem 0.45rem 2.1rem; font-size: 0.85rem;"
|
||||
/>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
style="position: absolute; left: 0.7rem; top: 50%; transform: translateY(-50%); color: var(--text-muted); pointer-events: none;"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Scope dropdown */}
|
||||
<div style="display: flex; gap: 0.4rem; align-items: center;">
|
||||
<label style="font-weight: 600; font-size: 0.85rem; color: var(--text-secondary); white-space: nowrap;">
|
||||
Scope:
|
||||
</label>
|
||||
<select
|
||||
id="filterScopeSelect"
|
||||
onchange="filterRolesTable()"
|
||||
style="padding: 0.35rem 0.6rem; border: 1px solid #ced4da; border-radius: 4px; background: white; font-size: 0.85rem;"
|
||||
onchange="filterRoles()"
|
||||
style="padding: 0.4rem 0.75rem; font-size: 0.85rem;"
|
||||
>
|
||||
<option value="all">All Roles</option>
|
||||
<option value="global">Global (Shared) Only</option>
|
||||
@ -143,20 +181,22 @@ export const AdminRolesPage = ({
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span
|
||||
id="roleCountDisplay"
|
||||
style="font-size: 0.85rem; color: #6c757d;"
|
||||
style="font-size: 0.85rem; color: var(--text-muted);"
|
||||
>
|
||||
Showing {roles.length} roles
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
{/* Desktop Table View (≥ 768px) */}
|
||||
<div class="table-container desktop-only" style="display: none;">
|
||||
<table id="rolesTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Role Name</th>
|
||||
<th>Role Identifier</th>
|
||||
<th>Scope</th>
|
||||
<th>Description</th>
|
||||
<th>Created</th>
|
||||
@ -168,8 +208,8 @@ export const AdminRolesPage = ({
|
||||
? (
|
||||
<tr>
|
||||
<td
|
||||
colspan={5}
|
||||
style="text-align: center; color: #6c757d; padding: 2rem;"
|
||||
colSpan={5}
|
||||
style="text-align: center; color: var(--text-muted); padding: 2rem;"
|
||||
>
|
||||
No roles found.
|
||||
</td>
|
||||
@ -181,9 +221,16 @@ export const AdminRolesPage = ({
|
||||
const isCoreAdmin = isGlobal && r.name === "admin";
|
||||
|
||||
return (
|
||||
<tr key={r.id} data-app-id={r.app_id || "global"}>
|
||||
<tr
|
||||
key={r.id}
|
||||
class="role-row"
|
||||
data-app-id={r.app_id || "global"}
|
||||
data-search={`${r.name} ${r.description || ""} ${
|
||||
isGlobal ? "global" : r.app_name || ""
|
||||
}`.toLowerCase()}
|
||||
>
|
||||
<td>
|
||||
<strong style="font-family: monospace; font-size: 0.95rem; color: #212529;">
|
||||
<strong style="font-family: monospace; font-size: 0.95rem; color: var(--text-primary);">
|
||||
{r.name}
|
||||
</strong>
|
||||
</td>
|
||||
@ -195,30 +242,43 @@ export const AdminRolesPage = ({
|
||||
</span>
|
||||
)
|
||||
: (
|
||||
<span class="badge badge-pending">
|
||||
<span class="badge badge-warning">
|
||||
{r.app_name || "App-Specific"}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style="color: #495057; font-size: 0.85rem;">
|
||||
<td style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{r.description || "-"}
|
||||
</td>
|
||||
<td style="font-size: 0.85rem;">
|
||||
<td style="font-size: 0.85rem; color: var(--text-secondary);">
|
||||
{new Date(r.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td>
|
||||
{!isCoreAdmin
|
||||
? (
|
||||
<div style="display: flex; gap: 0.35rem;">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-action btn-warning"
|
||||
class="btn-outline"
|
||||
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
|
||||
onclick={`openEditRoleDrawer(${
|
||||
JSON.stringify(JSON.stringify(r))
|
||||
})`}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger"
|
||||
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
|
||||
onclick={`deleteRole('${r.id}', '${r.name}')`}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<span style="color: #6c757d; font-size: 0.8rem; font-style: italic;">
|
||||
<span style="color: var(--text-muted); font-size: 0.8rem; font-style: italic;">
|
||||
System Core
|
||||
</span>
|
||||
)}
|
||||
@ -230,8 +290,91 @@ export const AdminRolesPage = ({
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile Adaptive Cards View (< 768px) */}
|
||||
<div
|
||||
id="rolesMobileDeck"
|
||||
class="mobile-only"
|
||||
style="display: flex; flex-direction: column; gap: 0.75rem;"
|
||||
>
|
||||
{roles.map((r) => {
|
||||
const isGlobal = !r.app_id;
|
||||
const isCoreAdmin = isGlobal && r.name === "admin";
|
||||
|
||||
return (
|
||||
<div
|
||||
class="card role-card"
|
||||
key={r.id}
|
||||
data-app-id={r.app_id || "global"}
|
||||
data-search={`${r.name} ${r.description || ""} ${
|
||||
isGlobal ? "global" : r.app_name || ""
|
||||
}`.toLowerCase()}
|
||||
style="margin-bottom: 0; padding: 1rem;"
|
||||
>
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem;">
|
||||
<strong style="font-family: monospace; font-size: 1rem; color: var(--text-primary);">
|
||||
{r.name}
|
||||
</strong>
|
||||
{isGlobal
|
||||
? <span class="badge badge-info">Global</span>
|
||||
: (
|
||||
<span class="badge badge-warning">
|
||||
{r.app_name || "App Scoped"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p style="margin: 0 0 0.75rem 0; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
{r.description || "No description provided."}
|
||||
</p>
|
||||
|
||||
{!isCoreAdmin
|
||||
? (
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.85rem;"
|
||||
onclick={`openEditRoleDrawer(${
|
||||
JSON.stringify(JSON.stringify(r))
|
||||
})`}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger"
|
||||
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.85rem;"
|
||||
onclick={`deleteRole('${r.id}', '${r.name}')`}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div style="font-size: 0.8rem; color: var(--text-muted); font-style: italic;">
|
||||
Protected System Core Role
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
{`
|
||||
@media (min-width: 768px) {
|
||||
.desktop-only { display: block !important; }
|
||||
.mobile-only { display: none !important; }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.desktop-only { display: none !important; }
|
||||
.mobile-only { display: flex !important; }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@ -239,15 +382,35 @@ export const AdminRolesPage = ({
|
||||
const banner = document.getElementById('status-banner');
|
||||
banner.textContent = msg;
|
||||
banner.style.display = 'block';
|
||||
banner.style.background = isError ? '#f8d7da' : '#d1e7dd';
|
||||
banner.style.color = isError ? '#842029' : '#0f5132';
|
||||
banner.style.border = isError ? '1px solid #f5c2c7' : '1px solid #badbcc';
|
||||
setTimeout(() => { banner.style.display = 'none'; }, 6000);
|
||||
banner.style.background = isError ? 'var(--danger-bg)' : 'var(--success-bg)';
|
||||
banner.style.color = isError ? 'var(--danger-text)' : 'var(--success-text)';
|
||||
banner.style.border = isError ? '1px solid var(--danger-border)' : '1px solid var(--success-border)';
|
||||
setTimeout(() => { banner.style.display = 'none'; }, 5000);
|
||||
}
|
||||
|
||||
function toggleCreateRoleForm() {
|
||||
const el = document.getElementById('create-role-card');
|
||||
el.style.display = el.style.display === 'none' ? 'block' : 'none';
|
||||
function openCreateRoleDrawer() {
|
||||
document.getElementById('editRoleId').value = '';
|
||||
document.getElementById('roleFormTitle').textContent = 'Create New Role';
|
||||
document.getElementById('scopeSelectContainer').style.display = 'grid';
|
||||
document.getElementById('roleName').value = '';
|
||||
document.getElementById('roleDescription').value = '';
|
||||
document.getElementById('roleFormCard').style.display = 'block';
|
||||
document.getElementById('roleFormCard').scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function openEditRoleDrawer(roleJson) {
|
||||
const role = JSON.parse(roleJson);
|
||||
document.getElementById('editRoleId').value = role.id;
|
||||
document.getElementById('roleFormTitle').textContent = 'Edit Role: ' + role.name;
|
||||
document.getElementById('scopeSelectContainer').style.display = 'none';
|
||||
document.getElementById('roleName').value = role.name || '';
|
||||
document.getElementById('roleDescription').value = role.description || '';
|
||||
document.getElementById('roleFormCard').style.display = 'block';
|
||||
document.getElementById('roleFormCard').scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function closeRoleDrawer() {
|
||||
document.getElementById('roleFormCard').style.display = 'none';
|
||||
}
|
||||
|
||||
function handleScopeChange() {
|
||||
@ -256,40 +419,47 @@ export const AdminRolesPage = ({
|
||||
appContainer.style.display = scope === 'app_specific' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function filterRolesTable() {
|
||||
const selected = document.getElementById('filterScopeSelect').value;
|
||||
const rows = document.querySelectorAll('#rolesTable tbody tr');
|
||||
function filterRoles() {
|
||||
const query = (document.getElementById('roleSearchInput')?.value || '').toLowerCase().trim();
|
||||
const selectedScope = document.getElementById('filterScopeSelect')?.value || 'all';
|
||||
|
||||
const rows = document.querySelectorAll('.role-row');
|
||||
const cards = document.querySelectorAll('.role-card');
|
||||
let visibleCount = 0;
|
||||
|
||||
rows.forEach((row) => {
|
||||
const rowAppId = row.getAttribute('data-app-id');
|
||||
if (!rowAppId) return;
|
||||
const checkMatch = (appId, searchText) => {
|
||||
const scopeMatch = selectedScope === 'all' || (selectedScope === 'global' && appId === 'global') || (appId === selectedScope);
|
||||
const textMatch = !query || searchText.includes(query);
|
||||
return scopeMatch && textMatch;
|
||||
};
|
||||
|
||||
if (selected === 'all') {
|
||||
row.style.display = '';
|
||||
visibleCount++;
|
||||
} else if (selected === 'global') {
|
||||
const isGlobal = rowAppId === 'global';
|
||||
row.style.display = isGlobal ? '' : 'none';
|
||||
if (isGlobal) visibleCount++;
|
||||
} else {
|
||||
const isMatch = rowAppId === selected;
|
||||
row.style.display = isMatch ? '' : 'none';
|
||||
if (isMatch) visibleCount++;
|
||||
}
|
||||
rows.forEach(r => {
|
||||
const appId = r.getAttribute('data-app-id');
|
||||
const search = r.getAttribute('data-search') || '';
|
||||
const match = checkMatch(appId, search);
|
||||
r.style.display = match ? '' : 'none';
|
||||
if (match) visibleCount++;
|
||||
});
|
||||
|
||||
cards.forEach(c => {
|
||||
const appId = c.getAttribute('data-app-id');
|
||||
const search = c.getAttribute('data-search') || '';
|
||||
const match = checkMatch(appId, search);
|
||||
c.style.display = match ? '' : 'none';
|
||||
});
|
||||
|
||||
document.getElementById('roleCountDisplay').textContent = 'Showing ' + visibleCount + ' roles';
|
||||
}
|
||||
|
||||
async function handleCreateRole(e) {
|
||||
async function handleSaveRole(e) {
|
||||
e.preventDefault();
|
||||
const scope = document.getElementById('roleScope').value;
|
||||
const editId = document.getElementById('editRoleId').value;
|
||||
const scope = document.getElementById('roleScope')?.value;
|
||||
const name = document.getElementById('roleName').value.trim();
|
||||
const description = document.getElementById('roleDescription').value.trim();
|
||||
let appId = null;
|
||||
|
||||
if (scope === 'app_specific') {
|
||||
if (!editId && scope === 'app_specific') {
|
||||
appId = document.getElementById('roleAppId').value;
|
||||
}
|
||||
|
||||
@ -299,20 +469,22 @@ export const AdminRolesPage = ({
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/roles', {
|
||||
method: 'POST',
|
||||
const url = editId ? ('/api/admin/roles/' + editId) : '/api/admin/roles';
|
||||
const method = editId ? 'PUT' : 'POST';
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, description, appId }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
showNotice('Role "' + name + '" created successfully!', false);
|
||||
setTimeout(() => window.location.reload(), 800);
|
||||
showNotice(editId ? 'Role updated successfully!' : 'Role created successfully!', false);
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
} else {
|
||||
showNotice(data.error || 'Failed to create role', true);
|
||||
showNotice(data.error || 'Failed to save role', true);
|
||||
}
|
||||
} catch (err) {
|
||||
showNotice('Network error creating role', true);
|
||||
showNotice('Network error saving role', true);
|
||||
}
|
||||
}
|
||||
|
||||
@ -324,7 +496,7 @@ export const AdminRolesPage = ({
|
||||
});
|
||||
if (res.ok) {
|
||||
showNotice('Role deleted', false);
|
||||
setTimeout(() => window.location.reload(), 800);
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
showNotice(data.error || 'Failed to delete role', true);
|
||||
|
||||
@ -19,32 +19,75 @@ export const AdminUserDetailsPage = ({
|
||||
<AdminLayout title={`User: ${user.username}`} currentPath="/admin/users">
|
||||
<div
|
||||
id="status-banner"
|
||||
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: 4px; font-size: 0.9rem;"
|
||||
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: var(--radius-md); font-size: 0.9rem;"
|
||||
/>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
|
||||
<div>
|
||||
<h2 style="margin: 0; border: none; padding: 0;">
|
||||
User Profile: {user.username}
|
||||
</h2>
|
||||
<span style="font-size: 0.85rem; color: #6c757d;">
|
||||
<h1 style="margin: 0 0 0.25rem 0; font-size: 1.75rem; font-weight: 700; color: var(--text-primary);">
|
||||
User Profile: @{user.username}
|
||||
</h1>
|
||||
<span style="font-size: 0.85rem; color: var(--text-muted); font-family: monospace;">
|
||||
UUID: {user.id}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href="/admin/users"
|
||||
style="color: #007bff; text-decoration: none; font-weight: 500;"
|
||||
class="btn-outline"
|
||||
style="text-decoration: none; font-size: 0.85rem; min-height: 36px;"
|
||||
>
|
||||
← Back to Users
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Profile & Display Name Editor Card */}
|
||||
<div class="card" style="margin-bottom: 1.5rem;">
|
||||
<h3 style="margin: 0 0 0.5rem 0; color: var(--text-primary);">
|
||||
Identity Details & Display Name
|
||||
</h3>
|
||||
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1.25rem 0;">
|
||||
Human-friendly display name passed to connected apps in the{" "}
|
||||
<code style="font-family: monospace;">X-Forwarded-User</code> header.
|
||||
</p>
|
||||
|
||||
<form
|
||||
id="editProfileForm"
|
||||
onsubmit={`handleUpdateProfile(event, '${user.id}')`}
|
||||
style="display: flex; gap: 0.75rem; align-items: flex-end; flex-wrap: wrap; max-width: 550px;"
|
||||
>
|
||||
<div style="flex: 1; min-width: 240px;">
|
||||
<label style="display: block; font-size: 0.85rem; font-weight: 600; margin-bottom: 0.35rem; color: var(--text-secondary);">
|
||||
Display Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="displayNameInput"
|
||||
value={user.display_name || ""}
|
||||
placeholder={`e.g. Tyler Gillispie (defaults to @${user.username})`}
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary"
|
||||
style="min-height: 44px;"
|
||||
>
|
||||
Save Display Name
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Application RBAC Access Matrix */}
|
||||
<div class="card" style="border-left: 4px solid #0d6efd;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div
|
||||
class="card"
|
||||
style="border-left: 4px solid var(--primary); margin-bottom: 1.5rem;"
|
||||
>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;">
|
||||
<div>
|
||||
<h3 style="margin: 0;">Application Access & RBAC Grants</h3>
|
||||
<p style="color: #6c757d; font-size: 0.9rem; margin-top: 0.2rem; margin-bottom: 0;">
|
||||
<h3 style="margin: 0; color: var(--text-primary);">
|
||||
Application Access & RBAC Grants
|
||||
</h3>
|
||||
<p style="color: var(--text-secondary); font-size: 0.9rem; margin-top: 0.2rem; margin-bottom: 0;">
|
||||
Manage this user's explicit permissions across registered
|
||||
applications (Default-Deny Zero-Trust).
|
||||
</p>
|
||||
@ -52,8 +95,8 @@ export const AdminUserDetailsPage = ({
|
||||
</div>
|
||||
|
||||
{/* Grant New Application Form */}
|
||||
<div style="margin-top: 1rem; padding: 1rem; background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px;">
|
||||
<h4 style="margin: 0 0 0.5rem 0; font-size: 0.9rem;">
|
||||
<div style="margin-top: 1rem; padding: 1.25rem; background: var(--surface-muted); border: 1px solid var(--border-subtle); border-radius: var(--radius-md);">
|
||||
<h4 style="margin: 0 0 0.75rem 0; font-size: 0.95rem; color: var(--text-primary);">
|
||||
Assign / Update Application Access
|
||||
</h4>
|
||||
<form
|
||||
@ -62,14 +105,14 @@ export const AdminUserDetailsPage = ({
|
||||
style="display: flex; gap: 0.8rem; align-items: flex-end; flex-wrap: wrap;"
|
||||
>
|
||||
<div style="flex: 2; min-width: 200px;">
|
||||
<label style="display: block; font-size: 0.8rem; font-weight: 600; margin-bottom: 0.2rem;">
|
||||
<label style="display: block; font-size: 0.8rem; font-weight: 600; margin-bottom: 0.3rem; color: var(--text-secondary);">
|
||||
Application
|
||||
</label>
|
||||
<select
|
||||
id="grantAppId"
|
||||
onchange="updateRoleOptions()"
|
||||
required
|
||||
style="width: 100%; padding: 0.45rem; border: 1px solid #ced4da; border-radius: 4px; background: white;"
|
||||
style="width: 100%;"
|
||||
>
|
||||
{allApps.map((app) => (
|
||||
<option value={app.id}>
|
||||
@ -80,13 +123,13 @@ export const AdminUserDetailsPage = ({
|
||||
</div>
|
||||
|
||||
<div style="flex: 1; min-width: 140px;">
|
||||
<label style="display: block; font-size: 0.8rem; font-weight: 600; margin-bottom: 0.2rem;">
|
||||
<label style="display: block; font-size: 0.8rem; font-weight: 600; margin-bottom: 0.3rem; color: var(--text-secondary);">
|
||||
Assigned Role
|
||||
</label>
|
||||
<select
|
||||
id="grantRole"
|
||||
required
|
||||
style="width: 100%; padding: 0.45rem; border: 1px solid #ced4da; border-radius: 4px; background: white;"
|
||||
style="width: 100%;"
|
||||
>
|
||||
{/* Dynamically populated */}
|
||||
</select>
|
||||
@ -94,15 +137,15 @@ export const AdminUserDetailsPage = ({
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-action btn-success"
|
||||
style="padding: 0.5rem 1rem; height: fit-content;"
|
||||
class="btn-primary"
|
||||
style="min-height: 44px;"
|
||||
>
|
||||
Save Grant
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="table-container" style="margin-top: 1rem;">
|
||||
<div class="table-container" style="margin-top: 1.25rem;">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@ -118,8 +161,8 @@ export const AdminUserDetailsPage = ({
|
||||
? (
|
||||
<tr>
|
||||
<td
|
||||
colspan={5}
|
||||
style="text-align: center; color: #dc3545; padding: 1.5rem;"
|
||||
colSpan={5}
|
||||
style="text-align: center; color: var(--danger); padding: 1.5rem;"
|
||||
>
|
||||
No application permissions granted (User is blocked from
|
||||
all subsidiary apps).
|
||||
@ -130,31 +173,28 @@ export const AdminUserDetailsPage = ({
|
||||
grants.map((grant) => (
|
||||
<tr key={grant.id}>
|
||||
<td>
|
||||
<strong>{grant.app_name}</strong>
|
||||
<strong style="color: var(--text-primary);">
|
||||
{grant.app_name}
|
||||
</strong>
|
||||
</td>
|
||||
<td>
|
||||
<code style="background: #e9ecef; padding: 0.2rem 0.4rem; border-radius: 3px; font-size: 0.8rem; color: #0d6efd;">
|
||||
<code style="background: var(--surface-muted); padding: 0.2rem 0.4rem; border-radius: var(--radius-sm); font-size: 0.8rem; font-family: monospace;">
|
||||
{grant.spiffe_id}
|
||||
</code>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class={`badge ${
|
||||
grant.role === "admin"
|
||||
? "badge-suspended"
|
||||
: "badge-info"
|
||||
}`}
|
||||
>
|
||||
<span class="badge badge-info">
|
||||
{grant.role}
|
||||
</span>
|
||||
</td>
|
||||
<td style="font-size: 0.85rem;">
|
||||
<td style="font-size: 0.85rem; color: var(--text-secondary);">
|
||||
{new Date(grant.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-action btn-warning"
|
||||
class="btn-danger"
|
||||
style="padding: 0.35rem 0.75rem; font-size: 0.8rem; min-height: 32px;"
|
||||
onclick={`revokeGrant('${user.id}', '${grant.app_id}', '${grant.app_name}')`}
|
||||
>
|
||||
Revoke Access
|
||||
@ -169,51 +209,55 @@ export const AdminUserDetailsPage = ({
|
||||
</div>
|
||||
|
||||
{/* Out-of-band Recovery */}
|
||||
<div class="card">
|
||||
<h3>Out-of-Band Account Recovery</h3>
|
||||
<p style="color: #6c757d; font-size: 0.9rem;">
|
||||
<div class="card" style="margin-bottom: 1.5rem;">
|
||||
<h3 style="margin: 0 0 0.5rem 0; color: var(--text-primary);">
|
||||
Out-of-Band Account Recovery
|
||||
</h3>
|
||||
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1rem 0;">
|
||||
Generate a one-time recovery link to allow the user to bind a new
|
||||
hardware passkey if all devices are lost.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-action btn-success"
|
||||
class="btn-primary"
|
||||
onclick={`generateRecoveryLink('${user.id}')`}
|
||||
>
|
||||
Generate Recovery Link
|
||||
</button>
|
||||
<div
|
||||
id="recovery-link-container"
|
||||
style="display: none; margin-top: 1rem; padding: 1rem; background: #f8f9fa; border: 1px solid #ced4da; border-radius: 4px;"
|
||||
style="display: none; margin-top: 1rem; padding: 1rem; background: var(--surface-muted); border: 1px solid var(--border-subtle); border-radius: var(--radius-md);"
|
||||
>
|
||||
<p style="margin-top: 0; font-weight: 500;">
|
||||
<p style="margin-top: 0; font-weight: 600; color: var(--text-primary);">
|
||||
Provide this link to the user:
|
||||
</p>
|
||||
<code
|
||||
id="recovery-link-text"
|
||||
style="display: block; word-break: break-all; margin-bottom: 0.5rem; color: #d63384;"
|
||||
style="display: block; word-break: break-all; margin-bottom: 0.5rem; color: var(--primary); font-family: monospace; background: var(--surface-card); padding: 0.5rem; border-radius: var(--radius-sm); border: 1px solid var(--border-subtle);"
|
||||
>
|
||||
</code>
|
||||
<p style="margin-bottom: 0; font-size: 0.85rem; color: #6c757d;">
|
||||
<p style="margin-bottom: 0; font-size: 0.85rem; color: var(--text-muted);">
|
||||
Link expires in 24 hours.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Sessions */}
|
||||
<div class="card">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<h3 style="margin: 0;">Active Sessions</h3>
|
||||
<div class="card" style="margin-bottom: 1.5rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h3 style="margin: 0; color: var(--text-primary);">
|
||||
Active Sessions
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-action btn-warning"
|
||||
class="btn-danger"
|
||||
onclick={`revokeAllSessions('${user.id}')`}
|
||||
>
|
||||
Revoke All Sessions
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="table-container" style="margin-top: 1rem;">
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@ -227,7 +271,10 @@ export const AdminUserDetailsPage = ({
|
||||
{sessions.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td colspan={4} style="text-align: center; color: #6c757d;">
|
||||
<td
|
||||
colSpan={4}
|
||||
style="text-align: center; color: var(--text-muted); padding: 1.5rem;"
|
||||
>
|
||||
No active sessions.
|
||||
</td>
|
||||
</tr>
|
||||
@ -236,16 +283,21 @@ export const AdminUserDetailsPage = ({
|
||||
sessions.map((session) => (
|
||||
<tr key={session.id}>
|
||||
<td>
|
||||
<code style="background: #f8f9fa; padding: 0.2rem 0.4rem; border-radius: 3px;">
|
||||
{session.id.substring(0, 8)}...
|
||||
<code style="background: var(--surface-muted); padding: 0.2rem 0.4rem; border-radius: var(--radius-sm); font-family: monospace;">
|
||||
{session.id.substring(0, 12)}...
|
||||
</code>
|
||||
</td>
|
||||
<td>{new Date(session.created_at).toLocaleString()}</td>
|
||||
<td>{new Date(session.expires_at).toLocaleString()}</td>
|
||||
<td style="color: var(--text-secondary);">
|
||||
{new Date(session.created_at).toLocaleString()}
|
||||
</td>
|
||||
<td style="color: var(--text-secondary);">
|
||||
{new Date(session.expires_at).toLocaleString()}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-action btn-warning"
|
||||
class="btn-danger"
|
||||
style="padding: 0.35rem 0.75rem; font-size: 0.8rem; min-height: 32px;"
|
||||
onclick={`revokeSession('${session.id}')`}
|
||||
>
|
||||
Revoke
|
||||
@ -261,8 +313,10 @@ export const AdminUserDetailsPage = ({
|
||||
|
||||
{/* Registered Passkeys */}
|
||||
<div class="card">
|
||||
<h3 style="margin-top: 0;">Registered Passkeys</h3>
|
||||
<div class="table-container" style="margin-top: 1rem;">
|
||||
<h3 style="margin: 0 0 1rem 0; color: var(--text-primary);">
|
||||
Registered Passkeys
|
||||
</h3>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@ -275,7 +329,10 @@ export const AdminUserDetailsPage = ({
|
||||
{passkeys.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td colspan={3} style="text-align: center; color: #6c757d;">
|
||||
<td
|
||||
colSpan={3}
|
||||
style="text-align: center; color: var(--text-muted); padding: 1.5rem;"
|
||||
>
|
||||
No registered passkeys.
|
||||
</td>
|
||||
</tr>
|
||||
@ -284,15 +341,18 @@ export const AdminUserDetailsPage = ({
|
||||
passkeys.map((pk) => (
|
||||
<tr key={pk.id}>
|
||||
<td>
|
||||
<code style="background: #f8f9fa; padding: 0.2rem 0.4rem; border-radius: 3px; word-break: break-all;">
|
||||
<code style="background: var(--surface-muted); padding: 0.2rem 0.4rem; border-radius: var(--radius-sm); word-break: break-all; font-family: monospace;">
|
||||
{pk.credential_id.substring(0, 32)}...
|
||||
</code>
|
||||
</td>
|
||||
<td>{pk.counter}</td>
|
||||
<td style="color: var(--text-secondary);">
|
||||
{pk.counter}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-action btn-warning"
|
||||
class="btn-danger"
|
||||
style="padding: 0.35rem 0.75rem; font-size: 0.8rem; min-height: 32px;"
|
||||
onclick={`deletePasskey('${user.id}', '${pk.id}')`}
|
||||
>
|
||||
Delete Device
|
||||
@ -333,7 +393,6 @@ export const AdminUserDetailsPage = ({
|
||||
});
|
||||
}
|
||||
|
||||
// Initial populate
|
||||
if (document.getElementById('grantAppId')) {
|
||||
updateRoleOptions();
|
||||
}
|
||||
@ -342,10 +401,32 @@ export const AdminUserDetailsPage = ({
|
||||
const banner = document.getElementById('status-banner');
|
||||
banner.textContent = msg;
|
||||
banner.style.display = 'block';
|
||||
banner.style.background = isError ? '#f8d7da' : '#d1e7dd';
|
||||
banner.style.color = isError ? '#842029' : '#0f5132';
|
||||
banner.style.border = isError ? '1px solid #f5c2c7' : '1px solid #badbcc';
|
||||
setTimeout(() => { banner.style.display = 'none'; }, 6000);
|
||||
banner.style.background = isError ? 'var(--danger-bg)' : 'var(--success-bg)';
|
||||
banner.style.color = isError ? 'var(--danger-text)' : 'var(--success-text)';
|
||||
banner.style.border = isError ? '1px solid var(--danger-border)' : '1px solid var(--success-border)';
|
||||
setTimeout(() => { banner.style.display = 'none'; }, 5000);
|
||||
}
|
||||
|
||||
async function handleUpdateProfile(e, userId) {
|
||||
e.preventDefault();
|
||||
const displayName = document.getElementById('displayNameInput').value.trim();
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/users/' + userId + '/profile', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ displayName }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
showNotice('User display name saved successfully!', false);
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
} else {
|
||||
showNotice(data.error || 'Failed to update display name', true);
|
||||
}
|
||||
} catch (err) {
|
||||
showNotice('Network error updating display name', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGrantAccess(e, userId) {
|
||||
@ -362,7 +443,7 @@ export const AdminUserDetailsPage = ({
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
showNotice('Application access granted successfully!', false);
|
||||
setTimeout(() => window.location.reload(), 800);
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
} else {
|
||||
showNotice(data.error || 'Failed to update application grant', true);
|
||||
}
|
||||
@ -379,9 +460,8 @@ export const AdminUserDetailsPage = ({
|
||||
});
|
||||
if (res.ok) {
|
||||
showNotice('Access revoked', false);
|
||||
setTimeout(() => window.location.reload(), 800);
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
showNotice(data.error || 'Failed to revoke grant', true);
|
||||
}
|
||||
} catch (err) {
|
||||
@ -412,7 +492,7 @@ export const AdminUserDetailsPage = ({
|
||||
const res = await fetch('/api/admin/sessions/' + sessionId, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
showNotice('Session revoked', false);
|
||||
setTimeout(() => window.location.reload(), 800);
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
} else {
|
||||
showNotice('Failed to revoke session', true);
|
||||
}
|
||||
@ -427,7 +507,7 @@ export const AdminUserDetailsPage = ({
|
||||
const res = await fetch('/api/admin/users/' + userId + '/sessions', { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
showNotice('All sessions revoked', false);
|
||||
setTimeout(() => window.location.reload(), 800);
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
} else {
|
||||
showNotice('Failed to revoke all sessions', true);
|
||||
}
|
||||
@ -443,7 +523,7 @@ export const AdminUserDetailsPage = ({
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
showNotice('Passkey deleted', false);
|
||||
setTimeout(() => window.location.reload(), 800);
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
} else {
|
||||
showNotice(data.error || 'Failed to delete passkey', true);
|
||||
}
|
||||
@ -453,7 +533,8 @@ export const AdminUserDetailsPage = ({
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
>
|
||||
</script>
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@ -12,7 +12,8 @@ export const AdminUsersPage = ({
|
||||
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: var(--radius-md); font-size: 0.9rem;"
|
||||
/>
|
||||
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
|
||||
<div>
|
||||
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
|
||||
User Management
|
||||
</h1>
|
||||
@ -22,13 +23,39 @@ export const AdminUsersPage = ({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Instant Search Bar */}
|
||||
<div style="position: relative; min-width: 260px; max-width: 360px; width: 100%;">
|
||||
<input
|
||||
type="text"
|
||||
id="userSearchInput"
|
||||
placeholder="Search users by name, handle, status..."
|
||||
oninput="filterUsersList()"
|
||||
style="width: 100%; padding: 0.55rem 1rem 0.55rem 2.25rem; font-size: 0.875rem;"
|
||||
/>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
style="position: absolute; left: 0.8rem; top: 50%; transform: translateY(-50%); color: var(--text-muted); pointer-events: none;"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Table View (≥ 768px) */}
|
||||
<div class="card desktop-only" style="display: none;">
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<table id="usersTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Identity</th>
|
||||
<th>Display Name</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
@ -43,14 +70,30 @@ export const AdminUsersPage = ({
|
||||
: "badge-warning";
|
||||
|
||||
return (
|
||||
<tr key={user.id}>
|
||||
<tr
|
||||
key={user.id}
|
||||
class="user-row"
|
||||
data-search={`${user.username} ${
|
||||
user.display_name || ""
|
||||
} ${user.account_status}`.toLowerCase()}
|
||||
>
|
||||
<td>
|
||||
<strong style="color: var(--text-primary);">
|
||||
{user.username}
|
||||
<strong style="color: var(--text-primary); font-family: monospace; font-size: 0.95rem;">
|
||||
@{user.username}
|
||||
</strong>
|
||||
</td>
|
||||
<td style="color: var(--text-secondary);">
|
||||
{user.display_name || "-"}
|
||||
{user.display_name
|
||||
? (
|
||||
<span style="font-weight: 600; color: var(--text-primary);">
|
||||
{user.display_name}
|
||||
</span>
|
||||
)
|
||||
: (
|
||||
<span style="color: var(--text-muted); font-style: italic;">
|
||||
(Matches @{user.username})
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span class={`badge ${statusClass}`}>
|
||||
@ -58,20 +101,32 @@ export const AdminUsersPage = ({
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div style="display: flex; gap: 0.35rem;">
|
||||
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||
<a
|
||||
href={`/admin/users/${user.id}`}
|
||||
class="btn-outline"
|
||||
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px; text-decoration: none;"
|
||||
style="padding: 0.35rem 0.75rem; font-size: 0.8rem; min-height: 32px; height: 32px; display: inline-flex; align-items: center; gap: 0.35rem; text-decoration: none; font-weight: 600;"
|
||||
>
|
||||
Manage
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M12 20h9"></path>
|
||||
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z">
|
||||
</path>
|
||||
</svg>
|
||||
<span>Manage</span>
|
||||
</a>
|
||||
{user.account_status === "pending" && (
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
style="background: var(--success); border-color: var(--success); padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
|
||||
onclick={`updateStatus('${user.id}', 'active')`}
|
||||
style="background: var(--success); border-color: var(--success); padding: 0.35rem 0.75rem; font-size: 0.8rem; min-height: 32px; height: 32px;"
|
||||
onclick={`updateStatus('${user.id}', 'active', '${user.username}')`}
|
||||
>
|
||||
Activate
|
||||
</button>
|
||||
@ -80,8 +135,8 @@ export const AdminUsersPage = ({
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger"
|
||||
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
|
||||
onclick={`updateStatus('${user.id}', 'suspended')`}
|
||||
style="padding: 0.35rem 0.75rem; font-size: 0.8rem; min-height: 32px; height: 32px;"
|
||||
onclick={`updateStatus('${user.id}', 'suspended', '${user.username}')`}
|
||||
>
|
||||
Suspend
|
||||
</button>
|
||||
@ -90,8 +145,8 @@ export const AdminUsersPage = ({
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
style="background: var(--success); border-color: var(--success); padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
|
||||
onclick={`updateStatus('${user.id}', 'active')`}
|
||||
style="background: var(--success); border-color: var(--success); padding: 0.35rem 0.75rem; font-size: 0.8rem; min-height: 32px; height: 32px;"
|
||||
onclick={`updateStatus('${user.id}', 'active', '${user.username}')`}
|
||||
>
|
||||
Re-Activate
|
||||
</button>
|
||||
@ -108,6 +163,7 @@ export const AdminUsersPage = ({
|
||||
|
||||
{/* Mobile Card Deck (< 768px) */}
|
||||
<div
|
||||
id="usersMobileDeck"
|
||||
class="mobile-only"
|
||||
style="display: flex; flex-direction: column; gap: 1rem;"
|
||||
>
|
||||
@ -119,18 +175,26 @@ export const AdminUsersPage = ({
|
||||
: "badge-warning";
|
||||
|
||||
return (
|
||||
<div class="card" key={user.id} style="margin-bottom: 0;">
|
||||
<div
|
||||
class="card user-card"
|
||||
key={user.id}
|
||||
data-search={`${user.username} ${
|
||||
user.display_name || ""
|
||||
} ${user.account_status}`.toLowerCase()}
|
||||
style="margin-bottom: 0;"
|
||||
>
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.75rem;">
|
||||
<div style="display: flex; align-items: center; gap: 0.65rem;">
|
||||
<div style="display: flex; align-items: center; justify-content: center; width: 38px; height: 38px; background: var(--primary-light); color: var(--primary); border-radius: var(--radius-md); font-weight: 700; font-size: 1rem;">
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
<div style="display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; background: var(--primary-light); color: var(--primary); border-radius: var(--radius-md); font-weight: 700; font-size: 1.1rem;">
|
||||
{(user.display_name || user.username).charAt(0)
|
||||
.toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<h3 style="margin: 0; font-size: 1.05rem; color: var(--text-primary);">
|
||||
{user.username}
|
||||
{user.display_name || user.username}
|
||||
</h3>
|
||||
<span style="font-size: 0.8rem; color: var(--text-muted);">
|
||||
{user.display_name || "No display name"}
|
||||
<span style="font-size: 0.8rem; color: var(--text-muted); font-family: monospace;">
|
||||
@{user.username}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -144,16 +208,28 @@ export const AdminUsersPage = ({
|
||||
<a
|
||||
href={`/admin/users/${user.id}`}
|
||||
class="btn-outline"
|
||||
style="flex: 1; text-decoration: none; justify-content: center; min-height: 40px; font-size: 0.85rem;"
|
||||
style="flex: 1; text-decoration: none; justify-content: center; min-height: 42px; font-size: 0.875rem; font-weight: 600; gap: 0.4rem;"
|
||||
>
|
||||
Manage User
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M12 20h9"></path>
|
||||
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z">
|
||||
</path>
|
||||
</svg>
|
||||
<span>Manage User</span>
|
||||
</a>
|
||||
{user.account_status === "pending" && (
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
style="flex: 1; background: var(--success); border-color: var(--success); justify-content: center; min-height: 40px; font-size: 0.85rem;"
|
||||
onclick={`updateStatus('${user.id}', 'active')`}
|
||||
style="flex: 1; background: var(--success); border-color: var(--success); justify-content: center; min-height: 42px; font-size: 0.875rem;"
|
||||
onclick={`updateStatus('${user.id}', 'active', '${user.username}')`}
|
||||
>
|
||||
Activate
|
||||
</button>
|
||||
@ -162,8 +238,8 @@ export const AdminUsersPage = ({
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger"
|
||||
style="flex: 1; justify-content: center; min-height: 40px; font-size: 0.85rem;"
|
||||
onclick={`updateStatus('${user.id}', 'suspended')`}
|
||||
style="flex: 1; justify-content: center; min-height: 42px; font-size: 0.875rem;"
|
||||
onclick={`updateStatus('${user.id}', 'suspended', '${user.username}')`}
|
||||
>
|
||||
Suspend
|
||||
</button>
|
||||
@ -172,8 +248,8 @@ export const AdminUsersPage = ({
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
style="flex: 1; background: var(--success); border-color: var(--success); justify-content: center; min-height: 40px; font-size: 0.85rem;"
|
||||
onclick={`updateStatus('${user.id}', 'active')`}
|
||||
style="flex: 1; background: var(--success); border-color: var(--success); justify-content: center; min-height: 42px; font-size: 0.875rem;"
|
||||
onclick={`updateStatus('${user.id}', 'active', '${user.username}')`}
|
||||
>
|
||||
Re-Activate
|
||||
</button>
|
||||
@ -207,11 +283,27 @@ export const AdminUsersPage = ({
|
||||
banner.style.background = isError ? 'var(--danger-bg)' : 'var(--success-bg)';
|
||||
banner.style.color = isError ? 'var(--danger-text)' : 'var(--success-text)';
|
||||
banner.style.border = isError ? '1px solid var(--danger-border)' : '1px solid var(--success-border)';
|
||||
setTimeout(() => { banner.style.display = 'none'; }, 6000);
|
||||
setTimeout(() => { banner.style.display = 'none'; }, 5000);
|
||||
}
|
||||
|
||||
async function updateStatus(userId, status) {
|
||||
if (!confirm('Are you sure you want to set this user to ' + status + '?')) {
|
||||
function filterUsersList() {
|
||||
const query = document.getElementById('userSearchInput').value.toLowerCase().trim();
|
||||
const rows = document.querySelectorAll('.user-row');
|
||||
const cards = document.querySelectorAll('.user-card');
|
||||
|
||||
rows.forEach(r => {
|
||||
const text = r.getAttribute('data-search') || '';
|
||||
r.style.display = text.includes(query) ? '' : 'none';
|
||||
});
|
||||
|
||||
cards.forEach(c => {
|
||||
const text = c.getAttribute('data-search') || '';
|
||||
c.style.display = text.includes(query) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
async function updateStatus(userId, status, username) {
|
||||
if (!confirm('Set user @' + username + ' to ' + status.toUpperCase() + '?')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@ -222,13 +314,13 @@ export const AdminUsersPage = ({
|
||||
});
|
||||
if (res.ok) {
|
||||
showNotice('User status updated to ' + status, false);
|
||||
setTimeout(() => window.location.reload(), 800);
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
showNotice(data.error || 'Failed to update status', true);
|
||||
}
|
||||
} catch (err) {
|
||||
showNotice('Network error', true);
|
||||
showNotice('Network error updating status', true);
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
@ -7,7 +7,8 @@ export const AuditLogPage = ({
|
||||
}) => {
|
||||
return (
|
||||
<AdminLayout title="Audit Logs" currentPath="/admin/audit-logs">
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
|
||||
<div>
|
||||
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
|
||||
Immutable Audit Ledger
|
||||
</h1>
|
||||
@ -17,10 +18,34 @@ export const AuditLogPage = ({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Instant Search Bar */}
|
||||
<div style="position: relative; min-width: 240px; max-width: 320px; width: 100%;">
|
||||
<input
|
||||
type="text"
|
||||
id="auditSearchInput"
|
||||
placeholder="Search action, user, IP..."
|
||||
oninput="filterAuditLogs()"
|
||||
style="width: 100%; padding: 0.5rem 1rem 0.5rem 2.25rem; font-size: 0.875rem;"
|
||||
/>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
style="position: absolute; left: 0.75rem; top: 50%; transform: translateY(-50%); color: var(--text-muted); pointer-events: none;"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Table (≥ 768px) */}
|
||||
<div class="card desktop-only" style="display: none;">
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<table id="auditTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
@ -37,7 +62,8 @@ export const AuditLogPage = ({
|
||||
log.action.includes("denied");
|
||||
const isSuccess = log.action.includes("success") ||
|
||||
log.action.includes("create") ||
|
||||
log.action.includes("activate");
|
||||
log.action.includes("activate") ||
|
||||
log.action.includes("updated");
|
||||
const badgeClass = isFail
|
||||
? "badge-danger"
|
||||
: isSuccess
|
||||
@ -45,7 +71,13 @@ export const AuditLogPage = ({
|
||||
: "badge-info";
|
||||
|
||||
return (
|
||||
<tr key={log.id}>
|
||||
<tr
|
||||
key={log.id}
|
||||
class="log-row"
|
||||
data-search={`${log.action} ${log.user || "system"} ${
|
||||
log.resource || ""
|
||||
} ${log.ip_address || ""}`.toLowerCase()}
|
||||
>
|
||||
<td style="font-size: 0.8rem; color: var(--text-muted); white-space: nowrap;">
|
||||
{new Date(log.created_at).toLocaleString()}
|
||||
</td>
|
||||
@ -62,7 +94,7 @@ export const AuditLogPage = ({
|
||||
<td style="color: var(--text-secondary);">
|
||||
{log.resource || "-"}
|
||||
</td>
|
||||
<td style="font-family: monospace; font-size: 0.85rem;">
|
||||
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
{log.ip_address || "-"}
|
||||
</td>
|
||||
<td>
|
||||
@ -90,6 +122,7 @@ export const AuditLogPage = ({
|
||||
|
||||
{/* Mobile Feed (< 768px) */}
|
||||
<div
|
||||
id="auditMobileFeed"
|
||||
class="mobile-only"
|
||||
style="display: flex; flex-direction: column; gap: 0.75rem;"
|
||||
>
|
||||
@ -97,7 +130,8 @@ export const AuditLogPage = ({
|
||||
const isFail = log.action.includes("fail") ||
|
||||
log.action.includes("denied");
|
||||
const isSuccess = log.action.includes("success") ||
|
||||
log.action.includes("create") || log.action.includes("activate");
|
||||
log.action.includes("create") || log.action.includes("activate") ||
|
||||
log.action.includes("updated");
|
||||
const badgeClass = isFail
|
||||
? "badge-danger"
|
||||
: isSuccess
|
||||
@ -106,8 +140,11 @@ export const AuditLogPage = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
class="card"
|
||||
class="card log-card"
|
||||
key={log.id}
|
||||
data-search={`${log.action} ${log.user || "system"} ${
|
||||
log.resource || ""
|
||||
} ${log.ip_address || ""}`.toLowerCase()}
|
||||
style="margin-bottom: 0; padding: 1rem;"
|
||||
>
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem;">
|
||||
@ -162,6 +199,24 @@ export const AuditLogPage = ({
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
function filterAuditLogs() {
|
||||
const query = (document.getElementById('auditSearchInput')?.value || '').toLowerCase().trim();
|
||||
document.querySelectorAll('.log-row').forEach(r => {
|
||||
const text = r.getAttribute('data-search') || '';
|
||||
r.style.display = text.includes(query) ? '' : 'none';
|
||||
});
|
||||
document.querySelectorAll('.log-card').forEach(c => {
|
||||
const text = c.getAttribute('data-search') || '';
|
||||
c.style.display = text.includes(query) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@ -16,26 +16,26 @@ export const AuthenticatedLayout = ({
|
||||
label: "Launchpad",
|
||||
href: "/dashboard",
|
||||
icon:
|
||||
`<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"></path><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"></path><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"></path><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"></path></svg>`,
|
||||
`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"></path><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"></path><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"></path><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"></path></svg>`,
|
||||
},
|
||||
{
|
||||
label: "Sessions",
|
||||
href: "/dashboard/sessions",
|
||||
icon:
|
||||
`<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="3" rx="2"></rect><line x1="8" x2="16" y1="21" y2="21"></line><line x1="12" x2="12" y1="17" y2="21"></line></svg>`,
|
||||
`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="3" rx="2"></rect><line x1="8" x2="16" y1="21" y2="21"></line><line x1="12" x2="12" y1="17" y2="21"></line></svg>`,
|
||||
},
|
||||
{
|
||||
label: "Passkeys",
|
||||
href: "/dashboard/passkeys",
|
||||
icon:
|
||||
`<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="7.5" cy="15.5" r="5.5"></circle><path d="m21 2-9.6 9.6"></path><path d="m15.5 7.5 3 3L22 7l-3-3"></path></svg>`,
|
||||
`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="7.5" cy="15.5" r="5.5"></circle><path d="m21 2-9.6 9.6"></path><path d="m15.5 7.5 3 3L22 7l-3-3"></path></svg>`,
|
||||
},
|
||||
...(isAdmin
|
||||
? [{
|
||||
label: "Admin",
|
||||
href: "/admin/users",
|
||||
icon:
|
||||
`<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path></svg>`,
|
||||
`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path></svg>`,
|
||||
}]
|
||||
: []),
|
||||
];
|
||||
@ -76,17 +76,18 @@ export const AuthenticatedLayout = ({
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
font-size: 1.15rem;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.brand-badge {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
@ -100,6 +101,7 @@ export const AuthenticatedLayout = ({
|
||||
/* Desktop Navigation */
|
||||
.desktop-nav {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: 2rem;
|
||||
}
|
||||
@ -108,12 +110,14 @@ export const AuthenticatedLayout = ({
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.9rem;
|
||||
padding: 0.4rem 0.85rem;
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
height: 36px;
|
||||
box-sizing: border-box;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
@ -137,8 +141,9 @@ export const AuthenticatedLayout = ({
|
||||
.logout-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.45rem 0.85rem;
|
||||
padding: 0.4rem 0.85rem;
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
color: var(--danger);
|
||||
@ -146,6 +151,8 @@ export const AuthenticatedLayout = ({
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid var(--danger-border);
|
||||
background: var(--danger-bg);
|
||||
height: 36px;
|
||||
box-sizing: border-box;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
@ -291,9 +298,22 @@ export const AuthenticatedLayout = ({
|
||||
<a
|
||||
href="/admin/users"
|
||||
class="btn-primary"
|
||||
style="padding: 0.4rem 0.85rem; font-size: 0.85rem; min-height: 36px; text-decoration: none;"
|
||||
style="padding: 0.4rem 0.85rem; font-size: 0.85rem; min-height: 36px; height: 36px; box-sizing: border-box; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; gap: 0.35rem;"
|
||||
>
|
||||
Admin Console
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z">
|
||||
</path>
|
||||
</svg>
|
||||
<span>Admin Console</span>
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user