109 lines
6.2 KiB
TypeScript
109 lines
6.2 KiB
TypeScript
// Shared plumbing for the admin example plugin: the section nav fragment, the admin-only gate, the
|
|
// CSRF-guarded form reader, the destructive-confirm model builder, and small RouteResult helpers
|
|
// (themed not-found / capability-unavailable). Ported from the former built-in admin screens;
|
|
// everything imports the host only through the #plugin-api barrel.
|
|
|
|
import { can, CSRF_FIELD, englishTranslator, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type Translate, type User } from "#plugin-api";
|
|
import enUS from "./i18n/en-US.ts";
|
|
|
|
// This plugin's English (its catalog, then the host's — the screens reuse core words like Cancel and
|
|
// Search), for a view model built outside a request: its unit tests. At runtime the handlers pass
|
|
// ctx.t, which reads this catalog in the visitor's locale first, then the host's.
|
|
export const ADMIN_EN: Translate = englishTranslator(enUS);
|
|
|
|
export const ADMIN_USERS_BASE = "/admin/users";
|
|
export const ADMIN_GROUPS_BASE = "/admin/groups";
|
|
export const ADMIN_CLIENTS_BASE = "/admin/clients";
|
|
|
|
// One resource per screen — the `<resource>` half of every permission this plugin gates on.
|
|
// `oauth2-clients` rather than `clients` because permission names are one global namespace.
|
|
// There is no `permissions` resource: permissions are declared in plugin code, not created here, so
|
|
// holding a grant is a property of a user or a group and is edited on those two screens.
|
|
export type AdminResource = "groups" | "oauth2-clients" | "users";
|
|
|
|
export type AdminAction = "read" | "write";
|
|
|
|
// `<resource>:<action>` (README → Naming a permission).
|
|
export function permissionName(resource: AdminResource, action: AdminAction): string {
|
|
return `${resource}:${action}`;
|
|
}
|
|
|
|
// This plugin's mapping from method to action: every screen reads on GET/HEAD and mutates on POST.
|
|
// The manifest's route table and the in-handler guard both go through it rather than each spelling
|
|
// the permission out, so they cannot drift into gating on different names. Deliberately local — as
|
|
// a general mechanism it would make authorization a function of the transport verb, and a route
|
|
// table should answer "what does this need?" on its own (AGENTS.md).
|
|
export function actionForMethod(method: string): AdminAction {
|
|
const verb = method.toUpperCase();
|
|
return verb === "GET" || verb === "HEAD" ? "read" : "write";
|
|
}
|
|
|
|
// The plugin's nav fragment: an ungated "Admin" header + its three screens, each gated on its own
|
|
// read permission. The header carries no `permission` because a user may hold one screen's and not
|
|
// another's; composeNav drops a header left with no visible children, so a user holding none of the
|
|
// three never sees the section. The host current-marks the active item — no `current`/`open` here.
|
|
export const ADMIN_NAV: NavNode = {
|
|
children: [
|
|
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "admin.nav.users", permission: permissionName("users", "read") },
|
|
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "admin.nav.groups", permission: permissionName("groups", "read") },
|
|
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "admin.nav.clients", permission: permissionName("oauth2-clients", "read") },
|
|
],
|
|
icon: "i-shield",
|
|
id: "admin",
|
|
label: "admin.nav.section", // a key in this plugin's catalog; the host translates nav labels
|
|
};
|
|
|
|
// The screen gate: a signed-in user holding this request's `<resource>:<action>`. Each route already
|
|
// declares the same permission, so the host enforces it before the handler runs; this is
|
|
// defence-in-depth and what a direct unit test relies on. Returns the (non-null) user for the
|
|
// handler to thread on. GuardError → /login or 403.
|
|
// `action` defaults to the method's, and is passed explicitly by a *write-intent GET* — a create form
|
|
// or a delete-confirm page, whose only purpose is to start a write. Those refuse a reader honestly
|
|
// instead of rendering a form whose submit would 403; the route table declares the same override, so
|
|
// the two still cannot disagree.
|
|
export function requirePermission(ctx: RequestContext, resource: AdminResource, action?: AdminAction): User {
|
|
const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept)
|
|
const permission = permissionName(resource, action ?? actionForMethod(ctx.req.method ?? "GET"));
|
|
if (!can(ctx, permission)) throw new GuardError(403, `${permission} required`);
|
|
return user;
|
|
}
|
|
|
|
// Read + CSRF-verify a mutation's form body once (double-submit via ctx.verifyCsrf); non-POST ⇒
|
|
// undefined. A POST without a valid token is refused (GuardError → 403).
|
|
export async function guardedForm(ctx: RequestContext): Promise<URLSearchParams | undefined> {
|
|
if ((ctx.req.method ?? "GET").toUpperCase() !== "POST") return undefined;
|
|
const form = await readFormBody(ctx.req);
|
|
if (!ctx.verifyCsrf(form.get(CSRF_FIELD))) throw new GuardError(403, "invalid CSRF token");
|
|
return form;
|
|
}
|
|
|
|
// A themed "not found" (bad id/name in the path) rendered in the admin shell — 404, never a 500.
|
|
export function notFound(ctx: RequestContext): RouteResult {
|
|
return { data: { chrome: ctx.chrome, message: ctx.t("admin.notFound.message"), title: ctx.t("admin.notFound.title") }, status: 404, view: "notice" };
|
|
}
|
|
|
|
// A capability the plugin needs isn't on ctx.system (Ory not wired). Login already requires these in
|
|
// a real deployment, so this is the honest 503 fallback for a misconfigured host, not a crash.
|
|
export function unavailable(ctx: RequestContext, what: string): RouteResult {
|
|
return { data: { chrome: ctx.chrome, message: ctx.t("admin.unavailable.message", { what }), title: ctx.t("admin.unavailable.title") }, status: 503, view: "notice" };
|
|
}
|
|
|
|
// Model for the shared destructive-confirm page (views/confirm.ejs). The view reads the shell fields
|
|
// (brand/csrf/theme/user/nav) from ctx.chrome; this carries only the page body + title/breadcrumbs.
|
|
export function buildConfirmModel(opts: {
|
|
breadcrumbs: { href?: string; label: string }[];
|
|
cancelHref: string;
|
|
confirmAction: string;
|
|
confirmLabel: string;
|
|
message: string;
|
|
title: string;
|
|
}) {
|
|
return {
|
|
breadcrumbs: opts.breadcrumbs,
|
|
cancelHref: opts.cancelHref,
|
|
confirm: { action: opts.confirmAction, label: opts.confirmLabel },
|
|
message: opts.message,
|
|
title: opts.title,
|
|
};
|
|
}
|