From b2a2bdab07521c5d36b43e23b778b9a3606e5b3d Mon Sep 17 00:00:00 2001 From: Tyler Gillispie Date: Thu, 27 Aug 2026 21:47:50 -0700 Subject: [PATCH] fix(ui): restore launchpad routing, admin catalog pages, and ui-audit sessions workflow --- src/features/admin/aaguid_fragments.tsx | 123 ++++++++ src/features/admin/admin.test.ts | 31 ++ src/features/admin/admin_actions_routes.ts | 282 ++++++++++++++++++ src/features/admin/invites_fragments.tsx | 190 ++++++++++++ src/features/admin/queries.ts | 143 +++++++++ src/features/admin/roles_fragments.tsx | 200 +++++++++++++ src/features/admin/routes.tsx | 262 +++------------- .../dashboard/launchpad_fragments.tsx | 136 +++++++++ src/features/events/queries.ts | 12 + src/features/sessions/routes.tsx | 168 ++++------- src/features/sessions/sessions.test.tsx | 7 + src/features/sessions/sessions_fragments.tsx | 219 ++++++++++++++ src/main.ts | 4 - src/tests/smoke_test.ts | 22 +- 14 files changed, 1454 insertions(+), 345 deletions(-) create mode 100644 src/features/admin/aaguid_fragments.tsx create mode 100644 src/features/admin/admin_actions_routes.ts create mode 100644 src/features/admin/invites_fragments.tsx create mode 100644 src/features/admin/roles_fragments.tsx create mode 100644 src/features/dashboard/launchpad_fragments.tsx create mode 100644 src/features/sessions/sessions_fragments.tsx diff --git a/src/features/admin/aaguid_fragments.tsx b/src/features/admin/aaguid_fragments.tsx new file mode 100644 index 0000000..3ba7b51 --- /dev/null +++ b/src/features/admin/aaguid_fragments.tsx @@ -0,0 +1,123 @@ +import { AdminLayoutFragment } from "../../shared/ui/fragments.tsx"; + +export const AAGUIDPageFragment = ({ + allowlist = [], +}: { + allowlist?: any[]; +}) => { + return ( + +
+

+ AAGUID Hardware Allow-List +

+

+ Manage the enterprise allow-list of approved hardware Authenticator + Attestation GUIDs (AAGUIDs). +

+
+ +
+

+ Add Hardware Key Model +

+
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+
+ + + + + + + + + + + {allowlist.length === 0 + ? ( + + + + ) + : ( + allowlist.map((item: any) => ( + + + + + + + )) + )} + +
AAGUIDDescriptionAdded OnActions
+ The allow-list is empty. All certified hardware passkeys + are accepted. +
+ + {item.aaguid} + + {item.description || "-"} + {new Date(item.created_at).toLocaleDateString()} + + +
+
+
+ +
+ ); +}; diff --git a/src/features/admin/admin.test.ts b/src/features/admin/admin.test.ts index 7147031..fe4480f 100644 --- a/src/features/admin/admin.test.ts +++ b/src/features/admin/admin.test.ts @@ -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(); }); diff --git a/src/features/admin/admin_actions_routes.ts b/src/features/admin/admin_actions_routes.ts new file mode 100644 index 0000000..26353da --- /dev/null +++ b/src/features/admin/admin_actions_routes.ts @@ -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 }); +}); diff --git a/src/features/admin/invites_fragments.tsx b/src/features/admin/invites_fragments.tsx new file mode 100644 index 0000000..c01dcb7 --- /dev/null +++ b/src/features/admin/invites_fragments.tsx @@ -0,0 +1,190 @@ +import { AdminLayoutFragment } from "../../shared/ui/fragments.tsx"; + +export const AdminInvitesPageFragment = ({ + invites = [], + apps = [], + allRoles = [], +}: { + invites?: any[]; + apps?: any[]; + allRoles?: any[]; +}) => { + return ( + +
+
+

+ Invite & Onboarding Tokens +

+

+ Issue single-use, team limited-use, or campaign registration tokens. +

+
+ + +
+ + {/* Invite Generation Drawer */} + + +
+
+ + + + + + + + + + + + + {invites.length === 0 + ? ( + + + + ) + : ( + invites.map((inv: any) => ( + + + + + + + + + )) + )} + +
Invite CodeTarget App / ScopeRoleUsageExpiresActions
+ No active invite tokens found. +
+ + {inv.code} + + + {inv.app_name + ? ( + + {inv.app_name} + + ) + : Global} + + {inv.role} + + {inv.uses_count || 0} / {inv.max_uses ?? "∞"} + + {new Date(inv.expires_at).toLocaleDateString()} + + +
+
+
+ +
+ ); +}; diff --git a/src/features/admin/queries.ts b/src/features/admin/queries.ts index 19267ab..67dedd9 100644 --- a/src/features/admin/queries.ts +++ b/src/features/admin/queries.ts @@ -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]); +}; diff --git a/src/features/admin/roles_fragments.tsx b/src/features/admin/roles_fragments.tsx new file mode 100644 index 0000000..e188df1 --- /dev/null +++ b/src/features/admin/roles_fragments.tsx @@ -0,0 +1,200 @@ +import { AdminLayoutFragment } from "../../shared/ui/fragments.tsx"; + +export const AdminRolesPageFragment = ({ + roles = [], + apps = [], +}: { + roles?: any[]; + apps?: any[]; +}) => { + return ( + +
+
+

+ Role & Permission Catalog +

+

+ Manage global and application-scoped RBAC roles and permissions. +

+
+ + +
+ + {/* Role Creation / Editing Drawer */} + + +
+
+ + + + + + + + + + + + {roles.length === 0 + ? ( + + + + ) + : ( + roles.map((r: any) => { + const isGlobal = !r.app_id; + const isCoreAdmin = isGlobal && r.name === "admin"; + + return ( + + + + + + + + ); + }) + )} + +
Role IdentifierScopeDescriptionCreatedActions
+ No roles found. +
+ + {r.name} + + + {isGlobal + ? Global + : ( + + {r.app_name || "App-Specific"} + + )} + + {r.description || "-"} + + {new Date(r.created_at).toLocaleDateString()} + + {!isCoreAdmin + ? ( +
+ +
+ ) + : ( + + System Core + + )} +
+
+
+ +
+ ); +}; diff --git a/src/features/admin/routes.tsx b/src/features/admin/routes.tsx index 9cb18ae..5dc3532 100644 --- a/src/features/admin/routes.tsx +++ b/src/features/admin/routes.tsx @@ -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(); }); +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(); +}); + +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( + , + ); +}); + +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(); +}); + 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(); }); - -// --- 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 }); -}); diff --git a/src/features/dashboard/launchpad_fragments.tsx b/src/features/dashboard/launchpad_fragments.tsx new file mode 100644 index 0000000..ad01431 --- /dev/null +++ b/src/features/dashboard/launchpad_fragments.tsx @@ -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 ( + +
+

