Give the paging loop, Link-header parser and FieldConfig one home each, and delete a group from its parents #235

Merged
lilleman merged 6 commits from item-70 into main 2026-11-09 01:42:19 +01:00
17 changed files with 146 additions and 119 deletions
Showing only changes of commit cdb5a00705 - Show all commits
+5
View File
@@ -30,6 +30,11 @@ every grant as it was and answers with the error page.
A Keto failure while deleting a group now leaves the group as it was, with its grants and members, A Keto failure while deleting a group now leaves the group as it was, with its grants and members,
and answers with the error page. It used to leave some grants gone and the members still listed. and answers with the error page. It used to leave some grants gone and the members still listed.
### The admin example removes a deleted group from the groups it was nested in
Deleting a group now also deletes the tuples that made it a member of another group, in the same
transaction. It used to leave them, so a group re-created with the same name was back in every parent.
### The admin example keeps a user's permissions section when it refuses a self-deactivate or self-delete ### The admin example keeps a user's permissions section when it refuses a self-deactivate or self-delete
The refusal page used to drop the permissions section of the user form. The refusal page used to drop the permissions section of the user form.
+1 -1
View File
@@ -1024,7 +1024,7 @@ whenever a child does. The rendered tree is **filtered per user** from the sessi
per-request authz call), so the menu only shows what that person can reach. per-request authz call), so the menu only shows what that person can reach.
Branding (name, logo, default theme) renders in the app shell. Branding (name, logo, default theme) renders in the app shell.
**One menu, one shell, everywhere.** A single menu (`src/ui/chrome.ts` `buildPluginChrome`) renders **One menu, one shell, everywhere.** A single menu (`src/ui/chrome.ts` `buildPageChrome`) renders
in the same app shell on **every** page — dashboard, plugin pages, and the login / registration / in the same app shell on **every** page — dashboard, plugin pages, and the login / registration /
recovery / front pages — so it looks identical signed in or out and just shows fewer items to an recovery / front pages — so it looks identical signed in or out and just shows fewer items to an
anonymous visitor. The sidebar collapses to a burger on a narrow screen; a page wanting a anonymous visitor. The sidebar collapses to a burger on a narrow screen; a page wanting a
+17
View File
@@ -564,3 +564,20 @@ Read most (opens and searches):
- `src/auth/routes.ts` 1 - `src/auth/routes.ts` 1
- `src/http/builtin-route.test.ts` 1 - `src/http/builtin-route.test.ts` 1
- `src/http/builtin-route.ts` 1 - `src/http/builtin-route.ts` 1
## Reading cost, chunk 1241ed10b1c1, finished 2026-11-09T00:14:18Z
69646 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 7
- `src/auth/bootstrap.test.ts` 4
- `src/auth/bootstrap.ts` 4
- `src/auth/keto-client.ts` 4
- `src/config.ts` 3
- `src/auth/kratos-admin.ts` 2
- `src/config.test.ts` 2
- `src/function-limits.test.ts` 2
- `AGENTS.md` 1
- `CHANGELOG.md` 1
+1 -2
View File
@@ -6,8 +6,7 @@
// per-route handlers (keyed on ctx.params) over a shared `withClients` gate — admin-only, CSRF-guarded. // per-route handlers (keyed on ctx.params) over a shared `withClients` gate — admin-only, CSRF-guarded.
import { buildPaginationModel, can, type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api"; import { buildPaginationModel, can, type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
import { ADMIN_CLIENTS_BASE, ADMIN_EN, type AdminAction, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts"; import { ADMIN_CLIENTS_BASE, ADMIN_EN, type AdminAction, buildConfirmModel, type FieldConfig, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
import type { FieldConfig } from "./admin-users.ts";
const DEFAULT_PAGE_SIZE = 25; const DEFAULT_PAGE_SIZE = 25;
const PAGE_SIZES = [25, 50, 100]; const PAGE_SIZES = [25, 50, 100];
+3 -8
View File
@@ -8,6 +8,7 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import type { KetoClient, PermissionDecl, RelationTuple, SubjectSet, Translate } from "@plainpages/plugin-api"; import type { KetoClient, PermissionDecl, RelationTuple, SubjectSet, Translate } from "@plainpages/plugin-api";
import { pagedTuples } from "./admin-shared.ts";
const PERMISSION_NS = "Permission"; const PERMISSION_NS = "Permission";
const GRANTED = "granted"; const GRANTED = "granted";
@@ -36,14 +37,8 @@ export function grantTuple(permission: string, subject: GrantSubject): RelationT
// The permissions this subject holds *directly* — one Keto read filtered by the subject, not one per // The permissions this subject holds *directly* — one Keto read filtered by the subject, not one per
// declared name. This is the edge the picker edits; `effectivePermissions` adds what a group confers. // declared name. This is the edge the picker edits; `effectivePermissions` adds what a group confers.
export async function heldPermissions(keto: KetoClient, subject: GrantSubject): Promise<string[]> { export async function heldPermissions(keto: KetoClient, subject: GrantSubject): Promise<string[]> {
const held = new Set<string>(); const tuples = await pagedTuples(keto, { namespace: PERMISSION_NS, relation: GRANTED, ...subject });
let pageToken: string | undefined; return [...new Set(tuples.map((tuple) => tuple.object))].sort();
do {
const page = await keto.listRelations({ namespace: PERMISSION_NS, relation: GRANTED, ...subject, ...(pageToken ? { pageToken } : {}) });
for (const tuple of page.tuples) held.add(tuple.object);
pageToken = page.nextPageToken ?? undefined;
} while (pageToken);
return [...held].sort();
} }
// Every declared permission the subject effectively holds — direct grants *plus* anything reached // Every declared permission the subject effectively holds — direct grants *plus* anything reached
+10 -20
View File
@@ -6,10 +6,9 @@
// per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded, // per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded,
// each returning a RouteResult. // each returning a RouteResult.
import { buildPaginationModel, can, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type Translate, type User } from "@plainpages/plugin-api"; import { buildPaginationModel, can, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type Translate, type User } from "@plainpages/plugin-api";
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, grantTuple, groupSubject, heldPermissions, grantsNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, REMOVE_FIELD, removeUndeclaredGrant, REMOVED_NOTICE, SAVED_NOTICE, userSubject } from "./admin-grants.ts"; import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, grantTuple, groupSubject, heldPermissions, grantsNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, REMOVE_FIELD, removeUndeclaredGrant, REMOVED_NOTICE, SAVED_NOTICE, userSubject } from "./admin-grants.ts";
import { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts"; import { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, type FieldConfig, guardedForm, notFound, pagedTuples, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
import type { FieldConfig } from "./admin-users.ts";
const GROUP_NS = "Group"; const GROUP_NS = "Group";
const MEMBERS = "members"; const MEMBERS = "members";
@@ -243,18 +242,6 @@ export function buildGroupDetailModel(opts: {
// ---- request handler (imperative shell) ---- // ---- request handler (imperative shell) ----
// Drain every page of a relation-tuple query.
export async function pagedTuples(keto: KetoClient, query: RelationQuery): Promise<RelationTuple[]> {
const out: RelationTuple[] = [];
let pageToken: string | undefined;
do {
const page = await keto.listRelations({ ...query, ...(pageToken ? { pageToken } : {}) });
out.push(...page.tuples);
pageToken = page.nextPageToken ?? undefined;
} while (pageToken);
return out;
}
// Build the member-picker options (every user by email + every existing group) and the id→email map // Build the member-picker options (every user by email + every existing group) and the id→email map
// detail rows render with. One Kratos page + one Keto scan; ample for an admin tool. // detail rows render with. One Kratos page + one Keto scan; ample for an admin tool.
export async function memberCandidates(keto: KetoClient, kratosAdmin: KratosAdmin): Promise<{ emailById: Map<string, string>; options: MemberOption[] }> { export async function memberCandidates(keto: KetoClient, kratosAdmin: KratosAdmin): Promise<{ emailById: Map<string, string>; options: MemberOption[] }> {
@@ -432,20 +419,23 @@ export const groupsDeleteConfirm = withGroupName(async (deps, name) => {
}) }, view: "confirm" }; }) }, view: "confirm" };
}, "write"); }, "write");
// POST /auth-admin/groups/:name/delete — one Keto transaction removes the group's member tuples // POST /auth-admin/groups/:name/delete — one Keto transaction removes the group's member tuples,
// and the grants it holds, so a Keto failure leaves the group as it was. // the tuples naming it as a member of a parent group, and the grants it holds, so a Keto failure
// leaves the group as it was.
export const groupsDelete = withGroupName(async (deps, name) => { export const groupsDelete = withGroupName(async (deps, name) => {
const { ctx, keto, user } = deps; const { ctx, keto, user } = deps;
await guardedForm(ctx); // CSRF-verify the POST await guardedForm(ctx); // CSRF-verify the POST
const lost = await lostOnDelete(deps, name); const lost = await lostOnDelete(deps, name);
if (lost.length > 0) return refuseSelfLockout(deps, name, "delete", lost); if (lost.length > 0) return refuseSelfLockout(deps, name, "delete", lost);
// A Keto set exists only through its tuples, so leaving the grants behind would resurrect every // A Keto set exists only through its tuples, so leaving the grants or the parents' membership
// permission the moment someone re-created a group with the same name. // tuples behind would resurrect every permission and nesting the moment someone re-created a
// group with the same name.
const subject = groupSubject(name); const subject = groupSubject(name);
const held = await heldPermissions(keto, subject); const held = await heldPermissions(keto, subject);
const grants = held.map((permission) => grantTuple(permission, subject)); const grants = held.map((permission) => grantTuple(permission, subject));
const members = await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS }); const members = await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS });
await keto.transactTuples({ delete: [...grants, ...members], insert: [] }); const inParents = await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS, ...subject });
await keto.transactTuples({ delete: [...grants, ...members, ...inParents], insert: [] });
ctx.log.info("admin: group deleted", { actor: user.id, group: name, revoked: held.join(",") }); ctx.log.info("admin: group deleted", { actor: user.id, group: name, revoked: held.join(",") });
return { redirect: ADMIN_GROUPS_BASE }; return { redirect: ADMIN_GROUPS_BASE };
}); });
+30 -2
View File
@@ -1,8 +1,9 @@
// Shared plumbing for the admin example plugin: the section nav fragment, the screen gate, the // Shared plumbing for the admin example plugin: the section nav fragment, the screen gate, the
// CSRF-guarded form reader, the destructive-confirm model builder, and small RouteResult helpers. // CSRF-guarded form reader, the destructive-confirm model builder, the form-field type, the Keto
// paging loop, and small RouteResult helpers.
// Everything imports the host only through the @plainpages/plugin-api barrel. // Everything imports the host only through the @plainpages/plugin-api barrel.
import { can, CSRF_FIELD, englishTranslator, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type Translate, type User } from "@plainpages/plugin-api"; import { can, CSRF_FIELD, englishTranslator, GuardError, type KetoClient, type NavNode, readFormBody, type RelationQuery, type RelationTuple, type RequestContext, requireSession, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
import enUS from "./i18n/en-US.ts"; import enUS from "./i18n/en-US.ts";
// This plugin's English — its catalog, then the host's — for a view model built outside a request, // This plugin's English — its catalog, then the host's — for a view model built outside a request,
@@ -103,3 +104,30 @@ export function buildConfirmModel(opts: {
title: opts.title, title: opts.title,
}; };
} }
// The config one text input takes in the core `field` partial.
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;
}
// Drain every page of a relation-tuple query.
export async function pagedTuples(keto: KetoClient, query: RelationQuery): Promise<RelationTuple[]> {
const out: RelationTuple[] = [];
let pageToken: string | undefined;
do {
const page = await keto.listRelations({ ...query, ...(pageToken ? { pageToken } : {}) });
out.push(...page.tuples);
pageToken = page.nextPageToken ?? undefined;
} while (pageToken);
return out;
}
+1 -15
View File
@@ -5,7 +5,7 @@
import { buildPaginationModel, can, type Identity, type KetoClient, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api"; import { buildPaginationModel, can, type Identity, type KetoClient, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, grantsNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, REMOVE_FIELD, removeUndeclaredGrant, REMOVED_NOTICE, SAVED_NOTICE, userSubject } from "./admin-grants.ts"; import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, grantsNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, REMOVE_FIELD, removeUndeclaredGrant, REMOVED_NOTICE, SAVED_NOTICE, userSubject } from "./admin-grants.ts";
import { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts"; import { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, type FieldConfig, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id
const DEFAULT_PAGE_SIZE = 25; const DEFAULT_PAGE_SIZE = 25;
@@ -185,20 +185,6 @@ function listFilterBar(state: ListState, total: number, t: Translate) {
}; };
} }
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: { export function buildUserFormModel(opts: {
canWrite?: boolean; // false ⇒ a `users:read` holder: show the state, render no write affordance canWrite?: boolean; // false ⇒ a `users:read` holder: show the state, render no write affordance
csrfToken?: string; csrfToken?: string;
+1 -7
View File
@@ -3,6 +3,7 @@
// *through* us with. Built-in `fetch` only, no SDK dep (AGENTS.md); `fetchImpl`-injectable // *through* us with. Built-in `fetch` only, no SDK dep (AGENTS.md); `fetchImpl`-injectable
// like the kratos/keto clients. We authenticate the user (login) and grant scopes (consent); // like the kratos/keto clients. We authenticate the user (login) and grant scopes (consent);
// Hydra mints the tokens. // Hydra mints the tokens.
import { nextPageToken } from "./link-header.ts";
export interface OAuth2Client { export interface OAuth2Client {
client_id?: string; client_id?: string;
@@ -101,13 +102,6 @@ export interface HydraAdmin {
rejectLoginRequest(challenge: string, body: RejectRequest): Promise<Completed>; rejectLoginRequest(challenge: string, body: RejectRequest): Promise<Completed>;
} }
// Hydra paginates with a Link header; pull the page_token of rel="next" (the href is relative, so
// resolve against a throwaway base to read the query param). Mirrors kratos-admin's helper.
function nextPageToken(link: string | null): string | null {
const href = link?.match(/<([^>]+)>\s*;\s*rel="next"/)?.[1];
return href ? new URL(href, "http://hydra").searchParams.get("page_token") : null;
}
export function createHydraAdmin(config: { baseUrl: string; fetchImpl?: typeof fetch }): HydraAdmin { export function createHydraAdmin(config: { baseUrl: string; fetchImpl?: typeof fetch }): HydraAdmin {
const base = config.baseUrl.replace(/\/+$/, ""); const base = config.baseUrl.replace(/\/+$/, "");
const http = config.fetchImpl ?? fetch; const http = config.fetchImpl ?? fetch;
+1 -7
View File
@@ -3,6 +3,7 @@
// completion projects Keto permissions into (README). Built-in `fetch` only, no SDK dep (AGENTS.md); // completion projects Keto permissions into (README). Built-in `fetch` only, no SDK dep (AGENTS.md);
// `fetchImpl`-injectable, reuses kratos-public.ts's `KratosError` (branch on `.status`). // `fetchImpl`-injectable, reuses kratos-public.ts's `KratosError` (branch on `.status`).
import { KratosError } from "./kratos-public.ts"; import { KratosError } from "./kratos-public.ts";
import { nextPageToken } from "./link-header.ts";
export interface Identity { export interface Identity {
id: string; id: string;
@@ -41,13 +42,6 @@ export interface KratosAdmin {
updateMetadataPublic(id: string, metadata: unknown): Promise<Identity>; updateMetadataPublic(id: string, metadata: unknown): Promise<Identity>;
} }
// Kratos paginates with a Link header; pull the page_token of rel="next" (the href is a
// relative path, so resolve it against a throwaway base just to read the query param).
function nextPageToken(link: string | null): string | null {
const href = link?.match(/<([^>]+)>\s*;\s*rel="next"/)?.[1];
return href ? new URL(href, "http://kratos").searchParams.get("page_token") : null;
}
export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof fetch }): KratosAdmin { export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof fetch }): KratosAdmin {
const base = config.baseUrl.replace(/\/+$/, ""); const base = config.baseUrl.replace(/\/+$/, "");
const http = config.fetchImpl ?? fetch; const http = config.fetchImpl ?? fetch;
+14
View File
@@ -0,0 +1,14 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { nextPageToken } from "./link-header.ts";
test("nextPageToken reads the page_token of the rel=next entry among several", () => {
const link = `</admin/identities?page_size=2&page_token=NEXT>; rel="next",</admin/identities?page_size=2>; rel="first"`;
assert.equal(nextPageToken(link), "NEXT");
});
test("nextPageToken is null with no header, no next entry, or a next href without a page_token", () => {
assert.equal(nextPageToken(null), null);
assert.equal(nextPageToken(`</admin/clients?page_size=2>; rel="first"`), null);
assert.equal(nextPageToken(`</admin/clients?page_size=2>; rel="next"`), null);
});
+7
View File
@@ -0,0 +1,7 @@
// Kratos and Hydra both paginate with an RFC 8288 `Link` header. Returns the `page_token` of its
// `rel="next"` entry, or null on the last page. The href is a relative path, so it is resolved
// against a throwaway base just to read the query parameter.
export function nextPageToken(link: string | null): string | null {
const href = link?.match(/<([^>]+)>\s*;\s*rel="next"/)?.[1];
return href ? new URL(href, "http://link-header.invalid").searchParams.get("page_token") : null;
}
+19
View File
@@ -427,6 +427,25 @@ test("admin group delete: a Keto failure leaves the group's grants and members i
assert.equal(tuples.length, 2); assert.equal(tuples.length, 2);
}); });
test("admin group delete: removes the group from every parent, so re-creating the name restores no nesting", async (t) => {
const ada = randomUUID();
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
const member = (object: string, subject: Partial<RelationTuple>): RelationTuple => ({ namespace: "Group", object, relation: "members", ...subject } as RelationTuple);
const inEng = { subject_set: { namespace: "Group", object: "eng", relation: "members" } };
const tuples: RelationTuple[] = [
member("eng", { subject_id: `user:${ada}` }),
member("org", { subject_id: `user:${ada}` }),
member("org", inEng),
member("staff", inEng),
member("staff", { subject_id: "user:other" }),
];
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const { post, token } = await adminHarness(t, { keto: fakeKeto(tuples), kratosAdmin });
assert.equal((await post("/auth-admin/groups/eng/delete", `_csrf=${token}`)).status, 303);
assert.deepEqual(tuples, [member("org", { subject_id: `user:${ada}` }), member("staff", { subject_id: "user:other" })]);
});
// A double-clicked Save posts twice from one page; the second post finds the first one's result. // A double-clicked Save posts twice from one page; the second post finds the first one's result.
// A save that changes nothing has nothing to refuse and nothing to revoke. // A save that changes nothing has nothing to refuse and nothing to revoke.
test("admin permission grants: a save that changes nothing is not refused for a stale page and leaves live tokens alone", async (t) => { test("admin permission grants: a save that changes nothing is not refused for a stale page and leaves live tokens alone", async (t) => {
+2 -2
View File
@@ -4,7 +4,7 @@ import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import ejs from "ejs"; import ejs from "ejs";
import { type BuiltinRoute, matchBuiltinRoute, type PluginContextFactory, type PluginOwned, type RequestCsrf } from "./builtin-route.ts"; import { type BuiltinRoute, matchBuiltinRoute, type PluginContextFactory, type PluginOwned, type RequestCsrf } from "./builtin-route.ts";
import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts"; import { buildPageChrome, type PageChrome } from "../ui/chrome.ts";
import { buildContext, type RequestContext, type User } from "./context.ts"; import { buildContext, type RequestContext, type User } from "./context.ts";
import { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts"; import { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts";
import type { Denylist } from "../auth/denylist.ts"; import type { Denylist } from "../auth/denylist.ts";
@@ -274,7 +274,7 @@ async function prepareRequest(host: Host, req: IncomingMessage, res: ServerRespo
// Chrome composes the whole menu, so it is memoized and resolved lazily — a json/redirect // Chrome composes the whole menu, so it is memoized and resolved lazily — a json/redirect
// handler, or the public "/" with a standalone home, never pays for it. // handler, or the public "/" with a standalone home, never pays for it.
let chromeMemo: PageChrome | undefined; let chromeMemo: PageChrome | undefined;
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ const chrome = (): PageChrome => (chromeMemo ??= buildPageChrome({
csrfToken: csrf.token, currentPath: url.pathname, localeHref: carryLocale, menu: host.menu, plugins: host.plugins, t, csrfToken: csrf.token, currentPath: url.pathname, localeHref: carryLocale, menu: host.menu, plugins: host.plugins, t,
translatorFor: (id) => i18n.translator(locale, id), user, translatorFor: (id) => i18n.translator(locale, id), user,
})); }));
+11 -11
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { buildPluginChrome, shellUser } from "./chrome.ts"; import { buildPageChrome, shellUser } from "./chrome.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts"; import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { NavNode } from "./nav.ts"; import type { NavNode } from "./nav.ts";
import type { Plugin } from "../plugin-host/plugin.ts"; import type { Plugin } from "../plugin-host/plugin.ts";
@@ -31,7 +31,7 @@ const adminLike: Plugin = {
const labels = (nodes: NavNode[]): string[] => nodes.map((n) => n.label); const labels = (nodes: NavNode[]): string[] => nodes.map((n) => n.label);
test("anonymous: brand from menu, Guest user; the gated Dashboard link is hidden, a public node still shows", () => { test("anonymous: brand from menu, Guest user; the gated Dashboard link is hidden, a public node still shows", () => {
const chrome = buildPluginChrome({ menu: DEFAULT_MENU, plugins: [scheduling, portal] }); const chrome = buildPageChrome({ menu: DEFAULT_MENU, plugins: [scheduling, portal] });
assert.equal(chrome.brand.name, DEFAULT_MENU.branding.name); assert.equal(chrome.brand.name, DEFAULT_MENU.branding.name);
assert.equal(chrome.user.name, "Guest"); assert.equal(chrome.user.name, "Guest");
// Dashboard points at the gated /dashboard — showing it to an anonymous visitor only dead-ends them // Dashboard points at the gated /dashboard — showing it to an anonymous visitor only dead-ends them
@@ -41,12 +41,12 @@ test("anonymous: brand from menu, Guest user; the gated Dashboard link is hidden
}); });
test("anonymous shell Sign-in link carries the current page as return_to", () => { test("anonymous shell Sign-in link carries the current page as return_to", () => {
assert.equal(buildPluginChrome({ menu: DEFAULT_MENU }).signInHref, "/login"); // no path known assert.equal(buildPageChrome({ menu: DEFAULT_MENU }).signInHref, "/login"); // no path known
assert.equal(buildPluginChrome({ currentPath: "/portal", menu: DEFAULT_MENU }).signInHref, "/login?return_to=%2Fportal"); assert.equal(buildPageChrome({ currentPath: "/portal", menu: DEFAULT_MENU }).signInHref, "/login?return_to=%2Fportal");
}); });
test("a permission holder sees the Dashboard link + plugin nav; current path opens the active leaf", () => { test("a permission holder sees the Dashboard link + plugin nav; current path opens the active leaf", () => {
const chrome = buildPluginChrome({ const chrome = buildPageChrome({
currentPath: "/scheduling/shifts", menu: DEFAULT_MENU, plugins: [scheduling], currentPath: "/scheduling/shifts", menu: DEFAULT_MENU, plugins: [scheduling],
user: { email: "ada@x.io", id: "u1", permissions: ["scheduling:read"] }, user: { email: "ada@x.io", id: "u1", permissions: ["scheduling:read"] },
}); });
@@ -58,7 +58,7 @@ test("a permission holder sees the Dashboard link + plugin nav; current path ope
}); });
test("a gated section (like the admin plugin) shows to a holder; a sub-path marks its base leaf current", () => { test("a gated section (like the admin plugin) shows to a holder; a sub-path marks its base leaf current", () => {
const chrome = buildPluginChrome({ currentPath: "/admin/users/new", menu: DEFAULT_MENU, plugins: [adminLike], user: { email: "a@b.c", id: "u1", permissions: ["users:read"] } }); const chrome = buildPageChrome({ currentPath: "/admin/users/new", menu: DEFAULT_MENU, plugins: [adminLike], user: { email: "a@b.c", id: "u1", permissions: ["users:read"] } });
const admin = chrome.nav.find((n) => n.label === "Admin")!; const admin = chrome.nav.find((n) => n.label === "Admin")!;
assert.ok(admin); // gated section visible to an admin assert.ok(admin); // gated section visible to an admin
assert.equal(admin.open, true); // ancestor of the current leaf opened assert.equal(admin.open, true); // ancestor of the current leaf opened
@@ -69,7 +69,7 @@ test("a gated section (like the admin plugin) shows to a holder; a sub-path mark
test("branding logo + default theme flow through when set", () => { test("branding logo + default theme flow through when set", () => {
const menu: MenuConfig = { branding: { logo: "/logo.svg", name: "Acme", theme: "dark" }, override: {} }; const menu: MenuConfig = { branding: { logo: "/logo.svg", name: "Acme", theme: "dark" }, override: {} };
const chrome = buildPluginChrome({ menu }); const chrome = buildPageChrome({ menu });
assert.equal(chrome.brand.logo, "/logo.svg"); assert.equal(chrome.brand.logo, "/logo.svg");
assert.equal(chrome.brand.name, "Acme"); assert.equal(chrome.brand.name, "Acme");
assert.equal(chrome.theme, "dark"); assert.equal(chrome.theme, "dark");
@@ -77,10 +77,10 @@ test("branding logo + default theme flow through when set", () => {
test("branding name and sub are translated; an unknown key renders as itself", () => { test("branding name and sub are translated; an unknown key renders as itself", () => {
const t = (key: string): string => (key === "brand.sub" ? "Undertitel" : key); const t = (key: string): string => (key === "brand.sub" ? "Undertitel" : key);
const chrome = buildPluginChrome({ menu: { branding: { name: "Acme", sub: "brand.sub" }, override: {} }, t }); const chrome = buildPageChrome({ menu: { branding: { name: "Acme", sub: "brand.sub" }, override: {} }, t });
assert.equal(chrome.brand.name, "Acme"); assert.equal(chrome.brand.name, "Acme");
assert.equal(chrome.brand.sub, "Undertitel"); assert.equal(chrome.brand.sub, "Undertitel");
assert.equal(buildPluginChrome({ menu: { branding: { name: "Acme" }, override: {} } }).brand.sub, undefined); assert.equal(buildPageChrome({ menu: { branding: { name: "Acme" }, override: {} } }).brand.sub, undefined);
}); });
test("shellUser derives the profile from the real user; anonymous → Guest", () => { test("shellUser derives the profile from the real user; anonymous → Guest", () => {
@@ -97,7 +97,7 @@ const account: Plugin = {
], ],
}; };
const entries = (opts: { permissions?: string[]; signedIn?: boolean }): { href: string; icon?: string; label: string }[] => const entries = (opts: { permissions?: string[]; signedIn?: boolean }): { href: string; icon?: string; label: string }[] =>
buildPluginChrome({ buildPageChrome({
localeHref: (href) => `${href}?locale=sv-SE`, localeHref: (href) => `${href}?locale=sv-SE`,
menu: DEFAULT_MENU, menu: DEFAULT_MENU,
plugins: [account], plugins: [account],
@@ -115,7 +115,7 @@ test("profile menu entries are gated like nav nodes, translated by their plugin
}); });
test("the central override hides and renames a profile-menu entry by its id; the host translates the new label", () => { test("the central override hides and renames a profile-menu entry by its id; the host translates the new label", () => {
const links = buildPluginChrome({ const links = buildPageChrome({
menu: { ...DEFAULT_MENU, override: { hide: ["help"], rename: { settings: "Preferences" } } }, menu: { ...DEFAULT_MENU, override: { hide: ["help"], rename: { settings: "Preferences" } } },
plugins: [{ plugins: [{
apiVersion: "1.0.0", id: "account", apiVersion: "1.0.0", id: "account",
+23 -21
View File
@@ -52,9 +52,26 @@ export interface ChromeOptions {
user?: User | null; user?: User | null;
} }
export function buildPluginChrome(opts: ChromeOptions): PageChrome { // The chrome of every page the host renders, its own screens and a plugin's alike.
export function buildPageChrome(opts: ChromeOptions): PageChrome {
const t = opts.t ?? ENGLISH; const t = opts.t ?? ENGLISH;
const carryLocale = opts.localeHref ?? ((href: string) => href); const carryLocale = opts.localeHref ?? ((href: string) => href);
// The sign-in link keeps the visitor's locale, and brings it back afterwards via return_to.
const returnTo = opts.currentPath ? `/login?return_to=${encodeURIComponent(carryLocale(opts.currentPath))}` : "/login";
const { theme, ...brand } = branding(opts.menu, t);
return {
brand,
csrfToken: opts.csrfToken ?? "",
nav: carryLocaleInto(currentMarkedNav(opts, t), carryLocale),
signInHref: carryLocale(returnTo),
...(theme != null ? { theme } : {}),
user: { ...shellUser(opts.user, t), profileLinks: profileLinks(opts, t, carryLocale) },
};
}
// Dashboard plus every plugin's fragment, composed and filtered for the user, with the current leaf
// marked. Marked before the locale rides along, so an href still matches the plain request path.
function currentMarkedNav(opts: ChromeOptions, t: Translate): NavNode[] {
const fragments: NavNode[][] = [[DASHBOARD_NAV]]; const fragments: NavNode[][] = [[DASHBOARD_NAV]];
// A plugin's nav labels are keys in *its* catalog, so translate each fragment with that plugin's // A plugin's nav labels are keys in *its* catalog, so translate each fragment with that plugin's
// translator before merging. composeNav then runs the core one over the result; already-translated // translator before merging. composeNav then runs the core one over the result; already-translated
@@ -62,27 +79,12 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
for (const p of opts.plugins ?? []) { for (const p of opts.plugins ?? []) {
if (p.nav?.length) fragments.push(translateNav(p.nav, opts.translatorFor?.(p.id) ?? t)); if (p.nav?.length) fragments.push(translateNav(p.nav, opts.translatorFor?.(p.id) ?? t));
} }
const nav = composeNav(fragments, opts.menu.override, opts.user ?? null, t); const nav = composeNav(fragments, opts.menu.override, opts.user ?? null, t);
if (opts.currentPath) { // The *best* (longest) href that is the path or a parent of it, so a sub-path like
// Mark by the *best* (longest) href that is the path or a parent of it, so a sub-path like // /auth-admin/users/new marks the Users base leaf (/auth-admin/users) and the dashboard marks Dashboard.
// /auth-admin/users/new marks the Users base leaf (/auth-admin/users) and the dashboard marks Dashboard. const target = opts.currentPath ? bestHref(nav, opts.currentPath) : null;
// Marked before the locale rides along, so an href still matches the plain request path. if (target) markCurrent(nav, target);
const target = bestHref(nav, opts.currentPath); return nav;
if (target) markCurrent(nav, target);
}
// The sign-in link keeps the visitor's locale, and brings it back afterwards via return_to.
const returnTo = opts.currentPath ? `/login?return_to=${encodeURIComponent(carryLocale(opts.currentPath))}` : "/login";
const { theme, ...brand } = branding(opts.menu, t);
return {
brand,
csrfToken: opts.csrfToken ?? "",
nav: carryLocaleInto(nav, carryLocale),
signInHref: carryLocale(returnTo),
...(theme != null ? { theme } : {}),
user: { ...shellUser(opts.user, t), profileLinks: profileLinks(opts, t, carryLocale) },
};
} }
function profileLinks(opts: ChromeOptions, t: Translate, carryLocale: (href: string) => string): ProfileLink[] { function profileLinks(opts: ChromeOptions, t: Translate, carryLocale: (href: string) => string): ProfileLink[] {
-23
View File
@@ -12,8 +12,6 @@
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score | | ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|---|---|---|---|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|---|---|---|---|
| 70 | 0.5.0 | | | **Give the duplicated paging loop, Link-header parser and `FieldConfig` type one home each, and rename `buildPluginChrome` to `buildPageChrome`.** | 2 | 1 | 1 | 2 | 3 | code health by 2 | 9.0 |
| 71 | 0.5.0 | | | **Delete, with a group, every membership tuple in another group that names it, so re-creating the name restores no nested membership.** | 1 | 2 | 2 | 3 | 2 | 2 by 3 | 4.2 |
| 19 | 0.6.0 | | question | **Run the documented Docker commands on macOS and fix whatever misbehaves.** | 2 | 2 | 1 | 3 | 3 | 1 by 3 | 3.3 | | 19 | 0.6.0 | | question | **Run the documented Docker commands on macOS and fix whatever misbehaves.** | 2 | 2 | 1 | 3 | 3 | 1 by 3 | 3.3 |
| 16 | 0.6.0 | | question | **Decide whether the single Keto `Resource` namespace splits into per-domain namespaces.** | 3 | 2 | 3 | 6 | 6 | 7 by 2 | 0.5 | | 16 | 0.6.0 | | question | **Decide whether the single Keto `Resource` namespace splits into per-domain namespaces.** | 3 | 2 | 3 | 6 | 6 | 7 by 2 | 0.5 |
| 65 | 0.6.0 | | | **Export `KetoError` from `@plainpages/plugin-api`, so a plugin can tell a Keto failure apart with `instanceof`.** | 1 | 2 | 1 | 2 | 1 | 7 by 3, 1 by 1 | 6.7 | | 65 | 0.6.0 | | | **Export `KetoError` from `@plainpages/plugin-api`, so a plugin can tell a Keto failure apart with `instanceof`.** | 1 | 2 | 1 | 2 | 1 | 7 by 3, 1 by 1 | 6.7 |
@@ -27,27 +25,6 @@
## Details ## Details
### 70. Give the duplicated paging loop, Link-header parser and `FieldConfig` type one home each, and rename `buildPluginChrome` to `buildPageChrome`.
- `pagedTuples` (`examples/plugins/auth-admin/admin-groups.ts` ~L246) and `heldPermissions`
(`admin-grants.ts` ~L38) each hand-write the Keto page-token loop; let `heldPermissions` use the
one loop.
- `nextPageToken` is copied in `src/auth/kratos-admin.ts` ~L46 and `src/auth/hydra-admin.ts` ~L106.
- `FieldConfig` lives in `admin-users.ts`, and `admin-groups.ts` and `admin-clients.ts` import it
from there; move it to `admin-shared.ts`.
- `buildPluginChrome` (`src/ui/chrome.ts:55`) builds the chrome for every page, core pages
included; rename it, its test and the README ~L1027 mention. Make `buildPluginChrome` easier to
read, a place the seats found hard.
### 71. Delete, with a group, every membership tuple in another group that names it, so re-creating the name restores no nested membership.
Groups nest: a parent holds `Group:<parent>#members` with `subject_set` `Group:<child>#members`.
`groupsDelete` in `examples/plugins/auth-admin/admin-groups.ts` drops the child's own member tuples
and held grants, but not those parent tuples, so a group re-created with the deleted name is back
in every parent, the orphan class its comment already guards against for grants. List them with
`pagedTuples` filtered on `subject_set` (`namespace: GROUP_NS`, `object: name`, `relation: MEMBERS`)
and add them to the same `transactTuples` delete; extend the comment to say so.
### 19. Run the documented Docker commands on macOS and fix whatever misbehaves. ### 19. Run the documented Docker commands on macOS and fix whatever misbehaves.
Needs a Mac. Two suspects, both from `--user "$(id -u):$(id -g)"`: a macOS `id -g` is `20`, which is Needs a Mac. Two suspects, both from `--user "$(id -u):$(id -g)"`: a macOS `id -g` is `20`, which is