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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user