diff --git a/README.md b/README.md index 6ebcb35..222c4d1 100644 --- a/README.md +++ b/README.md @@ -533,6 +533,8 @@ src/body.ts readFormBody(): read + size-cap an x-www-form-urlencoded re src/context.ts RequestContext handed to handlers + buildContext() src/config.ts Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, port; validated at boot src/dashboard.ts buildDashboardModel(): the home "/" People list view model (mock data, wires the §1 helpers) +src/admin-users.ts Built-in Users admin screen (§5): list Kratos identities (filter/sort/paginate) + create/edit/deactivate/delete/recovery; gated + CSRF-guarded +src/shell-context.ts buildShellContext(): brand/theme/user view-model shared by the dashboard + admin screens (real signed-in user, no demo profile) src/icons.ts Used-icon registry + sprite builder from lucide-static (regenerates partials/icons.ejs) src/list-query.ts parseListQuery(): read a list URL → { q, filters, sort, page, pageSize } src/nav.ts composeNav(): merge plugin nav fragments + central override, role-filter → nav-tree model @@ -542,7 +544,7 @@ src/discovery.ts discoverPlugins(): scan plugins/, import + validate each pl src/router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate (§2) src/view-resolver.ts renderPluginView(): render plugins//views/.ejs; plugin views can include() core partials (§2) src/menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central override + branding), validated at boot (§2) -views/ Core EJS templates (index = the app-shell People dashboard, auth = themed Kratos self-service page, 403/404/500, partials/ incl. app shell, nav tree, filter bar, data table, pagination, form field, auth card, alert, flow body, menu/popover, theme switch, icon sprite) +views/ Core EJS templates (index = the app-shell People dashboard, admin/ = the Users admin list + create/edit form, auth = themed Kratos self-service page, 403/404/500, partials/ incl. app shell, nav tree, filter bar, data table, pagination, form field, auth card, alert, flow body, user-form body, menu/popover, theme switch, icon sprite) public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt) config/menu.ts Central menu override + branding (optional; defaults apply if absent) ory/ Ory service config (kratos/: identity schema, kratos.yml, oidc/ SSO claims mapper, tokenizer/ session→JWT claims mapper + dev signing JWKS; keto/: keto.yml + namespaces.keto.ts OPL — role/group/resource; hydra/hydra.yml: OAuth2 issuer + login/consent URLs) + storage init (postgres/init/init.sql: one DB per service) diff --git a/public/css/styles.css b/public/css/styles.css index d081a44..ab2a7fd 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -664,3 +664,17 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); } /* the nav-toggle checkbox itself is visually hidden but focusable */ #nav-toggle { position: absolute; opacity: 0; pointer-events: none; } + +/* admin forms (§5): create/edit user, account actions */ +.form-page { padding: 16px; display: flex; flex-direction: column; gap: 14px; max-width: 560px; } +.form-card { + display: flex; flex-direction: column; gap: 14px; + padding: 18px; background: var(--surface); + border: 1px solid var(--border); border-radius: var(--radius); +} +.form-actions { display: flex; gap: 10px; justify-content: flex-end; } +.admin-actions { flex-flow: row wrap; gap: 10px; align-items: center; } +.admin-actions form { margin: 0; } +.btn-danger { color: var(--neg); border-color: var(--neg-bd); } +.btn-danger:hover { background: var(--neg-bg); } +.recovery-link { word-break: break-all; } diff --git a/src/admin-users.test.ts b/src/admin-users.test.ts new file mode 100644 index 0000000..d27e471 --- /dev/null +++ b/src/admin-users.test.ts @@ -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 => ({ + 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, 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" } }, + }); +}); diff --git a/src/admin-users.ts b/src/admin-users.ts new file mode 100644 index 0000000..e4f4e31 --- /dev/null +++ b/src/admin-users.ts @@ -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 = { 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 { + const traits: Record = { email: input.email }; + if (input.first || input.last) traits.name = { first: input.first, last: input.last }; + const payload: Record = { 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 { + const traits: Record = { ...(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 { + 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> = { + 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 = {}): 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) { + 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; +}) { + 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) => Promise; +} + +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 { + 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 => { + 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[0]): Promise => + ({ 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."; +} diff --git a/src/app.test.ts b/src/app.test.ts index eb4f8ff..2dce3c7 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -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 => ({ 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 => + ({ 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((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); diff --git a/src/app.ts b/src/app.ts index 6b6af21..24ebeed 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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): Promise => - ejs.renderFile(join(viewsDir, `${view}.ejs`), data, { cache }); + ejs.renderFile(join(viewsDir, `${view}.ejs`), data, { cache, views: [viewsDir] }); // A `view` RouteResult renders plugins//views/.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; } diff --git a/src/dashboard.ts b/src/dashboard.ts index 5dc4294..d1fd796 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -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 = {}): 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), }; } diff --git a/src/discovery.test.ts b/src/discovery.test.ts index a7588fa..98782a2 100644 --- a/src/discovery.test.ts +++ b/src/discovery.test.ts @@ -40,6 +40,7 @@ test("discovers each folder's manifest, sorted, id derived from the folder name" const badCases: Array<{ name: string; files: Record; 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 }, diff --git a/src/kratos-admin.test.ts b/src/kratos-admin.test.ts index 3960b07..4bf46e4 100644 --- a/src/kratos-admin.test.ts +++ b/src/kratos-admin.test.ts @@ -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); diff --git a/src/kratos-admin.ts b/src/kratos-admin.ts index 346dc14..60f1c95 100644 --- a/src/kratos-admin.ts +++ b/src/kratos-admin.ts @@ -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; + createRecoveryCode(identityId: string, opts?: { expiresIn?: string }): Promise; deleteIdentity(id: string): Promise; getIdentity(id: string): Promise; listIdentities(opts?: ListOptions): Promise; @@ -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 = { 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); diff --git a/src/login.test.ts b/src/login.test.ts index 27ed7e5..d4e4ef9 100644 --- a/src/login.test.ts +++ b/src/login.test.ts @@ -22,6 +22,7 @@ const ketoStub = (over: Partial = {}): KetoClient => ({ const adminStub = (over: Partial = {}): 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 }), diff --git a/src/plugin.ts b/src/plugin.ts index d7eb853..e94e0b1 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -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 = new Set([ - "auth", "login", "logout", "public", "recovery", "registration", "settings", "verification", + "admin", "auth", "login", "logout", "public", "recovery", "registration", "settings", "verification", ]); export interface Semver { diff --git a/src/shell-context.test.ts b/src/shell-context.test.ts new file mode 100644 index 0000000..072e872 --- /dev/null +++ b/src/shell-context.test.ts @@ -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); +}); diff --git a/src/shell-context.ts b/src/shell-context.ts new file mode 100644 index 0000000..dcbbb3b --- /dev/null +++ b/src/shell-context.ts @@ -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), + }; +} diff --git a/todo.md b/todo.md index e1aca57..e34d35f 100644 --- a/todo.md +++ b/todo.md @@ -93,7 +93,7 @@ everything via Docker. - [x] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us. → Pass over the §4 auth tests. The clients (`kratos-public`/`kratos-admin`/`keto-client`) and the focused units (`jwks`/`flow-view`/`guards`/`csrf`/`body`/`login`) already follow the per-module "matrix + edge" pattern, no fat to cut. Removed the two genuine §4-era overlaps: (1) `jwt-middleware.test.ts` re-ran `resolveSession`'s whole classification matrix again under `authenticate` — but `authenticate` is just `resolveSession(...).user`, so merged into one test where `resolveSession` owns the matrix and `authenticate` is asserted as its fail-closed user-projection (kept `authenticate` itself — a documented convenience export, just not double-tested). (2) `app.test.ts` had two `/auth/complete` HTTP tests (live-session vs no-session) for one route → merged into one (happy path + edge), mirroring the project's style. 219 → 217 tests, zero coverage lost; typecheck + tests green. ## 5. Built-in admin screens (writes go only to Keto/Kratos) -- [ ] Users: list (Kratos identities) with filter/sort/pagination; create/edit/deactivate/delete; trigger recovery. +- [x] Users: list (Kratos identities) with filter/sort/pagination; create/edit/deactivate/delete; trigger recovery. → `src/admin-users.ts`: pure view-model + Kratos-payload builders (`toUserView`, `buildUsersListModel`, `buildUserFormModel`, `create/updateIdentityPayload`, `setStatePayload`) + `handleAdminUsers` (the imperative shell app.ts dispatches `/admin/users*` to). Routes: `GET /admin/users` (list — filter by q/status, sortable headers, paginate; in-memory over one fetched Kratos page since the admin API has no search/sort), `GET|POST /admin/users/new`+`/` (create), `GET|POST /admin/users/:id` (edit; email is the read-only login identifier, name editable, optional initial password), `POST …/:id/state` (deactivate↔reactivate), `…/delete`, `…/recovery` (mints a code via the new `kratosAdmin.createRecoveryCode` admin endpoint, renders the link). Writes go **only to Kratos** (README "stateless"). Gated **admin-only** (anonymous→/login, non-admin→403 via `GuardError`) and every mutation is **CSRF-guarded** (signed double-submit, like logout); reuses the §1 building blocks (filter-bar/data-table/pagination/field) around the app shell. Reviewer's §5 opener done too: extracted `src/shell-context.ts` (`buildShellContext`/`shellUser`) shared by the dashboard + admin screens — kills the hardcoded "Sam Rivers" demo profile, threads the **real** signed-in user (email/derived initials; anonymous→Guest); `dashboard.ts` + `app.ts` now pass `ctx.user`. Added `readonly` to `field.ejs`, `admin` to `RESERVED_PLUGIN_IDS` (a plugin folder can't shadow the screens), `views:[viewsDir]` to the core renderer (so a subfolder view includes the shared `partials/` by root-relative name). Tests-first: `admin-users.test.ts` (mapping/selection/payload matrix), `app.test.ts` HTTP integration (gate/list-filter/create/edit/state/delete/recovery + CSRF reject), `shell-context.test.ts`, `kratos-admin.test.ts` (recovery endpoint), `discovery.test.ts` (reserved `admin`). typecheck + 228 units + 8 visual E2E green. Boot-verified live on the full Ory stack: seeded-admin login → JWT `roles:["admin"]` → `/admin/users` lists identities; create→303→listed, recovery→real Kratos code/link, state→inactive, delete→absent, forged CSRF→403; torn down. Groups/roles/menu-wiring are the next §5 items. - [ ] Groups: Keto subject sets — list/create/delete + membership management. - [ ] Roles & permissions: Keto relations — assign roles to users/groups; "effective access" view via Keto expand. - [ ] Wire into the menu (admin section, permission-gated). diff --git a/views/admin/user-form.ejs b/views/admin/user-form.ejs new file mode 100644 index 0000000..2505b46 --- /dev/null +++ b/views/admin/user-form.ejs @@ -0,0 +1,16 @@ +<%# + Users admin create/edit page (todo §5): the user-form body captured into the app shell. +%><% + const nav = include("partials/nav-tree", { nodes: model.nav }); + const body = include("partials/user-form-body", { edit: model.edit, error: model.error, form: model.form, recovery: model.recovery }); +-%> +<%- include("partials/shell", { + body, + brand: model.shell.brand, + breadcrumbs: model.shell.breadcrumbs, + csrfToken: model.shell.csrfToken, + nav, + theme: model.shell.theme, + title: model.shell.title, + user: model.shell.user, +}) %> diff --git a/views/admin/users.ejs b/views/admin/users.ejs new file mode 100644 index 0000000..c57e8c2 --- /dev/null +++ b/views/admin/users.ejs @@ -0,0 +1,21 @@ +<%# + Users admin list (todo §5): the same building blocks as the dashboard, around the shell, but + backed by live Kratos identities (src/admin-users.ts). Filter/sort/page all round-trip the URL. +%><% + const nav = include("partials/nav-tree", { nodes: model.nav }); + const filters = include("partials/filter-bar", model.filterBar); + const table = include("partials/data-table", model.table); + const pager = include("partials/pagination", model.pagination); + const actions = 'Add user'; +-%> +<%- include("partials/shell", { + actions, + body: filters + table + pager, + brand: model.shell.brand, + breadcrumbs: model.shell.breadcrumbs, + csrfToken: model.shell.csrfToken, + nav, + theme: model.shell.theme, + title: model.shell.title, + user: model.shell.user, +}) %> diff --git a/views/partials/field.ejs b/views/partials/field.ejs index bb23899..e7e93a3 100644 --- a/views/partials/field.ejs +++ b/views/partials/field.ejs @@ -2,7 +2,7 @@ Form field: label (+ inline link / "Optional") · icon input · hint · error. Mirrors html-css-foundation auth markup. Config: id, name, label - type? (default "text"), value?, placeholder?, autocomplete?, required?, minlength? + type? (default "text"), value?, placeholder?, autocomplete?, required?, readonly?, minlength? icon? input-ico id (e.g. "i-mail") → left-padded input optional? show an "Optional" tag in the label row link? { href, label } inline beside the label (e.g. Forgot password?) @@ -24,7 +24,7 @@ <% } else { -%> <% } -%> -
<% if (icon) { %><% } %> autocomplete="<%= locals.autocomplete %>"<% } %><% if (locals.placeholder) { %> placeholder="<%= locals.placeholder %>"<% } %><% if (locals.value != null) { %> value="<%= locals.value %>"<% } %><% if (locals.minlength != null) { %> minlength="<%= locals.minlength %>"<% } %><% if (error) { %> aria-invalid="true" aria-describedby="<%= errId %>"<% } %><% if (locals.required) { %> required<% } %>>
+
<% if (icon) { %><% } %> autocomplete="<%= locals.autocomplete %>"<% } %><% if (locals.placeholder) { %> placeholder="<%= locals.placeholder %>"<% } %><% if (locals.value != null) { %> value="<%= locals.value %>"<% } %><% if (locals.minlength != null) { %> minlength="<%= locals.minlength %>"<% } %><% if (error) { %> aria-invalid="true" aria-describedby="<%= errId %>"<% } %><% if (locals.required) { %> required<% } %><% if (locals.readonly) { %> readonly<% } %>>
<% if (hint) { -%> <%= hint %> <% } -%> diff --git a/views/partials/user-form-body.ejs b/views/partials/user-form-body.ejs new file mode 100644 index 0000000..0205428 --- /dev/null +++ b/views/partials/user-form-body.ejs @@ -0,0 +1,36 @@ +<%# + Admin user create/edit form body (todo §5), captured into the shell content slot. Config: + form { action, csrfToken, submitLabel, cancelHref, fields: field.ejs config[] } + edit? { nextLabel, stateAction, recoveryAction, deleteAction } (edit mode only) + recovery? { code?, link? } shown after a recovery link is generated + error? string shown when a write was rejected +%><% + const form = locals.form; + const edit = locals.edit; + const recovery = locals.recovery; +-%> +
+<% if (locals.error) { -%> +<%- include("alert", { text: locals.error, tone: "neg" }) %> +<% } -%> +<% if (recovery) { -%> +
Recovery link generated<% if (recovery.link) { %><%= recovery.link %><% } %><% if (recovery.code) { %>Code: <%= recovery.code %><% } %>
+<% } -%> +
+ +<% form.fields.forEach((field) => { -%> + <%- include("field", field) %> +<% }) -%> +
+ Cancel + +
+
+<% if (edit) { -%> +
+
+
+
+
+<% } -%> +