Let a plugin add links to the profile menu, and list the setting conflict kind #220
@@ -162,7 +162,7 @@ Revisit only if the stated reason stops holding.
|
||||
"permission" (the `Resource` `permits`: view/edit/delete) is the separate per-row tier.
|
||||
- **A permission name is always `<resource>:<action>`** — `scheduling:read`, `users:write`. A bare
|
||||
word names *who someone is* (a role), and roles are groups here. **Enforced at discovery**
|
||||
(`isValidPermissionName` in `plugin-host/plugin.ts`, checked by `shapeError` over every route/nav
|
||||
(`isValidPermissionName` in `plugin-host/plugin.ts`, checked by `shapeError` over every route, nav and profile-menu
|
||||
`permission` and every declared name), fail-loud like any other manifest rule — not only in the
|
||||
admin GUI, which an operator removes by not copying it in.
|
||||
- **Names are authored in plugin code; only grants live in Keto.** The host collects every installed
|
||||
@@ -288,8 +288,8 @@ Revisit only if the stated reason stops holding.
|
||||
declarative attribute's alone** — open a panel from script and no engine applies it, so
|
||||
"enhancing" one of these triggers is what would cost it its accessibility. `<details>` stays where
|
||||
it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the
|
||||
profile menu (its trigger composes escaped user values and its Sign-out item is a CSRF POST form;
|
||||
the plugin-declared links above it are `user.menu`) — keep the two in step.
|
||||
profile menu (its trigger composes escaped user values and its Sign-out item is a CSRF POST form) — keep
|
||||
the two in step.
|
||||
- **One scroller, the document** (priority 1). `.app` is `min-height: 100dvh`. `.nav`'s
|
||||
`overflow-y: auto` and `.side-footer`'s `flex: 0 0 auto` are not leftovers of a bounded frame:
|
||||
they are what makes the off-canvas panel usable with a long tree. `#nav-toggle` is `position: fixed` —
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ the links. A list that renamed its params in `parseListQuery` passes the same na
|
||||
|
||||
Declare `profileMenu: [{ href, label, icon?, …gate }]` in the manifest. Each entry names exactly one
|
||||
gate like a nav node, and shows above Sign out to a signed-in visitor who passes it. Discovery
|
||||
refuses an entry with no `href` or no gate.
|
||||
refuses an entry without an `href`, a `label`, or exactly one gate.
|
||||
|
||||
### A plugin can version its own `/public/` files
|
||||
|
||||
|
||||
@@ -468,7 +468,7 @@ folder-derived `id` to produce the loaded `Plugin`.
|
||||
| `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
|
||||
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). Every node names [exactly one gate](#public-pages--menu-items). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. A `label` that names a catalog key is [translated](#languages-i18n); anything else renders as written. |
|
||||
| `permissions` | no | Permissions this plugin gates on. See [Nav & permission gates](#nav--permission-gates). |
|
||||
| `profileMenu` | no | `ProfileMenuEntry[]`: `{ href, label, icon?, …gate }` links added above Sign out in the profile dropdown, shown only to a signed-in visitor. Each names [exactly one gate](#public-pages--menu-items); `icon` and `label` follow the same rules as a `nav` node. The host hands the visitor's entries to the shell as `chrome.user.menu`. |
|
||||
| `profileMenu` | no | `ProfileMenuEntry[]`: `{ href, label, icon?, …gate }` links added above Sign out in the profile dropdown, shown only to a signed-in visitor. Each names [exactly one gate](#public-pages--menu-items); `icon` and `label` follow the same rules as a `nav` node. |
|
||||
| `routes` | no | See [Routes & handlers](#routes--handlers). |
|
||||
| `hooks` | no | See [Hooks](#hooks). |
|
||||
| `settings` | no | Configuration this plugin accepts, one `PLUGIN_SETTING_<ID>_<KEY>` variable per key, resolved and validated at boot and handed to `onBoot`. See [Plugin settings](#plugin-settings). |
|
||||
@@ -738,7 +738,7 @@ The host detects collisions across all discovered plugins with `findConflicts` a
|
||||
| `permission` | warn | A permission name is declared by more than one plugin. Sharing is legitimate; pick a more specific [`<resource>`](#naming-a-permission) if unintended. Also warned: a route, nav node or profile-menu entry gates on a permission no installed plugin declares, so the admin screens can't grant it; declare it in that plugin's `permissions`. |
|
||||
|
||||
Mount-path uniqueness needs no rule of its own — it follows from the id check. Discovery also
|
||||
rejects **per-manifest shape errors**: a non-array `nav`/`profileMenu`/`routes`/`permissions`, a non-function
|
||||
rejects **per-manifest shape errors**: a non-array `nav`/`permissions`/`profileMenu`/`routes`, a non-function
|
||||
`home`/`dashboard`, a permission name that isn't [`<resource>:<action>`](#naming-a-permission), or a
|
||||
route, nav node or profile-menu entry that does not name [exactly one gate](#public-pages--menu-items).
|
||||
|
||||
|
||||
@@ -250,7 +250,6 @@ test.describe.serial("authenticated admin journey", () => {
|
||||
test("logout: signing out ends the session and returns to the login page", async () => {
|
||||
await page.goto("/dashboard");
|
||||
await page.locator("button.profile").click(); // open the profile dropdown
|
||||
// The scheduling plugin's profile entry sits above Sign out, which stays last.
|
||||
await expect(page.locator("#profile-menu .menu-item")).toHaveText(["My shifts", "Sign out"]);
|
||||
await expect(page.locator('#profile-menu a.menu-item[href="/scheduling/mine"]')).toHaveCount(1);
|
||||
await page.locator('form[action="/logout"] button[type="submit"]').click();
|
||||
|
||||
@@ -90,8 +90,7 @@ 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.
|
||||
// Links above Sign out in the profile dropdown, each gated like a nav node; only a signed-in visitor sees any.
|
||||
profileMenu?: ProfileMenuEntry[];
|
||||
routes?: Route[];
|
||||
// Operator-supplied configuration, one PLUGIN_SETTING_<ID>_<KEY> variable per key; the resolved
|
||||
|
||||
+1
-3
@@ -26,7 +26,7 @@ export interface ProfileMenuEntry extends ProfileLink, Gate {}
|
||||
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
|
||||
menu?: ProfileLink[]; // gate-filtered, translated, locale-carried. The host always sets it; a hand-built fixture may omit it
|
||||
name: string;
|
||||
}
|
||||
|
||||
@@ -83,8 +83,6 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
||||
};
|
||||
}
|
||||
|
||||
// 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 [];
|
||||
|
||||
Reference in New Issue
Block a user