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,
|
AdminUsersPageFragment,
|
||||||
AuditLogPageFragment,
|
AuditLogPageFragment,
|
||||||
} from "./fragments.tsx";
|
} 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 { sqlWrapper } from "../../core/db.ts";
|
||||||
import { rateLimitWrapper } from "../../core/middleware.ts";
|
import { rateLimitWrapper } from "../../core/middleware.ts";
|
||||||
|
|
||||||
@ -144,4 +147,32 @@ test("admin fragment components render valid HTML markup", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(auditFragment).toBeDefined();
|
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) => {
|
export const getAuditLogs = async (limit: number = 50) => {
|
||||||
return await sqlWrapper.sql`
|
return await sqlWrapper.sql`
|
||||||
SELECT a.id, a.action, a.resource, a.details, a.ip_address, a.created_at, u.username as user
|
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
|
RETURNING id
|
||||||
`.then((res: any) => res[0]);
|
`.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 { Hono } from "jsr:@hono/hono@4";
|
||||||
import { encodeBase64Url } from "jsr:@std/encoding@1/base64url";
|
import { adminRateLimiter } from "../../core/middleware.ts";
|
||||||
|
|
||||||
import { adminRateLimiter, getClientIp } from "../../core/middleware.ts";
|
|
||||||
import { getAuthenticatedUser, requireAdmin } from "../../core/session.ts";
|
import { getAuthenticatedUser, requireAdmin } from "../../core/session.ts";
|
||||||
import { valkey } from "../../core/valkey.ts";
|
|
||||||
import { auditWrapper } from "../../core/audit.ts";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
assignUserGrant,
|
getAaguidAllowlist,
|
||||||
createRecoveryLink,
|
getAdminApps,
|
||||||
|
getAdminInvites,
|
||||||
|
getAdminRoles,
|
||||||
getAllApps,
|
getAllApps,
|
||||||
getAllRoles,
|
getAllRoles,
|
||||||
getAllUsers,
|
getAllUsers,
|
||||||
getAppById,
|
|
||||||
getAuditLogs,
|
getAuditLogs,
|
||||||
getUserById,
|
getUserById,
|
||||||
getUserGrants,
|
getUserGrants,
|
||||||
getUserPasskeys,
|
getUserPasskeys,
|
||||||
getUserSessions,
|
getUserSessions,
|
||||||
removeUserGrant,
|
|
||||||
revokeAllUserSessions,
|
|
||||||
revokeSessionById,
|
|
||||||
revokeUserPasskey,
|
|
||||||
updateUserProfile,
|
|
||||||
updateUserStatus,
|
|
||||||
} from "./queries.ts";
|
} from "./queries.ts";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AdminAppsPageFragment,
|
AdminAppsPageFragment,
|
||||||
AdminUserDetailsPageFragment,
|
AdminUserDetailsPageFragment,
|
||||||
AdminUsersPageFragment,
|
AdminUsersPageFragment,
|
||||||
AuditLogPageFragment,
|
AuditLogPageFragment,
|
||||||
} from "./fragments.tsx";
|
} 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();
|
export const adminRoutes = new Hono();
|
||||||
|
|
||||||
adminRoutes.use("*", requireAdmin);
|
adminRoutes.use("*", requireAdmin);
|
||||||
adminRoutes.use("*", adminRateLimiter);
|
adminRoutes.use("*", adminRateLimiter);
|
||||||
|
|
||||||
|
// Mount Actions sub-router (mutations)
|
||||||
|
adminRoutes.route("/", adminActionsRoutes);
|
||||||
|
|
||||||
// --- HTML Pages & UI Routes ---
|
// --- HTML Pages & UI Routes ---
|
||||||
|
|
||||||
adminRoutes.get("/", (c) => c.redirect("/admin/users"));
|
adminRoutes.get("/", (c) => c.redirect("/admin/users"));
|
||||||
@ -80,10 +76,43 @@ adminRoutes.get("/apps", async (c) => {
|
|||||||
const auth = await getAuthenticatedUser(c);
|
const auth = await getAuthenticatedUser(c);
|
||||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
const apps = await getAllApps();
|
const apps = await getAdminApps();
|
||||||
return c.html(<AdminAppsPageFragment apps={apps} />);
|
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) => {
|
adminRoutes.get("/audit-logs", async (c) => {
|
||||||
const auth = await getAuthenticatedUser(c);
|
const auth = await getAuthenticatedUser(c);
|
||||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||||
@ -91,202 +120,3 @@ adminRoutes.get("/audit-logs", async (c) => {
|
|||||||
const logs = await getAuditLogs(100);
|
const logs = await getAuditLogs(100);
|
||||||
return c.html(<AuditLogPageFragment logs={logs} />);
|
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;
|
if (!result || result.length === 0) return null;
|
||||||
return result[0];
|
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 { streamDatastar } from "../../core/sse_adapter.ts";
|
||||||
import { renderErrorToastFragment } from "../../core/error_fragments.tsx";
|
import { renderErrorToastFragment } from "../../core/error_fragments.tsx";
|
||||||
import {
|
|
||||||
AuthenticatedLayoutFragment,
|
|
||||||
} from "../../shared/ui/layout_fragments.tsx";
|
|
||||||
import { getAuthenticatedUser, hasScope } from "../../core/session.ts";
|
import { getAuthenticatedUser, hasScope } from "../../core/session.ts";
|
||||||
import { getAllApps, getUserPasskeys } from "../admin/queries.ts";
|
|
||||||
|
|
||||||
import { getActiveSessions } from "./queries.ts";
|
|
||||||
import {
|
import {
|
||||||
DirectPassDrawerFragment,
|
getAllApps,
|
||||||
ScopeModalFragment,
|
getDashboardApps,
|
||||||
SessionDeckFragment,
|
getUserPasskeys,
|
||||||
SessionTableFragment,
|
} from "../admin/queries.ts";
|
||||||
} from "./fragments.tsx";
|
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 { PasskeysPageFragment } from "./passkeys_fragments.tsx";
|
||||||
import { sessionActionsRoutes } from "./actions_routes.ts";
|
import { sessionActionsRoutes } from "./actions_routes.ts";
|
||||||
|
|
||||||
@ -23,6 +20,48 @@ export const sessionRoutes = new Hono();
|
|||||||
// Mount Actions sub-router
|
// Mount Actions sub-router
|
||||||
sessionRoutes.route("/", sessionActionsRoutes);
|
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
|
// 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
|
// Live Telemetry / Revocation SSE Stream
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
|
|||||||
@ -7,6 +7,13 @@ import {
|
|||||||
SessionTableFragment,
|
SessionTableFragment,
|
||||||
} from "./fragments.tsx";
|
} 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 () => {
|
Deno.test("[Sessions] GET /dashboard/sessions unauthenticated redirects to /login", async () => {
|
||||||
const req = new Request("http://localhost/dashboard/sessions");
|
const req = new Request("http://localhost/dashboard/sessions");
|
||||||
const res = await sessionRoutes.fetch(req);
|
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");
|
return c.redirect("/login");
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/dashboard", (c) => {
|
|
||||||
return c.redirect("/dashboard/sessions");
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get("/logout", async (c) => {
|
app.get("/logout", async (c) => {
|
||||||
const sessionIds = extractAllSessionIds(c);
|
const sessionIds = extractAllSessionIds(c);
|
||||||
const rawRedirect = c.req.query("redirect");
|
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.status, 302);
|
||||||
assertEquals(resRoot.headers.get("location"), "/login");
|
assertEquals(resRoot.headers.get("location"), "/login");
|
||||||
|
|
||||||
// 3. /dashboard redirects to /dashboard/sessions
|
// 3. Unauthenticated /dashboard redirects to /login
|
||||||
const resDashRoot = await app.fetch(
|
const resDashRoot = await app.fetch(
|
||||||
new Request("http://localhost/dashboard"),
|
new Request("http://localhost/dashboard"),
|
||||||
);
|
);
|
||||||
assertEquals(resDashRoot.status, 302);
|
assertEquals(resDashRoot.status, 302);
|
||||||
assertEquals(resDashRoot.headers.get("location"), "/dashboard/sessions");
|
assertEquals(resDashRoot.headers.get("location"), "/login");
|
||||||
|
|
||||||
// 4. /logout redirects to /login
|
// 4. /logout redirects to /login
|
||||||
const resLogout = await app.fetch(new Request("http://localhost/logout"));
|
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);
|
assertEquals(resForwardAuth.status, 400);
|
||||||
|
|
||||||
// 12. Admin route unauthenticated redirects or denies
|
// 12. Admin routes unauthenticated redirect or deny
|
||||||
const resAdmin = await app.fetch(
|
for (
|
||||||
new Request("http://localhost/admin/users"),
|
const path of [
|
||||||
);
|
"/admin/users",
|
||||||
assertEquals(resAdmin.status === 302 || resAdmin.status === 401, true);
|
"/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 {
|
} finally {
|
||||||
auditWrapper.auditLog = origAudit;
|
auditWrapper.auditLog = origAudit;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user