From 32e5e2f7eb89e91180faf9d3878ba66582f3ef93 Mon Sep 17 00:00:00 2001 From: lilleman Date: Thu, 18 Jun 2026 17:40:36 +0200 Subject: [PATCH] =?UTF-8?q?Built-in=20Groups=20admin=20screen=20(todo=20?= =?UTF-8?q?=C2=A75);=20/admin/groups=20list=20(search/sort/paginate)=20+?= =?UTF-8?q?=20create/delete=20+=20membership=20(add/remove=20users=20&=20n?= =?UTF-8?q?ested=20groups),=20writing=20only=20to=20Keto=20=E2=80=94=20gat?= =?UTF-8?q?ed=20admin-only=20+=20CSRF-guarded=20like=20Users=20(Kratos=20r?= =?UTF-8?q?ead=20only=20to=20label=20pickers).=20A=20group=20=3D=20Keto=20?= =?UTF-8?q?subject=20set=20Group:#members,=20exists=20while=20it=20h?= =?UTF-8?q?as=20=E2=89=A51=20member:=20create=20writes=20the=20first-membe?= =?UTF-8?q?r=20tuple,=20delete=20removes=20all=20by=20partial-filter.=20Ex?= =?UTF-8?q?tracted=20shared=20admin-nav.ts=20(Dashboard=C2=B7Users=C2=B7Gr?= =?UTF-8?q?oups);=20new=20generic=20rowHeader=20=20data-?= =?UTF-8?q?table=20cell.=20Stability-reviewer=20run=20as=20a=20local=20PR:?= =?UTF-8?q?=20symmetric=20subject=20UUID-validation,=20duplicate-name=20re?= =?UTF-8?q?jection,=20malformed-%=E2=86=92404.=20228=E2=86=92237=20units?= =?UTF-8?q?=20+=20typecheck=20green;=20core=20Keto=20interactions=20boot-v?= =?UTF-8?q?erified=20live?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- public/css/styles.css | 2 + src/admin-groups.test.ts | 112 ++++++++ src/admin-groups.ts | 410 +++++++++++++++++++++++++++ src/admin-nav.ts | 23 ++ src/admin-users.ts | 11 +- src/app.test.ts | 86 +++++- src/app.ts | 24 +- src/data-table.test.ts | 7 + todo.md | 22 +- views/admin/group-detail.ejs | 16 ++ views/admin/group-form.ejs | 16 ++ views/admin/groups.ejs | 21 ++ views/partials/data-table.ejs | 6 +- views/partials/group-detail-body.ejs | 42 +++ views/partials/group-form-body.ejs | 26 ++ 16 files changed, 798 insertions(+), 30 deletions(-) create mode 100644 src/admin-groups.test.ts create mode 100644 src/admin-groups.ts create mode 100644 src/admin-nav.ts create mode 100644 views/admin/group-detail.ejs create mode 100644 views/admin/group-form.ejs create mode 100644 views/admin/groups.ejs create mode 100644 views/partials/group-detail-body.ejs create mode 100644 views/partials/group-form-body.ejs diff --git a/README.md b/README.md index 222c4d1..e271e97 100644 --- a/README.md +++ b/README.md @@ -534,6 +534,8 @@ 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/admin-groups.ts Built-in Groups admin screen (§5): list Keto subject sets + create/delete + membership (add/remove users & nested groups); writes only to Keto, gated + CSRF-guarded +src/admin-nav.ts adminNav(): the shared sidebar nav for the built-in admin screens (Dashboard · Users · Groups) 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 } @@ -544,7 +546,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, 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) +views/ Core EJS templates (index = the app-shell People dashboard, admin/ = the Users list + create/edit form and the Groups list + create form + membership detail, 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/group-form/group-detail bodies, 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 ab2a7fd..569ebac 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -673,6 +673,8 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); } border: 1px solid var(--border); border-radius: var(--radius); } .form-actions { display: flex; gap: 10px; justify-content: flex-end; } +.card-title { margin: 0; font-size: 14px; font-weight: 600; } +.inline-form { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; } .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); } diff --git a/src/admin-groups.test.ts b/src/admin-groups.test.ts new file mode 100644 index 0000000..9bdd84d --- /dev/null +++ b/src/admin-groups.test.ts @@ -0,0 +1,112 @@ +// Built-in Groups admin screen (§5): the pure view-model + Keto-tuple builders. A group is a +// Keto subject set (Group:#members); membership tuples carry users (subject_id) or nested +// groups (subject_set). The HTTP routing/gate/CSRF + live Keto/Kratos calls are exercised over +// HTTP in app.test.ts. +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + buildGroupDetailModel, + buildGroupFormModel, + buildGroupsListModel, + groupsFromTuples, + isValidGroupName, + memberTuple, + memberView, + parseSubject, +} from "./admin-groups.ts"; +import type { RelationTuple } from "./keto-client.ts"; + +const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`; +const userTuple = (group: string, n: number): RelationTuple => + ({ namespace: "Group", object: group, relation: "members", subject_id: `user:${uid(n)}` }); +const groupTuple = (group: string, child: string): RelationTuple => + ({ namespace: "Group", object: group, relation: "members", subject_set: { namespace: "Group", object: child, relation: "members" } }); + +test("isValidGroupName accepts URL-safe names, rejects empties/spaces/uppercase/leading punctuation", () => { + for (const ok of ["eng", "team-a", "a1_b9", "x"]) assert.equal(isValidGroupName(ok), true, ok); + for (const bad of ["", "Eng", "a b", "-bad", "_bad", "a/b", "a".repeat(65)]) assert.equal(isValidGroupName(bad), false, bad); +}); + +test("parseSubject + memberTuple map the form value to the user/nested-group subject (else null)", () => { + assert.deepEqual(parseSubject(`user:${uid(1)}`), { subject_id: `user:${uid(1)}` }); + assert.deepEqual(parseSubject("group:eng"), { subject_set: { namespace: "Group", object: "eng", relation: "members" } }); + // Both forms are validated: a non-UUID user / invalid group name is rejected, not written blindly. + for (const bad of ["", "user:", "user:not-a-uuid", "group:", "group:Bad Name", "nope:x", "plain"]) assert.equal(parseSubject(bad), null, bad); + + assert.deepEqual(memberTuple("design", `user:${uid(2)}`), { namespace: "Group", object: "design", relation: "members", subject_id: `user:${uid(2)}` }); + assert.deepEqual(memberTuple("design", "group:eng"), { namespace: "Group", object: "design", relation: "members", subject_set: { namespace: "Group", object: "eng", relation: "members" } }); + assert.equal(memberTuple("design", "bad"), null); +}); + +test("groupsFromTuples collapses membership tuples → distinct groups + member counts, sorted by name", () => { + const tuples = [userTuple("eng", 1), userTuple("eng", 2), groupTuple("eng", "design"), userTuple("design", 3)]; + assert.deepEqual(groupsFromTuples(tuples), [ + { memberCount: 1, name: "design" }, + { memberCount: 3, name: "eng" }, + ]); +}); + +test("memberView resolves a user subject to its email (else the raw id) and a subject_set to the group", () => { + const emails = new Map([[uid(1), "ada@example.com"]]); + assert.deepEqual(memberView(userTuple("eng", 1), emails), { kind: "user", label: "ada@example.com", subject: `user:${uid(1)}` }); + assert.deepEqual(memberView(userTuple("eng", 9), emails), { kind: "user", label: `user:${uid(9)}`, subject: `user:${uid(9)}` }); + assert.deepEqual(memberView(groupTuple("eng", "design"), emails), { kind: "group", label: "design", subject: "group:design" }); +}); + +test("buildGroupsListModel filters by search, sorts, paginates; the name links to the detail page", () => { + const groups = Array.from({ length: 30 }, (_, i) => ({ memberCount: i + 1, name: `team-${String(i).padStart(2, "0")}` })); + + const all = buildGroupsListModel({ groups, url: "http://x/admin/groups" }); + assert.equal(all.pagination.summary.total, 30); + assert.equal(all.table.rows.length, 25); // default page size + assert.equal(all.shell.title, "Groups"); + // The group name is the row header, linking to its detail page. + const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } }; + assert.equal(first.rowHeader.text, "team-00"); + assert.equal(first.rowHeader.href, "/admin/groups/team-00"); + + // Search narrows + shows a pill. + const one = buildGroupsListModel({ groups, url: "http://x/admin/groups?q=team-07" }); + assert.equal(one.pagination.summary.total, 1); + assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]); + + // Sort by members descending puts the biggest group first. + const desc = buildGroupsListModel({ groups, url: "http://x/admin/groups?sort=-members" }); + assert.equal((desc.table.rows[0]!.cells[0] as { rowHeader: { text: string } }).rowHeader.text, "team-29"); +}); + +test("buildGroupFormModel: a create form with a required name field + member options, no group of its own", () => { + const options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }]; + const m = buildGroupFormModel({ csrfToken: "tok.sig", memberOptions: options }); + assert.equal(m.shell.title, "New group"); + assert.equal(m.form.action, "/admin/groups"); + assert.equal(m.form.submitLabel, "Create group"); + assert.equal(m.form.csrfToken, "tok.sig"); + assert.equal(m.form.nameField.name, "name"); + assert.equal(m.form.nameField.required, true); + assert.deepEqual(m.form.memberOptions, options); + + // An error (e.g. a taken/invalid name) re-renders with the submitted values. + const err = buildGroupFormModel({ error: "That name is taken.", memberOptions: options, values: { member: "group:eng", name: "Eng" } }); + assert.equal(err.error, "That name is taken."); + assert.equal(err.form.nameField.value, "Eng"); + assert.equal(err.form.selectedMember, "group:eng"); +}); + +test("buildGroupDetailModel: members → rows, add-options exclude current members + the group itself, delete/remove wired", () => { + const members = [memberView(userTuple("eng", 1), new Map([[uid(1), "ada@example.com"]])), memberView(groupTuple("eng", "design"), new Map())]; + const candidates = [ + { label: "ada@example.com", value: `user:${uid(1)}` }, // already a member → excluded + { label: "grace@example.com", value: `user:${uid(2)}` }, + { label: "design (group)", value: "group:design" }, // already a member → excluded + { label: "eng (group)", value: "group:eng" }, // the group itself → excluded + { label: "ops (group)", value: "group:ops" }, + ]; + const m = buildGroupDetailModel({ candidates, group: { name: "eng" }, members }); + assert.equal(m.shell.title, "eng"); + assert.equal(m.members.rows.length, 2); + assert.equal(m.members.action, "/admin/groups/eng/members/delete"); + assert.equal(m.add.action, "/admin/groups/eng/members"); + assert.deepEqual(m.add.options.map((o) => o.value), [`user:${uid(2)}`, "group:ops"]); + assert.equal(m.delete.action, "/admin/groups/eng/delete"); +}); diff --git a/src/admin-groups.ts b/src/admin-groups.ts new file mode 100644 index 0000000..5609c6c --- /dev/null +++ b/src/admin-groups.ts @@ -0,0 +1,410 @@ +// Built-in Groups admin screen (todo §5): list / create / delete Keto groups and manage their +// membership. A group is a Keto subject set `Group:#members`; a membership tuple's subject is +// a user (`subject_id = user:`) or a nested group (`subject_set = Group:#members`). Writes +// go only to Keto (README "stateless"); there is no group store. Keto has no "create object" — a +// group exists exactly while it has ≥1 member, so creating one writes its first-member tuple and +// deleting one removes every member tuple. The pure builders turn tuples + the request URL into the +// building-block view models; `handleAdminGroups` is the imperative shell app.ts dispatches to — it +// gates (admin only), CSRF-guards every mutation, and maps each action to a RouteResult. + +import { ADMIN_GROUPS_BASE, ADMIN_PERMISSION, adminNav } from "./admin-nav.ts"; +import type { FieldConfig } from "./admin-users.ts"; +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 { KetoClient, RelationQuery, RelationTuple, SubjectSet } from "./keto-client.ts"; +import type { KratosAdmin } from "./kratos-admin.ts"; +import { parseListQuery } from "./list-query.ts"; +import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts"; +import { paginate } from "./paginate.ts"; +import type { RouteResult } from "./plugin.ts"; +import { buildShellContext } from "./shell-context.ts"; + +const GROUP_NS = "Group"; +const MEMBERS = "members"; +const DEFAULT_PAGE_SIZE = 25; +const PAGE_SIZES = [25, 50, 100]; +// One Keto page of candidate users is fetched for the member pickers (mirrors admin-users). +const LIST_FETCH_SIZE = 250; +const GROUP_NAME = /^[a-z0-9][a-z0-9_-]*$/; // URL-safe; doubles as the path segment +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; // a Kratos identity id + +export interface GroupView { + memberCount: number; + name: string; +} + +// A member's view model: a user (label = email) or a nested group (label = group name). `subject` +// is the form value that round-trips it — `user:` or `group:` (see parseSubject). +export interface MemberView { + kind: "group" | "user"; + label: string; + subject: string; +} + +// One option in a member +<% }) -%> + +<% } else { -%> +

No members yet.

+<% } -%> + +
+

Add a member

+<% if (add.options.length) { -%> +
+<% } else { -%> +

All users and groups are already members.

+<% } -%> +
+
+
+
+ diff --git a/views/partials/group-form-body.ejs b/views/partials/group-form-body.ejs new file mode 100644 index 0000000..04a383e --- /dev/null +++ b/views/partials/group-form-body.ejs @@ -0,0 +1,26 @@ +<%# + Admin group create form body (todo §5), captured into the shell content slot. Config: + form { action, csrfToken, submitLabel, cancelHref, nameField: field.ejs config, + memberOptions: {label,value}[], selectedMember } + error? string shown when a write was rejected +%><% + const form = locals.form; +-%> +
+<% if (locals.error) { -%> +<%- include("alert", { text: locals.error, tone: "neg" }) %> +<% } -%> +
+ + <%- include("field", form.nameField) %> +
+ + + A group exists once it has a member; add more after creating it. +
+
+ Cancel + +
+
+