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:
@@ -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/context.ts RequestContext handed to handlers + buildContext()
|
||||||
src/config.ts Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, port; validated at boot
|
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/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/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/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
|
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/router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate (§2)
|
||||||
src/view-resolver.ts renderPluginView(): render plugins/<id>/views/<view>.ejs; plugin views can include() core partials (§2)
|
src/view-resolver.ts renderPluginView(): render plugins/<id>/views/<view>.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)
|
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)
|
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)
|
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)
|
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)
|
||||||
|
|||||||
@@ -664,3 +664,17 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
|
|||||||
|
|
||||||
/* the nav-toggle checkbox itself is visually hidden but focusable */
|
/* the nav-toggle checkbox itself is visually hidden but focusable */
|
||||||
#nav-toggle { position: absolute; opacity: 0; pointer-events: none; }
|
#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; }
|
||||||
|
|||||||
125
src/admin-users.test.ts
Normal file
125
src/admin-users.test.ts
Normal 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
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.";
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import assert from "node:assert/strict";
|
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 { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import type { AddressInfo } from "node:net";
|
import type { AddressInfo } from "node:net";
|
||||||
import { tmpdir } from "node:os";
|
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.
|
// Login completion (§4): /auth/complete is where Kratos lands the browser after login.
|
||||||
const stubAdmin = (over: Partial<KratosAdmin>): KratosAdmin => ({
|
const stubAdmin = (over: Partial<KratosAdmin>): KratosAdmin => ({
|
||||||
createIdentity: async () => { throw new Error("unused"); },
|
createIdentity: async () => { throw new Error("unused"); },
|
||||||
|
createRecoveryCode: async () => ({ code: "000000", link: "http://kratos/recover" }),
|
||||||
deleteIdentity: async () => {},
|
deleteIdentity: async () => {},
|
||||||
getIdentity: async () => null,
|
getIdentity: async () => null,
|
||||||
listIdentities: async () => ({ identities: [], nextPageToken: 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
|
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", () => {
|
test("resolveStaticPath blocks traversal and control chars, allows nested files", () => {
|
||||||
assert.equal(resolveStaticPath("/srv/public", "../app.ts"), null);
|
assert.equal(resolveStaticPath("/srv/public", "../app.ts"), null);
|
||||||
assert.equal(resolveStaticPath("/srv/public", "a\x00b"), null);
|
assert.equal(resolveStaticPath("/srv/public", "a\x00b"), null);
|
||||||
|
|||||||
23
src/app.ts
23
src/app.ts
@@ -3,6 +3,7 @@ import { createServer, type Server, type ServerResponse } from "node:http";
|
|||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import * as ejs from "ejs";
|
import * as ejs from "ejs";
|
||||||
|
import { ADMIN_USERS_BASE, type AdminUsersDeps, handleAdminUsers } from "./admin-users.ts";
|
||||||
import { readFormBody } from "./body.ts";
|
import { readFormBody } from "./body.ts";
|
||||||
import { buildContext, type User } from "./context.ts";
|
import { buildContext, type User } from "./context.ts";
|
||||||
import { CSRF_FIELD, csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "./csrf.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 publicDir = options.publicDir ?? join(rootDir, "public");
|
||||||
const viewsDir = options.viewsDir ?? join(rootDir, "views");
|
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> =>
|
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
|
// 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.
|
// building-block partials (resolved from viewsDir) and their own partials/subfolders.
|
||||||
const renderView = renderPluginView({ cache, coreViewsDir: viewsDir, pluginsDir });
|
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 => {
|
const sendHtml = (res: ServerResponse, status: number, html: string): void => {
|
||||||
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
|
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
|
||||||
res.end(html);
|
res.end(html);
|
||||||
@@ -136,6 +143,18 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
return;
|
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).
|
// Themed Kratos self-service pages (login/registration/recovery/verification/settings).
|
||||||
const flowType = AUTH_FLOWS[pathname];
|
const flowType = AUTH_FLOWS[pathname];
|
||||||
if (kratos && flowType && (method === "GET" || method === "HEAD")) {
|
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.
|
// 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.
|
// 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 }));
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
// parseListQuery → filter/sort/paginate a mock dataset → composeNav. Mock data stands in for
|
// 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).
|
// 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 { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
|
||||||
import { composeNav, type NavNode, type NavOverride } from "./nav.ts";
|
import { composeNav, type NavNode, type NavOverride } from "./nav.ts";
|
||||||
import { parseListQuery } from "./list-query.ts";
|
import { parseListQuery } from "./list-query.ts";
|
||||||
import { paginate } from "./paginate.ts";
|
import { paginate } from "./paginate.ts";
|
||||||
|
import { buildShellContext } from "./shell-context.ts";
|
||||||
|
|
||||||
interface Person {
|
interface Person {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -77,7 +79,7 @@ function href(state: State, overrides: Partial<State> = {}): string {
|
|||||||
return qs ? `?${qs}` : "?";
|
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 query = parseListQuery(url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
||||||
const status = query.filters.status?.[0] ?? "all";
|
const status = query.filters.status?.[0] ?? "all";
|
||||||
const team = query.filters.team?.[0] ?? "";
|
const team = query.filters.team?.[0] ?? "";
|
||||||
@@ -104,18 +106,13 @@ export function buildDashboardModel(url: URL | URLSearchParams | string, roles:
|
|||||||
filterBar: filterBar(state),
|
filterBar: filterBar(state),
|
||||||
nav: nav(roles, menu.override),
|
nav: nav(roles, menu.override),
|
||||||
pagination: pagination(state, page),
|
pagination: pagination(state, page),
|
||||||
shell: {
|
shell: buildShellContext({
|
||||||
brand: {
|
|
||||||
...(menu.branding.logo != null ? { logo: menu.branding.logo } : {}),
|
|
||||||
name: menu.branding.name,
|
|
||||||
...(menu.branding.sub != null ? { sub: menu.branding.sub } : {}),
|
|
||||||
},
|
|
||||||
breadcrumbs: [{ href: "?", label: "Directory" }, { label: "People" }],
|
breadcrumbs: [{ href: "?", label: "Directory" }, { label: "People" }],
|
||||||
csrfToken, // hidden field for the shell's Sign-out POST form (§4)
|
csrfToken, // hidden field for the shell's Sign-out POST form (§4)
|
||||||
...(menu.branding.theme != null ? { theme: menu.branding.theme } : {}),
|
menu,
|
||||||
title: "People",
|
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),
|
table: table(rows, state, sort),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }> = [
|
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: "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 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: "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: "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 },
|
{ name: "import throws", files: { "explodes/plugin.ts": "throw new Error('boom');" }, match: /explodes.*boom/s },
|
||||||
|
|||||||
@@ -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"] } }]);
|
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 () => {
|
test("deleteIdentity DELETEs by id (204 resolves; non-204 throws a KratosError)", async () => {
|
||||||
const { calls, fetchImpl } = recorder(() => res(204));
|
const { calls, fetchImpl } = recorder(() => res(204));
|
||||||
await createKratosAdmin({ baseUrl: BASE, fetchImpl }).deleteIdentity(ID);
|
await createKratosAdmin({ baseUrl: BASE, fetchImpl }).deleteIdentity(ID);
|
||||||
|
|||||||
@@ -25,8 +25,15 @@ export interface ListOptions {
|
|||||||
pageToken?: string;
|
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 {
|
export interface KratosAdmin {
|
||||||
createIdentity(payload: unknown): Promise<Identity>;
|
createIdentity(payload: unknown): Promise<Identity>;
|
||||||
|
createRecoveryCode(identityId: string, opts?: { expiresIn?: string }): Promise<RecoveryCode>;
|
||||||
deleteIdentity(id: string): Promise<void>;
|
deleteIdentity(id: string): Promise<void>;
|
||||||
getIdentity(id: string): Promise<Identity | null>;
|
getIdentity(id: string): Promise<Identity | null>;
|
||||||
listIdentities(opts?: ListOptions): Promise<IdentityList>;
|
listIdentities(opts?: ListOptions): Promise<IdentityList>;
|
||||||
@@ -58,6 +65,17 @@ export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof
|
|||||||
return (await res.json()) as Identity;
|
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) {
|
async deleteIdentity(id) {
|
||||||
const res = await http(identity(id), { method: "DELETE" });
|
const res = await http(identity(id), { method: "DELETE" });
|
||||||
if (res.status !== 204) await fail("delete identity", res);
|
if (res.status !== 204) await fail("delete identity", res);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const ketoStub = (over: Partial<KetoClient> = {}): KetoClient => ({
|
|||||||
|
|
||||||
const adminStub = (over: Partial<KratosAdmin> = {}): KratosAdmin => ({
|
const adminStub = (over: Partial<KratosAdmin> = {}): KratosAdmin => ({
|
||||||
createIdentity: async () => { throw new Error("unused"); },
|
createIdentity: async () => { throw new Error("unused"); },
|
||||||
|
createRecoveryCode: async () => ({ code: "000000", link: "http://kratos/recover" }),
|
||||||
deleteIdentity: async () => {},
|
deleteIdentity: async () => {},
|
||||||
getIdentity: async () => null,
|
getIdentity: async () => null,
|
||||||
listIdentities: async () => ({ identities: [], nextPageToken: null }),
|
listIdentities: async () => ({ identities: [], nextPageToken: null }),
|
||||||
|
|||||||
@@ -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,
|
// 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
|
// /logout, the /admin screens, the dashboard's /public/ static). Plugin routes resolve before
|
||||||
// one of them would silently shadow a built-in route — discovery refuses it, loud like any conflict.
|
// 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([
|
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 {
|
export interface Semver {
|
||||||
|
|||||||
29
src/shell-context.test.ts
Normal file
29
src/shell-context.test.ts
Normal 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
47
src/shell-context.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
2
todo.md
2
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.
|
- [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)
|
## 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.
|
- [ ] Groups: Keto subject sets — list/create/delete + membership management.
|
||||||
- [ ] Roles & permissions: Keto relations — assign roles to users/groups; "effective access" view via Keto expand.
|
- [ ] Roles & permissions: Keto relations — assign roles to users/groups; "effective access" view via Keto expand.
|
||||||
- [ ] Wire into the menu (admin section, permission-gated).
|
- [ ] Wire into the menu (admin section, permission-gated).
|
||||||
|
|||||||
16
views/admin/user-form.ejs
Normal file
16
views/admin/user-form.ejs
Normal file
@@ -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,
|
||||||
|
}) %>
|
||||||
21
views/admin/users.ejs
Normal file
21
views/admin/users.ejs
Normal file
@@ -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 = '<a class="btn btn-primary" href="/admin/users/new"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Add user</a>';
|
||||||
|
-%>
|
||||||
|
<%- 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,
|
||||||
|
}) %>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
Form field: label (+ inline link / "Optional") · icon input · hint · error.
|
Form field: label (+ inline link / "Optional") · icon input · hint · error.
|
||||||
Mirrors html-css-foundation auth markup. Config:
|
Mirrors html-css-foundation auth markup. Config:
|
||||||
id, name, label
|
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
|
icon? input-ico id (e.g. "i-mail") → left-padded input
|
||||||
optional? show an "Optional" tag in the label row
|
optional? show an "Optional" tag in the label row
|
||||||
link? { href, label } inline beside the label (e.g. Forgot password?)
|
link? { href, label } inline beside the label (e.g. Forgot password?)
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
<% } else { -%>
|
<% } else { -%>
|
||||||
<label for="<%= id %>"><%= locals.label %></label>
|
<label for="<%= id %>"><%= locals.label %></label>
|
||||||
<% } -%>
|
<% } -%>
|
||||||
<div class="input-wrap"><% if (icon) { %><svg class="ico ico-sm input-ico" aria-hidden="true"><use href="#<%= icon %>"/></svg><% } %><input class="input<% if (icon) { %> has-ico<% } %>" id="<%= id %>" name="<%= locals.name %>" type="<%= type %>"<% if (locals.autocomplete) { %> 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<% } %>></div>
|
<div class="input-wrap"><% if (icon) { %><svg class="ico ico-sm input-ico" aria-hidden="true"><use href="#<%= icon %>"/></svg><% } %><input class="input<% if (icon) { %> has-ico<% } %>" id="<%= id %>" name="<%= locals.name %>" type="<%= type %>"<% if (locals.autocomplete) { %> 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<% } %>></div>
|
||||||
<% if (hint) { -%>
|
<% if (hint) { -%>
|
||||||
<span class="field-hint"><%= hint %></span>
|
<span class="field-hint"><%= hint %></span>
|
||||||
<% } -%>
|
<% } -%>
|
||||||
|
|||||||
36
views/partials/user-form-body.ejs
Normal file
36
views/partials/user-form-body.ejs
Normal file
@@ -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;
|
||||||
|
-%>
|
||||||
|
<div class="form-page">
|
||||||
|
<% if (locals.error) { -%>
|
||||||
|
<%- include("alert", { text: locals.error, tone: "neg" }) %>
|
||||||
|
<% } -%>
|
||||||
|
<% if (recovery) { -%>
|
||||||
|
<div class="alert alert-pos" role="status"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-check-circle"/></svg><div class="alert-body"><strong>Recovery link generated</strong><% if (recovery.link) { %><span class="recovery-link"><a href="<%= recovery.link %>"><%= recovery.link %></a></span><% } %><% if (recovery.code) { %><span>Code: <code><%= recovery.code %></code></span><% } %></div></div>
|
||||||
|
<% } -%>
|
||||||
|
<form class="form-card" method="post" action="<%= form.action %>">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
|
||||||
|
<% form.fields.forEach((field) => { -%>
|
||||||
|
<%- include("field", field) %>
|
||||||
|
<% }) -%>
|
||||||
|
<div class="form-actions">
|
||||||
|
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
|
||||||
|
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<% if (edit) { -%>
|
||||||
|
<section class="form-card admin-actions" aria-label="Account actions">
|
||||||
|
<form method="post" action="<%= edit.recoveryAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-mail"/></svg>Send recovery link</button></form>
|
||||||
|
<form method="post" action="<%= edit.stateAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><%= edit.nextLabel %></button></form>
|
||||||
|
<form method="post" action="<%= edit.deleteAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn btn-danger" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete user</button></form>
|
||||||
|
</section>
|
||||||
|
<% } -%>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user