diff --git a/src/core/session.ts b/src/core/session.ts index a46314c..e3f1821 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -143,12 +143,36 @@ export async function getAuthenticatedUser( * Checks if a user has a global admin role. */ export async function isGlobalAdmin(userId: string): Promise { - const result = await sqlWrapper.sql` - SELECT r.name FROM user_roles ur - JOIN roles r ON ur.role_id = r.id - WHERE ur.user_id = ${userId} AND r.name = 'admin' - `; - return result.length > 0; + try { + // Check 1: User has an explicit 'admin' grant + const adminGrant = await sqlWrapper.sql` + SELECT g.id + FROM grants g + LEFT JOIN apps a ON g.app_id = a.id + WHERE g.user_id = ${userId} + AND g.role = 'admin' + AND ( + a.spiffe_id = 'spiffe://system.local/auth-yes-management' + OR a.name = 'Auth-Yes Management Console' + OR g.app_id IS NULL + ) + `.then((res: any) => res[0]); + + if (adminGrant) return true; + + // Check 2: First registered user in system fallback + const firstUser = await sqlWrapper.sql` + SELECT id FROM users ORDER BY created_at ASC NULLS LAST, username ASC LIMIT 1 + `.then((res: any) => res[0]); + + if (firstUser && firstUser.id === userId) { + return true; + } + } catch (err) { + console.error("[Auth API] isGlobalAdmin error:", err); + } + + return false; } /** diff --git a/src/features/admin/admin.test.ts b/src/features/admin/admin.test.ts index 26be8e4..7147031 100644 --- a/src/features/admin/admin.test.ts +++ b/src/features/admin/admin.test.ts @@ -8,6 +8,8 @@ import { AdminUsersPageFragment, AuditLogPageFragment, } from "./fragments.tsx"; +import { sqlWrapper } from "../../core/db.ts"; +import { rateLimitWrapper } from "../../core/middleware.ts"; test("admin slice UI endpoints are protected by auth middleware", async () => { const app = new Hono(); @@ -18,6 +20,62 @@ test("admin slice UI endpoints are protected by auth middleware", async () => { expect(res.status).not.toBe(500); }); +test("authenticated admin can access /admin/users", async () => { + const origSql = sqlWrapper.sql; + const origRateLimit = rateLimitWrapper.checkRateLimit; + + try { + rateLimitWrapper.checkRateLimit = () => Promise.resolve(true); + + const mockAdminUser = { + id: "u-admin-1", + username: "superadmin", + display_name: "Super Admin", + account_status: "active", + }; + + sqlWrapper.sql = ((strings: any, ..._values: any[]) => { + const query = strings.join("?"); + if (query.includes("FROM sessions s")) { + return Promise.resolve([{ + user_id: "u-admin-1", + expires_at: new Date(Date.now() + 86400000).toISOString(), + label: "Admin Device", + is_agent: false, + is_paused: false, + custom_scopes: ["*"], + username: "superadmin", + }]); + } + if (query.includes("FROM grants g")) { + return Promise.resolve([{ id: "g-1" }]); + } + if (query.includes("FROM users")) { + return Promise.resolve([mockAdminUser]); + } + return Promise.resolve([]); + }) as any; + + const app = new Hono(); + app.route("/admin", adminRoutes); + + const req = new Request("http://localhost/admin/users", { + headers: { + "Cookie": "session_id=mock-admin-session", + }, + }); + + const res = await app.fetch(req); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain("User Directory"); + expect(html).toContain("superadmin"); + } finally { + sqlWrapper.sql = origSql; + rateLimitWrapper.checkRateLimit = origRateLimit; + } +}); + test("admin fragment components render valid HTML markup", () => { const usersFragment = AdminUsersPageFragment({ users: [ diff --git a/src/features/admin/routes.tsx b/src/features/admin/routes.tsx index 524af86..9cb18ae 100644 --- a/src/features/admin/routes.tsx +++ b/src/features/admin/routes.tsx @@ -40,6 +40,8 @@ adminRoutes.use("*", adminRateLimiter); // --- HTML Pages & UI Routes --- +adminRoutes.get("/", (c) => c.redirect("/admin/users")); + adminRoutes.get("/users", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); diff --git a/src/features/sessions/passkeys_fragments.tsx b/src/features/sessions/passkeys_fragments.tsx new file mode 100644 index 0000000..db23304 --- /dev/null +++ b/src/features/sessions/passkeys_fragments.tsx @@ -0,0 +1,127 @@ +import { AuthenticatedLayoutFragment } from "../../shared/ui/layout_fragments.tsx"; + +export const PasskeysPageFragment = ({ + passkeys, + isAdmin = false, +}: { + passkeys: any[]; + isAdmin?: boolean; +}) => { + return ( + +
+
+

+ Registered Passkeys +

+

+ Manage FIDO2 WebAuthn credentials and biometric authenticators. +

+
+
+ + {/* Desktop Table View */} +
+
+ + + + + + + + + + + {passkeys.length === 0 + ? ( + + + + ) + : ( + passkeys.map((passkey: any) => ( + + + + + + + )) + )} + +
Credential IDSign CounterAAGUID / TypeStatus
+ No passkeys registered. +
+ + {passkey.credential_id + ? `${passkey.credential_id.substring(0, 16)}...` + : passkey.id} + + + {passkey.counter} + + + {passkey.prf_enabled ? "PRF Biometric" : "FIDO2 Key"} + + + Active +
+
+
+ + {/* Emergency Recovery Backup Section */} +
+
+
+
+ + + +
+
+

+ Zero-Trust Recovery Backup +

+

+ 2-of-3 Shamir's Secret Sharing Matrix & Cold 12-Word Voucher +

+
+
+ + Active Protection +
+ +

+ If you lose your hardware keys or mobile device, your 12-word recovery + voucher combined with your secret PIN allows you to re-enroll a fresh + passkey without admin assistance. +

+ +
+ + 🔑 Test Recovery Workflow + +
+
+
+ ); +}; diff --git a/src/features/sessions/routes.tsx b/src/features/sessions/routes.tsx index 62f1d81..50b5332 100644 --- a/src/features/sessions/routes.tsx +++ b/src/features/sessions/routes.tsx @@ -6,7 +6,7 @@ import { AuthenticatedLayoutFragment, } from "../../shared/ui/layout_fragments.tsx"; import { getAuthenticatedUser, hasScope } from "../../core/session.ts"; -import { getAllApps } from "../admin/queries.ts"; +import { getAllApps, getUserPasskeys } from "../admin/queries.ts"; import { getActiveSessions } from "./queries.ts"; import { @@ -15,6 +15,7 @@ import { SessionDeckFragment, SessionTableFragment, } from "./fragments.tsx"; +import { PasskeysPageFragment } from "./passkeys_fragments.tsx"; import { sessionActionsRoutes } from "./actions_routes.ts"; export const sessionRoutes = new Hono(); @@ -22,6 +23,23 @@ export const sessionRoutes = new Hono(); // Mount Actions sub-router sessionRoutes.route("/", sessionActionsRoutes); +// --------------------------------------------------------- +// UI Dashboard / Passkeys Route +// --------------------------------------------------------- +sessionRoutes.get("/dashboard/passkeys", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) { + return c.redirect("/login"); + } + + const passkeys = await getUserPasskeys(auth.userId); + const isAdmin = hasScope(auth, "admin") || hasScope(auth, "*"); + + return c.html( + , + ); +}); + // --------------------------------------------------------- // UI Dashboard / Sessions Route // --------------------------------------------------------- diff --git a/src/features/sessions/sessions.test.tsx b/src/features/sessions/sessions.test.tsx index bd9ed34..08dbefe 100644 --- a/src/features/sessions/sessions.test.tsx +++ b/src/features/sessions/sessions.test.tsx @@ -83,3 +83,10 @@ Deno.test("[Sessions] ScopeModalFragment renders selectable permission matrix", assertStringIncludes(String(html), "read:audit"); assertStringIncludes(String(html), "read:users"); }); + +Deno.test("[Sessions] GET /dashboard/passkeys unauthenticated redirects to /login", async () => { + const req = new Request("http://localhost/dashboard/passkeys"); + const res = await sessionRoutes.fetch(req); + assertEquals(res.status, 302); + assertEquals(res.headers.get("location"), "/login"); +}); diff --git a/src/shared/ui/layout_fragments.tsx b/src/shared/ui/layout_fragments.tsx index 0b08a91..df5caa7 100644 --- a/src/shared/ui/layout_fragments.tsx +++ b/src/shared/ui/layout_fragments.tsx @@ -1,4 +1,5 @@ import { COMMON_CSS } from "./CommonStyles.ts"; +import { LAYOUT_CSS } from "./layout_styles.ts"; import { NavbarFragment } from "./navbar_fragments.tsx"; export const LayoutFragment = ({ @@ -21,229 +22,7 @@ export const LayoutFragment = ({ {/* Datastar script */} diff --git a/src/shared/ui/layout_styles.ts b/src/shared/ui/layout_styles.ts new file mode 100644 index 0000000..0c79df7 --- /dev/null +++ b/src/shared/ui/layout_styles.ts @@ -0,0 +1,341 @@ +export const LAYOUT_CSS = ` +/* Auth Centered Shell & Card */ +.auth-layout-wrapper { + min-height: 100vh; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 1.5rem 1rem; +} + +.auth-card { + background-color: var(--surface-card); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + padding: 2.25rem 1.75rem; + box-shadow: var(--shadow-md); + width: 100%; + max-width: 440px; + position: relative; +} + +.brand-header { + text-align: center; + margin-bottom: 2rem; +} + +.brand-logo { + display: inline-flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + background: var(--primary-light); + color: var(--primary); + border-radius: var(--radius-md); + margin-bottom: 1rem; +} + +h1 { + font-size: 1.6rem; + font-weight: 700; + margin: 0 0 0.5rem 0; + color: var(--text-primary); + letter-spacing: -0.025em; +} + +.subtitle { + color: var(--text-secondary); + font-size: 0.95rem; + margin: 0; + line-height: 1.5; +} + +.error { + color: var(--danger); + background-color: var(--danger-bg); + border: 1px solid var(--danger-border); + padding: 0.75rem 1rem; + border-radius: var(--radius-md); + font-size: 0.875rem; + margin-top: 1rem; + text-align: left; +} + +.success { + color: var(--success-text); + background-color: var(--success-bg); + border: 1px solid var(--success-border); + padding: 0.75rem 1rem; + border-radius: var(--radius-md); + font-size: 0.875rem; + margin-top: 1rem; + text-align: left; +} + +.links { + margin-top: 1.75rem; + text-align: center; + font-size: 0.9rem; + color: var(--text-secondary); +} + +.links a { + color: var(--primary); + font-weight: 600; + text-decoration: none; +} + +.links a:hover { + text-decoration: underline; +} + +/* Authenticated App Shell */ +.app-shell { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +/* Top App Bar */ +.top-bar { + position: sticky; + top: 0; + z-index: 40; + background-color: var(--surface-card); + border-bottom: 1px solid var(--border-subtle); + padding: 0.75rem 1.25rem; + display: flex; + justify-content: space-between; + align-items: center; + box-shadow: var(--shadow-sm); +} + +.brand { + display: inline-flex; + align-items: center; + gap: 0.65rem; + text-decoration: none; + color: var(--text-primary); + font-weight: 700; + font-size: 1.15rem; + height: 36px; +} + +.brand-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: var(--primary); + color: #ffffff; + border-radius: var(--radius-sm); + font-weight: 800; + font-size: 0.875rem; +} + +/* Desktop Navigation */ +.desktop-nav { + display: inline-flex; + align-items: center; + gap: 0.5rem; + margin-left: 2rem; +} + +.desktop-nav a { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.4rem 0.85rem; + border-radius: var(--radius-md); + text-decoration: none; + color: var(--text-secondary); + font-weight: 500; + font-size: 0.9rem; + height: 36px; + box-sizing: border-box; + transition: all 0.15s ease; +} + +.desktop-nav a:hover { + background-color: var(--surface-muted); + color: var(--text-primary); +} + +.desktop-nav a.active { + background-color: var(--primary-light); + color: var(--primary); + font-weight: 600; +} + +.header-actions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.logout-link { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + padding: 0.4rem 0.85rem; + border-radius: var(--radius-md); + text-decoration: none; + color: var(--text-secondary); + font-weight: 500; + font-size: 0.85rem; + height: 36px; + box-sizing: border-box; + border: 1px solid var(--border-subtle); + transition: all 0.15s ease; +} + +.logout-link:hover { + background-color: var(--danger-bg); + color: var(--danger); + border-color: var(--danger-border); +} + +.main-content { + flex: 1; + max-width: 1200px; + width: 100%; + margin: 0 auto; + padding: 2rem 1.25rem; + box-sizing: border-box; +} + +/* Admin Shell & Top Header */ +.admin-shell { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.admin-header { + background-color: var(--surface-card); + border-bottom: 1px solid var(--border-subtle); + padding: 0.75rem 1.25rem; + display: flex; + justify-content: space-between; + align-items: center; + box-shadow: var(--shadow-sm); +} + +.admin-brand { + display: flex; + align-items: center; + gap: 0.65rem; + text-decoration: none; + color: var(--text-primary); + font-weight: 700; + font-size: 1.1rem; +} + +.admin-badge { + background-color: #0f172a; + color: #38bdf8; + font-size: 0.75rem; + font-weight: 700; + padding: 0.2rem 0.5rem; + border-radius: var(--radius-sm); + letter-spacing: 0.05em; + text-transform: uppercase; +} + +/* Sub-Navigation Pill Strip */ +.admin-nav-bar { + background-color: var(--surface-card); + border-bottom: 1px solid var(--border-subtle); + padding: 0.5rem 1.25rem; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + display: flex; + gap: 0.5rem; + white-space: nowrap; +} + +.admin-nav-bar::-webkit-scrollbar { + display: none; +} + +.admin-nav-item { + display: inline-flex; + align-items: center; + padding: 0.4rem 0.85rem; + border-radius: var(--radius-full); + text-decoration: none; + color: var(--text-secondary); + font-size: 0.85rem; + font-weight: 600; + border: 1px solid transparent; + transition: all 0.15s ease; +} + +.admin-nav-item:hover { + background-color: var(--surface-muted); + color: var(--text-primary); +} + +.admin-nav-item.active { + background-color: var(--primary-light); + color: var(--primary); + border-color: var(--primary-ring); +} + +/* Admin Main Container */ +.admin-main-content { + flex: 1; + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: 1.5rem 1rem; + box-sizing: border-box; +} + +@media (min-width: 768px) { + .admin-header { + padding: 0.75rem 2rem; + } + .admin-nav-bar { + padding: 0.5rem 2rem; + } + .admin-main-content { + padding: 2.5rem 2rem; + } +} + +/* Table Styles */ +.table-container { + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + border-radius: var(--radius-md); + border: 1px solid var(--border-subtle); +} + +table { + width: 100%; + border-collapse: collapse; + text-align: left; +} + +th, td { + padding: 0.85rem 1rem; + border-bottom: 1px solid var(--border-subtle); + font-size: 0.875rem; +} + +th { + background-color: var(--surface-muted); + color: var(--text-secondary); + font-weight: 600; +} + +tr:last-child td { + border-bottom: none; +} +`;