§10 - one menu everywhere (buildPluginChrome) + shell on every page; instructional starter dashboard; Kratos-native email docs
Collapse the three nav builders into buildPluginChrome: chrome.bestHref does longest-prefix matching so deep admin routes mark their leaf. Delete adminNav; buildConfirmModel and the admin model builders take the resolved nav. The same role-filtered sidebar now renders signed in or out, collapsing to a burger on narrow screens. shell.ejs gains menu (default true; menu:false -> single-column .app-bare), docTitle (separate <title> from the topbar, so the body keeps the single <h1>), and hideSignIn (suppress the footer Sign-in on auth pages to avoid a login loop). auth/home/landing now render inside the shell. Dashboard is a replaceable instructional starter (definePlugin snippet, no mock data). Email stays delegated to Kratos: documented its built-in courier.template_override_path instead of adding web-side SMTP.
This commit is contained in:
+13
-8
@@ -5,13 +5,14 @@
|
||||
// PRG redirect (mirrors the Users "trigger recovery" one-time code). `handleAdminClients` is the
|
||||
// imperative shell app.ts dispatches to — gated admin-only, CSRF-guarded.
|
||||
|
||||
import { ADMIN_CLIENTS_BASE, adminNav, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
|
||||
import { ADMIN_CLIENTS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
|
||||
import { safeDecode } from "./admin-groups.ts";
|
||||
import type { FieldConfig } from "./admin-users.ts";
|
||||
import type { RequestContext, User } from "./context.ts";
|
||||
import { HydraError, type HydraAdmin, type OAuth2Client } from "./hydra-admin.ts";
|
||||
import { parseListQuery } from "./list-query.ts";
|
||||
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
|
||||
import type { NavNode } from "./nav.ts";
|
||||
import { paginate } from "./paginate.ts";
|
||||
import type { RouteResult } from "./plugin.ts";
|
||||
import { buildShellContext } from "./shell-context.ts";
|
||||
@@ -110,6 +111,7 @@ export function buildClientsListModel(opts: {
|
||||
clients: OAuth2Client[];
|
||||
csrfToken?: string;
|
||||
menu?: MenuConfig;
|
||||
nav?: NavNode[];
|
||||
url: URL | URLSearchParams | string;
|
||||
user?: User | null;
|
||||
}) {
|
||||
@@ -127,7 +129,7 @@ export function buildClientsListModel(opts: {
|
||||
|
||||
return {
|
||||
filterBar: listFilterBar(state),
|
||||
nav: adminNav(opts.user?.roles ?? [], menu, "clients"),
|
||||
nav: opts.nav ?? [],
|
||||
pagination: listPagination(state, page),
|
||||
shell: buildShellContext({
|
||||
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "Admin" }, { label: "OAuth2 clients" }],
|
||||
@@ -192,6 +194,7 @@ export function buildClientFormModel(opts: {
|
||||
csrfToken?: string;
|
||||
error?: string;
|
||||
menu?: MenuConfig;
|
||||
nav?: NavNode[];
|
||||
user?: User | null;
|
||||
values?: Partial<ClientInput>;
|
||||
}) {
|
||||
@@ -217,7 +220,7 @@ export function buildClientFormModel(opts: {
|
||||
scopeField,
|
||||
submitLabel: "Register client",
|
||||
},
|
||||
nav: adminNav(opts.user?.roles ?? [], menu, "clients"),
|
||||
nav: opts.nav ?? [],
|
||||
shell: buildShellContext({
|
||||
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { label: "Register" }],
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
@@ -233,6 +236,7 @@ export function buildClientDetailModel(opts: {
|
||||
created?: boolean; // just registered → success banner + the one-time secret (if any)
|
||||
csrfToken?: string;
|
||||
menu?: MenuConfig;
|
||||
nav?: NavNode[];
|
||||
secret?: string; // one-time client_secret (confidential clients), shown once right after create
|
||||
user?: User | null;
|
||||
}) {
|
||||
@@ -243,7 +247,7 @@ export function buildClientDetailModel(opts: {
|
||||
created: opts.created ?? false,
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
delete: { action: `${base}/delete` },
|
||||
nav: adminNav(opts.user?.roles ?? [], menu, "clients"),
|
||||
nav: opts.nav ?? [],
|
||||
secret: opts.secret,
|
||||
shell: buildShellContext({
|
||||
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { label: opts.client.name }],
|
||||
@@ -280,21 +284,22 @@ export async function handleAdminClients(ctx: RequestContext, csrfToken: string,
|
||||
|
||||
const user = requireAdmin(ctx); // signed-in admin only (else GuardError → /login or 403)
|
||||
const { hydra, menu, render } = deps;
|
||||
const nav = ctx.chrome.nav; // the one global menu, role-filtered + current-marked by the host
|
||||
const method = (ctx.req.method ?? "GET").toUpperCase();
|
||||
const seg = path.slice(ADMIN_CLIENTS_BASE.length).split("/").filter(Boolean);
|
||||
const form = await guardedForm(ctx, deps.csrfSecret); // parsed + CSRF-verified on POST, else undefined
|
||||
|
||||
const renderForm = async (extra: { error?: string; values?: Partial<ClientInput> }): Promise<RouteResult> =>
|
||||
({ html: await render("admin/client-form", { model: buildClientFormModel({ csrfToken, menu, user, ...extra }) }) });
|
||||
({ html: await render("admin/client-form", { model: buildClientFormModel({ csrfToken, menu, nav, user, ...extra }) }) });
|
||||
const renderDetail = async (client: OAuth2Client, extra: { created?: boolean; secret?: string } = {}): Promise<RouteResult> =>
|
||||
({ html: await render("admin/client-detail", { model: buildClientDetailModel({ client: toClientView(client), csrfToken, menu, user, ...extra }) }) });
|
||||
({ html: await render("admin/client-detail", { model: buildClientDetailModel({ client: toClientView(client), csrfToken, menu, nav, user, ...extra }) }) });
|
||||
const notFound = async (): Promise<RouteResult> => ({ html: await render("404", { title: "Not found" }), status: 404 });
|
||||
|
||||
// /admin/clients — list (GET) · register (POST)
|
||||
if (seg.length === 0) {
|
||||
if (method === "GET") {
|
||||
const { clients } = await hydra.listClients({ pageSize: LIST_FETCH_SIZE });
|
||||
return { html: await render("admin/clients", { model: buildClientsListModel({ clients, csrfToken, menu, url: ctx.url, user }) }) };
|
||||
return { html: await render("admin/clients", { model: buildClientsListModel({ clients, csrfToken, menu, nav, url: ctx.url, user }) }) };
|
||||
}
|
||||
if (method === "POST") {
|
||||
const input = readClientInput(form!);
|
||||
@@ -335,7 +340,7 @@ export async function handleAdminClients(ctx: RequestContext, csrfToken: string,
|
||||
return { html: await render("admin/confirm", { model: buildConfirmModel({
|
||||
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { href: base, label: name }, { label: "Delete" }],
|
||||
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete client", csrfToken,
|
||||
current: "clients", menu, message: `Delete client ${name}? Apps using it can no longer sign in through Plainpages.`, title: "Delete client", user,
|
||||
menu, message: `Delete client ${name}? Apps using it can no longer sign in through Plainpages.`, nav, title: "Delete client", user,
|
||||
}) }) };
|
||||
}
|
||||
if (seg.length === 2 && seg[1] === "delete" && method === "POST") {
|
||||
|
||||
+13
-8
@@ -6,13 +6,14 @@
|
||||
// is the imperative shell app.ts dispatches to — gated admin-only, CSRF-guarded, mapping each action
|
||||
// to a RouteResult.
|
||||
|
||||
import { ADMIN_GROUPS_BASE, adminNav, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
|
||||
import { ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
|
||||
import type { FieldConfig } from "./admin-users.ts";
|
||||
import type { RequestContext, User } from "./context.ts";
|
||||
import type { KetoClient, RelationQuery, RelationTuple, SubjectSet } from "./keto-client.ts";
|
||||
import type { KratosAdmin } from "./kratos-admin.ts";
|
||||
import { parseListQuery } from "./list-query.ts";
|
||||
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
|
||||
import type { NavNode } from "./nav.ts";
|
||||
import { paginate } from "./paginate.ts";
|
||||
import type { RouteResult } from "./plugin.ts";
|
||||
import { buildShellContext } from "./shell-context.ts";
|
||||
@@ -120,6 +121,7 @@ export function buildGroupsListModel(opts: {
|
||||
csrfToken?: string;
|
||||
groups: GroupView[];
|
||||
menu?: MenuConfig;
|
||||
nav?: NavNode[];
|
||||
url: URL | URLSearchParams | string;
|
||||
user?: User | null;
|
||||
}) {
|
||||
@@ -147,7 +149,7 @@ export function buildGroupsListModel(opts: {
|
||||
|
||||
return {
|
||||
filterBar: listFilterBar(state),
|
||||
nav: adminNav(opts.user?.roles ?? [], menu, "groups"),
|
||||
nav: opts.nav ?? [],
|
||||
pagination: listPagination(state, page),
|
||||
shell: buildShellContext({
|
||||
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Admin" }, { label: "Groups" }],
|
||||
@@ -214,6 +216,7 @@ export function buildGroupFormModel(opts: {
|
||||
error?: string;
|
||||
memberOptions: MemberOption[];
|
||||
menu?: MenuConfig;
|
||||
nav?: NavNode[];
|
||||
user?: User | null;
|
||||
values?: { member?: string; name?: string };
|
||||
}) {
|
||||
@@ -233,7 +236,7 @@ export function buildGroupFormModel(opts: {
|
||||
selectedMember: opts.values?.member ?? "",
|
||||
submitLabel: "Create group",
|
||||
},
|
||||
nav: adminNav(opts.user?.roles ?? [], menu, "groups"),
|
||||
nav: opts.nav ?? [],
|
||||
shell: buildShellContext({
|
||||
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { label: "New" }],
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
@@ -251,6 +254,7 @@ export function buildGroupDetailModel(opts: {
|
||||
group: { name: string };
|
||||
members: MemberView[];
|
||||
menu?: MenuConfig;
|
||||
nav?: NavNode[];
|
||||
user?: User | null;
|
||||
}) {
|
||||
const menu = opts.menu ?? DEFAULT_MENU;
|
||||
@@ -266,7 +270,7 @@ export function buildGroupDetailModel(opts: {
|
||||
error: opts.error,
|
||||
group: { name },
|
||||
members: { action: `${base}/members/delete`, rows: opts.members },
|
||||
nav: adminNav(opts.user?.roles ?? [], menu, "groups"),
|
||||
nav: opts.nav ?? [],
|
||||
shell: buildShellContext({
|
||||
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { label: name }],
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
@@ -332,22 +336,23 @@ export async function handleAdminGroups(ctx: RequestContext, csrfToken: string,
|
||||
|
||||
const user = requireAdmin(ctx); // signed-in admin only (else GuardError → /login or 403)
|
||||
const { keto, kratosAdmin, menu, render } = deps;
|
||||
const nav = ctx.chrome.nav; // the one global menu, role-filtered + current-marked by the host
|
||||
const method = (ctx.req.method ?? "GET").toUpperCase();
|
||||
const seg = path.slice(ADMIN_GROUPS_BASE.length).split("/").filter(Boolean);
|
||||
const form = await guardedForm(ctx, deps.csrfSecret); // parsed + CSRF-verified on POST, else undefined
|
||||
|
||||
const renderList = async (): Promise<RouteResult> => {
|
||||
const groups = groupsFromTuples(await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS }));
|
||||
return { html: await render("admin/groups", { model: buildGroupsListModel({ csrfToken, groups, menu, url: ctx.url, user }) }) };
|
||||
return { html: await render("admin/groups", { model: buildGroupsListModel({ csrfToken, groups, menu, nav, url: ctx.url, user }) }) };
|
||||
};
|
||||
const renderForm = async (extra: { error?: string; values?: { member?: string; name?: string } }): Promise<RouteResult> => {
|
||||
const { options } = await memberCandidates(keto, kratosAdmin);
|
||||
return { html: await render("admin/group-form", { model: buildGroupFormModel({ csrfToken, memberOptions: options, menu, user, ...extra }) }) };
|
||||
return { html: await render("admin/group-form", { model: buildGroupFormModel({ csrfToken, memberOptions: options, menu, nav, user, ...extra }) }) };
|
||||
};
|
||||
const renderDetail = async (name: string): Promise<RouteResult> => {
|
||||
const { emailById, options } = await memberCandidates(keto, kratosAdmin);
|
||||
const members = (await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS })).map((t) => memberView(t, emailById));
|
||||
return { html: await render("admin/group-detail", { model: buildGroupDetailModel({ candidates: options, csrfToken, group: { name }, members, menu, user }) }) };
|
||||
return { html: await render("admin/group-detail", { model: buildGroupDetailModel({ candidates: options, csrfToken, group: { name }, members, menu, nav, user }) }) };
|
||||
};
|
||||
|
||||
// /admin/groups — list (GET) · create (POST)
|
||||
@@ -388,7 +393,7 @@ export async function handleAdminGroups(ctx: RequestContext, csrfToken: string,
|
||||
return { html: await render("admin/confirm", { model: buildConfirmModel({
|
||||
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { href: base, label: name }, { label: "Delete" }],
|
||||
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete group", csrfToken,
|
||||
current: "groups", menu, message: `Delete group ${name}? This removes the group and all its memberships.`, title: "Delete group", user,
|
||||
menu, message: `Delete group ${name}? This removes the group and all its memberships.`, nav, title: "Delete group", user,
|
||||
}) }) };
|
||||
}
|
||||
if (seg.length === 2 && seg[1] === "delete" && method === "POST") {
|
||||
|
||||
+7
-13
@@ -6,7 +6,7 @@ import { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { Socket } from "node:net";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
ADMIN_PERMISSION, ADMIN_USERS_BASE, adminNav, adminSection, buildConfirmModel, guardedForm, requireAdmin,
|
||||
ADMIN_PERMISSION, ADMIN_USERS_BASE, adminSection, buildConfirmModel, guardedForm, requireAdmin,
|
||||
} from "./admin-nav.ts";
|
||||
import { buildContext, type RequestContext, type User } from "./context.ts";
|
||||
import { CSRF_COOKIE, CSRF_FIELD, issueCsrfToken } from "./csrf.ts";
|
||||
@@ -45,15 +45,8 @@ test("adminSection: gated Admin header over the four screens; current marks the
|
||||
assert.equal(onRoles.children?.find((c) => c.id === "users")?.current, undefined);
|
||||
});
|
||||
|
||||
test("adminNav: prepends Dashboard and role-filters the section (admin sees it, others get only Dashboard)", () => {
|
||||
const forAdmin = adminNav(admin.roles, DEFAULT_MENU, "users");
|
||||
assert.deepEqual(labels(forAdmin), ["Dashboard", "Admin"]);
|
||||
// composeNav strips `id` from rendered nodes but keeps `current`/`href`, so match the active item by href.
|
||||
assert.equal(forAdmin.find((n) => n.label === "Admin")!.children?.find((c) => c.href === ADMIN_USERS_BASE)?.current, true);
|
||||
|
||||
assert.deepEqual(labels(adminNav(member.roles, DEFAULT_MENU, "users")), ["Dashboard"]); // non-admin → gated section dropped
|
||||
assert.deepEqual(labels(adminNav([], DEFAULT_MENU, "users")), ["Dashboard"]); // anonymous too
|
||||
});
|
||||
// (The in-screen admin sidebar is gone in §10 — every page renders the one global menu, built by
|
||||
// buildPluginChrome; see chrome.test.ts. adminSection above is that menu's gated Admin fragment.)
|
||||
|
||||
// ---- auth gates ----
|
||||
|
||||
@@ -82,15 +75,16 @@ test("guardedForm: valid double-submit → the parsed body, bad/missing token
|
||||
|
||||
// ---- confirm-page model ----
|
||||
|
||||
test("buildConfirmModel wires the danger action, message, role-filtered nav and shell", () => {
|
||||
test("buildConfirmModel wires the danger action, message, passed-in nav and shell", () => {
|
||||
const nav = [{ label: "Dashboard" }, { label: "Admin" }];
|
||||
const model = buildConfirmModel({
|
||||
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { label: "Delete" }],
|
||||
cancelHref: ADMIN_USERS_BASE, confirmAction: `${ADMIN_USERS_BASE}/u1/delete`, confirmLabel: "Delete user",
|
||||
csrfToken: "tok", current: "users", menu: DEFAULT_MENU, message: "Delete ada@x.io?", title: "Delete user", user: admin,
|
||||
csrfToken: "tok", menu: DEFAULT_MENU, message: "Delete ada@x.io?", nav, title: "Delete user", user: admin,
|
||||
});
|
||||
assert.deepEqual(model.confirm, { action: `${ADMIN_USERS_BASE}/u1/delete`, label: "Delete user" });
|
||||
assert.equal(model.message, "Delete ada@x.io?");
|
||||
assert.equal(model.cancelHref, ADMIN_USERS_BASE);
|
||||
assert.ok(labels(model.nav).includes("Admin")); // admin user ⇒ section present in the in-screen sidebar
|
||||
assert.equal(model.nav, nav); // the host's one global menu, passed through verbatim
|
||||
assert.equal(model.shell.title, "Delete user");
|
||||
});
|
||||
|
||||
+13
-17
@@ -1,15 +1,15 @@
|
||||
// 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`).
|
||||
// The built-in admin section of the menu (todo §5). `adminSection()` is the one definition of the
|
||||
// permission-gated "Admin" header (Users/Groups/Roles/clients) + its gate, composed into the single
|
||||
// global menu (`buildPluginChrome`, §10) — composeNav drops the whole header + subtree for a
|
||||
// non-admin. Every page (dashboard, admin, plugin, auth) renders that one menu, so there's no
|
||||
// separate admin sidebar to drift.
|
||||
|
||||
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 type { NavNode } from "./nav.ts";
|
||||
import { buildShellContext } from "./shell-context.ts";
|
||||
|
||||
export const ADMIN_PERMISSION = "admin"; // role token gating the admin section
|
||||
@@ -20,9 +20,9 @@ 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).
|
||||
// The "Dashboard" link to the gated app home (/dashboard), composed into the global menu by
|
||||
// buildPluginChrome (chrome.ts). 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 }[] = [
|
||||
@@ -45,11 +45,6 @@ export function adminSection(current?: AdminScreen): NavNode {
|
||||
};
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -70,16 +65,17 @@ export async function guardedForm(ctx: RequestContext, csrfSecret: string): Prom
|
||||
}
|
||||
|
||||
// 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.
|
||||
// danger action behind a deliberate second step, plus a cancel link. Reused by all admin screens.
|
||||
// `nav` is the unified global menu (ctx.chrome.nav), passed in by the handler.
|
||||
export function buildConfirmModel(opts: {
|
||||
breadcrumbs: { href?: string; label: string }[];
|
||||
cancelHref: string;
|
||||
confirmAction: string;
|
||||
confirmLabel: string;
|
||||
csrfToken: string;
|
||||
current: AdminScreen;
|
||||
menu: MenuConfig;
|
||||
message: string;
|
||||
nav: NavNode[];
|
||||
title: string;
|
||||
user: User | null;
|
||||
}) {
|
||||
@@ -87,7 +83,7 @@ export function buildConfirmModel(opts: {
|
||||
cancelHref: opts.cancelHref,
|
||||
confirm: { action: opts.confirmAction, label: opts.confirmLabel },
|
||||
message: opts.message,
|
||||
nav: adminNav(opts.user?.roles ?? [], opts.menu, opts.current),
|
||||
nav: opts.nav,
|
||||
shell: buildShellContext({ breadcrumbs: opts.breadcrumbs, csrfToken: opts.csrfToken, menu: opts.menu, title: opts.title, user: opts.user }),
|
||||
};
|
||||
}
|
||||
|
||||
+13
-8
@@ -8,7 +8,7 @@
|
||||
// Kratos is read only to label members. `handleAdminRoles` is the imperative shell app.ts dispatches
|
||||
// to — gated admin-only, CSRF-guarded.
|
||||
|
||||
import { ADMIN_PERMISSION, ADMIN_ROLES_BASE, adminNav, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
|
||||
import { ADMIN_PERMISSION, ADMIN_ROLES_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
|
||||
import {
|
||||
type GroupView,
|
||||
groupsFromTuples,
|
||||
@@ -27,6 +27,7 @@ import type { ExpandTree, KetoClient, RelationTuple } from "./keto-client.ts";
|
||||
import type { KratosAdmin } from "./kratos-admin.ts";
|
||||
import { parseListQuery } from "./list-query.ts";
|
||||
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
|
||||
import type { NavNode } from "./nav.ts";
|
||||
import { paginate } from "./paginate.ts";
|
||||
import type { RouteResult } from "./plugin.ts";
|
||||
import { buildShellContext } from "./shell-context.ts";
|
||||
@@ -105,6 +106,7 @@ function listHref(state: ListState, overrides: Partial<ListState> = {}): string
|
||||
export function buildRolesListModel(opts: {
|
||||
csrfToken?: string;
|
||||
menu?: MenuConfig;
|
||||
nav?: NavNode[];
|
||||
roles: RoleView[];
|
||||
url: URL | URLSearchParams | string;
|
||||
user?: User | null;
|
||||
@@ -133,7 +135,7 @@ export function buildRolesListModel(opts: {
|
||||
|
||||
return {
|
||||
filterBar: listFilterBar(state),
|
||||
nav: adminNav(opts.user?.roles ?? [], menu, "roles"),
|
||||
nav: opts.nav ?? [],
|
||||
pagination: listPagination(state, page),
|
||||
shell: buildShellContext({
|
||||
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Admin" }, { label: "Roles" }],
|
||||
@@ -200,6 +202,7 @@ export function buildRoleFormModel(opts: {
|
||||
error?: string;
|
||||
memberOptions: MemberOption[];
|
||||
menu?: MenuConfig;
|
||||
nav?: NavNode[];
|
||||
user?: User | null;
|
||||
values?: { member?: string; name?: string };
|
||||
}) {
|
||||
@@ -219,7 +222,7 @@ export function buildRoleFormModel(opts: {
|
||||
selectedMember: opts.values?.member ?? "",
|
||||
submitLabel: "Create role",
|
||||
},
|
||||
nav: adminNav(opts.user?.roles ?? [], menu, "roles"),
|
||||
nav: opts.nav ?? [],
|
||||
shell: buildShellContext({
|
||||
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { label: "New" }],
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
@@ -237,6 +240,7 @@ export function buildRoleDetailModel(opts: {
|
||||
error?: string;
|
||||
members: MemberView[];
|
||||
menu?: MenuConfig;
|
||||
nav?: NavNode[];
|
||||
role: { name: string };
|
||||
user?: User | null;
|
||||
}) {
|
||||
@@ -252,7 +256,7 @@ export function buildRoleDetailModel(opts: {
|
||||
effective: opts.effective,
|
||||
error: opts.error,
|
||||
members: { action: `${base}/members/delete`, rows: opts.members },
|
||||
nav: adminNav(opts.user?.roles ?? [], menu, "roles"),
|
||||
nav: opts.nav ?? [],
|
||||
role: { name },
|
||||
shell: buildShellContext({
|
||||
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { label: name }],
|
||||
@@ -304,24 +308,25 @@ export async function handleAdminRoles(ctx: RequestContext, csrfToken: string, d
|
||||
|
||||
const user = requireAdmin(ctx); // signed-in admin only (else GuardError → /login or 403)
|
||||
const { keto, kratosAdmin, menu, render } = deps;
|
||||
const nav = ctx.chrome.nav; // the one global menu, role-filtered + current-marked by the host
|
||||
const method = (ctx.req.method ?? "GET").toUpperCase();
|
||||
const seg = path.slice(ADMIN_ROLES_BASE.length).split("/").filter(Boolean);
|
||||
const form = await guardedForm(ctx, deps.csrfSecret); // parsed + CSRF-verified on POST, else undefined
|
||||
|
||||
const renderList = async (): Promise<RouteResult> => {
|
||||
const roles = rolesFromTuples(await pagedTuples(keto, { namespace: ROLE_NS, relation: MEMBERS }));
|
||||
return { html: await render("admin/roles", { model: buildRolesListModel({ csrfToken, menu, roles, url: ctx.url, user }) }) };
|
||||
return { html: await render("admin/roles", { model: buildRolesListModel({ csrfToken, menu, nav, roles, url: ctx.url, user }) }) };
|
||||
};
|
||||
const renderForm = async (extra: { error?: string; values?: { member?: string; name?: string } }): Promise<RouteResult> => {
|
||||
const { options } = await memberCandidates(keto, kratosAdmin);
|
||||
return { html: await render("admin/role-form", { model: buildRoleFormModel({ csrfToken, memberOptions: options, menu, user, ...extra }) }) };
|
||||
return { html: await render("admin/role-form", { model: buildRoleFormModel({ csrfToken, memberOptions: options, menu, nav, user, ...extra }) }) };
|
||||
};
|
||||
const renderDetail = async (name: string, error?: string): Promise<RouteResult> => {
|
||||
const { emailById, options } = await memberCandidates(keto, kratosAdmin);
|
||||
const tuples = await pagedTuples(keto, { namespace: ROLE_NS, object: name, relation: MEMBERS });
|
||||
const members = tuples.map((t) => memberView(t, emailById));
|
||||
const effective = await effectiveUsers(keto, name, tuples.length > 0, emailById);
|
||||
const html = await render("admin/role-detail", { model: buildRoleDetailModel({ candidates: options, csrfToken, effective, members, menu, role: { name }, user, ...(error ? { error } : {}) }) });
|
||||
const html = await render("admin/role-detail", { model: buildRoleDetailModel({ candidates: options, csrfToken, effective, members, menu, nav, role: { name }, user, ...(error ? { error } : {}) }) });
|
||||
return error ? { html, status: 400 } : { html };
|
||||
};
|
||||
|
||||
@@ -367,7 +372,7 @@ export async function handleAdminRoles(ctx: RequestContext, csrfToken: string, d
|
||||
return { html: await render("admin/confirm", { model: buildConfirmModel({
|
||||
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { href: base, label: name }, { label: "Delete" }],
|
||||
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete role", csrfToken,
|
||||
current: "roles", menu, message: `Delete role ${name}? This revokes it from everyone it's assigned to.`, title: "Delete role", user,
|
||||
menu, message: `Delete role ${name}? This revokes it from everyone it's assigned to.`, nav, title: "Delete role", user,
|
||||
}) }) };
|
||||
}
|
||||
if (seg.length === 2 && seg[1] === "delete" && method === "POST") {
|
||||
|
||||
+10
-6
@@ -5,12 +5,13 @@
|
||||
// CSRF-guarded, each action mapped to a RouteResult (render, or redirect after a write — PRG).
|
||||
|
||||
import { safeDecode } from "./admin-groups.ts";
|
||||
import { ADMIN_USERS_BASE, adminNav, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
|
||||
import { ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
|
||||
import type { RequestContext, User } from "./context.ts";
|
||||
import type { Identity, KratosAdmin, RecoveryCode } from "./kratos-admin.ts";
|
||||
import { KratosError } from "./kratos-public.ts";
|
||||
import { parseListQuery } from "./list-query.ts";
|
||||
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
|
||||
import type { NavNode } from "./nav.ts";
|
||||
import { paginate } from "./paginate.ts";
|
||||
import type { RouteResult } from "./plugin.ts";
|
||||
import { buildShellContext } from "./shell-context.ts";
|
||||
@@ -118,6 +119,7 @@ export function buildUsersListModel(opts: {
|
||||
csrfToken?: string;
|
||||
identities: Identity[];
|
||||
menu?: MenuConfig;
|
||||
nav?: NavNode[]; // the unified global menu (ctx.chrome.nav); the host builds it once per request
|
||||
url: URL | URLSearchParams | string;
|
||||
user?: User | null;
|
||||
}) {
|
||||
@@ -145,7 +147,7 @@ export function buildUsersListModel(opts: {
|
||||
|
||||
return {
|
||||
filterBar: listFilterBar(state, all.length),
|
||||
nav: adminNav(opts.user?.roles ?? [], menu, "users"),
|
||||
nav: opts.nav ?? [],
|
||||
pagination: listPagination(state, page),
|
||||
shell: buildShellContext({
|
||||
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Admin" }, { label: "Users" }],
|
||||
@@ -237,6 +239,7 @@ export function buildUserFormModel(opts: {
|
||||
error?: string;
|
||||
identity?: Identity | null;
|
||||
menu?: MenuConfig;
|
||||
nav?: NavNode[];
|
||||
recovery?: RecoveryCode;
|
||||
user?: User | null;
|
||||
values?: Partial<UserInput>;
|
||||
@@ -267,7 +270,7 @@ export function buildUserFormModel(opts: {
|
||||
} : undefined,
|
||||
error: opts.error,
|
||||
form: { action: idPath, cancelHref: ADMIN_USERS_BASE, csrfToken: opts.csrfToken ?? "", fields, submitLabel: editing ? "Save changes" : "Create user" },
|
||||
nav: adminNav(opts.user?.roles ?? [], menu, "users"),
|
||||
nav: opts.nav ?? [],
|
||||
recovery: opts.recovery,
|
||||
shell: buildShellContext({
|
||||
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { label: editing ? "Edit" : "New" }],
|
||||
@@ -306,16 +309,17 @@ export async function handleAdminUsers(ctx: RequestContext, csrfToken: string, d
|
||||
|
||||
const user = requireAdmin(ctx); // signed-in admin only (else GuardError → /login or 403)
|
||||
const { kratosAdmin, menu, render } = deps;
|
||||
const nav = ctx.chrome.nav; // the one global menu, role-filtered + current-marked by the host
|
||||
const method = (ctx.req.method ?? "GET").toUpperCase();
|
||||
const seg = path.slice(ADMIN_USERS_BASE.length).split("/").filter(Boolean);
|
||||
const form = await guardedForm(ctx, deps.csrfSecret); // parsed + CSRF-verified on POST, else undefined
|
||||
|
||||
const renderList = async (): Promise<RouteResult> => {
|
||||
const { identities } = await kratosAdmin.listIdentities({ pageSize: LIST_FETCH_SIZE });
|
||||
return { html: await render("admin/users", { model: buildUsersListModel({ csrfToken, identities, menu, url: ctx.url, user }) }) };
|
||||
return { html: await render("admin/users", { model: buildUsersListModel({ csrfToken, identities, menu, nav, url: ctx.url, user }) }) };
|
||||
};
|
||||
const renderForm = async (extra: Parameters<typeof buildUserFormModel>[0]): Promise<RouteResult> =>
|
||||
({ html: await render("admin/user-form", { model: buildUserFormModel({ csrfToken, menu, user, ...extra }) }) });
|
||||
({ html: await render("admin/user-form", { model: buildUserFormModel({ csrfToken, menu, nav, user, ...extra }) }) });
|
||||
|
||||
// /admin/users — list (GET) · create (POST)
|
||||
if (seg.length === 0) {
|
||||
@@ -366,7 +370,7 @@ export async function handleAdminUsers(ctx: RequestContext, csrfToken: string, d
|
||||
return { html: await render("admin/confirm", { model: buildConfirmModel({
|
||||
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { href: back, label: view.name }, { label: "Delete" }],
|
||||
cancelHref: back, confirmAction: `${back}/delete`, confirmLabel: "Delete user", csrfToken,
|
||||
current: "users", menu, message: `Delete ${view.email}? This permanently removes the account and can't be undone.`, title: "Delete user", user,
|
||||
menu, message: `Delete ${view.email}? This permanently removes the account and can't be undone.`, nav, title: "Delete user", user,
|
||||
}) }) };
|
||||
}
|
||||
if (method === "POST") {
|
||||
|
||||
+11
-13
@@ -48,20 +48,20 @@ before(async () => {
|
||||
|
||||
after(() => server.close());
|
||||
|
||||
test("the dashboard at /dashboard: the app-shell People list, gated to a session, filterable via the URL", async () => {
|
||||
test("the dashboard at /dashboard: the instructional starter in the unified shell, gated to a session", async () => {
|
||||
// The dashboard is gated to a signed-in user (§10), so present a session.
|
||||
const res = await fetch(base + "/dashboard", { headers: { cookie: session() } });
|
||||
assert.equal(res.status, 200);
|
||||
assert.match(res.headers.get("content-type") ?? "", /text\/html/);
|
||||
const html = await res.text();
|
||||
// Shell + building blocks composed around the mock data.
|
||||
// The unified app shell (§10): the same sidebar/menu every page renders.
|
||||
assert.match(html, /Plainpages/); // sidebar brand
|
||||
assert.match(html, /<aside class="sidebar"/);
|
||||
assert.match(html, /<form class="filters"/);
|
||||
assert.match(html, /<table class="table"/);
|
||||
assert.match(html, /<footer class="pager"/);
|
||||
assert.match(html, /Avery Kline/); // a mock person on page 1
|
||||
assert.match(html, /Starter dashboard/); // the default flags itself a demo to replace with a `dashboard` plugin (§10)
|
||||
// The default is a short instructional starter, not a mock-data list.
|
||||
assert.match(html, /Starter dashboard/);
|
||||
assert.match(html, /export default definePlugin/); // shows how to replace it from a plugin
|
||||
assert.doesNotMatch(html, /<form class="filters"/); // the old mock People list is gone
|
||||
assert.doesNotMatch(html, /Avery Kline/);
|
||||
|
||||
// The Sign-out POST form carries a CSRF token matching the Set-Cookie issued for the page (§4).
|
||||
const csrfCookie = (res.headers.get("set-cookie") ?? "").match(/plainpages_csrf=([^;]+)/)?.[1];
|
||||
@@ -69,19 +69,17 @@ test("the dashboard at /dashboard: the app-shell People list, gated to a session
|
||||
assert.match(res.headers.get("set-cookie") ?? "", /plainpages_csrf=[^;]+;.*HttpOnly/);
|
||||
assert.match(html, /<form class="menu-item-form" method="post" action="\/logout">/);
|
||||
assert.match(html, new RegExp(`name="_csrf" value="${csrfCookie!.replace(/[.]/g, "\\.")}"`));
|
||||
|
||||
// A search query filters server-side: a no-match query drops every row.
|
||||
const empty = await fetch(base + "/dashboard?q=zzz-no-such-person", { headers: { cookie: session() } });
|
||||
assert.doesNotMatch(await empty.text(), /Avery Kline/);
|
||||
});
|
||||
|
||||
test("/ is the public landing (§10): anonymous → 200 with intro + sign-in/register links, no gate", async () => {
|
||||
test("/ is the public landing (§10): anonymous → 200 with intro + sign-in/register links, in the unified shell", async () => {
|
||||
const res = await fetch(base + "/", { redirect: "manual" });
|
||||
assert.equal(res.status, 200); // public — no redirect to sign in
|
||||
const html = await res.text();
|
||||
assert.match(html, /href="\/login"/); // a prominent path to sign in
|
||||
assert.match(html, /href="\/registration"/); // and to register
|
||||
assert.doesNotMatch(html, /<aside class="sidebar"/); // standalone page, not the signed-in app shell
|
||||
// §10: the same app shell every page renders — the menu shows even when signed out (role-filtered).
|
||||
assert.match(html, /<aside class="sidebar"/);
|
||||
assert.match(html, /class="landing-title"/); // the landing hero owns the page's single <h1>
|
||||
});
|
||||
|
||||
test("/dashboard is gated (§10): an anonymous visitor is bounced to sign in (return_to kept)", async () => {
|
||||
|
||||
+10
-5
@@ -324,7 +324,10 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
}
|
||||
throw err; // any other Kratos 4xx → the catch-all (genuinely unexpected)
|
||||
}
|
||||
sendHtml(res, 200, await render("auth", { brand: menu.branding.name, flow: buildFlowView(flow, flowType) }));
|
||||
// Rendered inside the unified app shell (§10), so set a fresh CSRF cookie when minted — the
|
||||
// shell's Sign-out form (shown on /settings, where the user is signed in) needs the token.
|
||||
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
|
||||
sendHtml(res, 200, await render("auth", { chrome: ctx.chrome, flow: buildFlowView(flow, flowType) }));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -484,8 +487,10 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
await sendResult(res, result, (view, data) => renderView(homePlugin.id, view, data));
|
||||
return;
|
||||
}
|
||||
// Default landing — no form, so no CSRF cookie. `user` lets it show "go to dashboard" vs sign in.
|
||||
sendHtml(res, 200, await render("home", { brand: menu.branding.name, user }));
|
||||
// Default landing in the unified app shell (§10): `user` picks "go to dashboard" vs sign-in,
|
||||
// and the shell's Sign-out form (when signed in) needs a fresh CSRF cookie.
|
||||
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
|
||||
sendHtml(res, 200, await render("home", { chrome: ctx.chrome, user }));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -504,8 +509,8 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
await sendResult(res, result, (view, data) => renderView(dashboardPlugin.id, view, data));
|
||||
return;
|
||||
}
|
||||
// Roles from the verified JWT; branding/override come from config/menu.ts.
|
||||
sendHtml(res, 200, await render("index", { model: buildDashboardModel(ctx.url, ctx.roles, menu, csrf.token, user, plugins) }));
|
||||
// The one global menu (ctx.chrome.nav) + branding/override from config/menu.ts.
|
||||
sendHtml(res, 200, await render("index", { model: buildDashboardModel({ csrfToken: csrf.token, menu, nav: ctx.chrome.nav, user }) }));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+8
-3
@@ -45,9 +45,14 @@ 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", () => {
|
||||
const chrome = buildPluginChrome({ menu: DEFAULT_MENU, user: { email: "a@b.c", id: "u1", roles: ["admin"] } });
|
||||
assert.ok(labels(chrome.nav).includes("Admin"));
|
||||
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"] } });
|
||||
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.
|
||||
assert.equal(admin.children!.find((c) => c.label === "Users")!.current, true);
|
||||
assert.equal(admin.children!.find((c) => c.label === "Groups")!.current, undefined);
|
||||
});
|
||||
|
||||
test("branding logo + default theme flow through when set", () => {
|
||||
|
||||
+27
-5
@@ -37,7 +37,12 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
||||
|
||||
const roles = opts.user?.roles ?? [];
|
||||
const nav = composeNav(fragments, opts.menu.override, roles);
|
||||
if (opts.currentPath) markCurrent(nav, opts.currentPath);
|
||||
if (opts.currentPath) {
|
||||
// Mark by the *best* (longest) href that is the path or a parent of it, so a sub-path like
|
||||
// /admin/users/new marks the Users base leaf (/admin/users) and the dashboard marks Dashboard.
|
||||
const target = bestHref(nav, opts.currentPath);
|
||||
if (target) markCurrent(nav, target);
|
||||
}
|
||||
|
||||
const b = opts.menu.branding;
|
||||
return {
|
||||
@@ -51,14 +56,31 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
||||
};
|
||||
}
|
||||
|
||||
// Mark the leaf whose href equals `path` as current and open every ancestor header so the active
|
||||
// The href of the leaf that owns `path`: an exact match, else the longest href that is a parent of
|
||||
// it (href + "/" prefixes path), so /admin/users/123 resolves to the /admin/users leaf. "/" never
|
||||
// counts as a parent (it would own everything). Returns undefined when nothing matches.
|
||||
function bestHref(nodes: NavNode[], path: string): string | undefined {
|
||||
let best: string | undefined;
|
||||
const visit = (ns: NavNode[]): void => {
|
||||
for (const n of ns) {
|
||||
if (n.href != null && (n.href === path || (n.href !== "/" && path.startsWith(`${n.href}/`)))) {
|
||||
if (best === undefined || n.href.length > best.length) best = n.href;
|
||||
}
|
||||
if (n.children) visit(n.children);
|
||||
}
|
||||
};
|
||||
visit(nodes);
|
||||
return best;
|
||||
}
|
||||
|
||||
// Mark the leaf whose href equals `target` as current and open every ancestor header so the active
|
||||
// page is revealed. Mutates the freshly-composed nodes (composeNav returns new objects each call).
|
||||
// Returns whether this subtree contains the current node.
|
||||
function markCurrent(nodes: NavNode[], path: string): boolean {
|
||||
function markCurrent(nodes: NavNode[], target: string): boolean {
|
||||
let hit = false;
|
||||
for (const node of nodes) {
|
||||
const here = node.href === path;
|
||||
const inChild = node.children ? markCurrent(node.children, path) : false;
|
||||
const here = node.href === target;
|
||||
const inChild = node.children ? markCurrent(node.children, target) : false;
|
||||
if (here) node.current = true;
|
||||
if (here || inChild) {
|
||||
if (node.children) node.open = true;
|
||||
|
||||
+19
-105
@@ -1,114 +1,28 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { buildDashboardModel } from "./dashboard.ts";
|
||||
import type { NavNode } from "./nav.ts";
|
||||
|
||||
// Pull the first data-table column ("Name") and the rendered row count for terse assertions.
|
||||
const rowCount = (m: ReturnType<typeof buildDashboardModel>): number => m.table.rows.length;
|
||||
const nameOf = (m: ReturnType<typeof buildDashboardModel>, i: number): string =>
|
||||
// first cell is the user cell { user: { name } }
|
||||
((m.table.rows[i] as { cells: unknown[] }).cells[0] as { user: { name: string } }).user.name;
|
||||
const col0 = (m: ReturnType<typeof buildDashboardModel>) =>
|
||||
m.table.columns[0] as { href?: string; label: string; sort?: string };
|
||||
// The default /dashboard is an instructional starter (todo §10): no mock data, just the unified
|
||||
// menu + shell. The host passes the one global menu (ctx.chrome.nav); the model passes it through.
|
||||
const NAV: NavNode[] = [{ href: "/dashboard", label: "Dashboard" }, { children: [{ href: "/admin/users", label: "Users" }], label: "Admin" }];
|
||||
|
||||
test("dashboard default: page 1, mock data, nav + shell wired", () => {
|
||||
const m = buildDashboardModel(new URL("http://x/"));
|
||||
|
||||
assert.equal(m.shell.title, "People");
|
||||
assert.equal(m.shell.csrfToken, ""); // default empty; app.ts passes the per-request token
|
||||
assert.equal(buildDashboardModel(new URL("http://x/"), [], undefined, "tok.sig").shell.csrfToken, "tok.sig");
|
||||
assert.ok(m.nav.length > 0); // composeNav produced a tree
|
||||
assert.equal(col0(m).label, "Name");
|
||||
assert.equal(m.pagination.summary.total, 30); // full mock dataset
|
||||
assert.equal(m.pagination.summary.from, 1);
|
||||
assert.equal(rowCount(m), 12); // default page size
|
||||
assert.equal(m.pagination.prev.href, undefined); // first page → prev disabled
|
||||
assert.ok(m.pagination.next.href); // more pages → next enabled
|
||||
test("dashboard model: titled shell, passes the unified nav + csrf + user through", () => {
|
||||
const m = buildDashboardModel({ csrfToken: "tok.sig", nav: NAV, user: { email: "ada@x.io", id: "u1", roles: ["admin"] } });
|
||||
assert.equal(m.shell.title, "Dashboard");
|
||||
assert.equal(m.shell.csrfToken, "tok.sig");
|
||||
assert.equal(m.shell.user.name, "ada"); // real signed-in identity, not a demo profile
|
||||
assert.deepEqual(m.nav, NAV); // the host's menu is used verbatim — the dashboard builds no nav of its own
|
||||
});
|
||||
|
||||
test("dashboard search filters rows, shrinks the total and shows a pill", () => {
|
||||
const first = nameOf(buildDashboardModel(new URL("http://x/")), 0); // e.g. "Avery Kline"
|
||||
const m = buildDashboardModel(new URL(`http://x/?q=${encodeURIComponent(first)}`));
|
||||
test("dashboard model: sensible defaults (empty nav, no token, Guest) and branding from menu", () => {
|
||||
const m = buildDashboardModel();
|
||||
assert.deepEqual(m.nav, []);
|
||||
assert.equal(m.shell.csrfToken, "");
|
||||
assert.equal(m.shell.user.name, "Guest");
|
||||
assert.equal(m.shell.brand.name, "Plainpages");
|
||||
|
||||
assert.equal(m.pagination.summary.total, 1);
|
||||
assert.equal(rowCount(m), 1);
|
||||
assert.equal(nameOf(m, 0), first);
|
||||
assert.deepEqual(m.filterBar.pills.map((p) => p.label), ["Search"]);
|
||||
|
||||
// A no-match query yields an empty table, not an error.
|
||||
const none = buildDashboardModel(new URL("http://x/?q=zzz-no-such-person"));
|
||||
assert.equal(none.pagination.summary.total, 0);
|
||||
assert.equal(rowCount(none), 0);
|
||||
});
|
||||
|
||||
test("dashboard sorts by a column, reflects direction, and the header toggles", () => {
|
||||
const asc = buildDashboardModel(new URL("http://x/?sort=name"));
|
||||
const desc = buildDashboardModel(new URL("http://x/?sort=-name"));
|
||||
|
||||
// asc first name ≤ desc first name (reverse order).
|
||||
assert.ok(nameOf(asc, 0) <= nameOf(asc, 1));
|
||||
assert.ok(nameOf(desc, 0) >= nameOf(desc, 1));
|
||||
|
||||
// The Name column carries the current direction and its header links to the opposite.
|
||||
assert.equal(col0(asc).sort, "asc");
|
||||
assert.match(col0(asc).href ?? "", /sort=-name/); // asc → click flips to desc
|
||||
assert.equal(col0(desc).sort, "desc");
|
||||
assert.match(col0(desc).href ?? "", /sort=name(?!\w)/); // desc → click flips to asc
|
||||
|
||||
// An unknown sort field is ignored (no crash, no sort indicator).
|
||||
const bad = buildDashboardModel(new URL("http://x/?sort=bogus"));
|
||||
assert.equal(col0(bad).sort, undefined);
|
||||
});
|
||||
|
||||
test("dashboard applies the central menu config: branding + nav override (rename/hide)", () => {
|
||||
const m = buildDashboardModel(new URL("http://x/"), [], {
|
||||
branding: { logo: "/public/logo.svg", name: "Acme Ops", sub: "Admin", theme: "dark" },
|
||||
override: { hide: ["teams"], rename: { people: "Staff" } },
|
||||
});
|
||||
|
||||
assert.deepEqual(m.shell.brand, { logo: "/public/logo.svg", name: "Acme Ops", sub: "Admin" });
|
||||
assert.equal(m.shell.theme, "dark");
|
||||
const labels = m.nav.map((n) => n.label);
|
||||
assert.ok(labels.includes("Staff")); // "People" renamed
|
||||
assert.ok(!labels.includes("Teams")); // "Teams" hidden
|
||||
});
|
||||
|
||||
// The dashboard assembles its nav from two gated sources — the built-in Admin section and each
|
||||
// discovered plugin's fragment (§7) — and role-filters both via composeNav. One contract: a section
|
||||
// shows only for a holder of its permission, and each source is gated independently.
|
||||
test("dashboard role-filters the gated Admin section and plugin fragments, each independently", () => {
|
||||
const plugin = {
|
||||
apiVersion: "1.0.0", id: "scheduling",
|
||||
nav: [{ children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }], icon: "i-cal", id: "scheduling", label: "Scheduling" }],
|
||||
};
|
||||
const section = (m: ReturnType<typeof buildDashboardModel>, label: string) => m.nav.find((n) => n.label === label);
|
||||
|
||||
// An admin sees the Admin section (the four built-in screens) but not the plugin's gated section.
|
||||
const admin = buildDashboardModel(new URL("http://x/"), ["admin"], undefined, "", null, [plugin]);
|
||||
assert.deepEqual(section(admin, "Admin")!.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/roles", "/admin/clients"]);
|
||||
assert.equal(section(admin, "Scheduling"), undefined);
|
||||
|
||||
// A plugin-permission holder sees the plugin section (reachable from "/") but not Admin.
|
||||
const member = buildDashboardModel(new URL("http://x/"), ["scheduling:read"], undefined, "", null, [plugin]);
|
||||
assert.ok(section(member, "Scheduling")!.children?.some((c) => c.href === "/scheduling/shifts"));
|
||||
assert.equal(section(member, "Admin"), undefined);
|
||||
|
||||
// Anonymous sees neither — composeNav drops a gated header and its whole subtree.
|
||||
const anon = buildDashboardModel(new URL("http://x/"), [], undefined, "", null, [plugin]);
|
||||
assert.equal(section(anon, "Admin"), undefined);
|
||||
assert.equal(section(anon, "Scheduling"), undefined);
|
||||
});
|
||||
|
||||
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
|
||||
assert.equal(rowCount(p2), 12);
|
||||
|
||||
// prev/next present on the middle page; both preserve the active sort.
|
||||
assert.match(p2.pagination.prev.href ?? "", /sort=-name/);
|
||||
assert.match(p2.pagination.next.href ?? "", /sort=-name/);
|
||||
|
||||
// Team filter actually narrows the set and adds a pill.
|
||||
const eng = buildDashboardModel(new URL("http://x/?team=Engineering"));
|
||||
assert.ok(eng.pagination.summary.total < 30);
|
||||
assert.deepEqual(eng.filterBar.pills.map((p) => p.label), ["Team"]);
|
||||
const branded = buildDashboardModel({ menu: { branding: { name: "Acme Ops", sub: "Admin", theme: "dark" }, override: {} } });
|
||||
assert.equal(branded.shell.brand.name, "Acme Ops");
|
||||
assert.equal(branded.shell.theme, "dark");
|
||||
});
|
||||
|
||||
+13
-205
@@ -1,217 +1,25 @@
|
||||
// Dashboard view model (todo §1): the gated "/dashboard" app-shell "People" list. Pure — turns a
|
||||
// request URL into the data the building-block partials render, wiring the §1 helpers end-to-end:
|
||||
// parseListQuery → filter/sort/paginate a mock dataset → composeNav. This is the built-in *demo* home
|
||||
// over mock data (a plugin owns the real one via a `dashboard` handler, §10); the filter form,
|
||||
// sortable headers and pager all round-trip the URL (zero-JS).
|
||||
// Dashboard view model (todo §10): the gated "/dashboard" app home. By default a short instructional
|
||||
// starter — what the dashboard is and how to replace it by exporting a `dashboard` handler from a
|
||||
// plugin (views/index.ejs holds the prose). A plugin owns the real one (PluginManifest.dashboard);
|
||||
// this placeholder renders until then. Pure: `nav` is the one global menu (ctx.chrome.nav), built
|
||||
// once per request by the host, so the dashboard shows the exact same menu as every other page.
|
||||
|
||||
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";
|
||||
import type { Plugin } from "./plugin.ts";
|
||||
import { parseListQuery } from "./list-query.ts";
|
||||
import { paginate } from "./paginate.ts";
|
||||
import type { NavNode } from "./nav.ts";
|
||||
import { buildShellContext } from "./shell-context.ts";
|
||||
|
||||
interface Person {
|
||||
id: string;
|
||||
email: string;
|
||||
initials: string;
|
||||
lastActive: string;
|
||||
name: string;
|
||||
role: string;
|
||||
status: string;
|
||||
team: string;
|
||||
}
|
||||
|
||||
const FIRST = ["Avery", "Blair", "Casey", "Devon", "Emerson", "Finley", "Gray", "Harper", "Iris", "Jordan", "Kai", "Logan", "Morgan", "Noor", "Oakley", "Parker", "Quinn", "Riley", "Sage", "Tatum", "Uma", "Vance", "Wren", "Yuki", "Zarah", "Aria", "Beau", "Cleo", "Dane", "Esme"];
|
||||
const LAST = ["Kline", "Mora", "Nguyen", "Patel", "Rossi", "Stone", "Vega", "Wu", "Ahmed", "Boyd", "Cruz", "Diaz", "Engel", "Frost", "Gomez", "Hale", "Ito", "Jain", "Khan", "Lund", "Marsh", "Novak", "Ortiz", "Pace", "Reed", "Sato", "Tran", "Udall", "Voss", "Webb"];
|
||||
const ROLES = ["Admin", "Member", "Viewer"];
|
||||
const TEAMS = ["Engineering", "Design", "Sales", "Support"];
|
||||
const STATUSES = ["active", "invited", "suspended"];
|
||||
const ACTIVE = ["2m ago", "1h ago", "3h ago", "Yesterday", "2d ago", "Last week"];
|
||||
const TONE: Record<string, string> = { active: "pos", invited: "info", suspended: "warn" };
|
||||
|
||||
// Cycle a fixed, non-empty list by index (parallel mock arrays — always in range).
|
||||
const at = <T>(arr: T[], i: number): T => arr[i % arr.length] as T;
|
||||
|
||||
const PEOPLE: Person[] = FIRST.map((first, i) => {
|
||||
const last = LAST[i] as string;
|
||||
export function buildDashboardModel(opts: { csrfToken?: string; menu?: MenuConfig; nav?: NavNode[]; user?: User | null } = {}) {
|
||||
return {
|
||||
id: `${first}-${last}`.toLowerCase(),
|
||||
email: `${first}.${last}`.toLowerCase() + "@example.com",
|
||||
initials: first.charAt(0) + last.charAt(0),
|
||||
lastActive: at(ACTIVE, i),
|
||||
name: `${first} ${last}`,
|
||||
role: at(ROLES, i),
|
||||
status: at(STATUSES, i),
|
||||
team: at(TEAMS, i),
|
||||
};
|
||||
});
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 12;
|
||||
const PAGE_SIZES = [12, 25, 50];
|
||||
// Sortable columns → the value to compare on (also gates `?sort=` to known fields).
|
||||
const SORT: Record<string, (p: Person) => string> = {
|
||||
email: (p) => p.email, name: (p) => p.name, role: (p) => p.role, status: (p) => p.status, team: (p) => p.team,
|
||||
};
|
||||
const COLUMNS: { key: string; label: string; sortable?: boolean }[] = [
|
||||
{ key: "name", label: "Name", sortable: true },
|
||||
{ key: "email", label: "Email", sortable: true },
|
||||
{ key: "role", label: "Role", sortable: true },
|
||||
{ key: "team", label: "Team", sortable: true },
|
||||
{ key: "status", label: "Status", sortable: true },
|
||||
{ key: "lastActive", label: "Last active" },
|
||||
];
|
||||
|
||||
interface State { page: number; pageSize: number; q: string; sort: string | null; status: string; team: string; }
|
||||
|
||||
const cap = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1);
|
||||
|
||||
// Canonical list URL from the current state plus per-link overrides; omits defaults so links stay tidy.
|
||||
function href(state: State, overrides: Partial<State> = {}): string {
|
||||
const s = { ...state, ...overrides };
|
||||
const p = new URLSearchParams();
|
||||
if (s.q) p.set("q", s.q);
|
||||
if (s.status && s.status !== "all") p.set("status", s.status);
|
||||
if (s.team) p.set("team", s.team);
|
||||
if (s.sort) p.set("sort", s.sort);
|
||||
if (s.page > 1) p.set("page", String(s.page));
|
||||
if (s.pageSize !== DEFAULT_PAGE_SIZE) p.set("pageSize", String(s.pageSize));
|
||||
const qs = p.toString();
|
||||
return qs ? `?${qs}` : "?";
|
||||
}
|
||||
|
||||
export function buildDashboardModel(url: URL | URLSearchParams | string, roles: string[] = [], menu: MenuConfig = DEFAULT_MENU, csrfToken = "", user: User | null = null, plugins: Plugin[] = []) {
|
||||
const query = parseListQuery(url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
||||
const status = query.filters.status?.[0] ?? "all";
|
||||
const team = query.filters.team?.[0] ?? "";
|
||||
const sort = query.sort && SORT[query.sort.field] ? query.sort : null; // ignore unknown fields
|
||||
const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null;
|
||||
const needle = query.q.toLowerCase();
|
||||
|
||||
let list = PEOPLE.filter((p) =>
|
||||
(!needle || p.name.toLowerCase().includes(needle) || p.email.toLowerCase().includes(needle)) &&
|
||||
(status === "all" || p.status === status) &&
|
||||
(!team || p.team === team));
|
||||
if (sort) {
|
||||
const get = SORT[sort.field] as (p: Person) => string; // gated to known fields above
|
||||
const dir = sort.dir === "desc" ? -1 : 1;
|
||||
list = [...list].sort((a, b) => get(a).localeCompare(get(b)) * dir);
|
||||
}
|
||||
|
||||
const page = paginate(list.length, query.page, query.pageSize, { boundaries: 1, siblings: 1 });
|
||||
const start = (page.page - 1) * page.pageSize;
|
||||
const rows = list.slice(start, start + page.pageSize);
|
||||
const state: State = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken, status, team };
|
||||
|
||||
return {
|
||||
filterBar: filterBar(state),
|
||||
nav: nav(roles, menu.override, plugins),
|
||||
pagination: pagination(state, page),
|
||||
nav: opts.nav ?? [],
|
||||
shell: buildShellContext({
|
||||
breadcrumbs: [{ href: "?", label: "Directory" }, { label: "People" }],
|
||||
csrfToken, // hidden field for the shell's Sign-out POST form (§4)
|
||||
menu,
|
||||
title: "People",
|
||||
user, // real signed-in identity (§4); anonymous ⇒ Guest
|
||||
breadcrumbs: [{ label: "Dashboard" }],
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
menu: opts.menu ?? DEFAULT_MENU,
|
||||
title: "Dashboard",
|
||||
user: opts.user ?? null,
|
||||
}),
|
||||
table: table(rows, state, sort),
|
||||
};
|
||||
}
|
||||
|
||||
export type DashboardModel = ReturnType<typeof buildDashboardModel>;
|
||||
|
||||
// Sidebar: the demo "Directory" fragment, then each discovered plugin's own nav fragment (so a
|
||||
// plugin is reachable from the dashboard; gated nodes stay invisible to non-admins), then the gated
|
||||
// admin section. composeNav applies the central override + per-user role filter.
|
||||
function nav(roles: string[], override: NavOverride, plugins: Plugin[]): NavNode[] {
|
||||
const pluginFragments = plugins.filter((p) => p.nav?.length).map((p) => p.nav as NavNode[]);
|
||||
return composeNav([[
|
||||
{ count: PEOPLE.length, current: true, href: "/dashboard", icon: "i-users", id: "people", label: "People" },
|
||||
{ href: "#teams", icon: "i-grid", id: "teams", label: "Teams" },
|
||||
{ children: [
|
||||
{ href: "#activity", id: "activity", label: "Activity" },
|
||||
{ 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" },
|
||||
], ...pluginFragments, [
|
||||
adminSection(), // built-in Users/Groups/Roles screens; gated → invisible to non-admins
|
||||
]], override, roles);
|
||||
}
|
||||
|
||||
function table(rows: Person[], state: State, sort: { dir: "asc" | "desc"; field: string } | null) {
|
||||
return {
|
||||
actions: true,
|
||||
caption: "People",
|
||||
columns: COLUMNS.map((c) => {
|
||||
if (!c.sortable) return { label: c.label };
|
||||
const dir = sort && sort.field === c.key ? sort.dir : undefined;
|
||||
const next = dir === "asc" ? `-${c.key}` : c.key; // asc→desc, else→asc
|
||||
return { href: href(state, { page: 1, sort: next }), label: c.label, sort: dir, sortable: true };
|
||||
}),
|
||||
rows: rows.map((p) => ({
|
||||
cells: [
|
||||
{ user: { initials: p.initials, name: p.name } },
|
||||
p.email,
|
||||
p.role,
|
||||
p.team,
|
||||
{ badge: { label: cap(p.status), tone: TONE[p.status] } },
|
||||
p.lastActive,
|
||||
],
|
||||
name: p.name,
|
||||
actions: [
|
||||
{ href: "#", icon: "i-user", label: "View" },
|
||||
{ href: "#", icon: "i-edit", label: "Edit" },
|
||||
{ danger: true, icon: "i-trash", label: "Deactivate", separatorBefore: true },
|
||||
],
|
||||
})),
|
||||
selectable: true,
|
||||
};
|
||||
}
|
||||
|
||||
function filterBar(state: State) {
|
||||
const pills: { label: string; remove: string; value: string }[] = [];
|
||||
if (state.q) pills.push({ label: "Search", remove: href(state, { page: 1, q: "" }), value: state.q });
|
||||
if (state.status !== "all") pills.push({ label: "Status", remove: href(state, { page: 1, status: "all" }), value: cap(state.status) });
|
||||
if (state.team) pills.push({ label: "Team", remove: href(state, { page: 1, team: "" }), value: state.team });
|
||||
|
||||
return {
|
||||
applyLabel: "Apply filters",
|
||||
clearHref: "?",
|
||||
label: "Filter people",
|
||||
pills,
|
||||
rows: [[
|
||||
{ label: "Search people", name: "q", placeholder: "Search people…", type: "search", value: state.q },
|
||||
{ legend: "Status", name: "status", options: [
|
||||
{ count: PEOPLE.length, label: "All", value: "all" },
|
||||
{ label: "Active", value: "active" },
|
||||
{ label: "Invited", value: "invited" },
|
||||
{ label: "Suspended", value: "suspended" },
|
||||
], type: "segmented", value: state.status },
|
||||
{ label: "Team", name: "team", options: [{ label: "All teams", value: "" }, ...TEAMS.map((t) => ({ label: t, value: t }))], type: "select", value: state.team },
|
||||
{ type: "spacer" },
|
||||
]],
|
||||
};
|
||||
}
|
||||
|
||||
function pagination(state: State, page: ReturnType<typeof paginate>) {
|
||||
// Hidden inputs carry the list state through the rows-per-page GET form (page resets on change).
|
||||
const hidden: { name: string; value: string }[] = [];
|
||||
if (state.q) hidden.push({ name: "q", value: state.q });
|
||||
if (state.status !== "all") hidden.push({ name: "status", value: state.status });
|
||||
if (state.team) hidden.push({ name: "team", value: state.team });
|
||||
if (state.sort) hidden.push({ name: "sort", value: state.sort });
|
||||
|
||||
return {
|
||||
label: "People pagination",
|
||||
next: { href: page.next ? href(state, { page: page.next }) : undefined },
|
||||
pages: page.pages.map((p) =>
|
||||
p.ellipsis ? { ellipsis: true }
|
||||
: p.current ? { current: true, label: String(p.page) }
|
||||
: { href: href(state, { page: p.page as number }), label: String(p.page) }),
|
||||
prev: { href: page.prev ? href(state, { page: page.prev }) : undefined },
|
||||
rows: { hidden, label: "Rows", name: "pageSize", options: PAGE_SIZES, submitLabel: "Go", value: state.pageSize },
|
||||
summary: { from: page.from, to: page.to, total: page.total },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,6 +51,10 @@ test("app shell offers Sign in (not Sign out) to an anonymous visitor — so a p
|
||||
// When chrome supplies signInHref (the current page as return_to), the link carries it.
|
||||
const withReturn = await render({ title: "Overview", brand: { name: "Acme" }, nav: "", body: "x", signInHref: "/login?return_to=%2Fscheduling" });
|
||||
assert.match(withReturn, /href="\/login\?return_to=%2Fscheduling"[^>]*>[\s\S]*?Sign in/);
|
||||
|
||||
// hideSignIn (the auth pages, §10): no footer Sign-in — a Sign-in on the login page only loops back.
|
||||
const onAuth = await render({ title: "", docTitle: "Sign in", brand: { name: "Acme" }, nav: "", body: "x", hideSignIn: true });
|
||||
assert.doesNotMatch(onAuth, />[\s\S]*?Sign in<\/a>/);
|
||||
});
|
||||
|
||||
test("app shell renders a configured logo + default theme, falls back to the brand mark", async () => {
|
||||
@@ -73,6 +77,26 @@ test("app shell links extra per-page stylesheets via the styles slot (e.g. a plu
|
||||
assert.equal((none.match(/rel="stylesheet"/g) ?? []).length, 1);
|
||||
});
|
||||
|
||||
test("app shell can disable the menu (§10): no sidebar, focused single-column layout", async () => {
|
||||
const bare = await render({ menu: false, title: "Focus", body: '<section id="b">x</section>', nav: '<a href="/x">Overview</a>' });
|
||||
assert.doesNotMatch(bare, /<aside class="sidebar"/); // sidebar dropped
|
||||
assert.doesNotMatch(bare, /class="hamburger"/); // and its mobile toggle
|
||||
assert.match(bare, /<div class="app app-bare">/); // single-column variant
|
||||
assert.match(bare, /<main class="content" id="main-content"/); // content still renders
|
||||
assert.match(bare, /<section id="b">x<\/section>/);
|
||||
});
|
||||
|
||||
test("app shell: an empty title yields no topbar <h1> so the body owns the single heading; docTitle sets <title> (§10)", async () => {
|
||||
// Auth/landing pass title:"" (their card/hero is the <h1>) + an explicit docTitle for the tab.
|
||||
const html = await render({ title: "", docTitle: "Sign in", brand: { name: "Acme" }, body: "<h1>Sign in</h1>" });
|
||||
assert.doesNotMatch(html, /<h1 class="page-title"/); // topbar carries no heading
|
||||
assert.match(html, /<title>Sign in<\/title>/); // explicit document title
|
||||
assert.match(html, /<aside class="sidebar"/); // menu still shown (the point of §10)
|
||||
|
||||
const fallback = await render({ title: "", brand: { name: "Acme" } }); // no docTitle → brand
|
||||
assert.match(fallback, /<title>Acme<\/title>/);
|
||||
});
|
||||
|
||||
test("app shell escapes text but passes slot HTML through, and renders with defaults", async () => {
|
||||
const escaped = await render({ title: "<x>", body: "<p>raw</p>" });
|
||||
assert.match(escaped, /<title><x><\/title>/); // user text is escaped
|
||||
|
||||
Reference in New Issue
Block a user