Files
plainpages/src/admin-nav.ts
T

94 lines
4.7 KiB
TypeScript

// 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 { readFormBody } from "./body.ts";
import type { RequestContext, User } from "./context.ts";
import { CSRF_FIELD, verifyCsrfRequest } from "./csrf.ts";
import { GuardError, loginRedirect } from "./guards.ts";
import { type MenuConfig } from "./menu-config.ts";
import { composeNav, type NavNode } from "./nav.ts";
import { buildShellContext } from "./shell-context.ts";
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";
export const ADMIN_CLIENTS_BASE = "/admin/clients";
export type AdminScreen = "clients" | "groups" | "roles" | "users";
// The "Dashboard" link to the gated app home (/dashboard). One definition, reused by the in-screen
// admin sidebar and the plugin-page chrome (chrome.ts) so the two can't drift. It targets a gated
// route, so the chrome hides it from anonymous visitors (a non-signed-in click only dead-ends at /login).
export const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "Dashboard" };
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" },
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "OAuth2 clients" },
];
// 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[] {
return composeNav([[DASHBOARD_NAV, adminSection(current)]], menu.override, roles);
}
// The shared gate for every admin screen: a signed-in admin only. Throws GuardError that app.ts maps
// (anonymous → /login, non-admin → 403). Returns the (non-null) user for the handler to thread on.
export function requireAdmin(ctx: RequestContext): User {
if (!ctx.user) throw new GuardError(401, "authentication required", loginRedirect(ctx));
if (!ctx.roles.includes(ADMIN_PERMISSION)) throw new GuardError(403, "admin role required");
return ctx.user;
}
// Read + CSRF-verify a mutation's form body once. Every admin write is a first-party POST form, so a
// POST without a valid double-submit token is refused (GuardError → 403); non-POST ⇒ undefined.
export async function guardedForm(ctx: RequestContext, csrfSecret: string): Promise<URLSearchParams | undefined> {
if ((ctx.req.method ?? "GET").toUpperCase() !== "POST") return undefined;
const form = await readFormBody(ctx.req);
if (!verifyCsrfRequest({ cookieHeader: ctx.req.headers.cookie, secret: csrfSecret, submitted: form.get(CSRF_FIELD) })) {
throw new GuardError(403, "invalid CSRF token");
}
return form;
}
// Build the model for the shared destructive-action confirm page (views/admin/confirm.ejs): a single
// danger action behind a deliberate second step, plus a cancel link. Reused by all three screens.
export function buildConfirmModel(opts: {
breadcrumbs: { href?: string; label: string }[];
cancelHref: string;
confirmAction: string;
confirmLabel: string;
csrfToken: string;
current: AdminScreen;
menu: MenuConfig;
message: string;
title: string;
user: User | null;
}) {
return {
cancelHref: opts.cancelHref,
confirm: { action: opts.confirmAction, label: opts.confirmLabel },
message: opts.message,
nav: adminNav(opts.user?.roles ?? [], opts.menu, opts.current),
shell: buildShellContext({ breadcrumbs: opts.breadcrumbs, csrfToken: opts.csrfToken, menu: opts.menu, title: opts.title, user: opts.user }),
};
}