Move admin screens (users/groups/roles/oauth2-clients) into a drop-in example plugin; add the ctx.system capability surface
This commit is contained in:
+17
-5
@@ -15,6 +15,18 @@ const scheduling: Plugin = {
|
||||
};
|
||||
// A plugin with a public nav node (reachable by anyone, signed in or not).
|
||||
const portal: Plugin = { apiVersion: "1.0.0", id: "portal", nav: [{ href: "/portal", id: "portal", label: "Portal", public: true }] };
|
||||
// A gated section fragment like the admin plugin's nav: the header carries the permission, so
|
||||
// composeNav drops the whole subtree for a non-holder (the admin screens ship as a drop-in plugin).
|
||||
const adminLike: Plugin = {
|
||||
apiVersion: "1.0.0", id: "admin",
|
||||
nav: [{
|
||||
children: [
|
||||
{ href: "/admin/users", id: "users", label: "Users" },
|
||||
{ href: "/admin/groups", id: "groups", label: "Groups" },
|
||||
],
|
||||
icon: "i-shield", id: "admin", label: "Admin", permission: "admin",
|
||||
}],
|
||||
};
|
||||
|
||||
const labels = (nodes: NavNode[]): string[] => nodes.map((n) => n.label);
|
||||
|
||||
@@ -23,8 +35,8 @@ test("anonymous: brand from menu, Guest user; the gated Dashboard link is hidden
|
||||
assert.equal(chrome.brand.name, DEFAULT_MENU.branding.name);
|
||||
assert.equal(chrome.user.name, "Guest");
|
||||
// Dashboard points at the gated /dashboard — showing it to an anonymous visitor only dead-ends them
|
||||
// at /login, so it's dropped. Scheduling's only child is gated (dropped), admin gated (dropped);
|
||||
// the explicitly public Portal node remains.
|
||||
// at /login, so it's dropped. Scheduling's only child is gated (dropped); the explicitly public
|
||||
// Portal node remains.
|
||||
assert.deepEqual(labels(chrome.nav), ["Portal"]);
|
||||
});
|
||||
|
||||
@@ -45,12 +57,12 @@ test("a permission holder sees the Dashboard link + plugin nav; current path ope
|
||||
assert.equal(chrome.user.name, "ada"); // email local part
|
||||
});
|
||||
|
||||
test("an admin sees the gated admin section; a sub-path marks its base leaf current", () => {
|
||||
const chrome = buildPluginChrome({ currentPath: "/admin/users/new", menu: DEFAULT_MENU, user: { email: "a@b.c", id: "u1", roles: ["admin"] } });
|
||||
test("a gated section (like the admin plugin) shows to a holder; a sub-path marks its base leaf current", () => {
|
||||
const chrome = buildPluginChrome({ currentPath: "/admin/users/new", menu: DEFAULT_MENU, plugins: [adminLike], user: { email: "a@b.c", id: "u1", roles: ["admin"] } });
|
||||
const admin = chrome.nav.find((n) => n.label === "Admin")!;
|
||||
assert.ok(admin); // gated section visible to an admin
|
||||
assert.equal(admin.open, true); // ancestor of the current leaf opened
|
||||
// /admin/users/new is under the Users base (/admin/users) → that leaf is current, not Groups/Roles.
|
||||
// /admin/users/new is under the Users base (/admin/users) → that leaf is current, not Groups.
|
||||
assert.equal(admin.children!.find((c) => c.label === "Users")!.current, true);
|
||||
assert.equal(admin.children!.find((c) => c.label === "Groups")!.current, undefined);
|
||||
});
|
||||
|
||||
+10
-6
@@ -1,16 +1,20 @@
|
||||
// Page chrome for plugin pages: the brand / global-nav / user / theme / csrf block a
|
||||
// plugin view hands to partials/shell so its page looks native — the same shell the dashboard and
|
||||
// admin screens render. Pure; the host builds it per plugin request and exposes it on ctx.chrome.
|
||||
// nav is the global menu — Dashboard + every plugin's fragment + the gated admin section — run
|
||||
// through composeNav (override + per-user filter) and current-marked for the request path.
|
||||
// every plugin renders. Pure; the host builds it per plugin request and exposes it on ctx.chrome.
|
||||
// nav is the global menu — Dashboard + every plugin's fragment (admin screens included, when the
|
||||
// admin plugin is installed) — run through composeNav (override + per-user filter) and
|
||||
// current-marked for the request path.
|
||||
|
||||
import { adminSection, DASHBOARD_NAV } from "../admin/admin-nav.ts";
|
||||
import type { User } from "../http/context.ts";
|
||||
import { type MenuConfig } from "./menu-config.ts";
|
||||
import { composeNav, type NavNode } from "./nav.ts";
|
||||
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||
import { shellUser, type ShellUser } from "./shell-context.ts";
|
||||
|
||||
// The "Dashboard" link to the gated app home (/dashboard). It targets a gated route, so it's shown
|
||||
// only to a signed-in user (an anonymous click would only dead-end at /login).
|
||||
const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "Dashboard" };
|
||||
|
||||
export interface PageChrome {
|
||||
brand: { logo?: string; name: string; sub?: string };
|
||||
csrfToken: string; // double-submit token for the shell's Sign-out form + a plugin's own forms
|
||||
@@ -30,10 +34,10 @@ export interface ChromeOptions {
|
||||
|
||||
export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
||||
// The Dashboard link targets the gated /dashboard, so show it only to a signed-in user — to an
|
||||
// anonymous visitor (a public page in the shell) it would only dead-end at /login.
|
||||
// anonymous visitor (a public page in the shell) it would only dead-end at /login. The admin
|
||||
// section, when present, is just another plugin's nav fragment (examples/plugins/admin).
|
||||
const fragments: NavNode[][] = opts.user ? [[DASHBOARD_NAV]] : [];
|
||||
for (const p of opts.plugins ?? []) if (p.nav?.length) fragments.push(p.nav);
|
||||
fragments.push([adminSection()]);
|
||||
|
||||
const roles = opts.user?.roles ?? [];
|
||||
const nav = composeNav(fragments, opts.menu.override, roles);
|
||||
|
||||
Reference in New Issue
Block a user