Rename the Keto User namespace to Identity, matching Kratos
CI / full-gate (push) Successful in 2m34s

This commit is contained in:
2026-08-03 12:19:12 +02:00
parent ca3d49ec45
commit f7d70cfff8
37 changed files with 220 additions and 212 deletions
+2 -2
View File
@@ -5,7 +5,7 @@
// PRG redirect (mirrors the Users "trigger recovery" one-time code). Below the builders are thin
// per-route handlers (keyed on ctx.params) over a shared `withClients` gate — admin-only, CSRF-guarded.
import { type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type User } from "#plugin-api";
import { type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type SessionIdentity } from "#plugin-api";
import { ADMIN_CLIENTS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
import type { FieldConfig } from "./admin-users.ts";
@@ -235,7 +235,7 @@ function readClientInput(form: URLSearchParams): ClientInput {
// Shared per-request deps for the OAuth2-clients screen, resolved by `withClients`: the gate + the
// Hydra capability (else a themed 503). Each route below is a thin handler over these.
interface ClientsDeps { ctx: RequestContext; hydra: HydraAdmin; user: User; }
interface ClientsDeps { ctx: RequestContext; hydra: HydraAdmin; user: SessionIdentity; }
function withClients(inner: (deps: ClientsDeps) => Promise<RouteResult>): RouteHandler {
return async (ctx) => {
+10 -10
View File
@@ -18,7 +18,7 @@ import type { RelationTuple } from "#plugin-api";
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)}` });
({ namespace: "Group", object: group, relation: "members", subject_id: `identity:${uid(n)}` });
const groupTuple = (group: string, child: string): RelationTuple =>
({ namespace: "Group", object: group, relation: "members", subject_set: { namespace: "Group", object: child, relation: "members" } });
@@ -28,12 +28,12 @@ test("isValidGroupName accepts URL-safe names, rejects empties/spaces/uppercase/
});
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(`identity:${uid(1)}`), { subject_id: `identity:${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);
for (const bad of ["", "identity:", "identity: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", `identity:${uid(2)}`), { namespace: "Group", object: "design", relation: "members", subject_id: `identity:${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);
});
@@ -48,8 +48,8 @@ test("groupsFromTuples collapses membership tuples → distinct groups + member
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(userTuple("eng", 1), emails), { kind: "identity", label: "ada@example.com", subject: `identity:${uid(1)}` });
assert.deepEqual(memberView(userTuple("eng", 9), emails), { kind: "identity", label: `identity:${uid(9)}`, subject: `identity:${uid(9)}` });
assert.deepEqual(memberView(groupTuple("eng", "design"), emails), { kind: "group", label: "design", subject: "group:design" });
});
@@ -76,7 +76,7 @@ test("buildGroupsListModel filters by search, sorts, paginates; the name links t
});
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 options = [{ label: "ada@example.com", value: `identity:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
const m = buildGroupFormModel({ csrfToken: "tok.sig", memberOptions: options });
assert.equal(m.title, "New group");
assert.equal(m.form.action, "/admin/groups");
@@ -96,8 +96,8 @@ test("buildGroupFormModel: a create form with a required name field + member opt
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: "ada@example.com", value: `identity:${uid(1)}` }, // already a member → excluded
{ label: "grace@example.com", value: `identity:${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" },
@@ -107,6 +107,6 @@ test("buildGroupDetailModel: members → rows, add-options exclude current membe
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.deepEqual(m.add.options.map((o) => o.value), [`identity:${uid(2)}`, "group:ops"]);
assert.equal(m.delete.action, "/admin/groups/eng/delete");
});
+9 -9
View File
@@ -6,7 +6,7 @@
// per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded,
// each returning a RouteResult.
import { type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type User } from "#plugin-api";
import { type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type SessionIdentity } from "#plugin-api";
import { ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
import type { FieldConfig } from "./admin-users.ts";
@@ -25,9 +25,9 @@ export interface GroupView {
}
// 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:<id>` or `group:<name>` (see parseSubject).
// is the form value that round-trips it — `identity:<id>` or `group:<name>` (see parseSubject).
export interface MemberView {
kind: "group" | "user";
kind: "group" | "identity";
label: string;
subject: string;
}
@@ -35,7 +35,7 @@ export interface MemberView {
// One option in a member <select>.
export interface MemberOption {
label: string;
value: string; // `user:<id>` | `group:<name>`
value: string; // `identity:<id>` | `group:<name>`
}
export function isValidGroupName(name: string): boolean {
@@ -51,7 +51,7 @@ export function parseSubject(value: string): { subject_id: string } | { subject_
if (!rest) return null;
// Validate both subject forms so a crafted POST can't write a dangling tuple (the pickers only
// ever offer real users/groups): a user id is a Kratos UUID, a nested group a valid group name.
if (value.slice(0, sep) === "user") return UUID.test(rest) ? { subject_id: `user:${rest}` } : null;
if (value.slice(0, sep) === "identity") return UUID.test(rest) ? { subject_id: `identity:${rest}` } : null;
if (value.slice(0, sep) === "group") return isValidGroupName(rest) ? { subject_set: { namespace: GROUP_NS, object: rest, relation: MEMBERS } } : null;
return null;
}
@@ -72,8 +72,8 @@ export function groupsFromTuples(tuples: RelationTuple[]): GroupView[] {
export function memberView(tuple: RelationTuple, emailById: Map<string, string>): MemberView {
if (tuple.subject_set) return { kind: "group", label: tuple.subject_set.object, subject: `group:${tuple.subject_set.object}` };
const subjectId = tuple.subject_id ?? "";
const id = subjectId.startsWith("user:") ? subjectId.slice("user:".length) : subjectId;
return { kind: "user", label: emailById.get(id) ?? subjectId, subject: subjectId };
const id = subjectId.startsWith("identity:") ? subjectId.slice("identity:".length) : subjectId;
return { kind: "identity", label: emailById.get(id) ?? subjectId, subject: subjectId };
}
// ---- list view model ----
@@ -267,7 +267,7 @@ export async function memberCandidates(keto: KetoClient, kratosAdmin: KratosAdmi
const trait = it.traits?.["email"];
const email = typeof trait === "string" ? trait : it.id;
emailById.set(it.id, email);
userOptions.push({ label: email, value: `user:${it.id}` });
userOptions.push({ label: email, value: `identity:${it.id}` });
}
const groups = groupsFromTuples(await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS }));
return { emailById, options: [...userOptions, ...groups.map((g) => ({ label: `${g.name} (group)`, value: `group:${g.name}` }))] };
@@ -281,7 +281,7 @@ async function groupExists(keto: KetoClient, name: string): Promise<boolean> {
// Shared per-request deps for the Groups screen, resolved by `withGroups`: the gate + the Keto and
// Kratos capabilities (else a themed 503). Each route below is a thin handler over these.
interface GroupsDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: KratosAdmin; user: User; }
interface GroupsDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: KratosAdmin; user: SessionIdentity; }
function withGroups(inner: (deps: GroupsDeps) => Promise<RouteResult>): RouteHandler {
return async (ctx) => {
+8 -8
View File
@@ -18,7 +18,7 @@ import type { ExpandTree, RelationTuple } from "#plugin-api";
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
const userTuple = (role: string, n: number): RelationTuple =>
({ namespace: "Role", object: role, relation: "members", subject_id: `user:${uid(n)}` });
({ namespace: "Role", object: role, relation: "members", subject_id: `identity:${uid(n)}` });
const groupTuple = (role: string, group: string): RelationTuple =>
({ namespace: "Role", object: role, relation: "members", subject_set: { namespace: "Group", object: group, relation: "members" } });
@@ -26,14 +26,14 @@ test("isValidRoleName + roleMemberTuple map the form value to a Role tuple over
for (const ok of ["admin", "editor", "team-a", "a1_b9"]) assert.equal(isValidRoleName(ok), true, ok);
for (const bad of ["", "Admin", "a b", "-bad", "a".repeat(65)]) assert.equal(isValidRoleName(bad), false, bad);
assert.deepEqual(roleMemberTuple("editor", `user:${uid(2)}`), { namespace: "Role", object: "editor", relation: "members", subject_id: `user:${uid(2)}` });
assert.deepEqual(roleMemberTuple("editor", `identity:${uid(2)}`), { namespace: "Role", object: "editor", relation: "members", subject_id: `identity:${uid(2)}` });
assert.deepEqual(roleMemberTuple("editor", "group:eng"), { namespace: "Role", object: "editor", relation: "members", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
for (const bad of ["", "user:not-a-uuid", "group:Bad Name", "nope:x"]) assert.equal(roleMemberTuple("editor", bad), null, bad);
for (const bad of ["", "identity:not-a-uuid", "group:Bad Name", "nope:x"]) assert.equal(roleMemberTuple("editor", bad), null, bad);
});
test("expandToEffectiveUsers flattens an expand tree → sorted distinct user ids, transitive through groups", () => {
// The subject rides on each node's `tuple` (Keto v26.2.0 shape, verified live).
const leaf = (n: number): ExpandTree => ({ tuple: { namespace: "", object: "", relation: "", subject_id: `user:${uid(n)}` }, type: "leaf" });
const leaf = (n: number): ExpandTree => ({ tuple: { namespace: "", object: "", relation: "", subject_id: `identity:${uid(n)}` }, type: "leaf" });
const tree: ExpandTree = {
children: [
leaf(1), // direct
@@ -71,7 +71,7 @@ test("buildRolesListModel filters by search, sorts, paginates; the name links to
});
test("buildRoleFormModel: a create form with a required name field + member options (user or group)", () => {
const options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
const options = [{ label: "ada@example.com", value: `identity:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
const m = buildRoleFormModel({ csrfToken: "tok.sig", memberOptions: options });
assert.equal(m.title, "New role");
assert.equal(m.form.action, "/admin/roles");
@@ -89,8 +89,8 @@ test("buildRoleFormModel: a create form with a required name field + member opti
test("buildRoleDetailModel: members → rows, add-options exclude current members, effective access listed, actions wired", () => {
const members = [memberView(userTuple("admin", 1), new Map([[uid(1), "ada@example.com"]])), memberView(groupTuple("admin", "eng"), 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: "ada@example.com", value: `identity:${uid(1)}` }, // already a member → excluded
{ label: "grace@example.com", value: `identity:${uid(2)}` },
{ label: "eng (group)", value: "group:eng" }, // already a member → excluded
{ label: "ops (group)", value: "group:ops" },
];
@@ -100,7 +100,7 @@ test("buildRoleDetailModel: members → rows, add-options exclude current member
assert.equal(m.members.rows.length, 2);
assert.equal(m.members.action, "/admin/roles/admin/members/delete");
assert.equal(m.add.action, "/admin/roles/admin/members");
assert.deepEqual(m.add.options.map((o) => o.value), [`user:${uid(2)}`, "group:ops"]);
assert.deepEqual(m.add.options.map((o) => o.value), [`identity:${uid(2)}`, "group:ops"]);
assert.deepEqual(m.effective.map((e) => e.label), ["ada@example.com", "grace@example.com"]);
assert.equal(m.delete.action, "/admin/roles/admin/delete");
});
+7 -7
View File
@@ -8,7 +8,7 @@
// Kratos is read only to label members. Below the builders are thin per-route handlers (keyed on
// ctx.params) over a shared `withRoles` gate — admin-only, CSRF-guarded.
import { type ExpandTree, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type User } from "#plugin-api";
import { type ExpandTree, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SessionIdentity } from "#plugin-api";
import { ADMIN_ROLE, ADMIN_ROLES_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
import {
type GroupView,
@@ -54,7 +54,7 @@ export function expandToEffectiveUsers(tree: ExpandTree | null | undefined): str
const walk = (node?: ExpandTree | null): void => {
if (!node) return;
const subjectId = node.tuple?.subject_id;
if (subjectId?.startsWith("user:")) ids.add(subjectId.slice("user:".length));
if (subjectId?.startsWith("identity:")) ids.add(subjectId.slice("identity:".length));
node.children?.forEach(walk);
};
walk(tree);
@@ -231,11 +231,11 @@ export function buildRoleDetailModel(opts: {
// ---- request handler (imperative shell) ----
// instant-revoke: a role change for a `user:<id>` member must take effect now, so revoke that
// instant-revoke: a role change for a `identity:<id>` member must take effect now, so revoke that
// user's live tokens (a re-mint then re-reads roles from Keto). A `group:<name>` change is
// transitive across many users — left to lag (documented), so only direct user members revoke.
function revokeUserMember(revoke: ((sub: string) => void) | undefined, member: string): void {
if (revoke && member.startsWith("user:")) revoke(member.slice("user:".length));
if (revoke && member.startsWith("identity:")) revoke(member.slice("identity:".length));
}
// A role exists exactly while it has ≥1 member (Keto has no create-object).
@@ -250,13 +250,13 @@ async function effectiveUsers(keto: KetoClient, name: string, hasMembers: boolea
if (!hasMembers) return [];
const tree = await keto.expand({ namespace: ROLE_NS, object: name, relation: MEMBERS }, { maxDepth: EXPAND_MAX_DEPTH });
return expandToEffectiveUsers(tree)
.map((id) => ({ label: emailById.get(id) ?? `user:${id}` }))
.map((id) => ({ label: emailById.get(id) ?? `identity:${id}` }))
.sort((a, b) => a.label.localeCompare(b.label));
}
// Shared per-request deps for the Roles screen, resolved by `withRoles`: the gate + the Keto and
// Kratos capabilities (else a themed 503). Each route below is a thin handler over these.
interface RolesDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: User; }
interface RolesDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: SessionIdentity; }
function withRoles(inner: (deps: RolesDeps) => Promise<RouteResult>): RouteHandler {
return async (ctx) => {
@@ -360,7 +360,7 @@ export const rolesRemoveMember = withRoleName(async (deps, name) => {
const { ctx, keto, revoke, user } = deps;
const form = (await guardedForm(ctx))!;
const member = (form.get("member") ?? "").trim();
if (name === ADMIN_ROLE && member === `user:${user.id}`) return roleDetailResult(deps, name, "You can't revoke your own admin access.");
if (name === ADMIN_ROLE && member === `identity:${user.id}`) return roleDetailResult(deps, name, "You can't revoke your own admin access.");
const tuple = roleMemberTuple(name, member);
if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: role unassigned", { actor: user.id, member, role: name }); }
return { redirect: detailHref(name) };
+6 -6
View File
@@ -6,20 +6,20 @@ import assert from "node:assert/strict";
import type { IncomingMessage, ServerResponse } from "node:http";
import { Readable } from "node:stream";
import { test } from "node:test";
import { GuardError, type Log, type PageChrome, type RequestContext, type User } from "#plugin-api";
import { GuardError, type Log, type PageChrome, type RequestContext, type SessionIdentity } from "#plugin-api";
import { ADMIN_NAV, ADMIN_ROLE, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-shared.ts";
const admin: User = { email: "ada@x.io", id: "u1", roles: ["admin"] };
const member: User = { email: "bo@x.io", id: "u2", roles: ["scheduling:read"] };
const admin: SessionIdentity = { email: "ada@x.io", id: "u1", roles: ["admin"] };
const member: SessionIdentity = { email: "bo@x.io", id: "u2", roles: ["scheduling:read"] };
const CHROME = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } } as PageChrome;
function fakeCtx(opts: { body?: string; method?: string; user?: User | null; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
function fakeCtx(opts: { body?: string; method?: string; user?: SessionIdentity | null; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
const url = new URL("http://localhost/admin/users");
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
req.method = opts.method ?? "GET";
return {
chrome: CHROME, log: {} as Log, params: {}, query: url.searchParams, req, res: {} as ServerResponse,
roles: opts.user?.roles ?? [], url, user: opts.user ?? null, verifyCsrf: opts.verifyCsrf ?? (() => true),
chrome: CHROME, identity: opts.user ?? null, log: {} as Log, params: {}, query: url.searchParams, req, res: {} as ServerResponse,
roles: opts.user?.roles ?? [], url, verifyCsrf: opts.verifyCsrf ?? (() => true),
};
}
+2 -2
View File
@@ -3,7 +3,7 @@
// (themed not-found / capability-unavailable). Ported from the former built-in admin screens;
// everything imports the host only through the #plugin-api barrel.
import { can, CSRF_FIELD, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type User } from "#plugin-api";
import { can, CSRF_FIELD, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type SessionIdentity } from "#plugin-api";
export const ADMIN_ROLE = "admin"; // the role gating the whole admin section
export const ADMIN_USERS_BASE = "/admin/users";
@@ -32,7 +32,7 @@ export const ADMIN_NAV: NavNode = {
// The admin gate: a signed-in admin only. Each route already declares `role: "admin"`, so the
// host enforces this before the handler runs; this is defence-in-depth and what a direct unit test
// relies on. Returns the (non-null) user for the handler to thread on. GuardError → /login or 403.
export function requireAdmin(ctx: RequestContext): User {
export function requireAdmin(ctx: RequestContext): SessionIdentity {
const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept)
if (!can(ctx, ADMIN_ROLE)) throw new GuardError(403, "admin role required");
return user;
+2 -2
View File
@@ -4,7 +4,7 @@
// models; below them are thin per-route handlers (keyed on ctx.params) over a shared `withUser` gate
// — admin-only, CSRF-guarded, each returning a RouteResult (a view, or a redirect after a write — PRG).
import { type Identity, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type User } from "#plugin-api";
import { type Identity, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type SessionIdentity } from "#plugin-api";
import { ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id
@@ -266,7 +266,7 @@ function readUserInput(form: URLSearchParams): UserInput {
// Shared per-request deps for the Users screen, resolved by `withUser`: the gate (admin only) and
// the Kratos capability (else a themed 503). Each route below is a thin handler over these.
interface UsersDeps { ctx: RequestContext; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: User; }
interface UsersDeps { ctx: RequestContext; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: SessionIdentity; }
// Resolve the shared deps, then run `inner`. The route's `role: "admin"` already gated at the
// host; `requireAdmin` is defence-in-depth and yields the user. GuardError (auth/CSRF) → host maps it.
@@ -1,7 +1,7 @@
<%#
Admin group membership body, captured into the shell content slot. Config:
group { name }
members { action, rows: { kind:"group"|"user", label, subject }[] } action = remove-member endpoint
members { action, rows: { kind:"group"|"identity", label, subject }[] } action = remove-member endpoint
add { action, options: {label,value}[] } action = add-member endpoint
del { action } delete the whole group
csrfToken, error?
@@ -1,7 +1,7 @@
<%#
Admin role detail body, captured into the shell content slot. Config:
role { name }
members { action, rows: { kind:"group"|"user", label, subject }[] } action = revoke endpoint
members { action, rows: { kind:"group"|"identity", label, subject }[] } action = revoke endpoint
effective { label }[] users who hold the role (expand)
add { action, options: {label,value}[] } action = assign endpoint
del { action } delete the whole role