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:
403
src/admin-users.ts
Normal file
403
src/admin-users.ts
Normal 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.";
|
||||
}
|
||||
Reference in New Issue
Block a user