Built-in Users admin screen (todo §5); /admin/users list (filter/sort/paginate) + create/edit/deactivate/delete + trigger-recovery, writing only to Kratos via the admin client — gated admin-only (anon→/login, non-admin→403) and CSRF-guarded like logout. New kratosAdmin.createRecoveryCode; reserved the "admin" plugin id; views:[viewsDir] so subfolder views reuse partials/. Reviewer §5 opener: extracted shell-context.ts (buildShellContext/shellUser) shared by dashboard+admin, threading the real signed-in user (drops the hardcoded demo profile). 217→228 units + 8 visual E2E green; boot-verified full CRUD+recovery live on the Ory stack

This commit is contained in:
2026-06-18 12:26:19 +02:00
parent cb050bd4c1
commit 79cfa2ee7f
19 changed files with 837 additions and 20 deletions

125
src/admin-users.test.ts Normal file
View File

@@ -0,0 +1,125 @@
// Built-in Users admin screen (§5): 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 "./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" } },
});
});

403
src/admin-users.ts Normal file
View File

@@ -0,0 +1,403 @@
// Built-in Users admin screen (todo §5): list Kratos identities (filter/sort/paginate) and
// create / edit / deactivate / delete / trigger-recovery them. Writes go only to Kratos via the
// admin client (README "stateless"); the app holds no user store. The pure builders here turn
// identities + the request URL into the building-block view models; `handleAdminUsers` is the
// imperative shell app.ts dispatches to — it gates (admin only), CSRF-guards every mutation, and
// maps each action to a RouteResult (render a page, or redirect after a write — PRG).
import { readFormBody } from "./body.ts";
import type { RequestContext, User } from "./context.ts";
import { CSRF_FIELD, verifyCsrfRequest } from "./csrf.ts";
import { GuardError } from "./guards.ts";
import type { Identity, KratosAdmin, RecoveryCode } from "./kratos-admin.ts";
import { KratosError } from "./kratos-public.ts";
import { parseListQuery } from "./list-query.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import { composeNav, type NavNode } from "./nav.ts";
import { paginate } from "./paginate.ts";
import type { RouteResult } from "./plugin.ts";
import { buildShellContext } from "./shell-context.ts";
export const ADMIN_USERS_BASE = "/admin/users";
export const ADMIN_PERMISSION = "admin"; // role token gating every admin screen
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" },
];
function adminNav(roles: string[], menu: MenuConfig, currentId: string): NavNode[] {
return composeNav([[
{ href: "/", icon: "i-grid", id: "dashboard", label: "Dashboard" },
{ ...(currentId === "users" ? { current: true } : {}), href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "Users", permission: ADMIN_PERMISSION },
]], menu.override, roles);
}
// 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;
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: adminNav(opts.user?.roles ?? [], menu, "users"),
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;
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 link.", 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: adminNav(opts.user?.roles ?? [], menu, "users"),
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>;
}
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;
if (!ctx.user) throw new GuardError(401, "authentication required", "/login");
if (!ctx.roles.includes(ADMIN_PERMISSION)) throw new GuardError(403, "admin role required");
const { kratosAdmin, menu, render } = deps;
const user = ctx.user;
const method = (ctx.req.method ?? "GET").toUpperCase();
const seg = path.slice(ADMIN_USERS_BASE.length).split("/").filter(Boolean);
// Every mutation is a first-party form → CSRF-guard it (the host doesn't gate plugin routes,
// but it owns these). Reads the body once; the action handlers reuse the parsed form.
let form: URLSearchParams | undefined;
if (method === "POST") {
form = await readFormBody(ctx.req);
if (!verifyCsrfRequest({ cookieHeader: ctx.req.headers.cookie, secret: deps.csrfSecret, submitted: form.get(CSRF_FIELD) })) {
throw new GuardError(403, "invalid CSRF token");
}
}
const renderList = async (): Promise<RouteResult> => {
const { identities } = await kratosAdmin.listIdentities({ pageSize: LIST_FETCH_SIZE });
return { html: await render("admin/users", { model: buildUsersListModel({ csrfToken, identities, menu, url: ctx.url, user }) }) };
};
const renderForm = async (extra: Parameters<typeof buildUserFormModel>[0]): Promise<RouteResult> =>
({ html: await render("admin/user-form", { model: buildUserFormModel({ csrfToken, menu, 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;
}
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 = decodeURIComponent(seg[0]!);
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 && method === "POST") {
if (seg[1] === "state") {
await kratosAdmin.updateIdentity(targetId, setStatePayload(identity, identity.state === "inactive" ? "active" : "inactive"));
return { redirect: back };
}
if (seg[1] === "delete") {
await kratosAdmin.deleteIdentity(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.";
}

View File

@@ -1,5 +1,5 @@
import assert from "node:assert/strict";
import { generateKeyPairSync, sign, type JsonWebKey } from "node:crypto";
import { generateKeyPairSync, randomUUID, sign, type JsonWebKey } from "node:crypto";
import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
@@ -380,6 +380,7 @@ test("renders a fetched flow as the themed auth page: fields post straight to Kr
// Login completion (§4): /auth/complete is where Kratos lands the browser after login.
const stubAdmin = (over: Partial<KratosAdmin>): KratosAdmin => ({
createIdentity: async () => { throw new Error("unused"); },
createRecoveryCode: async () => ({ code: "000000", link: "http://kratos/recover" }),
deleteIdentity: async () => {},
getIdentity: async () => null,
listIdentities: async () => ({ identities: [], nextPageToken: null }),
@@ -454,6 +455,83 @@ test("logout (CSRF-guarded POST): valid token revokes the Kratos session + clear
assert.equal((await post("", `_csrf=${token}`)).status, 403); // no cookie to match
});
// Built-in Users admin screen (§5): gate + every CRUD action over HTTP against a mock Kratos admin.
test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, recovery (CSRF-guarded)", async (t) => {
const mk = (email: string, over: Partial<Identity> = {}): Identity =>
({ id: randomUUID(), schema_id: "default", state: "active", traits: { email, name: { first: "Ada", last: "Lovelace" } }, ...over });
const store: Identity[] = [mk("ada@example.com"), mk("babbage@example.com", { state: "inactive" })];
let lastCreate: { traits?: unknown } | undefined;
const kratosAdmin = stubAdmin({
createIdentity: async (payload) => { lastCreate = payload as { traits?: unknown }; const created = mk("grace@example.com"); store.push(created); return created; },
createRecoveryCode: async (id) => ({ code: "123456", link: `http://kratos/self-service/recovery?code=123456&id=${id}` }),
deleteIdentity: async (id) => { const i = store.findIndex((x) => x.id === id); if (i >= 0) store.splice(i, 1); },
getIdentity: async (id) => store.find((x) => x.id === id) ?? null,
listIdentities: async () => ({ identities: store, nextPageToken: null }),
updateIdentity: async (id, payload) => { const it = store.find((x) => x.id === id)!; Object.assign(it, payload); return it; },
});
const csrfSecret = "admin-secret";
const app = createApp({ csrfSecret, jwks: staticJwks([ecJwk]), kratosAdmin });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
const nowSec = Math.floor(Date.now() / 1000);
const token = issueCsrfToken(csrfSecret);
const cookie = (roles: string[]) => `${SESSION_COOKIE}=${mintJwt({ email: "admin@x", exp: nowSec + 600, roles, sub: "admin1" })}; ${CSRF_COOKIE}=${token}`;
const get = (path: string, roles: string[] = ["admin"]) => fetch(url + path, { headers: { cookie: cookie(roles) }, redirect: "manual" });
const post = (path: string, body: string) =>
fetch(url + path, { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: cookie(["admin"]) }, method: "POST", redirect: "manual" });
// Gate: anonymous → /login; a signed-in non-admin → 403.
const anon = await fetch(url + "/admin/users", { redirect: "manual" });
assert.equal(anon.status, 303);
assert.equal(anon.headers.get("location"), "/login");
assert.equal((await get("/admin/users", [])).status, 403);
// 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/);
assert.match(listHtml, /href="\/admin\/users\/new"/);
assert.doesNotMatch(await (await get("/admin/users?status=inactive")).text(), /ada@example\.com/);
// Create: the form renders; a valid post creates the identity and redirects to the list.
assert.match(await (await get("/admin/users/new")).text(), /Create user/);
const created = await post("/admin/users", `_csrf=${token}&email=grace%40example.com&first=Grace&last=Hopper&password=`);
assert.equal(created.status, 303);
assert.equal(created.headers.get("location"), "/admin/users");
assert.deepEqual(lastCreate?.traits, { email: "grace@example.com", name: { first: "Grace", last: "Hopper" } });
// A create with no CSRF token is refused and creates nothing.
const before = store.length;
assert.equal((await post("/admin/users", "email=x%40y.z")).status, 403);
assert.equal(store.length, before);
// 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();
assert.match(editHtml, /name="email"[^>]*readonly/);
assert.match(editHtml, /value="ada@example\.com"/);
const updated = await post(`/admin/users/${target.id}`, `_csrf=${token}&first=Ada&last=King`);
assert.equal(updated.status, 303);
assert.deepEqual((target.traits as { name: unknown }).name, { first: "Ada", last: "King" });
// Deactivate (state toggle): active → inactive.
await post(`/admin/users/${target.id}/state`, `_csrf=${token}`);
assert.equal(target.state, "inactive");
// Recovery: renders the edit page (200) carrying the generated link.
const rec = await post(`/admin/users/${target.id}/recovery`, `_csrf=${token}`);
assert.equal(rec.status, 200);
assert.match(await rec.text(), /self-service\/recovery\?code=123456/);
// Delete: removes the identity, back to the list.
const del = await post(`/admin/users/${target.id}/delete`, `_csrf=${token}`);
assert.equal(del.status, 303);
assert.ok(!store.some((x) => x.id === target.id));
// Unknown id → 404.
assert.equal((await get(`/admin/users/${randomUUID()}`)).status, 404);
});
test("resolveStaticPath blocks traversal and control chars, allows nested files", () => {
assert.equal(resolveStaticPath("/srv/public", "../app.ts"), null);
assert.equal(resolveStaticPath("/srv/public", "a\x00b"), null);

View File

@@ -3,6 +3,7 @@ import { createServer, type Server, type ServerResponse } from "node:http";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import * as ejs from "ejs";
import { ADMIN_USERS_BASE, type AdminUsersDeps, handleAdminUsers } from "./admin-users.ts";
import { readFormBody } from "./body.ts";
import { buildContext, type User } from "./context.ts";
import { CSRF_FIELD, csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "./csrf.ts";
@@ -62,13 +63,19 @@ export function createApp(options: AppOptions = {}): Server {
const publicDir = options.publicDir ?? join(rootDir, "public");
const viewsDir = options.viewsDir ?? join(rootDir, "views");
// `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).
const render = (view: string, data: Record<string, unknown>): Promise<string> =>
ejs.renderFile(join(viewsDir, `${view}.ejs`), data, { cache });
ejs.renderFile(join(viewsDir, `${view}.ejs`), data, { cache, views: [viewsDir] });
// A `view` RouteResult renders plugins/<id>/views/<view>.ejs; such views may include() the core
// building-block partials (resolved from viewsDir) and their own partials/subfolders.
const renderView = renderPluginView({ cache, coreViewsDir: viewsDir, pluginsDir });
// Built-in admin screens (§5) — wired only when the Kratos admin client is present (the writes
// go there). They render core views via `render` and are gated/CSRF-guarded inside the handler.
const adminDeps: AdminUsersDeps | null = kratosAdmin ? { csrfSecret, kratosAdmin, 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);
@@ -136,6 +143,18 @@ export function createApp(options: AppOptions = {}): Server {
return;
}
// Built-in Users admin screens (§5). The 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;
}
}
// Themed Kratos self-service pages (login/registration/recovery/verification/settings).
const flowType = AUTH_FLOWS[pathname];
if (kratos && flowType && (method === "GET" || method === "HEAD")) {
@@ -194,7 +213,7 @@ export function createApp(options: AppOptions = {}): Server {
// Roles from the verified JWT (anonymous ⇒ []); branding/override come from config/menu.ts.
// The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent.
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
sendHtml(res, 200, await render("index", { model: buildDashboardModel(ctx.url, ctx.roles, menu, csrf.token) }));
sendHtml(res, 200, await render("index", { model: buildDashboardModel(ctx.url, ctx.roles, menu, csrf.token, user) }));
return;
}

View File

@@ -3,10 +3,12 @@
// parseListQuery → filter/sort/paginate a mock dataset → composeNav. Mock data stands in for
// upstream until §4; the filter form, sortable headers and pager all round-trip the URL (zero-JS).
import type { User } from "./context.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import { composeNav, type NavNode, type NavOverride } from "./nav.ts";
import { parseListQuery } from "./list-query.ts";
import { paginate } from "./paginate.ts";
import { buildShellContext } from "./shell-context.ts";
interface Person {
id: string;
@@ -77,7 +79,7 @@ function href(state: State, overrides: Partial<State> = {}): string {
return qs ? `?${qs}` : "?";
}
export function buildDashboardModel(url: URL | URLSearchParams | string, roles: string[] = [], menu: MenuConfig = DEFAULT_MENU, csrfToken = "") {
export function buildDashboardModel(url: URL | URLSearchParams | string, roles: string[] = [], menu: MenuConfig = DEFAULT_MENU, csrfToken = "", user: User | null = null) {
const query = parseListQuery(url, { defaultPageSize: DEFAULT_PAGE_SIZE });
const status = query.filters.status?.[0] ?? "all";
const team = query.filters.team?.[0] ?? "";
@@ -104,18 +106,13 @@ export function buildDashboardModel(url: URL | URLSearchParams | string, roles:
filterBar: filterBar(state),
nav: nav(roles, menu.override),
pagination: pagination(state, page),
shell: {
brand: {
...(menu.branding.logo != null ? { logo: menu.branding.logo } : {}),
name: menu.branding.name,
...(menu.branding.sub != null ? { sub: menu.branding.sub } : {}),
},
shell: buildShellContext({
breadcrumbs: [{ href: "?", label: "Directory" }, { label: "People" }],
csrfToken, // hidden field for the shell's Sign-out POST form (§4)
...(menu.branding.theme != null ? { theme: menu.branding.theme } : {}),
menu,
title: "People",
user: { email: "sam.rivers@example.com", initials: "SR", name: "Sam Rivers" }, // demo until §4
},
user, // real signed-in identity (§4); anonymous ⇒ Guest
}),
table: table(rows, state, sort),
};
}

View File

@@ -40,6 +40,7 @@ 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: "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 },
{ name: "import throws", files: { "explodes/plugin.ts": "throw new Error('boom');" }, match: /explodes.*boom/s },

View File

@@ -99,6 +99,15 @@ test("updateMetadataPublic PATCHes a JSON-Patch `add /metadata_public` so it nev
assert.deepEqual(JSON.parse(calls[0]!.body!), [{ op: "add", path: "/metadata_public", value: { roles: ["admin"] } }]);
});
test("createRecoveryCode POSTs the identity id to /admin/recovery/code → { code, link }", async () => {
const { calls, fetchImpl } = recorder(() => res(200, { recovery_code: "123456", recovery_link: "http://kratos/self-service/recovery?flow=f&code=123456" }));
const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).createRecoveryCode(ID);
assert.deepEqual(out, { code: "123456", link: "http://kratos/self-service/recovery?flow=f&code=123456" });
assert.equal(calls[0]!.method, "POST");
assert.match(calls[0]!.url, /\/admin\/recovery\/code$/);
assert.deepEqual(JSON.parse(calls[0]!.body!), { identity_id: ID });
});
test("deleteIdentity DELETEs by id (204 resolves; non-204 throws a KratosError)", async () => {
const { calls, fetchImpl } = recorder(() => res(204));
await createKratosAdmin({ baseUrl: BASE, fetchImpl }).deleteIdentity(ID);

View File

@@ -25,8 +25,15 @@ export interface ListOptions {
pageToken?: string;
}
// A one-time recovery code + the self-service link wrapping it (admin "trigger recovery", §5).
export interface RecoveryCode {
code: string;
link: string;
}
export interface KratosAdmin {
createIdentity(payload: unknown): Promise<Identity>;
createRecoveryCode(identityId: string, opts?: { expiresIn?: string }): Promise<RecoveryCode>;
deleteIdentity(id: string): Promise<void>;
getIdentity(id: string): Promise<Identity | null>;
listIdentities(opts?: ListOptions): Promise<IdentityList>;
@@ -58,6 +65,17 @@ export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof
return (await res.json()) as Identity;
},
// Mint a recovery code for an identity (admin "trigger recovery") — the link is mailed to the
// user by Kratos; the code/link are also returned so an operator can hand them over directly.
async createRecoveryCode(identityId, opts = {}) {
const body: Record<string, unknown> = { identity_id: identityId };
if (opts.expiresIn) body.expires_in = opts.expiresIn;
const res = await http(`${base}/admin/recovery/code`, { body: JSON.stringify(body), headers: json, method: "POST" });
if (res.status !== 200 && res.status !== 201) return fail("create recovery code", res);
const data = (await res.json()) as { recovery_code: string; recovery_link: string };
return { code: data.recovery_code, link: data.recovery_link };
},
async deleteIdentity(id) {
const res = await http(identity(id), { method: "DELETE" });
if (res.status !== 204) await fail("delete identity", res);

View File

@@ -22,6 +22,7 @@ const ketoStub = (over: Partial<KetoClient> = {}): KetoClient => ({
const adminStub = (over: Partial<KratosAdmin> = {}): KratosAdmin => ({
createIdentity: async () => { throw new Error("unused"); },
createRecoveryCode: async () => ({ code: "000000", link: "http://kratos/recover" }),
deleteIdentity: async () => {},
getIdentity: async () => null,
listIdentities: async () => ({ identities: [], nextPageToken: null }),

View File

@@ -78,10 +78,11 @@ export function isValidPluginId(id: string): boolean {
}
// Ids the host reserves for its own first-party mount segments (the auth flows, /auth/complete,
// /logout, the dashboard's /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.
// /logout, the /admin screens, the dashboard's /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.
export const RESERVED_PLUGIN_IDS: ReadonlySet<string> = new Set([
"auth", "login", "logout", "public", "recovery", "registration", "settings", "verification",
"admin", "auth", "login", "logout", "public", "recovery", "registration", "settings", "verification",
]);
export interface Semver {

29
src/shell-context.test.ts Normal file
View File

@@ -0,0 +1,29 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { buildShellContext, shellUser } from "./shell-context.ts";
test("shellUser derives the profile from the real user; anonymous → Guest", () => {
assert.deepEqual(shellUser(null), { email: "", initials: "G", name: "Guest" });
// Real user: name = email, initials = first two letters of the local part, upper-cased.
assert.deepEqual(shellUser({ email: "ada@example.com", id: "u1", roles: [] }), { email: "", initials: "AD", name: "ada@example.com" });
});
test("buildShellContext maps branding + breadcrumbs, omitting unset optional fields", () => {
const bare = buildShellContext({ menu: { branding: { name: "Plainpages" }, override: {} }, title: "Users" });
assert.deepEqual(bare.brand, { name: "Plainpages" }); // no logo/sub when unset
assert.equal(bare.theme, undefined);
assert.equal(bare.csrfToken, "");
assert.equal(bare.user.name, "Guest");
const full = buildShellContext({
breadcrumbs: [{ href: "/", label: "Home" }, { label: "Users" }],
csrfToken: "tok.sig",
menu: { branding: { logo: "/l.svg", name: "Acme", sub: "Ops", theme: "dark" }, override: {} },
title: "Users",
user: { email: "a@b.c", id: "u1", roles: ["admin"] },
});
assert.deepEqual(full.brand, { logo: "/l.svg", name: "Acme", sub: "Ops" });
assert.equal(full.theme, "dark");
assert.equal(full.csrfToken, "tok.sig");
assert.equal(full.breadcrumbs?.length, 2);
});

47
src/shell-context.ts Normal file
View File

@@ -0,0 +1,47 @@
// Shell view-model builder (todo §5): the brand/theme/user/title block every app-shell page
// (the home dashboard, the built-in admin screens) hands to shell.ejs. Pure. Extracted so the
// shell user is the *real* signed-in identity (§4) — no hardcoded demo profile — and branding is
// read from one place. The User carries no display name (the JWT holds only id/email/roles), so
// the profile shows the email with initials derived from its local part; anonymous ⇒ "Guest".
import type { User } from "./context.ts";
import { type MenuConfig } from "./menu-config.ts";
export interface ShellUser {
email: string;
initials: string;
name: string;
}
export interface ShellModel {
brand: { logo?: string; name: string; sub?: string };
breadcrumbs?: { href?: string; label: string }[];
csrfToken: string;
theme?: string;
title: string;
user: ShellUser;
}
export function shellUser(user: User | null | undefined): ShellUser {
if (!user) return { email: "", initials: "G", name: "Guest" };
const local = user.email.split("@")[0] || user.email;
return { email: "", initials: (local.slice(0, 2) || "U").toUpperCase(), name: user.email };
}
export function buildShellContext(opts: {
breadcrumbs?: { href?: string; label: string }[];
csrfToken?: string;
menu: MenuConfig;
title: string;
user?: User | null;
}): ShellModel {
const b = opts.menu.branding;
return {
brand: { ...(b.logo != null ? { logo: b.logo } : {}), name: b.name, ...(b.sub != null ? { sub: b.sub } : {}) },
...(opts.breadcrumbs ? { breadcrumbs: opts.breadcrumbs } : {}),
csrfToken: opts.csrfToken ?? "",
...(b.theme != null ? { theme: b.theme } : {}),
title: opts.title,
user: shellUser(opts.user),
};
}