fix(ui): restore launchpad routing, admin catalog pages, and ui-audit sessions workflow

This commit is contained in:
Tyler Gillispie 2026-08-27 21:47:50 -07:00
parent 2c07c8cba1
commit b2a2bdab07
14 changed files with 1454 additions and 345 deletions

View File

@ -0,0 +1,123 @@
import { AdminLayoutFragment } from "../../shared/ui/fragments.tsx";
export const AAGUIDPageFragment = ({
allowlist = [],
}: {
allowlist?: any[];
}) => {
return (
<AdminLayoutFragment
title="AAGUID Allow-List"
currentPath="/admin/aaguid"
>
<div style="margin-bottom: 1.5rem;">
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
AAGUID Hardware Allow-List
</h1>
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
Manage the enterprise allow-list of approved hardware Authenticator
Attestation GUIDs (AAGUIDs).
</p>
</div>
<div class="card" style="margin-bottom: 1.5rem;">
<h3 style="margin: 0 0 1rem 0; color: var(--text-primary);">
Add Hardware Key Model
</h3>
<form
id="add-aaguid-form"
onsubmit="handleAddAaguid(event)"
style="display: flex; gap: 1rem; align-items: flex-end; flex-wrap: wrap;"
>
<div style="flex: 1; min-width: 240px;">
<label style="display: block; margin-bottom: 0.35rem; font-weight: 600; font-size: 0.85rem; color: var(--text-secondary);">
AAGUID (UUID format) *
</label>
<input
type="text"
id="aaguidInput"
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
required
style="width: 100%; font-family: monospace;"
/>
</div>
<div style="flex: 2; min-width: 240px;">
<label style="display: block; margin-bottom: 0.35rem; font-weight: 600; font-size: 0.85rem; color: var(--text-secondary);">
Description (e.g. YubiKey 5 NFC / Titan Security Key)
</label>
<input
type="text"
id="aaguidDescInput"
placeholder="Hardware Model / Vendor Name"
style="width: 100%;"
/>
</div>
<div>
<button
type="submit"
class="btn-primary"
style="min-height: 42px;"
>
Add to Allow-List
</button>
</div>
</form>
</div>
<div class="card">
<div class="table-container">
<table>
<thead>
<tr>
<th>AAGUID</th>
<th>Description</th>
<th>Added On</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{allowlist.length === 0
? (
<tr>
<td
colSpan={4}
style="text-align: center; color: var(--text-muted); padding: 2rem;"
>
The allow-list is empty. All certified hardware passkeys
are accepted.
</td>
</tr>
)
: (
allowlist.map((item: any) => (
<tr key={item.id}>
<td>
<code style="background: var(--surface-muted); padding: 0.25rem 0.5rem; border-radius: var(--radius-sm); font-family: monospace;">
{item.aaguid}
</code>
</td>
<td>{item.description || "-"}</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">
{new Date(item.created_at).toLocaleDateString()}
</td>
<td>
<button
type="button"
class="btn-danger"
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
onclick={`removeAaguid('${item.id}')`}
>
Remove
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
<script src="/public/admin-scripts.js"></script>
</AdminLayoutFragment>
);
};

View File

@ -8,6 +8,9 @@ import {
AdminUsersPageFragment,
AuditLogPageFragment,
} from "./fragments.tsx";
import { AdminRolesPageFragment } from "./roles_fragments.tsx";
import { AdminInvitesPageFragment } from "./invites_fragments.tsx";
import { AAGUIDPageFragment } from "./aaguid_fragments.tsx";
import { sqlWrapper } from "../../core/db.ts";
import { rateLimitWrapper } from "../../core/middleware.ts";
@ -144,4 +147,32 @@ test("admin fragment components render valid HTML markup", () => {
],
});
expect(auditFragment).toBeDefined();
const rolesFragment = AdminRolesPageFragment({
roles: [{ id: "r-1", name: "editor", description: "Editor role" }],
apps: [{ id: "a-1", name: "ed-droid" }],
});
expect(rolesFragment).toBeDefined();
const invitesFragment = AdminInvitesPageFragment({
invites: [{
id: "inv-1",
code: "inv-abc",
role: "user",
max_uses: 10,
expires_at: new Date().toISOString(),
}],
apps: [{ id: "a-1", name: "ed-droid" }],
allRoles: [{ id: "r-1", name: "editor" }],
});
expect(invitesFragment).toBeDefined();
const aaguidFragment = AAGUIDPageFragment({
allowlist: [{
id: "aa-1",
aaguid: "00000000-0000-0000-0000-000000000000",
description: "YubiKey",
}],
});
expect(aaguidFragment).toBeDefined();
});

View File

@ -0,0 +1,282 @@
import { Hono } from "jsr:@hono/hono@4";
import { encodeBase64Url } from "jsr:@std/encoding@1/base64url";
import { getAuthenticatedUser } from "../../core/session.ts";
import { valkey } from "../../core/valkey.ts";
import { auditWrapper } from "../../core/audit.ts";
import { getClientIp } from "../../core/middleware.ts";
import {
addAaguid,
assignUserGrant,
createInvite,
createRecoveryLink,
createRole,
deleteRole,
getUserById,
removeAaguid,
removeUserGrant,
revokeAllUserSessions,
revokeInvite,
revokeSessionById,
revokeUserPasskey,
updateRole,
updateUserProfile,
updateUserStatus,
} from "./queries.ts";
export const adminActionsRoutes = new Hono();
// User Mutations
adminActionsRoutes.post("/users/:id/status", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const targetUserId = c.req.param("id");
const { status } = await c.req.json();
if (!["active", "suspended"].includes(status)) {
return c.json({ error: "Invalid status value" }, 400);
}
const updated = await updateUserStatus(targetUserId, status);
if (!updated) return c.json({ error: "User not found" }, 404);
if (status === "suspended") {
await revokeAllUserSessions(targetUserId);
}
auditWrapper.auditLog(
auth.userId,
"user_status_updated",
targetUserId,
{ new_status: status },
getClientIp(c),
);
return c.json({ success: true, status });
});
adminActionsRoutes.post("/users/:id/profile", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const targetUserId = c.req.param("id");
const { displayName } = await c.req.json();
const updated = await updateUserProfile(
targetUserId,
displayName?.trim() || null,
);
if (!updated) return c.json({ error: "User not found" }, 404);
auditWrapper.auditLog(
auth.userId,
"user_profile_updated",
targetUserId,
{ display_name: displayName },
getClientIp(c),
);
return c.json({ success: true, user: updated });
});
adminActionsRoutes.post("/users/:id/grants", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const targetUserId = c.req.param("id");
const { appId, role } = await c.req.json();
if (!appId || !role) {
return c.json({ error: "App and role are required" }, 400);
}
await assignUserGrant(targetUserId, appId, role);
try {
await valkey.del(`auth:grants:${targetUserId}:${appId}`);
} catch (_err) {}
auditWrapper.auditLog(
auth.userId,
"grant_assigned",
targetUserId,
{ app_id: appId, role },
getClientIp(c),
);
return c.json({ success: true });
});
adminActionsRoutes.delete("/users/:id/grants/:appId", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const targetUserId = c.req.param("id");
const appId = c.req.param("appId");
await removeUserGrant(targetUserId, appId);
try {
await valkey.del(`auth:grants:${targetUserId}:${appId}`);
} catch (_err) {}
auditWrapper.auditLog(
auth.userId,
"grant_removed",
targetUserId,
{ app_id: appId },
getClientIp(c),
);
return c.json({ success: true });
});
adminActionsRoutes.delete("/users/:id/sessions", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const targetUserId = c.req.param("id");
await revokeAllUserSessions(targetUserId);
auditWrapper.auditLog(
auth.userId,
"user_sessions_revoked_all",
targetUserId,
null,
getClientIp(c),
);
return c.json({ success: true });
});
adminActionsRoutes.delete("/users/:id/passkeys/:passkeyId", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const targetUserId = c.req.param("id");
const passkeyId = c.req.param("passkeyId");
const deleted = await revokeUserPasskey(targetUserId, passkeyId);
if (deleted) {
auditWrapper.auditLog(
auth.userId,
"user_passkey_revoked",
targetUserId,
{ passkey_id: passkeyId },
getClientIp(c),
);
return c.json({ success: true });
}
return c.json({ error: "Passkey not found" }, 404);
});
adminActionsRoutes.post("/users/:id/recovery", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const targetUserId = c.req.param("id");
const targetUser = await getUserById(targetUserId);
if (!targetUser) return c.json({ error: "User not found" }, 404);
const recoveryCode = encodeBase64Url(
crypto.getRandomValues(new Uint8Array(24)),
);
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 1);
await createRecoveryLink(recoveryCode, targetUserId, auth.userId, expiresAt);
auditWrapper.auditLog(
auth.userId,
"recovery_link_created",
targetUserId,
null,
getClientIp(c),
);
return c.json({ success: true, recoveryCode, expiresAt });
});
adminActionsRoutes.delete("/sessions/:id", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const sessionId = c.req.param("id");
await revokeSessionById(sessionId);
try {
await valkey.del(sessionId);
} catch (_err) {}
auditWrapper.auditLog(
auth.userId,
"admin_session_revoked",
sessionId,
null,
getClientIp(c),
);
return c.json({ success: true });
});
// Roles Mutations
adminActionsRoutes.post("/roles", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const { id, name, description, app_id } = await c.req.json();
if (!name) return c.json({ error: "Role name is required" }, 400);
let role;
if (id) {
role = await updateRole(id, name, description || "", app_id || null);
} else {
role = await createRole(name, description || "", app_id || null);
}
return c.json({ success: true, role });
});
adminActionsRoutes.delete("/roles/:id", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const roleId = c.req.param("id");
await deleteRole(roleId);
return c.json({ success: true });
});
// Invites Mutations
adminActionsRoutes.post("/invites", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const { app_id, role, max_uses, expires_in_days } = await c.req.json();
const code = encodeBase64Url(crypto.getRandomValues(new Uint8Array(12)));
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + (Number(expires_in_days) || 7));
const maxUsesNum = Number(max_uses) > 0 ? Number(max_uses) : null;
const invite = await createInvite(
code,
app_id || null,
role || "user",
maxUsesNum,
expiresAt,
true,
);
return c.json({ success: true, invite });
});
adminActionsRoutes.delete("/invites/:id", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const inviteId = c.req.param("id");
await revokeInvite(inviteId);
return c.json({ success: true });
});
// AAGUID Mutations
adminActionsRoutes.post("/aaguid", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const { aaguid, description } = await c.req.json();
if (!aaguid) return c.json({ error: "AAGUID is required" }, 400);
const entry = await addAaguid(aaguid.trim(), description || "");
return c.json({ success: true, entry });
});
adminActionsRoutes.delete("/aaguid/:id", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const id = c.req.param("id");
await removeAaguid(id);
return c.json({ success: true });
});

