fix(ui): add layout css for top navbar and fix isGlobalAdmin database query

This commit is contained in:
Tyler Gillispie 2026-08-27 21:29:16 -07:00
parent aa9760b1e1
commit 2c07c8cba1
8 changed files with 586 additions and 230 deletions

View File

@ -143,12 +143,36 @@ export async function getAuthenticatedUser(
* Checks if a user has a global admin role.
*/
export async function isGlobalAdmin(userId: string): Promise<boolean> {
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;
}
/**

View File

@ -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: [

View File

@ -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);

View File

@ -0,0 +1,127 @@
import { AuthenticatedLayoutFragment } from "../../shared/ui/layout_fragments.tsx";
export const PasskeysPageFragment = ({
passkeys,
isAdmin = false,
}: {
passkeys: any[];
isAdmin?: boolean;
}) => {
return (
<AuthenticatedLayoutFragment
title="Passkeys"
currentPath="/dashboard/passkeys"
isAdmin={isAdmin}
>
<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);">
Registered Passkeys
</h1>
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
Manage FIDO2 WebAuthn credentials and biometric authenticators.
</p>
</div>
</div>
{/* Desktop Table View */}
<div class="card" style="margin-bottom: 1.5rem;">
<div class="table-container">
<table>
<thead>
<tr>
<th>Credential ID</th>
<th>Sign Counter</th>
<th>AAGUID / Type</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{passkeys.length === 0
? (
<tr>
<td
colSpan={4}
style="text-align: center; padding: 2rem; color: var(--text-muted);"
>
No passkeys registered.
</td>
</tr>
)
: (
passkeys.map((passkey: any) => (
<tr key={passkey.id}>
<td>
<code style="background: var(--surface-muted); padding: 0.2rem 0.5rem; border-radius: var(--radius-sm); font-family: monospace; font-size: 0.85rem;">
{passkey.credential_id
? `${passkey.credential_id.substring(0, 16)}...`
: passkey.id}
</code>
</td>
<td style="color: var(--text-secondary);">
{passkey.counter}
</td>
<td>
<span class="badge badge-info">
{passkey.prf_enabled ? "PRF Biometric" : "FIDO2 Key"}
</span>
</td>
<td>
<span class="badge badge-success">Active</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Emergency Recovery Backup Section */}
<div class="card" style="border-left: 4px solid var(--warning);">
<div style="display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: 1rem; margin-bottom: 0.75rem;">
<div style="display: flex; align-items: center; gap: 0.65rem;">
<div style="display: flex; align-items: center; justify-content: center; width: 36px; height: 36px; background: var(--warning-bg); color: var(--warning-text); border-radius: var(--radius-md);">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
</svg>
</div>
<div>
<h3 style="margin: 0; font-size: 1.1rem; color: var(--text-primary);">
Zero-Trust Recovery Backup
</h3>
<p style="margin: 0; font-size: 0.85rem; color: var(--text-secondary);">
2-of-3 Shamir's Secret Sharing Matrix & Cold 12-Word Voucher
</p>
</div>
</div>
<span class="badge badge-warning">Active Protection</span>
</div>
<p style="color: var(--text-secondary); font-size: 0.875rem; line-height: 1.5; margin: 0 0 1.25rem 0;">
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.
</p>
<div style="display: flex; gap: 0.75rem; flex-wrap: wrap;">
<a
href="/recovery"
class="btn-outline"
style="font-size: 0.85rem; text-decoration: none;"
>
🔑 Test Recovery Workflow
</a>
</div>
</div>
</AuthenticatedLayoutFragment>
);
};

View File

@ -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(
<PasskeysPageFragment passkeys={passkeys} isAdmin={isAdmin} />,
);
});
// ---------------------------------------------------------
// UI Dashboard / Sessions Route
// ---------------------------------------------------------

View File

@ -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");
});

View File

@ -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 = ({
<style>
{`
${COMMON_CSS}
.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;
}
/* Admin Header / Layout overrides included from CommonStyles or here if needed */
.admin-shell {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* Admin Top Header */
.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;
}
${LAYOUT_CSS}
`}
</style>
{/* Datastar script */}

View File

@ -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;
}
`;