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:
@@ -1,105 +0,0 @@
|
||||
// Built-in OAuth2 clients admin screen: the pure view-model + Hydra-payload builders. A client
|
||||
// is an Ory Hydra OAuth2 client (apps that log in *through* us); writes go only to Hydra. The
|
||||
// HTTP routing/gate/CSRF + live Hydra calls (incl. the one-time secret) are exercised in app.test.ts.
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
buildClientDetailModel,
|
||||
buildClientFormModel,
|
||||
buildClientsListModel,
|
||||
type ClientInput,
|
||||
clientPayload,
|
||||
parseRedirectUris,
|
||||
toClientView,
|
||||
validateClientInput,
|
||||
} from "./admin-clients.ts";
|
||||
|
||||
const input = (over: Partial<ClientInput> = {}): ClientInput =>
|
||||
({ firstParty: false, name: "Acme", public: false, redirectUris: ["https://acme.example/cb"], scope: "openid offline_access", ...over });
|
||||
|
||||
test("toClientView maps Hydra fields → view (public from auth method, first-party from metadata, scopes split)", () => {
|
||||
assert.deepEqual(
|
||||
toClientView({ client_id: "c1", client_name: "Acme", metadata: { first_party: true }, redirect_uris: ["https://a/cb"], scope: "openid email", token_endpoint_auth_method: "client_secret_basic" }),
|
||||
{ firstParty: true, id: "c1", name: "Acme", public: false, redirectUris: ["https://a/cb"], scopes: ["openid", "email"] },
|
||||
);
|
||||
// A public client + no name → falls back to the id; absent metadata/scope tolerated.
|
||||
assert.deepEqual(
|
||||
toClientView({ client_id: "c2", token_endpoint_auth_method: "none" }),
|
||||
{ firstParty: false, id: "c2", name: "c2", public: true, redirectUris: [], scopes: [] },
|
||||
);
|
||||
});
|
||||
|
||||
test("parseRedirectUris splits on newlines/whitespace/commas and drops empties", () => {
|
||||
assert.deepEqual(parseRedirectUris("https://a/cb\n https://b/cb \n\n, https://c/cb"), ["https://a/cb", "https://b/cb", "https://c/cb"]);
|
||||
assert.deepEqual(parseRedirectUris(" "), []);
|
||||
});
|
||||
|
||||
test("clientPayload maps the input to Hydra's create body (auth-code + refresh, type via auth method)", () => {
|
||||
assert.deepEqual(clientPayload(input()), {
|
||||
client_name: "Acme",
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
metadata: { first_party: false },
|
||||
redirect_uris: ["https://acme.example/cb"],
|
||||
response_types: ["code"],
|
||||
scope: "openid offline_access",
|
||||
token_endpoint_auth_method: "client_secret_basic",
|
||||
});
|
||||
const pub = clientPayload(input({ firstParty: true, public: true }));
|
||||
assert.equal(pub.token_endpoint_auth_method, "none");
|
||||
assert.deepEqual(pub.metadata, { first_party: true });
|
||||
});
|
||||
|
||||
test("validateClientInput requires a name, ≥1 redirect URI, and absolute redirect URLs", () => {
|
||||
assert.equal(validateClientInput(input()), null);
|
||||
assert.match(validateClientInput(input({ name: "" }))!, /name/i);
|
||||
assert.match(validateClientInput(input({ redirectUris: [] }))!, /redirect/i);
|
||||
assert.match(validateClientInput(input({ redirectUris: ["not a url"] }))!, /redirect URI/i);
|
||||
});
|
||||
|
||||
test("buildClientsListModel filters by search, paginates; the name links to the detail page", () => {
|
||||
const clients = Array.from({ length: 30 }, (_, i) => ({ client_id: `id-${String(i).padStart(2, "0")}`, client_name: `app-${String(i).padStart(2, "0")}`, token_endpoint_auth_method: "client_secret_basic" }));
|
||||
|
||||
const all = buildClientsListModel({ clients, url: "http://x/admin/clients" });
|
||||
assert.equal(all.pagination.summary.total, 30);
|
||||
assert.equal(all.table.rows.length, 25); // default page size
|
||||
assert.equal(all.shell.title, "OAuth2 clients");
|
||||
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
|
||||
assert.equal(first.rowHeader.text, "app-00");
|
||||
assert.equal(first.rowHeader.href, "/admin/clients/id-00");
|
||||
|
||||
const one = buildClientsListModel({ clients, url: "http://x/admin/clients?q=app-07" });
|
||||
assert.equal(one.pagination.summary.total, 1);
|
||||
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
|
||||
});
|
||||
|
||||
test("buildClientFormModel: a register form with name + scope fields; values reflected on error", () => {
|
||||
const m = buildClientFormModel({ csrfToken: "tok.sig" });
|
||||
assert.equal(m.shell.title, "Register client");
|
||||
assert.equal(m.form.action, "/admin/clients");
|
||||
assert.equal(m.form.submitLabel, "Register client");
|
||||
assert.equal(m.form.csrfToken, "tok.sig");
|
||||
assert.equal(m.form.nameField.required, true);
|
||||
assert.equal(m.form.scopeField.value, "openid offline_access"); // sensible default
|
||||
|
||||
const err = buildClientFormModel({ error: "Add at least one redirect URI.", values: { firstParty: true, name: "Acme", public: true, redirectUris: ["https://a/cb"], scope: "openid" } });
|
||||
assert.equal(err.error, "Add at least one redirect URI.");
|
||||
assert.equal(err.form.nameField.value, "Acme");
|
||||
assert.equal(err.form.redirectUris, "https://a/cb");
|
||||
assert.equal(err.form.public, true);
|
||||
assert.equal(err.form.firstParty, true);
|
||||
});
|
||||
|
||||
test("buildClientDetailModel: client info + delete action; the one-time secret + created banner show only right after create", () => {
|
||||
const client = toClientView({ client_id: "c1", client_name: "Acme", redirect_uris: ["https://a/cb"], scope: "openid", token_endpoint_auth_method: "client_secret_basic" });
|
||||
|
||||
const plain = buildClientDetailModel({ client });
|
||||
assert.equal(plain.shell.title, "Acme");
|
||||
assert.equal(plain.delete.action, "/admin/clients/c1/delete");
|
||||
assert.equal(plain.created, false);
|
||||
assert.equal(plain.secret, undefined);
|
||||
|
||||
const fresh = buildClientDetailModel({ client, created: true, secret: "s3cr3t" });
|
||||
assert.equal(fresh.created, true);
|
||||
assert.equal(fresh.secret, "s3cr3t");
|
||||
assert.equal(fresh.shell.title, "Client registered");
|
||||
});
|
||||
@@ -1,352 +0,0 @@
|
||||
// Built-in OAuth2 clients admin screen: register / list / delete the OAuth2 clients other
|
||||
// apps log in *through* us with (Ory Hydra, the login+consent handlers). A client is an Ory Hydra
|
||||
// OAuth2 client; writes go only to Hydra. Hydra returns the client_secret once, on create — so the
|
||||
// register POST renders the new client's detail page (with the one-time secret) directly instead of a
|
||||
// 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, 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 "../http/context.ts";
|
||||
import { HydraError, type HydraAdmin, type OAuth2Client } from "../auth/hydra-admin.ts";
|
||||
import { parseListQuery } from "../ui/list-query.ts";
|
||||
import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
|
||||
import type { NavNode } from "../ui/nav.ts";
|
||||
import { paginate } from "../ui/paginate.ts";
|
||||
import type { RouteResult } from "../plugin-host/plugin.ts";
|
||||
import { buildShellContext } from "../ui/shell-context.ts";
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
const PAGE_SIZES = [25, 50, 100];
|
||||
// One Hydra page is fetched and filtered/paged in memory — its list API has no search. Ample for an
|
||||
// admin tool (the OAuth2 clients of a deployment number in the dozens); raise if one outgrows it.
|
||||
const LIST_FETCH_SIZE = 250;
|
||||
const DEFAULT_SCOPE = "openid offline_access";
|
||||
|
||||
export interface ClientView {
|
||||
firstParty: boolean;
|
||||
id: string; // client_id
|
||||
name: string;
|
||||
public: boolean; // public (PKCE, no secret) vs confidential
|
||||
redirectUris: string[];
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
export interface ClientInput {
|
||||
firstParty: boolean;
|
||||
name: string;
|
||||
public: boolean;
|
||||
redirectUris: string[];
|
||||
scope: string;
|
||||
}
|
||||
|
||||
export function toClientView(client: OAuth2Client): ClientView {
|
||||
const id = client.client_id ?? "";
|
||||
return {
|
||||
firstParty: (client.metadata as { first_party?: unknown } | undefined)?.first_party === true,
|
||||
id,
|
||||
name: client.client_name?.trim() || id || "(unnamed)",
|
||||
public: client.token_endpoint_auth_method === "none",
|
||||
redirectUris: client.redirect_uris ?? [],
|
||||
scopes: (client.scope ?? "").split(/\s+/).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
// Split a textarea value into redirect URIs (one per line / whitespace / comma), dropping empties.
|
||||
export function parseRedirectUris(raw: string): string[] {
|
||||
return raw.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
// Hydra's create body. We register a standard authorization-code web/native client (+ refresh);
|
||||
// the type (confidential vs public/PKCE) and auto-consent ride the auth method + metadata.
|
||||
export function clientPayload(input: ClientInput): Record<string, unknown> {
|
||||
return {
|
||||
client_name: input.name,
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
metadata: { first_party: input.firstParty },
|
||||
redirect_uris: input.redirectUris,
|
||||
response_types: ["code"],
|
||||
scope: input.scope,
|
||||
token_endpoint_auth_method: input.public ? "none" : "client_secret_basic",
|
||||
};
|
||||
}
|
||||
|
||||
export function validateClientInput(input: ClientInput): string | null {
|
||||
if (!input.name) return "Enter a name for the client.";
|
||||
if (!input.redirectUris.length) return "Add at least one redirect URI.";
|
||||
for (const uri of input.redirectUris) {
|
||||
try {
|
||||
new URL(uri); // must be an absolute URL — any scheme (public/native clients use custom ones)
|
||||
} catch {
|
||||
return `"${uri}" is not a valid redirect URI — use an absolute URL like https://app.example.com/callback.`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- list view model ----
|
||||
|
||||
interface ListState {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
q: string;
|
||||
}
|
||||
|
||||
function detailHref(id: string): string {
|
||||
return `${ADMIN_CLIENTS_BASE}/${encodeURIComponent(id)}`;
|
||||
}
|
||||
|
||||
function listHref(state: ListState, overrides: Partial<ListState> = {}): string {
|
||||
const s = { ...state, ...overrides };
|
||||
const p = new URLSearchParams();
|
||||
if (s.q) p.set("q", s.q);
|
||||
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 ? `${ADMIN_CLIENTS_BASE}?${qs}` : ADMIN_CLIENTS_BASE;
|
||||
}
|
||||
|
||||
export function buildClientsListModel(opts: {
|
||||
clients: OAuth2Client[];
|
||||
csrfToken?: string;
|
||||
menu?: MenuConfig;
|
||||
nav?: NavNode[];
|
||||
url: URL | URLSearchParams | string;
|
||||
user?: User | null;
|
||||
}) {
|
||||
const menu = opts.menu ?? DEFAULT_MENU;
|
||||
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
||||
const needle = query.q.toLowerCase();
|
||||
|
||||
const all = opts.clients.map(toClientView);
|
||||
const list = all.filter((c) => !needle || c.name.toLowerCase().includes(needle) || c.id.toLowerCase().includes(needle));
|
||||
|
||||
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: ListState = { page: page.page, pageSize: page.pageSize, q: query.q };
|
||||
|
||||
return {
|
||||
filterBar: listFilterBar(state),
|
||||
nav: opts.nav ?? [],
|
||||
pagination: listPagination(state, page),
|
||||
shell: buildShellContext({
|
||||
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "Admin" }, { label: "OAuth2 clients" }],
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
menu,
|
||||
title: "OAuth2 clients",
|
||||
user: opts.user ?? null,
|
||||
}),
|
||||
table: listTable(rows),
|
||||
};
|
||||
}
|
||||
|
||||
function listTable(rows: ClientView[]) {
|
||||
return {
|
||||
caption: "OAuth2 clients",
|
||||
columns: [{ label: "Name" }, { label: "Client ID" }, { label: "Type" }],
|
||||
rows: rows.map((c) => ({
|
||||
cells: [
|
||||
{ rowHeader: { href: detailHref(c.id), text: c.name } },
|
||||
{ className: "cell-muted", text: c.id },
|
||||
{ badge: { label: c.public ? "Public" : "Confidential", tone: c.public ? "warn" : "info" } },
|
||||
],
|
||||
name: c.name,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function listFilterBar(state: ListState) {
|
||||
const pills: { label: string; remove: string; value: string }[] = [];
|
||||
if (state.q) pills.push({ label: "Search", remove: listHref(state, { page: 1, q: "" }), value: state.q });
|
||||
return {
|
||||
applyLabel: "Apply",
|
||||
clearHref: ADMIN_CLIENTS_BASE,
|
||||
label: "Filter clients",
|
||||
pills,
|
||||
rows: [[
|
||||
{ label: "Search clients", name: "q", placeholder: "Search name or client ID…", type: "search", value: state.q },
|
||||
{ type: "spacer" },
|
||||
]],
|
||||
};
|
||||
}
|
||||
|
||||
function listPagination(state: ListState, page: ReturnType<typeof paginate>) {
|
||||
const hidden: { name: string; value: string }[] = [];
|
||||
if (state.q) hidden.push({ name: "q", value: state.q });
|
||||
return {
|
||||
label: "Clients pagination",
|
||||
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
|
||||
pages: page.pages.map((p) =>
|
||||
p.ellipsis ? { ellipsis: true }
|
||||
: p.current ? { current: true, label: String(p.page) }
|
||||
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
|
||||
prev: { href: page.prev ? listHref(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 },
|
||||
};
|
||||
}
|
||||
|
||||
// ---- register form + detail view models ----
|
||||
|
||||
export function buildClientFormModel(opts: {
|
||||
csrfToken?: string;
|
||||
error?: string;
|
||||
menu?: MenuConfig;
|
||||
nav?: NavNode[];
|
||||
user?: User | null;
|
||||
values?: Partial<ClientInput>;
|
||||
}) {
|
||||
const menu = opts.menu ?? DEFAULT_MENU;
|
||||
const v = opts.values;
|
||||
const nameField: FieldConfig = {
|
||||
autocomplete: "off", icon: "i-box", id: "name", label: "Name", name: "name", required: true, value: v?.name ?? "",
|
||||
};
|
||||
const scopeField: FieldConfig = {
|
||||
hint: "Space-separated scopes the client may request.", id: "scope", label: "Scopes", name: "scope",
|
||||
value: v?.scope ?? DEFAULT_SCOPE,
|
||||
};
|
||||
return {
|
||||
error: opts.error,
|
||||
form: {
|
||||
action: ADMIN_CLIENTS_BASE,
|
||||
cancelHref: ADMIN_CLIENTS_BASE,
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
firstParty: v?.firstParty ?? false,
|
||||
nameField,
|
||||
public: v?.public ?? false,
|
||||
redirectUris: (v?.redirectUris ?? []).join("\n"),
|
||||
scopeField,
|
||||
submitLabel: "Register client",
|
||||
},
|
||||
nav: opts.nav ?? [],
|
||||
shell: buildShellContext({
|
||||
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { label: "Register" }],
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
menu,
|
||||
title: "Register client",
|
||||
user: opts.user ?? null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildClientDetailModel(opts: {
|
||||
client: ClientView;
|
||||
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;
|
||||
}) {
|
||||
const menu = opts.menu ?? DEFAULT_MENU;
|
||||
const base = detailHref(opts.client.id);
|
||||
return {
|
||||
client: opts.client,
|
||||
created: opts.created ?? false,
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
delete: { action: `${base}/delete` },
|
||||
nav: opts.nav ?? [],
|
||||
secret: opts.secret,
|
||||
shell: buildShellContext({
|
||||
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { label: opts.client.name }],
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
menu,
|
||||
title: opts.created ? "Client registered" : opts.client.name,
|
||||
user: opts.user ?? null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- request handler (imperative shell) ----
|
||||
|
||||
export interface AdminClientsDeps {
|
||||
csrfSecret: string;
|
||||
hydra: HydraAdmin;
|
||||
menu: MenuConfig;
|
||||
render: (view: string, data: Record<string, unknown>) => Promise<string>;
|
||||
}
|
||||
|
||||
function readClientInput(form: URLSearchParams): ClientInput {
|
||||
return {
|
||||
firstParty: form.get("firstParty") === "on",
|
||||
name: (form.get("name") ?? "").trim(),
|
||||
public: form.get("public") === "on",
|
||||
redirectUris: parseRedirectUris(form.get("redirectUris") ?? ""),
|
||||
scope: (form.get("scope") ?? "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleAdminClients(ctx: RequestContext, csrfToken: string, deps: AdminClientsDeps): Promise<RouteResult | null> {
|
||||
const path = ctx.url.pathname;
|
||||
if (path !== ADMIN_CLIENTS_BASE && !path.startsWith(`${ADMIN_CLIENTS_BASE}/`)) return null;
|
||||
|
||||
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, 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, 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, nav, url: ctx.url, user }) }) };
|
||||
}
|
||||
if (method === "POST") {
|
||||
const input = readClientInput(form!);
|
||||
const error = validateClientInput(input);
|
||||
if (error) return { ...(await renderForm({ error, values: input })), status: 400 };
|
||||
let created: OAuth2Client;
|
||||
try {
|
||||
created = await hydra.createClient(clientPayload(input));
|
||||
} catch (err) {
|
||||
// A Hydra 4xx (bad redirect/scope it rejects) is the operator's input — re-render the form;
|
||||
// a 5xx (Hydra down) rethrows → 500. Mirrors the challenge-handler degrade.
|
||||
if (err instanceof HydraError && err.status < 500) {
|
||||
return { ...(await renderForm({ error: "Hydra rejected the client — check the redirect URIs and scopes.", values: input })), status: 400 };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
// Show the one-time secret now (Hydra never returns it again) — render the detail directly.
|
||||
ctx.log.info("admin: oauth2 client registered", { actor: user.id, client: created.client_id ?? "" });
|
||||
return renderDetail(created, { created: true, ...(created.client_secret ? { secret: created.client_secret } : {}) });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// /admin/clients/new — register form
|
||||
if (seg.length === 1 && seg[0] === "new" && method === "GET") return renderForm({});
|
||||
|
||||
// /admin/clients/:id …
|
||||
const id = safeDecode(seg[0]!);
|
||||
if (id === null) return notFound();
|
||||
const client = await hydra.getClient(id);
|
||||
if (!client) return notFound();
|
||||
const base = detailHref(id);
|
||||
|
||||
if (seg.length === 1 && method === "GET") return renderDetail(client);
|
||||
|
||||
if (seg.length === 2 && seg[1] === "delete" && method === "GET") {
|
||||
const name = toClientView(client).name;
|
||||
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,
|
||||
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") {
|
||||
await hydra.deleteClient(id);
|
||||
ctx.log.info("admin: oauth2 client deleted", { actor: user.id, client: id });
|
||||
return { redirect: ADMIN_CLIENTS_BASE };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
// Built-in Groups admin screen: the pure view-model + Keto-tuple builders. A group is a
|
||||
// Keto subject set (Group:<name>#members); membership tuples carry users (subject_id) or nested
|
||||
// groups (subject_set). The HTTP routing/gate/CSRF + live Keto/Kratos calls are exercised over
|
||||
// HTTP in app.test.ts.
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
buildGroupDetailModel,
|
||||
buildGroupFormModel,
|
||||
buildGroupsListModel,
|
||||
groupsFromTuples,
|
||||
isValidGroupName,
|
||||
memberTuple,
|
||||
memberView,
|
||||
parseSubject,
|
||||
} from "./admin-groups.ts";
|
||||
import type { RelationTuple } from "../auth/keto-client.ts";
|
||||
|
||||
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
|
||||
const userTuple = (group: string, n: number): RelationTuple =>
|
||||
({ namespace: "Group", object: group, relation: "members", subject_id: `user:${uid(n)}` });
|
||||
const groupTuple = (group: string, child: string): RelationTuple =>
|
||||
({ namespace: "Group", object: group, relation: "members", subject_set: { namespace: "Group", object: child, relation: "members" } });
|
||||
|
||||
test("isValidGroupName accepts URL-safe names, rejects empties/spaces/uppercase/leading punctuation", () => {
|
||||
for (const ok of ["eng", "team-a", "a1_b9", "x"]) assert.equal(isValidGroupName(ok), true, ok);
|
||||
for (const bad of ["", "Eng", "a b", "-bad", "_bad", "a/b", "a".repeat(65)]) assert.equal(isValidGroupName(bad), false, bad);
|
||||
});
|
||||
|
||||
test("parseSubject + memberTuple map the form value to the user/nested-group subject (else null)", () => {
|
||||
assert.deepEqual(parseSubject(`user:${uid(1)}`), { subject_id: `user:${uid(1)}` });
|
||||
assert.deepEqual(parseSubject("group:eng"), { subject_set: { namespace: "Group", object: "eng", relation: "members" } });
|
||||
// Both forms are validated: a non-UUID user / invalid group name is rejected, not written blindly.
|
||||
for (const bad of ["", "user:", "user:not-a-uuid", "group:", "group:Bad Name", "nope:x", "plain"]) assert.equal(parseSubject(bad), null, bad);
|
||||
|
||||
assert.deepEqual(memberTuple("design", `user:${uid(2)}`), { namespace: "Group", object: "design", relation: "members", subject_id: `user:${uid(2)}` });
|
||||
assert.deepEqual(memberTuple("design", "group:eng"), { namespace: "Group", object: "design", relation: "members", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
|
||||
assert.equal(memberTuple("design", "bad"), null);
|
||||
});
|
||||
|
||||
test("groupsFromTuples collapses membership tuples → distinct groups + member counts, sorted by name", () => {
|
||||
const tuples = [userTuple("eng", 1), userTuple("eng", 2), groupTuple("eng", "design"), userTuple("design", 3)];
|
||||
assert.deepEqual(groupsFromTuples(tuples), [
|
||||
{ memberCount: 1, name: "design" },
|
||||
{ memberCount: 3, name: "eng" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("memberView resolves a user subject to its email (else the raw id) and a subject_set to the group", () => {
|
||||
const emails = new Map([[uid(1), "ada@example.com"]]);
|
||||
assert.deepEqual(memberView(userTuple("eng", 1), emails), { kind: "user", label: "ada@example.com", subject: `user:${uid(1)}` });
|
||||
assert.deepEqual(memberView(userTuple("eng", 9), emails), { kind: "user", label: `user:${uid(9)}`, subject: `user:${uid(9)}` });
|
||||
assert.deepEqual(memberView(groupTuple("eng", "design"), emails), { kind: "group", label: "design", subject: "group:design" });
|
||||
});
|
||||
|
||||
test("buildGroupsListModel filters by search, sorts, paginates; the name links to the detail page", () => {
|
||||
const groups = Array.from({ length: 30 }, (_, i) => ({ memberCount: i + 1, name: `team-${String(i).padStart(2, "0")}` }));
|
||||
|
||||
const all = buildGroupsListModel({ groups, url: "http://x/admin/groups" });
|
||||
assert.equal(all.pagination.summary.total, 30);
|
||||
assert.equal(all.table.rows.length, 25); // default page size
|
||||
assert.equal(all.shell.title, "Groups");
|
||||
// The group name is the row header, linking to its detail page.
|
||||
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
|
||||
assert.equal(first.rowHeader.text, "team-00");
|
||||
assert.equal(first.rowHeader.href, "/admin/groups/team-00");
|
||||
|
||||
// Search narrows + shows a pill.
|
||||
const one = buildGroupsListModel({ groups, url: "http://x/admin/groups?q=team-07" });
|
||||
assert.equal(one.pagination.summary.total, 1);
|
||||
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
|
||||
|
||||
// Sort by members descending puts the biggest group first.
|
||||
const desc = buildGroupsListModel({ groups, url: "http://x/admin/groups?sort=-members" });
|
||||
assert.equal((desc.table.rows[0]!.cells[0] as { rowHeader: { text: string } }).rowHeader.text, "team-29");
|
||||
});
|
||||
|
||||
test("buildGroupFormModel: a create form with a required name field + member options, no group of its own", () => {
|
||||
const options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
|
||||
const m = buildGroupFormModel({ csrfToken: "tok.sig", memberOptions: options });
|
||||
assert.equal(m.shell.title, "New group");
|
||||
assert.equal(m.form.action, "/admin/groups");
|
||||
assert.equal(m.form.submitLabel, "Create group");
|
||||
assert.equal(m.form.csrfToken, "tok.sig");
|
||||
assert.equal(m.form.nameField.name, "name");
|
||||
assert.equal(m.form.nameField.required, true);
|
||||
assert.deepEqual(m.form.memberOptions, options);
|
||||
|
||||
// An error (e.g. a taken/invalid name) re-renders with the submitted values.
|
||||
const err = buildGroupFormModel({ error: "That name is taken.", memberOptions: options, values: { member: "group:eng", name: "Eng" } });
|
||||
assert.equal(err.error, "That name is taken.");
|
||||
assert.equal(err.form.nameField.value, "Eng");
|
||||
assert.equal(err.form.selectedMember, "group:eng");
|
||||
});
|
||||
|
||||
test("buildGroupDetailModel: members → rows, add-options exclude current members + the group itself, delete/remove wired", () => {
|
||||
const members = [memberView(userTuple("eng", 1), new Map([[uid(1), "ada@example.com"]])), memberView(groupTuple("eng", "design"), new Map())];
|
||||
const candidates = [
|
||||
{ label: "ada@example.com", value: `user:${uid(1)}` }, // already a member → excluded
|
||||
{ label: "grace@example.com", value: `user:${uid(2)}` },
|
||||
{ label: "design (group)", value: "group:design" }, // already a member → excluded
|
||||
{ label: "eng (group)", value: "group:eng" }, // the group itself → excluded
|
||||
{ label: "ops (group)", value: "group:ops" },
|
||||
];
|
||||
const m = buildGroupDetailModel({ candidates, group: { name: "eng" }, members });
|
||||
assert.equal(m.shell.title, "eng");
|
||||
assert.equal(m.members.rows.length, 2);
|
||||
assert.equal(m.members.action, "/admin/groups/eng/members/delete");
|
||||
assert.equal(m.add.action, "/admin/groups/eng/members");
|
||||
assert.deepEqual(m.add.options.map((o) => o.value), [`user:${uid(2)}`, "group:ops"]);
|
||||
assert.equal(m.delete.action, "/admin/groups/eng/delete");
|
||||
});
|
||||
@@ -1,410 +0,0 @@
|
||||
// Built-in Groups admin screen: list / create / delete Keto groups and manage membership.
|
||||
// A group is a Keto subject set `Group:<name>#members`; a member is a user or a nested group (see
|
||||
// parseSubject). Writes go only to Keto (README "stateless"). Keto has no "create object" — a group
|
||||
// exists exactly while it has ≥1 member, so create writes its first-member tuple and delete removes
|
||||
// every member tuple. Pure builders turn tuples + the request URL into view models; `handleAdminGroups`
|
||||
// is the imperative shell app.ts dispatches to — gated admin-only, CSRF-guarded, mapping each action
|
||||
// to a RouteResult.
|
||||
|
||||
import { ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
|
||||
import type { FieldConfig } from "./admin-users.ts";
|
||||
import type { RequestContext, User } from "../http/context.ts";
|
||||
import type { KetoClient, RelationQuery, RelationTuple, SubjectSet } from "../auth/keto-client.ts";
|
||||
import type { KratosAdmin } from "../auth/kratos-admin.ts";
|
||||
import { parseListQuery } from "../ui/list-query.ts";
|
||||
import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
|
||||
import type { NavNode } from "../ui/nav.ts";
|
||||
import { paginate } from "../ui/paginate.ts";
|
||||
import type { RouteResult } from "../plugin-host/plugin.ts";
|
||||
import { buildShellContext } from "../ui/shell-context.ts";
|
||||
|
||||
const GROUP_NS = "Group";
|
||||
const MEMBERS = "members";
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
const PAGE_SIZES = [25, 50, 100];
|
||||
// One Keto page of candidate users is fetched for the member pickers (mirrors admin-users).
|
||||
const LIST_FETCH_SIZE = 250;
|
||||
const GROUP_NAME = /^[a-z0-9][a-z0-9_-]*$/; // URL-safe; doubles as the path segment
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; // a Kratos identity id
|
||||
|
||||
export interface GroupView {
|
||||
memberCount: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// A member's view model: a user (label = email) or a nested group (label = group name). `subject`
|
||||
// is the form value that round-trips it — `user:<id>` or `group:<name>` (see parseSubject).
|
||||
export interface MemberView {
|
||||
kind: "group" | "user";
|
||||
label: string;
|
||||
subject: string;
|
||||
}
|
||||
|
||||
// One option in a member <select>.
|
||||
export interface MemberOption {
|
||||
label: string;
|
||||
value: string; // `user:<id>` | `group:<name>`
|
||||
}
|
||||
|
||||
export function isValidGroupName(name: string): boolean {
|
||||
return name.length <= 64 && GROUP_NAME.test(name);
|
||||
}
|
||||
|
||||
// Map a member-picker value → the Keto subject (subject_id for a user, subject_set for a nested
|
||||
// group). Returns null for anything unrecognised (a crafted/empty POST).
|
||||
export function parseSubject(value: string): { subject_id: string } | { subject_set: SubjectSet } | null {
|
||||
const sep = value.indexOf(":");
|
||||
if (sep <= 0) return null;
|
||||
const rest = value.slice(sep + 1);
|
||||
if (!rest) return null;
|
||||
// Validate both subject forms so a crafted POST can't write a dangling tuple (the pickers only
|
||||
// ever offer real users/groups): a user id is a Kratos UUID, a nested group a valid group name.
|
||||
if (value.slice(0, sep) === "user") return UUID.test(rest) ? { subject_id: `user:${rest}` } : null;
|
||||
if (value.slice(0, sep) === "group") return isValidGroupName(rest) ? { subject_set: { namespace: GROUP_NS, object: rest, relation: MEMBERS } } : null;
|
||||
return null;
|
||||
}
|
||||
|
||||
// The full membership tuple for adding/removing `value` to/from `group` (null if value is invalid).
|
||||
export function memberTuple(group: string, value: string): RelationTuple | null {
|
||||
const subject = parseSubject(value);
|
||||
return subject ? { namespace: GROUP_NS, object: group, relation: MEMBERS, ...subject } : null;
|
||||
}
|
||||
|
||||
// Collapse the namespace's membership tuples → distinct groups + member counts, sorted by name.
|
||||
export function groupsFromTuples(tuples: RelationTuple[]): GroupView[] {
|
||||
const counts = new Map<string, number>();
|
||||
for (const t of tuples) counts.set(t.object, (counts.get(t.object) ?? 0) + 1);
|
||||
return [...counts].map(([name, memberCount]) => ({ memberCount, name })).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export function memberView(tuple: RelationTuple, emailById: Map<string, string>): MemberView {
|
||||
if (tuple.subject_set) return { kind: "group", label: tuple.subject_set.object, subject: `group:${tuple.subject_set.object}` };
|
||||
const subjectId = tuple.subject_id ?? "";
|
||||
const id = subjectId.startsWith("user:") ? subjectId.slice("user:".length) : subjectId;
|
||||
return { kind: "user", label: emailById.get(id) ?? subjectId, subject: subjectId };
|
||||
}
|
||||
|
||||
// ---- list view model ----
|
||||
|
||||
interface ListState {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
q: string;
|
||||
sort: string | null;
|
||||
}
|
||||
|
||||
const SORT: Record<string, (g: GroupView) => number | string> = {
|
||||
members: (g) => g.memberCount,
|
||||
name: (g) => g.name,
|
||||
};
|
||||
const COLUMNS = [
|
||||
{ key: "name", label: "Group" },
|
||||
{ key: "members", label: "Members" },
|
||||
];
|
||||
|
||||
function detailHref(name: string): string {
|
||||
return `${ADMIN_GROUPS_BASE}/${encodeURIComponent(name)}`;
|
||||
}
|
||||
|
||||
function listHref(state: ListState, overrides: Partial<ListState> = {}): string {
|
||||
const s = { ...state, ...overrides };
|
||||
const p = new URLSearchParams();
|
||||
if (s.q) p.set("q", s.q);
|
||||
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 ? `${ADMIN_GROUPS_BASE}?${qs}` : ADMIN_GROUPS_BASE;
|
||||
}
|
||||
|
||||
export function buildGroupsListModel(opts: {
|
||||
csrfToken?: string;
|
||||
groups: GroupView[];
|
||||
menu?: MenuConfig;
|
||||
nav?: NavNode[];
|
||||
url: URL | URLSearchParams | string;
|
||||
user?: User | null;
|
||||
}) {
|
||||
const menu = opts.menu ?? DEFAULT_MENU;
|
||||
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
||||
const sort = query.sort && SORT[query.sort.field] ? query.sort : null;
|
||||
const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null;
|
||||
const needle = query.q.toLowerCase();
|
||||
|
||||
let list = opts.groups.filter((g) => !needle || g.name.toLowerCase().includes(needle));
|
||||
if (sort) {
|
||||
const get = SORT[sort.field]!;
|
||||
const dir = sort.dir === "desc" ? -1 : 1;
|
||||
list = [...list].sort((a, b) => {
|
||||
const av = get(a), bv = get(b);
|
||||
const cmp = typeof av === "number" && typeof bv === "number" ? av - bv : String(av).localeCompare(String(bv));
|
||||
return cmp * 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: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken };
|
||||
|
||||
return {
|
||||
filterBar: listFilterBar(state),
|
||||
nav: opts.nav ?? [],
|
||||
pagination: listPagination(state, page),
|
||||
shell: buildShellContext({
|
||||
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Admin" }, { label: "Groups" }],
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
menu,
|
||||
title: "Groups",
|
||||
user: opts.user ?? null,
|
||||
}),
|
||||
table: listTable(rows, state, sort),
|
||||
};
|
||||
}
|
||||
|
||||
function listTable(rows: GroupView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null) {
|
||||
return {
|
||||
caption: "Groups",
|
||||
columns: COLUMNS.map((c) => {
|
||||
const dir = sort && sort.field === c.key ? sort.dir : undefined;
|
||||
const next = dir === "asc" ? `-${c.key}` : c.key;
|
||||
return { href: listHref(state, { page: 1, sort: next }), label: c.label, sort: dir, sortable: true };
|
||||
}),
|
||||
rows: rows.map((g) => ({
|
||||
cells: [{ rowHeader: { href: detailHref(g.name), text: g.name } }, String(g.memberCount)],
|
||||
name: g.name,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function listFilterBar(state: ListState) {
|
||||
const pills: { label: string; remove: string; value: string }[] = [];
|
||||
if (state.q) pills.push({ label: "Search", remove: listHref(state, { page: 1, q: "" }), value: state.q });
|
||||
return {
|
||||
applyLabel: "Apply",
|
||||
clearHref: ADMIN_GROUPS_BASE,
|
||||
label: "Filter groups",
|
||||
pills,
|
||||
rows: [[
|
||||
{ label: "Search groups", name: "q", placeholder: "Search group name…", type: "search", value: state.q },
|
||||
{ type: "spacer" },
|
||||
]],
|
||||
};
|
||||
}
|
||||
|
||||
function listPagination(state: ListState, page: ReturnType<typeof paginate>) {
|
||||
const hidden: { name: string; value: string }[] = [];
|
||||
if (state.q) hidden.push({ name: "q", value: state.q });
|
||||
if (state.sort) hidden.push({ name: "sort", value: state.sort });
|
||||
return {
|
||||
label: "Groups pagination",
|
||||
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
|
||||
pages: page.pages.map((p) =>
|
||||
p.ellipsis ? { ellipsis: true }
|
||||
: p.current ? { current: true, label: String(p.page) }
|
||||
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
|
||||
prev: { href: page.prev ? listHref(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 },
|
||||
};
|
||||
}
|
||||
|
||||
// ---- create form + detail view models ----
|
||||
|
||||
export function buildGroupFormModel(opts: {
|
||||
csrfToken?: string;
|
||||
error?: string;
|
||||
memberOptions: MemberOption[];
|
||||
menu?: MenuConfig;
|
||||
nav?: NavNode[];
|
||||
user?: User | null;
|
||||
values?: { member?: string; name?: string };
|
||||
}) {
|
||||
const menu = opts.menu ?? DEFAULT_MENU;
|
||||
const nameField: FieldConfig = {
|
||||
autocomplete: "off", hint: "Lowercase letters, digits, dashes and underscores.", icon: "i-layers",
|
||||
id: "name", label: "Group name", name: "name", required: true, value: opts.values?.name ?? "",
|
||||
};
|
||||
return {
|
||||
error: opts.error,
|
||||
form: {
|
||||
action: ADMIN_GROUPS_BASE,
|
||||
cancelHref: ADMIN_GROUPS_BASE,
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
memberOptions: opts.memberOptions,
|
||||
nameField,
|
||||
selectedMember: opts.values?.member ?? "",
|
||||
submitLabel: "Create group",
|
||||
},
|
||||
nav: opts.nav ?? [],
|
||||
shell: buildShellContext({
|
||||
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { label: "New" }],
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
menu,
|
||||
title: "New group",
|
||||
user: opts.user ?? null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildGroupDetailModel(opts: {
|
||||
candidates: MemberOption[];
|
||||
csrfToken?: string;
|
||||
error?: string;
|
||||
group: { name: string };
|
||||
members: MemberView[];
|
||||
menu?: MenuConfig;
|
||||
nav?: NavNode[];
|
||||
user?: User | null;
|
||||
}) {
|
||||
const menu = opts.menu ?? DEFAULT_MENU;
|
||||
const name = opts.group.name;
|
||||
const base = detailHref(name);
|
||||
const taken = new Set(opts.members.map((m) => m.subject));
|
||||
const self = `group:${name}`; // a group can't be a member of itself
|
||||
const options = opts.candidates.filter((c) => c.value !== self && !taken.has(c.value));
|
||||
return {
|
||||
add: { action: `${base}/members`, options },
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
delete: { action: `${base}/delete` },
|
||||
error: opts.error,
|
||||
group: { name },
|
||||
members: { action: `${base}/members/delete`, rows: opts.members },
|
||||
nav: opts.nav ?? [],
|
||||
shell: buildShellContext({
|
||||
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { label: name }],
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
menu,
|
||||
title: name,
|
||||
user: opts.user ?? null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- request handler (imperative shell) ----
|
||||
|
||||
export interface AdminGroupsDeps {
|
||||
csrfSecret: string;
|
||||
keto: KetoClient;
|
||||
kratosAdmin: KratosAdmin;
|
||||
menu: MenuConfig;
|
||||
render: (view: string, data: Record<string, unknown>) => Promise<string>;
|
||||
}
|
||||
|
||||
// Drain every page of a relation-tuple query. (Reused by the Roles screen — same membership model.)
|
||||
export async function pagedTuples(keto: KetoClient, query: RelationQuery): Promise<RelationTuple[]> {
|
||||
const out: RelationTuple[] = [];
|
||||
let pageToken: string | undefined;
|
||||
do {
|
||||
const page = await keto.listRelations({ ...query, ...(pageToken ? { pageToken } : {}) });
|
||||
out.push(...page.tuples);
|
||||
pageToken = page.nextPageToken ?? undefined;
|
||||
} while (pageToken);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Build the member-picker options (every user by email + every existing group) and the id→email map
|
||||
// detail rows render with. One Kratos page + one Keto scan; ample for an admin tool.
|
||||
export async function memberCandidates(keto: KetoClient, kratosAdmin: KratosAdmin): Promise<{ emailById: Map<string, string>; options: MemberOption[] }> {
|
||||
const { identities } = await kratosAdmin.listIdentities({ pageSize: LIST_FETCH_SIZE });
|
||||
const emailById = new Map<string, string>();
|
||||
const userOptions: MemberOption[] = [];
|
||||
for (const it of identities) {
|
||||
const trait = it.traits?.["email"];
|
||||
const email = typeof trait === "string" ? trait : it.id;
|
||||
emailById.set(it.id, email);
|
||||
userOptions.push({ label: email, value: `user:${it.id}` });
|
||||
}
|
||||
const groups = groupsFromTuples(await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS }));
|
||||
return { emailById, options: [...userOptions, ...groups.map((g) => ({ label: `${g.name} (group)`, value: `group:${g.name}` }))] };
|
||||
}
|
||||
|
||||
// A group exists exactly while it has ≥1 member.
|
||||
async function groupExists(keto: KetoClient, name: string): Promise<boolean> {
|
||||
const page = await keto.listRelations({ namespace: GROUP_NS, object: name, relation: MEMBERS, pageSize: 1 });
|
||||
return page.tuples.length > 0;
|
||||
}
|
||||
|
||||
// Decode a path segment without letting malformed %-encoding throw (→ caller treats it as not found).
|
||||
export function safeDecode(seg: string): string | null {
|
||||
try { return decodeURIComponent(seg); } catch { return null; }
|
||||
}
|
||||
|
||||
export async function handleAdminGroups(ctx: RequestContext, csrfToken: string, deps: AdminGroupsDeps): Promise<RouteResult | null> {
|
||||
const path = ctx.url.pathname;
|
||||
if (path !== ADMIN_GROUPS_BASE && !path.startsWith(`${ADMIN_GROUPS_BASE}/`)) return null;
|
||||
|
||||
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, 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, 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, nav, user }) }) };
|
||||
};
|
||||
|
||||
// /admin/groups — list (GET) · create (POST)
|
||||
if (seg.length === 0) {
|
||||
if (method === "GET") return renderList();
|
||||
if (method === "POST") {
|
||||
const name = (form!.get("name") ?? "").trim();
|
||||
const tuple = memberTuple(name, (form!.get("member") ?? "").trim());
|
||||
const reject = (error: string): Promise<RouteResult> =>
|
||||
renderForm({ error, values: { member: form!.get("member") ?? "", name } }).then((r) => ({ ...r, status: 400 }));
|
||||
if (!isValidGroupName(name)) return reject("Group names use lowercase letters, digits, dashes and underscores.");
|
||||
if (!tuple) return reject("Pick a member to add as the group's first member.");
|
||||
if (await groupExists(keto, name)) return reject("A group with that name already exists.");
|
||||
await keto.writeTuple(tuple);
|
||||
ctx.log.info("admin: group created", { actor: user.id, group: name });
|
||||
return { redirect: detailHref(name) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// /admin/groups/new — create form
|
||||
if (seg.length === 1 && seg[0] === "new" && method === "GET") return renderForm({});
|
||||
|
||||
// /admin/groups/:name …
|
||||
const name = safeDecode(seg[0]!);
|
||||
if (name === null || !isValidGroupName(name)) return { html: await render("404", { title: "Not found" }), status: 404 };
|
||||
const base = detailHref(name);
|
||||
|
||||
if (seg.length === 1 && method === "GET") return renderDetail(name);
|
||||
|
||||
if (seg.length === 2 && seg[1] === "members" && method === "POST") {
|
||||
const tuple = memberTuple(name, (form!.get("member") ?? "").trim());
|
||||
// Skip an invalid member or a self-nest (the picker already excludes both).
|
||||
if (tuple && tuple.subject_set?.object !== name) await keto.writeTuple(tuple);
|
||||
return { redirect: base };
|
||||
}
|
||||
if (seg.length === 2 && seg[1] === "delete" && method === "GET") {
|
||||
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,
|
||||
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") {
|
||||
await keto.deleteTuple({ namespace: GROUP_NS, object: name, relation: MEMBERS }); // removes every member tuple
|
||||
ctx.log.info("admin: group deleted", { actor: user.id, group: name });
|
||||
return { redirect: ADMIN_GROUPS_BASE };
|
||||
}
|
||||
if (seg.length === 3 && seg[1] === "members" && seg[2] === "delete" && method === "POST") {
|
||||
const tuple = memberTuple(name, (form!.get("member") ?? "").trim());
|
||||
if (tuple) await keto.deleteTuple(tuple);
|
||||
return { redirect: base };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
// Direct units for the admin section's pure nav + auth helpers. They're security-critical
|
||||
// (requireAdmin/guardedForm gate every admin write) and reused across all four admin screens, so pin
|
||||
// the contract here in isolation — the admin-*.test.ts HTTP tests exercise them only end-to-end.
|
||||
import assert from "node:assert/strict";
|
||||
import { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { Socket } from "node:net";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
ADMIN_PERMISSION, ADMIN_USERS_BASE, adminSection, buildConfirmModel, guardedForm, requireAdmin,
|
||||
} from "./admin-nav.ts";
|
||||
import { buildContext, type RequestContext, type User } from "../http/context.ts";
|
||||
import { CSRF_COOKIE, CSRF_FIELD, issueCsrfToken } from "../auth/csrf.ts";
|
||||
import { GuardError } from "../auth/guards.ts";
|
||||
import { DEFAULT_MENU } from "../ui/menu-config.ts";
|
||||
|
||||
const admin: User = { email: "ada@x.io", id: "u1", roles: ["admin"] };
|
||||
const member: User = { email: "bo@x.io", id: "u2", roles: ["scheduling:read"] };
|
||||
|
||||
function reqCtx(opts: { body?: string; cookie?: string; method?: string; user?: User | null } = {}): RequestContext {
|
||||
const req = new IncomingMessage(new Socket());
|
||||
req.method = opts.method ?? "GET";
|
||||
req.url = "/admin/users";
|
||||
if (opts.cookie) req.headers.cookie = opts.cookie;
|
||||
req.push(opts.body ?? null);
|
||||
if (opts.body != null) req.push(null);
|
||||
return buildContext(req, new ServerResponse(req), { user: opts.user ?? null });
|
||||
}
|
||||
|
||||
const labels = (nodes: { label: string }[]): string[] => nodes.map((n) => n.label);
|
||||
|
||||
// ---- nav helpers ----
|
||||
|
||||
test("adminSection: gated Admin header over the four screens; current marks the item + opens the header", () => {
|
||||
const plain = adminSection();
|
||||
assert.equal(plain.id, "admin");
|
||||
assert.equal(plain.permission, ADMIN_PERMISSION); // gate on the header ⇒ composeNav drops the whole subtree for a non-admin
|
||||
assert.equal(plain.open, undefined);
|
||||
assert.deepEqual(plain.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/roles", "/admin/clients"]);
|
||||
assert.deepEqual(labels(plain.children ?? []), ["Users", "Groups", "Roles", "OAuth2 clients"]);
|
||||
assert.ok(plain.children?.every((c) => c.current === undefined)); // nothing active
|
||||
|
||||
const onRoles = adminSection("roles");
|
||||
assert.equal(onRoles.open, true);
|
||||
assert.equal(onRoles.children?.find((c) => c.id === "roles")?.current, true);
|
||||
assert.equal(onRoles.children?.find((c) => c.id === "users")?.current, undefined);
|
||||
});
|
||||
|
||||
// (The in-screen admin sidebar is gone in — 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 ----
|
||||
|
||||
test("requireAdmin: anonymous → 401→/login, signed-in non-admin → 403, admin → the user", () => {
|
||||
assert.throws(() => requireAdmin(reqCtx({ user: null })), (e: unknown) => e instanceof GuardError && e.status === 401 && e.location === "/login?return_to=%2Fadmin%2Fusers"); // anonymous bounce remembers the page
|
||||
assert.throws(() => requireAdmin(reqCtx({ user: member })), (e: unknown) => e instanceof GuardError && e.status === 403);
|
||||
assert.equal(requireAdmin(reqCtx({ user: admin })), admin);
|
||||
});
|
||||
|
||||
test("guardedForm: valid double-submit → the parsed body, bad/missing token → 403, non-POST → undefined", async () => {
|
||||
const secret = "test-secret";
|
||||
const token = issueCsrfToken(secret);
|
||||
const post = (over: { body?: string; cookie?: string }) => reqCtx({ method: "POST", ...over });
|
||||
|
||||
// cookie token === submitted field, both a genuine signature → the form is returned
|
||||
const ok = await guardedForm(post({ body: `${CSRF_FIELD}=${encodeURIComponent(token)}&name=Bo`, cookie: `${CSRF_COOKIE}=${token}` }), secret);
|
||||
assert.equal(ok?.get("name"), "Bo");
|
||||
|
||||
await assert.rejects(guardedForm(post({ body: `${CSRF_FIELD}=${encodeURIComponent(token)}` }), secret), // no cookie
|
||||
(e: unknown) => e instanceof GuardError && e.status === 403);
|
||||
await assert.rejects(guardedForm(post({ body: `${CSRF_FIELD}=nope`, cookie: `${CSRF_COOKIE}=${token}` }), secret), // field ≠ cookie
|
||||
(e: unknown) => e instanceof GuardError && e.status === 403);
|
||||
|
||||
assert.equal(await guardedForm(reqCtx({ method: "GET" }), secret), undefined); // not a mutation → no gate, no body read
|
||||
});
|
||||
|
||||
// ---- confirm-page model ----
|
||||
|
||||
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", 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.equal(model.nav, nav); // the host's one global menu, passed through verbatim
|
||||
assert.equal(model.shell.title, "Delete user");
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
// The built-in admin section of the menu. `adminSection()` is the one definition of the
|
||||
// permission-gated "Admin" header (Users/Groups/Roles/clients) + its gate, composed into the single
|
||||
// global menu (`buildPluginChrome`) — 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 "../http/body.ts";
|
||||
import type { RequestContext, User } from "../http/context.ts";
|
||||
import { CSRF_FIELD, verifyCsrfRequest } from "../auth/csrf.ts";
|
||||
import { GuardError, loginRedirect } from "../auth/guards.ts";
|
||||
import { type MenuConfig } from "../ui/menu-config.ts";
|
||||
import type { NavNode } from "../ui/nav.ts";
|
||||
import { buildShellContext } from "../ui/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), 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 }[] = [
|
||||
{ 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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// 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 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;
|
||||
menu: MenuConfig;
|
||||
message: string;
|
||||
nav: NavNode[];
|
||||
title: string;
|
||||
user: User | null;
|
||||
}) {
|
||||
return {
|
||||
cancelHref: opts.cancelHref,
|
||||
confirm: { action: opts.confirmAction, label: opts.confirmLabel },
|
||||
message: opts.message,
|
||||
nav: opts.nav,
|
||||
shell: buildShellContext({ breadcrumbs: opts.breadcrumbs, csrfToken: opts.csrfToken, menu: opts.menu, title: opts.title, user: opts.user }),
|
||||
};
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
// Built-in Roles & permissions admin screen: the pure view-model + Keto builders. A role is a
|
||||
// Keto subject set (Role:<name>#members); members are users (subject_id) or groups (subject_set) —
|
||||
// "assign roles to users/groups". The "effective access" view flattens a Keto `expand` tree into the
|
||||
// distinct set of users who hold the role directly or transitively via a group. The HTTP
|
||||
// routing/gate/CSRF + live Keto/Kratos calls are exercised over HTTP in app.test.ts.
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { memberView } from "./admin-groups.ts";
|
||||
import {
|
||||
buildRoleDetailModel,
|
||||
buildRoleFormModel,
|
||||
buildRolesListModel,
|
||||
expandToEffectiveUsers,
|
||||
isValidRoleName,
|
||||
roleMemberTuple,
|
||||
} from "./admin-roles.ts";
|
||||
import type { ExpandTree, RelationTuple } from "../auth/keto-client.ts";
|
||||
|
||||
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
|
||||
const userTuple = (role: string, n: number): RelationTuple =>
|
||||
({ namespace: "Role", object: role, relation: "members", subject_id: `user:${uid(n)}` });
|
||||
const groupTuple = (role: string, group: string): RelationTuple =>
|
||||
({ namespace: "Role", object: role, relation: "members", subject_set: { namespace: "Group", object: group, relation: "members" } });
|
||||
|
||||
test("isValidRoleName + roleMemberTuple map the form value to a Role tuple over a user/group (else null)", () => {
|
||||
for (const ok of ["admin", "editor", "team-a", "a1_b9"]) assert.equal(isValidRoleName(ok), true, ok);
|
||||
for (const bad of ["", "Admin", "a b", "-bad", "a".repeat(65)]) assert.equal(isValidRoleName(bad), false, bad);
|
||||
|
||||
assert.deepEqual(roleMemberTuple("editor", `user:${uid(2)}`), { namespace: "Role", object: "editor", relation: "members", subject_id: `user:${uid(2)}` });
|
||||
assert.deepEqual(roleMemberTuple("editor", "group:eng"), { namespace: "Role", object: "editor", relation: "members", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
|
||||
for (const bad of ["", "user:not-a-uuid", "group:Bad Name", "nope:x"]) assert.equal(roleMemberTuple("editor", bad), null, bad);
|
||||
});
|
||||
|
||||
test("expandToEffectiveUsers flattens an expand tree → sorted distinct user ids, transitive through groups", () => {
|
||||
// The subject rides on each node's `tuple` (Keto v26.2.0 shape, verified live).
|
||||
const leaf = (n: number): ExpandTree => ({ tuple: { namespace: "", object: "", relation: "", subject_id: `user:${uid(n)}` }, type: "leaf" });
|
||||
const tree: ExpandTree = {
|
||||
children: [
|
||||
leaf(1), // direct
|
||||
{
|
||||
children: [leaf(2), leaf(1)], // via group + dup
|
||||
tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Group", object: "eng", relation: "members" } }, // a member group, not a user
|
||||
type: "union",
|
||||
},
|
||||
],
|
||||
tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Role", object: "admin", relation: "members" } },
|
||||
type: "union",
|
||||
};
|
||||
assert.deepEqual(expandToEffectiveUsers(tree), [uid(1), uid(2)]);
|
||||
assert.deepEqual(expandToEffectiveUsers(null), []);
|
||||
assert.deepEqual(expandToEffectiveUsers({ type: "leaf" }), []); // an empty role
|
||||
});
|
||||
|
||||
test("buildRolesListModel filters by search, sorts, paginates; the name links to the detail page", () => {
|
||||
const roles = Array.from({ length: 30 }, (_, i) => ({ memberCount: i + 1, name: `role-${String(i).padStart(2, "0")}` }));
|
||||
|
||||
const all = buildRolesListModel({ roles, url: "http://x/admin/roles" });
|
||||
assert.equal(all.pagination.summary.total, 30);
|
||||
assert.equal(all.table.rows.length, 25); // default page size
|
||||
assert.equal(all.shell.title, "Roles");
|
||||
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
|
||||
assert.equal(first.rowHeader.text, "role-00");
|
||||
assert.equal(first.rowHeader.href, "/admin/roles/role-00");
|
||||
|
||||
const one = buildRolesListModel({ roles, url: "http://x/admin/roles?q=role-07" });
|
||||
assert.equal(one.pagination.summary.total, 1);
|
||||
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
|
||||
|
||||
const desc = buildRolesListModel({ roles, url: "http://x/admin/roles?sort=-members" });
|
||||
assert.equal((desc.table.rows[0]!.cells[0] as { rowHeader: { text: string } }).rowHeader.text, "role-29");
|
||||
});
|
||||
|
||||
test("buildRoleFormModel: a create form with a required name field + member options (user or group)", () => {
|
||||
const options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
|
||||
const m = buildRoleFormModel({ csrfToken: "tok.sig", memberOptions: options });
|
||||
assert.equal(m.shell.title, "New role");
|
||||
assert.equal(m.form.action, "/admin/roles");
|
||||
assert.equal(m.form.submitLabel, "Create role");
|
||||
assert.equal(m.form.csrfToken, "tok.sig");
|
||||
assert.equal(m.form.nameField.required, true);
|
||||
assert.deepEqual(m.form.memberOptions, options);
|
||||
|
||||
const err = buildRoleFormModel({ error: "That name is taken.", memberOptions: options, values: { member: "group:eng", name: "Admin" } });
|
||||
assert.equal(err.error, "That name is taken.");
|
||||
assert.equal(err.form.nameField.value, "Admin");
|
||||
assert.equal(err.form.selectedMember, "group:eng");
|
||||
});
|
||||
|
||||
test("buildRoleDetailModel: members → rows, add-options exclude current members, effective access listed, actions wired", () => {
|
||||
const members = [memberView(userTuple("admin", 1), new Map([[uid(1), "ada@example.com"]])), memberView(groupTuple("admin", "eng"), new Map())];
|
||||
const candidates = [
|
||||
{ label: "ada@example.com", value: `user:${uid(1)}` }, // already a member → excluded
|
||||
{ label: "grace@example.com", value: `user:${uid(2)}` },
|
||||
{ label: "eng (group)", value: "group:eng" }, // already a member → excluded
|
||||
{ label: "ops (group)", value: "group:ops" },
|
||||
];
|
||||
const effective = [{ label: "ada@example.com" }, { label: "grace@example.com" }]; // ada direct, grace via eng
|
||||
const m = buildRoleDetailModel({ candidates, effective, members, role: { name: "admin" } });
|
||||
assert.equal(m.shell.title, "admin");
|
||||
assert.equal(m.members.rows.length, 2);
|
||||
assert.equal(m.members.action, "/admin/roles/admin/members/delete");
|
||||
assert.equal(m.add.action, "/admin/roles/admin/members");
|
||||
assert.deepEqual(m.add.options.map((o) => o.value), [`user:${uid(2)}`, "group:ops"]);
|
||||
assert.deepEqual(m.effective.map((e) => e.label), ["ada@example.com", "grace@example.com"]);
|
||||
assert.equal(m.delete.action, "/admin/roles/admin/delete");
|
||||
});
|
||||
@@ -1,396 +0,0 @@
|
||||
// Built-in Roles & permissions admin screen: list / create / delete Keto roles and assign
|
||||
// them to users and groups. A role is a Keto subject set `Role:<name>#members` (OPL: members are users
|
||||
// or groups, resolved transitively) — the source of truth for the JWT `roles` claim. It shares the
|
||||
// Groups screen's membership model, so the pure helpers (parseSubject, member pickers, tuple paging)
|
||||
// are reused from admin-groups. The role-specific piece is the **effective access** view:
|
||||
// `keto.expand(Role:<name>#members)` flattened to the distinct users who hold the role directly or via
|
||||
// a group — matching what login projects into the JWT (login.ts readRoles). Writes go only to Keto;
|
||||
// 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, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
|
||||
import {
|
||||
type GroupView,
|
||||
groupsFromTuples,
|
||||
isValidGroupName,
|
||||
memberCandidates,
|
||||
type MemberOption,
|
||||
type MemberView,
|
||||
memberView,
|
||||
pagedTuples,
|
||||
parseSubject,
|
||||
safeDecode,
|
||||
} from "./admin-groups.ts";
|
||||
import type { FieldConfig } from "./admin-users.ts";
|
||||
import type { RequestContext, User } from "../http/context.ts";
|
||||
import type { ExpandTree, KetoClient, RelationTuple } from "../auth/keto-client.ts";
|
||||
import type { KratosAdmin } from "../auth/kratos-admin.ts";
|
||||
import { parseListQuery } from "../ui/list-query.ts";
|
||||
import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
|
||||
import type { NavNode } from "../ui/nav.ts";
|
||||
import { paginate } from "../ui/paginate.ts";
|
||||
import type { RouteResult } from "../plugin-host/plugin.ts";
|
||||
import { buildShellContext } from "../ui/shell-context.ts";
|
||||
|
||||
const ROLE_NS = "Role";
|
||||
const MEMBERS = "members";
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
const PAGE_SIZES = [25, 50, 100];
|
||||
// Expand far past any sane group-nesting depth so the effective-access view never silently
|
||||
// under-reports the deepest members (Keto's own default is shallow).
|
||||
const EXPAND_MAX_DEPTH = 50;
|
||||
|
||||
// A role and a group share the URL-safe name rule and the user|group membership model.
|
||||
export type RoleView = GroupView;
|
||||
export const isValidRoleName = isValidGroupName;
|
||||
export const rolesFromTuples = groupsFromTuples;
|
||||
export interface EffectiveUser {
|
||||
label: string; // email (or the raw id when unresolved)
|
||||
}
|
||||
|
||||
// The full membership tuple for assigning/revoking `value` to/from `role` (null if value is invalid).
|
||||
export function roleMemberTuple(role: string, value: string): RelationTuple | null {
|
||||
const subject = parseSubject(value);
|
||||
return subject ? { namespace: ROLE_NS, object: role, relation: MEMBERS, ...subject } : null;
|
||||
}
|
||||
|
||||
// Flatten a Keto `expand` tree → the sorted, distinct user ids that effectively hold the role
|
||||
// (direct leaves + users reached through member groups, any depth). The subject rides on each
|
||||
// node's `tuple`; subject-set nodes (the groups) contribute nothing directly — their members
|
||||
// surface as leaves under them.
|
||||
export function expandToEffectiveUsers(tree: ExpandTree | null | undefined): string[] {
|
||||
const ids = new Set<string>();
|
||||
const walk = (node?: ExpandTree | null): void => {
|
||||
if (!node) return;
|
||||
const subjectId = node.tuple?.subject_id;
|
||||
if (subjectId?.startsWith("user:")) ids.add(subjectId.slice("user:".length));
|
||||
node.children?.forEach(walk);
|
||||
};
|
||||
walk(tree);
|
||||
return [...ids].sort();
|
||||
}
|
||||
|
||||
// ---- list view model ----
|
||||
|
||||
interface ListState {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
q: string;
|
||||
sort: string | null;
|
||||
}
|
||||
|
||||
const SORT: Record<string, (r: RoleView) => number | string> = {
|
||||
members: (r) => r.memberCount,
|
||||
name: (r) => r.name,
|
||||
};
|
||||
const COLUMNS = [
|
||||
{ key: "name", label: "Role" },
|
||||
{ key: "members", label: "Members" },
|
||||
];
|
||||
|
||||
function detailHref(name: string): string {
|
||||
return `${ADMIN_ROLES_BASE}/${encodeURIComponent(name)}`;
|
||||
}
|
||||
|
||||
function listHref(state: ListState, overrides: Partial<ListState> = {}): string {
|
||||
const s = { ...state, ...overrides };
|
||||
const p = new URLSearchParams();
|
||||
if (s.q) p.set("q", s.q);
|
||||
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 ? `${ADMIN_ROLES_BASE}?${qs}` : ADMIN_ROLES_BASE;
|
||||
}
|
||||
|
||||
export function buildRolesListModel(opts: {
|
||||
csrfToken?: string;
|
||||
menu?: MenuConfig;
|
||||
nav?: NavNode[];
|
||||
roles: RoleView[];
|
||||
url: URL | URLSearchParams | string;
|
||||
user?: User | null;
|
||||
}) {
|
||||
const menu = opts.menu ?? DEFAULT_MENU;
|
||||
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
||||
const sort = query.sort && SORT[query.sort.field] ? query.sort : null;
|
||||
const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null;
|
||||
const needle = query.q.toLowerCase();
|
||||
|
||||
let list = opts.roles.filter((r) => !needle || r.name.toLowerCase().includes(needle));
|
||||
if (sort) {
|
||||
const get = SORT[sort.field]!;
|
||||
const dir = sort.dir === "desc" ? -1 : 1;
|
||||
list = [...list].sort((a, b) => {
|
||||
const av = get(a), bv = get(b);
|
||||
const cmp = typeof av === "number" && typeof bv === "number" ? av - bv : String(av).localeCompare(String(bv));
|
||||
return cmp * 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: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken };
|
||||
|
||||
return {
|
||||
filterBar: listFilterBar(state),
|
||||
nav: opts.nav ?? [],
|
||||
pagination: listPagination(state, page),
|
||||
shell: buildShellContext({
|
||||
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Admin" }, { label: "Roles" }],
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
menu,
|
||||
title: "Roles",
|
||||
user: opts.user ?? null,
|
||||
}),
|
||||
table: listTable(rows, state, sort),
|
||||
};
|
||||
}
|
||||
|
||||
function listTable(rows: RoleView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null) {
|
||||
return {
|
||||
caption: "Roles",
|
||||
columns: COLUMNS.map((c) => {
|
||||
const dir = sort && sort.field === c.key ? sort.dir : undefined;
|
||||
const next = dir === "asc" ? `-${c.key}` : c.key;
|
||||
return { href: listHref(state, { page: 1, sort: next }), label: c.label, sort: dir, sortable: true };
|
||||
}),
|
||||
rows: rows.map((r) => ({
|
||||
cells: [{ rowHeader: { href: detailHref(r.name), text: r.name } }, String(r.memberCount)],
|
||||
name: r.name,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function listFilterBar(state: ListState) {
|
||||
const pills: { label: string; remove: string; value: string }[] = [];
|
||||
if (state.q) pills.push({ label: "Search", remove: listHref(state, { page: 1, q: "" }), value: state.q });
|
||||
return {
|
||||
applyLabel: "Apply",
|
||||
clearHref: ADMIN_ROLES_BASE,
|
||||
label: "Filter roles",
|
||||
pills,
|
||||
rows: [[
|
||||
{ label: "Search roles", name: "q", placeholder: "Search role name…", type: "search", value: state.q },
|
||||
{ type: "spacer" },
|
||||
]],
|
||||
};
|
||||
}
|
||||
|
||||
function listPagination(state: ListState, page: ReturnType<typeof paginate>) {
|
||||
const hidden: { name: string; value: string }[] = [];
|
||||
if (state.q) hidden.push({ name: "q", value: state.q });
|
||||
if (state.sort) hidden.push({ name: "sort", value: state.sort });
|
||||
return {
|
||||
label: "Roles pagination",
|
||||
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
|
||||
pages: page.pages.map((p) =>
|
||||
p.ellipsis ? { ellipsis: true }
|
||||
: p.current ? { current: true, label: String(p.page) }
|
||||
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
|
||||
prev: { href: page.prev ? listHref(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 },
|
||||
};
|
||||
}
|
||||
|
||||
// ---- create form + detail view models ----
|
||||
|
||||
export function buildRoleFormModel(opts: {
|
||||
csrfToken?: string;
|
||||
error?: string;
|
||||
memberOptions: MemberOption[];
|
||||
menu?: MenuConfig;
|
||||
nav?: NavNode[];
|
||||
user?: User | null;
|
||||
values?: { member?: string; name?: string };
|
||||
}) {
|
||||
const menu = opts.menu ?? DEFAULT_MENU;
|
||||
const nameField: FieldConfig = {
|
||||
autocomplete: "off", hint: "Lowercase letters, digits, dashes and underscores.", icon: "i-shield",
|
||||
id: "name", label: "Role name", name: "name", required: true, value: opts.values?.name ?? "",
|
||||
};
|
||||
return {
|
||||
error: opts.error,
|
||||
form: {
|
||||
action: ADMIN_ROLES_BASE,
|
||||
cancelHref: ADMIN_ROLES_BASE,
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
memberOptions: opts.memberOptions,
|
||||
nameField,
|
||||
selectedMember: opts.values?.member ?? "",
|
||||
submitLabel: "Create role",
|
||||
},
|
||||
nav: opts.nav ?? [],
|
||||
shell: buildShellContext({
|
||||
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { label: "New" }],
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
menu,
|
||||
title: "New role",
|
||||
user: opts.user ?? null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRoleDetailModel(opts: {
|
||||
candidates: MemberOption[];
|
||||
csrfToken?: string;
|
||||
effective: EffectiveUser[];
|
||||
error?: string;
|
||||
members: MemberView[];
|
||||
menu?: MenuConfig;
|
||||
nav?: NavNode[];
|
||||
role: { name: string };
|
||||
user?: User | null;
|
||||
}) {
|
||||
const menu = opts.menu ?? DEFAULT_MENU;
|
||||
const name = opts.role.name;
|
||||
const base = detailHref(name);
|
||||
const taken = new Set(opts.members.map((m) => m.subject));
|
||||
const options = opts.candidates.filter((c) => !taken.has(c.value)); // members are users/groups, never the role itself
|
||||
return {
|
||||
add: { action: `${base}/members`, options },
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
delete: { action: `${base}/delete` },
|
||||
effective: opts.effective,
|
||||
error: opts.error,
|
||||
members: { action: `${base}/members/delete`, rows: opts.members },
|
||||
nav: opts.nav ?? [],
|
||||
role: { name },
|
||||
shell: buildShellContext({
|
||||
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { label: name }],
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
menu,
|
||||
title: name,
|
||||
user: opts.user ?? null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- request handler (imperative shell) ----
|
||||
|
||||
export interface AdminRolesDeps {
|
||||
csrfSecret: string;
|
||||
keto: KetoClient;
|
||||
kratosAdmin: KratosAdmin;
|
||||
menu: MenuConfig;
|
||||
render: (view: string, data: Record<string, unknown>) => Promise<string>;
|
||||
revoke?: (sub: string) => void; // optional instant-revoke: assigning/unassigning a *user* kills their live tokens
|
||||
}
|
||||
|
||||
// instant-revoke: a role change for a `user:<id>` member must take effect now, so revoke that
|
||||
// user's live tokens (a re-mint then re-reads roles from Keto). A `group:<name>` change is
|
||||
// transitive across many users — left to lag (documented), so only direct user members revoke.
|
||||
function revokeUserMember(deps: AdminRolesDeps, member: string): void {
|
||||
if (deps.revoke && member.startsWith("user:")) deps.revoke(member.slice("user:".length));
|
||||
}
|
||||
|
||||
// A role exists exactly while it has ≥1 member (Keto has no create-object).
|
||||
async function roleExists(keto: KetoClient, name: string): Promise<boolean> {
|
||||
const page = await keto.listRelations({ namespace: ROLE_NS, object: name, relation: MEMBERS, pageSize: 1 });
|
||||
return page.tuples.length > 0;
|
||||
}
|
||||
|
||||
// The distinct users who effectively hold the role (expand → flatten → label by email). Skipped for
|
||||
// an empty role (no member tuples) so we don't expand a non-existent Keto object.
|
||||
async function effectiveUsers(keto: KetoClient, name: string, hasMembers: boolean, emailById: Map<string, string>): Promise<EffectiveUser[]> {
|
||||
if (!hasMembers) return [];
|
||||
const tree = await keto.expand({ namespace: ROLE_NS, object: name, relation: MEMBERS }, { maxDepth: EXPAND_MAX_DEPTH });
|
||||
return expandToEffectiveUsers(tree)
|
||||
.map((id) => ({ label: emailById.get(id) ?? `user:${id}` }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
export async function handleAdminRoles(ctx: RequestContext, csrfToken: string, deps: AdminRolesDeps): Promise<RouteResult | null> {
|
||||
const path = ctx.url.pathname;
|
||||
if (path !== ADMIN_ROLES_BASE && !path.startsWith(`${ADMIN_ROLES_BASE}/`)) return null;
|
||||
|
||||
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, 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, 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, nav, role: { name }, user, ...(error ? { error } : {}) }) });
|
||||
return error ? { html, status: 400 } : { html };
|
||||
};
|
||||
|
||||
// /admin/roles — list (GET) · create (POST)
|
||||
if (seg.length === 0) {
|
||||
if (method === "GET") return renderList();
|
||||
if (method === "POST") {
|
||||
const name = (form!.get("name") ?? "").trim();
|
||||
const member = (form!.get("member") ?? "").trim();
|
||||
const tuple = roleMemberTuple(name, member);
|
||||
const reject = (error: string): Promise<RouteResult> =>
|
||||
renderForm({ error, values: { member, name } }).then((r) => ({ ...r, status: 400 }));
|
||||
if (!isValidRoleName(name)) return reject("Role names use lowercase letters, digits, dashes and underscores.");
|
||||
if (!tuple) return reject("Pick a user or group to assign the role to.");
|
||||
if (await roleExists(keto, name)) return reject("A role with that name already exists.");
|
||||
await keto.writeTuple(tuple);
|
||||
revokeUserMember(deps, member);
|
||||
ctx.log.info("admin: role created + first member assigned", { actor: user.id, member, role: name });
|
||||
return { redirect: detailHref(name) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// /admin/roles/new — create form
|
||||
if (seg.length === 1 && seg[0] === "new" && method === "GET") return renderForm({});
|
||||
|
||||
// /admin/roles/:name …
|
||||
const name = safeDecode(seg[0]!);
|
||||
if (name === null || !isValidRoleName(name)) return { html: await render("404", { title: "Not found" }), status: 404 };
|
||||
const base = detailHref(name);
|
||||
|
||||
if (seg.length === 1 && method === "GET") return renderDetail(name);
|
||||
|
||||
if (seg.length === 2 && seg[1] === "members" && method === "POST") {
|
||||
const member = (form!.get("member") ?? "").trim();
|
||||
const tuple = roleMemberTuple(name, member);
|
||||
if (tuple) { await keto.writeTuple(tuple); revokeUserMember(deps, member); ctx.log.info("admin: role assigned", { actor: user.id, member, role: name }); } // the picker only offers real users/groups
|
||||
return { redirect: base };
|
||||
}
|
||||
if (seg.length === 2 && seg[1] === "delete" && method === "GET") {
|
||||
// Self-protection: deleting the admin role removes everyone's admin — refuse it outright.
|
||||
if (name === ADMIN_PERMISSION) return renderDetail(name, "The admin role can't be deleted — it would remove all admin access.");
|
||||
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,
|
||||
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") {
|
||||
if (name === ADMIN_PERMISSION) return renderDetail(name, "The admin role can't be deleted — it would remove all admin access.");
|
||||
await keto.deleteTuple({ namespace: ROLE_NS, object: name, relation: MEMBERS }); // removes every member tuple
|
||||
// a whole-role delete drops many members at once — left to lag like a group change; the
|
||||
// per-member unassign above is the instant-revoke path.
|
||||
ctx.log.info("admin: role deleted", { actor: user.id, role: name });
|
||||
return { redirect: ADMIN_ROLES_BASE };
|
||||
}
|
||||
if (seg.length === 3 && seg[1] === "members" && seg[2] === "delete" && method === "POST") {
|
||||
const member = (form!.get("member") ?? "").trim();
|
||||
// Self-protection: don't let an admin revoke their own *direct* admin grant (would lock them out).
|
||||
// Admin held only via a group isn't covered here — the robust "last effective admin" check is deferred.
|
||||
if (name === ADMIN_PERMISSION && member === `user:${user.id}`) return renderDetail(name, "You can't revoke your own admin access.");
|
||||
const tuple = roleMemberTuple(name, member);
|
||||
if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(deps, member); ctx.log.info("admin: role unassigned", { actor: user.id, member, role: name }); }
|
||||
return { redirect: base };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
// Built-in Users admin screen: the pure view-model + Kratos-payload builders. The HTTP
|
||||
// routing/gate/CSRF + live Kratos calls are exercised over HTTP in app.test.ts.
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
buildUserFormModel,
|
||||
buildUsersListModel,
|
||||
createIdentityPayload,
|
||||
setStatePayload,
|
||||
toUserView,
|
||||
updateIdentityPayload,
|
||||
} from "./admin-users.ts";
|
||||
import type { Identity } from "../auth/kratos-admin.ts";
|
||||
|
||||
const id = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
|
||||
const identity = (n: number, over: Partial<Identity> = {}): Identity => ({
|
||||
id: id(n),
|
||||
schema_id: "default",
|
||||
state: "active",
|
||||
traits: { email: `user${n}@example.com`, name: { first: `First${n}`, last: `Last${n}` } },
|
||||
...over,
|
||||
});
|
||||
|
||||
test("toUserView maps traits → name/initials/email/state; falls back to the email local part", () => {
|
||||
const named = toUserView(identity(1));
|
||||
assert.equal(named.name, "First1 Last1");
|
||||
assert.equal(named.initials, "FL");
|
||||
assert.equal(named.email, "user1@example.com");
|
||||
assert.equal(named.state, "active");
|
||||
|
||||
// No name trait → derive from the email local part.
|
||||
const bare = toUserView({ id: id(2), state: "inactive", traits: { email: "ada.lovelace@example.com" } });
|
||||
assert.equal(bare.name, "ada.lovelace");
|
||||
assert.equal(bare.initials, "AD");
|
||||
assert.equal(bare.state, "inactive");
|
||||
});
|
||||
|
||||
test("buildUsersListModel filters by search + status, sorts, and paginates", () => {
|
||||
const people = Array.from({ length: 30 }, (_, i) => identity(i + 1, { state: i % 2 ? "inactive" : "active" }));
|
||||
|
||||
const all = buildUsersListModel({ identities: people, url: "http://x/admin/users" });
|
||||
assert.equal(all.pagination.summary.total, 30);
|
||||
assert.equal(all.table.rows.length, 25); // default page size
|
||||
assert.equal(all.shell.title, "Users");
|
||||
|
||||
// Search narrows to one and shows a pill.
|
||||
const one = buildUsersListModel({ identities: people, url: "http://x/admin/users?q=user7%40example.com" });
|
||||
assert.equal(one.pagination.summary.total, 1);
|
||||
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
|
||||
|
||||
// Status filter keeps only inactive identities.
|
||||
const inactive = buildUsersListModel({ identities: people, url: "http://x/admin/users?status=inactive" });
|
||||
assert.equal(inactive.pagination.summary.total, 15);
|
||||
assert.deepEqual(inactive.filterBar.pills.map((p) => p.label), ["Status"]);
|
||||
|
||||
// Sort by email descending reverses the order.
|
||||
const emailOf = (m: ReturnType<typeof buildUsersListModel>, i: number) => (m.table.rows[i]!.cells[1]) as string;
|
||||
const desc = buildUsersListModel({ identities: people, url: "http://x/admin/users?sort=-email" });
|
||||
assert.ok(emailOf(desc, 0) > emailOf(desc, 1));
|
||||
|
||||
// Edit link points at the per-identity route.
|
||||
assert.match(all.table.rows[0]!.actions![0]!.href!, new RegExp(`/admin/users/${people[0]!.id}$`));
|
||||
});
|
||||
|
||||
test("buildUserFormModel: create mode has an editable email + password, no edit actions", () => {
|
||||
const m = buildUserFormModel({ csrfToken: "tok.sig" });
|
||||
assert.equal(m.shell.title, "New user");
|
||||
assert.equal(m.form.action, "/admin/users");
|
||||
assert.equal(m.form.submitLabel, "Create user");
|
||||
assert.equal(m.form.csrfToken, "tok.sig");
|
||||
const names = m.form.fields.map((f) => f.name);
|
||||
assert.deepEqual(names, ["email", "first", "last", "password"]);
|
||||
assert.equal(m.form.fields.find((f) => f.name === "email")!.readonly, undefined); // editable on create
|
||||
assert.equal(m.edit, undefined);
|
||||
});
|
||||
|
||||
test("buildUserFormModel: edit mode prefills, locks email, and exposes state/delete/recovery actions", () => {
|
||||
const m = buildUserFormModel({ identity: identity(3) });
|
||||
assert.equal(m.shell.title, "Edit user");
|
||||
assert.equal(m.form.action, `/admin/users/${id(3)}`);
|
||||
assert.equal(m.form.submitLabel, "Save changes");
|
||||
const email = m.form.fields.find((f) => f.name === "email")!;
|
||||
assert.equal(email.value, "user3@example.com");
|
||||
assert.equal(email.readonly, true);
|
||||
assert.ok(!m.form.fields.some((f) => f.name === "password")); // no password field when editing
|
||||
assert.equal(m.form.fields.find((f) => f.name === "first")!.value, "First3");
|
||||
assert.equal(m.edit!.nextLabel, "Deactivate"); // active → offers Deactivate
|
||||
assert.match(m.edit!.deleteAction, /\/delete$/);
|
||||
assert.match(m.edit!.recoveryAction, /\/recovery$/);
|
||||
|
||||
// An inactive identity offers Reactivate.
|
||||
assert.equal(buildUserFormModel({ identity: identity(4, { state: "inactive" }) }).edit!.nextLabel, "Reactivate");
|
||||
});
|
||||
|
||||
test("createIdentityPayload: schema/state/traits, name only when given, password only when set", () => {
|
||||
assert.deepEqual(createIdentityPayload({ email: "a@b.c", first: "Ada", last: "Lovelace", password: "" }), {
|
||||
schema_id: "default",
|
||||
state: "active",
|
||||
traits: { email: "a@b.c", name: { first: "Ada", last: "Lovelace" } },
|
||||
});
|
||||
// No name parts → omit the name trait; a password → credentials.
|
||||
assert.deepEqual(createIdentityPayload({ email: "a@b.c", first: "", last: "", password: "s3cret" }), {
|
||||
credentials: { password: { config: { password: "s3cret" } } },
|
||||
schema_id: "default",
|
||||
state: "active",
|
||||
traits: { email: "a@b.c" },
|
||||
});
|
||||
});
|
||||
|
||||
test("updateIdentityPayload preserves email/schema/state and rewrites the name; setStatePayload flips state", () => {
|
||||
const existing = identity(5);
|
||||
assert.deepEqual(updateIdentityPayload(existing, { email: "ignored@x", first: "New", last: "Name", password: "" }), {
|
||||
schema_id: "default",
|
||||
state: "active",
|
||||
traits: { email: "user5@example.com", name: { first: "New", last: "Name" } }, // email kept, not the submitted one
|
||||
});
|
||||
// Clearing both name parts drops the name trait.
|
||||
assert.deepEqual(updateIdentityPayload(existing, { email: "", first: "", last: "", password: "" }).traits, { email: "user5@example.com" });
|
||||
|
||||
assert.deepEqual(setStatePayload(existing, "inactive"), {
|
||||
schema_id: "default",
|
||||
state: "inactive",
|
||||
traits: { email: "user5@example.com", name: { first: "First5", last: "Last5" } },
|
||||
});
|
||||
});
|
||||
@@ -1,405 +0,0 @@
|
||||
// Built-in Users admin screen: list Kratos identities (filter/sort/paginate) +
|
||||
// create/edit/deactivate/delete/trigger-recovery. Writes go only to Kratos via the admin client
|
||||
// (README "stateless"). Pure builders turn identities + the request URL into building-block view
|
||||
// models; `handleAdminUsers` is the imperative shell app.ts dispatches to — gated admin-only,
|
||||
// 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, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
|
||||
import type { RequestContext, User } from "../http/context.ts";
|
||||
import type { Identity, KratosAdmin, RecoveryCode } from "../auth/kratos-admin.ts";
|
||||
import { KratosError } from "../auth/kratos-public.ts";
|
||||
import { parseListQuery } from "../ui/list-query.ts";
|
||||
import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
|
||||
import type { NavNode } from "../ui/nav.ts";
|
||||
import { paginate } from "../ui/paginate.ts";
|
||||
import type { RouteResult } from "../plugin-host/plugin.ts";
|
||||
import { buildShellContext } from "../ui/shell-context.ts";
|
||||
|
||||
const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
const PAGE_SIZES = [25, 50, 100];
|
||||
// One Kratos page is fetched and filtered/sorted/paged in memory — the admin API offers no
|
||||
// full-text search or sort. Ample for an admin tool; raise if a deployment outgrows it.
|
||||
const LIST_FETCH_SIZE = 250;
|
||||
const STATE_TONE: Record<string, string> = { active: "pos", inactive: "warn" };
|
||||
|
||||
export interface UserView {
|
||||
email: string;
|
||||
id: string;
|
||||
initials: string;
|
||||
name: string;
|
||||
state: string; // Kratos identity state: "active" | "inactive"
|
||||
}
|
||||
|
||||
export interface UserInput {
|
||||
email: string;
|
||||
first: string;
|
||||
last: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
const cap = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1);
|
||||
|
||||
function nameParts(identity: Identity): { first: string; last: string } {
|
||||
const nm = ((identity.traits?.name ?? {}) as { first?: unknown; last?: unknown });
|
||||
return {
|
||||
first: typeof nm.first === "string" ? nm.first.trim() : "",
|
||||
last: typeof nm.last === "string" ? nm.last.trim() : "",
|
||||
};
|
||||
}
|
||||
|
||||
export function toUserView(identity: Identity): UserView {
|
||||
const email = typeof identity.traits?.email === "string" ? (identity.traits.email as string) : "";
|
||||
const { first, last } = nameParts(identity);
|
||||
const full = `${first} ${last}`.trim();
|
||||
const name = full || email.split("@")[0] || email;
|
||||
const initials = (first && last ? first[0]! + last[0]! : name.slice(0, 2) || "U").toUpperCase();
|
||||
return { email, id: identity.id, initials, name, state: identity.state ?? "active" };
|
||||
}
|
||||
|
||||
// ---- Kratos payloads ----
|
||||
|
||||
export function createIdentityPayload(input: UserInput): Record<string, unknown> {
|
||||
const traits: Record<string, unknown> = { email: input.email };
|
||||
if (input.first || input.last) traits.name = { first: input.first, last: input.last };
|
||||
const payload: Record<string, unknown> = { schema_id: SCHEMA_ID, state: "active", traits };
|
||||
if (input.password) payload.credentials = { password: { config: { password: input.password } } };
|
||||
return payload;
|
||||
}
|
||||
|
||||
// A full-identity PUT must carry schema/state/traits. Keep the existing email (the form's email is
|
||||
// read-only) and other traits; rewrite name from the input (cleared ⇒ drop it).
|
||||
export function updateIdentityPayload(identity: Identity, input: UserInput): Record<string, unknown> {
|
||||
const traits: Record<string, unknown> = { ...(identity.traits ?? {}) };
|
||||
if (input.first || input.last) traits.name = { first: input.first, last: input.last };
|
||||
else delete traits.name;
|
||||
return { schema_id: identity.schema_id ?? SCHEMA_ID, state: identity.state ?? "active", traits };
|
||||
}
|
||||
|
||||
export function setStatePayload(identity: Identity, state: "active" | "inactive"): Record<string, unknown> {
|
||||
return { schema_id: identity.schema_id ?? SCHEMA_ID, state, traits: { ...(identity.traits ?? {}) } };
|
||||
}
|
||||
|
||||
// ---- view models ----
|
||||
|
||||
interface ListState {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
q: string;
|
||||
sort: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
const SORT: Record<string, (u: UserView) => string> = {
|
||||
email: (u) => u.email,
|
||||
name: (u) => u.name,
|
||||
status: (u) => u.state,
|
||||
};
|
||||
const COLUMNS = [
|
||||
{ key: "name", label: "Name" },
|
||||
{ key: "email", label: "Email" },
|
||||
{ key: "status", label: "Status" },
|
||||
];
|
||||
|
||||
// Canonical list URL from the current state + per-link overrides; omits defaults so links stay tidy.
|
||||
function listHref(state: ListState, overrides: Partial<ListState> = {}): 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.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 ? `${ADMIN_USERS_BASE}?${qs}` : ADMIN_USERS_BASE;
|
||||
}
|
||||
|
||||
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;
|
||||
}) {
|
||||
const menu = opts.menu ?? DEFAULT_MENU;
|
||||
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
||||
const status = query.filters.status?.[0] ?? "all";
|
||||
const sort = query.sort && SORT[query.sort.field] ? query.sort : null;
|
||||
const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null;
|
||||
const needle = query.q.toLowerCase();
|
||||
|
||||
const all = opts.identities.map(toUserView);
|
||||
let list = all.filter((u) =>
|
||||
(!needle || u.name.toLowerCase().includes(needle) || u.email.toLowerCase().includes(needle)) &&
|
||||
(status === "all" || u.state === status));
|
||||
if (sort) {
|
||||
const get = SORT[sort.field] as (u: UserView) => string;
|
||||
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: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken, status };
|
||||
|
||||
return {
|
||||
filterBar: listFilterBar(state, all.length),
|
||||
nav: opts.nav ?? [],
|
||||
pagination: listPagination(state, page),
|
||||
shell: buildShellContext({
|
||||
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Admin" }, { label: "Users" }],
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
menu,
|
||||
title: "Users",
|
||||
user: opts.user ?? null,
|
||||
}),
|
||||
table: listTable(rows, state, sort),
|
||||
};
|
||||
}
|
||||
|
||||
function listTable(rows: UserView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null) {
|
||||
return {
|
||||
actions: true,
|
||||
caption: "Users",
|
||||
columns: COLUMNS.map((c) => {
|
||||
const dir = sort && sort.field === c.key ? sort.dir : undefined;
|
||||
const next = dir === "asc" ? `-${c.key}` : c.key; // asc→desc, else→asc
|
||||
return { href: listHref(state, { page: 1, sort: next }), label: c.label, sort: dir, sortable: true };
|
||||
}),
|
||||
rows: rows.map((u) => ({
|
||||
actions: [{ href: `${ADMIN_USERS_BASE}/${encodeURIComponent(u.id)}`, icon: "i-edit", label: "Edit" }],
|
||||
cells: [
|
||||
{ user: { initials: u.initials, name: u.name } },
|
||||
u.email,
|
||||
{ badge: { label: cap(u.state), tone: STATE_TONE[u.state] ?? "info" } },
|
||||
],
|
||||
name: u.name,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function listFilterBar(state: ListState, total: number) {
|
||||
const pills: { label: string; remove: string; value: string }[] = [];
|
||||
if (state.q) pills.push({ label: "Search", remove: listHref(state, { page: 1, q: "" }), value: state.q });
|
||||
if (state.status !== "all") pills.push({ label: "Status", remove: listHref(state, { page: 1, status: "all" }), value: cap(state.status) });
|
||||
return {
|
||||
applyLabel: "Apply filters",
|
||||
clearHref: ADMIN_USERS_BASE,
|
||||
label: "Filter users",
|
||||
pills,
|
||||
rows: [[
|
||||
{ label: "Search users", name: "q", placeholder: "Search name or email…", type: "search", value: state.q },
|
||||
{ legend: "Status", name: "status", options: [
|
||||
{ count: total, label: "All", value: "all" },
|
||||
{ label: "Active", value: "active" },
|
||||
{ label: "Inactive", value: "inactive" },
|
||||
], type: "segmented", value: state.status },
|
||||
{ type: "spacer" },
|
||||
]],
|
||||
};
|
||||
}
|
||||
|
||||
function listPagination(state: ListState, page: ReturnType<typeof paginate>) {
|
||||
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.sort) hidden.push({ name: "sort", value: state.sort });
|
||||
return {
|
||||
label: "Users pagination",
|
||||
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
|
||||
pages: page.pages.map((p) =>
|
||||
p.ellipsis ? { ellipsis: true }
|
||||
: p.current ? { current: true, label: String(p.page) }
|
||||
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
|
||||
prev: { href: page.prev ? listHref(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 },
|
||||
};
|
||||
}
|
||||
|
||||
export interface FieldConfig {
|
||||
autocomplete?: string;
|
||||
hint?: string;
|
||||
icon?: string;
|
||||
id: string;
|
||||
label: string;
|
||||
name: string;
|
||||
optional?: boolean;
|
||||
readonly?: boolean;
|
||||
required?: boolean;
|
||||
type?: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export function buildUserFormModel(opts: {
|
||||
csrfToken?: string;
|
||||
error?: string;
|
||||
identity?: Identity | null;
|
||||
menu?: MenuConfig;
|
||||
nav?: NavNode[];
|
||||
recovery?: RecoveryCode;
|
||||
user?: User | null;
|
||||
values?: Partial<UserInput>;
|
||||
}) {
|
||||
const menu = opts.menu ?? DEFAULT_MENU;
|
||||
const editing = opts.identity != null;
|
||||
const view = editing ? toUserView(opts.identity!) : null;
|
||||
const np = editing ? nameParts(opts.identity!) : { first: opts.values?.first ?? "", last: opts.values?.last ?? "" };
|
||||
const email = editing ? view!.email : (opts.values?.email ?? "");
|
||||
const idPath = editing ? `${ADMIN_USERS_BASE}/${encodeURIComponent(view!.id)}` : ADMIN_USERS_BASE;
|
||||
|
||||
const fields: FieldConfig[] = [
|
||||
{ autocomplete: "email", icon: "i-mail", id: "email", label: "Email", name: "email", required: !editing, type: "email", value: email,
|
||||
...(editing ? { hint: "The login identifier — can't be changed here.", readonly: true } : {}) },
|
||||
{ id: "first", label: "First name", name: "first", optional: true, value: np.first },
|
||||
{ id: "last", label: "Last name", name: "last", optional: true, value: np.last },
|
||||
];
|
||||
if (!editing) fields.push({ autocomplete: "new-password", hint: "Optional — leave blank to have the user set one via a recovery code.", icon: "i-lock", id: "password", label: "Password", name: "password", optional: true, type: "password" });
|
||||
|
||||
return {
|
||||
edit: editing ? {
|
||||
deleteAction: `${idPath}/delete`,
|
||||
id: view!.id,
|
||||
nextLabel: view!.state === "inactive" ? "Reactivate" : "Deactivate",
|
||||
recoveryAction: `${idPath}/recovery`,
|
||||
state: view!.state,
|
||||
stateAction: `${idPath}/state`,
|
||||
} : undefined,
|
||||
error: opts.error,
|
||||
form: { action: idPath, cancelHref: ADMIN_USERS_BASE, csrfToken: opts.csrfToken ?? "", fields, submitLabel: editing ? "Save changes" : "Create user" },
|
||||
nav: opts.nav ?? [],
|
||||
recovery: opts.recovery,
|
||||
shell: buildShellContext({
|
||||
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { label: editing ? "Edit" : "New" }],
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
menu,
|
||||
title: editing ? "Edit user" : "New user",
|
||||
user: opts.user ?? null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- request handler (imperative shell) ----
|
||||
|
||||
export interface AdminUsersDeps {
|
||||
csrfSecret: string;
|
||||
kratosAdmin: KratosAdmin;
|
||||
menu: MenuConfig;
|
||||
render: (view: string, data: Record<string, unknown>) => Promise<string>;
|
||||
revoke?: (sub: string) => void; // optional instant-revoke: kill the target's live tokens on deactivate/delete
|
||||
}
|
||||
|
||||
function readUserInput(form: URLSearchParams): UserInput {
|
||||
return {
|
||||
email: (form.get("email") ?? "").trim(),
|
||||
first: (form.get("first") ?? "").trim(),
|
||||
last: (form.get("last") ?? "").trim(),
|
||||
password: form.get("password") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
// Handle a request under /admin/users. Returns null when the path isn't ours (app.ts falls
|
||||
// through to its 404). Throws GuardError for auth/CSRF failures (app.ts maps it to a response).
|
||||
export async function handleAdminUsers(ctx: RequestContext, csrfToken: string, deps: AdminUsersDeps): Promise<RouteResult | null> {
|
||||
const path = ctx.url.pathname;
|
||||
if (path !== ADMIN_USERS_BASE && !path.startsWith(`${ADMIN_USERS_BASE}/`)) return null;
|
||||
|
||||
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, 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, nav, user, ...extra }) }) });
|
||||
|
||||
// /admin/users — list (GET) · create (POST)
|
||||
if (seg.length === 0) {
|
||||
if (method === "GET") return renderList();
|
||||
if (method === "POST") {
|
||||
const input = readUserInput(form!);
|
||||
try {
|
||||
await kratosAdmin.createIdentity(createIdentityPayload(input));
|
||||
} catch (err) {
|
||||
if (err instanceof KratosError) return { ...(await renderForm({ error: createError(err), values: input })), status: 400 };
|
||||
throw err;
|
||||
}
|
||||
ctx.log.info("admin: user created", { actor: user.id, email: input.email });
|
||||
return { redirect: ADMIN_USERS_BASE };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// /admin/users/new — create form
|
||||
if (seg.length === 1 && seg[0] === "new" && method === "GET") return renderForm({});
|
||||
|
||||
// /admin/users/:id …
|
||||
const targetId = safeDecode(seg[0]!); // malformed %-encoding → 404, not a 500 (matches groups/roles/clients)
|
||||
if (targetId === null) return { html: await render("404", { title: "Not found" }), status: 404 };
|
||||
const identity = await kratosAdmin.getIdentity(targetId);
|
||||
if (!identity) return { html: await render("404", { title: "Not found" }), status: 404 };
|
||||
const back = `${ADMIN_USERS_BASE}/${encodeURIComponent(targetId)}`;
|
||||
|
||||
if (seg.length === 1) {
|
||||
if (method === "GET") return renderForm({ identity });
|
||||
if (method === "POST") {
|
||||
try {
|
||||
await kratosAdmin.updateIdentity(targetId, updateIdentityPayload(identity, readUserInput(form!)));
|
||||
} catch (err) {
|
||||
if (err instanceof KratosError) return { ...(await renderForm({ error: "Could not save changes — check the fields and try again.", identity })), status: 400 };
|
||||
throw err;
|
||||
}
|
||||
return { redirect: back };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (seg.length === 2) {
|
||||
const isSelf = targetId === user.id; // self-protection: an admin must not lock themselves out
|
||||
if (seg[1] === "delete" && method === "GET") {
|
||||
if (isSelf) return { ...(await renderForm({ error: "You can't delete your own account.", identity })), status: 400 };
|
||||
const view = toUserView(identity);
|
||||
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,
|
||||
menu, message: `Delete ${view.email}? This permanently removes the account and can't be undone.`, nav, title: "Delete user", user,
|
||||
}) }) };
|
||||
}
|
||||
if (method === "POST") {
|
||||
if (seg[1] === "state") {
|
||||
if (isSelf) return { ...(await renderForm({ error: "You can't deactivate your own account.", identity })), status: 400 };
|
||||
const nextState = identity.state === "inactive" ? "active" : "inactive";
|
||||
await kratosAdmin.updateIdentity(targetId, setStatePayload(identity, nextState));
|
||||
if (nextState === "inactive") deps.revoke?.(targetId); // a deactivation takes effect now, not after the JWT TTL
|
||||
ctx.log.info("admin: user state changed", { actor: user.id, state: nextState, target: targetId });
|
||||
return { redirect: back };
|
||||
}
|
||||
if (seg[1] === "delete") {
|
||||
if (isSelf) return { ...(await renderForm({ error: "You can't delete your own account.", identity })), status: 400 };
|
||||
await kratosAdmin.deleteIdentity(targetId);
|
||||
deps.revoke?.(targetId); // the account is gone — reject its live tokens immediately
|
||||
ctx.log.info("admin: user deleted", { actor: user.id, target: targetId });
|
||||
return { redirect: ADMIN_USERS_BASE };
|
||||
}
|
||||
if (seg[1] === "recovery") {
|
||||
const recovery = await kratosAdmin.createRecoveryCode(targetId);
|
||||
return renderForm({ identity, recovery });
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function createError(err: KratosError): string {
|
||||
return err.status === 409
|
||||
? "A user with that email already exists."
|
||||
: "Could not create the user — check the email and try again.";
|
||||
}
|
||||
+22
-5
@@ -21,8 +21,14 @@ import { KratosError, type Flow, type FlowType, type KratosPublic, type Session,
|
||||
import { SESSION_COOKIE } from "../auth/login.ts";
|
||||
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||
import { contentTypeFor, resolveStaticPath, routePublic } from "./static.ts";
|
||||
import adminManifest from "../../examples/plugins/admin/plugin.ts";
|
||||
|
||||
const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views");
|
||||
// The admin screens ship as a drop-in example plugin; the HTTP-level admin tests mount it via
|
||||
// createApp (with stub Ory clients on ctx.system + views from examples/plugins) exactly as an
|
||||
// operator would after copying it into plugins/.
|
||||
const examplesPluginsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "examples", "plugins");
|
||||
const adminPlugin: Plugin = { ...adminManifest, id: "admin" };
|
||||
|
||||
// A session JWT signed with a throwaway test key — the verify path. Wired into the shared
|
||||
// `server` (and the per-test apps) so a request can present a valid session; the dashboard and the
|
||||
@@ -532,10 +538,11 @@ test("a verified session JWT authorizes a role-gated route; no cookie / expired
|
||||
assert.equal(noCookie.headers.get("location"), "/login?return_to=%2Fdemo%2Fsecret");
|
||||
assert.equal((await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["demo:read"], sub: "u1" })}`)).status, 303);
|
||||
|
||||
// The dashboard wires in the permission-gated Admin section: an admin's roles surface the links;
|
||||
// anonymous is bounced to sign in before any page renders (gate on /dashboard).
|
||||
const admin = await fetch(url + "/dashboard", { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["admin"], sub: "u1" })}` } });
|
||||
assert.match(await admin.text(), /href="\/admin\/users"/);
|
||||
// The gated dashboard renders for any signed-in user; anonymous is bounced to sign in before any
|
||||
// page renders (gate on /dashboard). The Admin section links come from the admin plugin — its nav
|
||||
// composition + role-filtering is covered in the admin-screen tests below.
|
||||
const dash = await fetch(url + "/dashboard", { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["admin"], sub: "u1" })}` } });
|
||||
assert.equal(dash.status, 200);
|
||||
const anonDash = await fetch(url + "/dashboard", { redirect: "manual" });
|
||||
assert.equal(anonDash.status, 303);
|
||||
assert.equal(anonDash.headers.get("location"), "/login?return_to=%2Fdashboard");
|
||||
@@ -852,7 +859,7 @@ const withWhoami = (whoami: KratosPublic["whoami"]): KratosPublic => ({ ...mockK
|
||||
// CSRF cookie. get(path, roles)/post(path, body) carry them; `token` is the matching CSRF field.
|
||||
const ADMIN_CSRF = "admin-secret";
|
||||
async function adminHarness(t: TestContext, opts: AppOptions = {}) {
|
||||
const app = createApp({ csrfSecret: ADMIN_CSRF, jwks: staticJwks([ecJwk]), ...opts });
|
||||
const app = createApp({ csrfSecret: ADMIN_CSRF, jwks: staticJwks([ecJwk]), pluginsDir: examplesPluginsDir, plugins: [adminPlugin], ...opts });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
@@ -1093,6 +1100,11 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
|
||||
|
||||
await assertAdminGate(url, get, "/admin/users");
|
||||
|
||||
// Nav: the admin plugin's section composes into the one global menu for an admin, and is filtered
|
||||
// out for a signed-in non-admin (the gate on the section header) — proving the drop-in nav fragment.
|
||||
assert.match(await (await get("/dashboard")).text(), /href="\/admin\/users"/);
|
||||
assert.doesNotMatch(await (await get("/dashboard", ["scheduling:read"])).text(), /href="\/admin\/users"/);
|
||||
|
||||
// List: the admin sees the rows + the "add" link; the status filter narrows server-side.
|
||||
const listHtml = await (await get("/admin/users")).text();
|
||||
assert.match(listHtml, /ada@example\.com/);
|
||||
@@ -1111,6 +1123,11 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
|
||||
assert.equal((await post("/admin/users", "email=x%40y.z")).status, 403);
|
||||
assert.equal(store.length, before);
|
||||
|
||||
// CSRF also guards a POST to an *existing* target (the per-route :id handlers), not just the
|
||||
// collection: a delete with no token is refused (403) and removes nothing.
|
||||
assert.equal((await post(`/admin/users/${store[1]!.id}/delete`, "")).status, 403);
|
||||
assert.ok(store.some((x) => x.id === store[1]!.id));
|
||||
|
||||
// Edit: email is read-only + prefilled; a post rewrites the name.
|
||||
const target = store[0]!;
|
||||
const editHtml = await (await get(`/admin/users/${target.id}`)).text();
|
||||
|
||||
+14
-57
@@ -3,11 +3,6 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import * as ejs from "ejs";
|
||||
import { ADMIN_CLIENTS_BASE, ADMIN_GROUPS_BASE, ADMIN_ROLES_BASE, ADMIN_USERS_BASE } from "../admin/admin-nav.ts";
|
||||
import { type AdminClientsDeps, handleAdminClients } from "../admin/admin-clients.ts";
|
||||
import { type AdminGroupsDeps, handleAdminGroups } from "../admin/admin-groups.ts";
|
||||
import { type AdminRolesDeps, handleAdminRoles } from "../admin/admin-roles.ts";
|
||||
import { type AdminUsersDeps, handleAdminUsers } from "../admin/admin-users.ts";
|
||||
import { readFormBody } from "./body.ts";
|
||||
import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts";
|
||||
import { buildContext, type User } from "./context.ts";
|
||||
@@ -30,6 +25,7 @@ import { resolveLoginChallenge } from "../auth/oauth-login.ts";
|
||||
import { acceptConsent, rejectConsent, resolveConsentChallenge } from "../auth/oauth-consent.ts";
|
||||
import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
|
||||
import type { Plugin, RouteHandler, RouteResult } from "../plugin-host/plugin.ts";
|
||||
import type { SystemCapabilities } from "../plugin-host/system.ts";
|
||||
import { allowedMethods, isAuthorized, matchRoute } from "../plugin-host/router.ts";
|
||||
import { securityHeaders } from "./security-headers.ts";
|
||||
import { localPath } from "./safe-url.ts";
|
||||
@@ -80,6 +76,12 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
const keto = options.keto;
|
||||
const kratos = options.kratos;
|
||||
const kratosAdmin = options.kratosAdmin;
|
||||
// Privileged host services handed to a system plugin via ctx.system — the Ory admin clients and
|
||||
// the instant-revoke hook. Only the wired capabilities are present; with none wired ctx.system
|
||||
// stays undefined, so an ordinary deployment (no Ory, hence no system plugin) pays nothing.
|
||||
const system: SystemCapabilities | undefined = kratosAdmin || keto || hydra || revoke
|
||||
? { ...(hydra ? { hydra } : {}), ...(keto ? { keto } : {}), ...(kratosAdmin ? { kratosAdmin } : {}), ...(revoke ? { revoke } : {}) }
|
||||
: undefined;
|
||||
// Silent default so unit/integration tests stay quiet; server.ts injects the configured logger.
|
||||
const log = options.log ?? createLogger({ level: "none" });
|
||||
const menu = options.menu ?? DEFAULT_MENU;
|
||||
@@ -99,8 +101,8 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// Response security headers, fixed at boot (only HSTS depends on the https deployment signal).
|
||||
const secHeaderEntries = Object.entries(securityHeaders({ secure: secureCookies }));
|
||||
|
||||
// `views: [viewsDir]` lets a view in a subfolder (e.g. admin/users.ejs) include() the shared
|
||||
// partials/ by the same root-relative name top-level views use (EJS tries relative first).
|
||||
// `views: [viewsDir]` lets a view in a subfolder (e.g. partials/…) include() the shared partials/
|
||||
// by the same root-relative name top-level views use (EJS tries relative first).
|
||||
const render = (view: string, data: Record<string, unknown>): Promise<string> =>
|
||||
ejs.renderFile(join(viewsDir, `${view}.ejs`), data, { cache, views: [viewsDir] });
|
||||
|
||||
@@ -108,15 +110,6 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// building-block partials (resolved from viewsDir) and their own partials/subfolders.
|
||||
const renderView = renderPluginView({ cache, coreViewsDir: viewsDir, pluginsDir });
|
||||
|
||||
// Built-in admin screens — wired only when their Ory clients are present (the writes go
|
||||
// there). They render core views via `render` and are gated/CSRF-guarded inside the handler.
|
||||
// Users writes to Kratos; Groups writes to Keto and reads users from Kratos for the pickers.
|
||||
const adminDeps: AdminUsersDeps | null = kratosAdmin ? { csrfSecret, kratosAdmin, menu, render, ...(revoke ? { revoke } : {}) } : null;
|
||||
const adminGroupsDeps: AdminGroupsDeps | null = kratosAdmin && keto ? { csrfSecret, keto, kratosAdmin, menu, render } : null;
|
||||
const adminRolesDeps: AdminRolesDeps | null = kratosAdmin && keto ? { csrfSecret, keto, kratosAdmin, menu, render, ...(revoke ? { revoke } : {}) } : null;
|
||||
// OAuth2 clients write to Hydra; wired only when the Hydra admin client is present.
|
||||
const adminClientsDeps: AdminClientsDeps | null = hydra ? { csrfSecret, hydra, menu, render } : null;
|
||||
|
||||
const sendHtml = (res: ServerResponse, status: number, html: string): void => {
|
||||
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
|
||||
res.end(html);
|
||||
@@ -190,8 +183,8 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
let chromeMemo: PageChrome | undefined;
|
||||
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, user }));
|
||||
|
||||
// base context (no route params yet); reused for onRequest + the built-in admin screens.
|
||||
const ctx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf });
|
||||
// base context (no route params yet); reused for onRequest hooks and the landing routes.
|
||||
const ctx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf, ...(system ? { system } : {}) });
|
||||
|
||||
// Plugin onRequest hooks run before routing and may short-circuit the request.
|
||||
if (anyRequestHooks) {
|
||||
@@ -210,7 +203,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// CSRF cookie is set so those forms have a valid double-submit token.
|
||||
const match = matchRoute(plugins, method, pathname);
|
||||
if (match) {
|
||||
const routeCtx = buildContext(req, res, { chrome, log: reqLog, params: match.params, user, verifyCsrf });
|
||||
const routeCtx = buildContext(req, res, { chrome, log: reqLog, params: match.params, user, verifyCsrf, ...(system ? { system } : {}) });
|
||||
if (!isAuthorized(match.route, routeCtx.roles)) {
|
||||
// Anonymous → sign in (like the built-in screens' requireSession), remembering the page as
|
||||
// return_to; a signed-in user who simply lacks the role gets the 403 page.
|
||||
@@ -226,42 +219,6 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
return;
|
||||
}
|
||||
|
||||
// Built-in admin screens. Each handler gates (admin only; throws GuardError the catch
|
||||
// maps), CSRF-guards mutations, and returns html/redirect. Set the page's CSRF cookie when
|
||||
// freshly minted (its forms carry the matching token); null ⇒ unknown subpath → 404.
|
||||
if (adminDeps && pathname.startsWith(ADMIN_USERS_BASE)) {
|
||||
const result = await handleAdminUsers(ctx, csrf.token, adminDeps);
|
||||
if (result) {
|
||||
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
|
||||
await sendResult(res, result, () => Promise.reject(new Error("admin screens return html, not view")));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (adminGroupsDeps && pathname.startsWith(ADMIN_GROUPS_BASE)) {
|
||||
const result = await handleAdminGroups(ctx, csrf.token, adminGroupsDeps);
|
||||
if (result) {
|
||||
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
|
||||
await sendResult(res, result, () => Promise.reject(new Error("admin screens return html, not view")));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (adminRolesDeps && pathname.startsWith(ADMIN_ROLES_BASE)) {
|
||||
const result = await handleAdminRoles(ctx, csrf.token, adminRolesDeps);
|
||||
if (result) {
|
||||
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
|
||||
await sendResult(res, result, () => Promise.reject(new Error("admin screens return html, not view")));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (adminClientsDeps && pathname.startsWith(ADMIN_CLIENTS_BASE)) {
|
||||
const result = await handleAdminClients(ctx, csrf.token, adminClientsDeps);
|
||||
if (result) {
|
||||
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
|
||||
await sendResult(res, result, () => Promise.reject(new Error("admin screens return html, not view")));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Themed Kratos self-service pages (login/registration/recovery/verification/settings).
|
||||
const flowType = AUTH_FLOWS[pathname];
|
||||
if (kratos && flowType && (method === "GET" || method === "HEAD")) {
|
||||
@@ -481,7 +438,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// any form it ships). Else the built-in intro page with prominent sign-in / register links.
|
||||
if (homePlugin) {
|
||||
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
|
||||
const homeCtx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf });
|
||||
const homeCtx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf, ...(system ? { system } : {}) });
|
||||
const result = (await homePlugin.home(homeCtx)) ?? null;
|
||||
if (anyResponseHooks) await runResponseHooks(plugins, homeCtx, result);
|
||||
await sendResult(res, result, (view, data) => renderView(homePlugin.id, view, data));
|
||||
@@ -503,7 +460,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// A plugin may fully own the dashboard: render its handler against its own views, native
|
||||
// shell via ctx.chrome — same path as a plugin route. Else the built-in mock-data People list.
|
||||
if (dashboardPlugin) {
|
||||
const dashCtx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf });
|
||||
const dashCtx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf, ...(system ? { system } : {}) });
|
||||
const result = (await dashboardPlugin.dashboard(dashCtx)) ?? null;
|
||||
if (anyResponseHooks) await runResponseHooks(plugins, dashCtx, result);
|
||||
await sendResult(res, result, (view, data) => renderView(dashboardPlugin.id, view, data));
|
||||
|
||||
@@ -46,6 +46,14 @@ test("buildContext defaults a missing request URL to /", () => {
|
||||
assert.equal(buildContext(req, res).url.pathname, "/");
|
||||
});
|
||||
|
||||
test("buildContext exposes ctx.system only when the host supplies it (else undefined)", () => {
|
||||
const { req, res } = reqRes("/admin/users");
|
||||
assert.equal(buildContext(req, res).system, undefined); // absent by default — a plugin must degrade
|
||||
const revoke = (): void => {};
|
||||
const system = { revoke };
|
||||
assert.equal(buildContext(req, res, { system }).system, system); // threaded through unchanged
|
||||
});
|
||||
|
||||
test("buildContext provides a logger: a silent default, or the host's request logger", () => {
|
||||
const { req, res } = reqRes("/");
|
||||
assert.equal(typeof buildContext(req, res).log.info, "function"); // always present (silent default)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { PageChrome } from "../ui/chrome.ts"; // type-only: no runtime import, so no cycle
|
||||
import type { SystemCapabilities } from "../plugin-host/system.ts"; // type-only
|
||||
import { createLogger, type Log } from "../logger.ts";
|
||||
|
||||
// The request context threaded to every route handler (plugin + built-in), built once
|
||||
@@ -27,6 +28,9 @@ export interface RequestContext {
|
||||
req: IncomingMessage;
|
||||
res: ServerResponse;
|
||||
roles: string[]; // user?.roles ?? [] — coarse gate without a null-check
|
||||
// Privileged host services (Ory admin clients + instant-revoke) for a system plugin. Undefined
|
||||
// unless the host wired them; every field optional. Ordinary domain plugins ignore it.
|
||||
system?: SystemCapabilities;
|
||||
url: URL;
|
||||
user: User | null;
|
||||
// Gate a first-party form submission: true iff `submitted` matches this request's signed CSRF
|
||||
@@ -41,6 +45,7 @@ export interface BuildContextOptions {
|
||||
chrome?: () => PageChrome;
|
||||
log?: Log;
|
||||
params?: Record<string, string>;
|
||||
system?: SystemCapabilities;
|
||||
user?: User | null;
|
||||
verifyCsrf?: (submitted: string | null | undefined) => boolean;
|
||||
}
|
||||
@@ -68,6 +73,7 @@ export function buildContext(
|
||||
req,
|
||||
res,
|
||||
roles: user?.roles ?? [],
|
||||
...(options.system ? { system: options.system } : {}),
|
||||
url,
|
||||
user,
|
||||
verifyCsrf: options.verifyCsrf ?? (() => false), // fail-closed unless the host binds the secret
|
||||
|
||||
@@ -40,7 +40,6 @@ test("discovers each folder's manifest, sorted, id derived from the folder name"
|
||||
const badCases: Array<{ name: string; files: Record<string, string>; match: RegExp }> = [
|
||||
{ name: "invalid folder name", files: { "Bad_Name/plugin.ts": full("x") }, match: /Bad_Name/ },
|
||||
{ name: "reserved id shadows a host route", files: { "login/plugin.ts": full("login") }, match: /login.*reserved/s },
|
||||
{ name: "reserved admin id shadows the admin screens", files: { "admin/plugin.ts": full("admin") }, match: /admin.*reserved/s },
|
||||
{ name: "reserved oauth2 id shadows the provider routes", files: { "oauth2/plugin.ts": full("oauth2") }, match: /oauth2.*reserved/s },
|
||||
{ name: "missing plugin.ts", files: { "broken/readme.txt": "x" }, match: /broken.*plugin\.ts/s },
|
||||
{ name: "no default export", files: { "named-only/plugin.ts": "export const x = 1;" }, match: /named-only.*default/s },
|
||||
@@ -71,6 +70,13 @@ test("a route + nav node may be marked public and load fine", async (t) => {
|
||||
assert.equal(plugins[0]?.nav?.[0]?.public, true);
|
||||
});
|
||||
|
||||
test("`admin` is not reserved — the admin screens ship as a drop-in plugin mounted at /admin", async (t) => {
|
||||
const dir = scaffold(t, { "admin/plugin.ts": full("admin") });
|
||||
const plugins = await discoverPlugins({ dir });
|
||||
assert.equal(plugins.length, 1);
|
||||
assert.equal(plugins[0]?.id, "admin");
|
||||
});
|
||||
|
||||
test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard) handlers", async (t) => {
|
||||
const dir = scaffold(t, { "portal/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ view: "home" }), dashboard: () => ({ view: "dash" }) };` });
|
||||
const plugins = await discoverPlugins({ dir });
|
||||
|
||||
@@ -11,8 +11,20 @@ export type { PageChrome } from "../ui/chrome.ts";
|
||||
export type { NavNode } from "../ui/nav.ts";
|
||||
export { can, check, GuardError, requireSession } from "../auth/guards.ts";
|
||||
export { parseListQuery } from "../ui/list-query.ts";
|
||||
export { paginate } from "../ui/paginate.ts";
|
||||
export type { PageModel } from "../ui/paginate.ts";
|
||||
export { readFormBody } from "../http/body.ts";
|
||||
export { CSRF_FIELD } from "../auth/csrf.ts";
|
||||
// System capabilities for a privileged/system plugin (ctx.system) — the Ory admin clients + the
|
||||
// instant-revoke hook. Undefined unless the host wired them; the built-in admin plugin is the
|
||||
// reference consumer. The Ory client types + their error classes are re-exported so a system
|
||||
// plugin can type against them and `instanceof`-match their errors. See README → System capabilities.
|
||||
export type { SystemCapabilities } from "./system.ts";
|
||||
export type { Identity, KratosAdmin, RecoveryCode } from "../auth/kratos-admin.ts";
|
||||
export type { ExpandTree, KetoClient, RelationQuery, RelationTuple, SubjectSet } from "../auth/keto-client.ts";
|
||||
export type { HydraAdmin, OAuth2Client } from "../auth/hydra-admin.ts";
|
||||
export { KratosError } from "../auth/kratos-public.ts";
|
||||
export { HydraError } from "../auth/hydra-admin.ts";
|
||||
// Sanitise an untrusted URL (upstream/user data) before rendering it in an href/src — partials
|
||||
// escape text but not URL schemes, so a `javascript:`/`data:` URL would be live XSS (see docs).
|
||||
export { safeUrl } from "../http/safe-url.ts";
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
type Plugin,
|
||||
type PluginManifest,
|
||||
} from "./plugin.ts";
|
||||
import { ADMIN_CLIENTS_BASE, ADMIN_GROUPS_BASE, ADMIN_ROLES_BASE, ADMIN_USERS_BASE } from "../admin/admin-nav.ts";
|
||||
import { AUTH_FLOWS } from "../auth/flow-view.ts";
|
||||
|
||||
// A representative manifest exercising every field — its existence type-checks the contract.
|
||||
@@ -115,12 +114,11 @@ test("findConflicts: each single slot (`home`/`dashboard`) may have one owner
|
||||
|
||||
// Drift guard: RESERVED_PLUGIN_IDS is a hand-maintained mirror of the host's own top-level mounts —
|
||||
// a folder claiming one would silently shadow a built-in route. Derive the segments from the real
|
||||
// route constants so adding a new auth flow or admin screen without reserving its id fails here.
|
||||
test("RESERVED_PLUGIN_IDS covers every built-in top-level mount; `home` (the / field) is NOT reserved", () => {
|
||||
// route constants so adding a new auth flow or provider route without reserving its id fails here.
|
||||
test("RESERVED_PLUGIN_IDS covers every built-in top-level mount; `home` (the / field) and `admin` (a plugin) are NOT reserved", () => {
|
||||
const seg = (path: string): string => path.split("/")[1] ?? ""; // first segment of "/x/y"
|
||||
const builtins = new Set<string>([
|
||||
...Object.keys(AUTH_FLOWS).map(seg), // /login, /recovery, /registration, /settings, /verification
|
||||
seg(ADMIN_USERS_BASE), seg(ADMIN_GROUPS_BASE), seg(ADMIN_ROLES_BASE), seg(ADMIN_CLIENTS_BASE), // → admin
|
||||
"auth", // /auth/complete (login completion)
|
||||
"logout", // POST /logout
|
||||
"oauth2", // /oauth2/login · /consent · /logout (Hydra provider)
|
||||
@@ -129,6 +127,8 @@ test("RESERVED_PLUGIN_IDS covers every built-in top-level mount; `home` (the / f
|
||||
]);
|
||||
for (const id of builtins) assert.ok(RESERVED_PLUGIN_IDS.has(id), `built-in mount "${id}" must be a reserved plugin id`);
|
||||
// "/" is owned by the `home` manifest field (not a /<id> route), so it cannot be shadowed and is
|
||||
// deliberately not reserved — a plugin folder named "home" is legal.
|
||||
// deliberately not reserved — a plugin folder named "home" is legal. `admin` is likewise free: the
|
||||
// admin screens ship as a drop-in plugin mounted at /admin, not a built-in route.
|
||||
assert.equal(RESERVED_PLUGIN_IDS.has("home"), false);
|
||||
assert.equal(RESERVED_PLUGIN_IDS.has("admin"), false);
|
||||
});
|
||||
|
||||
@@ -89,12 +89,13 @@ export function isValidPluginId(id: string): boolean {
|
||||
}
|
||||
|
||||
// Ids the host reserves for its own first-party mount segments (the gated /dashboard, the auth flows,
|
||||
// /auth/complete, /logout, the /admin screens, the /oauth2 provider routes, the /public/ static).
|
||||
// Plugin routes resolve before these, so a folder named one of them would silently shadow a
|
||||
// built-in route — discovery refuses it, loud like any conflict. ("/" is owned by the `home` field,
|
||||
// not a route, so it can't be shadowed and needs no reservation.)
|
||||
// /auth/complete, /logout, the /oauth2 provider routes, the /public/ static). Plugin routes resolve
|
||||
// before these, so a folder named one of them would silently shadow a built-in route — discovery
|
||||
// refuses it, loud like any conflict. ("/" is owned by the `home` field, not a route, so it can't be
|
||||
// shadowed and needs no reservation.) Note `admin` is NOT reserved: the admin screens ship as a
|
||||
// drop-in plugin (examples/plugins/admin, mounted at /admin), not a built-in route.
|
||||
export const RESERVED_PLUGIN_IDS: ReadonlySet<string> = new Set([
|
||||
"admin", "auth", "dashboard", "login", "logout", "oauth2", "public", "recovery", "registration", "settings", "verification",
|
||||
"auth", "dashboard", "login", "logout", "oauth2", "public", "recovery", "registration", "settings", "verification",
|
||||
]);
|
||||
|
||||
export interface Semver {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// System capabilities: privileged host services a first-party/system plugin (the built-in admin
|
||||
// screens are the reference consumer) needs but an ordinary domain plugin does not — the Ory admin
|
||||
// clients and the instant-revoke hook. Exposed on ctx.system and re-exported via #plugin-api.
|
||||
//
|
||||
// Every field is optional: it is present only when the host wired that dependency (Ory configured,
|
||||
// denylist enabled), and ctx.system itself is undefined when the host wired none. A plugin must
|
||||
// treat each as optional and degrade when absent — the host does not fail a request over it.
|
||||
|
||||
import type { HydraAdmin } from "../auth/hydra-admin.ts";
|
||||
import type { KetoClient } from "../auth/keto-client.ts";
|
||||
import type { KratosAdmin } from "../auth/kratos-admin.ts";
|
||||
|
||||
// Grouping criterion (keep this cohesive — it's a contract, so the "no catch-all bucket" rule that
|
||||
// governs folders governs this bag too): every field is a *privileged, host-owned, wire-dependent*
|
||||
// capability for administering Plainpages' own identity/permission stack. Add a field only when it
|
||||
// meets all three; if unrelated privileged concerns accrete (mailer, metrics, flags), sub-group
|
||||
// rather than pile them in flat.
|
||||
export interface SystemCapabilities {
|
||||
hydra?: HydraAdmin; // OAuth2 client admin (Hydra); present when the Hydra admin client is wired
|
||||
keto?: KetoClient; // relationship read/write (Keto); present when Keto is wired
|
||||
kratosAdmin?: KratosAdmin; // identity admin (Kratos); present when the Kratos admin client is wired
|
||||
revoke?: (sub: string) => void; // instant-revoke a subject's live tokens; present when the denylist is on
|
||||
}
|
||||
+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