Address whole-project architecture + product reviews (todo §5): make readRoles transitive so group→role grants reach the JWT (matches the Roles 'Effective access' view + OPL model; per-login only), per the user's call; add a zero-JS server-rendered confirm step for delete user/group/role (views/admin/confirm.ejs + shared buildConfirmModel; the Delete control is now a GET link, the delete stays a CSRF-guarded POST); self-lockout guards — no self-delete/deactivate (Users), no self-revoke of the direct admin grant + no delete of the admin role (Roles), each → 400 + inline error (direct-grant paths incl. the seeded admin; group-only-admin lockout = robust last-effective-admin check deferred §9); extract the gate+CSRF preamble copied across the 3 admin handlers into admin-nav.ts requireAdmin/guardedForm; shellUser keeps the email (name = local part, full email beneath). Reviewers: architecture no Critical/High, product 2 Critical + 1 High (all fixed). Deferred (scoped): host route-table→§6, list/template dedup→§5 cleanup, success-flash/empty-states/dangling-refs→§5 polish/§8, safeUrl→§7, 413/https/§N-drift→§9. Tests-first (extended the 3 admin HTTP tests + login/shell-context units); typecheck + 244 units + 8 visual + auth-refresh E2E green; stability-reviewer APPROVE
This commit is contained in:
@@ -4,8 +4,13 @@
|
||||
// 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 } 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";
|
||||
@@ -40,3 +45,45 @@ export function adminNav(roles: string[], menu: MenuConfig, current: AdminScreen
|
||||
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", "/login");
|
||||
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 }),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user