Give the paging loop, Link-header parser and FieldConfig one home each, and delete a group from its parents #235
@@ -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,
|
||||
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 refusal page used to drop the permissions section of the user form.
|
||||
|
||||
@@ -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.
|
||||
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 /
|
||||
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
|
||||
|
||||
@@ -564,3 +564,20 @@ Read most (opens and searches):
|
||||
- `src/auth/routes.ts` 1
|
||||
- `src/http/builtin-route.test.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
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
// 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 { ADMIN_CLIENTS_BASE, ADMIN_EN, type AdminAction, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
||||
import type { FieldConfig } from "./admin-users.ts";
|
||||
import { ADMIN_CLIENTS_BASE, ADMIN_EN, type AdminAction, buildConfirmModel, type FieldConfig, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
const PAGE_SIZES = [25, 50, 100];
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import type { KetoClient, PermissionDecl, RelationTuple, SubjectSet, Translate } from "@plainpages/plugin-api";
|
||||
import { pagedTuples } from "./admin-shared.ts";
|
||||
|
||||
const PERMISSION_NS = "Permission";
|
||||
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
|
||||
// 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[]> {
|
||||
const held = new Set<string>();
|
||||
let pageToken: string | undefined;
|
||||
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();
|
||||
const tuples = await pagedTuples(keto, { namespace: PERMISSION_NS, relation: GRANTED, ...subject });
|
||||
return [...new Set(tuples.map((tuple) => tuple.object))].sort();
|
||||
}
|
||||
|
||||
// Every declared permission the subject effectively holds — direct grants *plus* anything reached
|
||||
|
||||
@@ -6,10 +6,9 @@
|
||||
// per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded,
|
||||
// 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 { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
||||
import type { FieldConfig } from "./admin-users.ts";
|
||||
import { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, type FieldConfig, guardedForm, notFound, pagedTuples, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
||||
|
||||
const GROUP_NS = "Group";
|
||||
const MEMBERS = "members";
|
||||
@@ -243,18 +242,6 @@ export function buildGroupDetailModel(opts: {
|
||||
|
||||
// ---- 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
|
||||
// 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[] }> {
|
||||
@@ -432,20 +419,23 @@ export const groupsDeleteConfirm = withGroupName(async (deps, name) => {
|
||||
}) }, view: "confirm" };
|
||||
}, "write");
|
||||
|
||||
// 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.
|
||||
// POST /auth-admin/groups/:name/delete — one Keto transaction removes the group's member tuples,
|
||||
// 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) => {
|
||||
const { ctx, keto, user } = deps;
|
||||
await guardedForm(ctx); // CSRF-verify the POST
|
||||
const lost = await lostOnDelete(deps, name);
|
||||
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
|
||||
// permission the moment someone re-created a group with the same name.
|
||||
// A Keto set exists only through its tuples, so leaving the grants or the parents' membership
|
||||
// tuples behind would resurrect every permission and nesting the moment someone re-created a
|
||||
// group with the same name.
|
||||
const subject = groupSubject(name);
|
||||
const held = await heldPermissions(keto, subject);
|
||||
const grants = held.map((permission) => grantTuple(permission, subject));
|
||||
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(",") });
|
||||
return { redirect: ADMIN_GROUPS_BASE };
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// 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.
|
||||
|
||||
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";
|
||||
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -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 { 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 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: {
|
||||
canWrite?: boolean; // false ⇒ a `users:read` holder: show the state, render no write affordance
|
||||
csrfToken?: string;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// *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);
|
||||
// Hydra mints the tokens.
|
||||
import { nextPageToken } from "./link-header.ts";
|
||||
|
||||
export interface OAuth2Client {
|
||||
client_id?: string;
|
||||
@@ -101,13 +102,6 @@ export interface HydraAdmin {
|
||||
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 {
|
||||
const base = config.baseUrl.replace(/\/+$/, "");
|
||||
const http = config.fetchImpl ?? fetch;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// 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`).
|
||||
import { KratosError } from "./kratos-public.ts";
|
||||
import { nextPageToken } from "./link-header.ts";
|
||||
|
||||
export interface Identity {
|
||||
id: string;
|
||||
@@ -41,13 +42,6 @@ export interface KratosAdmin {
|
||||
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 {
|
||||
const base = config.baseUrl.replace(/\/+$/, "");
|
||||
const http = config.fetchImpl ?? fetch;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -427,6 +427,25 @@ test("admin group delete: a Keto failure leaves the group's grants and members i
|
||||
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 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) => {
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ejs from "ejs";
|
||||
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 { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.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
|
||||
// handler, or the public "/" with a standalone home, never pays for it.
|
||||
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,
|
||||
translatorFor: (id) => i18n.translator(locale, id), user,
|
||||
}));
|
||||
|
||||
+11
-11
@@ -1,6 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
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 type { NavNode } from "./nav.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);
|
||||
|
||||
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.user.name, "Guest");
|
||||
// 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", () => {
|
||||
assert.equal(buildPluginChrome({ menu: DEFAULT_MENU }).signInHref, "/login"); // no path known
|
||||
assert.equal(buildPluginChrome({ currentPath: "/portal", menu: DEFAULT_MENU }).signInHref, "/login?return_to=%2Fportal");
|
||||
assert.equal(buildPageChrome({ menu: DEFAULT_MENU }).signInHref, "/login"); // no path known
|
||||
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", () => {
|
||||
const chrome = buildPluginChrome({
|
||||
const chrome = buildPageChrome({
|
||||
currentPath: "/scheduling/shifts", menu: DEFAULT_MENU, plugins: [scheduling],
|
||||
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", () => {
|
||||
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")!;
|
||||
assert.ok(admin); // gated section visible to an admin
|
||||
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", () => {
|
||||
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.name, "Acme");
|
||||
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", () => {
|
||||
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.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", () => {
|
||||
@@ -97,7 +97,7 @@ const account: Plugin = {
|
||||
],
|
||||
};
|
||||
const entries = (opts: { permissions?: string[]; signedIn?: boolean }): { href: string; icon?: string; label: string }[] =>
|
||||
buildPluginChrome({
|
||||
buildPageChrome({
|
||||
localeHref: (href) => `${href}?locale=sv-SE`,
|
||||
menu: DEFAULT_MENU,
|
||||
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", () => {
|
||||
const links = buildPluginChrome({
|
||||
const links = buildPageChrome({
|
||||
menu: { ...DEFAULT_MENU, override: { hide: ["help"], rename: { settings: "Preferences" } } },
|
||||
plugins: [{
|
||||
apiVersion: "1.0.0", id: "account",
|
||||
|
||||
+21
-19
@@ -52,9 +52,26 @@ export interface ChromeOptions {
|
||||
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 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]];
|
||||
// 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
|
||||
@@ -62,27 +79,12 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
||||
for (const p of opts.plugins ?? []) {
|
||||
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);
|
||||
if (opts.currentPath) {
|
||||
// Mark by the *best* (longest) href that is the path or a parent of it, so a sub-path like
|
||||
// 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.
|
||||
// Marked before the locale rides along, so an href still matches the plain request path.
|
||||
const target = bestHref(nav, opts.currentPath);
|
||||
const target = opts.currentPath ? bestHref(nav, opts.currentPath) : null;
|
||||
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) },
|
||||
};
|
||||
return nav;
|
||||
}
|
||||
|
||||
function profileLinks(opts: ChromeOptions, t: Translate, carryLocale: (href: string) => string): ProfileLink[] {
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -27,27 +25,6 @@
|
||||
|
||||
## 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.
|
||||
|
||||
Needs a Mac. Two suspects, both from `--user "$(id -u):$(id -g)"`: a macOS `id -g` is `20`, which is
|
||||
|
||||
Reference in New Issue
Block a user