+ Application Launchpad +

+

+ Single sign-on authorized workloads and microservices. +

+
+ +
+ {apps.length === 0 + ? ( +
+
+ + + + + +
+

+ No Authorized Applications +

+

+ You do not have active role grants for any workloads. +

+
+ ) + : ( + apps.map((app) => { + const isAdminRole = app.role === "Admin" || + app.role === "Global Admin"; + const targetUrl = app.domain.startsWith("http") + ? app.domain + : `https://${app.domain}`; + + return ( +
+
+
+
+
+ {app.name.charAt(0).toUpperCase()} +
+
+

+ {app.name} +

+ + {app.domain} + +
+
+ + + {app.role} + +
+ +

+ {app.description || + "Zero-trust secured internal application."} +

+
+ + + Launch App + + + + + +
+ ); + }) + )} +
+
+ ); +}; diff --git a/src/features/events/queries.ts b/src/features/events/queries.ts index 022e6f6..9d9b093 100644 --- a/src/features/events/queries.ts +++ b/src/features/events/queries.ts @@ -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; +} diff --git a/src/features/sessions/routes.tsx b/src/features/sessions/routes.tsx index 50b5332..351a75f 100644 --- a/src/features/sessions/routes.tsx +++ b/src/features/sessions/routes.tsx @@ -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( + , + ); +}); + +// --------------------------------------------------------- +// 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( + , + ); +}); + // --------------------------------------------------------- // 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( - -
-
-

- Active Sessions & Delegation -

-

- Manage authorized devices, mint scoped agent tokens, and oversee - live connections. -

-
-
- -
-
- -
- - {/* Desktop Table */} - - - {/* Mobile Deck */} - - - {/* Delegation Drawer */} - - - {/* Scope Modal */} - - - - -
, - ); -}); - // --------------------------------------------------------- // Live Telemetry / Revocation SSE Stream // --------------------------------------------------------- diff --git a/src/features/sessions/sessions.test.tsx b/src/features/sessions/sessions.test.tsx index 08dbefe..f416988 100644 --- a/src/features/sessions/sessions.test.tsx +++ b/src/features/sessions/sessions.test.tsx @@ -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); diff --git a/src/features/sessions/sessions_fragments.tsx b/src/features/sessions/sessions_fragments.tsx new file mode 100644 index 0000000..e31296e --- /dev/null +++ b/src/features/sessions/sessions_fragments.tsx @@ -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 ( + +