View File

@ -0,0 +1,190 @@
import { AdminLayoutFragment } from "../../shared/ui/fragments.tsx";
export const AdminInvitesPageFragment = ({
invites = [],
apps = [],
allRoles = [],
}: {
invites?: any[];
apps?: any[];
allRoles?: any[];
}) => {
return (
<AdminLayoutFragment
title="Invite & Onboarding Tokens"
currentPath="/admin/invites"
>
<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);">
Invite & Onboarding 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>
<button
type="button"
class="btn-primary"
style="min-height: 40px;"
onclick="toggleCreateInviteForm()"
>
+ Generate Token
</button>
</div>
{/* Invite Generation Drawer */}
<div
id="inviteDrawer"
class="card"
style="display: none; border-left: 4px solid var(--primary); 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);">
Generate Registration Invite Token
</h3>
<button
type="button"
onclick="toggleCreateInviteForm()"
style="background: none; border: none; font-size: 1.25rem; color: var(--text-muted); cursor: pointer;"
>
&times;
</button>
</div>
<form id="createInviteForm" onsubmit="handleCreateInvite(event)">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 1rem;">
<div>
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
Target Application Scope
</label>
<select
id="inviteAppSelect"
style="width: 100%; padding: 0.5rem 0.75rem; background: var(--surface-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); color: var(--text-primary);"
>
<option value="">Global (Auth-Yes Platform)</option>
{apps.map((app: any) => (
<option value={app.id} key={app.id}>
{app.name} ({app.domain || "Internal"})
</option>
))}
</select>
</div>
<div>
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
Granted Role
</label>
<select
id="inviteRoleSelect"
style="width: 100%; padding: 0.5rem 0.75rem; background: var(--surface-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); color: var(--text-primary);"
>
<option value="user">Standard User (user)</option>
<option value="admin">Administrator (admin)</option>
{allRoles.map((r: any) => (
<option value={r.name} key={r.id}>{r.name}</option>
))}
</select>
</div>
<div>
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
Max Redemptions (0 = Unlimited)
</label>
<input
type="number"
id="inviteMaxUsesInput"
value="1"
min="0"
style="width: 100%;"
/>
</div>
</div>
<div style="display: flex; gap: 0.75rem;">
<button type="submit" class="btn-primary" style="min-height: 38px;">
Mint Invite Token
</button>
<button
type="button"
class="btn-outline"
onclick="toggleCreateInviteForm()"
style="min-height: 38px;"
>
Cancel
</button>
</div>
</form>
</div>
<div class="card">
<div class="table-container">
<table id="invitesTable">
<thead>
<tr>
<th>Invite Code</th>
<th>Target App / Scope</th>
<th>Role</th>
<th>Usage</th>
<th>Expires</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{invites.length === 0
? (
<tr>
<td
colSpan={6}
style="text-align: center; color: var(--text-muted); padding: 2rem;"
>
No active invite tokens found.
</td>
</tr>
)
: (
invites.map((inv: any) => (
<tr key={inv.id}>
<td>
<code style="background: var(--surface-muted); padding: 0.25rem 0.5rem; border-radius: var(--radius-sm); font-family: monospace; font-weight: 700; color: var(--primary);">
{inv.code}
</code>
</td>
<td>
{inv.app_name
? (
<span class="badge badge-warning">
{inv.app_name}
</span>
)
: <span class="badge badge-info">Global</span>}
</td>
<td>
<span class="badge badge-secondary">{inv.role}</span>
</td>
<td>
{inv.uses_count || 0} / {inv.max_uses ?? "∞"}
</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">
{new Date(inv.expires_at).toLocaleDateString()}
</td>
<td>
<button
type="button"
class="btn-danger"
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
onclick={`revokeInvite('${inv.id}')`}
>
Revoke
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
<script src="/public/admin-scripts.js"></script>
</AdminLayoutFragment>
);
};

View File

@ -126,6 +126,8 @@ export const getAllApps = async () => {
`;
};
export const getAdminApps = getAllApps;
export const getAuditLogs = async (limit: number = 50) => {
return await sqlWrapper.sql`
SELECT a.id, a.action, a.resource, a.details, a.ip_address, a.created_at, u.username as user
@ -159,3 +161,144 @@ export const revokeSessionById = async (sessionId: string) => {
RETURNING id
`.then((res: any) => res[0]);
};
export const getDashboardApps = async (
userId: string,
isAdmin: boolean,
customScopes?: string[],
) => {
if (isAdmin) {
return await sqlWrapper.sql`
SELECT id, name, description, domain, 'Admin' as role
FROM apps
WHERE domain IS NOT NULL
ORDER BY name ASC
`;
} else {
const appNames = (customScopes || [])
.filter((s) => s.startsWith("app:"))
.map((s) => s.split(":")[1]);
if (appNames.length > 0) {
return await sqlWrapper.sql`
SELECT a.id, a.name, a.description, a.domain, g.role
FROM apps a
JOIN grants g ON a.id = g.app_id
WHERE g.user_id = ${userId} AND a.domain IS NOT NULL
UNION
SELECT id, name, description, domain, 'Guest (Viewer)' as role
FROM apps
WHERE domain IS NOT NULL AND name = ANY(${appNames}::text[])
ORDER BY name ASC
`;
} else {
return await sqlWrapper.sql`
SELECT a.id, a.name, a.description, a.domain, g.role
FROM apps a
JOIN grants g ON a.id = g.app_id
WHERE g.user_id = ${userId} AND a.domain IS NOT NULL
ORDER BY a.name ASC
`;
}
}
};
export const getAdminRoles = async () => {
return await sqlWrapper.sql`
SELECT r.id, r.name, r.description, r.app_id, r.created_at,
a.name AS app_name
FROM roles r
LEFT JOIN apps a ON r.app_id = a.id
ORDER BY r.app_id NULLS FIRST, r.name ASC
`;
};
export const createRole = async (
name: string,
description: string,
appId: string | null,
) => {
return await sqlWrapper.sql`
INSERT INTO roles (name, description, app_id)
VALUES (${name}, ${description}, ${appId || null})
RETURNING *
`.then((res: any) => res[0]);
};
export const updateRole = async (
id: string,
name: string,
description: string,
appId: string | null,
) => {
return await sqlWrapper.sql`
UPDATE roles
SET name = ${name}, description = ${description}, app_id = ${appId || null}
WHERE id = ${id}
RETURNING *
`.then((res: any) => res[0]);
};
export const deleteRole = async (id: string) => {
return await sqlWrapper.sql`
DELETE FROM roles WHERE id = ${id} RETURNING id
`.then((res: any) => res[0]);
};
export const getAdminInvites = async () => {
return await sqlWrapper.sql`
SELECT i.id, i.code, i.role, i.max_uses, i.uses_count, i.auto_activate, i.expires_at, i.created_at, i.used_at,
a.name AS app_name, a.id AS app_id,
u.username AS used_by_username
FROM invites i
LEFT JOIN apps a ON i.app_id = a.id
LEFT JOIN users u ON i.used_by = u.id
ORDER BY i.created_at DESC
`;
};
export const createInvite = async (
code: string,
appId: string | null,
role: string,
maxUses: number | null,
expiresAt: Date,
autoActivate: boolean,
) => {
return await sqlWrapper.sql`
INSERT INTO invites (code, app_id, role, max_uses, expires_at, auto_activate)
VALUES (${code}, ${
appId || null
}, ${role}, ${maxUses}, ${expiresAt.toISOString()}, ${autoActivate})
RETURNING *
`.then((res: any) => res[0]);
};
export const revokeInvite = async (id: string) => {
return await sqlWrapper.sql`
DELETE FROM invites WHERE id = ${id} RETURNING id
`.then((res: any) => res[0]);
};
export const getAaguidAllowlist = async () => {
return await sqlWrapper.sql`
SELECT id, aaguid, description, created_at
FROM aaguid_allowlist
ORDER BY created_at DESC
`;
};
export const addAaguid = async (aaguid: string, description: string) => {
return await sqlWrapper.sql`
INSERT INTO aaguid_allowlist (aaguid, description)
VALUES (${aaguid}, ${description})
ON CONFLICT (aaguid) DO NOTHING
RETURNING *
`.then((res: any) => res[0]);
};
export const removeAaguid = async (id: string) => {
return await sqlWrapper.sql`
DELETE FROM aaguid_allowlist WHERE id = ${id} RETURNING id
`.then((res: any) => res[0]);
};

View File

@ -0,0 +1,200 @@
import { AdminLayoutFragment } from "../../shared/ui/fragments.tsx";
export const AdminRolesPageFragment = ({
roles = [],
apps = [],
}: {
roles?: any[];
apps?: any[];
}) => {
return (
<AdminLayoutFragment
title="Role & Permission Catalog"
currentPath="/admin/roles"
>
<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);">
Role & Permission Catalog
</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-primary"
style="min-height: 40px;"
onclick="openCreateRoleDrawer()"
>
+ Create Custom Role
</button>
</div>
{/* Role Creation / Editing Drawer */}
<div
id="roleDrawer"
class="card"
style="display: none; border-left: 4px solid var(--primary); margin-bottom: 1.5rem;"
>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3
id="roleDrawerTitle"
style="margin: 0; color: var(--text-primary);"
>
Create Custom Role
</h3>
<button
type="button"
onclick="closeRoleDrawer()"
style="background: none; border: none; font-size: 1.25rem; color: var(--text-muted); cursor: pointer;"
>
&times;
</button>
</div>
<form id="roleForm" onsubmit="handleSaveRole(event)">
<input type="hidden" id="roleEditId" value="" />
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; margin-bottom: 1rem;">
<div>
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
Role Name *
</label>
<input
type="text"
id="roleNameInput"
placeholder="e.g. editor, auditor, devops"
required
style="width: 100%;"
/>
</div>
<div>
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
Application Scope
</label>
<select
id="roleAppSelect"
style="width: 100%; padding: 0.5rem 0.75rem; background: var(--surface-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); color: var(--text-primary);"
>
<option value="">Global (Shared across all apps)</option>
{apps.map((app: any) => (
<option value={app.id} key={app.id}>
{app.name} ({app.domain || "Internal"})
</option>
))}
</select>
</div>
</div>
<div style="margin-bottom: 1.25rem;">
<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="roleDescInput"
placeholder="e.g. Read-write access to staging resources"
style="width: 100%;"
/>
</div>
<div style="display: flex; gap: 0.75rem;">
<button type="submit" class="btn-primary" style="min-height: 38px;">
Save Role
</button>
<button
type="button"
class="btn-outline"
onclick="closeRoleDrawer()"
style="min-height: 38px;"
>
Cancel
</button>
</div>
</form>
</div>
<div class="card">
<div class="table-container">
<table id="rolesTable">
<thead>
<tr>
<th>Role Identifier</th>
<th>Scope</th>
<th>Description</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{roles.length === 0
? (
<tr>
<td
colSpan={5}
style="text-align: center; color: var(--text-muted); padding: 2rem;"
>
No roles found.
</td>
</tr>
)
: (
roles.map((r: any) => {
const isGlobal = !r.app_id;
const isCoreAdmin = isGlobal && r.name === "admin";
return (
<tr key={r.id}>
<td>
<strong style="font-family: monospace; font-size: 0.95rem; color: var(--text-primary);">
{r.name}
</strong>
</td>
<td>
{isGlobal
? <span class="badge badge-info">Global</span>
: (
<span class="badge badge-warning">
{r.app_name || "App-Specific"}
</span>
)}
</td>
<td style="color: var(--text-secondary); font-size: 0.85rem;">
{r.description || "-"}
</td>
<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-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: var(--text-muted); font-size: 0.8rem; font-style: italic;">
System Core
</span>
)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
<script src="/public/admin-scripts.js"></script>
</AdminLayoutFragment>
);
};

View File

@ -1,43 +1,39 @@
import { Hono } from "jsr:@hono/hono@4";
import { encodeBase64Url } from "jsr:@std/encoding@1/base64url";
import { adminRateLimiter, getClientIp } from "../../core/middleware.ts";
import { adminRateLimiter } from "../../core/middleware.ts";
import { getAuthenticatedUser, requireAdmin } from "../../core/session.ts";
import { valkey } from "../../core/valkey.ts";
import { auditWrapper } from "../../core/audit.ts";
import {
assignUserGrant,
createRecoveryLink,
getAaguidAllowlist,
getAdminApps,
getAdminInvites,
getAdminRoles,
getAllApps,
getAllRoles,
getAllUsers,
getAppById,
getAuditLogs,
getUserById,
getUserGrants,
getUserPasskeys,
getUserSessions,
removeUserGrant,
revokeAllUserSessions,
revokeSessionById,
revokeUserPasskey,
updateUserProfile,
updateUserStatus,
} from "./queries.ts";
import {
AdminAppsPageFragment,
AdminUserDetailsPageFragment,
AdminUsersPageFragment,
AuditLogPageFragment,
} from "./fragments.tsx";
import { AdminRolesPageFragment } from "./roles_fragments.tsx";
import { AdminInvitesPageFragment } from "./invites_fragments.tsx";
import { AAGUIDPageFragment } from "./aaguid_fragments.tsx";
import { adminActionsRoutes } from "./admin_actions_routes.ts";
export const adminRoutes = new Hono();
adminRoutes.use("*", requireAdmin);
adminRoutes.use("*", adminRateLimiter);
// Mount Actions sub-router (mutations)
adminRoutes.route("/", adminActionsRoutes);
// --- HTML Pages & UI Routes ---
adminRoutes.get("/", (c) => c.redirect("/admin/users"));
@ -80,10 +76,43 @@ adminRoutes.get("/apps", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const apps = await getAllApps();
const apps = await getAdminApps();
return c.html(<AdminAppsPageFragment apps={apps} />);
});
adminRoutes.get("/roles", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const roles = await getAdminRoles();
const apps = await getAllApps();
return c.html(<AdminRolesPageFragment roles={roles} apps={apps} />);
});
adminRoutes.get("/invites", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const invites = await getAdminInvites();
const apps = await getAllApps();
const allRoles = await getAllRoles();
return c.html(
<AdminInvitesPageFragment
invites={invites}
apps={apps}
allRoles={allRoles}
/>,
);
});
adminRoutes.get("/aaguid", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const allowlist = await getAaguidAllowlist();
return c.html(<AAGUIDPageFragment allowlist={allowlist} />);
});
adminRoutes.get("/audit-logs", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
@ -91,202 +120,3 @@ adminRoutes.get("/audit-logs", async (c) => {
const logs = await getAuditLogs(100);
return c.html(<AuditLogPageFragment logs={logs} />);
});
// --- JSON API Endpoints (mounted under /api/admin and /admin) ---
adminRoutes.post("/users/:id/status", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const targetUserId = c.req.param("id");
const { status } = await c.req.json();
if (!["active", "pending", "suspended"].includes(status)) {
return c.json({ error: "Invalid status" }, 400);
}
const targetUser = await updateUserStatus(targetUserId, status);
if (!targetUser) return c.json({ error: "User not found" }, 404);
auditWrapper.auditLog(
auth.userId,
"user_status_changed",
targetUserId,
{ newStatus: status },
getClientIp(c),
);
return c.json({ success: true });
});
adminRoutes.post("/users/:id/profile", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const targetUserId = c.req.param("id");
const { displayName } = await c.req.json();
const targetUser = await updateUserProfile(
targetUserId,
displayName?.trim() || null,
);
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 });
});
adminRoutes.get("/users/:id/grants", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const targetUserId = c.req.param("id");
const grants = await getUserGrants(targetUserId);
return c.json({ grants });
});
adminRoutes.post("/users/:id/grants", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const targetUserId = c.req.param("id");
const { appId, role } = await c.req.json();
if (!appId || !role) {
return c.json({ error: "appId and role are required" }, 400);
}
const app = await getAppById(appId);
if (!app) return c.json({ error: "Application not found" }, 404);
const targetUser = await getUserById(targetUserId);
if (!targetUser) return c.json({ error: "User not found" }, 404);
await assignUserGrant(targetUserId, appId, role);
auditWrapper.auditLog(
auth.userId,
"user_grant_assigned",
targetUserId,
{ app_id: appId, app_name: app.name, role },
getClientIp(c),
);
return c.json({ success: true });
});
adminRoutes.delete("/users/:id/grants/:appId", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const { id: targetUserId, appId } = c.req.param();
const grant = await removeUserGrant(targetUserId, appId);
if (grant) {
auditWrapper.auditLog(
auth.userId,
"user_grant_revoked",
targetUserId,
{ app_id: appId },
getClientIp(c),
);
return c.json({ success: true });
}
return c.json({ error: "Grant not found" }, 404);
});
adminRoutes.delete("/users/:id/sessions", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const targetUserId = c.req.param("id");
const sessions = await getUserSessions(targetUserId);
await revokeAllUserSessions(targetUserId);
for (const session of sessions) {
try {
await valkey.del(session.id);
} catch (_err) {}
}
auditWrapper.auditLog(
auth.userId,
"admin_all_sessions_revoked",
targetUserId,
null,
getClientIp(c),
);
return c.json({ success: true });
});
adminRoutes.delete("/users/:userId/passkeys/:passkeyId", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const { userId, passkeyId } = c.req.param();
const passkey = await revokeUserPasskey(userId, passkeyId);
if (passkey) {
auditWrapper.auditLog(
auth.userId,
"admin_passkey_revoked",
userId,
{ passkey_id: passkey.id },
getClientIp(c),
);
return c.json({ success: true });
}
return c.json({ error: "Passkey not found" }, 404);
});
adminRoutes.post("/users/:id/recovery", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const targetUserId = c.req.param("id");
const targetUser = await getUserById(targetUserId);
if (!targetUser) return c.json({ error: "User not found" }, 404);
const recoveryCode = encodeBase64Url(
crypto.getRandomValues(new Uint8Array(24)),
);
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 1);
await createRecoveryLink(recoveryCode, targetUserId, auth.userId, expiresAt);
auditWrapper.auditLog(
auth.userId,
"recovery_link_created",
targetUserId,
null,
getClientIp(c),
);
return c.json({ success: true, recoveryCode, expiresAt });
});
adminRoutes.delete("/sessions/:id", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const sessionId = c.req.param("id");
await revokeSessionById(sessionId);
try {
await valkey.del(sessionId);
} catch (_err) {}
auditWrapper.auditLog(
auth.userId,
"admin_session_revoked",
sessionId,
null,
getClientIp(c),
);
return c.json({ success: true });
});

View File

@ -0,0 +1,136 @@
import { AuthenticatedLayoutFragment } from "../../shared/ui/layout_fragments.tsx";
export interface AppCard {
id: string;
name: string;
description: string;
domain: string;
role: string;
}
export const AppLaunchpadPageFragment = ({
apps,
isAdmin = false,
}: {
apps: AppCard[];
isAdmin?: boolean;
}) => {
return (
<AuthenticatedLayoutFragment
title="Launchpad"
currentPath="/dashboard"
isAdmin={isAdmin}
>
<div style="margin-bottom: 2rem;">
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
Application Launchpad
</h1>
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
Single sign-on authorized workloads and microservices.
</p>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1.25rem;">
{apps.length === 0
? (
<div
class="card"
style="grid-column: 1 / -1; text-align: center; padding: 3rem 1.5rem;"
>
<div style="display: inline-flex; width: 48px; height: 48px; background: var(--surface-muted); border-radius: var(--radius-full); align-items: center; justify-content: center; margin-bottom: 1rem; color: var(--text-muted);">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
</div>
<h3 style="margin: 0 0 0.5rem 0; color: var(--text-primary);">
No Authorized Applications
</h3>
<p style="margin: 0; color: var(--text-muted); font-size: 0.9rem;">
You do not have active role grants for any workloads.
</p>
</div>
)
: (
apps.map((app) => {
const isAdminRole = app.role === "Admin" ||
app.role === "Global Admin";
const targetUrl = app.domain.startsWith("http")
? app.domain
: `https://${app.domain}`;
return (
<div
class="card"
key={app.id}
style="display: flex; flex-direction: column; justify-content: space-between; margin-bottom: 0; transition: transform 0.15s ease, box-shadow 0.15s ease;"
>
<div>
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.75rem; gap: 0.5rem;">
<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;">
{app.name.charAt(0).toUpperCase()}
</div>
<div>
<h3 style="margin: 0; font-size: 1.1rem; color: var(--text-primary);">
{app.name}
</h3>
<span style="font-size: 0.8rem; color: var(--text-muted); font-family: monospace;">
{app.domain}
</span>
</div>
</div>
<span
class={`badge ${
isAdminRole ? "badge-success" : "badge-info"
}`}
>
{app.role}
</span>
</div>
<p style="color: var(--text-secondary); font-size: 0.875rem; line-height: 1.5; margin: 0 0 1.25rem 0; flex: 1;">
{app.description ||
"Zero-trust secured internal application."}
</p>
</div>
<a
href={targetUrl}
class="btn-primary"
style="width: 100%; min-height: 46px; text-decoration: none; justify-content: center; display: inline-flex; align-items: center; gap: 0.5rem;"
>
<span>Launch App</span>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="7" y1="17" x2="17" y2="7"></line>
<polyline points="7 7 17 7 17 17"></polyline>
</svg>
</a>
</div>
);
})
)}
</div>
</AuthenticatedLayoutFragment>
);
};

