fix(ui): restore launchpad routing, admin catalog pages, and ui-audit sessions workflow
This commit is contained in:
parent
2c07c8cba1
commit
b2a2bdab07
123
src/features/admin/aaguid_fragments.tsx
Normal file
123
src/features/admin/aaguid_fragments.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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();
|
||||
});
|
||||
|
||||
282
src/features/admin/admin_actions_routes.ts
Normal file
282
src/features/admin/admin_actions_routes.ts
Normal 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 });
|
||||
});
|
||||
190
src/features/admin/invites_fragments.tsx
Normal file
190
src/features/admin/invites_fragments.tsx
Normal 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;"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@ -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]);
|
||||
};
|
||||
|
||||
200
src/features/admin/roles_fragments.tsx
Normal file
200
src/features/admin/roles_fragments.tsx
Normal 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;"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@ -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 });
|
||||
});
|
||||
|
||||
136
src/features/dashboard/launchpad_fragments.tsx
Normal file
136
src/features/dashboard/launchpad_fragments.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;"
|
||||
>
|
||||
×
|
||||
</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
|
||||
// ---------------------------------------------------------
|
||||
|
||||
@ -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);
|
||||
|
||||
219
src/features/sessions/sessions_fragments.tsx
Normal file
219
src/features/sessions/sessions_fragments.tsx
Normal 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;"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@ -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");
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user