Let a plugin add links to the profile menu, and list the setting conflict kind
CI / full-gate (push) Failing after 1m17s
CI / full-gate (push) Failing after 1m17s
This commit is contained in:
@@ -20,7 +20,6 @@ const grandfathered: Record<string, { lines?: number; complexity?: number }> = {
|
||||
"src/auth/kratos-admin.ts createKratosAdmin": { lines: 68 },
|
||||
"src/auth/kratos-public.ts createKratosPublic": { lines: 60 },
|
||||
"src/plugin-host/discovery.ts discoverPlugins": { lines: 74, complexity: 22 },
|
||||
"src/plugin-host/discovery.ts shapeError": { complexity: 23 },
|
||||
"src/config.ts loadConfig": { lines: 52 },
|
||||
"release-tooling/dockerhub-overview.ts main": { lines: 57 },
|
||||
"examples/plugins/admin/admin-users.ts buildUserFormModel": { complexity: 21 },
|
||||
|
||||
@@ -74,6 +74,10 @@ const badCases: Array<{ name: string; files: Record<string, string>; match: RegE
|
||||
// A permission name is <resource>:<action> wherever the manifest mentions one. Enforced here, not
|
||||
// only in the admin GUI, so it holds for a plugin installed without that GUI.
|
||||
{ name: "a route gating on a bare word", files: { "bare/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", permission: "admin", handler: () => ({ html: "x" }) }] };` }, match: /bare.*admin.*<resource>:<action>/s },
|
||||
{ name: "a profile-menu entry naming no gate", files: { "nogateprofile/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: [{ href: "/x", label: "X" }] };` }, match: /nogateprofile.*profile-menu entry "X".*names no gate/s },
|
||||
{ name: "a profile-menu entry gating on a bare word", files: { "bareprofile/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: [{ href: "/x", label: "X", permission: "admin" }] };` }, match: /bareprofile.*admin.*<resource>:<action>/s },
|
||||
{ name: "a profile-menu entry without an href", files: { "nohrefprofile/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: [{ label: "X", session: true }] };` }, match: /nohrefprofile.*profile-menu entry "X".*href/s },
|
||||
{ name: "profileMenu that is not an array", files: { "objprofile/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: {} };` }, match: /objprofile.*"profileMenu" must be an array/s },
|
||||
{ name: "a nav node gating on a bare word", files: { "barenav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", permission: "admin" }] };` }, match: /barenav.*admin.*<resource>:<action>/s },
|
||||
{ name: "a declared permission that is a bare word", files: { "baredecl/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", permissions: [{ name: "admin" }] };` }, match: /baredecl.*admin.*<resource>:<action>/s },
|
||||
{ name: "a plugin shipping its own copy of the barrel", files: { "shadow/node_modules/@plainpages/plugin-api/index.js": `export class GuardError extends Error {}`, "shadow/plugin.ts": full("shadow") }, match: /shadow.*@plainpages\/plugin-api/s },
|
||||
|
||||
@@ -133,7 +133,7 @@ function asManifest(value: unknown): PluginManifest | null {
|
||||
|
||||
// The collection fields feed findConflicts, which iterates them — a non-array crashes it opaquely.
|
||||
function shapeError(manifest: PluginManifest): string | null {
|
||||
for (const field of ["nav", "permissions", "routes"] as const) {
|
||||
for (const field of ["nav", "permissions", "profileMenu", "routes"] as const) {
|
||||
if (manifest[field] !== undefined && !Array.isArray(manifest[field])) return `"${field}" must be an array`;
|
||||
}
|
||||
// `home` / `dashboard` (the landing-page overrides) are route handlers; the host calls them, so
|
||||
@@ -151,9 +151,11 @@ function shapeError(manifest: PluginManifest): string | null {
|
||||
const gate = gateError(`route "${route?.method} ${route?.path}"`, route);
|
||||
if (gate) return gate;
|
||||
}
|
||||
const navGate = findNavGateError(manifest.nav);
|
||||
if (navGate) return navGate;
|
||||
for (const decl of Array.isArray(manifest.permissions) ? manifest.permissions : []) {
|
||||
return findNavGateError(manifest.nav) ?? findProfileMenuError(manifest.profileMenu) ?? findPermissionNameError(manifest.permissions);
|
||||
}
|
||||
|
||||
function findPermissionNameError(decls: PluginManifest["permissions"]): string | null {
|
||||
for (const decl of Array.isArray(decls) ? decls : []) {
|
||||
if (decl?.name == null || !isValidPermissionName(decl.name)) {
|
||||
return `declared permission "${decl?.name}" is not <resource>:<action>, e.g. "things:read"`;
|
||||
}
|
||||
@@ -189,6 +191,16 @@ function findNavGateError(nodes: PluginManifest["nav"]): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function findProfileMenuError(entries: PluginManifest["profileMenu"]): string | null {
|
||||
for (const entry of Array.isArray(entries) ? entries : []) {
|
||||
const what = `profile-menu entry "${entry?.label ?? "?"}"`;
|
||||
if (typeof entry?.href !== "string" || entry.href === "") return `${what} needs an href`;
|
||||
const err = gateError(what, entry);
|
||||
if (err) return err;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function messageOf(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export type { BootContext, HttpMethod, Plugin, PluginHooks, PluginManifest, Perm
|
||||
export type { PluginSettings, SettingDecl, SettingSummary, SettingType, SettingValue } from "./settings.ts";
|
||||
export type { StorageCredentials } from "./storage.ts";
|
||||
export type { RequestContext, User } from "../http/context.ts";
|
||||
export type { PageChrome } from "../ui/chrome.ts";
|
||||
export type { PageChrome, ProfileMenuEntry } from "../ui/chrome.ts";
|
||||
export type { NavNode } from "../ui/nav.ts";
|
||||
export { can, check, GuardError, requireSession } from "../auth/guards.ts";
|
||||
// The three coarse gates a route or nav node may declare — `Route` and `NavNode` both extend it.
|
||||
|
||||
@@ -131,19 +131,19 @@ test("findConflicts: duplicate nav id is an error, a shared permission name only
|
||||
|
||||
const noopHandler = () => {};
|
||||
|
||||
test("findConflicts: a route or nav node gating on a permission no plugin declares warns once per plugin and name, sorted", () => {
|
||||
test("findConflicts: a route, nav node or profile entry gating on a permission no plugin declares warns once per plugin and name, sorted", () => {
|
||||
const gated = findConflicts([
|
||||
p({ id: "a", nav: [{ children: [{ id: "a:x", label: "X", permission: "navonly:read" }], id: "a:root", label: "A", session: true }], routes: [
|
||||
{ handler: noopHandler, method: "GET", path: "/x", permission: "routeonly:read" },
|
||||
{ handler: noopHandler, method: "GET", path: "/y", permission: "both:read" },
|
||||
{ handler: noopHandler, method: "POST", path: "/y", permission: "both:read" },
|
||||
{ handler: noopHandler, method: "GET", path: "/z", permission: "declared:write" },
|
||||
], permissions: [] }),
|
||||
], permissions: [], profileMenu: [{ href: "/p", label: "P", permission: "profileonly:read" }] }),
|
||||
p({ id: "b", nav: [{ id: "b:y", label: "Y", permission: "both:read" }], permissions: [{ name: "declared:write" }] }),
|
||||
]);
|
||||
assert.ok(gated.every((c) => c.kind === "permission" && c.level === "warn"));
|
||||
assert.deepEqual(gated.map((c) => c.plugins), [["a"], ["a"], ["a"], ["b"]]);
|
||||
assert.deepEqual(gated.map((c) => /"([^"]+:[a-z]+)"/.exec(c.message)?.[1]), ["both:read", "navonly:read", "routeonly:read", "both:read"]);
|
||||
assert.deepEqual(gated.map((c) => c.plugins), [["a"], ["a"], ["a"], ["a"], ["b"]]);
|
||||
assert.deepEqual(gated.map((c) => /"([^"]+:[a-z]+)"/.exec(c.message)?.[1]), ["both:read", "navonly:read", "profileonly:read", "routeonly:read", "both:read"]);
|
||||
});
|
||||
|
||||
test("findConflicts: a gate on a permission another plugin declares, or on none, stays quiet", () => {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import type { Gate } from "../auth/gate.ts";
|
||||
import type { RequestContext } from "../http/context.ts";
|
||||
import type { ProfileMenuEntry } from "../ui/chrome.ts";
|
||||
import type { NavNode } from "../ui/nav.ts";
|
||||
import { envName, type SettingDecl, type SettingsOf } from "./settings.ts";
|
||||
import type { StorageCredentials } from "./storage.ts";
|
||||
@@ -89,6 +90,9 @@ export interface PluginManifest<S extends readonly SettingDecl[] = readonly Sett
|
||||
hooks?: PluginHooks<S>;
|
||||
nav?: NavNode[]; // fragment merged into the menu (composeNav); node `icon` is a Lucide sprite id (src/ui/icons.ts), node ids must be globally unique
|
||||
permissions?: PermissionDecl[];
|
||||
// Links added above Sign out in the profile dropdown, each gated like a nav node and shown only to a
|
||||
// signed-in visitor; `icon` is a Lucide sprite id, and a `label` naming a catalog key is translated.
|
||||
profileMenu?: ProfileMenuEntry[];
|
||||
routes?: Route[];
|
||||
// Operator-supplied configuration, one PLUGIN_SETTING_<ID>_<KEY> variable per key; the resolved
|
||||
// values arrive on onBoot's BootContext, typed from these declarations (settings.ts).
|
||||
@@ -241,6 +245,7 @@ function undeclaredGates(plugins: Plugin[]): PluginConflict[] {
|
||||
const gated = new Set<string>();
|
||||
for (const route of plugin.routes ?? []) if (route.permission != null) gated.add(route.permission);
|
||||
collectNavPermissions(plugin.nav, (name) => gated.add(name));
|
||||
for (const entry of plugin.profileMenu ?? []) if (entry.permission != null) gated.add(entry.permission);
|
||||
for (const name of [...gated].sort()) {
|
||||
if (!declared.has(name)) out.push({ kind: "permission", level: "warn", message: `plugin "${plugin.id}" gates on "${name}", which no installed plugin declares, so the admin screens can't grant it; declare it in that plugin's \`permissions\``, plugins: [plugin.id] });
|
||||
}
|
||||
|
||||
@@ -87,3 +87,29 @@ test("shellUser derives the profile from the real user; anonymous → Guest", ()
|
||||
assert.deepEqual(shellUser(null), { email: "", initials: "G", name: "Guest" });
|
||||
assert.deepEqual(shellUser({ email: "ada@example.com", id: "u1", permissions: [] }), { email: "ada@example.com", initials: "AD", name: "ada" });
|
||||
});
|
||||
|
||||
const account: Plugin = {
|
||||
apiVersion: "1.0.0", id: "account",
|
||||
profileMenu: [
|
||||
{ href: "/account/settings", icon: "i-user", label: "account.settings", session: true },
|
||||
{ href: "/account/billing", label: "account.billing", permission: "billing:read" },
|
||||
{ href: "/account/help", label: "Help", public: true },
|
||||
],
|
||||
};
|
||||
const entries = (opts: { permissions?: string[]; signedIn?: boolean }): { href: string; label: string }[] =>
|
||||
buildPluginChrome({
|
||||
localeHref: (href) => `${href}?locale=sv-SE`,
|
||||
menu: DEFAULT_MENU,
|
||||
plugins: [account],
|
||||
translatorFor: () => (key) => (key === "account.settings" ? "Inställningar" : key),
|
||||
...(opts.signedIn === false ? {} : { user: { email: "ada@x.io", id: "u1", permissions: opts.permissions ?? [] } }),
|
||||
}).user.menu!.map(({ href, label }) => ({ href, label }));
|
||||
|
||||
test("profile menu entries are gated like nav nodes, translated by their plugin and carry the locale", () => {
|
||||
assert.deepEqual(entries({}), [
|
||||
{ href: "/account/settings?locale=sv-SE", label: "Inställningar" },
|
||||
{ href: "/account/help?locale=sv-SE", label: "Help" },
|
||||
]);
|
||||
assert.deepEqual(entries({ permissions: ["billing:read"] }).map((e) => e.label), ["Inställningar", "account.billing", "Help"]);
|
||||
assert.deepEqual(entries({ signedIn: false }).map((e) => e.label), []); // an anonymous visitor has no profile menu
|
||||
});
|
||||
|
||||
+29
-1
@@ -2,6 +2,7 @@
|
||||
// ctx.chrome. `nav` is the global menu — Dashboard plus every plugin's fragment — run through
|
||||
// composeNav (override + per-user filter) and current-marked for the request path.
|
||||
|
||||
import { allows, type Gate } from "../auth/gate.ts";
|
||||
import type { User } from "../http/context.ts";
|
||||
import { ENGLISH } from "../i18n/english.ts";
|
||||
import type { Translate } from "../i18n/translate.ts";
|
||||
@@ -11,9 +12,21 @@ import type { Plugin } from "../plugin-host/plugin.ts";
|
||||
|
||||
const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "nav.dashboard", session: true };
|
||||
|
||||
// A link in the shell's profile dropdown, above Sign out.
|
||||
export interface ProfileLink {
|
||||
href: string;
|
||||
icon?: string; // a Lucide sprite id (src/ui/icons.ts)
|
||||
label: string;
|
||||
}
|
||||
|
||||
// What a plugin's `profileMenu` declares: a link plus the one gate that decides who sees it.
|
||||
export interface ProfileMenuEntry extends ProfileLink, Gate {}
|
||||
|
||||
// `menu` rides on the user, which every view already hands to the shell, so no view needs a new local.
|
||||
export interface ShellUser {
|
||||
email: string;
|
||||
initials: string;
|
||||
menu?: ProfileLink[]; // permission-filtered, translated and locale-carried; the host always sets it, a hand-built fixture may omit it
|
||||
name: string;
|
||||
}
|
||||
|
||||
@@ -66,10 +79,25 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
||||
nav: carryLocaleInto(nav, carryLocale),
|
||||
signInHref: carryLocale(returnTo),
|
||||
...(theme != null ? { theme } : {}),
|
||||
user: shellUser(opts.user, t),
|
||||
user: { ...shellUser(opts.user, t), menu: profileLinks(opts, t, carryLocale) },
|
||||
};
|
||||
}
|
||||
|
||||
// A plugin's labels are keys in *its* catalog, hence its own translator. Only a signed-in visitor
|
||||
// has a profile menu; the gate then decides per entry.
|
||||
function profileLinks(opts: ChromeOptions, t: Translate, carryLocale: (href: string) => string): ProfileLink[] {
|
||||
const user = opts.user ?? null;
|
||||
if (!user) return [];
|
||||
return (opts.plugins ?? []).flatMap((plugin) => {
|
||||
const pluginT = opts.translatorFor?.(plugin.id) ?? t;
|
||||
return (plugin.profileMenu ?? []).filter((entry) => allows(entry, user)).map((entry) => ({
|
||||
href: carryLocale(entry.href),
|
||||
...(entry.icon != null ? { icon: entry.icon } : {}),
|
||||
label: pluginT(entry.label),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
// The JWT holds only id/email/permissions, so the profile name is the email's local part.
|
||||
export function shellUser(user: User | null | undefined, t: Translate = ENGLISH): ShellUser {
|
||||
if (!user) {
|
||||
|
||||
@@ -131,3 +131,17 @@ test("the chrome carries the visitor's language: breadcrumb links go through loc
|
||||
assert.match(html, /<a href="\/admin\/users\?locale=sv-SE">Users<\/a>/);
|
||||
assert.match(html, /<span>Ada<\/span>/); // the current crumb has no href to carry
|
||||
});
|
||||
|
||||
test("profile menu entries render as links above Sign out, with their icon", async () => {
|
||||
const html = await render({
|
||||
csrfToken: "tok.sig",
|
||||
user: {
|
||||
email: "ada@acme.io", initials: "AD", name: "ada",
|
||||
menu: [{ href: "/account/settings", icon: "i-user", label: "My <settings>" }, { href: "/account/help", label: "Help" }],
|
||||
},
|
||||
});
|
||||
const panel = html.slice(html.indexOf('id="profile-menu"'), html.indexOf("</aside>"));
|
||||
assert.match(panel, /<a class="menu-item" href="\/account\/settings"><svg class="ico"><use href="#i-user" \/><\/svg>My <settings><\/a>/);
|
||||
assert.match(panel, /<a class="menu-item" href="\/account\/help">Help<\/a>/);
|
||||
assert.ok(panel.indexOf("/account/help") < panel.indexOf('class="menu-item-form"'), "Sign out stays last");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user