View File

@ -178,3 +178,15 @@ export async function getEventBySlug(slug: string) {
if (!result || result.length === 0) return null;
return result[0];
}
export async function getUserEventPasses(userId: string) {
const result = await sqlWrapper.sql`
SELECT ep.*, a.name as app_name, a.domain as app_domain
FROM event_passes ep
LEFT JOIN apps a ON ep.app_id = a.id
WHERE ep.created_by = ${userId}
AND ep.is_active = TRUE
ORDER BY ep.created_at DESC
`;
return result;
}

View File

@ -2,19 +2,16 @@ import { Hono } from "jsr:@hono/hono@4";
import { streamDatastar } from "../../core/sse_adapter.ts";
import { renderErrorToastFragment } from "../../core/error_fragments.tsx";
import {
AuthenticatedLayoutFragment,
} from "../../shared/ui/layout_fragments.tsx";
import { getAuthenticatedUser, hasScope } from "../../core/session.ts";
import { getAllApps, getUserPasskeys } from "../admin/queries.ts";
import { getActiveSessions } from "./queries.ts";
import {
DirectPassDrawerFragment,
ScopeModalFragment,
SessionDeckFragment,
SessionTableFragment,
} from "./fragments.tsx";
getAllApps,
getDashboardApps,
getUserPasskeys,
} from "../admin/queries.ts";
import { getUserEventPasses } from "../events/queries.ts";
import { getActiveSessions } from "./queries.ts";
import { AppLaunchpadPageFragment } from "../dashboard/launchpad_fragments.tsx";
import { SessionsPageFragment } from "./sessions_fragments.tsx";
import { PasskeysPageFragment } from "./passkeys_fragments.tsx";
import { sessionActionsRoutes } from "./actions_routes.ts";
@ -23,6 +20,48 @@ export const sessionRoutes = new Hono();
// Mount Actions sub-router
sessionRoutes.route("/", sessionActionsRoutes);
// ---------------------------------------------------------
// UI Dashboard / Launchpad Route
// ---------------------------------------------------------
sessionRoutes.get("/dashboard", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) {
return c.redirect("/login");
}
const isAdmin = hasScope(auth, "admin") || hasScope(auth, "*");
const apps = await getDashboardApps(auth.userId, isAdmin, auth.customScopes);
return c.html(
<AppLaunchpadPageFragment apps={apps as any} isAdmin={isAdmin} />,
);
});
// ---------------------------------------------------------
// UI Dashboard / Sessions Route
// ---------------------------------------------------------
sessionRoutes.get("/dashboard/sessions", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) {
return c.redirect("/login");
}
const sessions = await getActiveSessions(auth.userId);
const apps = await getAllApps();
const eventPasses = await getUserEventPasses(auth.userId);
const isAdmin = hasScope(auth, "admin") || hasScope(auth, "*");
return c.html(
<SessionsPageFragment
sessions={sessions}
currentSessionId={auth.sessionId}
apps={apps}
isAdmin={isAdmin}
eventPasses={eventPasses as any}
/>,
);
});
// ---------------------------------------------------------
// UI Dashboard / Passkeys Route
// ---------------------------------------------------------
@ -40,113 +79,6 @@ sessionRoutes.get("/dashboard/passkeys", async (c) => {
);
});
// ---------------------------------------------------------
// UI Dashboard / Sessions Route
// ---------------------------------------------------------
sessionRoutes.get("/dashboard/sessions", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) {
return c.redirect("/login");
}
const sessions = await getActiveSessions(auth.userId);
const apps = await getAllApps();
const isAdmin = hasScope(auth, "admin") || hasScope(auth, "*");
return c.html(
<AuthenticatedLayoutFragment
title="Active Sessions"
currentPath="/dashboard/sessions"
isAdmin={isAdmin}
>
<div style="display: flex; justify-content: space-between; align-items: center; 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);">
Active Sessions & Delegation
</h1>
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
Manage authorized devices, mint scoped agent tokens, and oversee
live connections.
</p>
</div>
<div style="display: flex; gap: 0.75rem;">
<button
type="button"
class="btn-primary"
onclick="openDelegateDrawer()"
style="min-height: 42px; font-size: 0.9rem;"
>
🔑 Mint Scoped Pass
</button>
</div>
</div>
<div id="status-banner"></div>
{/* Desktop Table */}
<SessionTableFragment
sessions={sessions}
currentSessionId={auth.sessionId}
/>
{/* Mobile Deck */}
<SessionDeckFragment
sessions={sessions}
currentSessionId={auth.sessionId}
/>
{/* Delegation Drawer */}
<div
id="delegateDrawer"
class="drawer-overlay"
style="display: none; position: fixed; inset: 0; z-index: 1040; background: rgba(0,0,0,0.5); opacity: 0; transition: opacity 0.2s ease;"
>
<div
class="drawer-panel"
style="position: fixed; background: var(--surface-card); box-shadow: var(--shadow-lg); z-index: 1050; display: flex; flex-direction: column; transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1);"
>
<div style="padding: 1.5rem; border-bottom: 1px solid var(--border-subtle); display: flex; justify-content: space-between; align-items: center;">
<h2 style="margin: 0; font-size: 1.25rem; color: var(--text-primary);">
Mint Delegated Session Pass
</h2>
<button
type="button"
onclick="closeDelegateDrawer()"
style="background: none; border: none; font-size: 1.5rem; color: var(--text-muted); cursor: pointer; line-height: 1;"
>
&times;
</button>
</div>
<div style="flex: 1; overflow-y: auto; padding: 1.5rem;">
<DirectPassDrawerFragment apps={apps} isAdmin={isAdmin} />
</div>
</div>
</div>
{/* Scope Modal */}
<ScopeModalFragment apps={apps} />
<style>
{`
@media (min-width: 768px) {
.desktop-only { display: block !important; }
.mobile-only { display: none !important; }
.drawer-panel { top: 0; right: 0; width: 440px; height: 100vh; transform: translateX(100%); }
}
@media (max-width: 767px) {
.desktop-only { display: none !important; }
.mobile-only { display: flex !important; }
.drawer-panel { bottom: 0; left: 0; width: 100vw; height: 85vh; border-radius: 16px 16px 0 0; transform: translateY(100%); }
}
.drawer-panel.open { transform: translate(0, 0) !important; }
.drawer-overlay.open { display: block !important; opacity: 1 !important; }
`}
</style>
<script src="/public/sessions-scripts.js"></script>
</AuthenticatedLayoutFragment>,
);
});
// ---------------------------------------------------------
// Live Telemetry / Revocation SSE Stream
// ---------------------------------------------------------

