Wire built-in admin screens into the global menu (todo §5); extract adminSection() = one permission-gated 'Admin' header (Users/Groups/Roles), reused by both the home dashboard menu and the in-screen adminNav so they can't drift. composeNav drops the whole gated header+subtree for a non-admin/anonymous (cosmetic — the admin routes stay independently GuardError(403)-gated); narrowed AdminScreen to groups|roles|users. Reuses existing sprite icons (no icon-guard change); default anonymous / render byte-equivalent so visual E2E unaffected. Tests-first: dashboard model gating (admin→3 hrefs, non-admin→absent) + app HTTP (admin JWT→/admin/users link, anon→absent). Stability-reviewer run as a local PR: APPROVE, no Critical/High/Medium. README Layout updated. 242→244 units + typecheck green

This commit is contained in:
2026-06-18 18:33:19 +02:00
parent a016a0131e
commit 6920751cb8
6 changed files with 50 additions and 13 deletions

View File

@@ -1,25 +1,42 @@
// Shared sidebar nav for the built-in admin screens (todo §5). Both the Users and Groups
// screens render the same admin section (Dashboard · Users · Groups), with `current` set on the
// active item. Extracted so the two screens can't drift. The global config-driven menu wiring
// (an admin section gated per user) is the separate §5 menu item; this is the local in-screen nav.
// The built-in admin section of the menu (todo §5). One definition of the Users/Groups/Roles links
// + their gate, reused two ways so they can't drift: `adminSection()` is the permission-gated
// "Admin" header wired into the global dashboard menu (composeNav drops the whole header + subtree
// for a non-admin), and `adminNav()` is the in-screen sidebar each admin screen renders (a link home
// + the same section, with the active item marked `current`).
import { type MenuConfig } from "./menu-config.ts";
import { composeNav, type NavNode } from "./nav.ts";
export const ADMIN_PERMISSION = "admin"; // role token gating every admin screen
export const ADMIN_PERMISSION = "admin"; // role token gating the admin section
export const ADMIN_USERS_BASE = "/admin/users";
export const ADMIN_GROUPS_BASE = "/admin/groups";
export const ADMIN_ROLES_BASE = "/admin/roles";
type AdminScreen = "dashboard" | "groups" | "roles" | "users";
export type AdminScreen = "groups" | "roles" | "users";
const ITEMS: { href: string; icon: string; id: AdminScreen; label: string }[] = [
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "Users" },
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "Groups" },
{ href: ADMIN_ROLES_BASE, icon: "i-shield", id: "roles", label: "Roles" },
];
// The gated "Admin" header + its three screens; `current` marks the active screen and opens the
// header. The permission lives on the header, so composeNav drops the whole section for a non-admin.
export function adminSection(current?: AdminScreen): NavNode {
return {
children: ITEMS.map((it) => ({ ...it, ...(it.id === current ? { current: true } : {}) })),
icon: "i-shield",
id: "admin",
label: "Admin",
permission: ADMIN_PERMISSION,
...(current ? { open: true } : {}),
};
}
// In-screen sidebar for the admin screens: a link home + the admin section (active item marked).
export function adminNav(roles: string[], menu: MenuConfig, current: AdminScreen): NavNode[] {
const gated = (id: AdminScreen, href: string, icon: string, label: string): NavNode =>
({ ...(current === id ? { current: true } : {}), href, icon, id, label, permission: ADMIN_PERMISSION });
return composeNav([[
{ href: "/", icon: "i-grid", id: "dashboard", label: "Dashboard" },
gated("users", ADMIN_USERS_BASE, "i-users", "Users"),
gated("groups", ADMIN_GROUPS_BASE, "i-layers", "Groups"),
gated("roles", ADMIN_ROLES_BASE, "i-shield", "Roles"),
adminSection(current),
]], menu.override, roles);
}

View File

@@ -216,6 +216,11 @@ test("a verified session JWT authorizes a role-gated route; no cookie / expired
// No cookie and an expired token both render anonymous → the gate denies (403).
assert.equal((await secret()).status, 403);
assert.equal((await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["demo:read"], sub: "u1" })}`)).status, 403);
// The home menu wires in the permission-gated Admin section: an admin's roles surface the links.
const home = (cookie?: string) => fetch(url + "/", cookie ? { headers: { cookie } } : {});
assert.match(await (await home(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["admin"], sub: "u1" })}`)).text(), /href="\/admin\/users"/);
assert.doesNotMatch(await (await home()).text(), /href="\/admin\/users"/); // anonymous → no admin section
});
test("session re-mint: an expired JWT backed by a live Kratos session is silently re-minted; a dead session clears it", async (t) => {

View File

@@ -72,6 +72,19 @@ test("dashboard applies the central menu config: branding + nav override (rename
assert.ok(!labels.includes("Teams")); // "Teams" hidden
});
test("dashboard menu wires in the permission-gated Admin section (only for admins)", () => {
// An admin sees the Admin section with the three built-in screens.
const admin = buildDashboardModel(new URL("http://x/"), ["admin"]);
const adminNode = admin.nav.find((n) => n.label === "Admin");
assert.ok(adminNode, "admin role → Admin section present");
assert.deepEqual(adminNode!.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/roles"]);
// A non-admin (default []) never sees it — composeNav drops the gated header + its subtree.
const plain = buildDashboardModel(new URL("http://x/"));
assert.equal(plain.nav.find((n) => n.label === "Admin"), undefined);
assert.ok(!plain.nav.some((n) => n.children?.some((c) => c.href === "/admin/users")));
});
test("dashboard paginates: page 2 slices the next rows and preserves state in links", () => {
const p2 = buildDashboardModel(new URL("http://x/?sort=-name&page=2"));
assert.equal(p2.pagination.summary.from, 13); // 30 rows / 12 per page → page 2 starts at 13

View File

@@ -3,6 +3,7 @@
// parseListQuery → filter/sort/paginate a mock dataset → composeNav. Mock data stands in for
// upstream until §4; the filter form, sortable headers and pager all round-trip the URL (zero-JS).
import { adminSection } from "./admin-nav.ts";
import type { User } from "./context.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import { composeNav, type NavNode, type NavOverride } from "./nav.ts";
@@ -128,6 +129,7 @@ function nav(roles: string[], override: NavOverride): NavNode[] {
{ href: "#exports", id: "exports", label: "Exports" },
], icon: "i-chart", id: "reports", label: "Reports", open: true },
{ href: "#settings", icon: "i-gear", id: "settings", label: "Settings", permission: "admin" },
adminSection(), // built-in Users/Groups/Roles screens; gated → invisible to non-admins
]], override, roles);
}