View File

@ -7,6 +7,13 @@ import {
SessionTableFragment,
} from "./fragments.tsx";
Deno.test("[Sessions] GET /dashboard unauthenticated redirects to /login", async () => {
const req = new Request("http://localhost/dashboard");
const res = await sessionRoutes.fetch(req);
assertEquals(res.status, 302);
assertEquals(res.headers.get("location"), "/login");
});
Deno.test("[Sessions] GET /dashboard/sessions unauthenticated redirects to /login", async () => {
const req = new Request("http://localhost/dashboard/sessions");
const res = await sessionRoutes.fetch(req);

View File

@ -0,0 +1,219 @@
import { AuthenticatedLayoutFragment } from "../../shared/ui/layout_fragments.tsx";
import {
DirectPassDrawerFragment,
ScopeModalFragment,
SessionDeckFragment,
SessionTableFragment,
} from "./fragments.tsx";
import {
EventCockpitDeckFragment,
GuestDrawerAttendeesFragment,
WorkshopPassDrawerFragment,
} from "../events/fragments.tsx";
export const SessionsPageFragment = ({
sessions,
currentSessionId,
apps = [],
isAdmin = false,
eventPasses = [],
}: {
sessions: any[];
currentSessionId: string;
apps?: any[];
isAdmin?: boolean;
eventPasses?: any[];
}) => {
return (
<AuthenticatedLayoutFragment
title="Sessions & Events"
currentPath="/dashboard/sessions"
isAdmin={isAdmin}
>
<div
id="status-banner"
role="status"
aria-live="polite"
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: var(--radius-md); font-size: 0.9rem;"
/>
{/* Top Action Bar */}
<div style="margin-bottom: 1.5rem; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem; border-bottom: 1px solid var(--border-subtle); padding-bottom: 1rem;">
<div>
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.25rem 0; color: var(--text-primary);">
Sessions & Events
</h1>
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
Manage logins, mint 1:1 delegated tokens, or launch multi-claim
workshop events.
</p>
</div>
<div style="display: flex; gap: 0.75rem; flex-wrap: wrap;">
<button
type="button"
class="btn-primary"
style="min-height: 42px; box-shadow: var(--shadow-sm); display: inline-flex; align-items: center; justify-content: center;"
onclick="openDelegateDrawer()"
>
Delegate Session
</button>
</div>
</div>
{/* Event Passes Cockpit Deck */}
<EventCockpitDeckFragment eventPasses={eventPasses} />
{/* 2-Tab Delegate Session Drawer */}
<div
id="delegateDrawer"
class="card"
style="display: none; border-left: 4px solid var(--primary); margin-bottom: 1.5rem;"
>
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem;">
<div style="width: 100%;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h2
id="delegateDrawerTitle"
style="margin: 0 0 0.25rem 0; color: var(--text-primary); font-size: 1.25rem;"
>
Delegate Session
</h2>
<button
type="button"
onclick="closeDelegateDrawer()"
style="background: none; border: none; font-size: 1.3rem; color: var(--text-muted); cursor: pointer;"
>
&times;
</button>
</div>
<p style="color: var(--text-secondary); margin: 0; font-size: 0.9rem;">
Mint a 1:1 delegated token or launch a multi-seat workshop event.
</p>
<div
role="tablist"
style="display: flex; background: var(--surface-muted); padding: 4px; border-radius: var(--radius-sm); border: 1px solid var(--border-subtle); gap: 4px; margin-top: 1rem; margin-bottom: 1rem;"
>
<button
type="button"
role="tab"
aria-selected="true"
id="tabBtnDirectPass"
class="delegate-tab-btn active"
onclick="switchDelegateTab('tabDirectPass')"
style="display: flex; flex-direction: column; align-items: center; gap: 2px;"
>
<span style="font-size: 0.95rem; font-weight: 700;">
Single Session
</span>
<span style="font-size: 0.75rem; font-weight: 400; opacity: 0.85;">
For agents, CI/CD, or 1:1 delegation
</span>
</button>
<button
type="button"
role="tab"
aria-selected="false"
id="tabBtnWorkshopPass"
class="delegate-tab-btn"
onclick="switchDelegateTab('tabWorkshopPass')"
style="display: flex; flex-direction: column; align-items: center; gap: 2px;"
>
<span style="font-size: 0.95rem; font-weight: 700;">
Multi-Claim Event
</span>
<span style="font-size: 0.75rem; font-weight: 400; opacity: 0.85;">
For workshops, teams & guest pools
</span>
</button>
</div>
</div>
</div>
<DirectPassDrawerFragment apps={apps} isAdmin={isAdmin} />
<WorkshopPassDrawerFragment apps={apps} />
</div>
{/* Slide-over Guest Attendees Drawer */}
<GuestDrawerAttendeesFragment />
{/* Granular Scope Matrix Modal */}
<ScopeModalFragment apps={apps} />
{/* Sessions Table / Deck Section */}
<h2 style="font-size: 1.25rem; margin-top: 2rem; margin-bottom: 0.25rem; color: var(--text-primary);">
Sessions
</h2>
<p style="color: var(--text-secondary); margin-bottom: 1rem; font-size: 0.95rem;">
Direct device logins, passkey authentications, and delegated agent
tokens.
</p>
{/* Desktop Table View */}
<SessionTableFragment
sessions={sessions}
currentSessionId={currentSessionId}
/>
{/* Mobile Adaptive Cards View */}
<SessionDeckFragment
sessions={sessions}
currentSessionId={currentSessionId}
/>
<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; }
}
.pill-btn {
background: var(--surface-card);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-full);
padding: 0.4rem 0.85rem;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s ease;
}
.pill-btn:hover {
border-color: var(--primary);
color: var(--primary);
}
.pill-btn.active {
background: var(--primary-light);
border-color: var(--primary);
color: var(--primary);
}
.delegate-tab-btn {
flex: 1;
padding: 0.5rem 0.75rem;
border-radius: var(--radius-sm);
border: none;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
background: transparent;
color: var(--text-secondary);
opacity: 0.75;
}
.delegate-tab-btn.active {
background: var(--primary);
color: #ffffff;
box-shadow: var(--shadow-xs);
opacity: 1;
}
`}
</style>
<script src="/public/sessions-scripts.js"></script>
</AuthenticatedLayoutFragment>
);
};

View File

@ -37,10 +37,6 @@ app.get("/", (c) => {
return c.redirect("/login");
});
app.get("/dashboard", (c) => {
return c.redirect("/dashboard/sessions");
});
app.get("/logout", async (c) => {
const sessionIds = extractAllSessionIds(c);
const rawRedirect = c.req.query("redirect");

View File

@ -17,12 +17,12 @@ Deno.test("[Smoke Test] Core Server Routing & Hypermedia Endpoints", async () =>
assertEquals(resRoot.status, 302);
assertEquals(resRoot.headers.get("location"), "/login");
// 3. /dashboard redirects to /dashboard/sessions
// 3. Unauthenticated /dashboard redirects to /login
const resDashRoot = await app.fetch(
new Request("http://localhost/dashboard"),
);
assertEquals(resDashRoot.status, 302);
assertEquals(resDashRoot.headers.get("location"), "/dashboard/sessions");
assertEquals(resDashRoot.headers.get("location"), "/login");
// 4. /logout redirects to /login
const resLogout = await app.fetch(new Request("http://localhost/logout"));
@ -103,11 +103,19 @@ Deno.test("[Smoke Test] Core Server Routing & Hypermedia Endpoints", async () =>
);
assertEquals(resForwardAuth.status, 400);
// 12. Admin route unauthenticated redirects or denies
const resAdmin = await app.fetch(
new Request("http://localhost/admin/users"),
);
assertEquals(resAdmin.status === 302 || resAdmin.status === 401, true);
// 12. Admin routes unauthenticated redirect or deny
for (
const path of [
"/admin/users",
"/admin/apps",
"/admin/roles",
"/admin/invites",
"/admin/aaguid",
]
) {
const resAdmin = await app.fetch(new Request(`http://localhost${path}`));
assertEquals(resAdmin.status === 302 || resAdmin.status === 401, true);
}
} finally {
auditWrapper.auditLog = origAudit;
}